import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, type MessageId, type ProjectScript, type ModelSlug, type ProviderKind, type ProjectEntry, type ProjectId, type ProviderApprovalDecision, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ResolvedKeybindingsConfig, type ServerProviderStatus, type ThreadId, type TurnId, type EditorId, type KeybindingCommand, OrchestrationThreadActivity, ProviderInteractionMode, RuntimeMode, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, getDefaultModel, getDefaultReasoningEffort, getReasoningEffortOptions, isClaudeUltrathinkPrompt, normalizeClaudeModelOptions, normalizeCodexModelOptions, normalizeModelSlug, resolveReasoningEffortForProvider, resolveModelSlugForProvider, supportsClaudeUltrathinkKeyword, } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { clampCollapsedComposerCursor, type ComposerTrigger, collapseExpandedComposerCursor, detectComposerTrigger, expandCollapsedComposerCursor, parseStandaloneComposerSlashCommand, replaceTextRange, } from "../composer-logic"; import { derivePendingApprovals, derivePendingUserInputs, derivePhase, deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, findSidebarProposedPlan, findLatestProposedPlan, deriveWorkLogEntries, hasActionableProposedPlan, hasToolActivityForTurn, isLatestTurnSettled, formatElapsed, } from "../session-logic"; import { isScrollContainerNearBottom } from "../chat-scroll"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, setPendingUserInputCustomAnswer, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { useStore } from "../store"; import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, proposedPlanTitle, resolvePlanFollowUpSubmission, } from "../proposedPlan"; import { truncateTitle } from "../truncateTitle"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, type TurnDiffSummary, } from "../types"; import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { BotIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, CircleAlertIcon, ListTodoIcon, LockIcon, LockOpenIcon, XIcon, } from "lucide-react"; import { Button } from "./ui/button"; import { Separator } from "./ui/separator"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { cn, randomUUID } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, nextProjectScriptId, projectScriptRuntimeEnv, projectScriptIdFromCommand, setupProjectScript, } from "~/projectScripts"; import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { resolveAppModelSelection, useAppSettings } from "../appSettings"; import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, type DraftThreadEnvMode, type PersistedComposerImageAttachment, useComposerDraftStore, useComposerThreadDraft, } from "../composerDraftStore"; import { appendTerminalContextsToPrompt, formatTerminalContextLabel, insertInlineTerminalContextPlaceholder, removeInlineTerminalContextPlaceholder, type TerminalContextDraft, type TerminalContextSelection, } from "../lib/terminalContext"; import { shouldUseCompactComposerFooter } from "./composerFooterLayout"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./chat/ClaudeTraitsPicker"; import { CodexTraitsMenuContent, CodexTraitsPicker } from "./chat/CodexTraitsPicker"; import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu"; import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; import { ProviderHealthBanner } from "./chat/ProviderHealthBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildTemporaryWorktreeBranchName, cloneComposerImageForRetry, collectUserMessageBlobPreviewUrls, deriveComposerSendState, getCustomModelOptionsByProvider, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, PullRequestDialogState, readFileAsDataUrl, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, SendPhase, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDER_STATUSES: ServerProviderStatus[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; function formatOutgoingPrompt(params: { provider: ProviderKind; effort: string | null; text: string; }): string { if (params.provider === "claudeAgent" && params.effort === "ultrathink") { return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); } return params.text; } const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; 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]); interface ChatViewProps { threadId: ThreadId; } export default function ChatView({ threadId }: ChatViewProps) { const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); const markThreadVisited = useStore((store) => store.markThreadVisited); const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ strict: false, select: (params) => parseDiffRouteSearch(params), }); const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); const composerDraft = useComposerThreadDraft(threadId); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; const composerSendState = useMemo( () => deriveComposerSendState({ prompt, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, }), [composerImages.length, composerTerminalContexts, prompt], ); const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider); const setComposerDraftModel = useComposerDraftStore((store) => store.setModel); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, ); 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 addComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.addTerminalContexts, ); const removeComposerDraftTerminalContext = useComposerDraftStore( (store) => store.removeTerminalContext, ); const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); const syncComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.syncPersistedAttachments, ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, ); const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); const clearProjectDraftThreadId = useComposerDraftStore( (store) => store.clearProjectDraftThreadId, ); const draftThread = useComposerDraftStore( (store) => store.draftThreadsByThreadId[threadId] ?? null, ); const promptRef = useRef(prompt); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const composerTerminalContextsRef = useRef(composerTerminalContexts); const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< Record >({}); const [sendPhase, setSendPhase] = useState("idle"); const [sendStartedAt, setSendStartedAt] = useState(null); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] >([]); const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< Record> >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [planSidebarOpen, setPlanSidebarOpen] = useState(false); const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); // Tracks whether the user explicitly dismissed the sidebar for the active turn. const planSidebarDismissedForTurnRef = useRef(null); // When set, the thread-change reset effect will open the sidebar instead of closing it. // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. const planSidebarOpenOnNextThreadRef = useRef(false); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); const [composerCursor, setComposerCursor] = useState(() => collapseExpandedComposerCursor(prompt, prompt.length), ); const [composerTrigger, setComposerTrigger] = useState(() => detectComposerTrigger(prompt, prompt.length), ); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, LastInvokedScriptByProjectSchema, ); const messagesScrollRef = useRef(null); const [messagesScrollElement, setMessagesScrollElement] = useState(null); const shouldAutoScrollRef = useRef(true); const lastKnownScrollTopRef = useRef(0); const isPointerScrollActiveRef = useRef(false); const lastTouchClientYRef = useRef(null); const pendingUserScrollUpIntentRef = useRef(false); const pendingAutoScrollFrameRef = useRef(null); const pendingInteractionAnchorRef = useRef<{ element: HTMLElement; top: number; } | null>(null); const pendingInteractionAnchorFrameRef = useRef(null); const composerEditorRef = useRef(null); const composerFormRef = useRef(null); const composerFormHeightRef = useRef(0); const composerImagesRef = useRef([]); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef([]); const activeComposerMenuItemRef = useRef(null); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { messagesScrollRef.current = element; setMessagesScrollElement(element); }, []); const terminalState = useTerminalStateStore((state) => selectThreadTerminalState(state.terminalStateByThreadId, threadId), ); const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); const setPrompt = useCallback( (nextPrompt: string) => { setComposerDraftPrompt(threadId, nextPrompt); }, [setComposerDraftPrompt, threadId], ); const addComposerImage = useCallback( (image: ComposerImageAttachment) => { addComposerDraftImage(threadId, image); }, [addComposerDraftImage, threadId], ); const addComposerImagesToDraft = useCallback( (images: ComposerImageAttachment[]) => { addComposerDraftImages(threadId, images); }, [addComposerDraftImages, threadId], ); const addComposerTerminalContextsToDraft = useCallback( (contexts: TerminalContextDraft[]) => { addComposerDraftTerminalContexts(threadId, contexts); }, [addComposerDraftTerminalContexts, threadId], ); const removeComposerImageFromDraft = useCallback( (imageId: string) => { removeComposerDraftImage(threadId, imageId); }, [removeComposerDraftImage, threadId], ); const removeComposerTerminalContextFromDraft = useCallback( (contextId: string) => { const contextIndex = composerTerminalContexts.findIndex( (context) => context.id === contextId, ); if (contextIndex < 0) { return; } const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); promptRef.current = nextPrompt.prompt; setPrompt(nextPrompt.prompt); removeComposerDraftTerminalContext(threadId, contextId); setComposerCursor(nextPrompt.cursor); setComposerTrigger( detectComposerTrigger( nextPrompt.prompt, expandCollapsedComposerCursor(nextPrompt.prompt, nextPrompt.cursor), ), ); }, [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], ); const serverThread = threads.find((t) => t.id === threadId); const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId); const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); const localDraftThread = useMemo( () => draftThread ? buildLocalDraftThread( threadId, draftThread, fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex, localDraftError, ) : undefined, [draftThread, fallbackDraftProject?.model, localDraftError, threadId], ); const activeThread = serverThread ?? localDraftThread; const runtimeMode = composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isServerThread = serverThread !== undefined; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeLatestTurn = activeThread?.latestTurn ?? null; const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProject = projects.find((p) => p.id === activeThread?.projectId); const openPullRequestDialog = useCallback( (reference?: string) => { if (!canCheckoutPullRequestIntoThread) { return; } setPullRequestDialogState({ initialReference: reference ?? null, key: Date.now(), }); setComposerHighlightedItemId(null); }, [canCheckoutPullRequestIntoThread], ); const closePullRequestDialog = useCallback(() => { setPullRequestDialogState(null); }, []); const openOrReuseProjectDraftThread = useCallback( async (input: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => { if (!activeProject) { throw new Error("No active project is available for this pull request."); } const storedDraftThread = getDraftThreadByProjectId(activeProject.id); if (storedDraftThread) { setDraftThreadContext(storedDraftThread.threadId, input); setProjectDraftThreadId(activeProject.id, storedDraftThread.threadId, input); if (storedDraftThread.threadId !== threadId) { await navigate({ to: "/$threadId", params: { threadId: storedDraftThread.threadId }, }); } return; } const activeDraftThread = getDraftThread(threadId); if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { setDraftThreadContext(threadId, input); setProjectDraftThreadId(activeProject.id, threadId, input); return; } clearProjectDraftThreadId(activeProject.id); const nextThreadId = newThreadId(); setProjectDraftThreadId(activeProject.id, nextThreadId, { createdAt: new Date().toISOString(), runtimeMode: DEFAULT_RUNTIME_MODE, interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); await navigate({ to: "/$threadId", params: { threadId: nextThreadId }, }); }, [ activeProject, clearProjectDraftThreadId, getDraftThread, getDraftThreadByProjectId, isServerThread, navigate, setDraftThreadContext, setProjectDraftThreadId, threadId, ], ); const handlePreparedPullRequestThread = useCallback( async (input: { branch: string; worktreePath: string | null }) => { await openOrReuseProjectDraftThread({ branch: input.branch, worktreePath: input.worktreePath, envMode: input.worktreePath ? "worktree" : "local", }); }, [openOrReuseProjectDraftThread], ); useEffect(() => { if (!activeThread?.id) return; if (!latestTurnSettled) return; if (!activeLatestTurn?.completedAt) return; const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); if (Number.isNaN(turnCompletedAt)) return; const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; markThreadVisited(activeThread.id); }, [ activeThread?.id, activeThread?.lastVisitedAt, activeLatestTurn?.completedAt, latestTurnSettled, markThreadVisited, ]); const sessionProvider = activeThread?.session?.provider ?? null; const selectedProviderByThreadId = composerDraft.provider; const hasThreadStarted = Boolean( activeThread && (activeThread.latestTurn !== null || activeThread.messages.length > 0 || activeThread.session !== null), ); const lockedProvider: ProviderKind | null = hasThreadStarted ? (sessionProvider ?? selectedProviderByThreadId ?? null) : null; const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; const baseThreadModel = resolveModelSlugForProvider( selectedProvider, activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), ); const customModelsByProvider = useMemo( () => ({ codex: settings.customCodexModels, claudeAgent: settings.customClaudeModels, }), [settings.customClaudeModels, settings.customCodexModels], ); const customModelsForSelectedProvider = customModelsByProvider[selectedProvider]; const selectedModel = useMemo(() => { const draftModel = composerDraft.model; if (!draftModel) { return baseThreadModel; } return resolveAppModelSelection( selectedProvider, customModelsForSelectedProvider, draftModel, ) as ModelSlug; }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]); const draftModelOptions = composerDraft.modelOptions; const selectedCodexEffort = selectedProvider === "codex" ? (resolveReasoningEffortForProvider("codex", draftModelOptions?.codex?.reasoningEffort) ?? getDefaultReasoningEffort("codex")) : null; const selectedClaudeReasoningOptions = selectedProvider === "claudeAgent" ? getReasoningEffortOptions("claudeAgent", selectedModel) : ([] as const); const selectedClaudeBaseEffort = selectedProvider === "claudeAgent" && selectedClaudeReasoningOptions.length > 0 ? (() => { const draftEffort = resolveReasoningEffortForProvider( "claudeAgent", draftModelOptions?.claudeAgent?.effort, ); if ( draftEffort && draftEffort !== "ultrathink" && selectedClaudeReasoningOptions.includes(draftEffort) ) { return draftEffort; } const defaultEffort = getDefaultReasoningEffort("claudeAgent"); return selectedClaudeReasoningOptions.includes(defaultEffort) ? defaultEffort : null; })() : null; const isClaudeUltrathink = selectedProvider === "claudeAgent" && supportsClaudeUltrathinkKeyword(selectedModel) && isClaudeUltrathinkPrompt(prompt); const selectedPromptEffort = selectedCodexEffort ?? selectedClaudeBaseEffort; const selectedModelOptionsForDispatch = useMemo(() => { if (selectedProvider === "codex") { const codexOptions = normalizeCodexModelOptions(draftModelOptions?.codex); return codexOptions ? { codex: codexOptions } : undefined; } if (selectedProvider === "claudeAgent") { const claudeOptions = normalizeClaudeModelOptions( selectedModel, draftModelOptions?.claudeAgent, ); return claudeOptions ? { claudeAgent: claudeOptions } : undefined; } return undefined; }, [draftModelOptions, selectedModel, selectedProvider]); const providerOptionsForDispatch = useMemo(() => { if (!settings.codexBinaryPath && !settings.codexHomePath) { return undefined; } return { codex: { ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}), ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), }, }; }, [settings.codexBinaryPath, settings.codexHomePath]); const selectedModelForPicker = selectedModel; const modelOptionsByProvider = useMemo( () => getCustomModelOptionsByProvider(settings), [settings], ); const selectedModelForPickerWithCustomFallback = useMemo(() => { const currentOptions = modelOptionsByProvider[selectedProvider]; return currentOptions.some((option) => option.slug === selectedModelForPicker) ? selectedModelForPicker : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); const searchableModelOptions = useMemo( () => AVAILABLE_PROVIDER_OPTIONS.filter( (option) => lockedProvider === null || option.value === lockedProvider, ).flatMap((option) => modelOptionsByProvider[option.value].map(({ slug, name }) => ({ provider: option.value, providerLabel: option.label, slug, name, searchSlug: slug.toLowerCase(), searchName: name.toLowerCase(), searchProvider: option.label.toLowerCase(), })), ), [lockedProvider, modelOptionsByProvider], ); const phase = derivePhase(activeThread?.session ?? null); const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, sendStartedAt, ); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); const latestTurnHasToolActivity = useMemo( () => hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId), [activeLatestTurn?.turnId, threadActivities], ); const pendingApprovals = useMemo( () => derivePendingApprovals(threadActivities), [threadActivities], ); const pendingUserInputs = useMemo( () => derivePendingUserInputs(threadActivities), [threadActivities], ); const activePendingUserInput = pendingUserInputs[0] ?? null; const activePendingDraftAnswers = useMemo( () => activePendingUserInput ? (pendingUserInputAnswersByRequestId[activePendingUserInput.requestId] ?? EMPTY_PENDING_USER_INPUT_ANSWERS) : EMPTY_PENDING_USER_INPUT_ANSWERS, [activePendingUserInput, pendingUserInputAnswersByRequestId], ); const activePendingQuestionIndex = activePendingUserInput ? (pendingUserInputQuestionIndexByRequestId[activePendingUserInput.requestId] ?? 0) : 0; const activePendingProgress = useMemo( () => activePendingUserInput ? derivePendingUserInputProgress( activePendingUserInput.questions, activePendingDraftAnswers, activePendingQuestionIndex, ) : null, [activePendingDraftAnswers, activePendingQuestionIndex, activePendingUserInput], ); const activePendingResolvedAnswers = useMemo( () => activePendingUserInput ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingDraftAnswers) : null, [activePendingDraftAnswers, activePendingUserInput], ); const activePendingIsResponding = activePendingUserInput ? respondingUserInputRequestIds.includes(activePendingUserInput.requestId) : false; const activeProposedPlan = useMemo(() => { if (!latestTurnSettled) { return null; } return findLatestProposedPlan( activeThread?.proposedPlans ?? [], activeLatestTurn?.turnId ?? null, ); }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); const sidebarProposedPlan = useMemo( () => findSidebarProposedPlan({ threads, latestTurn: activeLatestTurn, latestTurnSettled, threadId: activeThread?.id ?? null, }), [activeLatestTurn, activeThread?.id, latestTurnSettled, threads], ); const activePlan = useMemo( () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && latestTurnSettled && hasActionableProposedPlan(activeProposedPlan); const activePendingApproval = pendingApprovals[0] ?? null; const isComposerApprovalState = activePendingApproval !== null; const hasComposerHeader = isComposerApprovalState || pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; 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, activePendingUserInput?.requestId, activePendingProgress?.activeQuestion?.id, ]); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); const clearAttachmentPreviewHandoffs = useCallback(() => { for (const timeoutId of Object.values(attachmentPreviewHandoffTimeoutByMessageIdRef.current)) { window.clearTimeout(timeoutId); } attachmentPreviewHandoffTimeoutByMessageIdRef.current = {}; for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) { for (const previewUrl of previewUrls) { revokeBlobPreviewUrl(previewUrl); } } attachmentPreviewHandoffByMessageIdRef.current = {}; setAttachmentPreviewHandoffByMessageId({}); }, []); useEffect(() => { return () => { clearAttachmentPreviewHandoffs(); for (const message of optimisticUserMessagesRef.current) { revokeUserMessagePreviewUrls(message); } }; }, [clearAttachmentPreviewHandoffs]); const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { if (previewUrls.length === 0) return; const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; for (const previewUrl of previousPreviewUrls) { if (!previewUrls.includes(previewUrl)) { revokeBlobPreviewUrl(previewUrl); } } setAttachmentPreviewHandoffByMessageId((existing) => { const next = { ...existing, [messageId]: previewUrls, }; attachmentPreviewHandoffByMessageIdRef.current = next; return next; }); const existingTimeout = attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; if (typeof existingTimeout === "number") { window.clearTimeout(existingTimeout); } attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId] = window.setTimeout(() => { const currentPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId]; if (currentPreviewUrls) { for (const previewUrl of currentPreviewUrls) { revokeBlobPreviewUrl(previewUrl); } } setAttachmentPreviewHandoffByMessageId((existing) => { if (!(messageId in existing)) return existing; const next = { ...existing }; delete next[messageId]; attachmentPreviewHandoffByMessageIdRef.current = next; return next; }); delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS); }, []); const serverMessages = activeThread?.messages; const timelineMessages = useMemo(() => { const messages = serverMessages ?? []; const serverMessagesWithPreviewHandoff = Object.keys(attachmentPreviewHandoffByMessageId).length === 0 ? messages : // Spread only fires for the few messages that actually changed; // unchanged ones early-return their original reference. // In-place mutation would break React's immutable state contract. // oxlint-disable-next-line no-map-spread messages.map((message) => { if ( message.role !== "user" || !message.attachments || message.attachments.length === 0 ) { return message; } const handoffPreviewUrls = attachmentPreviewHandoffByMessageId[message.id]; if (!handoffPreviewUrls || handoffPreviewUrls.length === 0) { return message; } let changed = false; let imageIndex = 0; const attachments = message.attachments.map((attachment) => { if (attachment.type !== "image") { return attachment; } const handoffPreviewUrl = handoffPreviewUrls[imageIndex]; imageIndex += 1; if (!handoffPreviewUrl || attachment.previewUrl === handoffPreviewUrl) { return attachment; } changed = true; return { ...attachment, previewUrl: handoffPreviewUrl, }; }); return changed ? { ...message, attachments } : message; }); if (optimisticUserMessages.length === 0) { return serverMessagesWithPreviewHandoff; } const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id)); const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); if (pendingMessages.length === 0) { return serverMessagesWithPreviewHandoff; } return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); const timelineEntries = useMemo( () => deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), [activeThread?.proposedPlans, timelineMessages, workLogEntries], ); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); const turnDiffSummaryByAssistantMessageId = useMemo(() => { const byMessageId = new Map(); for (const summary of turnDiffSummaries) { if (!summary.assistantMessageId) continue; byMessageId.set(summary.assistantMessageId, summary); } return byMessageId; }, [turnDiffSummaries]); const revertTurnCountByUserMessageId = useMemo(() => { const byUserMessageId = new Map(); for (let index = 0; index < timelineEntries.length; index += 1) { const entry = timelineEntries[index]; if (!entry || entry.kind !== "message" || entry.message.role !== "user") { continue; } for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) { const nextEntry = timelineEntries[nextIndex]; if (!nextEntry || nextEntry.kind !== "message") { continue; } if (nextEntry.message.role === "user") { break; } const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); if (!summary) { continue; } const turnCount = summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; if (typeof turnCount !== "number") { break; } byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1)); break; } } return byUserMessageId; }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); const completionSummary = useMemo(() => { if (!latestTurnSettled) return null; if (!activeLatestTurn?.startedAt) return null; if (!activeLatestTurn.completedAt) return null; if (!latestTurnHasToolActivity) return null; const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt); return elapsed ? `Worked for ${elapsed}` : null; }, [ activeLatestTurn?.completedAt, activeLatestTurn?.startedAt, latestTurnHasToolActivity, latestTurnSettled, ]); const completionDividerBeforeEntryId = useMemo(() => { if (!latestTurnSettled) return null; if (!activeLatestTurn?.startedAt) return null; if (!activeLatestTurn.completedAt) return null; if (!completionSummary) return null; const turnStartedAt = Date.parse(activeLatestTurn.startedAt); const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); if (Number.isNaN(turnStartedAt)) return null; if (Number.isNaN(turnCompletedAt)) return null; let inRangeMatch: string | null = null; let fallbackMatch: string | null = null; for (const timelineEntry of timelineEntries) { if (timelineEntry.kind !== "message") continue; if (timelineEntry.message.role !== "assistant") continue; const messageAt = Date.parse(timelineEntry.message.createdAt); if (Number.isNaN(messageAt) || messageAt < turnStartedAt) continue; fallbackMatch = timelineEntry.id; if (messageAt <= turnCompletedAt) { inRangeMatch = timelineEntry.id; } } return inRangeMatch ?? fallbackMatch; }, [ activeLatestTurn?.completedAt, activeLatestTurn?.startedAt, completionSummary, latestTurnSettled, timelineEntries, ]); const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; const composerTriggerKind = composerTrigger?.kind ?? null; const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( pathTriggerQuery, { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, (debouncerState) => ({ isPending: debouncerState.isPending }), ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const workspaceEntriesQuery = useQuery( projectSearchEntriesQueryOptions({ cwd: gitCwd, query: effectivePathQuery, enabled: isPathTrigger, limit: 80, }), ); const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { return workspaceEntries.map((entry) => ({ id: `path:${entry.kind}:${entry.path}`, type: "path", path: entry.path, pathKind: entry.kind, label: basenameOfPath(entry.path), description: entry.parentPath ?? "", })); } if (composerTrigger.kind === "slash-command") { const slashCommandItems = [ { 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 chat mode", }, ] satisfies ReadonlyArray>; const query = composerTrigger.query.trim().toLowerCase(); if (!query) { return [...slashCommandItems]; } return slashCommandItems.filter( (item) => item.command.includes(query) || item.label.slice(1).includes(query), ); } return searchableModelOptions .filter(({ searchSlug, searchName, searchProvider }) => { const query = composerTrigger.query.trim().toLowerCase(); if (!query) return true; return ( searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query) ); }) .map(({ provider, providerLabel, slug, name }) => ({ id: `model:${provider}:${slug}`, type: "model", provider, model: slug, label: name, description: `${providerLabel} · ${slug}`, })); }, [composerTrigger, searchableModelOptions, workspaceEntries]); const composerMenuOpen = Boolean(composerTrigger); const activeComposerMenuItem = useMemo( () => composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? composerMenuItems[0] ?? null, [composerHighlightedItemId, composerMenuItems], ); composerMenuOpenRef.current = composerMenuOpen; composerMenuItemsRef.current = composerMenuItems; activeComposerMenuItemRef.current = activeComposerMenuItem; const nonPersistedComposerImageIdSet = useMemo( () => new Set(nonPersistedComposerImageIds), [nonPersistedComposerImageIds], ); const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES; const activeProvider = activeThread?.session?.provider ?? "codex"; const activeProviderStatus = useMemo( () => providerStatuses.find((status) => status.provider === activeProvider) ?? null, [activeProvider, providerStatuses], ); const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const threadTerminalRuntimeEnv = useMemo(() => { if (!activeProjectCwd) return {}; return projectScriptRuntimeEnv({ project: { cwd: activeProjectCwd, }, worktreePath: activeThreadWorktreePath, }); }, [activeProjectCwd, activeThreadWorktreePath]); // Default true while loading to avoid toolbar flicker. const isGitRepo = branchesQuery.data?.isRepo ?? true; const splitTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.split"), [keybindings], ); const newTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.new"), [keybindings], ); const closeTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.close"), [keybindings], ); const diffPanelShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "diff.toggle"), [keybindings], ); const onToggleDiff = useCallback(() => { void navigate({ to: "/$threadId", params: { threadId }, replace: true, search: (previous) => { const rest = stripDiffSearchParams(previous); return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; }, }); }, [diffOpen, navigate, threadId]); const envLocked = Boolean( activeThread && (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "closed")), ); const activeTerminalGroup = terminalState.terminalGroups.find( (group) => group.id === terminalState.activeTerminalGroupId, ) ?? terminalState.terminalGroups.find((group) => group.terminalIds.includes(terminalState.activeTerminalId), ) ?? null; const hasReachedSplitLimit = (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP; const setThreadError = useCallback( (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; if (threads.some((thread) => thread.id === targetThreadId)) { setStoreThreadError(targetThreadId, error); return; } setLocalDraftErrorsByThreadId((existing) => { if ((existing[targetThreadId] ?? null) === error) { return existing; } return { ...existing, [targetThreadId]: error, }; }); }, [setStoreThreadError, threads], ); const focusComposer = useCallback(() => { composerEditorRef.current?.focusAtEnd(); }, []); const scheduleComposerFocus = useCallback(() => { window.requestAnimationFrame(() => { focusComposer(); }); }, [focusComposer]); const addTerminalContextToDraft = useCallback( (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( activeThread.id, 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); }); }, [activeThread, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext], ); const setTerminalOpen = useCallback( (open: boolean) => { if (!activeThreadId) return; storeSetTerminalOpen(activeThreadId, open); }, [activeThreadId, storeSetTerminalOpen], ); const setTerminalHeight = useCallback( (height: number) => { if (!activeThreadId) return; storeSetTerminalHeight(activeThreadId, height); }, [activeThreadId, storeSetTerminalHeight], ); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadId) return; setTerminalOpen(!terminalState.terminalOpen); }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]); const splitTerminal = useCallback(() => { if (!activeThreadId || hasReachedSplitLimit) return; const terminalId = `terminal-${randomUUID()}`; storeSplitTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminal]); const createNewTerminal = useCallback(() => { if (!activeThreadId) return; const terminalId = `terminal-${randomUUID()}`; storeNewTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, storeNewTerminal]); const activateTerminal = useCallback( (terminalId: string) => { if (!activeThreadId) return; storeSetActiveTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, storeSetActiveTerminal], ); const closeTerminal = useCallback( (terminalId: string) => { const api = readNativeApi(); if (!activeThreadId || !api) return; const isFinalTerminal = terminalState.terminalIds.length <= 1; const fallbackExitWrite = () => api.terminal .write({ threadId: activeThreadId, terminalId, data: "exit\n" }) .catch(() => undefined); if ("close" in api.terminal && typeof api.terminal.close === "function") { void (async () => { if (isFinalTerminal) { await api.terminal .clear({ threadId: activeThreadId, terminalId }) .catch(() => undefined); } await api.terminal.close({ threadId: activeThreadId, terminalId, deleteHistory: true, }); })().catch(() => fallbackExitWrite()); } else { void fallbackExitWrite(); } storeCloseTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, storeCloseTerminal, terminalState.terminalIds.length], ); const runProjectScript = useCallback( async ( script: ProjectScript, options?: { cwd?: string; env?: Record; worktreePath?: string | null; preferNewTerminal?: boolean; rememberAsLastInvoked?: boolean; allowLocalDraftThread?: boolean; }, ) => { const api = readNativeApi(); if (!api || !activeThreadId || !activeProject || !activeThread) return; if (!isServerThread && !options?.allowLocalDraftThread) return; if (options?.rememberAsLastInvoked !== false) { setLastInvokedScriptByProjectId((current) => { if (current[activeProject.id] === script.id) return current; return { ...current, [activeProject.id]: script.id }; }); } const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; const baseTerminalId = terminalState.activeTerminalId || terminalState.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId); const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; const shouldCreateNewTerminal = wantsNewTerminal; const targetTerminalId = shouldCreateNewTerminal ? `terminal-${randomUUID()}` : baseTerminalId; setTerminalOpen(true); if (shouldCreateNewTerminal) { storeNewTerminal(activeThreadId, targetTerminalId); } else { storeSetActiveTerminal(activeThreadId, targetTerminalId); } setTerminalFocusRequestId((value) => value + 1); const runtimeEnv = projectScriptRuntimeEnv({ project: { cwd: activeProject.cwd, }, worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, ...(options?.env ? { extraEnv: options.env } : {}), }); const openTerminalInput: Parameters[0] = shouldCreateNewTerminal ? { threadId: activeThreadId, terminalId: targetTerminalId, cwd: targetCwd, env: runtimeEnv, cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS, } : { threadId: activeThreadId, terminalId: targetTerminalId, cwd: targetCwd, env: runtimeEnv, }; try { await api.terminal.open(openTerminalInput); await api.terminal.write({ threadId: activeThreadId, terminalId: targetTerminalId, data: `${script.command}\r`, }); } catch (error) { setThreadError( activeThreadId, error instanceof Error ? error.message : `Failed to run script "${script.name}".`, ); } }, [ activeProject, activeThread, activeThreadId, gitCwd, isServerThread, setTerminalOpen, setThreadError, storeNewTerminal, storeSetActiveTerminal, setLastInvokedScriptByProjectId, terminalState.activeTerminalId, terminalState.runningTerminalIds, terminalState.terminalIds, ], ); const persistProjectScripts = useCallback( async (input: { projectId: ProjectId; projectCwd: string; previousScripts: ProjectScript[]; nextScripts: ProjectScript[]; keybinding?: string | null; keybindingCommand: KeybindingCommand; }) => { const api = readNativeApi(); if (!api) return; await api.orchestration.dispatchCommand({ type: "project.meta.update", commandId: newCommandId(), projectId: input.projectId, scripts: input.nextScripts, }); const keybindingRule = decodeProjectScriptKeybindingRule({ keybinding: input.keybinding, command: input.keybindingCommand, }); if (isElectron && keybindingRule) { await api.server.upsertKeybinding(keybindingRule); await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); } }, [queryClient], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput) => { if (!activeProject) return; const nextId = nextProjectScriptId( input.name, activeProject.scripts.map((script) => script.id), ); const nextScript: ProjectScript = { id: nextId, name: input.name, command: input.command, icon: input.icon, runOnWorktreeCreate: input.runOnWorktreeCreate, }; const nextScripts = input.runOnWorktreeCreate ? [ ...activeProject.scripts.map((script) => script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, ), nextScript, ] : [...activeProject.scripts, nextScript]; await persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.cwd, previousScripts: activeProject.scripts, nextScripts, keybinding: input.keybinding, keybindingCommand: commandForProjectScript(nextId), }); }, [activeProject, persistProjectScripts], ); const updateProjectScript = useCallback( async (scriptId: string, input: NewProjectScriptInput) => { if (!activeProject) return; const existingScript = activeProject.scripts.find((script) => script.id === scriptId); if (!existingScript) { throw new Error("Script not found."); } const updatedScript: ProjectScript = { ...existingScript, name: input.name, command: input.command, icon: input.icon, runOnWorktreeCreate: input.runOnWorktreeCreate, }; const nextScripts = activeProject.scripts.map((script) => script.id === scriptId ? updatedScript : input.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, ); await persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.cwd, previousScripts: activeProject.scripts, nextScripts, keybinding: input.keybinding, keybindingCommand: commandForProjectScript(scriptId), }); }, [activeProject, persistProjectScripts], ); const deleteProjectScript = useCallback( async (scriptId: string) => { if (!activeProject) return; const nextScripts = activeProject.scripts.filter((script) => script.id !== scriptId); const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name; try { await persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.cwd, previousScripts: activeProject.scripts, nextScripts, keybinding: null, keybindingCommand: commandForProjectScript(scriptId), }); toastManager.add({ type: "success", title: `Deleted action "${deletedName ?? "Unknown"}"`, }); } catch (error) { toastManager.add({ type: "error", title: "Could not delete action", description: error instanceof Error ? error.message : "An unexpected error occurred.", }); } }, [activeProject, persistProjectScripts], ); const handleRuntimeModeChange = useCallback( (mode: RuntimeMode) => { if (mode === runtimeMode) return; setComposerDraftRuntimeMode(threadId, mode); if (isLocalDraftThread) { setDraftThreadContext(threadId, { runtimeMode: mode }); } scheduleComposerFocus(); }, [ isLocalDraftThread, runtimeMode, scheduleComposerFocus, setComposerDraftRuntimeMode, setDraftThreadContext, threadId, ], ); const handleInteractionModeChange = useCallback( (mode: ProviderInteractionMode) => { if (mode === interactionMode) return; setComposerDraftInteractionMode(threadId, mode); if (isLocalDraftThread) { setDraftThreadContext(threadId, { interactionMode: mode }); } scheduleComposerFocus(); }, [ interactionMode, isLocalDraftThread, scheduleComposerFocus, setComposerDraftInteractionMode, setDraftThreadContext, threadId, ], ); const toggleInteractionMode = useCallback(() => { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); const toggleRuntimeMode = useCallback(() => { void handleRuntimeModeChange( runtimeMode === "full-access" ? "approval-required" : "full-access", ); }, [handleRuntimeModeChange, runtimeMode]); const togglePlanSidebar = useCallback(() => { setPlanSidebarOpen((open) => { if (open) { const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null; if (turnKey) { planSidebarDismissedForTurnRef.current = turnKey; } } else { planSidebarDismissedForTurnRef.current = null; } return !open; }); }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); const persistThreadSettingsForNextTurn = useCallback( async (input: { threadId: ThreadId; createdAt: string; model?: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; }) => { if (!serverThread) { return; } const api = readNativeApi(); if (!api) { return; } if (input.model !== undefined && input.model !== serverThread.model) { await api.orchestration.dispatchCommand({ type: "thread.meta.update", commandId: newCommandId(), threadId: input.threadId, model: input.model, }); } if (input.runtimeMode !== serverThread.runtimeMode) { await api.orchestration.dispatchCommand({ type: "thread.runtime-mode.set", commandId: newCommandId(), threadId: input.threadId, runtimeMode: input.runtimeMode, createdAt: input.createdAt, }); } if (input.interactionMode !== serverThread.interactionMode) { await api.orchestration.dispatchCommand({ type: "thread.interaction-mode.set", commandId: newCommandId(), threadId: input.threadId, interactionMode: input.interactionMode, createdAt: input.createdAt, }); } }, [serverThread], ); // Auto-scroll on new messages const messageCount = timelineMessages.length; const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { const scrollContainer = messagesScrollRef.current; if (!scrollContainer) return; scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior }); lastKnownScrollTopRef.current = scrollContainer.scrollTop; shouldAutoScrollRef.current = true; }, []); const cancelPendingStickToBottom = useCallback(() => { const pendingFrame = pendingAutoScrollFrameRef.current; if (pendingFrame === null) return; pendingAutoScrollFrameRef.current = null; window.cancelAnimationFrame(pendingFrame); }, []); const cancelPendingInteractionAnchorAdjustment = useCallback(() => { const pendingFrame = pendingInteractionAnchorFrameRef.current; if (pendingFrame === null) return; pendingInteractionAnchorFrameRef.current = null; window.cancelAnimationFrame(pendingFrame); }, []); const scheduleStickToBottom = useCallback(() => { if (pendingAutoScrollFrameRef.current !== null) return; pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { pendingAutoScrollFrameRef.current = null; scrollMessagesToBottom(); }); }, [scrollMessagesToBottom]); const onMessagesClickCapture = useCallback( (event: React.MouseEvent) => { const scrollContainer = messagesScrollRef.current; if (!scrollContainer || !(event.target instanceof Element)) return; const trigger = event.target.closest( "button, summary, [role='button'], [data-scroll-anchor-target]", ); if (!trigger || !scrollContainer.contains(trigger)) return; if (trigger.closest("[data-scroll-anchor-ignore]")) return; pendingInteractionAnchorRef.current = { element: trigger, top: trigger.getBoundingClientRect().top, }; cancelPendingInteractionAnchorAdjustment(); pendingInteractionAnchorFrameRef.current = window.requestAnimationFrame(() => { pendingInteractionAnchorFrameRef.current = null; const anchor = pendingInteractionAnchorRef.current; pendingInteractionAnchorRef.current = null; const activeScrollContainer = messagesScrollRef.current; if (!anchor || !activeScrollContainer) return; if (!anchor.element.isConnected || !activeScrollContainer.contains(anchor.element)) return; const nextTop = anchor.element.getBoundingClientRect().top; const delta = nextTop - anchor.top; if (Math.abs(delta) < 0.5) return; activeScrollContainer.scrollTop += delta; lastKnownScrollTopRef.current = activeScrollContainer.scrollTop; }); }, [cancelPendingInteractionAnchorAdjustment], ); const forceStickToBottom = useCallback(() => { cancelPendingStickToBottom(); scrollMessagesToBottom(); scheduleStickToBottom(); }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]); const onMessagesScroll = useCallback(() => { const scrollContainer = messagesScrollRef.current; if (!scrollContainer) return; const currentScrollTop = scrollContainer.scrollTop; const isNearBottom = isScrollContainerNearBottom(scrollContainer); if (!shouldAutoScrollRef.current && isNearBottom) { shouldAutoScrollRef.current = true; pendingUserScrollUpIntentRef.current = false; } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) { const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; if (scrolledUp) { shouldAutoScrollRef.current = false; } pendingUserScrollUpIntentRef.current = false; } else if (shouldAutoScrollRef.current && isPointerScrollActiveRef.current) { const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; if (scrolledUp) { shouldAutoScrollRef.current = false; } } else if (shouldAutoScrollRef.current && !isNearBottom) { // Catch-all for keyboard/assistive scroll interactions. const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1; if (scrolledUp) { shouldAutoScrollRef.current = false; } } setShowScrollToBottom(!shouldAutoScrollRef.current); lastKnownScrollTopRef.current = currentScrollTop; }, []); const onMessagesWheel = useCallback((event: React.WheelEvent) => { if (event.deltaY < 0) { pendingUserScrollUpIntentRef.current = true; } }, []); const onMessagesPointerDown = useCallback((_event: React.PointerEvent) => { isPointerScrollActiveRef.current = true; }, []); const onMessagesPointerUp = useCallback((_event: React.PointerEvent) => { isPointerScrollActiveRef.current = false; }, []); const onMessagesPointerCancel = useCallback((_event: React.PointerEvent) => { isPointerScrollActiveRef.current = false; }, []); const onMessagesTouchStart = useCallback((event: React.TouchEvent) => { const touch = event.touches[0]; if (!touch) return; lastTouchClientYRef.current = touch.clientY; }, []); const onMessagesTouchMove = useCallback((event: React.TouchEvent) => { const touch = event.touches[0]; if (!touch) return; const previousTouchY = lastTouchClientYRef.current; if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { pendingUserScrollUpIntentRef.current = true; } lastTouchClientYRef.current = touch.clientY; }, []); const onMessagesTouchEnd = useCallback((_event: React.TouchEvent) => { lastTouchClientYRef.current = null; }, []); useEffect(() => { return () => { cancelPendingStickToBottom(); cancelPendingInteractionAnchorAdjustment(); }; }, [cancelPendingInteractionAnchorAdjustment, cancelPendingStickToBottom]); useLayoutEffect(() => { if (!activeThread?.id) return; shouldAutoScrollRef.current = true; scheduleStickToBottom(); const timeout = window.setTimeout(() => { const scrollContainer = messagesScrollRef.current; if (!scrollContainer) return; if (isScrollContainerNearBottom(scrollContainer)) return; scheduleStickToBottom(); }, 96); return () => { window.clearTimeout(timeout); }; }, [activeThread?.id, scheduleStickToBottom]); useLayoutEffect(() => { const composerForm = composerFormRef.current; if (!composerForm) return; const measureComposerFormWidth = () => composerForm.clientWidth; composerFormHeightRef.current = composerForm.getBoundingClientRect().height; setIsComposerFooterCompact( shouldUseCompactComposerFooter(measureComposerFormWidth(), { hasWideActions: composerFooterHasWideActions, }), ); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver((entries) => { const [entry] = entries; if (!entry) return; const nextCompact = shouldUseCompactComposerFooter(measureComposerFormWidth(), { hasWideActions: composerFooterHasWideActions, }); setIsComposerFooterCompact((previous) => (previous === nextCompact ? previous : nextCompact)); const nextHeight = entry.contentRect.height; const previousHeight = composerFormHeightRef.current; composerFormHeightRef.current = nextHeight; if (previousHeight > 0 && Math.abs(nextHeight - previousHeight) < 0.5) return; if (!shouldAutoScrollRef.current) return; scheduleStickToBottom(); }); observer.observe(composerForm); return () => { observer.disconnect(); }; }, [activeThread?.id, composerFooterHasWideActions, scheduleStickToBottom]); useEffect(() => { if (!shouldAutoScrollRef.current) return; scheduleStickToBottom(); }, [messageCount, scheduleStickToBottom]); useEffect(() => { if (phase !== "running") return; if (!shouldAutoScrollRef.current) return; scheduleStickToBottom(); }, [phase, scheduleStickToBottom, timelineEntries]); useEffect(() => { setExpandedWorkGroups({}); setPullRequestDialogState(null); if (planSidebarOpenOnNextThreadRef.current) { planSidebarOpenOnNextThreadRef.current = false; setPlanSidebarOpen(true); } else { setPlanSidebarOpen(false); } planSidebarDismissedForTurnRef.current = null; }, [activeThread?.id]); useEffect(() => { if (!composerMenuOpen) { setComposerHighlightedItemId(null); return; } setComposerHighlightedItemId((existing) => existing && composerMenuItems.some((item) => item.id === existing) ? existing : (composerMenuItems[0]?.id ?? null), ); }, [composerMenuItems, composerMenuOpen]); useEffect(() => { setIsRevertingCheckpoint(false); }, [activeThread?.id]); useEffect(() => { if (!activeThread?.id || terminalState.terminalOpen) return; const frame = window.requestAnimationFrame(() => { focusComposer(); }); return () => { window.cancelAnimationFrame(frame); }; }, [activeThread?.id, focusComposer, terminalState.terminalOpen]); useEffect(() => { composerImagesRef.current = composerImages; }, [composerImages]); useEffect(() => { composerTerminalContextsRef.current = composerTerminalContexts; }, [composerTerminalContexts]); useEffect(() => { if (!activeThread?.id) return; if (activeThread.messages.length === 0) { return; } const serverIds = new Set(activeThread.messages.map((message) => message.id)); const removedMessages = optimisticUserMessages.filter((message) => serverIds.has(message.id)); if (removedMessages.length === 0) { return; } const timer = window.setTimeout(() => { setOptimisticUserMessages((existing) => existing.filter((message) => !serverIds.has(message.id)), ); }, 0); for (const removedMessage of removedMessages) { const previewUrls = collectUserMessageBlobPreviewUrls(removedMessage); if (previewUrls.length > 0) { handoffAttachmentPreviews(removedMessage.id, previewUrls); continue; } revokeUserMessagePreviewUrls(removedMessage); } return () => { window.clearTimeout(timer); }; }, [activeThread?.id, activeThread?.messages, handoffAttachmentPreviews, optimisticUserMessages]); useEffect(() => { promptRef.current = prompt; setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); }, [prompt]); useEffect(() => { setOptimisticUserMessages((existing) => { for (const message of existing) { revokeUserMessagePreviewUrls(message); } return []; }); setSendPhase("idle"); setSendStartedAt(null); setComposerHighlightedItemId(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); }, [threadId]); useEffect(() => { let cancelled = false; void (async () => { if (composerImages.length === 0) { clearComposerDraftPersistedAttachments(threadId); return; } const getPersistedAttachmentsForThread = () => useComposerDraftStore.getState().draftsByThreadId[threadId]?.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; } // Stage attachments in persisted draft state first so persist middleware can write them. syncComposerDraftPersistedAttachments(threadId, serialized); } catch { const currentImageIds = new Set(composerImages.map((image) => image.id)); const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); const fallbackPersistedIds = fallbackPersistedAttachments .map((attachment) => attachment.id) .filter((id) => currentImageIds.has(id)); const fallbackPersistedIdSet = new Set(fallbackPersistedIds); const fallbackAttachments = fallbackPersistedAttachments.filter((attachment) => fallbackPersistedIdSet.has(attachment.id), ); if (cancelled) { return; } syncComposerDraftPersistedAttachments(threadId, fallbackAttachments); } })(); return () => { cancelled = true; }; }, [ clearComposerDraftPersistedAttachments, composerImages, syncComposerDraftPersistedAttachments, threadId, ]); const closeExpandedImage = useCallback(() => { setExpandedImage(null); }, []); const navigateExpandedImage = useCallback((direction: -1 | 1) => { setExpandedImage((existing) => { if (!existing || existing.images.length <= 1) { return existing; } const nextIndex = (existing.index + direction + existing.images.length) % existing.images.length; if (nextIndex === existing.index) { return existing; } return { ...existing, index: nextIndex }; }); }, []); useEffect(() => { if (!expandedImage) { return; } const onKeyDown = (event: globalThis.KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeExpandedImage(); return; } if (expandedImage.images.length <= 1) { return; } if (event.key === "ArrowLeft") { event.preventDefault(); event.stopPropagation(); navigateExpandedImage(-1); return; } if (event.key !== "ArrowRight") return; event.preventDefault(); event.stopPropagation(); navigateExpandedImage(1); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [closeExpandedImage, expandedImage, navigateExpandedImage]); const activeWorktreePath = activeThread?.worktreePath; const envMode: DraftThreadEnvMode = activeWorktreePath ? "worktree" : isLocalDraftThread ? (draftThread?.envMode ?? "local") : "local"; useEffect(() => { if (phase !== "running") return; const timer = window.setInterval(() => { setNowTick(Date.now()); }, 1000); return () => { window.clearInterval(timer); }; }, [phase]); const beginSendPhase = useCallback((nextPhase: Exclude) => { setSendStartedAt((current) => current ?? new Date().toISOString()); setSendPhase(nextPhase); }, []); const resetSendPhase = useCallback(() => { setSendPhase("idle"); setSendStartedAt(null); }, []); useEffect(() => { if (sendPhase === "idle") { return; } if ( phase === "running" || activePendingApproval !== null || activePendingUserInput !== null || activeThread?.error ) { resetSendPhase(); } }, [ activePendingApproval, activePendingUserInput, activeThread?.error, phase, resetSendPhase, sendPhase, ]); useEffect(() => { if (!activeThreadId) return; const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false; const current = Boolean(terminalState.terminalOpen); if (!previous && current) { terminalOpenByThreadRef.current[activeThreadId] = current; setTerminalFocusRequestId((value) => value + 1); return; } else if (previous && !current) { terminalOpenByThreadRef.current[activeThreadId] = current; const frame = window.requestAnimationFrame(() => { focusComposer(); }); return () => { window.cancelAnimationFrame(frame); }; } terminalOpenByThreadRef.current[activeThreadId] = current; }, [activeThreadId, focusComposer, terminalState.terminalOpen]); useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { if (!activeThreadId || event.defaultPrevented) return; const shortcutContext = { terminalFocus: isTerminalFocused(), terminalOpen: Boolean(terminalState.terminalOpen), }; const command = resolveShortcutCommand(event, keybindings, { context: shortcutContext, }); if (!command) return; if (command === "terminal.toggle") { event.preventDefault(); event.stopPropagation(); toggleTerminalVisibility(); return; } if (command === "terminal.split") { event.preventDefault(); event.stopPropagation(); if (!terminalState.terminalOpen) { setTerminalOpen(true); } splitTerminal(); return; } if (command === "terminal.close") { event.preventDefault(); event.stopPropagation(); if (!terminalState.terminalOpen) return; closeTerminal(terminalState.activeTerminalId); return; } if (command === "terminal.new") { event.preventDefault(); event.stopPropagation(); if (!terminalState.terminalOpen) { setTerminalOpen(true); } createNewTerminal(); return; } if (command === "diff.toggle") { event.preventDefault(); event.stopPropagation(); onToggleDiff(); return; } const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; const script = activeProject.scripts.find((entry) => entry.id === scriptId); if (!script) return; event.preventDefault(); event.stopPropagation(); void runProjectScript(script); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [ activeProject, terminalState.terminalOpen, terminalState.activeTerminalId, activeThreadId, closeTerminal, createNewTerminal, setTerminalOpen, runProjectScript, splitTerminal, keybindings, onToggleDiff, toggleTerminalVisibility, ]); 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); }; 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 onRevertToTurnCount = useCallback( async (turnCount: number) => { const api = readNativeApi(); if (!api || !activeThread || isRevertingCheckpoint) return; if (phase === "running" || isSendBusy || isConnecting) { setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); return; } const confirmed = await api.dialogs.confirm( [ `Revert this thread to checkpoint ${turnCount}?`, "This will discard newer messages and turn diffs in this thread.", "This action cannot be undone.", ].join("\n"), ); if (!confirmed) { return; } setIsRevertingCheckpoint(true); setThreadError(activeThread.id, null); try { await api.orchestration.dispatchCommand({ type: "thread.checkpoint.revert", commandId: newCommandId(), threadId: activeThread.id, turnCount, createdAt: new Date().toISOString(), }); } catch (err) { setThreadError( activeThread.id, err instanceof Error ? err.message : "Failed to revert thread state.", ); } setIsRevertingCheckpoint(false); }, [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], ); const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); const api = readNativeApi(); if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; if (activePendingProgress) { onAdvanceActivePendingUserInput(); return; } const promptForSend = promptRef.current; const { trimmedPrompt: trimmed, sendableTerminalContexts: sendableComposerTerminalContexts, expiredTerminalContextCount, hasSendableContent, } = deriveComposerSendState({ prompt: promptForSend, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, }); if (showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed, planMarkdown: activeProposedPlan.planMarkdown, }); promptRef.current = ""; clearComposerDraftContent(activeThread.id); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); await onSubmitPlanFollowUp({ text: followUp.text, interactionMode: followUp.interactionMode, }); return; } const standaloneSlashCommand = composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; clearComposerDraftContent(activeThread.id); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); return; } if (!hasSendableContent) { if (expiredTerminalContextCount > 0) { const toastCopy = buildExpiredTerminalContextToastCopy( expiredTerminalContextCount, "empty", ); toastManager.add({ type: "warning", title: toastCopy.title, description: toastCopy.description, }); } return; } if (!activeProject) return; const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeThread.messages.length === 0; const baseBranchForWorktree = isFirstMessage && envMode === "worktree" && !activeThread.worktreePath ? activeThread.branch : null; // In worktree mode, require an explicit base branch so we don't silently // fall back to local execution when branch selection is missing. const shouldCreateWorktree = isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; if (shouldCreateWorktree && !activeThread.branch) { setStoreThreadError( threadIdForSend, "Select a base branch before sending in New worktree mode.", ); return; } sendInFlightRef.current = true; beginSendPhase(baseBranchForWorktree ? "preparing-worktree" : "sending-turn"); const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const messageTextForSend = appendTerminalContextsToPrompt( promptForSend, composerTerminalContextsSnapshot, ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ provider: selectedProvider, effort: selectedPromptEffort, text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ type: "image" as const, name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, dataUrl: await readFileAsDataUrl(image.file), })), ); const optimisticAttachments = composerImagesSnapshot.map((image) => ({ type: "image" as const, id: image.id, name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, previewUrl: image.previewUrl, })); setOptimisticUserMessages((existing) => [ ...existing, { id: messageIdForSend, role: "user", text: outgoingMessageText, ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), createdAt: messageCreatedAt, streaming: false, }, ]); // Sending a message should always bring the latest user turn into view. shouldAutoScrollRef.current = true; forceStickToBottom(); setThreadError(threadIdForSend, null); if (expiredTerminalContextCount > 0) { const toastCopy = buildExpiredTerminalContextToastCopy( expiredTerminalContextCount, "omitted", ); toastManager.add({ type: "warning", title: toastCopy.title, description: toastCopy.description, }); } promptRef.current = ""; clearComposerDraftContent(threadIdForSend); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); let createdServerThreadForLocalDraft = false; let turnStartSucceeded = false; let nextThreadBranch = activeThread.branch; let nextThreadWorktreePath = activeThread.worktreePath; await (async () => { // On first message: lock in branch + create worktree if needed. if (baseBranchForWorktree) { beginSendPhase("preparing-worktree"); const newBranch = buildTemporaryWorktreeBranchName(); const result = await createWorktreeMutation.mutateAsync({ cwd: activeProject.cwd, branch: baseBranchForWorktree, newBranch, }); nextThreadBranch = result.worktree.branch; nextThreadWorktreePath = result.worktree.path; if (isServerThread) { await api.orchestration.dispatchCommand({ type: "thread.meta.update", commandId: newCommandId(), threadId: threadIdForSend, branch: result.worktree.branch, worktreePath: result.worktree.path, }); // Keep local thread state in sync immediately so terminal drawer opens // with the worktree cwd/env instead of briefly using the project root. setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); } } let firstComposerImageName: string | null = null; if (composerImagesSnapshot.length > 0) { const firstComposerImage = composerImagesSnapshot[0]; if (firstComposerImage) { firstComposerImageName = firstComposerImage.name; } } let titleSeed = trimmed; if (!titleSeed) { if (firstComposerImageName) { titleSeed = `Image: ${firstComposerImageName}`; } else if (composerTerminalContextsSnapshot.length > 0) { titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); } else { titleSeed = "New thread"; } } const title = truncateTitle(titleSeed); let threadCreateModel: ModelSlug = selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER.codex; if (isLocalDraftThread) { await api.orchestration.dispatchCommand({ type: "thread.create", commandId: newCommandId(), threadId: threadIdForSend, projectId: activeProject.id, title, model: threadCreateModel, runtimeMode, interactionMode, branch: nextThreadBranch, worktreePath: nextThreadWorktreePath, createdAt: activeThread.createdAt, }); createdServerThreadForLocalDraft = true; } let setupScript: ProjectScript | null = null; if (baseBranchForWorktree) { setupScript = setupProjectScript(activeProject.scripts); } if (setupScript) { let shouldRunSetupScript = false; if (isServerThread) { shouldRunSetupScript = true; } else { if (createdServerThreadForLocalDraft) { shouldRunSetupScript = true; } } if (shouldRunSetupScript) { const setupScriptOptions: Parameters[1] = { worktreePath: nextThreadWorktreePath, rememberAsLastInvoked: false, allowLocalDraftThread: createdServerThreadForLocalDraft, }; if (nextThreadWorktreePath) { setupScriptOptions.cwd = nextThreadWorktreePath; } await runProjectScript(setupScript, setupScriptOptions); } } // Auto-title from first message if (isFirstMessage && isServerThread) { await api.orchestration.dispatchCommand({ type: "thread.meta.update", commandId: newCommandId(), threadId: threadIdForSend, title, }); } if (isServerThread) { await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, createdAt: messageCreatedAt, ...(selectedModel ? { model: selectedModel } : {}), runtimeMode, interactionMode, }); } beginSendPhase("sending-turn"); const turnAttachments = await turnAttachmentsPromise; await api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), threadId: threadIdForSend, message: { messageId: messageIdForSend, role: "user", text: outgoingMessageText, attachments: turnAttachments, }, model: selectedModel || undefined, ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), provider: selectedProvider, assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode, createdAt: messageCreatedAt, }); turnStartSucceeded = true; })().catch(async (err: unknown) => { if (createdServerThreadForLocalDraft && !turnStartSucceeded) { await api.orchestration .dispatchCommand({ type: "thread.delete", commandId: newCommandId(), threadId: threadIdForSend, }) .catch(() => undefined); } if ( !turnStartSucceeded && promptRef.current.length === 0 && composerImagesRef.current.length === 0 && composerTerminalContextsRef.current.length === 0 ) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); for (const message of removed) { revokeUserMessagePreviewUrls(message); } const next = existing.filter((message) => message.id !== messageIdForSend); return next.length === existing.length ? existing : next; }); promptRef.current = promptForSend; setPrompt(promptForSend); setComposerCursor(collapseExpandedComposerCursor(promptForSend, promptForSend.length)); addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); addComposerTerminalContextsToDraft(composerTerminalContextsSnapshot); setComposerTrigger(detectComposerTrigger(promptForSend, promptForSend.length)); } setThreadError( threadIdForSend, err instanceof Error ? err.message : "Failed to send message.", ); }); sendInFlightRef.current = false; if (!turnStartSucceeded) { resetSendPhase(); } }; const onInterrupt = async () => { const api = readNativeApi(); if (!api || !activeThread) return; await api.orchestration.dispatchCommand({ type: "thread.turn.interrupt", commandId: newCommandId(), threadId: activeThread.id, createdAt: new Date().toISOString(), }); }; const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { const api = readNativeApi(); if (!api || !activeThreadId) return; setRespondingRequestIds((existing) => existing.includes(requestId) ? existing : [...existing, requestId], ); await api.orchestration .dispatchCommand({ type: "thread.approval.respond", commandId: newCommandId(), threadId: activeThreadId, requestId, decision, createdAt: new Date().toISOString(), }) .catch((err: unknown) => { setStoreThreadError( activeThreadId, err instanceof Error ? err.message : "Failed to submit approval decision.", ); }); setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); }, [activeThreadId, setStoreThreadError], ); const onRespondToUserInput = useCallback( async (requestId: ApprovalRequestId, answers: Record) => { const api = readNativeApi(); if (!api || !activeThreadId) return; setRespondingUserInputRequestIds((existing) => existing.includes(requestId) ? existing : [...existing, requestId], ); await api.orchestration .dispatchCommand({ type: "thread.user-input.respond", commandId: newCommandId(), threadId: activeThreadId, requestId, answers, createdAt: new Date().toISOString(), }) .catch((err: unknown) => { setStoreThreadError( activeThreadId, err instanceof Error ? err.message : "Failed to submit user input.", ); }); setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); }, [activeThreadId, setStoreThreadError], ); const setActivePendingUserInputQuestionIndex = useCallback( (nextQuestionIndex: number) => { if (!activePendingUserInput) { return; } setPendingUserInputQuestionIndexByRequestId((existing) => ({ ...existing, [activePendingUserInput.requestId]: nextQuestionIndex, })); }, [activePendingUserInput], ); const onSelectActivePendingUserInputOption = useCallback( (questionId: string, optionLabel: string) => { if (!activePendingUserInput) { return; } setPendingUserInputAnswersByRequestId((existing) => ({ ...existing, [activePendingUserInput.requestId]: { ...existing[activePendingUserInput.requestId], [questionId]: { selectedOptionLabel: optionLabel, customAnswer: "", }, }, })); promptRef.current = ""; setComposerCursor(0); setComposerTrigger(null); }, [activePendingUserInput], ); const onChangeActivePendingUserInputCustomAnswer = useCallback( ( questionId: string, value: string, nextCursor: number, expandedCursor: number, cursorAdjacentToMention: boolean, ) => { if (!activePendingUserInput) { return; } promptRef.current = value; setPendingUserInputAnswersByRequestId((existing) => ({ ...existing, [activePendingUserInput.requestId]: { ...existing[activePendingUserInput.requestId], [questionId]: setPendingUserInputCustomAnswer( existing[activePendingUserInput.requestId]?.[questionId], value, ), }, })); setComposerCursor(nextCursor); setComposerTrigger( cursorAdjacentToMention ? null : detectComposerTrigger(value, expandedCursor), ); }, [activePendingUserInput], ); const onAdvanceActivePendingUserInput = useCallback(() => { if (!activePendingUserInput || !activePendingProgress) { return; } if (activePendingProgress.isLastQuestion) { if (activePendingResolvedAnswers) { void onRespondToUserInput(activePendingUserInput.requestId, activePendingResolvedAnswers); } return; } setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1); }, [ activePendingProgress, activePendingResolvedAnswers, activePendingUserInput, onRespondToUserInput, setActivePendingUserInputQuestionIndex, ]); const onPreviousActivePendingUserInputQuestion = useCallback(() => { if (!activePendingProgress) { return; } setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0)); }, [activePendingProgress, setActivePendingUserInputQuestionIndex]); const onSubmitPlanFollowUp = useCallback( async ({ text, interactionMode: nextInteractionMode, }: { text: string; interactionMode: "default" | "plan"; }) => { const api = readNativeApi(); if ( !api || !activeThread || !isServerThread || isSendBusy || isConnecting || sendInFlightRef.current ) { return; } const trimmed = text.trim(); if (!trimmed) { return; } const threadIdForSend = activeThread.id; const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ provider: selectedProvider, effort: selectedPromptEffort, text: trimmed, }); sendInFlightRef.current = true; beginSendPhase("sending-turn"); setThreadError(threadIdForSend, null); setOptimisticUserMessages((existing) => [ ...existing, { id: messageIdForSend, role: "user", text: outgoingMessageText, createdAt: messageCreatedAt, streaming: false, }, ]); shouldAutoScrollRef.current = true; forceStickToBottom(); try { await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, createdAt: messageCreatedAt, ...(selectedModel ? { model: selectedModel } : {}), runtimeMode, interactionMode: nextInteractionMode, }); // Keep the mode toggle and plan-follow-up banner in sync immediately // while the same-thread implementation turn is starting. setComposerDraftInteractionMode(threadIdForSend, nextInteractionMode); await api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), threadId: threadIdForSend, message: { messageId: messageIdForSend, role: "user", text: outgoingMessageText, attachments: [], }, provider: selectedProvider, model: selectedModel || undefined, ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: nextInteractionMode, ...(nextInteractionMode === "default" && activeProposedPlan ? { sourceProposedPlan: { threadId: activeThread.id, planId: activeProposedPlan.id, }, } : {}), createdAt: messageCreatedAt, }); // Optimistically open the plan sidebar when implementing (not refining). // "default" mode here means the agent is executing the plan, which produces // step-tracking activities that the sidebar will display. if (nextInteractionMode === "default") { planSidebarDismissedForTurnRef.current = null; setPlanSidebarOpen(true); } sendInFlightRef.current = false; } catch (err) { setOptimisticUserMessages((existing) => existing.filter((message) => message.id !== messageIdForSend), ); setThreadError( threadIdForSend, err instanceof Error ? err.message : "Failed to send plan follow-up.", ); sendInFlightRef.current = false; resetSendPhase(); } }, [ activeThread, activeProposedPlan, beginSendPhase, forceStickToBottom, isConnecting, isSendBusy, isServerThread, persistThreadSettingsForNextTurn, resetSendPhase, runtimeMode, selectedPromptEffort, selectedModel, selectedModelOptionsForDispatch, providerOptionsForDispatch, selectedProvider, setComposerDraftInteractionMode, setThreadError, settings.enableAssistantStreaming, ], ); const onImplementPlanInNewThread = useCallback(async () => { const api = readNativeApi(); if ( !api || !activeThread || !activeProject || !activeProposedPlan || !isServerThread || isSendBusy || isConnecting || sendInFlightRef.current ) { return; } const createdAt = new Date().toISOString(); const nextThreadId = newThreadId(); const planMarkdown = activeProposedPlan.planMarkdown; const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); const outgoingImplementationPrompt = formatOutgoingPrompt({ provider: selectedProvider, effort: selectedPromptEffort, text: implementationPrompt, }); const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); const nextThreadModel: ModelSlug = selectedModel || (activeThread.model as ModelSlug) || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER.codex; sendInFlightRef.current = true; beginSendPhase("sending-turn"); const finish = () => { sendInFlightRef.current = false; resetSendPhase(); }; await api.orchestration .dispatchCommand({ type: "thread.create", commandId: newCommandId(), threadId: nextThreadId, projectId: activeProject.id, title: nextThreadTitle, model: nextThreadModel, runtimeMode, interactionMode: "default", branch: activeThread.branch, worktreePath: activeThread.worktreePath, createdAt, }) .then(() => { return api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), threadId: nextThreadId, message: { messageId: newMessageId(), role: "user", text: outgoingImplementationPrompt, attachments: [], }, provider: selectedProvider, model: selectedModel || undefined, ...(selectedModelOptionsForDispatch ? { modelOptions: selectedModelOptionsForDispatch } : {}), ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: "default", createdAt, }); }) .then(() => api.orchestration.getSnapshot()) .then((snapshot) => { syncServerReadModel(snapshot); // Signal that the plan sidebar should open on the new thread. planSidebarOpenOnNextThreadRef.current = true; return navigate({ to: "/$threadId", params: { threadId: nextThreadId }, }); }) .catch(async (err) => { await api.orchestration .dispatchCommand({ type: "thread.delete", commandId: newCommandId(), threadId: nextThreadId, }) .catch(() => undefined); await api.orchestration .getSnapshot() .then((snapshot) => { syncServerReadModel(snapshot); }) .catch(() => undefined); toastManager.add({ type: "error", title: "Could not start implementation thread", description: err instanceof Error ? err.message : "An error occurred while creating the new thread.", }); }) .then(finish, finish); }, [ activeProject, activeProposedPlan, activeThread, beginSendPhase, isConnecting, isSendBusy, isServerThread, navigate, resetSendPhase, runtimeMode, selectedPromptEffort, selectedModel, selectedModelOptionsForDispatch, providerOptionsForDispatch, selectedProvider, settings.enableAssistantStreaming, syncServerReadModel, ]); const onProviderModelSelect = useCallback( (provider: ProviderKind, model: ModelSlug) => { if (!activeThread) return; if (lockedProvider !== null && provider !== lockedProvider) { scheduleComposerFocus(); return; } setComposerDraftProvider(activeThread.id, provider); setComposerDraftModel( activeThread.id, resolveAppModelSelection(provider, customModelsByProvider[provider], model), ); scheduleComposerFocus(); }, [ activeThread, customModelsByProvider, lockedProvider, scheduleComposerFocus, setComposerDraftModel, setComposerDraftProvider, ], ); const setPromptFromTraits = useCallback( (nextPrompt: string) => { const currentPrompt = promptRef.current; if (nextPrompt === currentPrompt) { scheduleComposerFocus(); return; } promptRef.current = nextPrompt; setPrompt(nextPrompt); const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); setComposerCursor(nextCursor); setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); scheduleComposerFocus(); }, [scheduleComposerFocus, setPrompt], ); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { if (isLocalDraftThread) { setDraftThreadContext(threadId, { envMode: mode }); } scheduleComposerFocus(); }, [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId], ); const applyPromptReplacement = useCallback( ( rangeStart: number, rangeEnd: number, replacement: string, options?: { expectedText?: string }, ): 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); promptRef.current = next.text; const activePendingQuestion = activePendingProgress?.activeQuestion; if (activePendingQuestion && activePendingUserInput) { setPendingUserInputAnswersByRequestId((existing) => ({ ...existing, [activePendingUserInput.requestId]: { ...existing[activePendingUserInput.requestId], [activePendingQuestion.id]: setPendingUserInputCustomAnswer( existing[activePendingUserInput.requestId]?.[activePendingQuestion.id], next.text, ), }, })); } else { setPrompt(next.text); } setComposerCursor(nextCursor); setComposerTrigger( detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor)), ); window.requestAnimationFrame(() => { composerEditorRef.current?.focusAt(nextCursor); }); return true; }, [activePendingProgress?.activeQuestion, activePendingUserInput, 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]); 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 = `@${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 replacement = "/model "; 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; } 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; } onProviderModelSelect(item.provider, item.model); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), }); if (applied) { setComposerHighlightedItemId(null); } }, [ applyPromptReplacement, handleInteractionModeChange, onProviderModelSelect, resolveActiveComposerTrigger, ], ); const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { setComposerHighlightedItemId(itemId); }, []); 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 isComposerMenuLoading = composerTriggerKind === "path" && ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || workspaceEntriesQuery.isLoading || workspaceEntriesQuery.isFetching); const onPromptChange = useCallback( ( nextPrompt: string, nextCursor: number, expandedCursor: number, cursorAdjacentToMention: boolean, terminalContextIds: string[], ) => { if (activePendingProgress?.activeQuestion && activePendingUserInput) { onChangeActivePendingUserInputCustomAnswer( activePendingProgress.activeQuestion.id, nextPrompt, nextCursor, expandedCursor, cursorAdjacentToMention, ); return; } promptRef.current = nextPrompt; setPrompt(nextPrompt); if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { setComposerDraftTerminalContexts( threadId, syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), ); } setComposerCursor(nextCursor); setComposerTrigger( cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), ); }, [ activePendingProgress?.activeQuestion, activePendingUserInput, composerTerminalContexts, onChangeActivePendingUserInputCustomAnswer, setPrompt, setComposerDraftTerminalContexts, threadId, ], ); 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; if (key === "ArrowDown" && currentItems.length > 0) { nudgeComposerMenuHighlight("ArrowDown"); return true; } if (key === "ArrowUp" && currentItems.length > 0) { nudgeComposerMenuHighlight("ArrowUp"); return true; } if (key === "Tab" || key === "Enter") { const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; if (selectedItem) { onSelectComposerItem(selectedItem); return true; } } } if (key === "Enter" && !event.shiftKey) { void onSend(); return true; } return false; }; const onToggleWorkGroup = useCallback((groupId: string) => { setExpandedWorkGroups((existing) => ({ ...existing, [groupId]: !existing[groupId], })); }, []); const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { void navigate({ to: "/$threadId", params: { threadId }, search: (previous) => { const rest = stripDiffSearchParams(previous); return filePath ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath } : { ...rest, diff: "1", diffTurnId: turnId }; }, }); }, [navigate, threadId], ); const onRevertUserMessage = (messageId: MessageId) => { const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); if (typeof targetTurnCount !== "number") { return; } void onRevertToTurnCount(targetTurnCount); }; // Empty state: no active thread if (!activeThread) { return (
{!isElectron && (
Threads
)} {isElectron && (
No active thread
)}

