"use client"; import type { Message } from "@langchain/langgraph-sdk"; import { useQueryClient } from "@tanstack/react-query"; import type { ChatStatus } from "ai"; import { AtSignIcon, CheckIcon, GraduationCapIcon, LightbulbIcon, Loader2Icon, MicIcon, PaperclipIcon, PlusIcon, RocketIcon, SparklesIcon, SquareIcon, TargetIcon, Undo2Icon, XIcon, ZapIcon, } from "lucide-react"; import { useSearchParams } from "next/navigation"; import { useCallback, useId, useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type ComponentProps, type ClipboardEvent, type FormEvent, type KeyboardEvent, } from "react"; import { flushSync } from "react-dom"; import { toast } from "sonner"; import { PromptInput, PromptInputActionMenu, PromptInputActionMenuContent, PromptInputActionMenuItem, PromptInputActionMenuTrigger, PromptInputAttachment, PromptInputAttachments, PromptInputButton, PromptInputFooter, PromptInputHeader, PromptInputSubmit, PromptInputTextarea, PromptInputTools, usePromptInputAttachments, usePromptInputController, type PromptInputMessage, } from "@/components/ai-elements/prompt-input"; import { Button } from "@/components/ui/button"; import { ConfettiButton } from "@/components/ui/confetti-button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { DropdownMenuGroup, DropdownMenuLabel, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { fetch } from "@/core/api/fetcher"; import { useAuth } from "@/core/auth/AuthProvider"; import { getBackendBaseURL } from "@/core/config"; import { buildConversationReferenceMetadata, type ConversationReference, } from "@/core/conversation-references"; import { extensionMentionId, MAX_EXTENSION_MENTIONS, } from "@/core/extensions/mentions"; import { useConversationReferencesCapability } from "@/core/features/hooks"; import { useI18n } from "@/core/i18n/hooks"; import { polishInputDraft } from "@/core/input-polish/api"; import { isHiddenFromUIMessage, type FileInMessage, } from "@/core/messages/utils"; import { useModels } from "@/core/models/hooks"; import { getReasoningEffortOptions, getResolvedMode, type InputMode, isThinkingRequired, type ReasoningEffortValue, reasoningEffortForMode, resolveReasoningEffort, supportsThinking as modelSupportsThinking, } from "@/core/models/reasoning"; import { attachProjectDocument } from "@/core/projects/api"; import { useStagedProjectAttachments } from "@/core/projects/composer-attach"; import { buildReferenceMessageMetadata, type SidecarContext, } from "@/core/sidecar"; import { parseSlashSkillReference } from "@/core/skills"; import { useSkills } from "@/core/skills/hooks"; import { DEFAULT_MAX_SUGGESTIONS } from "@/core/suggestions/api"; import { useSuggestionsConfig } from "@/core/suggestions/hooks"; import type { AgentThreadContext, GoalState } from "@/core/threads"; import { compactThreadContext } from "@/core/threads/api"; import { buildComposerDraftKey, clearComposerDraft, getSessionComposerDraftStorage, readComposerDraft, resolveComposerDraft, type ComposerDraft, writeComposerDraft, } from "@/core/threads/composer-draft"; import { threadTokenUsageQueryKey } from "@/core/threads/token-usage"; import { textOfMessage } from "@/core/threads/utils"; import { formatUploadSize, splitUnsupportedUploadFiles, useUploadLimits, validateUploadLimits, type UploadLimits, type UploadLimitViolation, } from "@/core/uploads"; import { appendSpeechTranscript, getSpeechRecognitionConstructor, getSpeechRecognitionLanguage, mapSpeechRecognitionError, readSpeechRecognitionTranscript, shouldRestartSpeechRecognition, type BrowserSpeechRecognition, type SpeechRecognitionErrorKind, } from "@/core/voice-input/speech-recognition"; import { isCompositionConfirmEnter, isIMEComposing } from "@/lib/ime"; import { cn } from "@/lib/utils"; import { Suggestion, Suggestions } from "../ai-elements/suggestion"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "../ui/dropdown-menu"; import { abortGoalRequest, beginGoalRequest, canPolishInput, createGoalRequestState, findSuggestionTemplatePlaceholder, finishGoalRequest, filterSkillsForAgent, getGoalObjectiveCounter, getInputSubmitAction, getLeadingSlashCommandQuery, getMatchingSlashCommands, type GoalCommand, isAbortError, isCurrentGoalRequest, isGoalObjectiveTooLong, MAX_GOAL_OBJECTIVE_CHARS, readGoalResponseError, type SlashCommandSuggestion, } from "./input-box-helpers"; import { extensionMentionMetadata, inlineReferences, reconcileConversationReferences, MAX_EXPLICIT_SKILLS, referenceToken, readableReferences, restoreReferenceLabels, readReferenceEditor, referenceCaret, focusReferenceAt, renderReferenceEditor, selectReferenceForDeletion, } from "./mentions/inline-references"; import { MentionPicker, type MentionPickerHandle, type MentionSelection, } from "./mentions/mention-picker"; import { getMentionQuery, type MentionQuery } from "./mentions/query"; import { useThread } from "./messages/context"; import { ModeHoverGuide } from "./mode-hover-guide"; import { ModelPicker, ModelPickerContent, ModelPickerTrigger, } from "./model-picker-content"; import { ReferenceAttachmentSummary, useMaybeSidecar } from "./sidecar"; import { Tooltip } from "./tooltip"; const COMPOSER_DRAFT_SAVE_DELAY_MS = 300; function focusContentEditableEnd(element: HTMLElement | null) { if (!element) { return; } element.focus(); const selection = window.getSelection(); if (!selection) { return; } const range = document.createRange(); range.selectNodeContents(element); range.collapse(false); selection.removeAllRanges(); selection.addRange(range); } function insertPlainTextAtSelection(container: HTMLElement, text: string) { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) { return false; } const range = selection.getRangeAt(0); const ancestor = range.commonAncestorContainer; if (ancestor !== container && !container.contains(ancestor)) { return false; } range.deleteContents(); const node = document.createTextNode(text); range.insertNode(node); range.setStartAfter(node); range.setEndAfter(node); selection.removeAllRanges(); selection.addRange(range); return true; } function escapeXmlAttribute(value: string) { return value .replace(/&/g, "&") .replace(/"/g, """) .replace(//g, ">"); } export type InputBoxSubmitOptions = { additionalKwargs?: Record; additionalInputMessages?: Message[]; /** Thread IDs attached through the conversation picker; sent as run context. */ conversationReferences?: string[]; onSent?: () => void; }; type VoiceRecognitionStartOptions = { focusAfterStart?: boolean; }; function buildHiddenConversationQuoteMessage({ contexts, }: { contexts: SidecarContext[]; }): Message { return { type: "human", content: [ { type: "text", text: [ contexts.length === 1 ? "The user added the following quoted context to this conversation." : `The user added the following ${contexts.length} quoted contexts to this conversation.`, "Use the referenced_message blocks as reference material for the user's next message.", "", ...contexts.flatMap((context, index) => [ ``, `Role: ${context.role === "user" ? "User" : "Assistant"}`, context.messageId ? `Message ID: ${context.messageId}` : null, "", context.content, "", "", ].filter((line): line is string => line !== null), ), ] .filter((line): line is string => line !== null) .join("\n"), }, ], additional_kwargs: { hide_from_ui: true, conversation_quote_context: true, // Keep ids/roles/count 1:1 parallel with `contexts` so consumers can zip // them safely; do not dedupe ids here. referenced_message_ids: contexts.map( (context) => context.messageId ?? "", ), referenced_message_roles: contexts.map((context) => context.role), quote_context_count: contexts.length, }, } as Message; } export function InputBox({ className, disabled, autoFocus, status = "ready", context, extraHeader, isWelcomeMode, threadId, draftThreadId = threadId, projectId, draftAgentName, defaultModelName, knowledgeScopeControl, initialValue, onContextChange, onFollowupsVisibilityChange, onGoalChange, onPrepareThread, onReferenceFileAttached, onSubmit, onStop, canStopStreaming = true, canCreateRuns = true, agentSkillNames, agentSkillsLoading = false, ...props }: Omit, "onSubmit"> & { assistantId?: string | null; status?: ChatStatus; disabled?: boolean; context: Omit< AgentThreadContext, "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" > & { mode: "flash" | "thinking" | "pro" | "ultra" | undefined; reasoning_effort?: ReasoningEffortValue; }; extraHeader?: React.ReactNode; /** * Whether to render the input in welcome layout (vertically centered, * with hero + quick action suggestions). This is purely a visual flag, * decoupled from "the backend has created the thread" — see issue #2746. */ isWelcomeMode?: boolean; threadId: string; draftThreadId?: string; projectId?: string | null; draftAgentName?: string | null; agentSkillNames?: string[] | null; agentSkillsLoading?: boolean; /** * The active custom agent's configured default model, if any. Used as the * auto-selection fallback so an agent chat honors the agent's own default * model instead of silently snapping to the first configured model * (issue #4336). ``null`` / undefined = no agent default → use models[0]. */ defaultModelName?: string | null; /** Optional knowledge-scope control rendered directly after mode. */ knowledgeScopeControl?: React.ReactNode; initialValue?: string; onContextChange?: ( // Explicit selections contain only the fields changed by that action, // never the whole thread-resolved context (which may override the account). context: Partial< Omit< AgentThreadContext, "thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled" > & { mode: "flash" | "thinking" | "pro" | "ultra" | undefined; reasoning_effort?: ReasoningEffortValue; } >, options?: { automatic: boolean }, ) => void; onFollowupsVisibilityChange?: (visible: boolean) => void; onGoalChange?: (goal: GoalState | null) => void; /** * Prepare a not-yet-materialized thread before a builtin command creates * it server-side. The `/goal ` PUT endpoint materializes a * missing thread row itself, so a project-scoped new chat uses this to * assign membership first — the later idempotent thread create would * otherwise return that unassigned row without the project. Only runs for * goal-set: status/clear never create a thread server-side. Rejecting * aborts the command and keeps the composer's text for a retry. */ onPrepareThread?: () => void | Promise; /** Move a prepared new project chat to its durable URL after file ingestion. */ onReferenceFileAttached?: () => void; onSubmit?: ( message: PromptInputMessage, options?: InputBoxSubmitOptions, ) => void | Promise; onStop?: () => void; /** * Whether the caller's role holds `runs:cancel` (RFC #4063 Phase 4). * Defaults to true so callers that don't resolve permissions (pre-Phase-4 * backends, storybook) keep today's behavior; the Gateway route guard * stays the enforcement point. */ canStopStreaming?: boolean; /** * Whether the caller's role holds `runs:create` (RFC #4063 Phase 4). * Defaults to true so callers that don't resolve permissions (pre-Phase-4 * backends, storybook) keep today's behavior; the Gateway route guard * stays the enforcement point. */ canCreateRuns?: boolean; }) { const { locale, t } = useI18n(); const queryClient = useQueryClient(); const searchParams = useSearchParams(); const mentionListId = useId(); const mentionPickerRef = useRef(null); const [activeMentionOption, setActiveMentionOption] = useState< string | undefined >(); const conversationCapability = useConversationReferencesCapability(); const [mentionQuery, setMentionQuery] = useState(null); const [mentionButtonOpen, setMentionButtonOpen] = useState(false); const [mentionBusy, setMentionBusy] = useState(false); const [mentionPlacement, setMentionPlacement] = useState({ above: true, maxHeight: 400, }); const [mentionError, setMentionError] = useState(null); const mentionEpoch = useRef(0); const mentionInFlight = useRef(false); const dismissedMention = useRef(null); const [modelDialogOpen, setModelDialogOpen] = useState(false); const { models } = useModels(); const { user } = useAuth(); const { thread, isMock } = useThread(); const { attachments, textInput } = usePromptInputController(); const setTextInput = textInput.setInput; const sidecar = useMaybeSidecar(); const attachmentParts = attachments.files; // References belong to the draft and clear only when its send proceeds. const [conversationReferences, setConversationReferences] = useState< ConversationReference[] >([]); useLayoutEffect(() => { mentionEpoch.current += 1; mentionInFlight.current = false; setMentionBusy(false); setMentionQuery(null); setMentionButtonOpen(false); setMentionError(null); return () => { mentionEpoch.current += 1; }; }, [threadId, projectId]); const removeAttachment = attachments.remove; // Project documents attached from the shelf arrive already ingested // thread-side (spec §9): the composer shows them as completed attachments // and includes them in the next send without a re-upload. Staged only on // attach success; the hook consumes the staged entry once per thread and // keeps it across a Strict-Mode effect replay. const [projectAttachments, setProjectAttachments] = useStagedProjectAttachments(threadId); const { skills, isLoading: skillsLoading, isFetching: skillsFetching, error: skillsError, refetch: refetchSkills, } = useSkills(); const { data: uploadLimits } = useUploadLimits(threadId); const promptRootRef = useRef(null); const textareaRef = useRef(null); const inlineSkillTextRef = useRef(null); const inlineSkillComposingRef = useRef(false); const composerCaretRef = useRef(0); const inlineRefs = useMemo( () => inlineReferences(textInput.value), [textInput.value], ); const hasInlineReferences = inlineRefs.length > 0; const conversationReferencesUnavailable = (!conversationCapability.isSuccess || conversationCapability.isLoading) && inlineRefs.some((ref) => ref.kind === "conversation"); const reconciledConversations = useMemo( () => reconcileConversationReferences( textInput.value, conversationReferences, { enabled: conversationCapability.enabled, maxReferences: conversationCapability.maxReferences, isLoading: conversationCapability.isLoading, isSuccess: conversationCapability.isSuccess, }, threadId, ), [ textInput.value, conversationReferences, conversationCapability.enabled, conversationCapability.maxReferences, conversationCapability.isLoading, conversationCapability.isSuccess, threadId, ], ); useEffect(() => { if (reconciledConversations.text !== textInput.value) setTextInput(reconciledConversations.text); }, [reconciledConversations.text, textInput.value, setTextInput]); const [inlineEditorActive, setInlineEditorActive] = useState(false); const projectReferenceCache = useRef( new Map(), ); const conversationReferenceCache = useRef( new Map(), ); useLayoutEffect(() => { projectReferenceCache.current.clear(); setInlineEditorActive(false); }, [threadId, user?.id]); useLayoutEffect(() => { for (const attachment of projectAttachments) { if (attachment.source_document_id) projectReferenceCache.current.set( attachment.source_document_id, attachment, ); } }, [projectAttachments]); useLayoutEffect(() => { for (const reference of conversationReferences) conversationReferenceCache.current.set(reference.threadId, reference); }, [conversationReferences]); const inlineCompositionEndedAt = useRef(-Infinity); const goalRequestStateRef = useRef(createGoalRequestState()); const compactRequestStateRef = useRef(createGoalRequestState()); const inputPolishRequestRef = useRef<{ controller: AbortController | null; sequence: number; }>({ controller: null, sequence: 0, }); const voiceRecognitionRef = useRef(null); const voiceBaseTextRef = useRef(""); const voiceLatestTextRef = useRef(""); const voiceLastErrorKindRef = useRef(null); const voiceStopRequestedRef = useRef(false); const voiceRestartTimerRef = useRef | null>( null, ); const startVoiceRecognitionRef = useRef< ((options?: VoiceRecognitionStartOptions) => boolean) | null >(null); const promptHistoryIndexRef = useRef(null); const promptHistoryDraftRef = useRef(""); const pendingDraftSubmissionRef = useRef<{ key: string; text: string; skillName: string | null; } | null>(null); const acceptedDraftRef = useRef<{ key: string; text: string; skillName: string | null; } | null>(null); const latestDraftRef = useRef<{ key: string; draft: ComposerDraft; } | null>(null); const draftSaveTimerRef = useRef(null); const draftSaveGenerationRef = useRef(0); const [followups, setFollowups] = useState([]); const { data: suggestionsConfig } = useSuggestionsConfig(); const suggestionsConfigLoaded = suggestionsConfig !== undefined; const suggestionsEnabled = suggestionsConfig?.enabled; const maxFollowupSuggestions = suggestionsConfig?.max_suggestions ?? DEFAULT_MAX_SUGGESTIONS; const [followupsHidden, setFollowupsHidden] = useState(false); const [followupsLoading, setFollowupsLoading] = useState(false); const [polishingInput, setPolishingInput] = useState(false); const [voiceListening, setVoiceListening] = useState(false); const [inputPolishUndo, setInputPolishUndo] = useState<{ originalText: string; rewrittenText: string; } | null>(null); const [textareaFocused, setTextareaFocused] = useState(false); const [commandSuggestionIndex, setCommandSuggestionIndex] = useState(0); const [hydratedDraftKey, setHydratedDraftKey] = useState(null); const [dismissedCommandSuggestionValue, setDismissedCommandSuggestionValue] = useState(null); const lastGeneratedForAiIdRef = useRef(null); const wasStreamingRef = useRef(false); // Set when the user stops a streaming turn. Such a turn ends on a // half-finished response, so we must NOT generate follow-up suggestions for it. const stoppedByUserRef = useRef(false); const messagesRef = useRef(thread.messages); const clearVoiceRestartTimer = useCallback(() => { if (voiceRestartTimerRef.current === null) { return; } clearTimeout(voiceRestartTimerRef.current); voiceRestartTimerRef.current = null; }, []); const cleanupVoiceRecognition = useCallback( ( recognition: BrowserSpeechRecognition | null, options: { keepListening?: boolean } = {}, ) => { clearVoiceRestartTimer(); if (!recognition) { if (!options.keepListening) { voiceLastErrorKindRef.current = null; voiceStopRequestedRef.current = false; setVoiceListening(false); } return; } recognition.onend = null; recognition.onerror = null; recognition.onresult = null; if (voiceRecognitionRef.current === recognition) { voiceRecognitionRef.current = null; } if (!options.keepListening) { voiceLastErrorKindRef.current = null; voiceStopRequestedRef.current = false; setVoiceListening(false); } }, [clearVoiceRestartTimer], ); const abortVoiceInput = useCallback(() => { const recognition = voiceRecognitionRef.current; voiceStopRequestedRef.current = true; if (!recognition) { cleanupVoiceRecognition(null); return; } cleanupVoiceRecognition(recognition); try { recognition.abort(); } catch { // Browser implementations can throw when the recognizer already ended. } }, [cleanupVoiceRecognition]); const [confirmOpen, setConfirmOpen] = useState(false); const [pendingSuggestion, setPendingSuggestion] = useState( null, ); const builtinSlashCommands = useMemo( () => [ { name: "goal", description: t.inputBox.goalCommandDescription, }, { name: "compact", description: t.inputBox.compactCommandDescription, }, ], [t.inputBox.compactCommandDescription, t.inputBox.goalCommandDescription], ); const reportUploadLimitViolations = useCallback( (violations: UploadLimitViolation[]) => { for (const violation of violations) { if (violation.code === "max_file_size") { toast.error( t.uploads.filesTooLarge( violation.files.map((file) => file.name).join(", "), formatUploadSize(violation.limit), ), ); } else if (violation.code === "max_files") { toast.error( t.uploads.tooManyFiles(violation.files.length, violation.limit), ); } else { toast.error( t.uploads.totalSizeTooLarge( violation.files.length, formatUploadSize(violation.limit), ), ); } } }, [t.uploads], ); useEffect(() => { if (!uploadLimits) { return; } const attachmentEntries = attachmentParts.flatMap((attachment) => attachment.file instanceof File ? [{ id: attachment.id, file: attachment.file }] : [], ); const validation = validateUploadLimits( [], attachmentEntries.map(({ file }) => file), uploadLimits, ); if (validation.rejected.length === 0) { return; } const rejected = new Set(validation.rejected); for (const entry of attachmentEntries) { if (rejected.has(entry.file)) { removeAttachment(entry.id); } } reportUploadLimitViolations(validation.violations); }, [ attachmentParts, removeAttachment, reportUploadLimitViolations, uploadLimits, ]); useEffect(() => { if (models.length === 0) { return; } const currentModel = models.find((m) => m.name === context.model_name); // Prefer the active agent's configured default model over models[0] as the // auto-selection fallback, so an agent chat respects the agent's own // default instead of snapping to the first model (issue #4336). const agentDefaultModel = defaultModelName ? models.find((m) => m.name === defaultModelName) : undefined; const fallbackModel = currentModel ?? agentDefaultModel ?? models[0]!; const nextModelName = fallbackModel.name; const nextMode = getResolvedMode(context.mode, fallbackModel); // A remembered effort may not exist in this model's contract (issue #5073); // map it through the aliases / default instead of sending it verbatim. const nextEffort = resolveReasoningEffort( fallbackModel, context.reasoning_effort, ); if ( context.model_name === nextModelName && context.mode === nextMode && context.reasoning_effort === nextEffort ) { return; } onContextChange?.( { ...context, model_name: nextModelName, mode: nextMode, reasoning_effort: nextEffort, }, { automatic: true }, ); }, [context, models, defaultModelName, onContextChange]); const selectedModel = useMemo(() => { if (models.length === 0) { return undefined; } return models.find((m) => m.name === context.model_name) ?? models[0]; }, [context.model_name, models]); const resolvedModelName = selectedModel?.name; const supportThinking = useMemo( () => modelSupportsThinking(selectedModel), [selectedModel], ); const thinkingRequired = useMemo( () => isThinkingRequired(selectedModel), [selectedModel], ); // Effort choices come from the model's reasoning contract (issue #5073), so // a provider that only accepts e.g. low/high/max never sees a generic value. const reasoningEffortOptions = useMemo( () => getReasoningEffortOptions(selectedModel), [selectedModel], ); const supportReasoningEffort = reasoningEffortOptions.length > 0; const effectiveReasoningEffort = useMemo( () => resolveReasoningEffort( selectedModel, context.reasoning_effort ?? reasoningEffortForMode(context.mode ?? "pro", selectedModel), ), [context.mode, context.reasoning_effort, selectedModel], ); const reasoningEffortLabel = useCallback( (effort: string | undefined) => { switch (effort) { case "minimal": return t.inputBox.reasoningEffortMinimal; case "low": return t.inputBox.reasoningEffortLow; case "medium": return t.inputBox.reasoningEffortMedium; case "high": return t.inputBox.reasoningEffortHigh; case "xhigh": return t.inputBox.reasoningEffortXhigh; case "max": return t.inputBox.reasoningEffortMax; default: return effort ?? ""; } }, [t], ); const reasoningEffortDescription = useCallback( (effort: string) => { switch (effort) { case "minimal": return t.inputBox.reasoningEffortMinimalDescription; case "low": return t.inputBox.reasoningEffortLowDescription; case "medium": return t.inputBox.reasoningEffortMediumDescription; case "high": return t.inputBox.reasoningEffortHighDescription; case "xhigh": return t.inputBox.reasoningEffortXhighDescription; case "max": return t.inputBox.reasoningEffortMaxDescription; default: return null; } }, [t], ); const draftKey = useMemo( () => buildComposerDraftKey({ userId: user?.id ?? "anonymous", agentName: draftAgentName ?? (typeof context.agent_name === "string" ? context.agent_name : null), threadId: draftThreadId, }), [context.agent_name, draftAgentName, draftThreadId, user?.id], ); const agentScopedSkills = useMemo( () => agentSkillsLoading ? [] : filterSkillsForAgent(skills, agentSkillNames), [agentSkillNames, agentSkillsLoading, skills], ); const mentionSkills = useMemo( () => agentScopedSkills.filter( (skill) => parseSlashSkillReference(`/${skill.name}`)?.name === skill.name, ), [agentScopedSkills], ); const enabledSkillNames = useMemo( () => new Set( mentionSkills .filter((skill) => skill.enabled) .map((skill) => skill.name), ), [mentionSkills], ); const cancelDraftSaveTimer = useCallback(() => { if (draftSaveTimerRef.current === null) { return; } window.clearTimeout(draftSaveTimerRef.current); draftSaveTimerRef.current = null; }, []); const invalidateDraftSaveTimer = useCallback(() => { draftSaveGenerationRef.current += 1; cancelDraftSaveTimer(); }, [cancelDraftSaveTimer]); const scheduleDraftSave = useCallback( (draft: ComposerDraft, key = draftKey) => { // An accepted attachment send keeps its text visible until upload finishes. // Reference cleanup can rerun this effect meanwhile; do not resurrect the // accepted snapshot. Actual input edits release it, even for identical text. const accepted = acceptedDraftRef.current; if ( accepted?.key === key && accepted.text === draft.text && accepted.skillName === draft.skillName ) return null; if (accepted?.key === key && !draft.text && !draft.skillName) { acceptedDraftRef.current = null; } if ( !draft.text && !draft.skillName && pendingDraftSubmissionRef.current?.key === key ) { return null; } const pending = pendingDraftSubmissionRef.current; if ( pending?.key === key && (draft.text !== pending.text || draft.skillName !== pending.skillName) ) { pendingDraftSubmissionRef.current = null; } const reconciled = reconcileConversationReferences( draft.text, conversationReferences, conversationCapability, threadId, ); draft = { ...draft, text: reconciled.text, conversationReferences: reconciled.references, }; latestDraftRef.current = { key, draft }; cancelDraftSaveTimer(); draftSaveGenerationRef.current += 1; const generation = draftSaveGenerationRef.current; const timer = window.setTimeout(() => { if ( draftSaveGenerationRef.current !== generation || draftSaveTimerRef.current !== timer ) { return; } draftSaveTimerRef.current = null; writeComposerDraft(getSessionComposerDraftStorage(), key, draft); }, COMPOSER_DRAFT_SAVE_DELAY_MS); draftSaveTimerRef.current = timer; return timer; }, [ cancelDraftSaveTimer, draftKey, conversationReferences, conversationCapability, threadId, ], ); const flushLatestDraft = useCallback( (expectedKey?: string) => { const latest = latestDraftRef.current; if (!latest || (expectedKey && latest.key !== expectedKey)) { return; } cancelDraftSaveTimer(); writeComposerDraft( getSessionComposerDraftStorage(), latest.key, latest.draft, ); }, [cancelDraftSaveTimer], ); const promptHistory = useMemo(() => { const history: string[] = []; for (const message of thread.messages) { if (message.type !== "human") { continue; } const additionalKwargs = message.additional_kwargs; if ( additionalKwargs && typeof additionalKwargs === "object" && Reflect.get(additionalKwargs, "hide_from_ui") === true ) { continue; } const text = textOfMessage(message)?.trim(); if (!text) { continue; } if (history.at(-1) !== text) { history.push(text); } } return history; }, [thread.messages]); useLayoutEffect(() => { promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; setTextInput(""); conversationReferenceCache.current.clear(); setConversationReferences([]); setMentionQuery(null); setMentionButtonOpen(false); setMentionError(null); setMentionBusy(false); mentionInFlight.current = false; mentionEpoch.current += 1; setInputPolishUndo(null); setHydratedDraftKey(null); pendingDraftSubmissionRef.current = null; acceptedDraftRef.current = null; latestDraftRef.current = null; invalidateDraftSaveTimer(); return () => { mentionEpoch.current += 1; flushLatestDraft(draftKey); }; }, [draftKey, flushLatestDraft, invalidateDraftSaveTimer, setTextInput]); useLayoutEffect(() => { const handlePageHide = () => flushLatestDraft(); window.addEventListener("pagehide", handlePageHide); return () => window.removeEventListener("pagehide", handlePageHide); }, [flushLatestDraft]); useEffect(() => { if (skillsLoading || agentSkillsLoading || hydratedDraftKey === draftKey) { return; } const savedDraft = readComposerDraft( getSessionComposerDraftStorage(), draftKey, ); if (!savedDraft) { if (!textInput.value && initialValue) { setTextInput(initialValue); } setHydratedDraftKey(draftKey); return; } const resolvedDraft = resolveComposerDraft(savedDraft, enabledSkillNames); const restoredText = resolvedDraft.skillName && !inlineReferences(resolvedDraft.text).some( (ref) => ref.kind === "skill" && ref.id === resolvedDraft.skillName, ) ? referenceToken( "skill", resolvedDraft.skillName, resolvedDraft.skillName, ) + (resolvedDraft.text ? ` ${resolvedDraft.text}` : "") : resolvedDraft.text; setConversationReferences(savedDraft.conversationReferences ?? []); const existingReferences = inlineReferences(restoredText); const legacyReferences = (savedDraft.conversationReferences ?? []).filter( (ref) => !existingReferences.some( (item) => item.kind === "conversation" && item.id === ref.threadId, ), ); setTextInput( legacyReferences .map( (ref) => referenceToken("conversation", ref.threadId, ref.title) + " ", ) .join("") + restoredText, ); setHydratedDraftKey(draftKey); }, [ draftKey, enabledSkillNames, hydratedDraftKey, initialValue, setTextInput, agentScopedSkills, agentSkillsLoading, skillsLoading, textInput.value, ]); useEffect(() => { if (hydratedDraftKey !== draftKey) { return; } const draft: ComposerDraft = { text: textInput.value ?? "", skillName: null, }; const timer = scheduleDraftSave(draft, draftKey); return () => { if (timer === null) { return; } window.clearTimeout(timer); if (draftSaveTimerRef.current === timer) { draftSaveTimerRef.current = null; } }; }, [draftKey, hydratedDraftKey, scheduleDraftSave, textInput.value]); useEffect(() => { const goalRequestState = goalRequestStateRef.current; const compactRequestState = compactRequestStateRef.current; return () => { abortGoalRequest(goalRequestState); abortGoalRequest(compactRequestState); }; }, [threadId]); const abortInputPolishRequest = useCallback(() => { inputPolishRequestRef.current.controller?.abort(); inputPolishRequestRef.current.controller = null; inputPolishRequestRef.current.sequence += 1; setPolishingInput(false); }, []); useEffect(() => { return () => abortInputPolishRequest(); }, [abortInputPolishRequest, threadId]); useEffect(() => { const currentIndex = promptHistoryIndexRef.current; if (currentIndex !== null && currentIndex >= promptHistory.length) { promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; } }, [promptHistory.length]); const handleModelSelect = useCallback( (model_name: string) => { if (disabled || polishingInput) { return; } const model = models.find((m) => m.name === model_name); if (!model) { return; } const mode = getResolvedMode(context.mode, model); const reasoning_effort = resolveReasoningEffort( model, context.reasoning_effort, ); onContextChange?.({ model_name, ...(mode !== context.mode ? { mode } : {}), ...(reasoning_effort !== context.reasoning_effort ? { reasoning_effort } : {}), }); setModelDialogOpen(false); }, [disabled, onContextChange, context, models, polishingInput], ); const handleModeSelect = useCallback( (mode: InputMode) => { if (disabled || polishingInput) { return; } const nextMode = getResolvedMode(mode, selectedModel); onContextChange?.({ mode: nextMode, reasoning_effort: reasoningEffortForMode(nextMode, selectedModel), }); }, [disabled, onContextChange, polishingInput, selectedModel], ); const handleReasoningEffortSelect = useCallback( (effort: ReasoningEffortValue) => { if (disabled || polishingInput) { return; } onContextChange?.({ reasoning_effort: effort, }); }, [disabled, onContextChange, polishingInput], ); const handleGoalCommand = useCallback( async (command: GoalCommand): Promise => { const request = beginGoalRequest(goalRequestStateRef.current, threadId); const signal = request.controller.signal; try { let goal: GoalState | null = null; if (command.kind === "status") { const response = await fetch( `${getBackendBaseURL()}/api/threads/${encodeURIComponent( threadId, )}/goal`, { method: "GET", signal }, ); if (!response.ok) { throw new Error(await readGoalResponseError(response)); } goal = ((await response.json()) as { goal?: GoalState | null }).goal ?? null; if ( !isCurrentGoalRequest( goalRequestStateRef.current, request, threadId, ) ) { return false; } const objective = goal?.objective; toast.info( objective !== undefined ? // Function replacer so a goal containing `$&`/`$1` isn't // interpreted as a replacement pattern. t.inputBox.goalActive.replace("{goal}", () => objective) : t.inputBox.goalNone, ); onGoalChange?.(goal); } else if (command.kind === "clear") { const response = await fetch( `${getBackendBaseURL()}/api/threads/${encodeURIComponent( threadId, )}/goal`, { method: "DELETE", signal }, ); if (!response.ok) { throw new Error(await readGoalResponseError(response)); } if ( !isCurrentGoalRequest( goalRequestStateRef.current, request, threadId, ) ) { return false; } toast.success(t.inputBox.goalCleared); onGoalChange?.(null); } else { const response = await fetch( `${getBackendBaseURL()}/api/threads/${encodeURIComponent( threadId, )}/goal`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ objective: command.objective }), signal, }, ); if (!response.ok) { throw new Error(await readGoalResponseError(response)); } goal = ((await response.json()) as { goal?: GoalState | null }).goal ?? null; if ( !isCurrentGoalRequest( goalRequestStateRef.current, request, threadId, ) ) { return false; } toast.success(t.inputBox.goalSet); onGoalChange?.(goal); } textInput.setInput(""); return true; } catch (error) { if ( isAbortError(error) || !isCurrentGoalRequest(goalRequestStateRef.current, request, threadId) ) { return false; } toast.error( error instanceof Error ? error.message : t.inputBox.goalFailed, ); return false; } finally { finishGoalRequest(goalRequestStateRef.current, request); } }, [ onGoalChange, t.inputBox.goalActive, t.inputBox.goalCleared, t.inputBox.goalFailed, t.inputBox.goalNone, t.inputBox.goalSet, textInput, threadId, ], ); const handleCompactCommand = useCallback(async (): Promise => { if (isWelcomeMode) { textInput.setInput(""); toast.info(t.inputBox.compactSkipped); return; } const request = beginGoalRequest(compactRequestStateRef.current, threadId); const signal = request.controller.signal; try { const result = await compactThreadContext(threadId, { signal, agentName: typeof context.agent_name === "string" ? context.agent_name : null, modelName: typeof context.model_name === "string" ? context.model_name : null, }); if ( !isCurrentGoalRequest(compactRequestStateRef.current, request, threadId) ) { return; } textInput.setInput(""); promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; setFollowups([]); setFollowupsHidden(false); setFollowupsLoading(false); void queryClient.invalidateQueries({ queryKey: ["thread", threadId] }); void queryClient.invalidateQueries({ queryKey: threadTokenUsageQueryKey(threadId), }); if (result.compacted) { toast.success(t.inputBox.compactSuccess); } else { toast.info(t.inputBox.compactSkipped); } } catch (error) { if ( isAbortError(error) || !isCurrentGoalRequest(compactRequestStateRef.current, request, threadId) ) { return; } toast.error( error instanceof Error ? error.message : t.inputBox.compactFailed, ); } finally { finishGoalRequest(compactRequestStateRef.current, request); } }, [ context.agent_name, context.model_name, queryClient, t.inputBox.compactFailed, t.inputBox.compactSkipped, t.inputBox.compactSuccess, isWelcomeMode, textInput, threadId, ]); const submitThreadMessage = useCallback( (message: PromptInputMessage) => { const files = message.files.flatMap((file) => file.file instanceof File ? [file.file] : [], ); const uploadValidation = validateUploadLimits([], files, uploadLimits); if (uploadValidation.violations.length > 0) { reportUploadLimitViolations(uploadValidation.violations); return Promise.reject(new Error("Attachment limits exceeded.")); } const placeholder = findSuggestionTemplatePlaceholder(message.text); if (placeholder) { toast.warning(t.inputBox.suggestionPlaceholderRequired); requestAnimationFrame(() => { const textarea = textareaRef.current; if (!textarea) { return; } textarea.focus(); textarea.setSelectionRange(placeholder.start, placeholder.end); }); return Promise.reject( new Error("Suggestion template placeholder is unresolved."), ); } promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; setInputPolishUndo(null); setFollowups([]); setFollowupsHidden(false); setFollowupsLoading(false); const quotes = sidecar?.conversationQuotes ?? []; const quoteIds = quotes.map((quote) => quote.id); const quoteContexts = quotes.map((quote) => quote.context); const currentReferences = inlineReferences(textInput.value); if ( (!conversationCapability.isSuccess || conversationCapability.isLoading) && currentReferences.some((ref) => ref.kind === "conversation") ) { return Promise.reject( new Error( "Conversation capability is not available. Retry discovery before sending.", ), ); } const activeConversations = reconcileConversationReferences( textInput.value, conversationReferences, conversationCapability, threadId, ).references; const referenceIds = activeConversations.map( (reference) => reference.threadId, ); // Project-shelf attachments are already ingested thread-side (§9): // they join ``additional_kwargs.files`` as completed uploads without a // re-upload, and merge with any files uploaded in this send // (buildThreadSubmitMessages concatenates the two lists). const stagedFiles: FileInMessage[] = projectAttachments .filter( (file) => !file.source_document_id || currentReferences.some( (ref) => ref.kind === "file" && ref.id === file.source_document_id, ), ) .map((attachment) => ({ filename: attachment.filename, size: attachment.size_bytes, path: attachment.virtual_path, status: "uploaded" as const, })); const skillReferences = [ ...new Set( inlineReferences(textInput.value) .filter((ref) => ref.kind === "skill") .map((ref) => ref.id), ), ]; if (skillReferences.length > MAX_EXPLICIT_SKILLS) { toast.warning(t.inputBox.mentionMultipleSkills); return Promise.reject(new Error("Too many skill references.")); } const extensionMetadata = extensionMentionMetadata(textInput.value); if ( (extensionMetadata.extension_mentions?.length ?? 0) > MAX_EXTENSION_MENTIONS ) { toast.warning(t.inputBox.mentionExtensionsLimit); return Promise.reject(new Error("Too many extension mentions")); } pendingDraftSubmissionRef.current = { key: draftKey, text: textInput.value, skillName: null, }; const additionalKwargs = { ...extensionMetadata, ...(skillReferences.length ? { skill_references: skillReferences } : {}), ...(quotes.length ? buildReferenceMessageMetadata(quoteContexts) : {}), ...(referenceIds.length ? buildConversationReferenceMetadata(activeConversations) : {}), ...(stagedFiles.length > 0 ? { files: stagedFiles } : {}), }; const submitOptions: InputBoxSubmitOptions = { ...(Object.keys(additionalKwargs).length ? { additionalKwargs } : {}), ...(quotes.length ? { additionalInputMessages: [ buildHiddenConversationQuoteMessage({ contexts: quoteContexts, }), ], } : {}), ...(referenceIds.length ? { conversationReferences: referenceIds } : {}), // Clear one-time state only once the send genuinely proceeds. If the // send is dropped by the in-flight guard, `onSent` never fires. onSent: () => { setMentionQuery(null); setMentionButtonOpen(false); if (pendingDraftSubmissionRef.current?.key === draftKey) { acceptedDraftRef.current = { key: draftKey, text: textInput.value, skillName: null, }; pendingDraftSubmissionRef.current = null; latestDraftRef.current = null; invalidateDraftSaveTimer(); clearComposerDraft(getSessionComposerDraftStorage(), draftKey); } sidecar?.clearConversationQuotes(quoteIds); setConversationReferences([]); setProjectAttachments([]); }, }; const submit = () => onSubmit?.(message, submitOptions); // Guard against submitting before the initial model auto-selection // effect has flushed thread settings to storage/state. if (resolvedModelName && context.model_name !== resolvedModelName) { onContextChange?.( { ...context, model_name: resolvedModelName, mode: getResolvedMode(context.mode, selectedModel), }, { automatic: true }, ); return new Promise((resolve, reject) => { setTimeout(() => { Promise.resolve(submit()).then(resolve).catch(reject); }, 0); }); } return submit(); }, [ context, textInput.value, conversationReferences, draftKey, invalidateDraftSaveTimer, onContextChange, onSubmit, projectAttachments, setProjectAttachments, reportUploadLimitViolations, resolvedModelName, selectedModel, sidecar, t.inputBox.suggestionPlaceholderRequired, t.inputBox.mentionMultipleSkills, t.inputBox.mentionExtensionsLimit, conversationCapability, threadId, uploadLimits, ], ); const handleStopStreaming = useCallback(() => { // Roles denied runs:cancel must not interrupt the in-progress turn — // the Gateway would 403 the cancel anyway. The submit-button click is // the only live entry point today (handleSubmit returns early with the // pleaseWaitStreaming toast while streaming), but gate in the handler // as defense-in-depth so any future stop path is covered too. if (!canStopStreaming) { return; } // Mark the in-progress turn as user-interrupted so the next // streaming->ready transition does not suggest follow-ups for it. stoppedByUserRef.current = true; setFollowups([]); setFollowupsHidden(true); setFollowupsLoading(false); onStop?.(); }, [canStopStreaming, onStop]); const handleSubmit = useCallback( async (message: PromptInputMessage) => { if (mentionInFlight.current) return Promise.reject(new Error("Reference is loading")); if (status === "streaming") { toast.info(t.inputBox.pleaseWaitStreaming); return Promise.reject(new Error("streaming")); } abortVoiceInput(); message = { ...message, text: readableReferences(message.text) }; const submitAction = getInputSubmitAction({ text: message.text, // Staged project-shelf attachments count exactly like uploaded // files: submitThreadMessage maps them into the outgoing message's // ``additional_kwargs.files``, so an attachment-only submit must not // read as empty, and /goal or /compact must not intercept while an // attach chip or explicit conversation reference is present. fileCount: message.files.length + projectAttachments.length + conversationReferences.length + inlineRefs.length, status, }); // Check run-starting actions before goal preparation or persistence: // saving a goal also clears the draft and announces success. Status, // clear, and compact commands do not start runs and keep their own gates. if ( !canCreateRuns && (submitAction.kind === "message" || (submitAction.kind === "goal" && submitAction.command.kind === "set")) ) { toast.info(t.inputBox.startTurnUnavailable); return Promise.reject(new Error("runs-create-denied")); } if (submitAction.kind === "goal") { if ( submitAction.command.kind === "set" && isGoalObjectiveTooLong(submitAction.command.objective) ) { toast.error( t.inputBox.goalTooLong.replace("{max}", () => String(MAX_GOAL_OBJECTIVE_CHARS), ), ); // Reject so the composer keeps the user's text for editing instead of // clearing it (PromptInput only preserves input on a rejected submit). return Promise.reject(new Error("goal-too-long")); } if (submitAction.command.kind === "set") { // A goal-set PUT creates the thread server-side when missing, so a // project-scoped new chat must assign membership first. The prepare // callback toasts its own failure; reject so the composer keeps the // text for a retry instead of issuing an unassigned goal. // // Fence against conversation switches while preparation runs: the // goal PUT registers its AbortController only when it starts, so // the thread-change/unmount cleanup cannot cancel an in-flight // prepare. Capture the goal-request epoch before the await — the // cleanup bumps it via abortGoalRequest — and drop the stale // continuation before it can clear the new conversation's composer // or launch the abandoned submission. const requestEpoch = goalRequestStateRef.current.sequence; try { await onPrepareThread?.(); } catch (error) { return Promise.reject( error instanceof Error ? error : new Error("thread preparation failed"), ); } if (goalRequestStateRef.current.sequence !== requestEpoch) { // Reject (not resolve) so PromptInput keeps the current // conversation's composer text untouched. return Promise.reject(new Error("goal-preparation-stale")); } } promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; setFollowups([]); setFollowupsHidden(false); setFollowupsLoading(false); const saved = await handleGoalCommand(submitAction.command); // Only start a run when a goal was actually saved; status/clear never run. if (saved && submitAction.command.kind === "set") { return submitThreadMessage({ ...message, text: submitAction.command.objective, files: [], }); } return; } if (submitAction.kind === "compact") { return handleCompactCommand(); } if (submitAction.kind === "empty") { return; } await submitThreadMessage(message); }, [ abortVoiceInput, canCreateRuns, handleCompactCommand, handleGoalCommand, onPrepareThread, projectAttachments.length, conversationReferences.length, inlineRefs.length, status, submitThreadMessage, t.inputBox.goalTooLong, t.inputBox.pleaseWaitStreaming, t.inputBox.startTurnUnavailable, ], ); const requestFormSubmit = useCallback(() => { const form = promptRootRef.current?.querySelector("form"); form?.requestSubmit(); }, []); const handleFollowupClick = useCallback( (suggestion: string) => { if (status === "streaming") { return; } const current = (textInput.value ?? "").trim(); if (current) { setPendingSuggestion(suggestion); setConfirmOpen(true); return; } textInput.setInput(suggestion); setFollowupsHidden(true); setTimeout(() => requestFormSubmit(), 0); }, [requestFormSubmit, status, textInput], ); const confirmReplaceAndSend = useCallback(() => { if (!pendingSuggestion) { setConfirmOpen(false); return; } textInput.setInput(pendingSuggestion); setFollowupsHidden(true); setConfirmOpen(false); setPendingSuggestion(null); setTimeout(() => requestFormSubmit(), 0); }, [pendingSuggestion, requestFormSubmit, textInput]); const confirmAppendAndSend = useCallback(() => { if (!pendingSuggestion) { setConfirmOpen(false); return; } const current = (textInput.value ?? "").trim(); const next = current ? `${current}\n${pendingSuggestion}` : pendingSuggestion; textInput.setInput(next); setFollowupsHidden(true); setConfirmOpen(false); setPendingSuggestion(null); setTimeout(() => requestFormSubmit(), 0); }, [pendingSuggestion, requestFormSubmit, textInput]); const slashCommandQuery = useMemo( () => getLeadingSlashCommandQuery(textInput.value ?? ""), [textInput.value], ); const goalObjectiveCounter = useMemo( () => getGoalObjectiveCounter(textInput.value ?? ""), [textInput.value], ); const commandSuggestions = useMemo( () => slashCommandQuery === null || hasInlineReferences ? [] : getMatchingSlashCommands(slashCommandQuery, builtinSlashCommands), [builtinSlashCommands, hasInlineReferences, slashCommandQuery], ); const showCommandSuggestions = !disabled && textareaFocused && slashCommandQuery !== null && commandSuggestions.length > 0 && dismissedCommandSuggestionValue !== textInput.value; const isComposerDisabled = disabled === true; const isMockThread = isMock === true; const composerLocked = isComposerDisabled || polishingInput || mentionBusy; const showMentions = !disabled && !polishingInput && (mentionButtonOpen || mentionQuery !== null); useLayoutEffect(() => { if (!showMentions) return; const place = () => { const box = promptRootRef.current?.getBoundingClientRect(); if (!box) return; const top = box.top - 12; const bottom = window.innerHeight - box.bottom - 12; const above = top >= bottom; setMentionPlacement({ above, maxHeight: Math.max(100, above ? top : bottom), }); }; place(); window.addEventListener("resize", place); window.addEventListener("scroll", place, true); return () => { window.removeEventListener("resize", place); window.removeEventListener("scroll", place, true); }; }, [showMentions]); const closeMentions = useCallback(() => { if (mentionInFlight.current) return; dismissedMention.current = `${textInput.value}:${mentionQuery?.end}`; setMentionQuery(null); setMentionButtonOpen(false); setMentionError(null); }, [textInput.value, mentionQuery]); const openMentions = useCallback(() => { dismissedMention.current = null; const caret = inlineSkillTextRef.current ? referenceCaret(inlineSkillTextRef.current) : textareaRef.current?.selectionStart; if (caret != null) composerCaretRef.current = caret; setMentionQuery(null); setMentionButtonOpen(true); setMentionError(null); }, []); const updateMentionQuery = useCallback( (value: string, caret: number | null) => { if (caret !== null) composerCaretRef.current = caret; if (dismissedMention.current === `${value}:${caret}`) return; dismissedMention.current = null; setMentionButtonOpen(false); setMentionError(null); setMentionQuery(caret === null ? null : getMentionQuery(value, caret)); }, [], ); const focusComposerAt = useCallback((offset: number) => { requestAnimationFrame(() => { const textarea = textareaRef.current; if (textarea) { textarea.focus(); textarea.setSelectionRange(offset, offset); } else if (inlineSkillTextRef.current) focusReferenceAt(inlineSkillTextRef.current, offset); }); }, []); const selectMention = useCallback( async (selection: MentionSelection) => { if (mentionInFlight.current || disabled || polishingInput) return; const epoch = mentionEpoch.current; const originalText = textInput.value; const caret = mentionQuery?.start ?? Math.min(composerCaretRef.current, originalText.length); const selectionId = selection.kind === "skill" ? selection.skill.name : selection.kind === "conversation" ? selection.reference.threadId : selection.kind === "extension" ? extensionMentionId(selection.reference) : null; const selected = selectionId && inlineReferences(originalText).some( (ref) => ref.kind === selection.kind && ref.id === selectionId, ); if (selected) { let next = mentionQuery ? originalText.slice(0, mentionQuery.start) + originalText.slice(mentionQuery.end) : originalText; for (const ref of inlineReferences(next).reverse()) { if (ref.kind === selection.kind && ref.id === selectionId) next = next.slice(0, ref.start) + next.slice(ref.end); } if (selection.kind === "conversation") setConversationReferences((previous) => previous.filter((ref) => ref.threadId !== selectionId), ); textInput.setInput(next); setMentionQuery(null); setMentionButtonOpen(false); focusComposerAt(Math.min(caret, next.length)); return; } let token = ""; if (selection.kind === "skill") token = referenceToken( "skill", selection.skill.name, selection.skill.name, ); if (selection.kind === "conversation") token = referenceToken( "conversation", selection.reference.threadId, selection.reference.title, ); if (selection.kind === "extension") token = referenceToken( "extension", extensionMentionId(selection.reference), selection.reference.label, ); if (selection.kind === "file") token = referenceToken( "file", selection.document.id, selection.document.name, ); const inserted = token ? token + " " : ""; const nextText = originalText.slice(0, caret) + inserted + originalText.slice(mentionQuery?.end ?? caret); if (selection.kind === "file") { if (!projectId) return; const duplicate = projectAttachments.some( (file) => file.source_document_id === selection.document.id && file.source_project_id === projectId, ); if (!duplicate) { const allSizes = [ ...projectAttachments.map((file) => file.size_bytes), ...attachments.files.map((file) => file.file?.size ?? 0), selection.document.size_bytes, ]; if ( uploadLimits && (allSizes.length > uploadLimits.max_files || allSizes.reduce((a, b) => a + b, 0) > uploadLimits.max_total_size || selection.document.size_bytes > uploadLimits.max_file_size) ) { setMentionError( t.uploads.limitsHint( uploadLimits.max_files, formatUploadSize(uploadLimits.max_file_size), formatUploadSize(uploadLimits.max_total_size), ), ); return; } mentionInFlight.current = true; setMentionBusy(true); setMentionError(null); try { await onPrepareThread?.(); if (epoch !== mentionEpoch.current) return; const attached = await attachProjectDocument( projectId, selection.document.id, threadId, ); if (epoch !== mentionEpoch.current) return; setProjectAttachments((previous) => [ ...previous.filter( (file) => file.virtual_path !== attached.virtual_path, ), { ...attached, source_document_id: selection.document.id, source_project_id: projectId, }, ]); } catch { if (epoch === mentionEpoch.current) setMentionError(t.inputBox.mentionAttachFailed); return; } finally { if (epoch === mentionEpoch.current) { mentionInFlight.current = false; setMentionBusy(false); } } } } else if (selection.kind === "conversation") { setConversationReferences((previous) => previous.some( (item) => item.threadId === selection.reference.threadId, ) ? previous : [...previous, selection.reference], ); } else if (selection.kind === "upload") { // Keep the native file dialog in the user gesture for browser permission. attachments.openFileDialog(); } if ( selection.kind === "file" && onReferenceFileAttached && draftThreadId !== threadId ) { // A prepared new conversation now owns real files. Move its draft to // the durable identity before the page replaces /new with that ID. const storage = getSessionComposerDraftStorage(); const nextKey = buildComposerDraftKey({ userId: user?.id ?? "anonymous", agentName: draftAgentName ?? (typeof context.agent_name === "string" ? context.agent_name : null), threadId, }); writeComposerDraft(storage, nextKey, { text: nextText, skillName: null, ...(conversationReferences.length ? { conversationReferences } : {}), }); invalidateDraftSaveTimer(); latestDraftRef.current = null; clearComposerDraft(storage, draftKey); } textInput.setInput(nextText); setMentionQuery(null); setMentionButtonOpen(false); setMentionError(null); focusComposerAt(caret + inserted.length); if (selection.kind === "file") onReferenceFileAttached?.(); }, [ attachments, context.agent_name, conversationReferences, draftAgentName, draftKey, draftThreadId, invalidateDraftSaveTimer, onReferenceFileAttached, user?.id, disabled, focusComposerAt, mentionQuery, onPrepareThread, polishingInput, projectAttachments, projectId, setProjectAttachments, t, textInput, threadId, uploadLimits, ], ); useEffect(() => { if (!showMentions) return; const dismiss = (event: PointerEvent) => { if ( event.target instanceof Node && !promptRootRef.current?.contains(event.target) ) closeMentions(); }; document.addEventListener("pointerdown", dismiss); return () => document.removeEventListener("pointerdown", dismiss); }, [showMentions, closeMentions]); // A denied runs:cancel role sees a disabled stop affordance, not a removed // one — the composer must still show that a turn is in flight. const stopDenied = status === "streaming" && !canStopStreaming; // Mirror for runs:create on the send side. While streaming the button is // the stop affordance (gated above), so the send denial only applies to // the send state. const sendDenied = status !== "streaming" && !canCreateRuns; const inputPolishUndoAvailable = !polishingInput && inputPolishUndo !== null && (textInput.value ?? "") === inputPolishUndo.rewrittenText; const inputPolishDisabled = isComposerDisabled || isMockThread || polishingInput || (!inputPolishUndoAvailable && (status === "streaming" || slashCommandQuery !== null || !canPolishInput(textInput.value ?? ""))); const speechRecognitionConstructor = useMemo( () => typeof window === "undefined" ? null : getSpeechRecognitionConstructor(window), [], ); const voiceInputSupported = speechRecognitionConstructor !== null; const getVoiceInputErrorMessage = useCallback( (kind: SpeechRecognitionErrorKind) => { switch (kind) { case "permission_denied": return t.inputBox.voiceInputPermissionDenied; case "microphone_unavailable": return t.inputBox.voiceInputMicrophoneUnavailable; case "unsupported_language": return t.inputBox.voiceInputUnsupportedLanguage; case "network": return t.inputBox.voiceInputNetworkError; case "no_speech": return t.inputBox.voiceInputNoSpeech; case "cancelled": return null; default: return t.inputBox.voiceInputFailed; } }, [t], ); const startVoiceRecognition = useCallback( (options: VoiceRecognitionStartOptions = {}) => { if (composerLocked || !speechRecognitionConstructor) { return false; } const recognition = new speechRecognitionConstructor(); recognition.continuous = true; recognition.interimResults = true; recognition.lang = getSpeechRecognitionLanguage(locale); recognition.maxAlternatives = 1; voiceLastErrorKindRef.current = null; voiceLatestTextRef.current = voiceBaseTextRef.current; voiceRecognitionRef.current = recognition; recognition.onresult = (event) => { if (voiceRecognitionRef.current !== recognition) { return; } const transcript = readSpeechRecognitionTranscript(event.results).text; const nextValue = appendSpeechTranscript( voiceBaseTextRef.current, transcript, ); voiceLatestTextRef.current = nextValue; textInput.setInput(nextValue); }; recognition.onerror = (event) => { const errorKind = mapSpeechRecognitionError(event.error); voiceLastErrorKindRef.current = errorKind; if ( !voiceStopRequestedRef.current && shouldRestartSpeechRecognition(errorKind) ) { return; } const message = getVoiceInputErrorMessage(errorKind); if (message) { toast.error(message); } }; recognition.onend = () => { const shouldRestart = voiceRecognitionRef.current === recognition && !voiceStopRequestedRef.current && shouldRestartSpeechRecognition(voiceLastErrorKindRef.current); if (shouldRestart) { voiceBaseTextRef.current = voiceLatestTextRef.current; cleanupVoiceRecognition(recognition, { keepListening: true }); voiceRestartTimerRef.current = setTimeout(() => { voiceRestartTimerRef.current = null; if (voiceStopRequestedRef.current) { cleanupVoiceRecognition(null); return; } const restarted = startVoiceRecognitionRef.current?.() ?? false; if (!restarted) { cleanupVoiceRecognition(null); } }, 150); return; } cleanupVoiceRecognition(recognition); }; setVoiceListening(true); try { recognition.start(); if (options.focusAfterStart) { requestAnimationFrame(() => { if (inlineSkillTextRef.current) { focusContentEditableEnd(inlineSkillTextRef.current); } else { textareaRef.current?.focus(); } }); } return true; } catch { cleanupVoiceRecognition(recognition); toast.error(t.inputBox.voiceInputFailed); return false; } }, [ cleanupVoiceRecognition, composerLocked, getVoiceInputErrorMessage, locale, speechRecognitionConstructor, t.inputBox.voiceInputFailed, textInput, ], ); useEffect(() => { startVoiceRecognitionRef.current = startVoiceRecognition; }, [startVoiceRecognition]); const stopVoiceInput = useCallback(() => { const recognition = voiceRecognitionRef.current; voiceStopRequestedRef.current = true; if (!recognition) { cleanupVoiceRecognition(null); return; } try { recognition.stop(); } catch { cleanupVoiceRecognition(recognition); } }, [cleanupVoiceRecognition]); const toggleVoiceInput = useCallback(() => { if (voiceListening) { stopVoiceInput(); return; } if (composerLocked) { return; } if (!speechRecognitionConstructor) { toast.error(t.inputBox.voiceInputUnsupported); return; } abortInputPolishRequest(); setInputPolishUndo(null); promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; voiceStopRequestedRef.current = false; voiceBaseTextRef.current = textInput.value ?? ""; voiceLatestTextRef.current = voiceBaseTextRef.current; startVoiceRecognition({ focusAfterStart: true }); }, [ abortInputPolishRequest, composerLocked, speechRecognitionConstructor, startVoiceRecognition, stopVoiceInput, t.inputBox.voiceInputUnsupported, textInput, voiceListening, ]); useEffect(() => { if (composerLocked && voiceListening) { stopVoiceInput(); } }, [composerLocked, stopVoiceInput, voiceListening]); useEffect(() => { return () => abortVoiceInput(); }, [abortVoiceInput, threadId]); useEffect(() => { setCommandSuggestionIndex(0); }, [slashCommandQuery, commandSuggestions.length]); const applyCommandSuggestion = useCallback( (suggestion: SlashCommandSuggestion) => { const nextValue = `/${suggestion.name} `; // Commit the controlled text before moving the caret so React's selection // restoration cannot put fast typing back inside the command name. flushSync(() => { textInput.setInput(nextValue); setDismissedCommandSuggestionValue(nextValue); }); const editor = inlineSkillTextRef.current; if (editor) { focusReferenceAt(editor, nextValue.length); } else { focusComposerAt(nextValue.length); } }, [focusComposerAt, textInput], ); const handleCommandSuggestionKeyDown = useCallback( (event: KeyboardEvent) => { if (!showCommandSuggestions) { return; } if (event.key === "ArrowDown") { event.preventDefault(); setCommandSuggestionIndex( (index) => (index + 1) % commandSuggestions.length, ); return; } if (event.key === "ArrowUp") { event.preventDefault(); setCommandSuggestionIndex( (index) => (index - 1 + commandSuggestions.length) % commandSuggestions.length, ); return; } if (event.key === "Enter" || event.key === "Tab") { if (event.shiftKey) { return; } event.preventDefault(); const selectedCommand = commandSuggestions[commandSuggestionIndex]; if (selectedCommand) { applyCommandSuggestion(selectedCommand); } return; } if (event.key === "Escape") { event.preventDefault(); setDismissedCommandSuggestionValue(textInput.value); } }, [ applyCommandSuggestion, showCommandSuggestions, commandSuggestionIndex, commandSuggestions, textInput.value, ], ); const setPromptHistoryValue = useCallback( (value: string) => { textInput.setInput(value); requestAnimationFrame(() => { if (inlineSkillTextRef.current) focusReferenceAt(inlineSkillTextRef.current, value.length); else { const textarea = textareaRef.current; textarea?.focus(); textarea?.setSelectionRange(value.length, value.length); } }); }, [textInput], ); const handlePolishInput = useCallback(async () => { if (inputPolishDisabled) { return; } const originalText = textInput.value ?? ""; const controller = new AbortController(); inputPolishRequestRef.current.controller?.abort(); const sequence = inputPolishRequestRef.current.sequence + 1; inputPolishRequestRef.current = { controller, sequence, }; setPolishingInput(true); try { const result = await polishInputDraft( { text: readableReferences(originalText), locale, thread_id: threadId, }, { signal: controller.signal }, ); const isCurrentRequest = inputPolishRequestRef.current.controller === controller && inputPolishRequestRef.current.sequence === sequence && !controller.signal.aborted; if (!isCurrentRequest || (textInput.value ?? "") !== originalText) { return; } const rewrittenText = restoreReferenceLabels( originalText, result.rewritten_text.trim(), ); if (!rewrittenText || !result.changed) { toast.info(t.inputBox.inputPolishNoChanges); return; } // Applying the rewrite replaces the draft outside the textarea change // handler, so clear any in-progress history browse state; otherwise a // stale index would let the next ArrowDown overwrite the rewrite. promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; setPromptHistoryValue(rewrittenText); setInputPolishUndo({ originalText, rewrittenText, }); } catch (error) { const isCurrentRequest = inputPolishRequestRef.current.controller === controller && inputPolishRequestRef.current.sequence === sequence; if (isAbortError(error) || !isCurrentRequest) { return; } toast.error( error instanceof Error ? error.message : t.inputBox.inputPolishFailed, ); } finally { if ( inputPolishRequestRef.current.controller === controller && inputPolishRequestRef.current.sequence === sequence ) { inputPolishRequestRef.current.controller = null; setPolishingInput(false); } } }, [ inputPolishDisabled, locale, setPromptHistoryValue, t.inputBox.inputPolishFailed, t.inputBox.inputPolishNoChanges, textInput, threadId, ]); const handleUndoInputPolish = useCallback(() => { if (!inputPolishUndoAvailable || inputPolishUndo === null) { return; } promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; setPromptHistoryValue(inputPolishUndo.originalText); setInputPolishUndo(null); }, [inputPolishUndo, inputPolishUndoAvailable, setPromptHistoryValue]); const handlePromptHistoryKeyDown = useCallback( (event: KeyboardEvent) => { if ( event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || isIMEComposing(event) || promptHistory.length === 0 || (event.key !== "ArrowUp" && event.key !== "ArrowDown") ) { return; } const currentValue = textInput.value ?? ""; const currentHistoryIndex = promptHistoryIndexRef.current; const isBrowsingHistory = currentHistoryIndex !== null; if (!isBrowsingHistory && currentValue.length > 0) { return; } if (event.key === "ArrowUp") { event.preventDefault(); const nextIndex = isBrowsingHistory ? Math.max(currentHistoryIndex - 1, 0) : promptHistory.length - 1; if (!isBrowsingHistory) { promptHistoryDraftRef.current = currentValue; } promptHistoryIndexRef.current = nextIndex; setPromptHistoryValue(promptHistory[nextIndex] ?? ""); return; } if (!isBrowsingHistory) { return; } event.preventDefault(); if (currentHistoryIndex >= promptHistory.length - 1) { promptHistoryIndexRef.current = null; setPromptHistoryValue(promptHistoryDraftRef.current); promptHistoryDraftRef.current = ""; return; } const nextIndex = currentHistoryIndex + 1; promptHistoryIndexRef.current = nextIndex; setPromptHistoryValue(promptHistory[nextIndex] ?? ""); }, [promptHistory, setPromptHistoryValue, textInput.value], ); const handlePromptTextareaKeyDown = useCallback( (event: KeyboardEvent) => { // Same rule as the inline-skill editor: the catalog's navigation keys must // win over Enter-to-submit, but not mid-composition, where Enter belongs // to the IME candidate rather than the list. Safari's confirming Enter // carries neither composition flag; PromptInputTextarea drops it before // this handler runs. if (!isIMEComposing(event)) { if (showMentions) mentionPickerRef.current?.onKeyDown(event); if (event.defaultPrevented) return; handleCommandSuggestionKeyDown(event); if (event.defaultPrevented) { return; } } if (event.defaultPrevented) { return; } handlePromptHistoryKeyDown(event); }, [showMentions, handlePromptHistoryKeyDown, handleCommandSuggestionKeyDown], ); const handlePromptTextareaChange = useCallback( (event: ChangeEvent) => { acceptedDraftRef.current = null; pendingDraftSubmissionRef.current = null; updateMentionQuery( event.currentTarget.value, event.currentTarget.selectionStart, ); if (voiceListening) { abortVoiceInput(); } abortInputPolishRequest(); setInputPolishUndo(null); promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; scheduleDraftSave({ text: event.currentTarget.value, skillName: null, }); }, [ abortInputPolishRequest, abortVoiceInput, scheduleDraftSave, updateMentionQuery, voiceListening, ], ); const updateInlineSkillTextInput = useCallback( (element: HTMLElement) => { acceptedDraftRef.current = null; pendingDraftSubmissionRef.current = null; if (voiceListening) { abortVoiceInput(); } promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; const nextText = readReferenceEditor(element); const refs = inlineReferences(nextText); setConversationReferences((previous) => { const known = new Map(conversationReferenceCache.current); for (const reference of previous) known.set(reference.threadId, reference); return reconcileConversationReferences( nextText, [...known.values()], conversationCapability, threadId, ).references; }); setProjectAttachments((previous) => { const ids = new Set( refs.filter((ref) => ref.kind === "file").map((ref) => ref.id), ); return [ ...previous.filter((file) => !file.source_document_id), ...[...ids].flatMap((id) => { const file = projectReferenceCache.current.get(id); return file ? [file] : []; }), ]; }); abortInputPolishRequest(); setInputPolishUndo(null); const caret = referenceCaret(element); textInput.setInput(nextText); updateMentionQuery(nextText, caret); // The inline editor stays mounted after its last reference is removed. // Keep its native caret instead of scheduling a stale position that can // overwrite the caret set by a subsequently selected command. scheduleDraftSave({ text: nextText, skillName: null, }); }, [ conversationCapability, threadId, abortVoiceInput, abortInputPolishRequest, setProjectAttachments, scheduleDraftSave, textInput, updateMentionQuery, voiceListening, ], ); useLayoutEffect(() => { if (hasInlineReferences) setInlineEditorActive(true); const element = inlineSkillTextRef.current; if (element && !inlineSkillComposingRef.current) renderReferenceEditor(element, textInput.value); }, [hasInlineReferences, textInput.value]); const handleInlineSkillInput = useCallback( (event: FormEvent) => { updateInlineSkillTextInput(event.currentTarget); }, [updateInlineSkillTextInput], ); const handleInlineSkillPaste = useCallback( (event: ClipboardEvent) => { const pastedFiles = Array.from(event.clipboardData.items) .filter((item) => item.kind === "file") .flatMap((item) => { const file = item.getAsFile(); return file ? [file] : []; }); if (pastedFiles.length > 0) { event.preventDefault(); const { accepted, message } = splitUnsupportedUploadFiles(pastedFiles); if (message) { toast.error(message); } if (accepted.length > 0) { attachments.add(accepted); } return; } const text = event.clipboardData.getData("text/plain"); if (!text) { return; } event.preventDefault(); if (insertPlainTextAtSelection(event.currentTarget, text)) { updateInlineSkillTextInput(event.currentTarget); } }, [attachments, updateInlineSkillTextInput], ); const handleInlineSkillKeyDown = useCallback( (event: KeyboardEvent) => { if (isCompositionConfirmEnter(event, inlineCompositionEndedAt.current)) { event.preventDefault(); return; } // The catalog can be reopened from here, so its navigation keys must win // over Enter-to-submit. Skip it mid-composition, where Enter belongs to // the IME candidate rather than the list. if (!isIMEComposing(event, inlineSkillComposingRef.current)) { if ( !composerLocked && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey && (event.key === "Backspace" || event.key === "Delete") && selectReferenceForDeletion(event.currentTarget, event.key) ) return; if (showMentions) mentionPickerRef.current?.onKeyDown(event); if (event.defaultPrevented) return; handleCommandSuggestionKeyDown(event); if (event.defaultPrevented) { return; } } if (event.defaultPrevented) { return; } handlePromptHistoryKeyDown(event); if (event.defaultPrevented || event.key !== "Enter") return; if (isIMEComposing(event, inlineSkillComposingRef.current)) { return; } event.preventDefault(); if (event.shiftKey) { if (insertPlainTextAtSelection(event.currentTarget, "\n")) { updateInlineSkillTextInput(event.currentTarget); } return; } event.currentTarget.closest("form")?.requestSubmit(); }, [ composerLocked, showMentions, handlePromptHistoryKeyDown, handleCommandSuggestionKeyDown, updateInlineSkillTextInput, ], ); const showFollowups = !disabled && !isWelcomeMode && !showCommandSuggestions && !showMentions && !followupsHidden && // Never show stale follow-up chips while a turn is streaming: a message // sent before the previous response finished would otherwise leave the // old chips (and the lone close button) overlapping the input box. status !== "streaming" && (followupsLoading || followups.length > 0); useEffect(() => { onFollowupsVisibilityChange?.(showFollowups); }, [onFollowupsVisibilityChange, showFollowups]); useEffect(() => { return () => onFollowupsVisibilityChange?.(false); }, [onFollowupsVisibilityChange]); useEffect(() => { messagesRef.current = thread.messages; }, [thread.messages]); useEffect(() => { const streaming = status === "streaming"; const wasStreaming = wasStreamingRef.current; wasStreamingRef.current = streaming; if (!wasStreaming || streaming) { return; } // The turn was interrupted by the user, so skip generating follow-ups for // this half-finished response. if (stoppedByUserRef.current) { stoppedByUserRef.current = false; return; } if (disabled || isMock) { return; } const lastAi = [...messagesRef.current] .reverse() .find((m) => m.type === "ai"); const lastAiId = lastAi?.id ?? null; if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) { return; } if (!suggestionsConfigLoaded) { return; } lastGeneratedForAiIdRef.current = lastAiId; const recent = messagesRef.current .filter((m) => m.type === "human" || m.type === "ai") .filter((m) => !isHiddenFromUIMessage(m)) .map((m) => { const role = m.type === "human" ? "user" : "assistant"; const content = textOfMessage(m) ?? ""; return { role, content }; }) .filter((m) => m.content.trim().length > 0) .slice(-6); if (recent.length === 0) { return; } if (!suggestionsEnabled) { setFollowups([]); return; } const controller = new AbortController(); setFollowupsHidden(false); setFollowupsLoading(true); setFollowups([]); fetch(`${getBackendBaseURL()}/api/threads/${threadId}/suggestions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: recent, n: maxFollowupSuggestions, model_name: context.model_name ?? undefined, }), signal: controller.signal, }) .then(async (res) => { if (!res.ok) { return { suggestions: [] as string[] }; } return (await res.json()) as { suggestions?: string[] }; }) .then((data) => { const suggestions = (data.suggestions ?? []) .map((s) => (typeof s === "string" ? s.trim() : "")) .filter((s) => s.length > 0) .slice(0, maxFollowupSuggestions); setFollowups(suggestions); }) .catch(() => { setFollowups([]); }) .finally(() => { setFollowupsLoading(false); }); return () => controller.abort(); }, [ context.model_name, disabled, isMock, maxFollowupSuggestions, status, suggestionsConfigLoaded, suggestionsEnabled, threadId, ]); const onSelectPlaceholder = useCallback((newText: string) => { const placeholder = findSuggestionTemplatePlaceholder(newText); if (placeholder) { requestAnimationFrame(() => { const textarea = textareaRef.current; if (!textarea) return; textarea.focus(); textarea.setSelectionRange(placeholder.start, placeholder.end); }); } }, []); return (
{showFollowups && (
{followupsLoading ? (
{t.inputBox.followupLoading}
) : ( {followups.map((s) => ( handleFollowupClick(s)} /> ))} )}
)} {showMentions && (
ref.kind === "extension") .map((ref) => ref.id)} ref={mentionPickerRef} listId={mentionListId} query={mentionQuery?.query ?? ""} searchInput={mentionButtonOpen} skills={mentionSkills} selectedSkills={inlineRefs .filter((ref) => ref.kind === "skill") .map((ref) => ref.id)} references={reconciledConversations.references} capability={conversationCapability} skillsLoading={skillsLoading || skillsFetching} skillsError={skillsError} onRetrySkills={refetchSkills} onActiveOptionChange={setActiveMentionOption} threadId={threadId} projectId={projectId} busy={mentionBusy} error={mentionError} onSelect={(selection) => void selectMention(selection)} onClose={() => { closeMentions(); focusComposerAt(mentionQuery?.end ?? textInput.value.length); }} />
)} {showCommandSuggestions && !showMentions && (
{commandSuggestions.map((suggestion, index) => { const selected = index === commandSuggestionIndex; return ( ); })}
)} {polishingInput && (