import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, 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, Brain, ChevronDown, ChevronUp, CircleHelp, CornerDownRight, GripVertical, History, ImageIcon, Loader2, Mic, Plus, RotateCw, Shield, Sparkles, Square, SquarePen, Target, Trash2, Undo2, X, type LucideIcon, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { WorkspaceAccessMenu, WorkspaceProjectPicker, } from "@/components/thread/WorkspaceControls"; import { useAttachedImages, type AttachedImage, type AttachmentError, MAX_IMAGES_PER_MESSAGE, type RestoredReadyImage, } from "@/hooks/useAttachedImages"; import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop"; import type { SendImage, SendOptions } from "@/hooks/useNanobotStream"; import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder"; import type { CliAppInfo, GoalStateWsPayload, McpPresetInfo, OutboundCliAppMention, OutboundMcpPresetMention, SlashCommand, WorkspaceScopePayload, WorkspacesPayload, } 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"; const VOICE_SHORTCUT_CODE = "KeyD"; const VOICE_SHORTCUT_ARIA = "Control+Shift+D"; type VoiceShortcutPlatform = "apple" | "chromeos" | "linux" | "other" | "windows"; 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`; } function isVoiceShortcutDown(event: KeyboardEvent): boolean { return ( event.code === VOICE_SHORTCUT_CODE && event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey ); } function isVoiceShortcutRelease(event: KeyboardEvent): boolean { return ( event.code === VOICE_SHORTCUT_CODE || event.key === "Control" || event.key === "Shift" ); } function getVoiceShortcutPlatform(): VoiceShortcutPlatform { if (typeof navigator === "undefined") return "other"; const userAgentData = (navigator as Navigator & { userAgentData?: { platform?: string } }) .userAgentData; const platform = [ userAgentData?.platform, navigator.platform, navigator.userAgent, ].filter(Boolean).join(" ").toLowerCase(); const isIpadPretendingToBeMac = navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1; if (isIpadPretendingToBeMac || /mac|iphone|ipad|ipod/.test(platform)) return "apple"; if (/win/.test(platform)) return "windows"; if (/cros/.test(platform)) return "chromeos"; if (/linux|x11|android/.test(platform)) return "linux"; return "other"; } function getVoiceShortcutLabel(): string { switch (getVoiceShortcutPlatform()) { case "apple": return "⌃⇧D"; case "chromeos": case "linux": case "windows": case "other": return "Ctrl ⇧ D"; } } 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; modelNeedsSetup?: boolean; onModelBadgeClick?: () => void; variant?: "thread" | "hero"; slashCommands?: SlashCommand[]; cliApps?: CliAppInfo[]; mcpPresets?: McpPresetInfo[]; onStop?: () => void; onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise; /** Unix seconds from server; turn elapsed timer above input while set. */ runStartedAt?: number | null; /** Sustained objective for this chat (WebSocket ``goal_state``). */ goalState?: GoalStateWsPayload; workspaceScope?: WorkspaceScopePayload | null; workspaceDefaultScope?: WorkspaceScopePayload | null; workspaceControls?: WorkspacesPayload["controls"] | null; workspaceScopeDisabled?: boolean; workspaceError?: string | null; onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void; pendingQueueKey?: string | null; } const COMMAND_ICONS: Record = { activity: Activity, "book-open": BookOpen, brain: Brain, "circle-help": CircleHelp, history: History, "rotate-cw": RotateCw, shield: Shield, sparkles: Sparkles, square: Square, "square-pen": SquarePen, "undo-2": Undo2, }; 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 = 12; const SLASH_RECENTS_STORAGE_KEY = "nanobot.webui.slashCommandRecents"; const SLASH_RECENTS_LIMIT = 5; const QUEUED_PROMPTS_STORAGE_PREFIX = "nanobot.webui.composerQueuedGuidance.v1:"; const QUEUED_PROMPTS_LIMIT = 20; const QUEUED_PROMPT_MAX_CHARS = 4000; function VoiceRecordingMeter({ ariaLabel, className, elapsedLabel, isHero, levels, }: { ariaLabel: string; className?: string; elapsedLabel: string; isHero: boolean; levels: number[]; }) { return ( {levels.map((height, index) => ( ))} {elapsedLabel} ); } type SlashPalettePlacement = "above" | "below"; interface SlashPaletteLayout { placement: SlashPalettePlacement; maxHeight: number; } interface QueuedPrompt { id: string; text: string; images?: QueuedPromptImage[]; } interface QueuedPromptImage { dataUrl: string; name?: string; } interface CliAppMentionQuery { query: string; start: number; end: number; } type MentionCandidate = | { kind: "cli"; name: string; app: CliAppInfo } | { kind: "mcp"; name: string; preset: McpPresetInfo }; interface SlashPaletteCommand extends SlashCommand { detail: string; badge?: string; recent: boolean; } function slashCommandI18nKey(command: string): string { return command.replace(/^\//, "").replace(/-/g, "_"); } function readSlashRecents(): string[] { if (typeof window === "undefined") return []; try { const raw = window.localStorage.getItem(SLASH_RECENTS_STORAGE_KEY); const parsed = raw ? JSON.parse(raw) : []; return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string").slice(0, SLASH_RECENTS_LIMIT) : []; } catch { return []; } } function storeSlashRecents(commands: string[]): void { if (typeof window === "undefined") return; try { window.localStorage.setItem( SLASH_RECENTS_STORAGE_KEY, JSON.stringify(commands.slice(0, SLASH_RECENTS_LIMIT)), ); } catch { // localStorage may be unavailable in private contexts; command insertion still works. } } function queuedPromptsStorageKey(key?: string | null): string | null { const clean = key?.trim(); return clean ? `${QUEUED_PROMPTS_STORAGE_PREFIX}${clean}` : null; } function normalizeQueuedPrompt(item: unknown, index: number): QueuedPrompt | null { if (!item || typeof item !== "object") return null; const record = item as Partial; if (typeof record.text !== "string") return null; const text = record.text.trim().slice(0, QUEUED_PROMPT_MAX_CHARS); const images = Array.isArray(record.images) ? record.images.flatMap((image) => { if (!image || typeof image !== "object") return []; const candidate = image as Partial; if (typeof candidate.dataUrl !== "string" || !candidate.dataUrl.startsWith("data:image/")) { return []; } return [{ dataUrl: candidate.dataUrl, ...(typeof candidate.name === "string" && candidate.name.trim() ? { name: candidate.name.trim() } : {}), }]; }).slice(0, MAX_IMAGES_PER_MESSAGE) : []; if (!text && images.length === 0) return null; const id = typeof record.id === "string" && record.id.trim() ? record.id : `queued-prompt-restored-${index}`; return { id, text, ...(images.length > 0 ? { images } : {}) }; } function readQueuedPrompts(storageKey: string): QueuedPrompt[] { if (typeof window === "undefined") return []; try { const raw = window.localStorage.getItem(storageKey); const parsed = raw ? JSON.parse(raw) : []; if (!Array.isArray(parsed)) return []; return parsed .map((item, index) => normalizeQueuedPrompt(item, index)) .filter((item): item is QueuedPrompt => item != null) .slice(0, QUEUED_PROMPTS_LIMIT); } catch { return []; } } function storeQueuedPrompts(storageKey: string, prompts: QueuedPrompt[]): void { if (typeof window === "undefined") return; try { if (prompts.length === 0) { window.localStorage.removeItem(storageKey); return; } window.localStorage.setItem( storageKey, JSON.stringify( prompts.slice(0, QUEUED_PROMPTS_LIMIT).map((prompt) => ({ id: prompt.id, text: prompt.text.slice(0, QUEUED_PROMPT_MAX_CHARS), ...(prompt.images?.length ? { images: prompt.images.slice(0, MAX_IMAGES_PER_MESSAGE) } : {}), })), ), ); } catch { // localStorage persistence is a convenience; the in-memory queue still works. } } function readyImagesToQueuedImages( images: Array, ): QueuedPromptImage[] { return images.map((img) => ({ dataUrl: img.dataUrl, name: img.file.name, })); } function queuedImagesToSendImages(images?: QueuedPromptImage[]): SendImage[] | undefined { if (!images?.length) return undefined; return images.map((img) => ({ media: { data_url: img.dataUrl, ...(img.name ? { name: img.name } : {}), }, preview: { url: img.dataUrl, ...(img.name ? { name: img.name } : {}), }, })); } function queuedPromptLabel(prompt: QueuedPrompt): string { const text = prompt.text.trim(); if (text) return text; return prompt.images?.map((img) => img.name).filter(Boolean).join(", ") || "Image attachment"; } function suppressNativeDragPreview(dataTransfer: DataTransfer): void { if (typeof document === "undefined" || typeof dataTransfer.setDragImage !== "function") { return; } const ghost = document.createElement("div"); ghost.style.position = "fixed"; ghost.style.left = "-9999px"; ghost.style.top = "-9999px"; ghost.style.width = "1px"; ghost.style.height = "1px"; ghost.style.opacity = "0"; document.body.appendChild(ghost); try { dataTransfer.setDragImage(ghost, 0, 0); } catch { ghost.remove(); return; } window.setTimeout(() => ghost.remove(), 0); } 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 RunPulseIcon() { return ( ); } function RunElapsedStrip({ startedAt, goalState, }: { startedAt: number | null; goalState?: GoalStateWsPayload; }) { const { t } = useTranslation(); const [goalPanelOpen, setGoalPanelOpen] = useState(false); const showTimer = startedAt != null; const stripLabel = goalStateStripPreview(goalState, t); const showGoal = !!stripLabel?.trim(); const active = showTimer || showGoal; const [renderStrip, setRenderStrip] = useState(active); const [leaving, setLeaving] = useState(false); const [, setTick] = useState(0); const stripWrapperRef = useRef(null); const panelRef = useRef(null); const expandToggleRef = useRef(null); const stripSnapshotRef = useRef<{ startedAt: number | null; goalState?: GoalStateWsPayload; stripLabel: string | null; } | null>(null); const [panelMaxPx, setPanelMaxPx] = useState(280); if (active) { stripSnapshotRef.current = { startedAt, goalState, stripLabel }; } useEffect(() => { if (active) { setRenderStrip(true); setLeaving(false); return; } setGoalPanelOpen(false); if (!renderStrip) return; setLeaving(true); const id = window.setTimeout(() => { setRenderStrip(false); setLeaving(false); }, 180); return () => window.clearTimeout(id); }, [active, renderStrip]); useEffect(() => { if (startedAt == null) return; const id = window.setInterval(() => setTick((n) => n + 1), 1000); return () => window.clearInterval(id); }, [startedAt]); const display = active ? { startedAt, goalState, stripLabel } : stripSnapshotRef.current; const displayStartedAt = display?.startedAt ?? null; const displayGoalState = display?.goalState; const displayStripLabel = display?.stripLabel ?? null; const displayShowTimer = displayStartedAt != null; const displayShowGoal = !!displayStripLabel?.trim(); const objectiveFull = displayGoalState?.objective?.trim() ?? ""; const summaryFull = displayGoalState?.ui_summary?.trim() ?? ""; const canExpandGoal = !!(active && displayGoalState?.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]); if (!renderStrip || !display) return null; const elapsed = displayStartedAt != null ? Math.max(0, Math.floor(Date.now() / 1000 - displayStartedAt)) : 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 = displayShowTimer ? t("thread.composer.runRuntimeTitle", { elapsed: shortElapsed }) : null; const ariaParts = [timerTitle, displayShowGoal ? displayStripLabel : null].filter(Boolean); const ariaLabel = ariaParts.join(" · "); return ( {goalPanelOpen && canExpandGoal && markdownBody ? ( {t("thread.composer.goalStateSheetTitle")} setGoalPanelOpen(false)} > {markdownBody} ) : null} {displayShowTimer ? ( ) : ( )} {timerTitle ? {timerTitle} : null} {timerTitle && displayShowGoal ? ( · ) : null} {displayShowGoal ? ( {t("thread.composer.goalStateStrip", { label: displayStripLabel })} ) : null} {canExpandGoal ? ( setGoalPanelOpen((o) => !o)} > {goalPanelOpen ? ( ) : ( )} ) : null} ); } export function ThreadComposer({ onSend, disabled, placeholder, isStreaming = false, modelLabel = null, modelProvider = null, modelProviderLabel = null, modelNeedsSetup = false, onModelBadgeClick, variant = "thread", slashCommands = [], cliApps = [], mcpPresets = [], onStop, onTranscribeAudio, runStartedAt = null, goalState, workspaceScope = null, workspaceDefaultScope = null, workspaceControls = null, workspaceScopeDisabled = false, workspaceError = null, onWorkspaceScopeChange, pendingQueueKey = null, }: 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 [recentSlashCommands, setRecentSlashCommands] = useState(() => readSlashRecents()); const [queuedPrompts, setQueuedPrompts] = useState([]); const textareaRef = useRef(null); const formRef = useRef(null); const fileInputRef = useRef(null); const chipRefs = useRef(new Map()); const queuedPromptCounterRef = useRef(0); const draggedQueuedPromptIdRef = useRef(null); const previousPendingQueueKeyRef = useRef(pendingQueueKey); const wasStreamingRef = useRef(isStreaming); const skipNextQueuedFlushRef = useRef(false); const skipQueuedPromptPersistRef = useRef(false); const voiceShortcutDownRef = useRef(false); const isHero = variant === "hero"; const voiceShortcutLabel = useMemo(getVoiceShortcutLabel, []); const queuedPromptStorageKey = useMemo( () => queuedPromptsStorageKey(pendingQueueKey), [pendingQueueKey], ); const showProjectPicker = isHero && !!workspaceDefaultScope && !!onWorkspaceScopeChange && workspaceControls?.can_change_project !== false; useEffect(() => { skipQueuedPromptPersistRef.current = true; setQueuedPrompts(queuedPromptStorageKey ? readQueuedPrompts(queuedPromptStorageKey) : []); }, [queuedPromptStorageKey]); useEffect(() => { if (!queuedPromptStorageKey) return; if (skipQueuedPromptPersistRef.current) { skipQueuedPromptPersistRef.current = false; return; } storeQueuedPrompts(queuedPromptStorageKey, queuedPrompts); }, [queuedPromptStorageKey, queuedPrompts]); const resolvedPlaceholder = isStreaming ? t("thread.composer.placeholderStreaming") : placeholder ?? t("thread.composer.placeholderThread"); const { images, enqueue, remove, clear, restoreReadyImages, 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 hasComposerContent = value.trim().length > 0 || readyImages.length > 0; const canSend = !disabled && !modelNeedsSetup && !encoding && !hasErrors && hasComposerContent; const canOpenModelSettings = Boolean(modelNeedsSetup && onModelBadgeClick && !disabled); const canQueueGuidance = isStreaming && !disabled && !modelNeedsSetup && !encoding && !hasErrors && hasComposerContent && !value.trimStart().startsWith("/"); 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 visibleSlashCommands = useMemo(() => { const baseCommands = slashCommands.filter((command) => command.command !== "/stop"); if (!(isStreaming && onStop)) return baseCommands; const stopCommand = slashCommands.find((command) => command.command === "/stop") ?? { command: "/stop", title: "Stop current task", description: "Cancel the active agent turn for this chat.", icon: "square", }; return [ stopCommand, ...baseCommands, ]; }, [isStreaming, onStop, slashCommands]); const filteredSlashCommands = useMemo(() => { if (slashQuery === null) return []; const withDetails = visibleSlashCommands .filter((command) => { const commandKey = slashCommandI18nKey(command.command); const title = t(`thread.composer.slash.commands.${commandKey}.title`, { defaultValue: command.title, }); const description = t(`thread.composer.slash.commands.${commandKey}.description`, { defaultValue: command.description, }); const haystack = [ command.command, command.title, command.description, command.argHint ?? "", title, description, ].join(" ").toLowerCase(); return haystack.includes(slashQuery); }) .map((command) => { const commandKey = slashCommandI18nKey(command.command); const description = t(`thread.composer.slash.commands.${commandKey}.description`, { defaultValue: command.description, }); let detail = description; let badge: string | undefined; if (command.command === "/model" && modelLabel) { detail = modelLabel; badge = t("thread.composer.slash.badges.current"); } else if (command.command === "/goal") { detail = goalState?.active ? t("thread.composer.slash.details.goalActive") : t("thread.composer.slash.details.goalReady"); } else if (command.command === "/stop" && isStreaming) { detail = t("thread.composer.slash.details.stopRunning"); } else if (command.command === "/history") { detail = t("thread.composer.slash.details.history"); } return { ...command, detail, badge, recent: recentSlashCommands.includes(command.command), }; }) .sort((a, b) => { if (isStreaming) { if (a.command === "/stop") return -1; if (b.command === "/stop") return 1; } if (slashQuery !== "") return 0; const aRecent = recentSlashCommands.indexOf(a.command); const bRecent = recentSlashCommands.indexOf(b.command); if (aRecent !== -1 || bRecent !== -1) { if (aRecent === -1) return 1; if (bRecent === -1) return -1; return aRecent - bRecent; } return 0; }); return withDetails .slice(0, 8); }, [goalState?.active, isStreaming, modelLabel, recentSlashCommands, slashQuery, t, visibleSlashCommands]); 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]); 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(); }); }, []); // Runs before paint so switching sessions never flashes stale draft text. useLayoutEffect(() => { if (previousPendingQueueKeyRef.current === pendingQueueKey) return; previousPendingQueueKeyRef.current = pendingQueueKey; setValue(""); setInlineError(null); setSlashMenuDismissed(false); setCliAppMenuDismissed(false); setCursorPosition(0); clear(); requestAnimationFrame(() => { const el = textareaRef.current; if (!el) return; el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, 260)}px`; }); }, [clear, pendingQueueKey]); const appendTranscription = useCallback((text: string) => { const transcript = text.trim(); if (!transcript) return; setValue((current) => { if (!current.trim()) return transcript; const separator = /[\s\n]$/.test(current) ? "" : " "; return `${current}${separator}${transcript}`; }); setSlashMenuDismissed(false); setCliAppMenuDismissed(false); setInlineError(null); resizeTextarea(); }, [resizeTextarea]); const clearInlineError = useCallback(() => setInlineError(null), []); const setVoiceError = useCallback((key: VoiceRecorderErrorKey) => { setInlineError(t(`thread.composer.voiceErrors.${key}`)); }, [t]); const voiceRecorder = useVoiceRecorder({ disabled, onClearError: clearInlineError, onError: setVoiceError, onTranscript: appendTranscription, onTranscribeAudio, }); useEffect(() => { if (!onTranscribeAudio) return; function onKeyDown(event: KeyboardEvent): void { if (!isVoiceShortcutDown(event) || event.repeat || voiceShortcutDownRef.current) return; event.preventDefault(); voiceShortcutDownRef.current = true; voiceRecorder.beginShortcutHold(); } function onKeyUp(event: KeyboardEvent): void { if (!voiceShortcutDownRef.current || !isVoiceShortcutRelease(event)) return; event.preventDefault(); voiceShortcutDownRef.current = false; voiceRecorder.endShortcutHold(); } function onWindowBlur(): void { if (!voiceShortcutDownRef.current) return; voiceShortcutDownRef.current = false; voiceRecorder.endShortcutHold(); } window.addEventListener("keydown", onKeyDown); window.addEventListener("keyup", onKeyUp); window.addEventListener("blur", onWindowBlur); return () => { window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keyup", onKeyUp); window.removeEventListener("blur", onWindowBlur); }; }, [onTranscribeAudio, voiceRecorder.beginShortcutHold, voiceRecorder.endShortcutHold]); const chooseSlashCommand = useCallback( (command: SlashCommand) => { if (command.command === "/stop" && isStreaming && onStop) { onStop(); setValue(""); setSlashMenuDismissed(true); setCliAppMenuDismissed(false); setInlineError(null); resizeTextarea(); return; } const nextRecents = [ command.command, ...recentSlashCommands.filter((item) => item !== command.command), ].slice(0, SLASH_RECENTS_LIMIT); setRecentSlashCommands(nextRecents); storeSlashRecents(nextRecents); setValue(command.argHint ? `${command.command} ` : command.command); setSlashMenuDismissed(true); setCliAppMenuDismissed(false); setInlineError(null); resizeTextarea(); }, [isStreaming, onStop, recentSlashCommands, 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 clearComposerText = useCallback(() => { setValue(""); setInlineError(null); setSlashMenuDismissed(false); setCliAppMenuDismissed(false); setCursorPosition(0); resizeTextarea(); }, [resizeTextarea]); const queueGuidancePrompt = useCallback(() => { const text = value.trim(); if (!canQueueGuidance || (!text && readyImages.length === 0)) return; const queuedImages = readyImagesToQueuedImages(readyImages); queuedPromptCounterRef.current += 1; setQueuedPrompts((items) => [ ...items, { id: `queued-prompt-${Date.now()}-${queuedPromptCounterRef.current}`, text, ...(queuedImages.length > 0 ? { images: queuedImages } : {}), }, ]); clear(); clearComposerText(); }, [canQueueGuidance, clear, clearComposerText, readyImages, value]); const removeQueuedPrompt = useCallback((id: string) => { setQueuedPrompts((items) => items.filter((item) => item.id !== id)); requestAnimationFrame(() => textareaRef.current?.focus()); }, []); const editQueuedPrompt = useCallback((prompt: QueuedPrompt) => { setQueuedPrompts((items) => items.filter((item) => item.id !== prompt.id)); setValue(prompt.text); setInlineError(null); setSlashMenuDismissed(false); setCliAppMenuDismissed(false); setCursorPosition(prompt.text.length); if (prompt.images?.length) { restoreReadyImages(prompt.images as RestoredReadyImage[]); } else { clear(); } resizeTextarea(); requestAnimationFrame(() => { const el = textareaRef.current; if (!el) return; el.focus(); el.setSelectionRange(prompt.text.length, prompt.text.length); }); }, [clear, resizeTextarea, restoreReadyImages]); const moveQueuedPrompt = useCallback((dragId: string, targetId: string) => { if (dragId === targetId) return; setQueuedPrompts((items) => { const from = items.findIndex((item) => item.id === dragId); const to = items.findIndex((item) => item.id === targetId); if (from === -1 || to === -1) return items; const next = [...items]; const [moved] = next.splice(from, 1); next.splice(to, 0, moved); return next; }); }, []); const sendQueuedPrompt = useCallback( (prompt: QueuedPrompt) => { const text = prompt.text.trim(); const queuedImages = queuedImagesToSendImages(prompt.images); setQueuedPrompts((items) => items.filter((item) => item.id !== prompt.id)); if (text || queuedImages?.length) { if (queuedImages?.length) onSend(text, queuedImages); else onSend(text); } requestAnimationFrame(() => textareaRef.current?.focus()); }, [onSend], ); const sendNextQueuedPrompt = useCallback(() => { if (queuedPrompts.length === 0) return; const nextPrompt = queuedPrompts.find((prompt) => prompt.text.trim()); if (!nextPrompt) { setQueuedPrompts([]); return; } setQueuedPrompts((items) => items.filter((item) => item.id !== nextPrompt.id)); const queuedImages = queuedImagesToSendImages(nextPrompt.images); if (queuedImages?.length) onSend(nextPrompt.text.trim(), queuedImages); else onSend(nextPrompt.text.trim()); requestAnimationFrame(() => textareaRef.current?.focus()); }, [onSend, queuedPrompts]); useEffect(() => { const wasStreaming = wasStreamingRef.current; wasStreamingRef.current = isStreaming; if (!wasStreaming || isStreaming || queuedPrompts.length === 0) return; if (skipNextQueuedFlushRef.current) { skipNextQueuedFlushRef.current = false; return; } sendNextQueuedPrompt(); }, [sendNextQueuedPrompt, isStreaming, queuedPrompts.length]); const handleStop = useCallback(() => { if (queuedPrompts.length > 0) { skipNextQueuedFlushRef.current = true; } onStop?.(); }, [onStop, queuedPrompts.length]); const submit = useCallback(() => { if (modelNeedsSetup) { onModelBadgeClick?.(); return; } if (!canSend) return; const trimmed = value.trim(); const content = trimmed; // 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 = attachedCliApps.length > 0 || attachedMcpPresets.length > 0 ? { ...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}), ...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}), } : undefined; onSend(content, payload, options); setQueuedPrompts([]); // Bubble owns the data URL copy; safe to revoke every staged blob // preview here without affecting the rendered message. clear(); clearComposerText(); }, [ activeCliMentionApps, activeMcpPresetMentions, canSend, clear, clearComposerText, modelNeedsSetup, onModelBadgeClick, onSend, readyImages, 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(); if (canQueueGuidance) { queueGuidancePrompt(); return; } 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 showVoiceButton = Boolean(onTranscribeAudio); const voiceRecordingStatusLabel = t("thread.composer.voice.recordingStatus", { time: voiceRecorder.elapsedLabel, defaultValue: `Recording ${voiceRecorder.elapsedLabel}`, }); const voiceButtonLabel = voiceRecorder.state === "recording" ? t("thread.composer.voice.stop") : voiceRecorder.state === "transcribing" ? t("thread.composer.voice.transcribing") : t("thread.composer.tools.voice"); const voiceButtonTooltip = voiceRecorder.state === "recording" ? t("thread.composer.voice.stop") : voiceRecorder.state === "transcribing" ? t("thread.composer.voice.transcribing") : t("thread.composer.voice.hint"); const showStopButton = isStreaming && !!onStop; const relaxedHeroInput = isHero && images.length === 0 && !isStreaming; const inputTextClasses = cn( "w-full resize-none bg-transparent", isHero ? cn( "min-h-[78px] px-5 text-[15px] leading-6", relaxedHeroInput ? "pb-2 pt-[27px]" : "pb-1.5 pt-4", ) : "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} {queuedPrompts.length > 0 ? ( { draggedQueuedPromptIdRef.current = id; }} onDragEnd={() => { draggedQueuedPromptIdRef.current = null; }} onDrop={(targetId) => { const dragId = draggedQueuedPromptIdRef.current; if (dragId) moveQueuedPrompt(dragId, targetId); }} /> ) : 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} {hasMentionDecorations ? ( ) : null} { setValue(e.target.value); setSlashMenuDismissed(false); setCliAppMenuDismissed(false); setCursorPosition(e.target.selectionStart ?? e.target.value.length); }} onInput={onInput} onKeyDown={onKeyDown} onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)} onSelect={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)} onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)} onPaste={onPaste} rows={1} placeholder={resolvedPlaceholder} disabled={disabled} aria-label={t("thread.composer.inputAria")} className={cn( inputTextClasses, "relative z-10 caret-foreground placeholder:text-muted-foreground/70", "focus:outline-none focus-visible:outline-none", "disabled:cursor-not-allowed", hasMentionDecorations && "text-transparent selection:bg-primary/20", )} /> {inlineError ? ( {inlineError} ) : null} fileInputRef.current?.click()} className={cn( "rounded-full text-muted-foreground hover:text-foreground", isHero ? "h-8 w-8 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card" : "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card", )} > {voiceRecorder.isRecording ? ( ) : workspaceScope ? ( ) : null} {modelLabel && !voiceRecorder.isRecording ? ( ) : null} {showVoiceButton ? ( {voiceRecorder.state === "transcribing" ? ( ) : voiceRecorder.isRecording ? ( ) : ( )} {voiceButtonTooltip} {voiceRecorder.state === "idle" ? ( {voiceShortcutLabel} ) : null} ) : null} {showStopButton ? ( ) : isStreaming ? ( ) : ( )} ); } function QueuedPromptStack({ prompts, isHero, label, guideLabel, deleteLabel, dragLabel, editLabel, onGuide, onDelete, onEdit, onDragStart, onDragEnd, onDrop, }: { prompts: QueuedPrompt[]; isHero: boolean; label: string; guideLabel: string; deleteLabel: string; dragLabel: string; editLabel: string; onGuide: (prompt: QueuedPrompt) => void; onDelete: (id: string) => void; onEdit: (prompt: QueuedPrompt) => void; onDragStart: (id: string) => void; onDragEnd: () => void; onDrop: (targetId: string) => void; }) { const stripMaxHeight = Math.min(240, 14 + prompts.length * 34 + Math.max(0, prompts.length - 1) * 4); return ( {prompts.map((prompt) => ( ))} ); } function QueuedPromptRow({ prompt, isHero, guideLabel, deleteLabel, dragLabel, editLabel, onGuide, onDelete, onEdit, onDragStart, onDragEnd, onDrop, }: { prompt: QueuedPrompt; isHero: boolean; guideLabel: string; deleteLabel: string; dragLabel: string; editLabel: string; onGuide: (prompt: QueuedPrompt) => void; onDelete: (id: string) => void; onEdit: (prompt: QueuedPrompt) => void; onDragStart: (id: string) => void; onDragEnd: () => void; onDrop: (targetId: string) => void; }) { const displayLabel = queuedPromptLabel(prompt); return ( { event.preventDefault(); onDrop(prompt.id); }} onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={(event) => { event.preventDefault(); onDrop(prompt.id); }} onDragEnd={onDragEnd} className={cn( "queued-prompt-row group/queued flex min-h-8 items-center gap-1.5 rounded-[12px] px-2 py-0.5", "text-[13px] transition-colors hover:bg-muted/55 dark:hover:bg-white/[0.055]", isHero && "text-[13.5px]", )} > { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", prompt.id); suppressNativeDragPreview(event.dataTransfer); onDragStart(prompt.id); }} onDragEnd={onDragEnd} className={cn( "inline-flex h-7 w-7 shrink-0 cursor-grab items-center justify-center rounded-lg", "text-muted-foreground/45 transition-colors hover:bg-background/80 hover:text-muted-foreground", "active:cursor-grabbing dark:hover:bg-white/[0.06]", )} > {displayLabel} onGuide(prompt)} > {guideLabel} onEdit(prompt)} > onDelete(prompt.id)} > ); } function ComposerModelBadge({ label, provider, providerLabel, needsSetup, isHero, onClick, }: { label: string; provider?: string | null; providerLabel?: string | null; needsSetup?: boolean; isHero: boolean; onClick?: () => void; }) { const inferredProvider = needsSetup ? null : provider || inferProviderFromModelName(label); const brand = providerBrand(inferredProvider); const [logoIndex, setLogoIndex] = useState(0); const logoUrl = brand?.logoUrls[logoIndex]; const showLogo = !!logoUrl; const title = providerLabel ? `${label} · ${providerLabel}` : label; const interactive = Boolean(onClick); const Container = interactive ? "button" : "span"; useEffect(() => setLogoIndex(0), [inferredProvider]); return ( {needsSetup ? ( ) : showLogo ? ( setLogoIndex((index) => index + 1)} /> ) : brand ? ( {brand.initials.slice(0, 2)} ) : ( )} {label} ); } function ComposerCliMentionOverlay({ segments, isHero, className, }: { segments: CapabilityMentionSegment[]; isHero: boolean; className: string; }) { return ( {segments.map((segment, index) => { if (segment.kind === "text") { return {segment.text}; } if (segment.kind === "cli") return ( ); return ( ); })} ); } interface SlashCommandPaletteProps { commands: SlashPaletteCommand[]; selectedIndex: number; layout: SlashPaletteLayout; isHero: boolean; onHover: (index: number) => void; onChoose: (command: SlashPaletteCommand) => void; } interface CliAppMentionPaletteProps { candidates: MentionCandidate[]; selectedIndex: number; layout: SlashPaletteLayout; isHero: boolean; onHover: (index: number) => void; onChoose: (candidate: MentionCandidate) => void; } function useSelectedOptionScroll(selectedIndex: number) { const containerRef = useRef(null); useLayoutEffect(() => { const container = containerRef.current; if (!container) return; const option = container.querySelector( `[data-palette-index="${selectedIndex}"]`, ); if (typeof option?.scrollIntoView === "function") { option.scrollIntoView({ block: "nearest" }); } }, [selectedIndex]); return containerRef; } function CliAppMentionPalette({ candidates, selectedIndex, layout, isHero, onHover, onChoose, }: CliAppMentionPaletteProps) { const { t } = useTranslation(); const listMaxHeight = Math.max( 0, layout.maxHeight - SLASH_PALETTE_CHROME_PX, ); const listRef = useSelectedOptionScroll(selectedIndex); return ( {t("thread.composer.mentions.label")} {candidates.map((candidate, index) => { const selected = index === selectedIndex; const name = candidate.name; const displayName = candidate.kind === "cli" ? candidate.app.display_name : candidate.preset.display_name; const typeLabel = candidate.kind === "cli" ? t("thread.composer.mentions.cliBadge") : t("thread.composer.mentions.mcpBadge"); const ariaDescription = candidate.kind === "cli" ? t("thread.composer.mentions.cliDescription", { name }) : t("thread.composer.mentions.mcpDescription", { name }); return ( onHover(index)} onMouseDown={(e) => { e.preventDefault(); onChoose(candidate); }} className={cn( "flex h-10 w-full items-center gap-2.5 rounded-[13px] px-2.5 text-left transition-colors", selected ? "bg-foreground/[0.055] text-foreground" : "text-foreground/90 hover:bg-foreground/[0.04]", )} > {displayName} @{name} {typeLabel} ); })} ); } function MentionCandidateLogo({ candidate, selected, }: { candidate: MentionCandidate; selected: boolean; }) { const [logoIndex, setLogoIndex] = useState(0); const color = (candidate.kind === "cli" ? candidate.app.brand_color : candidate.preset.brand_color) || "hsl(var(--primary))"; const rawLogoUrl = candidate.kind === "cli" ? candidate.app.logo_url : candidate.preset.logo_url; const logoUrls = useMemo(() => logoFallbackUrls(rawLogoUrl), [rawLogoUrl]); const logoUrl = logoUrls[logoIndex]; useEffect(() => setLogoIndex(0), [rawLogoUrl]); if (logoUrl) { return ( setLogoIndex((index) => index + 1)} /> ); } return ( {candidate.kind === "cli" ? cliAppInitials(candidate.app) : mcpPresetInitials(candidate.preset)} ); } function SlashCommandPalette({ commands, selectedIndex, layout, isHero, onHover, onChoose, }: SlashCommandPaletteProps) { const { t } = useTranslation(); const listMaxHeight = Math.max( 0, layout.maxHeight - SLASH_PALETTE_CHROME_PX, ); const listRef = useSelectedOptionScroll(selectedIndex); return ( {commands.map((command, index) => { const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp; const selected = index === selectedIndex; const commandKey = slashCommandI18nKey(command.command); const title = t(`thread.composer.slash.commands.${commandKey}.title`, { defaultValue: command.title, }); const description = t(`thread.composer.slash.commands.${commandKey}.description`, { defaultValue: command.description, }); return ( onHover(index)} onMouseDown={(e) => { e.preventDefault(); onChoose(command); }} className={cn( "flex min-h-[44px] w-full items-center gap-3 rounded-[13px] px-3 py-2 text-left transition-colors", selected ? "bg-foreground/[0.065] text-foreground dark:bg-white/[0.09]" : "text-foreground/86 hover:bg-foreground/[0.045] dark:hover:bg-white/[0.065]", )} > {title} {command.detail || description} {command.badge || command.recent ? ( {command.badge ?? t("thread.composer.slash.badges.recent")} ) : null} {command.argHint ? `${command.command} ${command.argHint}` : command.command} ); })} ); } interface AttachmentChipProps { image: AttachedImage; labelRemove: string; labelEncoding: string; normalizedHint: (origBytes: number, currentBytes: number) => string; formatError: (reason: AttachmentError) => string; onRemove: () => void; onKeyDown: (e: ReactKeyboardEvent) => void; registerRef: (el: HTMLButtonElement | null) => void; } function AttachmentChip({ image, labelRemove, labelEncoding, normalizedHint, formatError, onRemove, onKeyDown, registerRef, }: AttachmentChipProps) { const sizeLabel = image.status === "ready" && image.normalized && image.encodedBytes ? normalizedHint(image.file.size, image.encodedBytes) : formatBytes(image.file.size); const tone = image.status === "error" ? "border-destructive/40 bg-destructive/5 text-destructive" : "border-border/70 bg-muted/60"; return ( {image.previewUrl ? ( ) : ( )} {image.status === "encoding" ? ( ) : null} {image.file.name} {image.status === "error" && image.error ? formatError(image.error) : sizeLabel} ); }
{displayLabel}