import type { ClientModel, EffectiveSessionControl } from "@opengeni/sdk"; import { AppWindowIcon, ArrowUpIcon, ChevronDownIcon, FileIcon, ImageIcon, LoaderCircleIcon, PaperclipIcon, PauseIcon, PlayIcon, RotateCwIcon, SquareIcon, XIcon, } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { cloneElement, createContext, forwardRef, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ButtonHTMLAttributes, type ChangeEvent, type ClipboardEvent, type ComponentPropsWithoutRef, type DragEvent, type KeyboardEvent, type ReactElement, type ReactNode, type Ref, type RefObject, } from "react"; import { argHint, defaultCommands } from "../commands/registry"; import { useErrorMessage } from "../lib/error-message"; import type { Notice, SlashCommand } from "../commands/types"; import type { ComposerState } from "../hooks/use-composer"; import { shouldSteerOnKey, shouldSubmitOnKey } from "../hooks/use-composer"; import type { UseFileAttachmentsResult } from "../hooks/use-file-attachments"; import { useSlashCommands, type ConfirmState, type SlashCommandContext, } from "../hooks/use-slash-commands"; import { cn } from "../lib/cn"; import { ComposerFooterSignalProvider, useComposerFooterSignalState, } from "../lib/composer-footer-signals"; import { ComposerResponsiveContext, type ResponsiveBasis, } from "../lib/composer-responsive-context"; import { composerSubmissionErrorMessage } from "../lib/format"; import { defaultChatComposerMessages, type ChatComposerMessages } from "../composer-messages"; import { singleSendText } from "../messages/single-send"; import type { PickerModelRow } from "../model-policy"; import { useLightboxOptional } from "../timeline/screenshot-lightbox"; import { OPEN_WORKSTREAM_CONTROL_EVENT } from "../workstream-control-event"; import { CommandPalette as CommandPaletteView } from "./command-palette"; import { ModelPicker as ModelPickerView } from "./model-picker"; import { TooltipProvider } from "./tooltip"; import { useOpenGeniText } from "../messages/runtime"; export { OPEN_WORKSTREAM_CONTROL_EVENT }; export type { ResponsiveBasis } from "../lib/composer-responsive-context"; function ComposerTip({ tip, children, }: { tip: string; children: ReactElement<{ title?: string }>; }) { if (!tip) return children; return cloneElement(children, { title: tip }); } export type ComposerDelivery = Pick< ComposerState, | "value" | "setValue" | "send" | "sendToQueue" | "busySend" | "singleSend" | "alternateSend" | "sendAlternate" | "steer" | "sending" | "canSend" | "error" | "clearError" | "annotations" | "requestAnnotationReview" >; export type ComposerDraftState = Pick< ComposerState, | "draftConflict" | "draftSaving" | "resolveDraftConflict" | "restoredResources" | "removeRestoredResource" >; export type ComposerControlState = Pick< ComposerState, "pause" | "pausing" | "resume" | "resumeScope" | "resuming" >; export type ComposerControlLinks = { workspaceHref?: string | undefined; sessionHref?: ((sessionId: string) => string) | undefined; }; export { defaultChatComposerMessages } from "../composer-messages"; export type { ChatComposerMessages } from "../composer-messages"; export type ComposerSubmitMode = "queue" | "steer"; export type ComposerSubmitBlocker = | "disabled" | "attachment" | "sending" | "command" | "annotations" | "empty" | null; /** * How the composer offers run control. * - `pause`: the workstream Pause control and paused-state strip (default; the * Opengeni console). * - `stop`: a Stop control only while a response runs. Stopping pauses this * conversation; the next message continues it, so people never see a paused * state they have to resume. Pauses applied elsewhere still show. * - `none`: no run control. */ export type ComposerRunControl = "pause" | "stop" | "none"; export type UseChatComposerControllerOptions = { delivery: ComposerDelivery; draft?: ComposerDraftState | undefined; control?: ComposerControlState | undefined; effectiveControl?: EffectiveSessionControl | null | undefined; queuedAheadCount?: number | undefined; canControlWorkspace?: boolean | undefined; controlLinks?: ComposerControlLinks | undefined; disabled?: boolean | undefined; attachments?: UseFileAttachmentsResult | undefined; commands?: readonly SlashCommand[] | undefined; commandContext?: SlashCommandContext | undefined; onClearView?: (() => void) | undefined; onPaste?: ((event: ClipboardEvent) => void) | undefined; messages?: Partial | undefined; /** Run control presentation. Defaults to `pause`. */ runControl?: ComposerRunControl | undefined; /** A response is in progress; shows the Stop control under `runControl="stop"`. */ running?: boolean | undefined; }; /** * Off-document mirror for shrink/steady measure. Measuring with * `height: auto` on the live `rows={1}` textarea collapses it for a layout * frame, expands the flex timeline sibling, and yanks tip-follow — the * multi-line typing flicker that stops only once the box is capped. */ let composerHeightMirror: HTMLTextAreaElement | null = null; function measureComposerContentHeight(textarea: HTMLTextAreaElement): number { if (typeof document === "undefined") { return textarea.scrollHeight; } const width = textarea.clientWidth; if (width <= 0) { return textarea.offsetHeight; } let mirror = composerHeightMirror; if (!mirror) { mirror = document.createElement("textarea"); mirror.setAttribute("aria-hidden", "true"); mirror.tabIndex = -1; mirror.rows = 1; mirror.style.cssText = "position:fixed;top:0;left:-100000px;visibility:hidden;pointer-events:none;height:auto;min-height:0;max-height:none;overflow:hidden;z-index:-1;"; composerHeightMirror = mirror; } const style = getComputedStyle(textarea); mirror.style.width = `${width}px`; mirror.style.boxSizing = style.boxSizing; mirror.style.font = style.font; mirror.style.fontSize = style.fontSize; mirror.style.fontFamily = style.fontFamily; mirror.style.fontWeight = style.fontWeight; mirror.style.fontStyle = style.fontStyle; mirror.style.letterSpacing = style.letterSpacing; mirror.style.lineHeight = style.lineHeight; mirror.style.textTransform = style.textTransform; mirror.style.paddingTop = style.paddingTop; mirror.style.paddingRight = style.paddingRight; mirror.style.paddingBottom = style.paddingBottom; mirror.style.paddingLeft = style.paddingLeft; mirror.style.borderTopWidth = style.borderTopWidth; mirror.style.borderRightWidth = style.borderRightWidth; mirror.style.borderBottomWidth = style.borderBottomWidth; mirror.style.borderLeftWidth = style.borderLeftWidth; mirror.style.borderStyle = style.borderStyle; mirror.style.whiteSpace = style.whiteSpace; mirror.style.wordBreak = style.wordBreak; mirror.style.overflowWrap = style.overflowWrap; mirror.value = textarea.value; if (!mirror.isConnected) { document.documentElement.appendChild(mirror); } return mirror.scrollHeight; } /** * Autosize a composer textarea up to `maxPx`. Never writes intermediate * `height: auto` / `0` on the live element — only the final pixel height. * * `measure` is injectable for unit tests; production always uses the off-DOM * mirror so shrink/steady never collapses the laid-out box. */ export function applyComposerTextareaHeight( textarea: HTMLTextAreaElement, maxPx: number = 220, measure: (el: HTMLTextAreaElement) => number = measureComposerContentHeight, ): void { const before = textarea.offsetHeight; // Overflow: content taller than the box — scrollHeight is the needed size. // Fit/shrink: content fits (or box is oversized) — measure off-DOM. const nextPx = Math.min( textarea.scrollHeight > textarea.clientHeight + 1 ? textarea.scrollHeight : measure(textarea), maxPx, ); if (Math.abs(nextPx - before) < 1) { return; } textarea.style.height = `${nextPx}px`; } /** * Headless interaction layer for a chat composer. It is the single owner of * delivery guards, keyboard routing, command interception, drag/drop, focus, * confirmation, and feedback state used by both the preset and primitives. */ export function useChatComposerController({ delivery, draft, control, effectiveControl, queuedAheadCount = 0, canControlWorkspace = false, controlLinks, disabled = false, attachments, commands: commandsProp, commandContext, onClearView, onPaste, messages: messageOverrides, runControl = "pause", running = false, }: UseChatComposerControllerOptions) { const t = useOpenGeniText(); const commands = useMemo(() => commandsProp ?? defaultCommands(t), [commandsProp, t]); const formatError = useErrorMessage(); const singleSend = delivery.singleSend === true; const messages = useMemo( () => ({ ...defaultChatComposerMessages(singleSend ? singleSendText(t) : t), ...messageOverrides, }), [messageOverrides, singleSend, t], ); const id = useId(); const rootRef = useRef(null); const textareaRef = useRef(null); const pauseButtonRef = useRef(null); const fileInputRef = useRef(null); const listboxId = useId(); const controlPaused = effectiveControl?.state === "paused"; // Set when this composer's Stop paused the conversation; cleared once it runs // again. Pauses from anyone or anywhere else are never reinterpreted. const [stoppedHere, setStoppedHere] = useState(false); useEffect(() => { if (!controlPaused) setStoppedHere(false); }, [controlPaused]); // Under Stop, the stopped response is not a state to manage: hide it and let // the next message continue (a Steer resumes a paused branch). It applies // only to this conversation's own pause with nothing queued behind it. const resumesOnSend = runControl === "stop" && stoppedHere && controlPaused && effectiveControl?.directState === "paused" && effectiveControl.additionalBlockerCount === 0 && queuedAheadCount === 0; const paused = controlPaused && !resumesOnSend; const [controlDetailsOpen, setControlDetailsOpen] = useState(false); const [paletteMounted, setPaletteMounted] = useState(false); const [submitting, setSubmitting] = useState(false); // Which control's submission is in flight, so only that control shows it. const [submittingMode, setSubmittingMode] = useState(null); const submittingRef = useRef(false); const controlOperationRef = useRef(false); const deliveryValue = delivery.value; const setDeliveryValue = delivery.setValue; const mountedRef = useRef(true); const deliveredValueRef = useRef(deliveryValue); const liveValueRef = useRef(deliveryValue); const [renderedValue, setRenderedValue] = useState(deliveryValue); const deliveryChanged = deliveryValue !== deliveredValueRef.current; if (deliveryChanged) { deliveredValueRef.current = deliveryValue; liveValueRef.current = deliveryValue; } const setComposerValue = useCallback( (next: string) => { liveValueRef.current = next; setRenderedValue(next); setDeliveryValue(next); }, [setDeliveryValue], ); const visibleValue = deliveryChanged ? deliveryValue : renderedValue; useLayoutEffect(() => { if (renderedValue !== liveValueRef.current) { setRenderedValue(liveValueRef.current); } }, [deliveryValue, renderedValue]); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); useEffect(() => { const openControl = (event: Event) => { const requestedId = event instanceof CustomEvent && isRecord(event.detail) && typeof event.detail.composerId === "string" ? event.detail.composerId : null; if (requestedId && requestedId !== id) return; if (!requestedId) { const roots = document.querySelectorAll("[data-og-composer-id]"); if (roots.length > 1 && !rootRef.current?.contains(document.activeElement)) return; } textareaRef.current?.scrollIntoView({ block: "end", behavior: "smooth" }); if (paused) { setControlDetailsOpen(true); return; } window.requestAnimationFrame(() => pauseButtonRef.current?.focus()); }; document.addEventListener(OPEN_WORKSTREAM_CONTROL_EVENT, openControl); return () => document.removeEventListener(OPEN_WORKSTREAM_CONTROL_EVENT, openControl); }, [id, paused]); const blockedByAttachment = attachments?.hasUnresolved === true; const [dragging, setDragging] = useState(false); const dragCarriesFiles = (event: { dataTransfer: DataTransfer | null }): boolean => event.dataTransfer !== null && [...event.dataTransfer.types].includes("Files"); const handleDragOver = useCallback( (event: DragEvent) => { if (!attachments || !dragCarriesFiles(event)) return; event.preventDefault(); if (!disabled) setDragging(true); }, [attachments, disabled], ); const handleDragLeave = useCallback( (event: DragEvent) => { if (!attachments) return; if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; setDragging(false); }, [attachments], ); const handleDrop = useCallback( (event: DragEvent) => { if (!attachments || !dragCarriesFiles(event)) return; event.preventDefault(); setDragging(false); if (!disabled && event.dataTransfer.files.length > 0) attachments.addFiles(event.dataTransfer.files); }, [attachments, disabled], ); useEffect(() => { if (disabled) setDragging(false); }, [disabled]); const [notice, setNotice] = useState(null); const [helpOpen, setHelpOpen] = useState(false); const [confirmState, setConfirmState] = useState(null); const pendingConfirm = useRef<((confirmed: boolean) => void) | null>(null); const settleConfirmation = useCallback((confirmed: boolean) => { const resolve = pendingConfirm.current; pendingConfirm.current = null; setConfirmState(null); resolve?.(confirmed); }, []); useEffect( () => () => { pendingConfirm.current?.(false); pendingConfirm.current = null; }, [], ); // Autosize without collapsing the composer to 0 on every keystroke. The old // height→0→content pattern briefly expanded the timeline scroller and yanked // tip-follow while streams were live (pin/unpin flicker when typing fast). const resizeInputRafRef = useRef(null); const resizeInput = useCallback(() => { if (resizeInputRafRef.current !== null) { return; } resizeInputRafRef.current = requestAnimationFrame(() => { resizeInputRafRef.current = null; const textarea = textareaRef.current; if (!textarea) { return; } applyComposerTextareaHeight(textarea, 220); }); }, []); useEffect(() => resizeInput(), [resizeInput, visibleValue]); useEffect( () => () => { if (resizeInputRafRef.current !== null) { cancelAnimationFrame(resizeInputRafRef.current); resizeInputRafRef.current = null; } }, [], ); const handlers = useMemo( () => ({ notice: (next: Notice) => { setNotice(next); delivery.clearError(); }, openHelp: () => setHelpOpen(true), clearView: () => { if (!onClearView) return false; onClearView(); return true; }, confirm: (command: SlashCommand) => new Promise((resolve) => { pendingConfirm.current?.(false); pendingConfirm.current = resolve; setConfirmState({ command, resolve: settleConfirmation }); }), }), [delivery, onClearView, settleConfirmation], ); const palette = useSlashCommands({ commands, context: commandContext, handlers, value: visibleValue, setValue: setComposerValue, }); const paletteEnabled = commandContext !== undefined; const commandDraftBlocked = paletteEnabled && palette.isCommandDraft; const annotationsIncomplete = (delivery.annotations ?? []).some( (annotation) => annotation.note.trim().length === 0, ); const submitBlocker: ComposerSubmitBlocker = disabled ? "disabled" : blockedByAttachment ? "attachment" : delivery.sending || submitting ? "sending" : commandDraftBlocked ? "command" : annotationsIncomplete ? "annotations" : delivery.canSend ? null : "empty"; const canSubmit = submitBlocker === null; const submit = useCallback( async (mode: ComposerSubmitMode): Promise => { if (commandDraftBlocked) { setNotice({ tone: "error", message: messages.slashCommandBlocked }); delivery.clearError(); return false; } if (disabled || blockedByAttachment || delivery.sending || submittingRef.current) return false; if (annotationsIncomplete) { setNotice({ tone: "error", message: messages.annotationNotesRequired }); delivery.clearError(); delivery.requestAnnotationReview?.(); return false; } if (!delivery.canSend) return false; submittingRef.current = true; setSubmitting(true); setSubmittingMode(mode); try { if (resumesOnSend) return await delivery.steer(); // With one Send, Cmd/Ctrl+Enter is just Send. if (mode !== "steer" || singleSend) return await delivery.send(); // Cmd/Ctrl+Enter and the button beside Send send the other way: // Steer when Send queues, queue when Send steers. return delivery.sendAlternate ? await delivery.sendAlternate() : await delivery.steer(); } finally { submittingRef.current = false; if (mountedRef.current) { setSubmitting(false); setSubmittingMode(null); } } }, [ annotationsIncomplete, blockedByAttachment, commandDraftBlocked, delivery, disabled, messages.annotationNotesRequired, messages.slashCommandBlocked, resumesOnSend, singleSend, ], ); const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (paletteEnabled && paletteMounted && palette.onKeyDown(event)) return; if (!shouldSubmitOnKey(event)) return; event.preventDefault(); void submit(shouldSteerOnKey(event) ? "steer" : "queue"); }, [palette, paletteEnabled, paletteMounted, submit], ); const handlePaste = useCallback( (event: ClipboardEvent) => { if (disabled) return; onPaste?.(event); attachments?.addFromPaste(event); }, [attachments, disabled, onPaste], ); const handleFileChange = useCallback( (event: ChangeEvent) => { if (!disabled && event.target.files) attachments?.addFiles(event.target.files); event.target.value = ""; }, [attachments, disabled], ); const helpCommands = useMemo( () => commands.filter((command) => { if (command.permission && commandContext) { const permissions = commandContext.permissions; return ( permissions.includes(command.permission) || permissions.includes("workspace:admin") ); } return true; }), [commandContext, commands], ); const activeNotice = notice ?? (delivery.error ? { tone: "error" as const, message: /control changed|paused while/i.test(delivery.error.message) ? messages.controlChangedError : formatError( delivery.error, composerSubmissionErrorMessage(t, delivery.error) || messages.sendFailedError, ), } : null); useEffect(() => { if (notice?.tone !== "ok") return; const timer = window.setTimeout( () => setNotice((current) => (current === notice ? null : current)), 2400, ); return () => window.clearTimeout(timer); }, [notice]); useEffect(() => { if (annotationsIncomplete) return; setNotice((current) => current?.message === messages.annotationNotesRequired ? null : current, ); }, [annotationsIncomplete, messages.annotationNotesRequired]); const runControlOperation = useCallback(async (operation: () => Promise) => { if (controlOperationRef.current) return false; controlOperationRef.current = true; try { await operation(); return true; } finally { controlOperationRef.current = false; } }, []); const pause = useCallback( async (reason?: string): Promise => { if (!control || control.pausing || control.resuming) return false; return await runControlOperation(() => control.pause(reason)); }, [control, runControlOperation], ); const resume = useCallback( async (reason?: string): Promise => { if (!control || control.pausing || control.resuming) return false; return await runControlOperation(() => control.resume(reason)); }, [control, runControlOperation], ); const resumeScope = useCallback( async (option: EffectiveSessionControl["resumeOptions"][number]): Promise => { if (!control || control.pausing || control.resuming) return false; return await runControlOperation(() => control.resumeScope(option)); }, [control, runControlOperation], ); const stop = useCallback(async (): Promise => { const stopped = await pause(); if (stopped && mountedRef.current) setStoppedHere(true); return stopped; }, [pause]); return { id, rootRef, textareaRef, pauseButtonRef, fileInputRef, listboxId, effectiveControl, queuedAheadCount, canControlWorkspace, controlLinks, disabled, attachments, messages, sending: delivery.sending || submitting, error: delivery.error, clearError: delivery.clearError, hasDraftState: draft !== undefined, draftConflict: draft?.draftConflict ?? null, draftSaving: draft?.draftSaving ?? false, restoredResources: draft?.restoredResources ?? [], removeRestoredResource: draft?.removeRestoredResource, resolveDraftConflict: draft?.resolveDraftConflict, hasControl: control !== undefined, runControl, running, pausing: control?.pausing ?? false, resuming: control?.resuming ?? false, pause, stop, resume, resumeScope, paused, controlDetailsOpen, setControlDetailsOpen, paletteMounted, setPaletteMounted, dragging, handleDragOver, handleDragLeave, handleDrop, palette, paletteEnabled, commandDraftBlocked, confirmState, settleConfirmation, helpOpen, setHelpOpen, helpCommands, activeNotice, canSubmit, submitBlocker, submit, busySend: delivery.busySend ?? "queue", singleSend, alternateSend: delivery.sendAlternate ? (delivery.alternateSend ?? null) : null, submittingMode, handleKeyDown, handlePaste, handleFileChange, focusInput: () => textareaRef.current?.focus(), setValue: setComposerValue, value: visibleValue, }; } export type ChatComposerController = ReturnType; const ComposerContext = createContext(null); function useComposerController(): ChatComposerController { const controller = useContext(ComposerContext); if (!controller) throw new Error("useChatComposer must be used inside "); return controller; } export type ChatComposerContextValue = Pick< ChatComposerController, | "id" | "value" | "setValue" | "focusInput" | "submit" | "canSubmit" | "submitBlocker" | "disabled" | "sending" | "error" | "clearError" | "attachments" | "messages" | "effectiveControl" | "paused" | "hasControl" | "pausing" | "resuming" | "pause" | "resume" | "resumeScope" >; /** Read the nearest compound composer's safe accessory-facing state and actions. */ export function useChatComposer(): ChatComposerContextValue { return useComposerController(); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } export type ComposerRootProps = Omit, "children"> & { controller: ChatComposerController; children: ReactNode; /** * Measurement surface for responsive composer chrome. `viewport` preserves * the existing breakpoint behavior; `container` responds to this root's * inline size and also bounds composer-owned portalled menus to it. */ responsiveBasis?: ResponsiveBasis | undefined; }; export const Root = forwardRef(function ComposerRoot( { controller, children, className, style, responsiveBasis = "viewport", ...props }, forwardedRef, ) { const responsiveRootRef = useRef(null); const responsiveContext = useMemo( () => ({ responsiveBasis, rootRef: responsiveRootRef }), [responsiveBasis], ); // Provider stays on Root so optional Radix tip surfaces (voice input) work. // Ordinary composer actions use native `title` via ComposerTip to avoid // pulling Popper into every tip hotspot. return (
{children}
); }); export type ComposerFrameProps = ComponentPropsWithoutRef<"div">; export const Frame = forwardRef(function ComposerFrame( { className, ...props }, ref, ) { return
; }); export type ComposerCommandPaletteProps = { className?: string | undefined }; export function CommandPalette({ className }: ComposerCommandPaletteProps) { const controller = useComposerController(); const setPaletteMounted = controller.setPaletteMounted; useEffect(() => { setPaletteMounted(true); return () => setPaletteMounted(false); }, [setPaletteMounted]); if (!controller.paletteEnabled) return null; return (
{ controller.palette.setHighlight(index); void controller.palette.runAt(index); }} argHintText={controller.palette.activeArgHint} listboxId={controller.listboxId} label={controller.messages.slashCommandsLabel} dangerLabel={controller.messages.danger} />
); } type OwnedSurfaceProps = "onDragOver" | "onDragLeave" | "onDrop"; export type ComposerSurfaceProps = Omit, OwnedSurfaceProps>; export const Surface = forwardRef(function ComposerSurface( { className, children, ...props }, ref, ) { const controller = useComposerController(); return (
{controller.dragging ? (
{controller.messages.dropFiles}
) : null} {children}
); }); export function PausedState() { const controller = useComposerController(); if (!controller.paused || !controller.effectiveControl || !controller.hasControl) return null; return ( void controller.resume()} onResumeOption={(option) => void controller.resumeScope(option)} /> ); } export function RestoredResources() { const controller = useComposerController(); if (controller.restoredResources.length === 0 || !controller.removeRestoredResource) return null; return ( ); } export function Attachments() { const controller = useComposerController(); if (!controller.attachments || controller.attachments.attachments.length === 0) return null; return ( ); } type OwnedInputProps = | "value" | "defaultValue" | "onChange" | "onKeyDown" | "onPaste" | "disabled" | "aria-autocomplete" | "aria-controls" | "aria-activedescendant" | "aria-keyshortcuts"; export type ComposerInputProps = Omit, OwnedInputProps>; const EDITING_FOCUS_SELECTOR = 'input, textarea, select, [contenteditable="true"], [role="textbox"]'; const POPUP_FOCUS_SELECTOR = '[role="menu"], [role="menubar"], [role="listbox"], [role="dialog"], [role="alertdialog"]'; /** * Initial focus is a convenience, not authority to move focus the person has * placed elsewhere. It yields to a field they started editing and to an open * menu, listbox, or dialog that does not contain the composer: moving focus * behind a non-modal popup dismisses it, and a modal surface owns focus until * it closes. */ function focusBelongsElsewhere(textarea: HTMLTextAreaElement): boolean { const active = textarea.ownerDocument.activeElement; if (!active || active === textarea) return false; if (active.closest(EDITING_FOCUS_SELECTOR)) return true; const popup = active.closest(POPUP_FOCUS_SELECTOR); if (popup) return !popup.contains(textarea); // A dismissed popup can restore a button or link while draft hydration is // still pending. Its focus remains intentional after the popup disappears. return ( active !== textarea.ownerDocument.body && active !== textarea.ownerDocument.documentElement ); } export const Input = forwardRef(function ComposerInput( { rows = 1, placeholder, className, "aria-label": ariaLabel, autoFocus = false, ...props }, forwardedRef, ) { const controller = useComposerController(); const autoFocusedRef = useRef(false); const paletteOpen = controller.paletteEnabled && controller.paletteMounted && controller.palette.open; // Focus once the controller is interactive. The textarea can mount disabled // (create-session draft hydrate), and the route can finish loading after the // person has already moved on, so this deliberately does not use the native // autoFocus attribute: that would take focus at mount without the guard. useEffect(() => { if (!autoFocus || controller.disabled || autoFocusedRef.current) return; const frame = window.requestAnimationFrame(() => { const textarea = controller.textareaRef.current; if (!textarea || textarea.disabled) return; autoFocusedRef.current = true; if (focusBelongsElsewhere(textarea)) return; textarea.focus(); }); return () => window.cancelAnimationFrame(frame); }, [autoFocus, controller.disabled, controller.textareaRef]); // Text rewraps when the box gets narrower or wider (panel resize, viewport // change), so refit the height on width changes, not only on edits. useEffect(() => { const textarea = controller.textareaRef.current; if (!textarea || typeof ResizeObserver === "undefined") return; let width = textarea.clientWidth; let frame: number | null = null; const observer = new ResizeObserver(() => { if (textarea.clientWidth === width) return; width = textarea.clientWidth; if (frame !== null) return; frame = window.requestAnimationFrame(() => { frame = null; applyComposerTextareaHeight(textarea, 220); }); }); observer.observe(textarea); return () => { observer.disconnect(); if (frame !== null) window.cancelAnimationFrame(frame); }; }, [controller.textareaRef]); return (