import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText"; import { CliAppMentionToken, McpPresetMentionToken, cliAppInitials, mcpPresetInitials, splitCapabilityMentionSegments, type CapabilityMentionSegment, } from "@/components/CliAppMentionText"; import { Activity, ArrowUp, BookOpen, Check, ChevronDown, ChevronUp, CircleHelp, History, ImageIcon, Loader2, Plus, RotateCw, Sparkles, Square, SquarePen, Target, Undo2, X, type LucideIcon, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { useAttachedImages, type AttachedImage, type AttachmentError, MAX_IMAGES_PER_MESSAGE, } from "@/hooks/useAttachedImages"; import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop"; import type { SendImage, SendOptions } from "@/hooks/useNanobotStream"; import type { CliAppInfo, GoalStateWsPayload, McpPresetInfo, OutboundCliAppMention, OutboundMcpPresetMention, SlashCommand, } from "@/lib/types"; import { inferProviderFromModelName, logoFallbackUrls, providerBrand, } from "@/lib/provider-brand"; import { cn } from "@/lib/utils"; /** ````: aligned with the server's MIME whitelist. SVG is * deliberately excluded to avoid an embedded-script XSS surface. */ const ACCEPT_ATTR = "image/png,image/jpeg,image/webp,image/gif"; function formatBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } interface ThreadComposerProps { onSend: (content: string, images?: SendImage[], options?: SendOptions) => void; disabled?: boolean; placeholder?: string; isStreaming?: boolean; modelLabel?: string | null; modelProvider?: string | null; modelProviderLabel?: string | null; variant?: "thread" | "hero"; slashCommands?: SlashCommand[]; cliApps?: CliAppInfo[]; mcpPresets?: McpPresetInfo[]; imageMode?: boolean; onImageModeChange?: (enabled: boolean) => void; onStop?: () => void; /** Unix seconds from server; turn elapsed timer above input while set. */ runStartedAt?: number | null; /** Sustained objective for this chat (WebSocket ``goal_state``). */ goalState?: GoalStateWsPayload; } const COMMAND_ICONS: Record = { activity: Activity, "book-open": BookOpen, "circle-help": CircleHelp, history: History, "rotate-cw": RotateCw, sparkles: Sparkles, square: Square, "square-pen": SquarePen, "undo-2": Undo2, }; type ImageAspectRatio = "auto" | "1:1" | "3:4" | "9:16" | "4:3" | "16:9"; const IMAGE_ASPECT_RATIOS: ImageAspectRatio[] = ["auto", "1:1", "3:4", "9:16", "4:3", "16:9"]; const SLASH_PALETTE_GAP_PX = 8; const SLASH_PALETTE_MAX_HEIGHT_PX = 288; const SLASH_PALETTE_MIN_HEIGHT_PX = 144; const SLASH_PALETTE_CHROME_PX = 40; type SlashPalettePlacement = "above" | "below"; interface SlashPaletteLayout { placement: SlashPalettePlacement; maxHeight: number; } interface CliAppMentionQuery { query: string; start: number; end: number; } type MentionCandidate = | { kind: "cli"; name: string; app: CliAppInfo } | { kind: "mcp"; name: string; preset: McpPresetInfo }; function slashCommandI18nKey(command: string): string { return command.replace(/^\//, "").replace(/-/g, "_"); } function scrollNearestOverflowParent(target: EventTarget | null, deltaY: number) { if (!(target instanceof Element) || deltaY === 0) return; let el: HTMLElement | null = target.parentElement; while (el) { const style = window.getComputedStyle(el); const canScroll = /(auto|scroll)/.test(style.overflowY) && el.scrollHeight > el.clientHeight; if (canScroll) { el.scrollTop += deltaY; return; } el = el.parentElement; } } function getVisibleBounds(el: HTMLElement): { top: number; bottom: number } { let top = 0; let bottom = window.innerHeight; let parent = el.parentElement; while (parent) { const style = window.getComputedStyle(parent); if (/(auto|scroll|hidden|clip)/.test(style.overflowY)) { const rect = parent.getBoundingClientRect(); top = Math.max(top, rect.top); bottom = Math.min(bottom, rect.bottom); } parent = parent.parentElement; } return { top, bottom }; } function goalStateStripPreview( goal: GoalStateWsPayload | undefined, t: (key: string) => string, ): string | null { if (!goal?.active) return null; const summary = goal.ui_summary?.trim(); if (summary) return summary; const obj = goal.objective?.trim(); if (obj) return obj.length > 72 ? `${obj.slice(0, 72)}…` : obj; return t("thread.composer.goalStateFallback"); } const GOAL_PANEL_VIEWPORT_TOP_PAD = 20; const GOAL_PANEL_GAP_ABOVE_STRIP_PX = 10; const GOAL_PANEL_MIN_HEIGHT_PX = 112; const GOAL_PANEL_MAX_VIEWPORT_RATIO = 0.62; function measureGoalPanelMaxCssHeight(stripTopY: number): number { const spaceAboveStrip = stripTopY - GOAL_PANEL_VIEWPORT_TOP_PAD - GOAL_PANEL_GAP_ABOVE_STRIP_PX; return Math.min( Math.max(spaceAboveStrip, GOAL_PANEL_MIN_HEIGHT_PX), Math.floor(window.innerHeight * GOAL_PANEL_MAX_VIEWPORT_RATIO), ); } function buildGoalMarkdownBody(summary: string, objective: string): string { const s = summary.trim(); const o = objective.trim(); if (s && o) return `${s}\n\n---\n\n${o}`; return o || s; } function cliAppMentionPayload(app: CliAppInfo): OutboundCliAppMention { return { name: app.name, display_name: app.display_name, category: app.category, entry_point: app.entry_point, logo_url: app.logo_url ?? null, brand_color: app.brand_color ?? null, }; } function mcpPresetMentionPayload(preset: McpPresetInfo): OutboundMcpPresetMention { return { name: preset.name, display_name: preset.display_name, category: preset.category, transport: preset.transport, status: preset.status, configured: preset.configured, logo_url: preset.logo_url ?? null, brand_color: preset.brand_color ?? null, }; } function RunElapsedStrip({ startedAt, goalState, }: { startedAt: number | null; goalState?: GoalStateWsPayload; }) { const { t } = useTranslation(); const [goalPanelOpen, setGoalPanelOpen] = useState(false); const [, setTick] = useState(0); const stripWrapperRef = useRef(null); const panelRef = useRef(null); const expandToggleRef = useRef(null); const [panelMaxPx, setPanelMaxPx] = useState(280); useEffect(() => { if (startedAt == null) return; const id = window.setInterval(() => setTick((n) => n + 1), 1000); return () => window.clearInterval(id); }, [startedAt]); const showTimer = startedAt != null; const stripLabel = goalStateStripPreview(goalState, t); const showGoal = !!stripLabel?.trim(); if (!showTimer && !showGoal) return null; const objectiveFull = goalState?.objective?.trim() ?? ""; const summaryFull = goalState?.ui_summary?.trim() ?? ""; const canExpandGoal = !!(goalState?.active && (objectiveFull || summaryFull)); const markdownBody = objectiveFull || summaryFull ? buildGoalMarkdownBody(summaryFull, objectiveFull) : ""; useLayoutEffect(() => { if (!goalPanelOpen) return; function relayout(): void { const el = stripWrapperRef.current; if (!el) return; const top = el.getBoundingClientRect().top; setPanelMaxPx(measureGoalPanelMaxCssHeight(top)); } relayout(); preloadMarkdownText(); const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => relayout()) : null; if (stripWrapperRef.current && ro) { ro.observe(stripWrapperRef.current); } window.addEventListener("resize", relayout); window.addEventListener("scroll", relayout, true); return () => { ro?.disconnect(); window.removeEventListener("resize", relayout); window.removeEventListener("scroll", relayout, true); }; }, [goalPanelOpen]); useEffect(() => { if (!goalPanelOpen) return; function onPointerDown(ev: MouseEvent): void { const target = ev.target as Node | null; if (!target) return; if (panelRef.current?.contains(target)) return; if (expandToggleRef.current?.contains(target)) return; setGoalPanelOpen(false); } function onKey(ev: KeyboardEvent): void { if (ev.key === "Escape") setGoalPanelOpen(false); } window.addEventListener("mousedown", onPointerDown); window.addEventListener("keydown", onKey); return () => { window.removeEventListener("mousedown", onPointerDown); window.removeEventListener("keydown", onKey); }; }, [goalPanelOpen]); const elapsed = startedAt != null ? Math.max(0, Math.floor(Date.now() / 1000 - startedAt)) : 0; const m = Math.floor(elapsed / 60); const sec = elapsed % 60; const shortElapsed = m > 0 ? `${m}:${sec.toString().padStart(2, "0")}` : `${sec}s`; const timerTitle = showTimer ? t("thread.composer.runRuntimeTitle", { elapsed: shortElapsed }) : null; const ariaParts = [timerTitle, showGoal ? stripLabel : null].filter(Boolean); const ariaLabel = ariaParts.join(" · "); return (
{goalPanelOpen && canExpandGoal && markdownBody ? ( ) : null}
{showTimer ? ( ) : ( )} {timerTitle ? {timerTitle} : null} {timerTitle && showGoal ? ( · ) : null} {showGoal ? ( {t("thread.composer.goalStateStrip", { label: stripLabel })} ) : null} {canExpandGoal ? ( ) : null}
); } export function ThreadComposer({ onSend, disabled, placeholder, isStreaming = false, modelLabel = null, modelProvider = null, modelProviderLabel = null, variant = "thread", slashCommands = [], cliApps = [], mcpPresets = [], imageMode: controlledImageMode, onImageModeChange, onStop, runStartedAt = null, goalState, }: ThreadComposerProps) { const { t } = useTranslation(); const [value, setValue] = useState(""); const [inlineError, setInlineError] = useState(null); const [slashMenuDismissed, setSlashMenuDismissed] = useState(false); const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); const [cliAppMenuDismissed, setCliAppMenuDismissed] = useState(false); const [selectedCliAppIndex, setSelectedCliAppIndex] = useState(0); const [cursorPosition, setCursorPosition] = useState(0); const [uncontrolledImageMode, setUncontrolledImageMode] = useState(false); const [imageAspectRatio, setImageAspectRatio] = useState("auto"); const [aspectMenuOpen, setAspectMenuOpen] = useState(false); const textareaRef = useRef(null); const formRef = useRef(null); const fileInputRef = useRef(null); const aspectControlRef = useRef(null); const chipRefs = useRef(new Map()); const isHero = variant === "hero"; const imageMode = controlledImageMode ?? uncontrolledImageMode; const setImageMode = useCallback( (enabled: boolean) => { if (controlledImageMode === undefined) { setUncontrolledImageMode(enabled); } onImageModeChange?.(enabled); }, [controlledImageMode, onImageModeChange], ); const resolvedPlaceholder = isStreaming ? t("thread.composer.placeholderStreaming") : imageMode ? t("thread.composer.imageMode.placeholder") : placeholder ?? t("thread.composer.placeholderThread"); const { images, enqueue, remove, clear, encoding, full } = useAttachedImages(); const formatRejection = useCallback( (reason: AttachmentError): string => { const key = `thread.composer.imageRejected.${reason}`; return t(key, { max: MAX_IMAGES_PER_MESSAGE }); }, [t], ); const addFiles = useCallback( (files: File[]) => { if (files.length === 0) return; const { rejected } = enqueue(files); if (rejected.length > 0) { setInlineError(formatRejection(rejected[0].reason)); } else { setInlineError(null); } }, [enqueue, formatRejection], ); const { isDragging, onPaste, onDragEnter, onDragOver, onDragLeave, onDrop, } = useClipboardAndDrop(addFiles); useEffect(() => { if (disabled) return; const el = textareaRef.current; if (!el) return; const id = requestAnimationFrame(() => el.focus()); return () => cancelAnimationFrame(id); }, [disabled]); const readyImages = useMemo( () => images.filter((img): img is AttachedImage & { dataUrl: string } => img.status === "ready" && typeof img.dataUrl === "string", ), [images], ); const hasErrors = images.some((img) => img.status === "error"); const canSend = !disabled && !encoding && !hasErrors && (value.trim().length > 0 || readyImages.length > 0); const slashQuery = useMemo(() => { if (disabled || slashMenuDismissed || !value.startsWith("/")) return null; const commandToken = value.slice(1); if (/\s/.test(commandToken)) return null; return commandToken.toLowerCase(); }, [disabled, slashMenuDismissed, value]); const filteredSlashCommands = useMemo(() => { if (slashQuery === null) return []; return slashCommands .filter((command) => { const haystack = [ command.command, command.title, command.description, command.argHint ?? "", t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.title`, { defaultValue: "", }), t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.description`, { defaultValue: "", }), ].join(" ").toLowerCase(); return haystack.includes(slashQuery); }) .slice(0, 8); }, [slashCommands, slashQuery, t]); const showSlashMenu = filteredSlashCommands.length > 0; const cliAppMention = useMemo(() => { if (disabled || cliAppMenuDismissed) return null; const caret = Math.min(Math.max(cursorPosition, 0), value.length); const beforeCaret = value.slice(0, caret); const match = /(?:^|\s)@([a-z0-9_-]*)$/i.exec(beforeCaret); if (!match) return null; const query = match[1].toLowerCase(); return { query, start: caret - query.length - 1, end: caret, }; }, [cliAppMenuDismissed, cursorPosition, disabled, value]); const filteredMentionCandidates = useMemo(() => { if (!cliAppMention) return []; const cliCandidates: MentionCandidate[] = cliApps .filter((app) => app.installed) .filter((app) => { const haystack = [ app.name, app.display_name, app.category, app.description, app.entry_point, ].join(" ").toLowerCase(); return haystack.includes(cliAppMention.query); }) .map((app) => ({ kind: "cli", name: app.name, app })); const mcpCandidates: MentionCandidate[] = mcpPresets .filter((preset) => preset.installed && preset.configured) .filter((preset) => { const haystack = [ preset.name, preset.display_name, preset.category, preset.description, preset.transport, ].join(" ").toLowerCase(); return haystack.includes(cliAppMention.query); }) .map((preset) => ({ kind: "mcp", name: preset.name, preset })); return [...cliCandidates, ...mcpCandidates].slice(0, 8); }, [cliAppMention, cliApps, mcpPresets]); const showCliAppMenu = filteredMentionCandidates.length > 0; const showAnyPalette = showSlashMenu || showCliAppMenu; const mentionSegments = useMemo( () => splitCapabilityMentionSegments(value, cliApps, mcpPresets), [cliApps, mcpPresets, value], ); const hasMentionDecorations = mentionSegments.some( (segment) => segment.kind === "cli" || segment.kind === "mcp", ); const activeCliMentionApps = useMemo(() => { const seen = new Set(); return mentionSegments.flatMap((segment) => { if (segment.kind !== "cli" || seen.has(segment.app.name)) return []; seen.add(segment.app.name); return [segment.app]; }); }, [mentionSegments]); const activeMcpPresetMentions = useMemo(() => { const seen = new Set(); return mentionSegments.flatMap((segment) => { if (segment.kind !== "mcp" || seen.has(segment.preset.name)) return []; seen.add(segment.preset.name); return [segment.preset]; }); }, [mentionSegments]); const [slashPaletteLayout, setSlashPaletteLayout] = useState({ placement: "above", maxHeight: SLASH_PALETTE_MAX_HEIGHT_PX, }); useEffect(() => { setSelectedCommandIndex(0); }, [slashQuery]); useEffect(() => { setSelectedCliAppIndex(0); }, [cliAppMention?.query]); useEffect(() => { if (selectedCommandIndex >= filteredSlashCommands.length) { setSelectedCommandIndex(0); } }, [filteredSlashCommands.length, selectedCommandIndex]); useEffect(() => { if (selectedCliAppIndex >= filteredMentionCandidates.length) { setSelectedCliAppIndex(0); } }, [filteredMentionCandidates.length, selectedCliAppIndex]); useEffect(() => { if (!showAnyPalette) return; const dismissOnPointerDown = (event: PointerEvent) => { const target = event.target; if (target instanceof Node && formRef.current?.contains(target)) return; setSlashMenuDismissed(true); setCliAppMenuDismissed(true); }; document.addEventListener("pointerdown", dismissOnPointerDown, true); return () => { document.removeEventListener("pointerdown", dismissOnPointerDown, true); }; }, [showAnyPalette]); useLayoutEffect(() => { if (!showAnyPalette) return; const updateLayout = () => { const form = formRef.current; if (!form) return; const rect = form.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) return; const bounds = getVisibleBounds(form); const spaceAbove = Math.max(0, rect.top - bounds.top - SLASH_PALETTE_GAP_PX); const spaceBelow = Math.max(0, bounds.bottom - rect.bottom - SLASH_PALETTE_GAP_PX); const placement: SlashPalettePlacement = spaceAbove >= SLASH_PALETTE_MIN_HEIGHT_PX || spaceAbove >= spaceBelow ? "above" : "below"; const available = placement === "above" ? spaceAbove : spaceBelow; const maxHeight = Math.min(SLASH_PALETTE_MAX_HEIGHT_PX, available); setSlashPaletteLayout((current) => current.placement === placement && current.maxHeight === maxHeight ? current : { placement, maxHeight }, ); }; updateLayout(); window.addEventListener("resize", updateLayout); document.addEventListener("scroll", updateLayout, true); return () => { window.removeEventListener("resize", updateLayout); document.removeEventListener("scroll", updateLayout, true); }; }, [filteredMentionCandidates.length, filteredSlashCommands.length, showAnyPalette]); useEffect(() => { if (!aspectMenuOpen) return; const closeOnPointerDown = (event: PointerEvent) => { const target = event.target; if (target instanceof Node && aspectControlRef.current?.contains(target)) return; setAspectMenuOpen(false); }; const closeOnKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setAspectMenuOpen(false); textareaRef.current?.focus(); } }; const closeOnScroll = () => setAspectMenuOpen(false); const closeOnWheel = (event: WheelEvent) => { setAspectMenuOpen(false); scrollNearestOverflowParent(event.target, event.deltaY); }; document.addEventListener("pointerdown", closeOnPointerDown, true); document.addEventListener("keydown", closeOnKeyDown); document.addEventListener("scroll", closeOnScroll, true); document.addEventListener("wheel", closeOnWheel, { capture: true, passive: true }); return () => { document.removeEventListener("pointerdown", closeOnPointerDown, true); document.removeEventListener("keydown", closeOnKeyDown); document.removeEventListener("scroll", closeOnScroll, true); document.removeEventListener("wheel", closeOnWheel, true); }; }, [aspectMenuOpen]); const resizeTextarea = useCallback(() => { requestAnimationFrame(() => { const el = textareaRef.current; if (!el) return; el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, 260)}px`; el.focus(); }); }, []); const chooseSlashCommand = useCallback( (command: SlashCommand) => { setValue(command.argHint ? `${command.command} ` : command.command); setSlashMenuDismissed(true); setCliAppMenuDismissed(false); setInlineError(null); resizeTextarea(); }, [resizeTextarea], ); const chooseMentionCandidate = useCallback( (candidate: MentionCandidate) => { if (!cliAppMention) return; const suffix = value.slice(cliAppMention.end); const mention = `@${candidate.name}${suffix.startsWith(" ") ? "" : " "}`; const next = `${value.slice(0, cliAppMention.start)}${mention}${suffix}`; const nextCursor = cliAppMention.start + mention.length; setValue(next); setCursorPosition(nextCursor); setCliAppMenuDismissed(true); setSlashMenuDismissed(false); setInlineError(null); resizeTextarea(); requestAnimationFrame(() => { const el = textareaRef.current; if (!el) return; el.focus(); el.setSelectionRange(nextCursor, nextCursor); }); }, [cliAppMention, resizeTextarea, value], ); const submit = useCallback(() => { if (!canSend) return; const trimmed = value.trim(); // Share the same normalized ``data:`` URL with both the wire payload and // the optimistic bubble preview: data URLs are self-contained (no blob // lifetime, safe under React StrictMode double-mount) and keep the // bubble in sync with whatever the backend actually sees. const payload: SendImage[] | undefined = readyImages.length > 0 ? readyImages.map((img) => ({ media: { data_url: img.dataUrl, name: img.file.name, }, preview: { url: img.dataUrl, name: img.file.name }, })) : undefined; const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload); const attachedMcpPresets = activeMcpPresetMentions.map(mcpPresetMentionPayload); const options: SendOptions | undefined = imageMode || attachedCliApps.length > 0 || attachedMcpPresets.length > 0 ? { ...(imageMode ? { imageGeneration: { enabled: true, aspect_ratio: imageAspectRatio === "auto" ? null : imageAspectRatio, }, } : {}), ...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}), ...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}), } : undefined; onSend(trimmed, payload, options); setValue(""); setInlineError(null); // Bubble owns the data URL copy; safe to revoke every staged blob // preview here without affecting the rendered message. clear(); setSlashMenuDismissed(false); setCliAppMenuDismissed(false); setCursorPosition(0); resizeTextarea(); }, [ activeCliMentionApps, activeMcpPresetMentions, canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value, ]); const onKeyDown = (e: ReactKeyboardEvent) => { if (showCliAppMenu) { if (e.key === "ArrowDown") { e.preventDefault(); setSelectedCliAppIndex((idx) => (idx + 1) % filteredMentionCandidates.length); return; } if (e.key === "ArrowUp") { e.preventDefault(); setSelectedCliAppIndex( (idx) => (idx - 1 + filteredMentionCandidates.length) % filteredMentionCandidates.length, ); return; } if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) { e.preventDefault(); chooseMentionCandidate(filteredMentionCandidates[selectedCliAppIndex]); return; } if (e.key === "Escape") { e.preventDefault(); setCliAppMenuDismissed(true); return; } } if (showSlashMenu) { if (e.key === "ArrowDown") { e.preventDefault(); setSelectedCommandIndex((idx) => (idx + 1) % filteredSlashCommands.length); return; } if (e.key === "ArrowUp") { e.preventDefault(); setSelectedCommandIndex( (idx) => (idx - 1 + filteredSlashCommands.length) % filteredSlashCommands.length, ); return; } if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) { e.preventDefault(); chooseSlashCommand(filteredSlashCommands[selectedCommandIndex]); return; } if (e.key === "Escape") { e.preventDefault(); setSlashMenuDismissed(true); return; } } if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); submit(); } }; const onInput: React.FormEventHandler = (e) => { const el = e.currentTarget; el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, 260)}px`; }; const onFilePick: React.ChangeEventHandler = (e) => { const files = Array.from(e.target.files ?? []); e.target.value = ""; addFiles(files); }; const removeChip = useCallback( (id: string) => { const { nextFocusId } = remove(id); setInlineError(null); requestAnimationFrame(() => { const el = nextFocusId ? chipRefs.current.get(nextFocusId) : null; if (el) { el.focus(); } else { textareaRef.current?.focus(); } }); }, [remove], ); const onChipKey = useCallback( (id: string) => (e: ReactKeyboardEvent) => { if ( e.key === "Delete" || e.key === "Backspace" || e.key === "Enter" || e.key === " " ) { e.preventDefault(); removeChip(id); } }, [removeChip], ); const attachButtonDisabled = disabled || full; const showStopButton = isStreaming && !!onStop; const inputTextClasses = cn( "w-full resize-none bg-transparent", isHero ? "min-h-[78px] px-5 pb-2 pt-5 text-[15px] leading-6" : "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5", ); return (
{ e.preventDefault(); submit(); }} onDragEnter={onDragEnter} onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop} className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")} > {showSlashMenu ? ( ) : null} {showCliAppMenu ? ( ) : null}
{images.length > 0 ? (
{images.map((img) => ( t("thread.composer.normalizedSizeHint", { orig: formatBytes(orig), current: formatBytes(current), }) } formatError={formatRejection} onRemove={() => removeChip(img.id)} onKeyDown={onChipKey(img.id)} registerRef={(el) => { if (el) chipRefs.current.set(img.id, el); else chipRefs.current.delete(img.id); }} /> ))}
) : null} {runStartedAt != null || goalState?.active ? ( ) : null}
{hasMentionDecorations ? ( ) : null}