"use client" /** * AskLeoComposer — ChatGPT-style composer: * - Compact: one row (`+ | input | mic send` pill). * - Wrapped: full-width text row, icons on a footer row (text never runs beside mic). * - Autosize via scrollHeight (no grow-wrap / field-sizing conflicts). */ import * as React from "react" import { AnimatePresence, motion, useReducedMotion } from "motion/react" import { DictationSoundwave } from "@/components/dictation-soundwave" import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Kbd } from "@/components/ui/kbd" import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" import { SearchRecentsPopover } from "@/components/search-recents-popover" import { useSpeechDictation } from "@/hooks/use-speech-dictation" import type { DedicatedSearchRecentsController } from "@/lib/dedicated-search-recents" import { processDictationTranscript } from "@/lib/dictation-transcript" import { cn } from "@/lib/utils" const GHOST_ICON_BTN = "icon-button-chrome size-9 shrink-0 rounded-full hover:bg-accent hover:text-interactive-hover-foreground" /** Matches `--exxat-composer-max-height: 13rem` in exxat-composer.css */ const COMPOSER_MAX_HEIGHT_PX = 208 export interface AskLeoComposerSearchRecents { recents: Pick onSelect: (query: string) => void } export interface AskLeoComposerProps { value: string onChange: (value: string) => void onSubmit?: (message: string) => void placeholder?: string animatedPlaceholders?: string[] animatedPlaceholderIntervalMs?: number animatedPlaceholderMaxLines?: 1 | 2 leadingSlot?: "attachments" | "ai-mark" inputLabel?: string submitButtonAriaLabel?: string submitAppearance?: "send" | "search" onExpandedChange?: (expanded: boolean) => void searchRecents?: AskLeoComposerSearchRecents dictationDisabled?: boolean isAnalyzing?: boolean onStop?: () => void composerShellClassName?: string shellMaxWidth?: "2xl" | "full" className?: string } export const AskLeoComposer = React.forwardRef( function AskLeoComposer( { value, onChange, onSubmit, placeholder = "Ask Leo anything…", className, composerShellClassName, onExpandedChange, animatedPlaceholders, animatedPlaceholderIntervalMs = 4200, animatedPlaceholderMaxLines = 1, leadingSlot = "attachments", inputLabel = "Message to Leo", submitButtonAriaLabel = "Send message", submitAppearance = "send", searchRecents, dictationDisabled = false, isAnalyzing = false, onStop, shellMaxWidth = "full", }, forwardedRef, ) { const [isWrapped, setIsWrapped] = React.useState(false) const [isOverflowing, setIsOverflowing] = React.useState(false) const [showBottomFade, setShowBottomFade] = React.useState(false) const [recentsOpen, setRecentsOpen] = React.useState(false) const [recentItems, setRecentItems] = React.useState([]) const [dictationError, setDictationError] = React.useState(null) const reduceMotion = useReducedMotion() const fieldId = React.useId() const dictationBaseRef = React.useRef("") const innerRef = React.useRef(null) const singleLineHeightRef = React.useRef(24) const fileInputRef = React.useRef(null) const onExpandedChangeRef = React.useRef(onExpandedChange) onExpandedChangeRef.current = onExpandedChange const applyWrapped = React.useCallback((next: boolean | ((prev: boolean) => boolean)) => { setIsWrapped(prev => { const resolved = typeof next === "function" ? next(prev) : next if (resolved !== prev) onExpandedChangeRef.current?.(resolved) return resolved }) }, []) const phrases = React.useMemo( () => (animatedPlaceholders ?? []).flatMap(s => { const trimmed = s.trim() return trimmed ? [trimmed] : [] }), [animatedPlaceholders], ) const [phraseIndex, setPhraseIndex] = React.useState(0) const syncComposerLayout = React.useCallback((text: string) => { const textarea = innerRef.current if (!textarea) { applyWrapped(text.includes("\n")) return } textarea.style.minHeight = "0" textarea.style.height = "0px" const scrollHeight = textarea.scrollHeight const cappedHeight = Math.min(scrollHeight, COMPOSER_MAX_HEIGHT_PX) textarea.style.height = `${cappedHeight}px` const overflowing = scrollHeight > COMPOSER_MAX_HEIGHT_PX setIsOverflowing(overflowing) if (overflowing) { textarea.scrollTop = textarea.scrollHeight } setShowBottomFade(overflowing && textarea.scrollTop > 4) if (!text.trim()) { singleLineHeightRef.current = scrollHeight applyWrapped(false) return } if (text.includes("\n")) { applyWrapped(true) return } const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight) || 24 const lineCount = Math.max(1, Math.round(scrollHeight / lineHeight)) const baseline = singleLineHeightRef.current || lineHeight const wrappedNow = lineCount > 1 || scrollHeight > baseline + 2 // Stay wrapped once multiline — full-width reflow can collapse line count // back to 1 and would otherwise flip to compact with centered icons. applyWrapped(wasWrapped => wrappedNow || wasWrapped) }, [applyWrapped]) const updateScrollFade = React.useCallback(() => { const textarea = innerRef.current if (!textarea) return const overflowing = textarea.scrollHeight > COMPOSER_MAX_HEIGHT_PX const atBottom = textarea.scrollTop + textarea.clientHeight >= textarea.scrollHeight - 4 setShowBottomFade(overflowing && !atBottom) }, []) const appendDictation = React.useCallback( (chunk: string, isFinal: boolean) => { if (!chunk && !isFinal) return const base = dictationBaseRef.current const next = processDictationTranscript(base, chunk, isFinal) if (isFinal) { dictationBaseRef.current = next } onChange(next) }, [onChange], ) const { isSupported: dictationSupported, isListening, waveformLevels, start: startDictation, stop: stopDictation, } = useSpeechDictation({ onTranscript: appendDictation, onError: () => { setDictationError("Could not capture speech. Check microphone permissions and try again.") }, }) const beginDictation = React.useCallback(() => { if (!dictationSupported || dictationDisabled || isAnalyzing) return setRecentsOpen(false) setDictationError(null) dictationBaseRef.current = value void startDictation() }, [dictationDisabled, dictationSupported, isAnalyzing, startDictation, value]) const finishDictation = React.useCallback(() => { stopDictation() }, [stopDictation]) const canSend = Boolean(value.trim()) const showAnimatedPlaceholder = phrases.length > 0 && !value.trim() && !isWrapped && !isListening React.useEffect(() => { if (!showAnimatedPlaceholder) return const id = window.setInterval(() => { setPhraseIndex(i => (i + 1) % phrases.length) }, animatedPlaceholderIntervalMs) return () => window.clearInterval(id) }, [showAnimatedPlaceholder, phrases.length, animatedPlaceholderIntervalMs]) React.useEffect(() => { if (!showAnimatedPlaceholder) setPhraseIndex(0) }, [showAnimatedPlaceholder]) React.useEffect(() => { if (!searchRecents) return const sync = () => setRecentItems(searchRecents.recents.read()) sync() window.addEventListener(searchRecents.recents.eventName, sync) window.addEventListener("storage", sync) return () => { window.removeEventListener(searchRecents.recents.eventName, sync) window.removeEventListener("storage", sync) } }, [searchRecents]) React.useEffect(() => { if ((dictationDisabled || isAnalyzing) && isListening) { stopDictation({ playRelease: false }) } }, [dictationDisabled, isAnalyzing, isListening, stopDictation]) const dictationHotkeyRef = React.useRef<(e: KeyboardEvent) => void>(() => {}) dictationHotkeyRef.current = (e: KeyboardEvent) => { if (e.metaKey || e.ctrlKey || e.altKey) return const tag = (e.target as HTMLElement)?.tagName if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement)?.isContentEditable) return if ((e.key === "m" || e.key === "M") && dictationSupported && !dictationDisabled && !isAnalyzing) { e.preventDefault() if (isListening) finishDictation() else beginDictation() } } React.useEffect(() => { const listener = (e: KeyboardEvent) => dictationHotkeyRef.current(e) window.addEventListener("keydown", listener) return () => window.removeEventListener("keydown", listener) }, []) React.useLayoutEffect(() => { syncComposerLayout(value) }, [value, isWrapped, syncComposerLayout]) const setTextareaRef = React.useCallback( (node: HTMLTextAreaElement | null) => { innerRef.current = node if (typeof forwardedRef === "function") { forwardedRef(node) } else if (forwardedRef) { ;(forwardedRef as React.MutableRefObject).current = node } }, [forwardedRef], ) function handleShellMouseDown(e: React.MouseEvent) { const target = e.target as HTMLElement if (target.closest("button, a, input, textarea, [role='button']")) return e.preventDefault() innerRef.current?.focus() } function handleSubmit(e: React.FormEvent) { e.preventDefault() if (isListening) stopDictation({ playRelease: false }) const trimmed = value.trim() if (!trimmed) return onSubmit?.(trimmed) onChange("") setIsWrapped(false) setIsOverflowing(false) setShowBottomFade(false) if (innerRef.current) { innerRef.current.style.height = "0px" } } function handleTextareaScroll() { updateScrollFade() } function handleTextareaChange(e: React.ChangeEvent) { if (isListening) stopDictation({ playRelease: false }) onChange(e.target.value) syncComposerLayout(e.target.value) } function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() handleSubmit(e as unknown as React.FormEvent) } } function openComposerRecentsOnFocus() { if (!isListening && !value.trim() && recentItems.length > 0) { setRecentsOpen(true) } } function dismissComposerRecentsOnBlur() { window.setTimeout(() => setRecentsOpen(false), 120) } const leadingActions = leadingSlot === "ai-mark" ? (