{"version":3,"file":"AIChatComposer.cjs","names":[],"sources":["../../../src/components/AIChat/AIChatComposer.tsx"],"sourcesContent":["/**\n * @tempest-limits props-count, function-lines — a textarea that grows, submits on\n * Enter, offers a stop button while a turn is generating and clears only when onSend\n * resolves. The eight props are that contract (onSend, onStop, generating, onError)\n * plus its slots (actions, footer) and shape (maxRows, locale); the body is the\n * autosize measurement and the key handling, which both need the same ref and cannot\n * be lifted out of it.\n */\nimport {\n    forwardRef,\n    useImperativeHandle,\n    useLayoutEffect,\n    useRef,\n    useState,\n    type FormEvent,\n    type KeyboardEvent,\n    type ReactNode,\n    type TextareaHTMLAttributes,\n} from \"react\";\n\nimport { cn } from \"@/utils/cn\";\n\nimport { aiChatStrings } from \"./ai-chat-turns\";\nimport styles from \"./AIChat.module.css\";\n\n/** DOM attributes the composer redefines. */\ntype OverriddenDomProps = \"onSubmit\" | \"value\" | \"defaultValue\" | \"rows\";\n\nexport interface AIChatComposerProps extends Omit<\n    TextareaHTMLAttributes<HTMLTextAreaElement>,\n    OverriddenDomProps\n> {\n    /** Called with the trimmed prompt. The field clears only when this does not throw. */\n    onSend: (text: string) => void | Promise<void>;\n    /**\n     * Abort the turn in flight.\n     *\n     * When given together with `generating`, the send button becomes a stop button\n     * and `Escape` aborts too.\n     */\n    onStop?: () => void;\n    /** A turn is being generated. Replaces send with stop and refuses to send. */\n    generating?: boolean;\n    /** Locale for the placeholder and the button labels. Default `\"pt-BR\"`. */\n    locale?: \"pt-BR\" | \"en\";\n    /** Left of the send button — an attach control, a model picker, a tool toggle. */\n    actions?: ReactNode;\n    /** Under the field — a token count, the model name, a disclaimer. */\n    footer?: ReactNode;\n    /** Largest height the field grows to, in lines. Default 8. */\n    maxRows?: number;\n    /**\n     * Called when `onSend` rejects. The draft is kept either way.\n     *\n     * Without it the rejection is swallowed after the draft is preserved: re-throwing\n     * out of a DOM event handler surfaces as an unhandled promise rejection, which is\n     * console noise for the developer and nothing the user can act on. The visible\n     * signal is the prompt still sitting in the field; wire this to a toast to say why.\n     */\n    onError?: (error: unknown) => void;\n}\n\n/** Imperative handle, so a thread can focus, read or refill the field. */\nexport interface AIChatComposerHandle {\n    focus: () => void;\n    /** Replace the draft — used to put a prompt back in the field. */\n    setValue: (text: string) => void;\n    /**\n     * The current draft.\n     *\n     * The counterpart `setValue` needs to be usable for anything **additive**. The\n     * field is uncontrolled, so without this the only way to append to a draft — a\n     * dictated phrase, a picked slash-command, a pasted citation — is to shadow the\n     * whole value in app state through `onChange` and hope the two never drift.\n     */\n    getValue: () => string;\n}\n\n/**\n * The prompt field of a conversation with a model: a textarea that grows with its\n * content, sends on `Enter`, keeps `Shift+Enter` for a newline, and turns into a\n * stop button while a turn is streaming.\n *\n * Uncontrolled on purpose. A draft changes on every keystroke, and lifting that into\n * app state re-renders the whole transcript per character — with a streaming answer\n * above, that is the one place where \"controlled by default\" costs something\n * visible. Apps that need the draft (a persisted composer, a slash-command menu)\n * read it from `onChange` or drive it through the ref.\n *\n * @example\n * <AIChatComposer\n *     generating={generating}\n *     onSend={(text) => ask(text)}\n *     onStop={() => controller.abort()}\n *     footer={<small>Claude Opus 5 · pode errar</small>}\n * />\n */\nexport const AIChatComposer = forwardRef<AIChatComposerHandle, AIChatComposerProps>(\n    function AIChatComposer(\n        {\n            onSend,\n            onStop,\n            generating = false,\n            locale = \"pt-BR\",\n            actions,\n            footer,\n            maxRows = 8,\n            onError,\n            className,\n            disabled,\n            placeholder,\n            onKeyDown,\n            onChange,\n            ...rest\n        },\n        ref,\n    ) {\n        const strings = aiChatStrings(locale);\n        const textarea = useRef<HTMLTextAreaElement | null>(null);\n        const [value, setValue] = useState(\"\");\n        const [busy, setBusy] = useState(false);\n\n        useImperativeHandle(ref, () => ({\n            focus: () => textarea.current?.focus(),\n            setValue: (text: string) => {\n                setValue(text);\n                textarea.current?.focus();\n            },\n            getValue: () => value,\n        }));\n\n        /**\n         * Grow the field to fit its content, up to `maxRows`.\n         *\n         * Measured from `scrollHeight` after resetting the height, because\n         * `scrollHeight` on an element that is already tall enough reports the current\n         * height and the field would never shrink back.\n         */\n        useLayoutEffect(() => {\n            const node = textarea.current;\n            if (!node) return;\n            node.style.height = \"auto\";\n            const lineHeight = Number.parseFloat(getComputedStyle(node).lineHeight) || 20;\n            const max = lineHeight * maxRows;\n            node.style.height = `${Math.min(node.scrollHeight, max)}px`;\n            node.style.overflowY = node.scrollHeight > max ? \"auto\" : \"hidden\";\n        }, [value, maxRows]);\n\n        const submit = async (): Promise<void> => {\n            const text = value.trim();\n            if (!text || busy || disabled || generating) return;\n            setBusy(true);\n            try {\n                await onSend(text);\n                setValue(\"\");\n            } catch (error) {\n                onError?.(error);\n            } finally {\n                setBusy(false);\n            }\n        };\n\n        const handleSubmit = (event: FormEvent): void => {\n            event.preventDefault();\n            void submit();\n        };\n\n        /**\n         * `Enter` sends, `Shift+Enter` breaks the line, `Escape` aborts a turn in\n         * flight.\n         *\n         * The IME check is not optional: while composing Japanese or Korean, `Enter`\n         * confirms the candidate word and `keyCode === 229` marks that keystroke.\n         * Sending there would post half a word and eat the confirmation.\n         */\n        const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {\n            onKeyDown?.(event);\n            if (event.defaultPrevented) return;\n            if (event.key === \"Escape\" && generating && onStop) {\n                event.preventDefault();\n                onStop();\n                return;\n            }\n            if (event.key !== \"Enter\" || event.shiftKey) return;\n            if (event.nativeEvent.isComposing || event.keyCode === 229) return;\n            event.preventDefault();\n            void submit();\n        };\n\n        const showStop = generating && onStop !== undefined;\n\n        return (\n            <form className={cn(styles.composer, className)} onSubmit={handleSubmit}>\n                <div className={styles.composerBox}>\n                    <textarea\n                        {...rest}\n                        ref={textarea}\n                        className={styles.field}\n                        rows={1}\n                        value={value}\n                        disabled={disabled}\n                        placeholder={placeholder ?? strings.placeholder}\n                        onChange={(event) => {\n                            setValue(event.target.value);\n                            onChange?.(event);\n                        }}\n                        onKeyDown={handleKeyDown}\n                    />\n                    <div className={styles.composerActions}>\n                        {actions}\n                        {showStop ? (\n                            <button type=\"button\" className={styles.stop} onClick={onStop}>\n                                <span aria-hidden=\"true\" className={styles.stopGlyph} />\n                                {strings.stop}\n                            </button>\n                        ) : (\n                            <button\n                                type=\"submit\"\n                                className={styles.send}\n                                disabled={disabled || busy || generating || value.trim() === \"\"}\n                            >\n                                {strings.send}\n                            </button>\n                        )}\n                    </div>\n                </div>\n                {footer && <div className={styles.composerFooter}>{footer}</div>}\n            </form>\n        );\n    },\n);\n"],"mappings":"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"}