"use client" /** * AskLeoComposer — ChatGPT-style composer: * - Compact: one row (`+ | input | mic`, send appears on focus). * - Wrapped: full-width text (grows to ~8 rows, then scrolls), icons on a footer row. * - 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 { useLeoAmbience } from "@/components/leo-ambience-context" import { LeoSearchBarWash } from "@/components/leo-search-bar-wash" 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 { LeoIcon, type LeoIconMotionState, } from "@/components/ui/leo-icon" 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 (~8–9 text rows). */ const COMPOSER_MAX_ROWS = 8 const COMPOSER_MAX_HEIGHT_FALLBACK_PX = 208 const PHRASE_EASE = [0.22, 1, 0.36, 1] as const export interface AskLeoComposerSearchRecents { recents: Pick onSelect: (query: string) => void } export interface AskLeoComposerProps { value: string onChange: (value: string) => void onSubmit?: (message: string) => void /** * Whether submitting empties the field. True for a conversation, where the * message leaves the composer and becomes a turn in the transcript. False for * a search bar, where the query is the state of the results below it and has * to stay legible and editable after the search runs. */ clearOnSubmit?: boolean 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 /** In-flight search (Library AI search). Shows a spinner on the submit control. */ isSearching?: boolean /** * Library / mode-switch Leo search — prefs-driven wash, double-click opens * Leo appearance settings. */ searchBarAmbience?: boolean /** * Host actions rendered inside the pill, ahead of dictation and submit. * For a named action the composer itself does not own (LeoAssistBar's * rewrite action and its overflow). */ inlineActions?: React.ReactNode onStop?: () => void composerShellClassName?: string shellMaxWidth?: "2xl" | "full" className?: string } export const AskLeoComposer = React.forwardRef( function AskLeoComposer( { value, onChange, onSubmit, clearOnSubmit = true, 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, isSearching = false, searchBarAmbience = false, inlineActions, onStop, shellMaxWidth = "full", }, forwardedRef, ) { const { prefs: leoPrefs, previewThinking, setSettingsOpen, setPreviewThinking, } = useLeoAmbience() const [isWrapped, setIsWrapped] = React.useState(false) const [isOverflowing, setIsOverflowing] = React.useState(false) const [showBottomFade, setShowBottomFade] = React.useState(false) const [isFocused, setIsFocused] = React.useState(false) /** True after the user clicks or types in the composer (not panel autofocus). */ const [userActivated, setUserActivated] = 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 maxHeightPxRef = React.useRef(COMPOSER_MAX_HEIGHT_FALLBACK_PX) const fileInputRef = React.useRef(null) const onExpandedChangeRef = React.useRef(onExpandedChange) React.useEffect(() => { 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 } const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight) || 24 const maxHeightPx = lineHeight * COMPOSER_MAX_ROWS maxHeightPxRef.current = maxHeightPx textarea.style.minHeight = "0" textarea.style.height = "0px" const scrollHeight = textarea.scrollHeight const cappedHeight = Math.min(scrollHeight, maxHeightPx) textarea.style.height = `${cappedHeight}px` const overflowing = scrollHeight > maxHeightPx + 1 setIsOverflowing(overflowing) if (overflowing) { textarea.scrollTop = textarea.scrollHeight } setShowBottomFade(overflowing && textarea.scrollTop > 4) if (!text.trim()) { singleLineHeightRef.current = Math.min(scrollHeight, lineHeight) applyWrapped(false) return } if (text.includes("\n")) { applyWrapped(true) return } const lineCount = Math.max(1, Math.round(scrollHeight / lineHeight)) const baseline = singleLineHeightRef.current || lineHeight // Expand to the wrapped footer layout only when a second row appears. 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 maxHeightPx = maxHeightPxRef.current const overflowing = textarea.scrollHeight > maxHeightPx + 1 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 || isSearching) return setRecentsOpen(false) setDictationError(null) dictationBaseRef.current = value void startDictation() }, [dictationDisabled, dictationSupported, isAnalyzing, isSearching, startDictation, value]) const finishDictation = React.useCallback(() => { stopDictation() }, [stopDictation]) const canSend = Boolean(value.trim()) // Idle: dictation only. Reveal send after the user engages the field // (click / type) or when there is text to submit — not on panel autofocus alone. const showSend = ((userActivated && isFocused) || canSend) && !isListening && !isAnalyzing && !isSearching const activateComposer = React.useCallback(() => { setUserActivated(true) }, []) // Dictation takes the placeholder over with "Listening…", and a search that // is running is showing its own query, so neither wants suggestions rotating // underneath. Two strings at one spot read as garbled text, not as a state. const showAnimatedPlaceholder = phrases.length > 0 && !value.trim() && !isWrapped && !isListening && !isSearching 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]) /** * `exit` is a function variant so it can read `custom` at the moment the * phrase leaves: `true` means the next suggestion is taking its place and * the two crossfade, `false` means the placeholder itself is over (someone * typed, dictated, or the field wrapped) and it has to disappear at once. */ const phraseMotion = React.useMemo( () => ({ enter: { opacity: 0, y: reduceMotion ? 0 : 3 }, visible: { opacity: 1, y: 0, transition: { duration: reduceMotion ? 0 : 0.32, ease: PHRASE_EASE }, }, exit: (rotating: boolean) => { const instant = reduceMotion || !rotating return { opacity: 0, y: instant ? 0 : -3, transition: { duration: instant ? 0 : 0.32, ease: PHRASE_EASE }, } }, }), [reduceMotion], ) 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 || isSearching) && isListening) { stopDictation({ playRelease: false }) } }, [dictationDisabled, isAnalyzing, isSearching, isListening, stopDictation]) const dictationHotkey = (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 && !isSearching) { e.preventDefault() if (isListening) finishDictation() else beginDictation() } } const dictationHotkeyRef = React.useRef<(e: KeyboardEvent) => void>(() => {}) React.useEffect(() => { dictationHotkeyRef.current = dictationHotkey }) 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']")) { if (target.closest("textarea")) activateComposer() return } e.preventDefault() activateComposer() innerRef.current?.focus() } function handleSubmit(e: React.FormEvent) { e.preventDefault() if (isSearching || isAnalyzing) return if (isListening) stopDictation({ playRelease: false }) const trimmed = value.trim() if (!trimmed) return onSubmit?.(trimmed) if (!clearOnSubmit) return onChange("") setIsWrapped(false) setIsOverflowing(false) setShowBottomFade(false) setUserActivated(false) if (innerRef.current) { innerRef.current.style.height = "0px" } } function handleTextareaScroll() { updateScrollFade() } function handleTextareaChange(e: React.ChangeEvent) { if (isListening) stopDictation({ playRelease: false }) activateComposer() onChange(e.target.value) syncComposerLayout(e.target.value) } function handleKeyDown(e: React.KeyboardEvent) { activateComposer() if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() handleSubmit(e as unknown as React.FormEvent) } } function openComposerRecentsOnFocus() { setIsFocused(true) if ( !isListening && !isSearching && !isAnalyzing && !value.trim() && recentItems.length > 0 ) { setRecentsOpen(true) } } function dismissComposerRecentsOnBlur() { setIsFocused(false) if (!value.trim()) setUserActivated(false) window.setTimeout(() => setRecentsOpen(false), 120) } const leoBusy = isSearching || isAnalyzing || (searchBarAmbience && previewThinking) // The thinking wash belongs to the search bar only. The Ask Leo chatbox // shows work through the status marker instead, so a composer that is not // a search bar never paints a wash. const showSearchBarWash = searchBarAmbience && leoPrefs.searchBarWash && (isSearching || isAnalyzing || previewThinking) const washMode = leoPrefs.searchBarWashMode // Only the in-pill wash needs clipping. Idle pills stay overflow-visible so // recents and popovers are not trapped. const clipPillForWash = showSearchBarWash && washMode === "inside" const openAmbienceFromDoubleClick = React.useCallback( (event: React.MouseEvent) => { if (!searchBarAmbience) return event.preventDefault() setSettingsOpen(true) setPreviewThinking(true) }, [searchBarAmbience, setPreviewThinking, setSettingsOpen], ) const leoMarkState: LeoIconMotionState = leoBusy ? "working" : isFocused || userActivated ? "invited" : "rest" const leadingActions = leadingSlot === "ai-mark" ? ( {isSearching ? "Searching" : "AI search"} ) : (