Select a thread or create a new one to get started.

); } return (
{/* Top bar */}
{ void runProjectScript(script); }} onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} onDeleteProjectScript={deleteProjectScript} onToggleDiff={onToggleDiff} />
{/* Error banner */} setThreadError(activeThread.id, null)} /> {/* Main content area with optional plan sidebar */}
{/* Chat column */}
{/* Messages Wrapper */}
{/* Messages */}
0} isWorking={isWorking} activeTurnInProgress={isWorking || !latestTurnSettled} activeTurnStartedAt={activeWorkStartedAt} scrollContainer={messagesScrollElement} timelineEntries={timelineEntries} completionDividerBeforeEntryId={completionDividerBeforeEntryId} completionSummary={completionSummary} turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} nowIso={nowIso} expandedWorkGroups={expandedWorkGroups} onToggleWorkGroup={onToggleWorkGroup} onOpenTurnDiff={onOpenTurnDiff} revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} onRevertUserMessage={onRevertUserMessage} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={activeProject?.cwd ?? undefined} />
{/* scroll to bottom pill — shown when user has scrolled away from the bottom */} {showScrollToBottom && (
)}
{/* Input bar */}
{activePendingApproval ? (
) : pendingUserInputs.length > 0 ? (
) : showPlanFollowUpPrompt && activeProposedPlan ? (
) : null}
{composerMenuOpen && !isComposerApprovalState && (
)} {!isComposerApprovalState && pendingUserInputs.length === 0 && composerImages.length > 0 && (
{composerImages.map((image) => (
{image.previewUrl ? ( ) : (
{image.name}
)} {nonPersistedComposerImageIdSet.has(image.id) && ( } /> Draft attachment could not be saved locally and may be lost on navigation. )}
))}
)}
{/* Bottom toolbar */} {activePendingApproval ? (
) : (
{/* Provider/model picker */} {isComposerFooterCompact ? ( ) : selectedProvider === "claudeAgent" ? ( ) : null } onToggleInteractionMode={toggleInteractionMode} onTogglePlanSidebar={togglePlanSidebar} onToggleRuntimeMode={toggleRuntimeMode} /> ) : ( <> {selectedProvider === "codex" ? ( <> ) : selectedProvider === "claudeAgent" ? ( <> ) : null} {activePlan || sidebarProposedPlan || planSidebarOpen ? ( <> ) : null} )}
{/* Right side: send / stop button */}
{isPreparingWorktree ? ( Preparing worktree... ) : null} {activePendingProgress ? (
{activePendingProgress.questionIndex > 0 ? ( ) : null}
) : phase === "running" ? ( ) : pendingUserInputs.length === 0 ? ( showPlanFollowUpPrompt ? ( prompt.trim().length > 0 ? ( ) : (
} > void onImplementPlanInNewThread()} > Implement in a new thread
) ) : ( ) ) : null}
)}
{isGitRepo && ( )} {pullRequestDialogState ? ( { if (!open) { closePullRequestDialog(); } }} onPrepared={handlePreparedPullRequestThread} /> ) : null}
{/* end chat column */} {/* Plan sidebar */} {planSidebarOpen ? ( { setPlanSidebarOpen(false); // Track that the user explicitly dismissed for this turn so auto-open won't fight them. const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null; if (turnKey) { planSidebarDismissedForTurnRef.current = turnKey; } }} /> ) : null}
{/* end horizontal flex container */} {(() => { if (!terminalState.terminalOpen || !activeProject) { return null; } return ( ); })()} {expandedImage && expandedImageItem && (
)}
{expandedImageItem.name}

{expandedImageItem.name} {expandedImage.images.length > 1 ? ` (${expandedImage.index + 1}/${expandedImage.images.length})` : ""}

{expandedImage.images.length > 1 && ( )}
)}
); }