import type { ApprovalRequestId, EnvironmentId, ModelSelection, ProjectEntry, 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 { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model";import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState,} from "react";import { useQuery } from "@tanstack/react-query";import { useDebouncedValue } from "@tanstack/react-pacer";import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";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 { 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 { getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker,} from "./composerProviderState";import { ContextWindowMeter } from "./ContextWindowMeter";import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview";import { basenameOfPath } from "../../vscode-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, type LucideIcon, LockIcon, LockOpenIcon, PenLineIcon, XIcon,} from "lucide-react";import { proposedPlanTitle } from "../../proposedPlan";import { getProviderInteractionModeToggle } from "../../providerModels";import { 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 } from "../../lib/contextWindow";import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";import { searchProviderSkills } from "../../providerSkillSearch";import { useMediaQuery } from "../../hooks/useMediaQuery"; 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_PATH_QUERY_DEBOUNCE_MS = 120;const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];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<TerminalContextDraft>, ids: ReadonlyArray<string>,): 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<TerminalContextDraft>, ids: ReadonlyArray<string>,): 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; return ( <> <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> {props.showInteractionModeToggle ? ( <> <Button variant="ghost" className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" size="sm" type="button" onClick={props.onToggleInteractionMode} title={ props.interactionMode === "plan" ? "Plan mode — click to return to normal build mode" : "Default mode — click to enter plan mode" } > <BotIcon /> <span className="sr-only sm:not-sr-only"> {props.interactionMode === "plan" ? "Plan" : "Build"} </span> </Button> <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> </> ) : null} <Select value={props.runtimeMode} onValueChange={(value) => props.onRuntimeModeChange(value!)} > <SelectTrigger variant="ghost" size="sm" className="font-medium" aria-label="Runtime mode" title={runtimeModeOption.description} > <RuntimeModeIcon className="size-4" /> <SelectValue>{runtimeModeOption.label}</SelectValue> </SelectTrigger> <SelectPopup alignItemWithTrigger={false}> {runtimeModeOptions.map((mode) => { const option = runtimeModeConfig[mode]; const OptionIcon = option.icon; return ( <SelectItem key={mode} value={mode} className="min-w-64 py-2"> <div className="grid min-w-0 gap-0.5"> <span className="inline-flex items-center gap-1.5 font-medium text-foreground"> <OptionIcon className="size-3.5 shrink-0 text-muted-foreground" /> {option.label} </span> <span className="text-muted-foreground text-xs leading-4"> {option.description} </span> </div> </SelectItem> ); })} </SelectPopup> </Select> {props.showPlanToggle ? ( <> <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> <Button variant="ghost" className={cn( "shrink-0 whitespace-nowrap px-2 sm:px-3", props.planSidebarOpen ? "text-blue-400 hover:text-blue-300" : "text-muted-foreground/70 hover:text-foreground/80", )} size="sm" type="button" onClick={props.onTogglePlanSidebar} title={ props.planSidebarOpen ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` : `Show ${props.planSidebarLabel.toLowerCase()} sidebar` } > <ListTodoIcon /> <span className="sr-only sm:not-sr-only">{props.planSidebarLabel}</span> </Button> </> ) : null} </> );}); const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; activeContextWindow: ReturnType<typeof deriveLatestContextWindowSnapshot>; 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 ? <ContextWindowMeter usage={props.activeContextWindow} /> : null} {props.isPreparingWorktree ? ( <span className="text-muted-foreground/70 text-xs">Preparing worktree...</span> ) : null} <ComposerPrimaryActions compact={props.compact} pendingAction={props.pendingAction} isRunning={props.isRunning} showPlanFollowUpPrompt={props.showPlanFollowUpPrompt} promptHasText={props.promptHasText} isSendBusy={props.isSendBusy} isConnecting={props.isConnecting} isEnvironmentUnavailable={props.isEnvironmentUnavailable} isPreparingWorktree={props.isPreparingWorktree} hasSendableContent={props.hasSendableContent} preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} onImplementPlanInNewThread={props.onImplementPlanInNewThread} /> </> );}); // --------------------------------------------------------------------------// Handle exposed to ChatView// -------------------------------------------------------------------------- export interface ChatComposerHandle { focusAtEnd: () => void; focusAt: (cursor: number) => void; 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[]; selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; selectedProvider: ProviderDriverKind; selectedModel: string; selectedProviderModels: ReadonlyArray<ServerProvider["models"][number]>; };} // --------------------------------------------------------------------------// 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 connectionState: "connecting" | "disconnected" | "error"; } | 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<string, unknown> | null; activePendingIsResponding: boolean; activePendingDraftAnswers: Record<string, PendingUserInputDraftAnswer>; 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.MutableRefObject<string>; composerImagesRef: React.MutableRefObject<ComposerImageAttachment[]>; composerTerminalContextsRef: React.MutableRefObject<TerminalContextDraft[]>; // Scroll shouldAutoScrollRef: React.MutableRefObject<boolean>; scheduleStickToBottom: () => void; // Callbacks onSend: (e?: { preventDefault: () => void }) => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; onRespondToApproval: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, ) => Promise<void>; 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; 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( forwardRef<ChatComposerHandle, ChatComposerProps>(function ChatComposer(props, ref) { const { composerDraftTarget, environmentId, routeKind, routeThreadRef, draftId, activeThreadId,Lines 1–500 of 2423. Load more
import type { ApprovalRequestId, EnvironmentId, ModelSelection, ProjectEntry, 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 { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { useQuery } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; 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 { 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 { getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, } from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../vscode-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, type LucideIcon, LockIcon, LockOpenIcon, PenLineIcon, XIcon, } from "lucide-react"; import { proposedPlanTitle } from "../../proposedPlan"; import { getProviderInteractionModeToggle } from "../../providerModels"; import { 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 } from "../../lib/contextWindow"; import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; 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_PATH_QUERY_DEBOUNCE_MS = 120; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; 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<TerminalContextDraft>, ids: ReadonlyArray<string>, ): 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<TerminalContextDraft>, ids: ReadonlyArray<string>, ): 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; return ( <> <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> {props.showInteractionModeToggle ? ( <> <Button variant="ghost" className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" size="sm" type="button" onClick={props.onToggleInteractionMode} title={ props.interactionMode === "plan" ? "Plan mode — click to return to normal build mode" : "Default mode — click to enter plan mode" } > <BotIcon /> <span className="sr-only sm:not-sr-only"> {props.interactionMode === "plan" ? "Plan" : "Build"} </span> </Button> <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> </> ) : null} <Select value={props.runtimeMode} onValueChange={(value) => props.onRuntimeModeChange(value!)} > <SelectTrigger variant="ghost" size="sm" className="font-medium" aria-label="Runtime mode" title={runtimeModeOption.description} > <RuntimeModeIcon className="size-4" /> <SelectValue>{runtimeModeOption.label}</SelectValue> </SelectTrigger> <SelectPopup alignItemWithTrigger={false}> {runtimeModeOptions.map((mode) => { const option = runtimeModeConfig[mode]; const OptionIcon = option.icon; return ( <SelectItem key={mode} value={mode} className="min-w-64 py-2"> <div className="grid min-w-0 gap-0.5"> <span className="inline-flex items-center gap-1.5 font-medium text-foreground"> <OptionIcon className="size-3.5 shrink-0 text-muted-foreground" /> {option.label} </span> <span className="text-muted-foreground text-xs leading-4"> {option.description} </span> </div> </SelectItem> ); })} </SelectPopup> </Select> {props.showPlanToggle ? ( <> <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> <Button variant="ghost" className={cn( "shrink-0 whitespace-nowrap px-2 sm:px-3", props.planSidebarOpen ? "text-blue-400 hover:text-blue-300" : "text-muted-foreground/70 hover:text-foreground/80", )} size="sm" type="button" onClick={props.onTogglePlanSidebar} title={ props.planSidebarOpen ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` : `Show ${props.planSidebarLabel.toLowerCase()} sidebar` } > <ListTodoIcon /> <span className="sr-only sm:not-sr-only">{props.planSidebarLabel}</span> </Button> </> ) : null} </> ); }); const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; activeContextWindow: ReturnType<typeof deriveLatestContextWindowSnapshot>; 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 ? <ContextWindowMeter usage={props.activeContextWindow} /> : null} {props.isPreparingWorktree ? ( <span className="text-muted-foreground/70 text-xs">Preparing worktree...</span> ) : null} <ComposerPrimaryActions compact={props.compact} pendingAction={props.pendingAction} isRunning={props.isRunning} showPlanFollowUpPrompt={props.showPlanFollowUpPrompt} promptHasText={props.promptHasText} isSendBusy={props.isSendBusy} isConnecting={props.isConnecting} isEnvironmentUnavailable={props.isEnvironmentUnavailable} isPreparingWorktree={props.isPreparingWorktree} hasSendableContent={props.hasSendableContent} preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} onImplementPlanInNewThread={props.onImplementPlanInNewThread} /> </> ); }); // -------------------------------------------------------------------------- // Handle exposed to ChatView // -------------------------------------------------------------------------- export interface ChatComposerHandle { focusAtEnd: () => void; focusAt: (cursor: number) => void; 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[]; selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; selectedProvider: ProviderDriverKind; selectedModel: string; selectedProviderModels: ReadonlyArray<ServerProvider["models"][number]>; }; } // -------------------------------------------------------------------------- // 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 connectionState: "connecting" | "disconnected" | "error"; } | 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<string, unknown> | null; activePendingIsResponding: boolean; activePendingDraftAnswers: Record<string, PendingUserInputDraftAnswer>; 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.MutableRefObject<string>; composerImagesRef: React.MutableRefObject<ComposerImageAttachment[]>; composerTerminalContextsRef: React.MutableRefObject<TerminalContextDraft[]>; // Scroll shouldAutoScrollRef: React.MutableRefObject<boolean>; scheduleStickToBottom: () => void; // Callbacks onSend: (e?: { preventDefault: () => void }) => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; onRespondToApproval: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, ) => Promise<void>; 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; 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( forwardRef<ChatComposerHandle, ChatComposerProps>(function ChatComposer(props, ref) { const { composerDraftTarget, environmentId, routeKind, routeThreadRef, draftId, activeThreadId,