import { type ApprovalRequestId, DEFAULT_MODEL, defaultInstanceIdForDriver, type EnvironmentId, type MessageId, type ModelSelection, type ProjectScript, type ProjectId, type ProviderApprovalDecision, ProviderInstanceId, type ServerProvider, type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, type TurnId, type KeybindingCommand, OrchestrationThreadActivity, ProviderInteractionMode, ProviderDriverKind, RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; import { parseScopedThreadKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime"; import { applyClaudePromptEffortPrefix, createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { Debouncer } from "@tanstack/react-pacer"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useVcsStatus } from "~/lib/vcsStatusState"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { collapseExpandedComposerCursor, parseStandaloneComposerSlashCommand, } from "../composer-logic"; import { deriveCompletionDividerBeforeEntryId, derivePendingApprovals, derivePendingUserInputs, derivePhase, deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, findSidebarProposedPlan, findLatestProposedPlan, deriveWorkLogEntries, hasActionableProposedPlan, hasToolActivityForTurn, isLatestTurnSettled, formatElapsed, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { selectProjectsAcrossEnvironments, selectThreadsAcrossEnvironments, useStore, } from "../store"; import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, resolvePlanFollowUpSubmission, } from "../proposedPlan"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, type SessionPhase, type Thread, type TurnDiffSummary, } from "../types"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { commandForProjectScript, nextProjectScriptId, projectScriptIdFromCommand, } from "~/projectScripts"; import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; import { reconnectSavedEnvironment, useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, type DraftThreadEnvMode, useComposerDraftStore, type DraftId, } from "../composerDraftStore"; import { appendTerminalContextsToPrompt, formatTerminalContextLabel, type TerminalContextDraft, type TerminalContextSelection, } from "../lib/terminalContext"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, hasServerAcknowledgedLocalDispatch, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, type LocalDispatchSnapshot, PullRequestDialogState, cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, shouldWriteThreadErrorToCurrentServerThread, waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; import { useServerAvailableEditors, useServerConfig, useServerKeybindings, } from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { retainThreadDetailSubscription } from "../environments/runtime/service"; import { RightPanelSheet } from "./RightPanelSheet"; import { Button } from "./ui/button"; import { buildVersionMismatchDismissalKey, dismissVersionMismatch, isVersionMismatchDismissed, resolveServerConfigVersionMismatch, } from "../versionSkew"; import { equals } from "effect/Equal"; 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_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; type EnvironmentUnavailableState = { readonly environmentId: EnvironmentId; readonly label: string; readonly connectionState: "connecting" | "disconnected" | "error"; }; type ThreadPlanCatalogEntry = Pick; function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { return useStore( useMemo(() => { let previousThreadIds: readonly ThreadId[] = []; let previousResult: ThreadPlanCatalogEntry[] = []; let previousEntries = new Map< ThreadId, { shell: object | null; proposedPlanIds: readonly string[] | undefined; proposedPlansById: Record | undefined; entry: ThreadPlanCatalogEntry; } >(); return (state) => { const sameThreadIds = previousThreadIds.length === threadIds.length && previousThreadIds.every((id, index) => id === threadIds[index]); const nextEntries = new Map< ThreadId, { shell: object | null; proposedPlanIds: readonly string[] | undefined; proposedPlansById: Record | undefined; entry: ThreadPlanCatalogEntry; } >(); const nextResult: ThreadPlanCatalogEntry[] = []; let changed = !sameThreadIds; for (const threadId of threadIds) { let shell: object | undefined; let proposedPlanIds: readonly string[] | undefined; let proposedPlansById: Record | undefined; for (const environmentState of Object.values(state.environmentStateById)) { const matchedShell = environmentState.threadShellById[threadId]; if (!matchedShell) { continue; } shell = matchedShell; proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId]; proposedPlansById = environmentState.proposedPlanByThreadId[threadId] as | Record | undefined; break; } if (!shell) { const previous = previousEntries.get(threadId); if ( previous && previous.shell === null && previous.proposedPlanIds === undefined && previous.proposedPlansById === undefined ) { nextEntries.set(threadId, previous); continue; } changed = true; nextEntries.set(threadId, { shell: null, proposedPlanIds: undefined, proposedPlansById: undefined, entry: { id: threadId, proposedPlans: EMPTY_PROPOSED_PLANS }, }); continue; } const previous = previousEntries.get(threadId); if ( previous && previous.shell === shell && previous.proposedPlanIds === proposedPlanIds && previous.proposedPlansById === proposedPlansById ) { nextEntries.set(threadId, previous); nextResult.push(previous.entry); continue; } changed = true; const proposedPlans = proposedPlanIds && proposedPlanIds.length > 0 && proposedPlansById ? proposedPlanIds.flatMap((planId) => { const proposedPlan = proposedPlansById?.[planId]; return proposedPlan ? [proposedPlan] : []; }) : EMPTY_PROPOSED_PLANS; const entry = { id: threadId, proposedPlans }; nextEntries.set(threadId, { shell, proposedPlanIds, proposedPlansById, entry, }); nextResult.push(entry); } if (!changed && previousResult.length === nextResult.length) { return previousResult; } previousThreadIds = threadIds; previousEntries = nextEntries; previousResult = nextResult; return nextResult; }; }, [threadIds]), ); } function formatOutgoingPrompt(params: { provider: ProviderDriverKind; model: string | null; models: ReadonlyArray; effort: string | null; text: string; }): string { const caps = getProviderModelCapabilities(params.models, params.model, params.provider); const promptEffort = resolvePromptInjectedEffort(caps, params.effort); return applyClaudePromptEffortPrefix(params.text, promptEffort); } const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; type ChatViewProps = | { environmentId: EnvironmentId; threadId: ThreadId; onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; routeKind: "server"; draftId?: never; } | { environmentId: EnvironmentId; threadId: ThreadId; onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; routeKind: "draft"; draftId: DraftId; }; interface TerminalLaunchContext { threadId: ThreadId; cwd: string; worktreePath: string | null; } type PersistentTerminalLaunchContext = Pick; function useLocalDispatchState(input: { activeThread: Thread | undefined; activeLatestTurn: Thread["latestTurn"] | null; phase: SessionPhase; activePendingApproval: ApprovalRequestId | null; activePendingUserInput: ApprovalRequestId | null; threadError: string | null | undefined; }) { const [localDispatch, setLocalDispatch] = useState(null); const beginLocalDispatch = useCallback( (options?: { preparingWorktree?: boolean }) => { const preparingWorktree = Boolean(options?.preparingWorktree); setLocalDispatch((current) => { if (current) { return current.preparingWorktree === preparingWorktree ? current : { ...current, preparingWorktree }; } return createLocalDispatchSnapshot(input.activeThread, options); }); }, [input.activeThread], ); const resetLocalDispatch = useCallback(() => { setLocalDispatch(null); }, []); const serverAcknowledgedLocalDispatch = useMemo( () => hasServerAcknowledgedLocalDispatch({ localDispatch, phase: input.phase, latestTurn: input.activeLatestTurn, session: input.activeThread?.session ?? null, hasPendingApproval: input.activePendingApproval !== null, hasPendingUserInput: input.activePendingUserInput !== null, threadError: input.threadError, }), [ input.activeLatestTurn, input.activePendingApproval, input.activePendingUserInput, input.activeThread?.session, input.phase, input.threadError, localDispatch, ], ); useEffect(() => { if (!serverAcknowledgedLocalDispatch) { return; } resetLocalDispatch(); }, [resetLocalDispatch, serverAcknowledgedLocalDispatch]); return { beginLocalDispatch, resetLocalDispatch, localDispatchStartedAt: localDispatch?.startedAt ?? null, isPreparingWorktree: localDispatch?.preparingWorktree ?? false, isSendBusy: localDispatch !== null && !serverAcknowledgedLocalDispatch, }; } /** Same terminal ids (order ignored) — avoids reconcile when only server session ordering differs. */ function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean { if (left.length !== right.length) { return false; } if (left.length === 0) { return true; } const sortedLeft = left.toSorted((a, b) => a.localeCompare(b)); const sortedRight = right.toSorted((a, b) => a.localeCompare(b)); for (let index = 0; index < sortedLeft.length; index += 1) { if (sortedLeft[index] !== sortedRight[index]) { return false; } } return true; } /** * Server knows about fewer sessions than the client, but every server id still exists locally. * Typical right after `terminal.open`: known-session list lags; reconciling would drop the new id * and later re-add it as a separate group (no split layout). */ function serverTerminalIdsStrictSubsetOfClient( serverIds: readonly string[], clientIds: readonly string[], ): boolean { if (serverIds.length >= clientIds.length || clientIds.length === 0) { return false; } const clientSet = new Set(clientIds); for (const id of serverIds) { if (!clientSet.has(id)) { return false; } } return true; } interface PersistentThreadTerminalDrawerProps { threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; threadId: ThreadId; visible: boolean; launchContext: PersistentTerminalLaunchContext | null; focusRequestId: number; splitShortcutLabel: string | undefined; newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; keybindings: ResolvedKeybindingsConfig; onAddTerminalContext: (selection: TerminalContextSelection) => void; } const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ threadRef, threadId, visible, launchContext, focusRequestId, splitShortcutLabel, newShortcutLabel, closeShortcutLabel, keybindings, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); const projectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), ); const knownTerminalSessions = useKnownTerminalSessions({ environmentId: threadRef.environmentId, threadId, }); const terminalLabelsById = useMemo(() => { const next = new Map(); for (const session of knownTerminalSessions) { next.set( session.target.terminalId, resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), ); } return next; }, [knownTerminalSessions]); const terminalLaunchLocationsById = useMemo(() => { const next = new Map< string, { readonly cwd: string; readonly worktreePath: string | null; readonly runtimeEnv: Record; } >(); if (!project) { return next; } for (const session of knownTerminalSessions) { const summary = session.state.summary; if (!summary) { continue; } const worktreePathForLaunch = launchContext !== null ? launchContext.worktreePath : summary.worktreePath; next.set(session.target.terminalId, { cwd: launchContext?.cwd ?? summary.cwd, worktreePath: worktreePathForLaunch, runtimeEnv: projectScriptRuntimeEnv({ project: { cwd: project.cwd }, worktreePath: worktreePathForLaunch, }), }); } return next; }, [knownTerminalSessions, launchContext, project]); const serverOrderedTerminalIds = useMemo( () => knownTerminalSessions.map((session) => session.target.terminalId), [knownTerminalSessions], ); const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); useEffect(() => { if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) { return; } if ( serverTerminalIdsStrictSubsetOfClient(serverOrderedTerminalIds, terminalUiState.terminalIds) ) { return; } reconcileTerminalIds(threadRef, serverOrderedTerminalIds); }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]); const [localFocusRequestId, setLocalFocusRequestId] = useState(0); const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; const effectiveWorktreePath = useMemo(() => { if (launchContext !== null) { return launchContext.worktreePath; } return worktreePath; }, [launchContext, worktreePath]); const cwd = useMemo( () => launchContext?.cwd ?? (project ? projectScriptCwd({ project: { cwd: project.cwd }, worktreePath: effectiveWorktreePath, }) : null), [effectiveWorktreePath, launchContext?.cwd, project], ); const runtimeEnv = useMemo( () => project ? projectScriptRuntimeEnv({ project: { cwd: project.cwd }, worktreePath: effectiveWorktreePath, }) : {}, [effectiveWorktreePath, project], ); const bumpFocusRequestId = useCallback(() => { if (!visible) { return; } setLocalFocusRequestId((value) => value + 1); }, [visible]); const setTerminalHeight = useCallback( (height: number) => { storeSetTerminalHeight(threadRef, height); }, [storeSetTerminalHeight, threadRef], ); const splitTerminal = useCallback(() => { const api = readEnvironmentApi(threadRef.environmentId); if (!api || !cwd) { return; } const terminalId = nextTerminalId(serverOrderedTerminalIds); storeSplitTerminal(threadRef, terminalId); bumpFocusRequestId(); void (async () => { try { await api.terminal.open({ threadId, terminalId, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), env: runtimeEnv, }); } catch { // Opening failed; the tab is already in the store — user can retry or close it. } })(); }, [ bumpFocusRequestId, cwd, effectiveWorktreePath, runtimeEnv, serverOrderedTerminalIds, storeSplitTerminal, threadId, threadRef, ]); const createNewTerminal = useCallback(() => { const api = readEnvironmentApi(threadRef.environmentId); if (!api || !cwd) { return; } const terminalId = nextTerminalId(serverOrderedTerminalIds); storeNewTerminal(threadRef, terminalId); bumpFocusRequestId(); void (async () => { try { await api.terminal.open({ threadId, terminalId, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), env: runtimeEnv, }); } catch { // Opening failed; the tab is already in the store — user can retry or close it. } })(); }, [ bumpFocusRequestId, cwd, effectiveWorktreePath, runtimeEnv, serverOrderedTerminalIds, storeNewTerminal, threadId, threadRef, ]); const activateTerminal = useCallback( (terminalId: string) => { storeSetActiveTerminal(threadRef, terminalId); bumpFocusRequestId(); }, [bumpFocusRequestId, storeSetActiveTerminal, threadRef], ); const closeTerminal = useCallback( (terminalId: string) => { const api = readEnvironmentApi(threadRef.environmentId); if (!api) return; const isFinalTerminal = terminalUiState.terminalIds.length <= 1; const fallbackExitWrite = () => api.terminal.write({ threadId, 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, terminalId }).catch(() => undefined); } await api.terminal.close({ threadId, terminalId, deleteHistory: true, }); })().catch(() => fallbackExitWrite()); } else { void fallbackExitWrite(); } storeCloseTerminal(threadRef, terminalId); bumpFocusRequestId(); }, [bumpFocusRequestId, storeCloseTerminal, terminalUiState.terminalIds, threadId, threadRef], ); const handleAddTerminalContext = useCallback( (selection: TerminalContextSelection) => { if (!visible) { return; } onAddTerminalContext(selection); }, [onAddTerminalContext, visible], ); if (!project || !terminalUiState.terminalOpen || !cwd) { return null; } return (
); }); export default function ChatView(props: ChatViewProps) { const { environmentId, threadId, routeKind, onDiffPanelOpen, reserveTitleBarControlInset = true, } = props; const draftId = routeKind === "draft" ? props.draftId : null; const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); const composerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; const serverThread = useStore( useMemo( () => createThreadSelectorByRef(routeKind === "server" ? routeThreadRef : null), [routeKind, routeThreadRef], ), ); const setStoreThreadError = useStore((store) => store.setError); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLastVisitedAt = useUiStateStore((store) => routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, ); const settings = useSettings(); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; const navigate = useNavigate(); const rawSearch = useSearch({ strict: false, select: (params) => parseDiffRouteSearch(params), }); const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. const composerRuntimeMode = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.runtimeMode ?? null, ); const composerInteractionMode = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.interactionMode ?? null, ); const composerActiveProvider = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, ); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const getDraftSessionByLogicalProjectKey = useComposerDraftStore( (store) => store.getDraftSessionByLogicalProjectKey, ); const getDraftSession = useComposerDraftStore((store) => store.getDraftSession); const setLogicalProjectDraftThreadId = useComposerDraftStore( (store) => store.setLogicalProjectDraftThreadId, ); const draftThread = useComposerDraftStore((store) => routeKind === "server" ? store.getDraftSessionByRef(routeThreadRef) : draftId ? store.getDraftSession(draftId) : null, ); const promptRef = useRef(""); const composerImagesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< Record >({}); 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 [planSidebarOpen, setPlanSidebarOpen] = useState(false); const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); // 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 [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); const [terminalUiLaunchContext, setTerminalUiLaunchContext] = useState(null); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] = useState(null); const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState(); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, LastInvokedScriptByProjectSchema, ); const legendListRef = useRef(null); const isAtEndRef = useRef(true); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const terminalUiOpenByThreadRef = useRef>({}); const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), ); const openTerminalThreadKeys = useTerminalUiStateStore( useShallow((state) => Object.entries(state.terminalUiStateByThreadKey).flatMap( ([nextThreadKey, nextTerminalUiState]) => nextTerminalUiState.terminalOpen ? [nextThreadKey] : [], ), ), ); const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); const serverThreadKeys = useStore( useShallow((state) => selectThreadsAcrossEnvironments(state).map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ), ), ); const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); const draftThreadKeys = useMemo( () => Object.values(draftThreadsByThreadKey).map((draftThread) => scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), ), [draftThreadsByThreadKey], ); const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); const mountedTerminalThreadRefs = useMemo( () => mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; }), [mountedTerminalThreadKeys], ); const fallbackDraftProjectRef = draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; const fallbackDraftProject = useStore( useMemo(() => createProjectSelectorByRef(fallbackDraftProjectRef), [fallbackDraftProjectRef]), ); const localDraftError = routeKind === "server" && serverThread ? null : ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null); const localDraftThread = useMemo( () => draftThread ? buildLocalDraftThread( threadId, draftThread, fallbackDraftProject?.defaultModelSelection ?? { instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, }, localDraftError, ) : undefined, [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId], ); const isServerThread = routeKind === "server" && serverThread !== undefined; const activeThread = isServerThread ? serverThread : localDraftThread; const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const runningTerminalIds = useThreadRunningTerminalIds({ environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, }); const activeThreadKnownSessionsRaw = useKnownTerminalSessions({ environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, }); const activeThreadKnownSessions = useMemo(() => { if (activeThreadId === null) { return []; } return activeThreadKnownSessionsRaw.filter( (session) => session.target.threadId === activeThreadId, ); }, [activeThreadId, activeThreadKnownSessionsRaw]); const activeServerOrderedTerminalIds = useMemo( () => activeThreadKnownSessions.map((session) => session.target.terminalId), [activeThreadKnownSessions], ); const activeKnownTerminalIds = useMemo( () => [...new Set([...activeServerOrderedTerminalIds, ...terminalUiState.terminalIds])], [activeServerOrderedTerminalIds, terminalUiState.terminalIds], ); const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); const activeThreadRef = useMemo( () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; useEffect(() => { if (!activeThreadRef) { return; } if (terminalIdListsEqual(activeServerOrderedTerminalIds, terminalUiState.terminalIds)) { return; } if ( serverTerminalIdsStrictSubsetOfClient( activeServerOrderedTerminalIds, terminalUiState.terminalIds, ) ) { return; } reconcileTerminalIds(activeThreadRef, activeServerOrderedTerminalIds); }, [ activeThreadRef, activeServerOrderedTerminalIds, reconcileTerminalIds, terminalUiState.terminalIds, ]); const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; const threadPlanCatalog = useThreadPlanCatalog( useMemo(() => { const threadIds: ThreadId[] = []; if (activeThread?.id) { threadIds.push(activeThread.id); } const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; if (sourceThreadId && sourceThreadId !== activeThread?.id) { threadIds.push(sourceThreadId); } return threadIds; }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]), ); useEffect(() => { setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ currentThreadIds, openThreadIds: existingOpenTerminalThreadKeys, activeThreadId: activeThreadKey, activeThreadTerminalOpen: Boolean(activeThreadKey && terminalUiState.terminalOpen), maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, }); return currentThreadIds.length === nextThreadIds.length && currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) ? currentThreadIds : nextThreadIds; }); }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProjectRef = activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null; const activeProject = useStore( useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), ); useEffect(() => { if (routeKind !== "server") { return; } return retainThreadDetailSubscription(environmentId, threadId); }, [environmentId, routeKind, threadId]); // Compute the list of environments this logical project spans, used to // drive the environment picker in BranchToolbar. const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); const primaryEnvironmentId = usePrimaryEnvironmentId(); const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); const activeSavedEnvironmentRecord = activeThread && activeThread.environmentId !== primaryEnvironmentId ? (savedEnvironmentRegistry[activeThread.environmentId] ?? null) : null; const activeSavedEnvironmentRuntime = activeSavedEnvironmentRecord ? (savedEnvironmentRuntimeById[activeSavedEnvironmentRecord.environmentId] ?? null) : null; const activeSavedEnvironmentConnectionState = activeSavedEnvironmentRecord ? (activeSavedEnvironmentRuntime?.connectionState ?? "disconnected") : "connected"; const activeEnvironmentUnavailable = activeSavedEnvironmentRecord !== null && activeSavedEnvironmentConnectionState !== "connected"; const activeSavedEnvironmentId = activeSavedEnvironmentRecord?.environmentId ?? null; const activeEnvironmentUnavailableLabel = activeSavedEnvironmentRecord ? resolveEnvironmentOptionLabel({ isPrimary: false, environmentId: activeSavedEnvironmentRecord.environmentId, runtimeLabel: activeSavedEnvironmentRuntime?.descriptor?.label ?? null, savedLabel: activeSavedEnvironmentRecord.label, }) : null; const activeEnvironmentUnavailableState = useMemo(() => { if ( !activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeSavedEnvironmentId ) { return null; } return { environmentId: activeSavedEnvironmentId, label: activeEnvironmentUnavailableLabel, connectionState: activeSavedEnvironmentConnectionState === "connecting" || activeSavedEnvironmentConnectionState === "error" ? activeSavedEnvironmentConnectionState : "disconnected", }; }, [ activeEnvironmentUnavailable, activeEnvironmentUnavailableLabel, activeSavedEnvironmentConnectionState, activeSavedEnvironmentId, ]); const [reconnectingEnvironmentId, setReconnectingEnvironmentId] = useState( null, ); const handleReconnectActiveEnvironment = useCallback( async (environmentId: EnvironmentId, label: string) => { setReconnectingEnvironmentId(environmentId); try { await reconnectSavedEnvironment(environmentId); toastManager.add({ type: "success", title: "Environment reconnected", description: `${label} is ready.`, }); } catch (error) { toastManager.add( stackedThreadToast({ type: "error", title: "Could not reconnect environment", description: error instanceof Error ? error.message : "Failed to reconnect.", }), ); } finally { setReconnectingEnvironmentId(null); } }, [], ); const projectGroupingSettings = useSettings(selectProjectGroupingSettings); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); const memberProjects = allProjects.filter( (p) => deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) === logicalKey, ); const seen = new Set(); const envs: Array<{ environmentId: EnvironmentId; projectId: ProjectId; label: string; isPrimary: boolean; }> = []; for (const p of memberProjects) { if (seen.has(p.environmentId)) continue; seen.add(p.environmentId); const isPrimary = p.environmentId === primaryEnvironmentId; const savedRecord = savedEnvironmentRegistry[p.environmentId]; const runtimeState = savedEnvironmentRuntimeById[p.environmentId]; const label = resolveEnvironmentOptionLabel({ isPrimary, environmentId: p.environmentId, runtimeLabel: runtimeState?.descriptor?.label ?? null, savedLabel: savedRecord?.label ?? null, }); envs.push({ environmentId: p.environmentId, projectId: p.id, label, isPrimary, }); } // Sort: primary first, then alphabetical envs.sort((a, b) => { if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1; return a.label.localeCompare(b.label); }); return envs; }, [ activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, savedEnvironmentRegistry, savedEnvironmentRuntimeById, ]); const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; const openPullRequestDialog = useCallback( (reference?: string) => { if (!canCheckoutPullRequestIntoThread) { return; } setPullRequestDialogState({ initialReference: reference ?? null, key: Date.now(), }); }, [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 activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); const logicalProjectKey = deriveLogicalProjectKeyFromSettings( activeProject, projectGroupingSettings, ); const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); if (storedDraftSession) { setDraftThreadContext(storedDraftSession.draftId, input); setLogicalProjectDraftThreadId( logicalProjectKey, activeProjectRef, storedDraftSession.draftId, { threadId: storedDraftSession.threadId, ...input, }, ); if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) { await navigate({ to: "/draft/$draftId", params: buildDraftThreadRouteParams(storedDraftSession.draftId), }); } return storedDraftSession.threadId; } const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null; if ( !isServerThread && activeDraftSession?.logicalProjectKey === logicalProjectKey && draftId ) { setDraftThreadContext(draftId, input); setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, draftId, { threadId: activeDraftSession.threadId, createdAt: activeDraftSession.createdAt, runtimeMode: activeDraftSession.runtimeMode, interactionMode: activeDraftSession.interactionMode, ...input, }); return activeDraftSession.threadId; } const nextDraftId = newDraftId(); const nextThreadId = newThreadId(); setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { threadId: nextThreadId, createdAt: new Date().toISOString(), runtimeMode: DEFAULT_RUNTIME_MODE, interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); await navigate({ to: "/draft/$draftId", params: buildDraftThreadRouteParams(nextDraftId), }); return nextThreadId; }, [ activeProject, draftId, getDraftSession, getDraftSessionByLogicalProjectKey, isServerThread, navigate, projectGroupingSettings, routeKind, setDraftThreadContext, setLogicalProjectDraftThreadId, ], ); 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 (!serverThread?.id) return; if (!latestTurnSettled) return; if (!activeLatestTurn?.completedAt) return; const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); if (Number.isNaN(turnCompletedAt)) return; const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; markThreadVisited( scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), activeLatestTurn.completedAt, ); }, [ activeLatestTurn?.completedAt, activeThreadLastVisitedAt, latestTurnSettled, markThreadVisited, serverThread?.environmentId, serverThread?.id, ]); const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.instanceId ?? activeProject?.defaultModelSelection?.instanceId ?? null; const lockedProvider = deriveLockedProvider({ thread: activeThread, selectedProvider: selectedProviderByThreadId, threadProvider, }); const primaryServerConfig = useServerConfig(); const activeEnvRuntimeState = useSavedEnvironmentRuntimeStore((s) => activeThread?.environmentId ? s.byId[activeThread.environmentId] : null, ); // Use the server config for the thread's environment. For the primary // environment fall back to the global atom; for remote environments use // the runtime state stored by the environment manager. const serverConfig = primaryEnvironmentId && activeThread?.environmentId === primaryEnvironmentId ? primaryServerConfig : (activeEnvRuntimeState?.serverConfig ?? primaryServerConfig); const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread ? buildVersionMismatchDismissalKey(activeThread.environmentId, versionMismatch) : null; const [dismissedVersionMismatchKey, setDismissedVersionMismatchKey] = useState( null, ); const versionMismatchDismissed = versionMismatchDismissKey === dismissedVersionMismatchKey || isVersionMismatchDismissed(versionMismatchDismissKey); const showVersionMismatchBanner = versionMismatch !== null && versionMismatchDismissKey !== null && !versionMismatchDismissed; const hasMultipleRegisteredEnvironments = Object.keys(savedEnvironmentRegistry).length > 0; const versionMismatchServerLabel = useMemo(() => { if (!hasMultipleRegisteredEnvironments || !activeThread) { return "server"; } const isPrimary = activeThread.environmentId === primaryEnvironmentId; const savedRecord = savedEnvironmentRegistry[activeThread.environmentId]; const runtimeState = savedEnvironmentRuntimeById[activeThread.environmentId]; return `${resolveEnvironmentOptionLabel({ isPrimary, environmentId: activeThread.environmentId, runtimeLabel: runtimeState?.descriptor?.label ?? serverConfig?.environment.label ?? null, savedLabel: savedRecord?.label ?? null, })} server`; }, [ activeThread, hasMultipleRegisteredEnvironments, primaryEnvironmentId, savedEnvironmentRegistry, savedEnvironmentRuntimeById, serverConfig?.environment.label, ]); const composerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; if (activeEnvironmentUnavailableState) { items.push({ id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, variant: activeEnvironmentUnavailableState.connectionState === "error" ? "error" : "warning", icon: , title: ( <> {activeEnvironmentUnavailableState.label} is{" "} {activeEnvironmentUnavailableState.connectionState === "connecting" ? "connecting" : "disconnected"} ), description: "Reconnect this environment before sending messages or running actions.", actions: ( <> ), }); } if (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey) { items.push({ id: `version-mismatch:${versionMismatchDismissKey}`, variant: "warning", icon: , title: "Client and server versions differ", description: ( <> Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "} {versionMismatch.serverVersion}. Sync them if RPC calls or reconnects fail. ), dismissLabel: "Dismiss version mismatch warning", onDismiss: () => { dismissVersionMismatch(versionMismatchDismissKey); setDismissedVersionMismatchKey(versionMismatchDismissKey); }, }); } return items; }, [ activeEnvironmentUnavailableState, handleReconnectActiveEnvironment, navigate, reconnectingEnvironmentId, showVersionMismatchBanner, versionMismatch, versionMismatchDismissKey, versionMismatchServerLabel, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const unlockedSelectedProvider = resolveSelectableProvider( providerStatuses, selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"), ); const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); 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: threadPlanCatalog, latestTurn: activeLatestTurn, latestTurnSettled, threadId: activeThread?.id ?? null, }), [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog], ); const activePlan = useMemo( () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && latestTurnSettled && hasActionableProposedPlan(activeProposedPlan); const activePendingApproval = pendingApprovals[0] ?? null; const { beginLocalDispatch, resetLocalDispatch, localDispatchStartedAt, isPreparingWorktree, isSendBusy, } = useLocalDispatchState({ activeThread, activeLatestTurn, phase, activePendingApproval: activePendingApproval?.requestId ?? null, activePendingUserInput: activePendingUserInput?.requestId ?? null, threadError: activeThread?.error, }); const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, localDispatchStartedAt, ); useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); const clearAttachmentPreviewHandoff = useCallback( (messageId: MessageId, previewUrls?: ReadonlyArray) => { delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; const currentPreviewUrls = previewUrls ?? attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; setAttachmentPreviewHandoffByMessageId((existing) => { if (!(messageId in existing)) { return existing; } const next = { ...existing }; delete next[messageId]; attachmentPreviewHandoffByMessageIdRef.current = next; return next; }); for (const previewUrl of currentPreviewUrls) { revokeBlobPreviewUrl(previewUrl); } }, [], ); const clearAttachmentPreviewHandoffs = useCallback(() => { attachmentPreviewPromotionInFlightByMessageIdRef.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 serverMessages = activeThread?.messages; useEffect(() => { if (typeof Image === "undefined" || !serverMessages || serverMessages.length === 0) { return; } const cleanups: Array<() => void> = []; for (const [messageId, handoffPreviewUrls] of Object.entries( attachmentPreviewHandoffByMessageId, )) { if (attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]) { continue; } const serverMessage = serverMessages.find( (message) => message.id === messageId && message.role === "user", ); if (!serverMessage?.attachments || serverMessage.attachments.length === 0) { continue; } const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) => attachment.type === "image" && attachment.previewUrl ? [attachment.previewUrl] : [], ); if ( serverPreviewUrls.length === 0 || serverPreviewUrls.length !== handoffPreviewUrls.length || serverPreviewUrls.some((previewUrl) => previewUrl.startsWith("blob:")) ) { continue; } attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId] = true; let cancelled = false; const imageInstances: HTMLImageElement[] = []; const preloadServerPreviews = Promise.all( serverPreviewUrls.map( (previewUrl) => new Promise((resolve, reject) => { const image = new Image(); imageInstances.push(image); const handleLoad = () => resolve(); const handleError = () => reject(new Error(`Failed to load server preview for ${messageId}.`)); image.addEventListener("load", handleLoad, { once: true }); image.addEventListener("error", handleError, { once: true }); image.src = previewUrl; }), ), ); void preloadServerPreviews .then(() => { if (cancelled) { return; } clearAttachmentPreviewHandoff(messageId as MessageId, handoffPreviewUrls); }) .catch(() => { if (!cancelled) { delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; } }); cleanups.push(() => { cancelled = true; delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; for (const image of imageInstances) { image.src = ""; } }); } return () => { for (const cleanup of cleanups) { cleanup(); } }; }, [attachmentPreviewHandoffByMessageId, clearAttachmentPreviewHandoff, serverMessages]); 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 (!completionSummary) return null; return deriveCompletionDividerBeforeEntryId(timelineEntries, activeLatestTurn); }, [activeLatestTurn, completionSummary, latestTurnSettled, timelineEntries]); const gitCwd = activeProject ? projectScriptCwd({ project: { cwd: activeProject.cwd }, worktreePath: activeThread?.worktreePath ?? null, }) : null; const gitStatusQuery = useVcsStatus({ environmentId, cwd: gitCwd }); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); // Prefer an instance-id match so a custom Codex instance (e.g. // `codex_personal`) surfaces its own status/message in the banner rather // than the default Codex's. Falls back to first-match-by-kind when no // saved instance id is available or the instance no longer exists. const activeProviderInstanceId = activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId ?? activeProject?.defaultModelSelection?.instanceId ?? null; const activeProviderStatus = useMemo(() => { if (activeProviderInstanceId) { return ( providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null ); } const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const terminalShortcutLabelOptions = useMemo( () => ({ context: { terminalFocus: true, terminalOpen: Boolean(terminalUiState.terminalOpen), }, }), [terminalUiState.terminalOpen], ); const nonTerminalShortcutLabelOptions = useMemo( () => ({ context: { terminalFocus: false, terminalOpen: Boolean(terminalUiState.terminalOpen), }, }), [terminalUiState.terminalOpen], ); const terminalToggleShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.toggle"), [keybindings], ); const splitTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], ); const newTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], ); const closeTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], ); const diffPanelShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "diff.toggle", nonTerminalShortcutLabelOptions), [keybindings, nonTerminalShortcutLabelOptions], ); const onToggleDiff = useCallback(() => { if (!isServerThread) { return; } if (!diffOpen) { onDiffPanelOpen?.(); } void navigate({ to: "/$environmentId/$threadId", params: { environmentId, threadId, }, replace: true, search: (previous) => { const rest = stripDiffSearchParams(previous); return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; }, }); }, [diffOpen, environmentId, isServerThread, navigate, onDiffPanelOpen, threadId]); const envLocked = Boolean( activeThread && (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "closed")), ); // Handle environment change for draft threads. When the user picks a // different environment we update the draft context to point at the physical // project in that environment while keeping the same logical project. const onEnvironmentChange = useCallback( (nextEnvironmentId: EnvironmentId) => { if (envLocked || !draftId) return; const target = logicalProjectEnvironments.find( (env) => env.environmentId === nextEnvironmentId, ); if (!target) return; setDraftThreadContext(draftId, { projectRef: scopeProjectRef(target.environmentId, target.projectId), }); }, [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], ); const activeTerminalGroup = terminalUiState.terminalGroups.find( (group) => group.id === terminalUiState.activeTerminalGroupId, ) ?? terminalUiState.terminalGroups.find((group) => group.terminalIds.includes(terminalUiState.activeTerminalId), ) ?? null; const hasReachedSplitLimit = (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP; const setThreadError = useCallback( (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; const nextError = sanitizeThreadErrorMessage(error); const isCurrentServerThread = shouldWriteThreadErrorToCurrentServerThread({ serverThread, routeThreadRef, targetThreadId, }); if (isCurrentServerThread) { setStoreThreadError(targetThreadId, nextError); return; } const localDraftErrorKey = draftId ?? targetThreadId; setLocalDraftErrorsByDraftId((existing) => { if ((existing[localDraftErrorKey] ?? null) === nextError) { return existing; } return { ...existing, [localDraftErrorKey]: nextError, }; }); }, [draftId, routeThreadRef, serverThread, setStoreThreadError], ); const focusComposer = useCallback(() => { composerRef.current?.focusAtEnd(); }, []); const scheduleComposerFocus = useCallback(() => { window.requestAnimationFrame(() => { focusComposer(); }); }, [focusComposer]); const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => { composerRef.current?.addTerminalContext(selection); }, []); const setTerminalOpen = useCallback( (open: boolean) => { if (!activeThreadRef) return; storeSetTerminalOpen(activeThreadRef, open); }, [activeThreadRef, storeSetTerminalOpen], ); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadRef) return; setTerminalOpen(!terminalUiState.terminalOpen); }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]); const splitTerminal = useCallback(() => { if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { return; } const cwdForOpen = gitCwd ?? activeProject.cwd; if (!cwdForOpen) { return; } const api = readEnvironmentApi(environmentId); if (!api) { return; } const terminalId = nextTerminalId(activeKnownTerminalIds); storeSplitTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); void (async () => { try { await api.terminal.open({ threadId: activeThreadId, terminalId, cwd: cwdForOpen, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), env: projectScriptRuntimeEnv({ project: { cwd: activeProject.cwd }, worktreePath: activeThreadWorktreePath, }), }); } catch { // Opening failed; the tab is already in the store — user can retry or close it. } })(); }, [ activeProject, activeKnownTerminalIds, activeThreadId, activeThreadRef, activeThreadWorktreePath, environmentId, gitCwd, hasReachedSplitLimit, storeSplitTerminal, ]); const createNewTerminal = useCallback(() => { if (!activeThreadRef || !activeThreadId || !activeProject) { return; } const cwdForOpen = gitCwd ?? activeProject.cwd; if (!cwdForOpen) { return; } const api = readEnvironmentApi(environmentId); if (!api) { return; } const terminalId = nextTerminalId(activeKnownTerminalIds); storeNewTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); void (async () => { try { await api.terminal.open({ threadId: activeThreadId, terminalId, cwd: cwdForOpen, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), env: projectScriptRuntimeEnv({ project: { cwd: activeProject.cwd }, worktreePath: activeThreadWorktreePath, }), }); } catch { // Opening failed; the tab is already in the store — user can retry or close it. } })(); }, [ activeProject, activeKnownTerminalIds, activeThreadId, activeThreadRef, activeThreadWorktreePath, environmentId, gitCwd, storeNewTerminal, ]); const closeTerminal = useCallback( (terminalId: string) => { const api = readEnvironmentApi(environmentId); if (!activeThreadId || !api || !activeThreadRef) return; const isFinalTerminal = activeKnownTerminalIds.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(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, activeThreadRef, activeKnownTerminalIds, environmentId, storeCloseTerminal], ); const runProjectScript = useCallback( async ( script: ProjectScript, options?: { cwd?: string; env?: Record; worktreePath?: string | null; preferNewTerminal?: boolean; rememberAsLastInvoked?: boolean; }, ) => { const api = readEnvironmentApi(environmentId); if (!api || !activeThreadId || !activeProject || !activeThread) 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 = terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId); const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; const shouldCreateNewTerminal = wantsNewTerminal; const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; setTerminalUiLaunchContext({ threadId: activeThreadId, cwd: targetCwd, worktreePath: targetWorktreePath, }); setTerminalOpen(true); if (!activeThreadRef) { return; } setTerminalFocusRequestId((value) => value + 1); const runtimeEnv = projectScriptRuntimeEnv({ project: { cwd: activeProject.cwd, }, worktreePath: targetWorktreePath, ...(options?.env ? { extraEnv: options.env } : {}), }); const targetTerminalId = shouldCreateNewTerminal ? nextTerminalId(activeKnownTerminalIds) : baseTerminalId; const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal ? { threadId: activeThreadId, terminalId: targetTerminalId, cwd: targetCwd, ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), env: runtimeEnv, cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS, } : { threadId: activeThreadId, terminalId: targetTerminalId, cwd: targetCwd, ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), env: runtimeEnv, }; if (shouldCreateNewTerminal) { storeNewTerminal(activeThreadRef, targetTerminalId); } else { storeSetActiveTerminal(activeThreadRef, targetTerminalId); } 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, activeThreadRef, gitCwd, setTerminalOpen, setThreadError, storeNewTerminal, storeSetActiveTerminal, setLastInvokedScriptByProjectId, environmentId, activeKnownTerminalIds, runningTerminalIds, terminalUiState.activeTerminalId, ], ); const persistProjectScripts = useCallback( async (input: { projectId: ProjectId; projectCwd: string; previousScripts: ProjectScript[]; nextScripts: ProjectScript[]; keybinding?: string | null; keybindingCommand: KeybindingCommand; }) => { const api = readEnvironmentApi(environmentId); 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) { const localApi = readLocalApi(); if (!localApi) { throw new Error("Local API unavailable."); } await localApi.server.upsertKeybinding(keybindingRule); } }, [environmentId], ); 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( stackedThreadToast({ 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(composerDraftTarget, mode); if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { runtimeMode: mode }); } scheduleComposerFocus(); }, [ isLocalDraftThread, runtimeMode, scheduleComposerFocus, composerDraftTarget, setComposerDraftRuntimeMode, setDraftThreadContext, ], ); const handleInteractionModeChange = useCallback( (mode: ProviderInteractionMode) => { if (mode === interactionMode) return; setComposerDraftInteractionMode(composerDraftTarget, mode); if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { interactionMode: mode }); } scheduleComposerFocus(); }, [ interactionMode, isLocalDraftThread, scheduleComposerFocus, composerDraftTarget, setComposerDraftInteractionMode, setDraftThreadContext, ], ); const toggleInteractionMode = useCallback(() => { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); const togglePlanSidebar = useCallback(() => { setPlanSidebarOpen((open) => { if (open) { planSidebarDismissedForTurnRef.current = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; } else { planSidebarDismissedForTurnRef.current = null; } return !open; }); }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); const closePlanSidebar = useCallback(() => { setPlanSidebarOpen(false); planSidebarDismissedForTurnRef.current = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); const persistThreadSettingsForNextTurn = useCallback( async (input: { threadId: ThreadId; createdAt: string; modelSelection?: ModelSelection; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; }) => { if (!serverThread) { return; } const api = readEnvironmentApi(environmentId); if (!api) { return; } if ( input.modelSelection !== undefined && (input.modelSelection.model !== serverThread.modelSelection.model || input.modelSelection.instanceId !== serverThread.modelSelection.instanceId || JSON.stringify(input.modelSelection.options ?? null) !== JSON.stringify(serverThread.modelSelection.options ?? null)) ) { await api.orchestration.dispatchCommand({ type: "thread.meta.update", commandId: newCommandId(), threadId: input.threadId, modelSelection: input.modelSelection, }); } 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, }); } }, [environmentId, serverThread], ); // Scroll helpers — LegendList handles auto-scroll via maintainScrollAtEnd. const scrollToEnd = useCallback((animated = false) => { legendListRef.current?.scrollToEnd?.({ animated }); }, []); // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during // thread switches. LegendList fires scroll events with isAtEnd=false while // initialScrollAtEnd is settling; hiding is always immediate. const showScrollDebouncer = useRef( new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), ); const onIsAtEndChange = useCallback((isAtEnd: boolean) => { if (isAtEndRef.current === isAtEnd) return; isAtEndRef.current = isAtEnd; if (isAtEnd) { showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); } else { showScrollDebouncer.current.maybeExecute(); } }, []); useEffect(() => { setPullRequestDialogState(null); isAtEndRef.current = true; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); if (planSidebarOpenOnNextThreadRef.current) { planSidebarOpenOnNextThreadRef.current = false; setPlanSidebarOpen(true); } else { planSidebarOpenOnNextThreadRef.current = false; setPlanSidebarOpen(false); } planSidebarDismissedForTurnRef.current = null; }, [activeThread?.id]); // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. // Don't auto-open for plans carried over from a previous turn (the user can open manually). useEffect(() => { if (!autoOpenPlanSidebar) return; if (!activePlan) return; if (planSidebarOpen) return; const latestTurnId = activeLatestTurn?.turnId ?? null; if (latestTurnId && activePlan.turnId !== latestTurnId) return; const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; if (planSidebarDismissedForTurnRef.current === turnKey) return; setPlanSidebarOpen(true); }, [ activePlan, activeLatestTurn?.turnId, autoOpenPlanSidebar, planSidebarOpen, sidebarProposedPlan?.turnId, ]); useEffect(() => { setIsRevertingCheckpoint(false); }, [activeThread?.id]); useEffect(() => { if (!activeThread?.id || terminalUiState.terminalOpen) return; const frame = window.requestAnimationFrame(() => { focusComposer(); }); return () => { window.cancelAnimationFrame(frame); }; }, [activeThread?.id, focusComposer, terminalUiState.terminalOpen]); 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(() => { setOptimisticUserMessages((existing) => { for (const message of existing) { revokeUserMessagePreviewUrls(message); } return []; }); resetLocalDispatch(); setExpandedImage(null); }, [draftId, resetLocalDispatch, threadId]); const closeExpandedImage = useCallback(() => { setExpandedImage(null); }, []); const activeWorktreePath = activeThread?.worktreePath ?? null; const derivedEnvMode: DraftThreadEnvMode = resolveEffectiveEnvMode({ activeWorktreePath, hasServerThread: isServerThread, draftThreadEnvMode: isLocalDraftThread ? draftThread?.envMode : undefined, }); const canOverrideServerThreadEnvMode = Boolean( isServerThread && activeThread && activeThread.messages.length === 0 && activeThread.worktreePath === null && !envLocked, ); const envMode: DraftThreadEnvMode = canOverrideServerThreadEnvMode ? (pendingServerThreadEnvMode ?? draftThread?.envMode ?? derivedEnvMode) : derivedEnvMode; const activeThreadBranch = canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined ? pendingServerThreadBranch : (activeThread?.branch ?? null); const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, isGitRepo, }); useEffect(() => { setPendingServerThreadEnvMode(null); setPendingServerThreadBranch(undefined); }, [activeThread?.id]); useEffect(() => { if (canOverrideServerThreadEnvMode) { return; } setPendingServerThreadEnvMode(null); setPendingServerThreadBranch(undefined); }, [canOverrideServerThreadEnvMode]); useEffect(() => { if (!activeThreadId) { setTerminalUiLaunchContext(null); return; } setTerminalUiLaunchContext((current) => { if (!current) return current; if (current.threadId === activeThreadId) return current; return null; }); }, [activeThreadId]); useEffect(() => { if (!activeThreadId || !activeProjectCwd) { return; } setTerminalUiLaunchContext((current) => { if (!current || current.threadId !== activeThreadId) { return current; } const settledCwd = projectScriptCwd({ project: { cwd: activeProjectCwd }, worktreePath: activeThreadWorktreePath, }); if ( settledCwd === current.cwd && (activeThreadWorktreePath ?? null) === current.worktreePath ) { return null; } return current; }); }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath]); useEffect(() => { if (terminalUiState.terminalOpen) { return; } setTerminalUiLaunchContext((current) => current?.threadId === activeThreadId ? null : current, ); }, [activeThreadId, terminalUiState.terminalOpen]); useEffect(() => { if (!activeThreadKey) return; const previous = terminalUiOpenByThreadRef.current[activeThreadKey] ?? false; const current = Boolean(terminalUiState.terminalOpen); if (!previous && current) { terminalUiOpenByThreadRef.current[activeThreadKey] = current; setTerminalFocusRequestId((value) => value + 1); return; } else if (previous && !current) { terminalUiOpenByThreadRef.current[activeThreadKey] = current; const frame = window.requestAnimationFrame(() => { focusComposer(); }); return () => { window.cancelAnimationFrame(frame); }; } terminalUiOpenByThreadRef.current[activeThreadKey] = current; }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { if (!activeThreadId || useCommandPaletteStore.getState().open || event.defaultPrevented) { return; } const shortcutContext = { terminalFocus: isTerminalFocused(), terminalOpen: Boolean(terminalUiState.terminalOpen), modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, }; 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 (!terminalUiState.terminalOpen) { setTerminalOpen(true); } splitTerminal(); return; } if (command === "terminal.close") { event.preventDefault(); event.stopPropagation(); if (!terminalUiState.terminalOpen) return; closeTerminal(terminalUiState.activeTerminalId); return; } if (command === "terminal.new") { event.preventDefault(); event.stopPropagation(); if (!terminalUiState.terminalOpen) { setTerminalOpen(true); } createNewTerminal(); return; } if (command === "diff.toggle") { event.preventDefault(); event.stopPropagation(); onToggleDiff(); return; } if (command === "modelPicker.toggle") { event.preventDefault(); event.stopPropagation(); composerRef.current?.toggleModelPicker(); 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, true); return () => window.removeEventListener("keydown", handler, true); }, [ activeProject, terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, closeTerminal, createNewTerminal, setTerminalOpen, runProjectScript, splitTerminal, keybindings, onToggleDiff, toggleTerminalVisibility, ]); const onRevertToTurnCount = useCallback( async (turnCount: number) => { const api = readEnvironmentApi(environmentId); const localApi = readLocalApi(); if (!api || !localApi || !activeThread || isRevertingCheckpoint) return; if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) { setThreadError( activeThread.id, `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`, ); return; } if (phase === "running" || isSendBusy || isConnecting) { setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); return; } const confirmed = await localApi.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, activeEnvironmentUnavailable, activeEnvironmentUnavailableLabel, environmentId, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError, ], ); const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); const api = readEnvironmentApi(environmentId); if ( !api || !activeThread || isSendBusy || isConnecting || activeEnvironmentUnavailable || sendInFlightRef.current ) return; if (activePendingProgress) { onAdvanceActivePendingUserInput(); return; } const sendCtx = composerRef.current?.getSendContext(); if (!sendCtx) return; const { images: composerImages, terminalContexts: composerTerminalContexts, selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, selectedPromptEffort: ctxSelectedPromptEffort, selectedModelSelection: ctxSelectedModelSelection, } = sendCtx; 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(composerDraftTarget); composerRef.current?.resetCursorState(); 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(composerDraftTarget); composerRef.current?.resetCursorState(); return; } if (!hasSendableContent) { if (expiredTerminalContextCount > 0) { const toastCopy = buildExpiredTerminalContextToastCopy( expiredTerminalContextCount, "empty", ); toastManager.add( stackedThreadToast({ 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 && sendEnvMode === "worktree" && !activeThread.worktreePath ? activeThreadBranch : 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 && sendEnvMode === "worktree" && !activeThread.worktreePath; if (shouldCreateWorktree && !activeThreadBranch) { setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; } sendInFlightRef.current = true; beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const messageTextForSend = appendTerminalContextsToPrompt( promptForSend, composerTerminalContextsSnapshot, ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, models: ctxSelectedProviderModels, effort: ctxSelectedPromptEffort, 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, })); // Scroll to the current end *before* adding the optimistic message. // This sets LegendList's internal isAtEnd=true so maintainScrollAtEnd // automatically pins to the new item when the data changes. isAtEndRef.current = true; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); await legendListRef.current?.scrollToEnd?.({ animated: false }); setOptimisticUserMessages((existing) => [ ...existing, { id: messageIdForSend, role: "user", text: outgoingMessageText, ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), createdAt: messageCreatedAt, streaming: false, }, ]); setThreadError(threadIdForSend, null); if (expiredTerminalContextCount > 0) { const toastCopy = buildExpiredTerminalContextToastCopy( expiredTerminalContextCount, "omitted", ); toastManager.add( stackedThreadToast({ type: "warning", title: toastCopy.title, description: toastCopy.description, }), ); } promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); let turnStartSucceeded = false; await (async () => { 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 = truncate(titleSeed); const threadCreateModelSelection = createModelSelection( ctxSelectedModelSelection.instanceId, ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL, ctxSelectedModelSelection.options, ); // 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, ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}), runtimeMode, interactionMode, }); } const turnAttachments = await turnAttachmentsPromise; const bootstrap = isLocalDraftThread || baseBranchForWorktree ? { ...(isLocalDraftThread ? { createThread: { projectId: activeProject.id, title, modelSelection: threadCreateModelSelection, runtimeMode, interactionMode, branch: activeThreadBranch, worktreePath: activeThread.worktreePath, createdAt: activeThread.createdAt, }, } : {}), ...(baseBranchForWorktree ? { prepareWorktree: { projectCwd: activeProject.cwd, baseBranch: baseBranchForWorktree, branch: buildTemporaryWorktreeBranchName(), }, runSetupScript: true, } : {}), } : undefined; beginLocalDispatch({ preparingWorktree: false }); await api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), threadId: threadIdForSend, message: { messageId: messageIdForSend, role: "user", text: outgoingMessageText, attachments: turnAttachments, }, modelSelection: ctxSelectedModelSelection, titleSeed: title, runtimeMode, interactionMode, ...(bootstrap ? { bootstrap } : {}), createdAt: messageCreatedAt, }); turnStartSucceeded = true; })().catch(async (err: unknown) => { 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; const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); composerImagesRef.current = retryComposerImages; composerTerminalContextsRef.current = composerTerminalContextsSnapshot; setComposerDraftPrompt(composerDraftTarget, promptForSend); addComposerDraftImages(composerDraftTarget, retryComposerImages); setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); composerRef.current?.resetCursorState({ cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), prompt: promptForSend, detectTrigger: true, }); } setThreadError( threadIdForSend, err instanceof Error ? err.message : "Failed to send message.", ); }); sendInFlightRef.current = false; if (!turnStartSucceeded) { resetLocalDispatch(); } }; const onInterrupt = async () => { const api = readEnvironmentApi(environmentId); 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 = readEnvironmentApi(environmentId); 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) => { setThreadError( activeThreadId, err instanceof Error ? err.message : "Failed to submit approval decision.", ); }); setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); }, [activeThreadId, environmentId, setThreadError], ); const onRespondToUserInput = useCallback( async (requestId: ApprovalRequestId, answers: Record) => { const api = readEnvironmentApi(environmentId); 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) => { setThreadError( activeThreadId, err instanceof Error ? err.message : "Failed to submit user input.", ); }); setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); }, [activeThreadId, environmentId, setThreadError], ); 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) => { const question = (activePendingProgress?.activeQuestion?.id === questionId ? activePendingProgress.activeQuestion : undefined) ?? activePendingUserInput.questions.find((entry) => entry.id === questionId); if (!question) { return existing; } return { ...existing, [activePendingUserInput.requestId]: { ...existing[activePendingUserInput.requestId], [questionId]: togglePendingUserInputOptionSelection( question, existing[activePendingUserInput.requestId]?.[questionId], optionLabel, ), }, }; }); promptRef.current = ""; composerRef.current?.resetCursorState({ cursor: 0 }); }, [activePendingProgress?.activeQuestion, 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, ), }, })); const snapshot = composerRef.current?.readSnapshot(); if ( snapshot?.value !== value || snapshot.cursor !== nextCursor || snapshot.expandedCursor !== expandedCursor ) { composerRef.current?.focusAt(nextCursor); } }, [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 = readEnvironmentApi(environmentId); if ( !api || !activeThread || !isServerThread || isSendBusy || isConnecting || sendInFlightRef.current ) { return; } const trimmed = text.trim(); if (!trimmed) { return; } const sendCtx = composerRef.current?.getSendContext(); if (!sendCtx) { return; } const { selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, selectedPromptEffort: ctxSelectedPromptEffort, selectedModelSelection: ctxSelectedModelSelection, } = sendCtx; const threadIdForSend = activeThread.id; const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, models: ctxSelectedProviderModels, effort: ctxSelectedPromptEffort, text: trimmed, }); sendInFlightRef.current = true; beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); // Scroll to the current end *before* adding the optimistic message. isAtEndRef.current = true; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); await legendListRef.current?.scrollToEnd?.({ animated: false }); setOptimisticUserMessages((existing) => [ ...existing, { id: messageIdForSend, role: "user", text: outgoingMessageText, createdAt: messageCreatedAt, streaming: false, }, ]); try { await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, createdAt: messageCreatedAt, modelSelection: ctxSelectedModelSelection, runtimeMode, interactionMode: nextInteractionMode, }); // Keep the mode toggle and plan-follow-up banner in sync immediately // while the same-thread implementation turn is starting. setComposerDraftInteractionMode( scopeThreadRef(activeThread.environmentId, threadIdForSend), nextInteractionMode, ); await api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), threadId: threadIdForSend, message: { messageId: messageIdForSend, role: "user", text: outgoingMessageText, attachments: [], }, modelSelection: ctxSelectedModelSelection, titleSeed: activeThread.title, 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" && autoOpenPlanSidebar) { 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; resetLocalDispatch(); } }, [ activeThread, activeProposedPlan, beginLocalDispatch, isConnecting, isSendBusy, isServerThread, persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, setComposerDraftInteractionMode, setThreadError, autoOpenPlanSidebar, environmentId, ], ); const onImplementPlanInNewThread = useCallback(async () => { const api = readEnvironmentApi(environmentId); if ( !api || !activeThread || !activeProject || !activeProposedPlan || !isServerThread || isSendBusy || isConnecting || activeEnvironmentUnavailable || sendInFlightRef.current ) { return; } const sendCtx = composerRef.current?.getSendContext(); if (!sendCtx) { return; } const { selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, selectedPromptEffort: ctxSelectedPromptEffort, selectedModelSelection: ctxSelectedModelSelection, } = sendCtx; const createdAt = new Date().toISOString(); const nextThreadId = newThreadId(); const planMarkdown = activeProposedPlan.planMarkdown; const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); const outgoingImplementationPrompt = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, models: ctxSelectedProviderModels, effort: ctxSelectedPromptEffort, text: implementationPrompt, }); const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown)); const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection; sendInFlightRef.current = true; beginLocalDispatch({ preparingWorktree: false }); const finish = () => { sendInFlightRef.current = false; resetLocalDispatch(); }; await api.orchestration .dispatchCommand({ type: "thread.create", commandId: newCommandId(), threadId: nextThreadId, projectId: activeProject.id, title: nextThreadTitle, modelSelection: nextThreadModelSelection, runtimeMode, interactionMode: "default", branch: activeThreadBranch, 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: [], }, modelSelection: ctxSelectedModelSelection, titleSeed: nextThreadTitle, runtimeMode, interactionMode: "default", sourceProposedPlan: { threadId: activeThread.id, planId: activeProposedPlan.id, }, createdAt, }); }) .then(() => { return waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId)); }) .then(() => { // Signal that the plan sidebar should open on the new thread when enabled. planSidebarOpenOnNextThreadRef.current = autoOpenPlanSidebar; return navigate({ to: "/$environmentId/$threadId", params: { environmentId: activeThread.environmentId, threadId: nextThreadId, }, }); }) .catch(async (err: unknown) => { await api.orchestration .dispatchCommand({ type: "thread.delete", commandId: newCommandId(), threadId: nextThreadId, }) .catch(() => undefined); toastManager.add( stackedThreadToast({ 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, activeThreadBranch, activeThread, beginLocalDispatch, activeEnvironmentUnavailable, isConnecting, isSendBusy, isServerThread, navigate, resetLocalDispatch, runtimeMode, autoOpenPlanSidebar, environmentId, ]); const onProviderModelSelect = useCallback( (instanceId: ProviderInstanceId, model: string) => { if (!activeThread) return; // Look up the configured instance so model normalization and custom // model lookup stay scoped to that exact instance. Unknown instance ids // are rejected by returning early; the server remains authoritative too. const entry = providerStatuses.find((snapshot) => snapshot.instanceId === instanceId); const resolvedDriverKind = entry?.driver ?? null; if ( lockedProvider !== null && resolvedDriverKind !== null && resolvedDriverKind !== lockedProvider ) { scheduleComposerFocus(); return; } if (lockedProvider !== null && activeThread.session?.providerInstanceId) { const currentEntry = providerStatuses.find( (snapshot) => snapshot.instanceId === activeThread.session?.providerInstanceId, ); if ( currentEntry?.continuation?.groupKey && entry?.continuation?.groupKey && currentEntry.continuation.groupKey !== entry.continuation.groupKey ) { scheduleComposerFocus(); return; } } const resolvedModel = resolveAppModelSelectionForInstance( instanceId, settings, providerStatuses, model, ); if (!resolvedModel) { scheduleComposerFocus(); return; } const nextModelSelection: ModelSelection = { instanceId, model: resolvedModel, }; setComposerDraftModelSelection( scopeThreadRef(activeThread.environmentId, activeThread.id), nextModelSelection, ); setStickyComposerModelSelection(nextModelSelection); scheduleComposerFocus(); }, [ activeThread, lockedProvider, scheduleComposerFocus, setComposerDraftModelSelection, setStickyComposerModelSelection, providerStatuses, settings, ], ); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { if (canOverrideServerThreadEnvMode) { setPendingServerThreadEnvMode(mode); scheduleComposerFocus(); return; } if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { envMode: mode, ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), }); } scheduleComposerFocus(); }, [ canOverrideServerThreadEnvMode, composerDraftTarget, draftThread?.worktreePath, isLocalDraftThread, setPendingServerThreadEnvMode, scheduleComposerFocus, setDraftThreadContext, ], ); const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { if (!isServerThread) { return; } onDiffPanelOpen?.(); void navigate({ to: "/$environmentId/$threadId", params: { environmentId, threadId, }, search: (previous) => { const rest = stripDiffSearchParams(previous); return filePath ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath } : { ...rest, diff: "1", diffTurnId: turnId }; }, }); }, [environmentId, isServerThread, navigate, onDiffPanelOpen, threadId], ); // Both the Map and the revert handler are read from refs at call-time so // the callback reference is fully stable and never busts context identity. const revertTurnCountRef = useRef(revertTurnCountByUserMessageId); revertTurnCountRef.current = revertTurnCountByUserMessageId; const onRevertToTurnCountRef = useRef(onRevertToTurnCount); onRevertToTurnCountRef.current = onRevertToTurnCount; const onRevertUserMessage = useCallback((messageId: MessageId) => { const targetTurnCount = revertTurnCountRef.current.get(messageId); if (typeof targetTurnCount !== "number") { return; } void onRevertToTurnCountRef.current(targetTurnCount); }, []); // Empty state: no active thread if (!activeThread) { return ; } return (
{/* Top bar */}
{/* Error banner */} setThreadError(activeThread.id, null)} /> {/* Main content area with optional plan sidebar */}
{/* Chat column */}
{/* Messages Wrapper */}
{/* Messages — LegendList handles virtualization and scrolling internally */} {/* scroll to bottom pill — shown when user has scrolled away from the bottom */} {showScrollToBottom && (
)}
{/* Input bar */}
{isGitRepo && ( )}
{pullRequestDialogState ? ( { if (!open) { closePullRequestDialog(); } }} onPrepared={handlePreparedPullRequestThread} /> ) : null}
{/* end chat column */} {/* Plan sidebar */} {planSidebarOpen && !shouldUsePlanSidebarSheet ? ( ) : null}
{/* end horizontal flex container */} {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( ))} {shouldUsePlanSidebarSheet ? ( ) : null} {expandedImage && ( )}
); }