import type { ApprovalRequestId, EnvironmentId, ModelSelection, PreviewAnnotationPayload, ProviderApprovalDecision, ProviderInteractionMode, ResolvedKeybindingsConfig, RuntimeMode, ScopedThreadRef, ServerProvider, ThreadId, TurnId, } from "@t3tools/contracts"; import { ProviderDriverKind, ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; import { connectionStatusText, type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { clampCollapsedComposerCursor, type ComposerTrigger, collapseExpandedComposerCursor, detectComposerTrigger, expandCollapsedComposerCursor, replaceTextRange, } from "../../composer-logic"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; import { type ComposerImageAttachment, type DraftId, type PersistedComposerImageAttachment, useComposerDraftStore, useComposerThreadDraft, useEffectiveComposerModelState, } from "../../composerDraftStore"; import { type TerminalContextDraft, type TerminalContextSelection, insertInlineTerminalContextPlaceholder, removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; import { useComposerPathSearch } from "../../lib/composerPathSearchState"; import { type ElementContextDraft } from "../../lib/elementContext"; import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts"; import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments"; import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards"; import { shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, } from "../composerFooterLayout"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; import { ProviderModelPicker } from "./ProviderModelPicker"; import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { getComposerPromptInjectionState, getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; import { Separator } from "../ui/separator"; import { Button } from "../ui/button"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { BotIcon, CircleAlertIcon, ListTodoIcon, PencilRulerIcon, type LucideIcon, LockIcon, LockOpenIcon, PenLineIcon, XIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, resolveProviderDriverKindForInstanceSelection, sortProviderInstanceEntries, type ProviderInstanceEntry, } from "../../providerInstances"; import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; import type { UnifiedSettings } from "@t3tools/contracts/settings"; import type { SessionPhase, Thread } from "../../types"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; import type { PendingApproval, PendingUserInput } from "../../session-logic"; import { deriveLatestContextWindowSnapshot, formatProviderDisplayName, } from "../../lib/contextWindow"; import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; const runtimeModeConfig: Record< RuntimeMode, { label: string; description: string; icon: LucideIcon } > = { "approval-required": { label: "Supervised", description: "Ask before commands and file changes.", icon: LockIcon, }, "auto-accept-edits": { label: "Auto-accept edits", description: "Auto-approve edits, ask before other actions.", icon: PenLineIcon, }, "full-access": { label: "Full access", description: "Allow commands and edits without prompts.", icon: LockOpenIcon, }, }; const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[]; const COMPOSER_FLOATING_LAYER_SELECTOR = [ '[data-slot="popover-popup"]', '[data-slot="menu-popup"]', '[data-slot="select-popup"]', '[data-slot="combobox-popup"]', '[data-slot="autocomplete-popup"]', ].join(","); const extendReplacementRangeForTrailingSpace = ( text: string, rangeEnd: number, replacement: string, ): number => { if (!replacement.endsWith(" ")) { return rangeEnd; } return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; }; const syncTerminalContextsByIds = ( contexts: ReadonlyArray, ids: ReadonlyArray, ): TerminalContextDraft[] => { const contextsById = new Map(contexts.map((context) => [context.id, context])); return ids.flatMap((id) => { const context = contextsById.get(id); return context ? [context] : []; }); }; const terminalContextIdListsEqual = ( contexts: ReadonlyArray, ids: ReadonlyArray, ): boolean => contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); function isInsideComposerFloatingLayer(element: Element): boolean { return element.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null; } const ComposerFooterModeControls = memo(function ComposerFooterModeControls(props: { showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; showPlanToggle: boolean; planSidebarLabel: string; planSidebarOpen: boolean; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; onTogglePlanSidebar: () => void; }) { const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; const interactionModeTooltip = props.interactionMode === "plan" ? "Plan mode — click to return to normal build mode" : "Default mode — click to enter plan mode"; const planSidebarTooltip = props.planSidebarOpen ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`; const interactionModeToggle = props.showInteractionModeToggle ? ( <> } > {props.interactionMode === "plan" ? ( ) : ( )} {props.interactionMode === "plan" ? "Plan" : "Build"} {interactionModeTooltip} ) : null; return ( <> {runtimeModeOption.description} {interactionModeToggle} {props.showPlanToggle ? ( <> } > {props.planSidebarLabel} {planSidebarTooltip} ) : null} ); }); const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; activeContextWindow: ReturnType; activeThreadProviderDisplayName: string | null; isPreparingWorktree: boolean; pendingAction: { questionIndex: number; isLastQuestion: boolean; canAdvance: boolean; isResponding: boolean; isComplete: boolean; } | null; isRunning: boolean; showPlanFollowUpPrompt: boolean; promptHasText: boolean; isSendBusy: boolean; isConnecting: boolean; isEnvironmentUnavailable: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; }) { return ( <> {props.activeContextWindow ? ( ) : null} {props.isPreparingWorktree ? ( Preparing worktree... ) : null} ); }); // -------------------------------------------------------------------------- // Handle exposed to ChatView // -------------------------------------------------------------------------- export interface ChatComposerHandle { focusAtEnd: () => void; focusAt: (cursor: number) => void; insertTextAtEnd: (text: string) => boolean; openModelPicker: () => void; toggleModelPicker: () => void; isModelPickerOpen: () => boolean; readSnapshot: () => { value: string; cursor: number; expandedCursor: number; terminalContextIds: string[]; }; /** Reset composer cursor/trigger/highlight after external prompt mutations (e.g. onSend). */ resetCursorState: (options?: { cursor?: number; prompt?: string; detectTrigger?: boolean; }) => void; /** Insert a terminal context from the terminal drawer. */ addTerminalContext: (selection: TerminalContextSelection) => void; /** Get the current prompt/effort/model state for use in send. */ getSendContext: () => { prompt: string; images: ComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; elementContexts: ElementContextDraft[]; previewAnnotations: PreviewAnnotationPayload[]; reviewComments: ReviewCommentContext[]; selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; selectedProvider: ProviderDriverKind; selectedModel: string; selectedProviderModels: ReadonlyArray; }; } // -------------------------------------------------------------------------- // Props // -------------------------------------------------------------------------- export interface ChatComposerProps { composerDraftTarget: ScopedThreadRef | DraftId; environmentId: EnvironmentId; routeKind: "server" | "draft"; routeThreadRef: ScopedThreadRef; draftId: DraftId | null; // Thread context activeThreadId: ThreadId | null; activeThreadEnvironmentId: EnvironmentId | undefined; activeThread: Thread | undefined; isServerThread: boolean; isLocalDraftThread: boolean; // Session phase phase: SessionPhase; isConnecting: boolean; isSendBusy: boolean; isPreparingWorktree: boolean; environmentUnavailable: { readonly label: string; readonly connection: EnvironmentConnectionPresentation; } | null; // Pending approvals / inputs activePendingApproval: PendingApproval | null; pendingApprovals: PendingApproval[]; pendingUserInputs: PendingUserInput[]; activePendingProgress: { questionIndex: number; isLastQuestion: boolean; canAdvance: boolean; customAnswer: string; activeQuestion: { id: string; multiSelect?: boolean | undefined } | null; } | null; activePendingResolvedAnswers: Record | null; activePendingIsResponding: boolean; activePendingDraftAnswers: Record; activePendingQuestionIndex: number; respondingRequestIds: ApprovalRequestId[]; // Plan showPlanFollowUpPrompt: boolean; activeProposedPlan: Thread["proposedPlans"][number] | null; activePlan: { turnId?: TurnId } | null; sidebarProposedPlan: { turnId?: TurnId } | null; planSidebarLabel: string; planSidebarOpen: boolean; // Mode runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; // Provider / model lockedProvider: ProviderDriverKind | null; providerStatuses: ServerProvider[]; activeProjectDefaultModelSelection: ModelSelection | null | undefined; activeThreadModelSelection: ModelSelection | null | undefined; // Context window activeThreadActivities: Thread["activities"] | undefined; // Misc resolvedTheme: "light" | "dark"; settings: UnifiedSettings; keybindings: ResolvedKeybindingsConfig; terminalOpen: boolean; gitCwd: string | null; // Refs the parent needs kept in sync promptRef: React.RefObject; composerImagesRef: React.RefObject; composerTerminalContextsRef: React.RefObject; composerElementContextsRef: React.RefObject; composerRef: React.RefObject; // Callbacks onSend: (e?: { preventDefault: () => void }) => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; onRespondToApproval: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, ) => Promise; onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void; onAdvanceActivePendingUserInput: () => void; onPreviousActivePendingUserInputQuestion: () => void; onChangeActivePendingUserInputCustomAnswer: ( questionId: string, value: string, nextCursor: number, expandedCursor: number, cursorAdjacentToMention: boolean, ) => void; onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void; getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null; toggleInteractionMode: () => void; handleRuntimeModeChange: (mode: RuntimeMode) => void; handleInteractionModeChange: (mode: ProviderInteractionMode) => void; togglePlanSidebar: () => void; focusComposer: () => void; scheduleComposerFocus: () => void; setThreadError: (threadId: ThreadId | null, error: string | null) => void; onExpandImage: (preview: ExpandedImagePreview) => void; } // -------------------------------------------------------------------------- // Component // -------------------------------------------------------------------------- export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { const { composerDraftTarget, environmentId, routeKind, routeThreadRef, draftId, activeThreadId, activeThreadEnvironmentId: _activeThreadEnvironmentId, activeThread, isServerThread: _isServerThread, isLocalDraftThread: _isLocalDraftThread, phase, isConnecting, isSendBusy, isPreparingWorktree, environmentUnavailable, activePendingApproval, pendingApprovals, pendingUserInputs, activePendingProgress, activePendingResolvedAnswers, activePendingIsResponding, activePendingDraftAnswers, activePendingQuestionIndex, respondingRequestIds, showPlanFollowUpPrompt, activeProposedPlan, activePlan, sidebarProposedPlan, planSidebarLabel, planSidebarOpen, runtimeMode, interactionMode, lockedProvider, providerStatuses, activeProjectDefaultModelSelection, activeThreadModelSelection, activeThreadActivities, resolvedTheme, settings, keybindings, terminalOpen, gitCwd, promptRef, composerRef, composerImagesRef, composerTerminalContextsRef, composerElementContextsRef, onSend, onInterrupt, onImplementPlanInNewThread, onRespondToApproval, onSelectActivePendingUserInputOption, onAdvanceActivePendingUserInput, onPreviousActivePendingUserInputQuestion, onChangeActivePendingUserInputCustomAnswer, onProviderModelSelect, getModelDisabledReason, toggleInteractionMode, handleRuntimeModeChange, handleInteractionModeChange, togglePlanSidebar, focusComposer, scheduleComposerFocus, setThreadError, onExpandImage, } = props; // ------------------------------------------------------------------ // Store subscriptions (prompt / images / terminal contexts) // ------------------------------------------------------------------ const composerDraft = useComposerThreadDraft(composerDraftTarget); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; const composerElementContexts = composerDraft.elementContexts; const composerPreviewAnnotations = composerDraft.previewAnnotations; const composerReviewComments = composerDraft.reviewComments; const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); const insertComposerDraftTerminalContext = useComposerDraftStore( (store) => store.insertTerminalContext, ); const removeComposerDraftTerminalContext = useComposerDraftStore( (store) => store.removeTerminalContext, ); const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); const removeComposerDraftElementContext = useComposerDraftStore( (store) => store.removeElementContext, ); const removeComposerDraftPreviewAnnotation = useComposerDraftStore( (store) => store.removePreviewAnnotation, ); const removeComposerDraftReviewComment = useComposerDraftStore( (store) => store.removeReviewComment, ); const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); const syncComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.syncPersistedAttachments, ); const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); // ------------------------------------------------------------------ // Model state // ------------------------------------------------------------------ // Instance-aware projection of the wire provider list. One entry per // configured instance (default built-in + any custom `providerInstances.*`), // sorted default-first per driver kind for a stable picker order. const providerInstanceEntries = useMemo>( () => sortProviderInstanceEntries( applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), ), [providerStatuses, settings], ); const selectedProviderByThreadId = composerDraft.activeProvider ?? null; const threadProvider = activeThread?.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId ?? activeProjectDefaultModelSelection?.instanceId ?? null; const explicitSelectedInstanceId = selectedProviderByThreadId ?? threadProvider; const unlockedSelectedProvider = resolveProviderDriverKindForInstanceSelection( providerInstanceEntries, providerStatuses, explicitSelectedInstanceId, ) ?? ProviderDriverKind.make("codex"); const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const lockedContinuationGroupKey = useMemo((): string | null => { if (!lockedProvider || !activeThread) return null; const lockedInstanceId = activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId; if (!lockedInstanceId) return null; return ( providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId) ?.continuationGroupKey ?? null ); }, [ activeThread, activeThreadModelSelection?.instanceId, lockedProvider, providerInstanceEntries, ]); // Resolve which configured instance the composer is currently targeting. // Priority: // 1. The composer draft's `activeProvider` — the user's unsaved pick // from the model picker (must win, otherwise the UI appears to // ignore picker selections). // 2. Thread's persisted instance id (server-side saved selection). // 3. Project default's instance id. // 4. First enabled entry matching the current driver kind. // 5. First enabled entry overall / default instance for the kind. // const selectedInstanceId = useMemo(() => { const candidates: Array = [ composerDraft.activeProvider, activeThread?.session?.providerInstanceId, activeThreadModelSelection?.instanceId, activeProjectDefaultModelSelection?.instanceId, ]; for (const candidate of candidates) { if (!candidate) continue; const match = providerInstanceEntries.find( (entry) => entry.instanceId === candidate && entry.enabled, ); if (match) { // When locked to a specific driver kind, ignore persisted instance // ids from a different kind or continuation group. if (lockedProvider && match.driverKind !== lockedProvider) continue; if ( lockedContinuationGroupKey && match.continuationGroupKey !== lockedContinuationGroupKey ) { continue; } return match.instanceId; } } if (explicitSelectedInstanceId) { return ProviderInstanceId.make(explicitSelectedInstanceId); } const byKind = providerInstanceEntries.find( (entry) => entry.enabled && entry.driverKind === selectedProvider && (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), ); if (byKind) return byKind.instanceId; const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled); return ( anyEnabled?.instanceId ?? providerInstanceEntries[0]?.instanceId ?? activeThreadModelSelection?.instanceId ?? activeProjectDefaultModelSelection?.instanceId ?? ProviderInstanceId.make("codex") ); }, [ activeProjectDefaultModelSelection?.instanceId, activeThread?.session?.providerInstanceId, activeThreadModelSelection?.instanceId, composerDraft.activeProvider, explicitSelectedInstanceId, lockedContinuationGroupKey, lockedProvider, providerInstanceEntries, selectedProvider, ]); const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadRef: composerDraftTarget, providers: providerStatuses, selectedProvider, selectedInstanceId, threadModelSelection: activeThreadModelSelection, projectModelSelection: activeProjectDefaultModelSelection, settings, }); // Resolve the active instance's snapshot by `instanceId` so a custom // instance gets its own slash commands, skills, and model list — not // the first snapshot for the same driver kind. const selectedProviderEntry = useMemo( () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), [providerInstanceEntries, selectedInstanceId], ); const selectedProviderStatus = useMemo( () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], ); const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], ); const composerPromptInjectionState = useMemo( () => getComposerPromptInjectionState(prompt), [prompt], ); const composerProviderState = useMemo( () => getComposerProviderState({ provider: selectedProvider, model: selectedModel, models: selectedProviderModels, promptInjectionState: composerPromptInjectionState, modelOptions: composerModelOptions?.[selectedInstanceId], }), [ composerModelOptions, composerPromptInjectionState, selectedInstanceId, selectedModel, selectedProvider, selectedProviderModels, ], ); const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; const composerProviderControls = useMemo( () => ({ showInteractionModeToggle: getProviderInteractionModeToggle( providerStatuses, selectedProvider, ), }), [providerStatuses, selectedProvider], ); const selectedModelSelection = useMemo( () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch], ); const selectedModelForPicker = selectedModel; // Instance-keyed option list so the picker can show each configured // instance (built-in + custom) as a first-class sidebar entry. The // options are server-reported models plus that exact instance's // configured custom models; selected slugs are not injected into lists. const modelOptionsByInstance = useMemo< ReadonlyMap> >(() => { const out = new Map>(); for (const entry of providerInstanceEntries) { out.set(entry.instanceId, getAppModelOptionsForInstance(settings, entry)); } return out; }, [providerInstanceEntries, settings]); const selectedModelForPickerWithCustomFallback = useMemo(() => { const currentOptions = modelOptionsByInstance.get(selectedInstanceId) ?? []; return currentOptions.some((option) => option.slug === selectedModelForPicker) ? selectedModelForPicker : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); }, [modelOptionsByInstance, selectedInstanceId, selectedModelForPicker, selectedProvider]); // ------------------------------------------------------------------ // Context window // ------------------------------------------------------------------ const activeContextWindow = useMemo( () => deriveLatestContextWindowSnapshot(activeThreadActivities ?? []), [activeThreadActivities], ); const activeThreadProviderDisplayName = useMemo(() => { if (!activeThreadModelSelection) return null; const entry = providerStatuses.find( (p) => p.instanceId === activeThreadModelSelection.instanceId, ); if (entry) { return getProviderDisplayName(providerStatuses, entry.driver); } return formatProviderDisplayName(activeThreadModelSelection.instanceId); }, [providerStatuses, activeThreadModelSelection]); // ------------------------------------------------------------------ // Composer-local state // ------------------------------------------------------------------ const [composerCursor, setComposerCursor] = useState(() => collapseExpandedComposerCursor(prompt, prompt.length), ); const [composerTrigger, setComposerTrigger] = useState(() => detectComposerTrigger(prompt, prompt.length), ); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState( null, ); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const [isComposerFocused, setIsComposerFocused] = useState(false); const isMobileViewport = useMediaQuery("max-sm"); const isComposerCollapsedMobile = isMobileViewport && !isComposerFocused; // ------------------------------------------------------------------ // Refs // ------------------------------------------------------------------ const composerEditorRef = useRef(null); const composerFormRef = useRef(null); const composerSurfaceRef = useRef(null); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef([]); const activeComposerMenuItemRef = useRef(null); const composerBlurFrameRef = useRef(null); const mobileComposerExpandFrameRef = useRef(null); const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); const dragDepthRef = useRef(0); // ------------------------------------------------------------------ // Derived: composer send state // ------------------------------------------------------------------ const composerSendState = useMemo( () => deriveComposerSendState({ prompt, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, elementContextCount: composerElementContexts.length + composerPreviewAnnotations.length + composerReviewComments.length, }), [ composerElementContexts.length, composerImages.length, composerPreviewAnnotations.length, composerReviewComments.length, composerTerminalContexts, prompt, ], ); // ------------------------------------------------------------------ // Derived: composer trigger / menu // ------------------------------------------------------------------ const composerTriggerKind = composerTrigger?.kind ?? null; const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; const workspaceEntries = useComposerPathSearch({ environmentId, cwd: isPathTrigger ? gitCwd : null, query: isPathTrigger ? pathTriggerQuery : null, }); const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { return workspaceEntries.entries.map((entry) => ({ id: `path:${entry.kind}:${entry.path}`, type: "path", path: entry.path, pathKind: entry.kind, label: basenameOfPath(entry.path), description: entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))), })); } if (composerTrigger.kind === "slash-command") { const builtInSlashCommandItems = [ { id: "slash:model", type: "slash-command", command: "model", label: "/model", description: "Switch response model for this thread", }, { id: "slash:plan", type: "slash-command", command: "plan", label: "/plan", description: "Switch this thread into plan mode", }, { id: "slash:default", type: "slash-command", command: "default", label: "/default", description: "Switch this thread back to normal build mode", }, ] satisfies ReadonlyArray>; const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( (command) => ({ id: `provider-slash-command:${selectedProvider}:${command.name}`, type: "provider-slash-command" as const, provider: selectedProvider, command, label: `/${command.name}`, description: command.description ?? command.input?.hint ?? "Run provider command", }), ); const query = composerTrigger.query.trim().toLowerCase(); const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems]; if (!query) { return slashCommandItems; } return searchSlashCommandItems(slashCommandItems, query); } if (composerTrigger.kind === "skill") { return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map( (skill) => ({ id: `skill:${selectedProvider}:${skill.name}`, type: "skill" as const, provider: selectedProvider, skill, label: formatProviderSkillDisplayName(skill), description: skill.shortDescription ?? skill.description ?? (skill.scope ? `${skill.scope} skill` : "Run provider skill"), }), ); } return []; }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger ? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}` : null; const activeComposerMenuItem = useMemo(() => { const activeItemId = resolveComposerMenuActiveItemId({ items: composerMenuItems, highlightedItemId: composerHighlightedItemId, currentSearchKey: composerMenuSearchKey, highlightedSearchKey: composerHighlightedSearchKey, }); return composerMenuItems.find((item) => item.id === activeItemId) ?? null; }, [ composerHighlightedItemId, composerHighlightedSearchKey, composerMenuItems, composerMenuSearchKey, ]); composerMenuOpenRef.current = composerMenuOpen; composerMenuItemsRef.current = composerMenuItems; activeComposerMenuItemRef.current = activeComposerMenuItem; const nonPersistedComposerImageIdSet = useMemo( () => new Set(nonPersistedComposerImageIds), [nonPersistedComposerImageIds], ); const isComposerApprovalState = activePendingApproval !== null; const activePendingUserInput = pendingUserInputs[0] ?? null; const hasComposerHeader = isComposerApprovalState || pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); const showCollapsedMobilePromptRow = isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0; const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen); const composerFooterActionLayoutKey = useMemo(() => { if (activePendingProgress) { return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; } if (phase === "running") { return "running"; } if (showPlanFollowUpPrompt) { return prompt.trim().length > 0 ? "plan:refine" : "plan:implement"; } return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`; }, [ activePendingIsResponding, activePendingProgress, composerSendState.hasSendableContent, isConnecting, isPreparingWorktree, isSendBusy, phase, prompt, showPlanFollowUpPrompt, ]); const isComposerMenuLoading = composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending; const composerMenuEmptyState = useMemo(() => { if (composerTriggerKind === "skill") { return "No skills found. Try / to browse provider commands."; } return composerTriggerKind === "path" ? "No matching files or folders." : "No matching command."; }, [composerTriggerKind]); // ------------------------------------------------------------------ // Provider traits UI // ------------------------------------------------------------------ const setPromptFromTraits = useCallback( (nextPrompt: string) => { if (nextPrompt === promptRef.current) { scheduleComposerFocus(); return; } promptRef.current = nextPrompt; setComposerDraftPrompt(composerDraftTarget, nextPrompt); const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); setComposerCursor(nextCursor); setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); scheduleComposerFocus(); }, [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt], ); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ provider: selectedProvider, instanceId: selectedInstanceId, ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedInstanceId], prompt, onPromptChange: setPromptFromTraits, }); const providerTraitsPicker = renderProviderTraitsPicker({ provider: selectedProvider, instanceId: selectedInstanceId, ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedInstanceId], prompt, onPromptChange: setPromptFromTraits, }); const pendingPrimaryAction = useMemo( () => activePendingProgress ? { questionIndex: activePendingProgress.questionIndex, isLastQuestion: activePendingProgress.isLastQuestion, canAdvance: activePendingProgress.canAdvance, isResponding: activePendingIsResponding, isComplete: Boolean(activePendingResolvedAnswers), } : null, [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], ); const collapsedComposerPrimaryActionDisabled = phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent; const collapsedComposerPrimaryActionLabel = "Send message"; const showMobilePendingAnswerActions = isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null; // ------------------------------------------------------------------ // Prompt helpers // ------------------------------------------------------------------ const setPrompt = useCallback( (nextPrompt: string) => { setComposerDraftPrompt(composerDraftTarget, nextPrompt); }, [composerDraftTarget, setComposerDraftPrompt], ); const addComposerImage = useCallback( (image: ComposerImageAttachment) => { addComposerDraftImage(composerDraftTarget, image); }, [composerDraftTarget, addComposerDraftImage], ); const addComposerImagesToDraft = useCallback( (images: ComposerImageAttachment[]) => { addComposerDraftImages(composerDraftTarget, images); }, [composerDraftTarget, addComposerDraftImages], ); const removeComposerImageFromDraft = useCallback( (imageId: string) => { removeComposerDraftImage(composerDraftTarget, imageId); }, [composerDraftTarget, removeComposerDraftImage], ); const removeComposerTerminalContextFromDraft = useCallback( (contextId: string) => { const contextIndex = composerTerminalContexts.findIndex( (context) => context.id === contextId, ); if (contextIndex < 0) return; const removal = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); promptRef.current = removal.prompt; setPrompt(removal.prompt); removeComposerDraftTerminalContext(composerDraftTarget, contextId); const nextCursor = collapseExpandedComposerCursor(removal.prompt, removal.cursor); setComposerCursor(nextCursor); setComposerTrigger(detectComposerTrigger(removal.prompt, removal.cursor)); }, [ composerDraftTarget, composerTerminalContexts, promptRef, removeComposerDraftTerminalContext, setPrompt, ], ); // ------------------------------------------------------------------ // Sync refs back to parent // ------------------------------------------------------------------ useEffect(() => { promptRef.current = prompt; setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); }, [prompt, promptRef]); useEffect(() => { composerImagesRef.current = composerImages; }, [composerImages, composerImagesRef]); useEffect(() => { composerTerminalContextsRef.current = composerTerminalContexts; }, [composerTerminalContexts, composerTerminalContextsRef]); useEffect(() => { composerElementContextsRef.current = composerElementContexts; }, [composerElementContexts, composerElementContextsRef]); // ------------------------------------------------------------------ // Composer menu highlight sync // ------------------------------------------------------------------ useEffect(() => { if (!composerMenuOpen) { setComposerHighlightedItemId(null); setComposerHighlightedSearchKey(null); return; } const nextActiveItemId = resolveComposerMenuActiveItemId({ items: composerMenuItems, highlightedItemId: composerHighlightedItemId, currentSearchKey: composerMenuSearchKey, highlightedSearchKey: composerHighlightedSearchKey, }); setComposerHighlightedItemId((existing) => existing === nextActiveItemId ? existing : nextActiveItemId, ); setComposerHighlightedSearchKey((existing) => existing === composerMenuSearchKey ? existing : composerMenuSearchKey, ); }, [ composerHighlightedItemId, composerHighlightedSearchKey, composerMenuItems, composerMenuOpen, composerMenuSearchKey, ]); const lastSyncedPendingInputRef = useRef<{ requestId: string | null; questionId: string | null; } | null>(null); useEffect(() => { const nextCustomAnswer = activePendingProgress?.customAnswer; if (typeof nextCustomAnswer !== "string") { lastSyncedPendingInputRef.current = null; return; } const nextRequestId = activePendingUserInput?.requestId ?? null; const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null; const questionChanged = lastSyncedPendingInputRef.current?.requestId !== nextRequestId || lastSyncedPendingInputRef.current?.questionId !== nextQuestionId; const textChangedExternally = promptRef.current !== nextCustomAnswer; lastSyncedPendingInputRef.current = { requestId: nextRequestId, questionId: nextQuestionId, }; if (!questionChanged && !textChangedExternally) { return; } promptRef.current = nextCustomAnswer; const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); setComposerCursor(nextCursor); setComposerTrigger( detectComposerTrigger( nextCustomAnswer, expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), ), ); setComposerHighlightedItemId(null); }, [ activePendingProgress?.customAnswer, activePendingProgress?.activeQuestion?.id, activePendingUserInput?.requestId, promptRef, ]); // ------------------------------------------------------------------ // Reset compositor state on thread/draft change // ------------------------------------------------------------------ useEffect(() => { setComposerHighlightedItemId(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); dragDepthRef.current = 0; setIsDragOverComposer(false); }, [draftId, activeThreadId, promptRef]); // ------------------------------------------------------------------ // Footer compact layout observation // ------------------------------------------------------------------ useLayoutEffect(() => { const composerForm = composerFormRef.current; if (!composerForm) return; const measureComposerFormWidth = () => composerForm.clientWidth; const measureFooterCompactness = () => { const composerFormWidth = measureComposerFormWidth(); const footerCompact = shouldUseCompactComposerFooter(composerFormWidth, { hasWideActions: composerFooterHasWideActions, }); const primaryActionsCompact = footerCompact && shouldUseCompactComposerPrimaryActions(composerFormWidth, { hasWideActions: composerFooterHasWideActions, }); return { primaryActionsCompact, footerCompact, }; }; const initialCompactness = measureFooterCompactness(); setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); setIsComposerFooterCompact(initialCompactness.footerCompact); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(() => { const nextCompactness = measureFooterCompactness(); setIsComposerPrimaryActionsCompact((previous) => previous === nextCompactness.primaryActionsCompact ? previous : nextCompactness.primaryActionsCompact, ); setIsComposerFooterCompact((previous) => previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, ); }); observer.observe(composerForm); return () => { observer.disconnect(); }; }, [activeThreadId, composerFooterActionLayoutKey, composerFooterHasWideActions]); // ------------------------------------------------------------------ // Image persist effect // ------------------------------------------------------------------ useEffect(() => { let cancelled = false; void (async () => { if (composerImages.length === 0) { clearComposerDraftPersistedAttachments(composerDraftTarget); return; } const getPersistedAttachmentsForThread = () => getComposerDraft(composerDraftTarget)?.persistedAttachments ?? []; try { const currentPersistedAttachments = getPersistedAttachmentsForThread(); const existingPersistedById = new Map( currentPersistedAttachments.map((attachment) => [attachment.id, attachment]), ); const stagedAttachmentById = new Map(); await Promise.all( composerImages.map(async (image) => { try { const dataUrl = await readFileAsDataUrl(image.file); stagedAttachmentById.set(image.id, { id: image.id, name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, dataUrl, }); } catch { const existingPersisted = existingPersistedById.get(image.id); if (existingPersisted) { stagedAttachmentById.set(image.id, existingPersisted); } } }), ); const serialized = Array.from(stagedAttachmentById.values()); if (cancelled) return; syncComposerDraftPersistedAttachments(composerDraftTarget, serialized); } catch { const currentImageIds = new Set(composerImages.map((image) => image.id)); const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); const fallbackPersistedIds: Array = []; for (const attachment of fallbackPersistedAttachments) { if (currentImageIds.has(attachment.id)) { fallbackPersistedIds.push(attachment.id); } } const fallbackPersistedIdSet = new Set(fallbackPersistedIds); const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) => fallbackPersistedIdSet.has(attachment.id), ); if (cancelled) return; syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments); } })(); return () => { cancelled = true; }; }, [ composerDraftTarget, clearComposerDraftPersistedAttachments, composerImages, getComposerDraft, syncComposerDraftPersistedAttachments, ]); // ------------------------------------------------------------------ // Callbacks: prompt change // ------------------------------------------------------------------ const onPromptChange = useCallback( ( nextPrompt: string, nextCursor: number, expandedCursor: number, cursorAdjacentToMention: boolean, terminalContextIds: string[], ) => { if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { setComposerCursor(nextCursor); setComposerTrigger( cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), ); onChangeActivePendingUserInputCustomAnswer( activePendingProgress.activeQuestion.id, nextPrompt, nextCursor, expandedCursor, cursorAdjacentToMention, ); return; } promptRef.current = nextPrompt; setPrompt(nextPrompt); if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { setComposerDraftTerminalContexts( composerDraftTarget, syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), ); } setComposerCursor(nextCursor); setComposerTrigger( cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), ); }, [ activePendingProgress?.activeQuestion, pendingUserInputs.length, onChangeActivePendingUserInputCustomAnswer, promptRef, setPrompt, composerDraftTarget, composerTerminalContexts, setComposerDraftTerminalContexts, ], ); // ------------------------------------------------------------------ // Callbacks: prompt replacement / menu // ------------------------------------------------------------------ const applyPromptReplacement = useCallback( ( rangeStart: number, rangeEnd: number, replacement: string, options?: { expectedText?: string; focusEditorAfterReplace?: boolean }, ): boolean => { const currentText = promptRef.current; const safeStart = Math.max(0, Math.min(currentText.length, rangeStart)); const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd)); if ( options?.expectedText !== undefined && currentText.slice(safeStart, safeEnd) !== options.expectedText ) { return false; } const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor); promptRef.current = next.text; const activePendingQuestion = activePendingProgress?.activeQuestion; if (activePendingQuestion && activePendingUserInput) { onChangeActivePendingUserInputCustomAnswer( activePendingQuestion.id, next.text, nextCursor, nextExpandedCursor, false, ); } else { setPrompt(next.text); } setComposerCursor(nextCursor); setComposerTrigger(detectComposerTrigger(next.text, nextExpandedCursor)); if (options?.focusEditorAfterReplace !== false) { window.requestAnimationFrame(() => { composerEditorRef.current?.focusAt(nextCursor); }); } return true; }, [ activePendingProgress?.activeQuestion, activePendingUserInput, onChangeActivePendingUserInputCustomAnswer, promptRef, setPrompt, ], ); const readComposerSnapshot = useCallback((): { value: string; cursor: number; expandedCursor: number; terminalContextIds: string[]; } => { const editorSnapshot = composerEditorRef.current?.readSnapshot(); if (editorSnapshot) { return editorSnapshot; } return { value: promptRef.current, cursor: composerCursor, expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), terminalContextIds: composerTerminalContexts.map((context) => context.id), }; }, [composerCursor, composerTerminalContexts, promptRef]); const resolveActiveComposerTrigger = useCallback((): { snapshot: { value: string; cursor: number; expandedCursor: number }; trigger: ComposerTrigger | null; } => { const snapshot = readComposerSnapshot(); return { snapshot, trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), }; }, [readComposerSnapshot]); const onSelectComposerItem = useCallback( (item: ComposerCommandItem) => { if (composerSelectLockRef.current) return; composerSelectLockRef.current = true; window.requestAnimationFrame(() => { composerSelectLockRef.current = false; }); const { snapshot, trigger } = resolveActiveComposerTrigger(); if (!trigger) return; if (item.type === "path") { const replacement = `${serializeComposerFileLink(item.path)} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, trigger.rangeEnd, replacement, ); const applied = applyPromptReplacement( trigger.rangeStart, replacementRangeEnd, replacement, { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, ); if (applied) { setComposerHighlightedItemId(null); } return; } if (item.type === "slash-command") { if (item.command === "model") { const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), focusEditorAfterReplace: false, }); if (applied) { setComposerHighlightedItemId(null); setIsComposerModelPickerOpen(true); } return; } void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), }); if (applied) { setComposerHighlightedItemId(null); } return; } if (item.type === "provider-slash-command") { const replacement = `/${item.command.name} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, trigger.rangeEnd, replacement, ); const applied = applyPromptReplacement( trigger.rangeStart, replacementRangeEnd, replacement, { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, ); if (applied) { setComposerHighlightedItemId(null); } return; } if (item.type === "skill") { const replacement = `$${item.skill.name} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, trigger.rangeEnd, replacement, ); const applied = applyPromptReplacement( trigger.rangeStart, replacementRangeEnd, replacement, { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, ); if (applied) { setComposerHighlightedItemId(null); } return; } }, [applyPromptReplacement, handleInteractionModeChange, resolveActiveComposerTrigger], ); const onComposerMenuItemHighlighted = useCallback( (itemId: string | null) => { setComposerHighlightedItemId(itemId); setComposerHighlightedSearchKey(composerMenuSearchKey); }, [composerMenuSearchKey], ); const nudgeComposerMenuHighlight = useCallback( (key: "ArrowDown" | "ArrowUp") => { if (composerMenuItems.length === 0) return; const highlightedIndex = composerMenuItems.findIndex( (item) => item.id === composerHighlightedItemId, ); const normalizedIndex = highlightedIndex >= 0 ? highlightedIndex : key === "ArrowDown" ? -1 : 0; const offset = key === "ArrowDown" ? 1 : -1; const nextIndex = (normalizedIndex + offset + composerMenuItems.length) % composerMenuItems.length; const nextItem = composerMenuItems[nextIndex]; setComposerHighlightedItemId(nextItem?.id ?? null); }, [composerHighlightedItemId, composerMenuItems], ); const blurMobileComposerAfterSend = useCallback(() => { if (!isMobileViewport) return; if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); composerBlurFrameRef.current = null; } const activeElement = document.activeElement; if (activeElement instanceof HTMLElement) { activeElement.blur(); } setIsComposerFocused(false); }, [isMobileViewport]); const shouldBlurMobileComposerOnSubmit = useCallback(() => { if (!isMobileViewport) return false; if (isSendBusy || isConnecting || phase === "running") return false; if (activePendingProgress) { return activePendingProgress.isLastQuestion && Boolean(activePendingResolvedAnswers); } return showPlanFollowUpPrompt || composerSendState.hasSendableContent; }, [ activePendingProgress, activePendingResolvedAnswers, composerSendState.hasSendableContent, isConnecting, isMobileViewport, isSendBusy, phase, showPlanFollowUpPrompt, ]); const submitComposer = useCallback( (event?: { preventDefault: () => void }) => { onSend(event); if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); } }, [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit], ); const expandMobileComposer = useCallback(() => { if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); composerBlurFrameRef.current = null; } if (mobileComposerExpandFrameRef.current !== null) { window.cancelAnimationFrame(mobileComposerExpandFrameRef.current); } if (mobileComposerExpandReleaseFrameRef.current !== null) { window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current); } mobileComposerExpandInFlightRef.current = true; setIsComposerFocused(true); mobileComposerExpandFrameRef.current = window.requestAnimationFrame(() => { mobileComposerExpandFrameRef.current = null; composerEditorRef.current?.focusAtEnd(); mobileComposerExpandReleaseFrameRef.current = window.requestAnimationFrame(() => { mobileComposerExpandReleaseFrameRef.current = null; mobileComposerExpandInFlightRef.current = false; }); }); }, []); // ------------------------------------------------------------------ // Callbacks: command key // ------------------------------------------------------------------ const onComposerCommandKey = ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent, ) => { if (key === "Tab" && event.shiftKey) { toggleInteractionMode(); return true; } const { trigger } = resolveActiveComposerTrigger(); const menuIsActive = composerMenuOpenRef.current || trigger !== null; if (menuIsActive) { const currentItems = composerMenuItemsRef.current; const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; if (key === "ArrowDown" && currentItems.length > 0) { nudgeComposerMenuHighlight("ArrowDown"); return true; } if (key === "ArrowUp" && currentItems.length > 0) { nudgeComposerMenuHighlight("ArrowUp"); return true; } if ((key === "Enter" || key === "Tab") && selectedItem) { onSelectComposerItem(selectedItem); return true; } } if (key === "Enter" && !event.shiftKey) { submitComposer(); return true; } return false; }; // ------------------------------------------------------------------ // Callbacks: images // ------------------------------------------------------------------ const addComposerImages = (files: File[]) => { if (!activeThreadId || files.length === 0) return; if (pendingUserInputs.length > 0) { toastManager.add({ type: "error", title: "Attach images after answering plan questions.", }); return; } const nextImages: ComposerImageAttachment[] = []; let nextImageCount = composerImagesRef.current.length; let error: string | null = null; for (const file of files) { if (!file.type.startsWith("image/")) { error = `Unsupported file type for '${file.name}'. Please attach image files only.`; continue; } if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; continue; } if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; break; } const previewUrl = URL.createObjectURL(file); nextImages.push({ type: "image", id: randomUUID(), name: file.name || "image", mimeType: file.type, sizeBytes: file.size, previewUrl, file, }); nextImageCount += 1; } if (nextImages.length === 1 && nextImages[0]) { addComposerImage(nextImages[0]); } else if (nextImages.length > 1) { addComposerImagesToDraft(nextImages); } setThreadError(activeThreadId, error); }; const removeComposerImage = (imageId: string) => { removeComposerImageFromDraft(imageId); }; // ------------------------------------------------------------------ // Callbacks: paste / drag // ------------------------------------------------------------------ const onComposerPaste = (event: React.ClipboardEvent) => { const files = Array.from(event.clipboardData.files); if (files.length === 0) return; const imageFiles = files.filter((file) => file.type.startsWith("image/")); if (imageFiles.length === 0) return; event.preventDefault(); addComposerImages(imageFiles); }; const onComposerDragEnter = (event: React.DragEvent) => { if (!event.dataTransfer.types.includes("Files")) return; event.preventDefault(); dragDepthRef.current += 1; setIsDragOverComposer(true); }; const onComposerDragOver = (event: React.DragEvent) => { if (!event.dataTransfer.types.includes("Files")) return; event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setIsDragOverComposer(true); }; const onComposerDragLeave = (event: React.DragEvent) => { if (!event.dataTransfer.types.includes("Files")) return; event.preventDefault(); const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) { setIsDragOverComposer(false); } }; const onComposerDrop = (event: React.DragEvent) => { if (!event.dataTransfer.types.includes("Files")) return; event.preventDefault(); dragDepthRef.current = 0; setIsDragOverComposer(false); const files = Array.from(event.dataTransfer.files); addComposerImages(files); focusComposer(); }; const handleInterruptPrimaryAction = useCallback(() => { void onInterrupt(); }, [onInterrupt]); const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => { void onImplementPlanInNewThread(); }, [onImplementPlanInNewThread]); const scheduleComposerCollapseCheck = useCallback(() => { if (!isMobileViewport) { return; } if (mobileComposerExpandInFlightRef.current) { return; } if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); } composerBlurFrameRef.current = window.requestAnimationFrame(() => { composerBlurFrameRef.current = null; if (mobileComposerExpandInFlightRef.current) { return; } const composerSurface = composerSurfaceRef.current; const activeElement = document.activeElement; if (activeElement instanceof Element && isInsideComposerFloatingLayer(activeElement)) { return; } if ( composerSurface && activeElement instanceof Node && composerSurface.contains(activeElement) ) { return; } setIsComposerFocused(false); }); }, [isMobileViewport]); useEffect(() => { return () => { if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); } if (mobileComposerExpandFrameRef.current !== null) { window.cancelAnimationFrame(mobileComposerExpandFrameRef.current); } if (mobileComposerExpandReleaseFrameRef.current !== null) { window.cancelAnimationFrame(mobileComposerExpandReleaseFrameRef.current); } }; }, []); // ------------------------------------------------------------------ // Imperative handle // ------------------------------------------------------------------ useImperativeHandle( composerRef, () => ({ focusAtEnd: () => { composerEditorRef.current?.focusAtEnd(); }, focusAt: (cursor: number) => { composerEditorRef.current?.focusAt(cursor); }, insertTextAtEnd: (text: string) => { if ( text.length === 0 || isConnecting || isComposerApprovalState || pendingUserInputs.length > 0 || (environmentUnavailable !== null && activePendingProgress === null) ) { return false; } const rangeEnd = promptRef.current.length; return applyPromptReplacement(rangeEnd, rangeEnd, text); }, openModelPicker: () => { setIsComposerModelPickerOpen(true); }, toggleModelPicker: () => { setIsComposerModelPickerOpen((open) => !open); }, isModelPickerOpen: () => isComposerModelPickerOpen, readSnapshot: () => { return readComposerSnapshot(); }, resetCursorState: (options?: { cursor?: number; prompt?: string; detectTrigger?: boolean; }) => { const promptForState = options?.prompt ?? promptRef.current; const cursor = clampCollapsedComposerCursor(promptForState, options?.cursor ?? 0); setComposerHighlightedItemId(null); setComposerCursor(cursor); setComposerTrigger( options?.detectTrigger ? detectComposerTrigger( promptForState, expandCollapsedComposerCursor(promptForState, cursor), ) : null, ); }, addTerminalContext: (selection: TerminalContextSelection) => { if (!activeThread) return; const snapshot = composerEditorRef.current?.readSnapshot() ?? { value: promptRef.current, cursor: composerCursor, expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), terminalContextIds: composerTerminalContexts.map((context) => context.id), }; const insertion = insertInlineTerminalContextPlaceholder( snapshot.value, snapshot.expandedCursor, ); const nextCollapsedCursor = collapseExpandedComposerCursor( insertion.prompt, insertion.cursor, ); const inserted = insertComposerDraftTerminalContext( composerDraftTarget, insertion.prompt, { id: randomUUID(), threadId: activeThread.id, createdAt: new Date().toISOString(), ...selection, }, insertion.contextIndex, ); if (!inserted) return; promptRef.current = insertion.prompt; setComposerCursor(nextCollapsedCursor); setComposerTrigger(detectComposerTrigger(insertion.prompt, insertion.cursor)); window.requestAnimationFrame(() => { composerEditorRef.current?.focusAt(nextCollapsedCursor); }); }, getSendContext: () => ({ prompt: promptRef.current, images: composerImagesRef.current, terminalContexts: composerTerminalContextsRef.current, elementContexts: composerElementContextsRef.current, previewAnnotations: composerPreviewAnnotations, reviewComments: composerReviewComments, selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, selectedProvider, selectedModel, selectedProviderModels, }), }), [ activeThread, composerDraftTarget, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext, promptRef, composerImagesRef, composerTerminalContextsRef, composerElementContextsRef, composerPreviewAnnotations, composerReviewComments, isConnecting, isComposerApprovalState, pendingUserInputs.length, environmentUnavailable, activePendingProgress, applyPromptReplacement, isComposerModelPickerOpen, readComposerSnapshot, selectedModel, selectedModelOptionsForDispatch, selectedModelSelection, selectedPromptEffort, selectedProvider, selectedProviderModels, ], ); // Render // ------------------------------------------------------------------ return (
{ const activeElement = event.target; if ( isComposerCollapsedMobile && activeElement instanceof HTMLElement && activeElement.closest('[data-chat-composer-collapsed-controls="true"]') ) { return; } if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); composerBlurFrameRef.current = null; } setIsComposerFocused(true); }} onBlurCapture={() => { scheduleComposerCollapseCheck(); }} > {!isComposerCollapsedMobile && (activePendingApproval ? (
) : pendingUserInputs.length > 0 ? (
) : showPlanFollowUpPrompt && activeProposedPlan ? (
) : null)} {isComposerCollapsedMobile && activePendingApproval ? (
) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
{activePendingProgress?.activeQuestion?.multiSelect ? ( ) : null}
) : null} {showCollapsedMobilePromptRow ? (
) : null}
{composerMenuOpen && !isComposerApprovalState && (
)} {!isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0 && composerPreviewAnnotations.length > 0 && ( removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId) } onExpandImage={(imageId) => { const preview = buildExpandedImagePreview(composerImages, imageId); if (preview) onExpandImage(preview); }} className="mb-3" /> )} {!isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0 && composerReviewComments.length > 0 && ( removeComposerDraftReviewComment(composerDraftTarget, commentId) } className="mb-3" /> )} {!isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0 && composerElementContexts.length > 0 && ( removeComposerDraftElementContext(composerDraftTarget, contextId) } className="mb-3" /> )} {!isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0 && composerImages.some( (image) => !composerPreviewAnnotations.some((annotation) => annotation.id === image.id), ) && (
{composerImages .filter( (image) => !composerPreviewAnnotations.some( (annotation) => annotation.id === image.id, ), ) .map((image) => (
{image.previewUrl ? ( ) : (
{image.name}
)} {nonPersistedComposerImageIdSet.has(image.id) && ( } /> Draft attachment could not be saved locally and may be lost on navigation. )}
))}
)}
{showMobilePendingAnswerActions ? (
) : null}
{/* Bottom toolbar */} {isComposerCollapsedMobile ? null : activePendingApproval ? (
) : (
0 && "pt-2", isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", showMobilePendingAnswerActions && "hidden sm:flex", )} >
{ setIsComposerModelPickerOpen(open); }} getModelDisabledReason={getModelDisabledReason} onInstanceModelChange={onProviderModelSelect} /> {isComposerFooterCompact ? ( ) : ( <> {providerTraitsPicker ? ( <> {providerTraitsPicker} ) : null} )}
{/* Right side: send / stop button */}
0} isSendBusy={isSendBusy} isConnecting={isConnecting} isEnvironmentUnavailable={environmentUnavailable !== null} isPreparingWorktree={isPreparingWorktree} hasSendableContent={composerSendState.hasSendableContent} preserveComposerFocusOnPointerDown={isMobileViewport} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} />
)}
); });