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 { connectionStatusTitle, type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import { parseScopedThreadKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { applyClaudePromptEffortPrefix, createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; 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 { useAtomValue } from "@effect/atom-react"; import { lazy, memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { flushSync } from "react-dom"; import { useNavigate } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { isAtomCommandInterrupted, mapAtomCommandResult, settlePromise, squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; import { collapseExpandedComposerCursor, parseStandaloneComposerSlashCommand, } from "../composer-logic"; import { derivePendingApprovals, derivePendingUserInputs, derivePhase, deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, findSidebarProposedPlan, findLatestProposedPlan, deriveWorkLogEntries, hasActionableProposedPlan, isLatestTurnSettled, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; 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 { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { selectActiveRightPanel, selectActiveRightPanelSurface, selectThreadRightPanelState, type RightPanelSurface, useRightPanelStore, } from "../rightPanelStore"; import { isPreviewSupportedInRuntime, setActivePreviewTab, useThreadPreviewState, } from "../previewStateStore"; import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; import { RightPanelTabs } from "./RightPanelTabs"; import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, TriangleAlertIcon, WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { buildProjectScript, commandForProjectScript, nextProjectScriptId, projectScriptIdFromCommand, } from "~/projectScripts"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; 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 { appendElementContextsToPrompt, type ElementContextDraft, formatElementContextLabel, } from "../lib/elementContext"; import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom, primaryServerSettingsAtom, serverEnvironment, } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; import { useProject, useProjects, useThread, useThreadProposedPlans, useThreadRefs, useThreadShell, } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { getProviderStatusBannerKey, ProviderStatusBanner, shouldShowProviderStatusBanner, } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { resolveThreadPr } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, DRAFT_HERO_TRANSITION_DURATION_MS, DRAFT_HERO_TRANSITION_EASING, MOBILE_COMPOSER_VIEW_TRANSITION_NAME, MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME, runMobileComposerTransition, } from "./chat/draftHeroTransition"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildThreadTurnInterruptInput, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, shouldShowBranchMismatchBanner, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, type LocalDispatchSnapshot, PullRequestDialogState, cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { Button } from "./ui/button"; import { AlertDialog, AlertDialogClose, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogPopup, AlertDialogTitle, } from "./ui/alert-dialog"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ServerUpdateAction } from "./ServerUpdateAction"; import { buildVersionMismatchDismissalKey, dismissVersionMismatch, isVersionMismatchDismissed, resolveServerConfigVersionMismatch, resolveServerSelfUpdateCapability, serverUpdateGuidance, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; 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_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroupRef = useRef(null); const composerAnchorRef = useRef(null); const previousStateRef = useRef(isDraftHeroState); const previousComposerRectRef = useRef(null); const animationRef = useRef(null); const attachTransitionGroupRef = (element: HTMLDivElement | null) => { transitionGroupRef.current = element; }; const attachComposerAnchorRef = (element: HTMLDivElement | null) => { composerAnchorRef.current = element; }; const captureComposerRect = () => { previousComposerRectRef.current = composerAnchorRef.current?.getBoundingClientRect() ?? null; }; useLayoutEffect(() => { const transitionGroup = transitionGroupRef.current; const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null; const stateChanged = previousStateRef.current !== isDraftHeroState; const prefersReducedMotion = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; const mobileComposerTransitionActive = typeof document !== "undefined" && document.documentElement.dataset.mobileComposerRouteTransition === "true"; animationRef.current?.cancel(); animationRef.current = null; const previousComposerRect = previousComposerRectRef.current; if ( stateChanged && !prefersReducedMotion && !mobileComposerTransitionActive && transitionGroup && previousComposerRect && nextComposerRect && typeof transitionGroup.animate === "function" ) { const translateX = previousComposerRect.left - nextComposerRect.left; const translateY = previousComposerRect.top - nextComposerRect.top; if (Math.abs(translateX) >= 0.5 || Math.abs(translateY) >= 0.5) { const animation = transitionGroup.animate( [ { transform: `translate3d(${translateX}px, ${translateY}px, 0)` }, { transform: "translate3d(0, 0, 0)" }, ], { duration: DRAFT_HERO_TRANSITION_DURATION_MS, easing: DRAFT_HERO_TRANSITION_EASING, }, ); animation.id = DRAFT_HERO_TRANSITION_ANIMATION_ID; animationRef.current = animation; void animation.finished .catch(() => undefined) .then(() => { if (animationRef.current !== animation) { return; } animationRef.current = null; }); } } previousStateRef.current = isDraftHeroState; previousComposerRectRef.current = nextComposerRect; }, [isDraftHeroState]); return [attachTransitionGroupRef, attachComposerAnchorRef, captureComposerRect] as const; } const PreviewPanel = lazy(() => import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), ); const DiffPanel = lazy(() => import("./DiffPanel")); const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ "input", "textarea", "select", '[contenteditable="true"]', '[contenteditable="plaintext-only"]', '[role="textbox"]', ].join(","); const TYPE_TO_FOCUS_INTERACTIVE_SELECTOR = [ "button", "a[href]", "summary", '[role="button"]', '[role="checkbox"]', '[role="menuitem"]', '[role="option"]', '[role="radio"]', '[role="switch"]', '[role="tab"]', ].join(","); const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [ '[data-slot="dialog"]', '[data-slot="menu-popup"]', '[data-slot="select-popup"]', '[data-slot="popover-popup"]', '[data-slot="combobox-popup"]', '[data-slot="autocomplete-popup"]', ].join(","); type EnvironmentUnavailableState = { readonly environmentId: EnvironmentId; readonly label: string; readonly connection: EnvironmentConnectionPresentation; }; type ThreadPlanCatalogEntry = Pick; function eventPathContainsSelector(event: Event, selector: string): boolean { const path = event.composedPath(); if (path.length === 0 && event.target) { path.push(event.target); } return path.some((target) => target instanceof Element && target.closest(selector)); } function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { if (event.defaultPrevented || event.isComposing) return false; if (event.metaKey || event.ctrlKey || event.altKey) return false; if (event.key.length !== 1) return false; if (eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR)) return false; if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false; if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false; return true; } 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; forceExpandedMobileComposer?: boolean; routeKind: "server"; draftId?: never; } | { environmentId: EnvironmentId; threadId: ThreadId; onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; forceExpandedMobileComposer?: 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 latestUserMessageId = input.activeThread?.messages.findLast((message) => message.role === "user")?.id ?? null; const resetLocalDispatch = useCallback(() => { setLocalDispatch(null); }, []); const serverAcknowledgedLocalDispatch = useMemo( () => hasServerAcknowledgedLocalDispatch({ localDispatch, phase: input.phase, latestTurn: input.activeLatestTurn, latestUserMessageId, 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, latestUserMessageId, localDispatch, ], ); const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; const beginLocalDispatch = useCallback( (options?: { preparingWorktree?: boolean }) => { const preparingWorktree = Boolean(options?.preparingWorktree); setLocalDispatch((current) => { const active = serverAcknowledgedLocalDispatch ? null : current; if (active) { return active.preparingWorktree === preparingWorktree ? active : { ...active, preparingWorktree }; } return createLocalDispatchSnapshot(input.activeThread, options); }); }, [input.activeThread, serverAcknowledgedLocalDispatch], ); return { beginLocalDispatch, resetLocalDispatch, localDispatchStartedAt: activeLocalDispatch?.startedAt ?? null, isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, isSendBusy: activeLocalDispatch !== null, }; } /** 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; splitVerticalShortcutLabel: 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, splitVerticalShortcutLabel, newShortcutLabel, closeShortcutLabel, keybindings, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); const serverThread = useThread(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 = useProject(projectRef); const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), ); const knownTerminalSessions = useKnownTerminalSessions({ environmentId: threadRef.environmentId, threadId, }); const panelSurfaces = useRightPanelStore( (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, ); const panelTerminalIds = useMemo( () => new Set( panelSurfaces.flatMap((surface) => surface.kind === "terminal" ? surface.terminalIds : [], ), ), [panelSurfaces], ); const drawerTerminalSessions = useMemo( () => knownTerminalSessions.filter((session) => !panelTerminalIds.has(session.target.terminalId)), [knownTerminalSessions, panelTerminalIds], ); const terminalLabelsById = useMemo(() => { const next = new Map(); for (const session of drawerTerminalSessions) { next.set( session.target.terminalId, resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), ); } return next; }, [drawerTerminalSessions]); 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 drawerTerminalSessions) { 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.workspaceRoot }, worktreePath: worktreePathForLaunch, }), }); } return next; }, [drawerTerminalSessions, launchContext, project]); const serverOrderedTerminalIds = useMemo( () => drawerTerminalSessions.map((session) => session.target.terminalId), [drawerTerminalSessions], ); const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); const storeSplitTerminalVertical = useTerminalUiStateStore( (state) => state.splitTerminalVertical, ); 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.workspaceRoot }, worktreePath: effectiveWorktreePath, }) : null), [effectiveWorktreePath, launchContext?.cwd, project], ); const runtimeEnv = useMemo( () => project ? projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot }, 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(() => { if (!cwd) { return; } const terminalId = nextTerminalId(serverOrderedTerminalIds); storeSplitTerminal(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ environmentId: threadRef.environmentId, input: { threadId, terminalId, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), env: runtimeEnv, }, }); }, [ bumpFocusRequestId, cwd, effectiveWorktreePath, runtimeEnv, serverOrderedTerminalIds, storeSplitTerminal, threadId, threadRef, openTerminal, ]); const splitTerminalVertical = useCallback(() => { if (!cwd) { return; } const terminalId = nextTerminalId(serverOrderedTerminalIds); storeSplitTerminalVertical(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ environmentId: threadRef.environmentId, input: { threadId, terminalId, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), env: runtimeEnv, }, }); }, [ bumpFocusRequestId, cwd, effectiveWorktreePath, openTerminal, runtimeEnv, serverOrderedTerminalIds, storeSplitTerminalVertical, threadId, threadRef, ]); const createNewTerminal = useCallback(() => { if (!cwd) { return; } const terminalId = nextTerminalId(serverOrderedTerminalIds); storeNewTerminal(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ environmentId: threadRef.environmentId, input: { threadId, terminalId, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), env: runtimeEnv, }, }); }, [ bumpFocusRequestId, cwd, effectiveWorktreePath, runtimeEnv, serverOrderedTerminalIds, storeNewTerminal, threadId, threadRef, openTerminal, ]); const activateTerminal = useCallback( (terminalId: string) => { storeSetActiveTerminal(threadRef, terminalId); bumpFocusRequestId(); }, [bumpFocusRequestId, storeSetActiveTerminal, threadRef], ); const closeTerminal = useCallback( (terminalId: string) => { const fallbackExitWrite = () => writeTerminal({ environmentId: threadRef.environmentId, input: { threadId, terminalId, data: "exit\n" }, }); void (async () => { const closeResult = await closeTerminalMutation({ environmentId: threadRef.environmentId, input: { threadId, terminalId, deleteHistory: true, }, }); if (closeResult._tag === "Failure" && !isAtomCommandInterrupted(closeResult)) { await fallbackExitWrite(); } })(); storeCloseTerminal(threadRef, terminalId); bumpFocusRequestId(); }, [ bumpFocusRequestId, storeCloseTerminal, threadId, threadRef, closeTerminalMutation, writeTerminal, ], ); const handleAddTerminalContext = useCallback( (selection: TerminalContextSelection) => { if (!visible) { return; } onAddTerminalContext(selection); }, [onAddTerminalContext, visible], ); if (!project || !terminalUiState.terminalOpen || !cwd) { return null; } return (
); }); interface PersistentThreadTerminalPanelProps { threadRef: ScopedThreadRef; surface: Extract; launchContext: PersistentTerminalLaunchContext | null; focusRequestId: number; keybindings: ResolvedKeybindingsConfig; onAddTerminalContext: (selection: TerminalContextSelection) => void; onSplitTerminal: () => void; onSplitTerminalVertical: () => void; onNewTerminal: () => void; onActiveTerminalChange: (terminalId: string) => void; onCloseTerminal: (terminalId: string) => void; splitShortcutLabel?: string | undefined; splitVerticalShortcutLabel?: string | undefined; newShortcutLabel?: string | undefined; closeShortcutLabel?: string | undefined; } const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ threadRef, surface, launchContext, focusRequestId, keybindings, onAddTerminalContext, onSplitTerminal, onSplitTerminalVertical, onNewTerminal, onActiveTerminalChange, onCloseTerminal, splitShortcutLabel, splitVerticalShortcutLabel, newShortcutLabel, closeShortcutLabel, }: PersistentThreadTerminalPanelProps) { const serverThread = useThread(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 = useProject(projectRef); const knownTerminalSessions = useKnownTerminalSessions({ environmentId: threadRef.environmentId, threadId: threadRef.threadId, }); const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; const activeSummary = knownTerminalSessions.find((session) => session.target.terminalId === surface.activeTerminalId) ?.state.summary ?? null; const worktreePath = launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath; const cwd = useMemo( () => launchContext?.cwd ?? activeSummary?.cwd ?? (project ? projectScriptCwd({ project: { cwd: project.workspaceRoot }, worktreePath, }) : null), [activeSummary?.cwd, launchContext?.cwd, project, worktreePath], ); const runtimeEnv = useMemo( () => project ? projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot }, worktreePath, }) : {}, [project, worktreePath], ); const terminalLabelsById = useMemo(() => { const labels = new Map(); for (const terminalId of surface.terminalIds) { const summary = knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state .summary ?? null; labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary)); } return labels; }, [knownTerminalSessions, surface.terminalIds]); const terminalLaunchLocationsById = useMemo(() => { const locations = new Map< string, { readonly cwd: string; readonly worktreePath: string | null; readonly runtimeEnv: Record; } >(); for (const terminalId of surface.terminalIds) { const summary = knownTerminalSessions.find((session) => session.target.terminalId === terminalId)?.state .summary ?? null; const terminalWorktreePath = launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath; const terminalCwd = launchContext?.cwd ?? summary?.cwd ?? (project ? projectScriptCwd({ project: { cwd: project.workspaceRoot }, worktreePath: terminalWorktreePath, }) : null); if (!terminalCwd || !project) continue; locations.set(terminalId, { cwd: terminalCwd, worktreePath: terminalWorktreePath, runtimeEnv: projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot }, worktreePath: terminalWorktreePath, }), }); } return locations; }, [ knownTerminalSessions, launchContext?.cwd, launchContext?.worktreePath, project, surface.terminalIds, threadWorktreePath, ]); if (!project || !cwd) return null; return ( undefined} onAddTerminalContext={onAddTerminalContext} terminalLabelsById={terminalLabelsById} terminalLaunchLocationsById={terminalLaunchLocationsById} keybindings={keybindings} /> ); }); // Errors surface through two maps (draft-keyed and thread-keyed) whose entries // can race around promotion, so each write carries its time to let the latest // one win when they collide. type LocalThreadErrorEntry = { readonly message: string | null; readonly at: number; }; function chatActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } function ChatViewContent(props: ChatViewProps) { const { environmentId, threadId, routeKind, onDiffPanelOpen, reserveTitleBarControlInset = true, forceExpandedMobileComposer = false, } = props; const draftId = routeKind === "draft" ? props.draftId : null; const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, }); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); const createThread = useAtomCommand(threadEnvironment.create, { reportFailure: false }); const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { reportFailure: false, }); const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, { reportFailure: false, }); const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); const respondToThreadApproval = useAtomCommand(threadEnvironment.respondToApproval, { reportFailure: false, }); const respondToThreadUserInput = useAtomCommand(threadEnvironment.respondToUserInput, { reportFailure: false, }); const revertThreadCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, { reportFailure: false, }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); const closePreview = useAtomCommand(previewEnvironment.close, "preview close"); const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); const environmentById = useMemo( () => new Map(environments.map((environment) => [environment.environmentId, environment])), [environments], ); const composerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; const serverThread = useThread(routeThreadRef); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLastVisitedAt = useUiStateStore( (store) => store.threadLastVisitedAtById[routeThreadKey], ); const settings = useEnvironmentSettings(environmentId); // New-thread defaults live in the primary environment's settings.json (the // settings UI never writes to remote environments), so read them from the // primary server rather than the thread's environment. const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; const navigate = useNavigate(); 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 setComposerDraftElementContexts = useComposerDraftStore( (store) => store.setElementContexts, ); const setComposerDraftPreviewAnnotations = useComposerDraftStore( (store) => store.setPreviewAnnotations, ); const setComposerDraftReviewComments = useComposerDraftStore((store) => store.setReviewComments); 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 composerElementContextsRef = 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 [localServerErrorsByThreadKey, setLocalServerErrorsByThreadKey] = useState< Record >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState( null, ); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] >([]); const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< Record> >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); 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 [ pendingServerThreadStartFromOriginByThreadId, setPendingServerThreadStartFromOriginByThreadId, ] = useState>({}); const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, {}, LastInvokedScriptByProjectSchema, ); const legendListRef = useRef(null); const [composerOverlayElement, setComposerOverlayElement] = useState(null); const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); const isAtEndRef = useRef(true); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const terminalUiOpenByThreadRef = useRef>({}); useLayoutEffect(() => { if (!composerOverlayElement) return; const updateHeight = () => { const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height); if (nextHeight <= 0) return; setComposerOverlayHeight((currentHeight) => currentHeight === nextHeight ? currentHeight : nextHeight, ); }; updateHeight(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(updateHeight); observer.observe(composerOverlayElement); return () => observer.disconnect(); }, [composerOverlayElement]); 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 storeEnsureTerminal = useTerminalUiStateStore((state) => state.ensureTerminal); const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical); const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); const serverThreadRefs = useThreadRefs(); const serverThreadKeys = useMemo(() => serverThreadRefs.map(scopedThreadKey), [serverThreadRefs]); 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 = useProject(fallbackDraftProjectRef); const localDraftError = serverThread ? null : ((draftId ? localDraftErrorsByDraftId[draftId]?.message : null) ?? null); const localServerError = localServerErrorsByThreadKey[routeThreadKey]?.message ?? null; // Draft errors are keyed by draftId while server errors are keyed by thread // key, so a pending draft entry must migrate when the server thread loads or // a failed send would silently disappear on promotion. When both keys hold // an entry, the most recent write wins. useEffect(() => { if (!serverThread || !draftId) { return; } const pendingDraftEntry = localDraftErrorsByDraftId[draftId]; if (pendingDraftEntry === undefined) { return; } setLocalDraftErrorsByDraftId((existing) => { if (existing[draftId] === undefined) { return existing; } const next = { ...existing }; delete next[draftId]; return next; }); setLocalServerErrorsByThreadKey((existing) => { const currentEntry = existing[routeThreadKey]; if ( currentEntry !== undefined && (currentEntry.at > pendingDraftEntry.at || currentEntry.message === pendingDraftEntry.message) ) { return existing; } return { ...existing, [routeThreadKey]: pendingDraftEntry, }; }); }, [draftId, localDraftErrorsByDraftId, routeThreadKey, serverThread]); const localDraftThread = useMemo( () => draftThread ? buildLocalDraftThread( threadId, draftThread, fallbackDraftProject?.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, ) : undefined, [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], ); // Promotion is data-driven: the draft route keeps rendering while the // server thread (same pre-allocated ref) starts, so live state must not // depend on which route is mounted. const isServerThread = serverThread !== null; const activeThread = isServerThread ? serverThread : localDraftThread; const threadError = isServerThread ? (localServerError ?? serverThread?.session?.lastError ?? null) : localDraftError; 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 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 activeTerminalLabelsById = useMemo(() => { const labels = new Map(); for (const session of activeThreadKnownSessions) { labels.set( session.target.terminalId, resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), ); } return labels; }, [activeThreadKnownSessions]); const activeThreadRef = useMemo( () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; readonly messageId: MessageId | null; }>({ threadKey: activeThreadKey, messageId: null }); if (timelineAnchor.threadKey !== activeThreadKey) { setTimelineAnchor({ threadKey: activeThreadKey, messageId: null }); } const timelineAnchorMessageId = timelineAnchor.messageId; const activeRightPanelKind = useRightPanelStore((state) => selectActiveRightPanel(state.byThreadKey, activeThreadRef), ); const diffOpen = activeRightPanelKind === "diff"; const rightPanelState = useRightPanelStore((state) => selectThreadRightPanelState(state.byThreadKey, activeThreadRef), ); const activeRightPanelSurface = useRightPanelStore((state) => selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), ); const activeFileSurface = activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; const activePreviewState = useThreadPreviewState(activeThreadRef); const panelTerminalIds = useMemo( () => new Set( rightPanelState.surfaces.flatMap((surface) => surface.kind === "terminal" ? surface.terminalIds : [], ), ), [rightPanelState.surfaces], ); const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; useEffect(() => { if (!activeThreadRef) return; useRightPanelStore .getState() .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); }, [activePreviewState.sessions, activeThreadRef]); const planSidebarOpen = activeRightPanelKind === "plan"; 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 sourcePlanThreadRef = useMemo(() => { const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) { return null; } return scopeThreadRef(activeThread.environmentId, sourceThreadId); }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread]); const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef); const threadPlanCatalog = useMemo(() => { if (!activeThread) { return []; } const entries: ThreadPlanCatalogEntry[] = [ { id: activeThread.id, proposedPlans: activeThread.proposedPlans }, ]; if (sourcePlanThreadRef) { entries.push({ id: sourcePlanThreadRef.threadId, proposedPlans: sourceThreadProposedPlans, }); } return entries; }, [activeThread, sourcePlanThreadRef, sourceThreadProposedPlans]); 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 = useProject(activeProjectRef); const activeEnvironmentShell = useEnvironmentQuery( activeThread ? environmentShell.stateAtom(activeThread.environmentId) : null, ); const activeEnvironmentBootstrapComplete = activeEnvironmentShell.data?.snapshot._tag === "Some"; const activeProjectKey = activeProject ? `${activeProject.environmentId}:${activeProject.workspaceRoot}` : null; const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< ReadonlyMap> >(() => new Map()); const pendingFileSurfaceIds = activeProjectKey ? (pendingFileSurfaceIdsByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS) : EMPTY_PENDING_FILE_SURFACE_IDS; const handleFilePendingChange = useCallback( (relativePath: string, pending: boolean) => { if (!activeProjectKey) return; setPendingFileSurfaceIdsByProject((currentByProject) => { const current = currentByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS; const surfaceId = `file:${relativePath}`; if (current.has(surfaceId) === pending) return currentByProject; const next = new Set(current); if (pending) next.add(surfaceId); else next.delete(surfaceId); const nextByProject = new Map(currentByProject); if (next.size === 0) nextByProject.delete(activeProjectKey); else nextByProject.set(activeProjectKey, next); return nextByProject; }); }, [activeProjectKey], ); const configuredPreviewUrls = useMemo( () => getConfiguredPreviewUrls(activeProject?.scripts), [activeProject?.scripts], ); useEffect(() => { if (!activeThreadRef || !activeEnvironmentBootstrapComplete) return; useRightPanelStore.getState().reconcileFileSurfaces(activeThreadRef, activeProject !== null); }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]); // Compute the list of environments this logical project spans, used to // drive the environment picker in BranchToolbar. const allProjects = useProjects(); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; const activeEnvironment = activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; const activeEnvironmentUnavailable = activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected"; const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; const activeEnvironmentUnavailableState = useMemo(() => { if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { return null; } return { environmentId: activeEnvironment.environmentId, label: activeEnvironmentUnavailableLabel, connection: activeEnvironment.connection, }; }, [activeEnvironment, activeEnvironmentUnavailable, activeEnvironmentUnavailableLabel]); const handleReconnectActiveEnvironment = useCallback( async (environmentId: EnvironmentId) => { const result = await retryEnvironment(environmentId); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Could not reconnect environment", description: error instanceof Error ? error.message : "Failed to reconnect.", }), ); } }, [retryEnvironment], ); const projectGroupingSettings = selectProjectGroupingSettings(settings); 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 label = environmentById.get(p.environmentId)?.label ?? p.environmentId; 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, environmentById]); 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; const threadUpdatedAt = Date.parse(serverThread.updatedAt); if (Number.isNaN(threadUpdatedAt)) return; const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return; markThreadVisited( scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), serverThread.updatedAt, ); }, [ activeThreadLastVisitedAt, markThreadVisited, serverThread?.environmentId, serverThread?.id, serverThread?.updatedAt, ]); const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.instanceId ?? activeProject?.defaultModelSelection?.instanceId ?? null; const lockedProvider = deriveLockedProvider({ thread: activeThread, selectedProvider: selectedProviderByThreadId, threadProvider, }); // Once a thread selects an environment, never substitute the primary // environment's config while the selected environment is still loading. const serverConfig = activeThread ? (activeEnvironment?.serverConfig ?? null) : (primaryEnvironment?.serverConfig ?? null); 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 = environments.length > 1; const versionMismatchServerLabel = hasMultipleRegisteredEnvironments && activeThread ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server` : "server"; const versionMismatchEnvironmentId = versionMismatch && activeThread ? activeThread.environmentId : null; const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); const systemComposerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; if (activeEnvironmentUnavailableState) { const connection = activeEnvironmentUnavailableState.connection; const isReconnecting = connection.phase === "connecting" || connection.phase === "reconnecting"; items.push({ id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, variant: connection.phase === "error" ? "error" : "warning", icon: , title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusTitle(connection)}`, description: connection.error ?? "Reconnect this environment before sending messages or running actions.", actions: ( <> ), }); } if ( showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey && versionMismatchEnvironmentId ) { 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}.{" "} {serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)} ), // The desktop-managed guidance is already the description; the action // slot would only repeat it. actions: versionMismatchSelfUpdate === "desktop-managed" ? undefined : ( ), dismissLabel: "Dismiss version mismatch warning", onDismiss: () => { dismissVersionMismatch(versionMismatchDismissKey); setDismissedVersionMismatchKey(versionMismatchDismissKey); }, }); } return items; }, [ activeEnvironmentUnavailableState, handleReconnectActiveEnvironment, navigate, setDismissedVersionMismatchKey, showVersionMismatchBanner, versionMismatch, versionMismatchDismissKey, versionMismatchEnvironmentId, versionMismatchSelfUpdate, versionMismatchServerLabel, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const unlockedSelectedProvider = resolveSelectableProvider( providerStatuses, selectedProviderByThreadId ?? threadProvider, ); const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [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, }); 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] ?? []; const nextPreviewUrlSet = new Set(previewUrls); for (const previewUrl of previousPreviewUrls) { if (!nextPreviewUrlSet.has(previewUrl)) { revokeBlobPreviewUrl(previewUrl); } } setAttachmentPreviewHandoffByMessageId((existing) => { const next = { ...existing, [messageId]: previewUrls, }; attachmentPreviewHandoffByMessageIdRef.current = next; return next; }); }, []); const serverMessages = activeThread?.messages; const serverAttachmentIds = useMemo(() => { const attachmentIds = new Set(); for (const message of serverMessages ?? []) { for (const attachment of message.attachments ?? []) { attachmentIds.add(attachment.id); } } return [...attachmentIds]; }, [serverMessages]); const serverAttachmentResources = useMemo( () => serverAttachmentIds.map((attachmentId) => ({ _tag: "attachment" as const, attachmentId, })), [serverAttachmentIds], ); const serverAttachmentUrls = useAssetUrls(environmentId, serverAttachmentResources); const serverAttachmentUrlById = useMemo( () => new Map( serverAttachmentIds.flatMap((attachmentId, index) => { const url = serverAttachmentUrls[index]; return url ? [[attachmentId, url] as const] : []; }), ), [serverAttachmentIds, serverAttachmentUrls], ); const displayServerMessages = useMemo>(() => { if (!serverMessages) return []; return serverMessages.map((message) => { if (!message.attachments || message.attachments.length === 0) { return message; } return { ...message, attachments: message.attachments.map((attachment) => { const previewUrl = serverAttachmentUrlById.get(attachment.id); return previewUrl ? { ...attachment, previewUrl } : attachment; }), }; }); }, [serverAttachmentUrlById, serverMessages]); useEffect(() => { if (typeof Image === "undefined" || displayServerMessages.length === 0) { return; } const cleanups: Array<() => void> = []; const userMessagesById = new Map( displayServerMessages .filter((message) => message.role === "user") .map((message) => [String(message.id), message] as const), ); for (const [messageId, handoffPreviewUrls] of Object.entries( attachmentPreviewHandoffByMessageId, )) { if (attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]) { continue; } const serverMessage = userMessagesById.get(messageId); 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, displayServerMessages]); const timelineMessages = useMemo(() => { const messages = displayServerMessages; 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. 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]; }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]); const timelineEntries = useMemo( () => deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), [activeThread?.proposedPlans, timelineMessages, workLogEntries], ); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; const isDraftHeroState = isLocalDraftThread && timelineEntries.length === 0 && !isWorking && !draftHeroDockRequested; const [ attachDraftHeroTransitionGroupRef, attachDraftHeroComposerAnchorRef, captureDraftHeroComposerRect, ] = useDraftHeroLayoutTransition(isDraftHeroState); 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 gitCwd = activeProject ? projectScriptCwd({ project: { cwd: activeProject.workspaceRoot }, worktreePath: activeThread?.worktreePath ?? null, }) : null; const gitStatusCwd = activeThread?.worktreePath ?? gitCwd; const gitStatusQuery = useEnvironmentQuery( gitStatusCwd === null ? null : vcsEnvironment.status({ environmentId, input: { cwd: gitStatusCwd }, }), ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); // 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 selectedProviderInstanceId = providerStatuses.find((status) => status.instanceId === selectedProviderByThreadId) ?.instanceId ?? null; const activeProviderInstanceId = selectedProviderInstanceId ?? 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 providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< string | null >(null); useEffect(() => { if (providerStatusBannerKey === null && dismissedProviderStatusBannerKey !== null) { setDismissedProviderStatusBannerKey(null); } }, [dismissedProviderStatusBannerKey, providerStatusBannerKey]); const visibleProviderStatus = shouldShowProviderStatusBanner( activeProviderStatus, dismissedProviderStatusBannerKey, ) ? activeProviderStatus : null; const hasTimelineTopBanner = Boolean(threadError) || visibleProviderStatus !== null; const activeProjectCwd = activeProject?.workspaceRoot ?? 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 showComposerContextStrip = isGitRepo && activeProject !== null; const initialDiffPanelGitScope = gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; const diffPanelGitStatusResolutionKey = gitStatusQuery.data ? "resolved" : "pending"; const terminalShortcutLabelOptions = useMemo( () => ({ context: { terminalFocus: true, terminalOpen: Boolean(terminalUiState.terminalOpen), }, }), [terminalUiState.terminalOpen], ); const splitTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], ); const splitTerminalVerticalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.splitVertical", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], ); const newTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], ); const closeTerminalShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions), [keybindings, terminalShortcutLabelOptions], ); const onToggleDiff = useCallback(() => { if (!isServerThread) { return; } if (!diffOpen) { onDiffPanelOpen?.(); } if (activeThreadRef) { useRightPanelStore.getState().toggle(activeThreadRef, "diff"); } }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]); const envLocked = Boolean( activeThread && (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "stopped")), ); // 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 nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() }; if ( serverThread && targetThreadId === routeThreadRef.threadId && serverThread.environmentId === routeThreadRef.environmentId && serverThread.id === targetThreadId ) { setLocalServerErrorsByThreadKey((existing) => { if ((existing[routeThreadKey]?.message ?? null) === nextError) { return existing; } return { ...existing, [routeThreadKey]: nextEntry, }; }); return; } const localDraftErrorKey = draftId ?? targetThreadId; setLocalDraftErrorsByDraftId((existing) => { if ((existing[localDraftErrorKey]?.message ?? null) === nextError) { return existing; } return { ...existing, [localDraftErrorKey]: nextEntry, }; }); }, [draftId, routeThreadKey, routeThreadRef, serverThread], ); const focusComposer = useCallback(() => { composerRef.current?.focusAtEnd(); }, [composerRef]); const scheduleComposerFocus = useCallback(() => { window.requestAnimationFrame(() => { focusComposer(); }); }, [focusComposer]); const addTerminalContextToDraft = useCallback( (selection: TerminalContextSelection) => { composerRef.current?.addTerminalContext(selection); }, [composerRef], ); const setTerminalOpen = useCallback( (open: boolean) => { if (!activeThreadRef) return; storeSetTerminalOpen(activeThreadRef, open); }, [activeThreadRef, storeSetTerminalOpen], ); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadRef) return; const nextOpen = !terminalUiState.terminalOpen; if (nextOpen && terminalUiState.terminalIds.length === 0) { if (!activeThreadId || !activeProject) { return; } const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; if (!cwdForOpen) { return; } const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); storeEnsureTerminal(activeThreadRef, terminalId, { open: true }); void openTerminal({ environmentId, input: { threadId: activeThreadId, terminalId, cwd: cwdForOpen, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), env: projectScriptRuntimeEnv({ project: { cwd: activeProject.workspaceRoot }, worktreePath: activeThreadWorktreePath, }), }, }); return; } setTerminalOpen(nextOpen); }, [ activeKnownTerminalIds, activeProject, activeThreadId, activeThreadRef, activeThreadWorktreePath, environmentId, gitCwd, openTerminal, panelTerminalIds, setTerminalOpen, storeEnsureTerminal, terminalUiState.terminalIds.length, terminalUiState.terminalOpen, ]); const splitTerminal = useCallback( (direction: "horizontal" | "vertical" = "horizontal") => { if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { return; } const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; if (!cwdForOpen) { return; } const terminalId = nextTerminalId(activeKnownTerminalIds); if (direction === "vertical") { storeSplitTerminalVertical(activeThreadRef, terminalId); } else { storeSplitTerminal(activeThreadRef, terminalId); } setTerminalFocusRequestId((value) => value + 1); void openTerminal({ environmentId, input: { threadId: activeThreadId, terminalId, cwd: cwdForOpen, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), env: projectScriptRuntimeEnv({ project: { cwd: activeProject.workspaceRoot }, worktreePath: activeThreadWorktreePath, }), }, }); }, [ activeProject, activeKnownTerminalIds, activeThreadId, activeThreadRef, openTerminal, activeThreadWorktreePath, environmentId, gitCwd, hasReachedSplitLimit, storeSplitTerminal, storeSplitTerminalVertical, ], ); const createNewTerminal = useCallback(() => { if (!activeThreadRef || !activeThreadId || !activeProject) { return; } const cwdForOpen = gitCwd ?? activeProject.workspaceRoot; if (!cwdForOpen) { return; } const terminalId = nextTerminalId(activeKnownTerminalIds); storeNewTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); void openTerminal({ environmentId, input: { threadId: activeThreadId, terminalId, cwd: cwdForOpen, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), env: projectScriptRuntimeEnv({ project: { cwd: activeProject.workspaceRoot }, worktreePath: activeThreadWorktreePath, }), }, }); }, [ activeProject, activeKnownTerminalIds, activeThreadId, activeThreadRef, openTerminal, activeThreadWorktreePath, environmentId, gitCwd, storeNewTerminal, ]); const closeTerminal = useCallback( (terminalId: string) => { if (!activeThreadId || !activeThreadRef) return; const fallbackExitWrite = () => writeTerminal({ environmentId, input: { threadId: activeThreadId, terminalId, data: "exit\n" }, }); void (async () => { const closeResult = await closeTerminalMutation({ environmentId, input: { threadId: activeThreadId, terminalId, deleteHistory: true, }, }); if (closeResult._tag === "Failure" && !isAtomCommandInterrupted(closeResult)) { await fallbackExitWrite(); } })(); storeCloseTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [ activeThreadId, activeThreadRef, closeTerminalMutation, environmentId, storeCloseTerminal, writeTerminal, ], ); const runProjectScript = useCallback( async ( script: ProjectScript, options?: { cwd?: string; env?: Record; worktreePath?: string | null; preferNewTerminal?: boolean; rememberAsLastInvoked?: boolean; }, ) => { if (!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.workspaceRoot; 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.workspaceRoot, }, 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); } const openResult = await openTerminal({ environmentId, input: openTerminalInput }); if (openResult._tag === "Failure") { if (!isAtomCommandInterrupted(openResult)) { const error = squashAtomCommandFailure(openResult); setThreadError( activeThreadId, error instanceof Error ? error.message : `Failed to run script "${script.name}".`, ); } return; } const writeResult = await writeTerminal({ environmentId, input: { threadId: activeThreadId, terminalId: targetTerminalId, data: `${script.command}\r`, }, }); if (writeResult._tag === "Failure" && !isAtomCommandInterrupted(writeResult)) { const error = squashAtomCommandFailure(writeResult); setThreadError( activeThreadId, error instanceof Error ? error.message : `Failed to run script "${script.name}".`, ); } }, [ activeProject, activeThread, activeThreadId, activeThreadRef, gitCwd, setTerminalOpen, setThreadError, storeNewTerminal, storeSetActiveTerminal, setLastInvokedScriptByProjectId, environmentId, openTerminal, activeKnownTerminalIds, runningTerminalIds, terminalUiState.activeTerminalId, writeTerminal, ], ); const persistProjectScripts = useCallback( async (input: { projectId: ProjectId; projectCwd: string; previousScripts: ReadonlyArray; nextScripts: ReadonlyArray; keybinding?: string | null; keybindingCommand: KeybindingCommand; }): Promise> => { const updateResult = mapAtomCommandResult( await updateProject({ environmentId, input: { projectId: input.projectId, scripts: input.nextScripts, }, }), () => undefined, ); if (updateResult._tag === "Failure") { return updateResult; } const keybindingRule = decodeProjectScriptKeybindingRule({ keybinding: input.keybinding, command: input.keybindingCommand, }); if (isElectron && keybindingRule) { return mapAtomCommandResult( await upsertKeybinding({ environmentId, input: keybindingRule, }), () => undefined, ); } return updateResult; }, [environmentId, updateProject, upsertKeybinding], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput): Promise> => { if (!activeProject) { return AsyncResult.success(undefined); } const nextId = nextProjectScriptId( input.name, activeProject.scripts.map((script) => script.id), ); const nextScript = buildProjectScript(nextId, input); const nextScripts = input.runOnWorktreeCreate ? [ ...activeProject.scripts.map((script) => script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, ), nextScript, ] : [...activeProject.scripts, nextScript]; return persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.workspaceRoot, previousScripts: activeProject.scripts, nextScripts, keybinding: input.keybinding, keybindingCommand: commandForProjectScript(nextId), }); }, [activeProject, persistProjectScripts], ); const updateProjectScript = useCallback( async ( scriptId: string, input: NewProjectScriptInput, ): Promise> => { if (!activeProject) { return AsyncResult.success(undefined); } const existingScript = activeProject.scripts.find((script) => script.id === scriptId); if (!existingScript) { return AsyncResult.failure(Cause.fail(new Error("Script not found."))); } const updatedScript = buildProjectScript(existingScript.id, input); const nextScripts = activeProject.scripts.map((script) => script.id === scriptId ? updatedScript : input.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, ); return persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.workspaceRoot, previousScripts: activeProject.scripts, nextScripts, keybinding: input.keybinding, keybindingCommand: commandForProjectScript(scriptId), }); }, [activeProject, persistProjectScripts], ); const deleteProjectScript = useCallback( async (scriptId: string): Promise> => { if (!activeProject) { return AsyncResult.success(undefined); } const nextScripts = activeProject.scripts.filter((script) => script.id !== scriptId); const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name; const result = await persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.workspaceRoot, previousScripts: activeProject.scripts, nextScripts, keybinding: null, keybindingCommand: commandForProjectScript(scriptId), }); if (result._tag === "Success") { toastManager.add({ type: "success", title: `Deleted action "${deletedName ?? "Unknown"}"`, }); } else if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Could not delete action", description: error instanceof Error ? error.message : "An unexpected error occurred.", }), ); } return result; }, [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 dismissPlanSidebarForCurrentTurn = useCallback(() => { planSidebarDismissedForTurnRef.current = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; }, [activePlan?.turnId, sidebarProposedPlan?.turnId]); const togglePlanSidebar = useCallback(() => { if (!activeThreadRef) return; if (planSidebarOpen) { dismissPlanSidebarForCurrentTurn(); } else { planSidebarDismissedForTurnRef.current = null; } useRightPanelStore.getState().toggle(activeThreadRef, "plan"); }, [activeThreadRef, dismissPlanSidebarForCurrentTurn, planSidebarOpen]); const closePlanSidebar = useCallback(() => { if (!activeThreadRef) return; setMaximizedRightPanelThreadKey(null); useRightPanelStore.getState().close(activeThreadRef); dismissPlanSidebarForCurrentTurn(); }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]); const createBrowserSurface = useCallback(() => { if (!activeThreadRef) return; void addBrowserSurface({ threadRef: activeThreadRef, openPreview }); }, [activeThreadRef, openPreview]); const addDiffSurface = useCallback(() => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; if (planSidebarOpen) { dismissPlanSidebarForCurrentTurn(); } useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); }, [ activeThreadRef, dismissPlanSidebarForCurrentTurn, isGitRepo, isServerThread, onDiffPanelOpen, planSidebarOpen, ]); const addFilesSurface = useCallback(() => { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "files"); }, [activeProject, activeThreadRef]); const openFileSurface = useCallback( (relativePath: string) => { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().openFile(activeThreadRef, relativePath); }, [activeProject, activeThreadRef], ); const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; if (previewPanelOpen) { useRightPanelStore.getState().close(activeThreadRef); return; } const activeTabId = activePreviewState.activeTabId; if (activeTabId) { useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId); } else { createBrowserSurface(); } }, [activePreviewState.activeTabId, activeThreadRef, createBrowserSurface, previewPanelOpen]); const closePreviewPanel = useCallback(() => { if (activeThreadRef) { setMaximizedRightPanelThreadKey(null); useRightPanelStore.getState().close(activeThreadRef); } }, [activeThreadRef]); const addTerminalSurface = useCallback(() => { if (!activeThreadRef || !activeThreadId || !activeProject) return; const cwd = gitCwd ?? activeProject.workspaceRoot; const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); void openTerminal({ environmentId: activeThreadRef.environmentId, input: { threadId: activeThreadId, terminalId, cwd, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), env: projectScriptRuntimeEnv({ project: { cwd: activeProject.workspaceRoot }, worktreePath: activeThreadWorktreePath, }), }, }); }, [ activeKnownTerminalIds, activeProject, activeThreadId, activeThreadRef, activeThreadWorktreePath, gitCwd, openTerminal, panelTerminalIds, ]); const splitPanelTerminal = useCallback( (direction: "horizontal" | "vertical" = "horizontal") => { if ( !activeThreadRef || !activeThreadId || !activeProject || activeRightPanelSurface?.kind !== "terminal" || activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP ) { return; } const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); const cwd = gitCwd ?? activeProject.workspaceRoot; useRightPanelStore .getState() .splitTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId, direction); setTerminalFocusRequestId((value) => value + 1); void openTerminal({ environmentId: activeThreadRef.environmentId, input: { threadId: activeThreadId, terminalId, cwd, ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), env: projectScriptRuntimeEnv({ project: { cwd: activeProject.workspaceRoot }, worktreePath: activeThreadWorktreePath, }), }, }); }, [ activeKnownTerminalIds, activeProject, activeRightPanelSurface, activeThreadId, activeThreadRef, activeThreadWorktreePath, gitCwd, openTerminal, panelTerminalIds, ], ); const splitPanelTerminalVertical = useCallback(() => { splitPanelTerminal("vertical"); }, [splitPanelTerminal]); const activatePanelTerminal = useCallback( (terminalId: string) => { if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; useRightPanelStore .getState() .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeRightPanelSurface, activeThreadRef], ); const closePanelTerminal = useCallback( (terminalId: string) => { if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return; void closeTerminalMutation({ environmentId: activeThreadRef.environmentId, input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, }); storeCloseTerminal(activeThreadRef, terminalId); useRightPanelStore .getState() .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeRightPanelSurface, activeThreadRef, closeTerminalMutation, storeCloseTerminal], ); const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; if (surface.kind === "plan") { planSidebarDismissedForTurnRef.current = null; } else if (planSidebarOpen) { dismissPlanSidebarForCurrentTurn(); } useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); if (surface.kind === "preview" && surface.resourceId) { setActivePreviewTab(activeThreadRef, surface.resourceId); } if (surface.kind === "terminal") { setTerminalFocusRequestId((value) => value + 1); } if (surface.kind === "diff" && !diffOpen) { onDiffPanelOpen?.(); } }, [activeThreadRef, diffOpen, dismissPlanSidebarForCurrentTurn, onDiffPanelOpen, planSidebarOpen], ); const toggleRightPanel = useCallback(() => { if (!activeThreadRef) return; if (rightPanelOpen) { if (planSidebarOpen) { closePlanSidebar(); } else { closePreviewPanel(); } return; } useRightPanelStore.getState().toggleVisibility(activeThreadRef); }, [activeThreadRef, closePlanSidebar, closePreviewPanel, planSidebarOpen, rightPanelOpen]); const toggleRightPanelMaximized = useCallback(() => { if (!canMaximizeRightPanel) return; setMaximizedRightPanelThreadKey((threadKey) => threadKey === routeThreadKey ? null : routeThreadKey, ); }, [canMaximizeRightPanel, routeThreadKey]); const cleanupRightPanelSurfaces = useCallback( (surfaces: readonly RightPanelSurface[]) => { if (!activeThreadRef) return; if (surfaces.some((surface) => surface.kind === "plan")) { dismissPlanSidebarForCurrentTurn(); } for (const surface of surfaces) { if (surface.kind === "preview" && surface.resourceId) { void closePreviewSession({ closePreview, snapshot: activePreviewState.sessions[surface.resourceId] ?? null, tabId: surface.resourceId, threadRef: activeThreadRef, }); } if (surface.kind === "terminal") { for (const terminalId of surface.terminalIds) { storeCloseTerminal(activeThreadRef, terminalId); void closeTerminalMutation({ environmentId: activeThreadRef.environmentId, input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true }, }); } } } }, [ activeThreadRef, activePreviewState.sessions, closePreview, closeTerminalMutation, dismissPlanSidebarForCurrentTurn, storeCloseTerminal, ], ); const syncActivePreviewSurface = useCallback(() => { if (!activeThreadRef) return; const nextActiveSurface = selectActiveRightPanelSurface( useRightPanelStore.getState().byThreadKey, activeThreadRef, ); if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) { setActivePreviewTab(activeThreadRef, nextActiveSurface.resourceId); } }, [activeThreadRef]); const closeRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; cleanupRightPanelSurfaces([surface]); useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); syncActivePreviewSurface(); }, [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface], ); const closeOtherRightPanelSurfaces = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; const surfaces = rightPanelState.surfaces.filter((entry) => entry.id !== surface.id); cleanupRightPanelSurfaces(surfaces); useRightPanelStore.getState().closeOtherSurfaces(activeThreadRef, surface.id); syncActivePreviewSurface(); }, [ activeThreadRef, cleanupRightPanelSurfaces, rightPanelState.surfaces, syncActivePreviewSurface, ], ); const closeRightPanelSurfacesToRight = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; const surfaceIndex = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id); if (surfaceIndex < 0) return; const surfaces = rightPanelState.surfaces.slice(surfaceIndex + 1); cleanupRightPanelSurfaces(surfaces); useRightPanelStore.getState().closeSurfacesToRight(activeThreadRef, surface.id); syncActivePreviewSurface(); }, [ activeThreadRef, cleanupRightPanelSurfaces, rightPanelState.surfaces, syncActivePreviewSurface, ], ); const closeAllRightPanelSurfaces = useCallback(() => { if (!activeThreadRef) return; cleanupRightPanelSurfaces(rightPanelState.surfaces); useRightPanelStore.getState().closeAllSurfaces(activeThreadRef); }, [activeThreadRef, cleanupRightPanelSurfaces, rightPanelState.surfaces]); const copyRightPanelFilePath = useCallback((relativePath: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( stackedThreadToast({ type: "error", title: "Failed to copy path", description: "Clipboard API unavailable.", }), ); return; } void navigator.clipboard.writeText(relativePath).then( () => { toastManager.add({ type: "success", title: "Path copied", description: relativePath, }); }, (error) => { toastManager.add( stackedThreadToast({ type: "error", title: "Failed to copy path", description: error instanceof Error ? error.message : "An error occurred.", }), ); }, ); }, []); useEffect( () => subscribePreviewAction((action) => { if (action === "toggle-panel") togglePreviewPanel(); }), [togglePreviewPanel], ); const persistThreadSettingsForNextTurn = useCallback( async (input: { threadId: ThreadId; createdAt: string; modelSelection?: ModelSelection; branch?: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; }): Promise> => { if (!serverThread) { return AsyncResult.success(undefined); } let result: AtomCommandResult = AsyncResult.success(undefined); const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({ currentModelSelection: serverThread.modelSelection, ...(input.modelSelection ? { nextModelSelection: input.modelSelection } : {}), currentBranch: serverThread.branch, ...(input.branch ? { nextBranch: input.branch } : {}), }); if (metadataUpdate) { result = mapAtomCommandResult( await updateThreadMetadata({ environmentId, input: { threadId: input.threadId, ...metadataUpdate, }, }), () => undefined, ); if (result._tag === "Failure") { return result; } } if (input.runtimeMode !== serverThread.runtimeMode) { result = mapAtomCommandResult( await setThreadRuntimeMode({ environmentId, input: { threadId: input.threadId, runtimeMode: input.runtimeMode, createdAt: input.createdAt, }, }), () => undefined, ); if (result._tag === "Failure") { return result; } } if (input.interactionMode !== serverThread.interactionMode) { result = mapAtomCommandResult( await setThreadInteractionMode({ environmentId, input: { threadId: input.threadId, interactionMode: input.interactionMode, createdAt: input.createdAt, }, }), () => undefined, ); } return result; }, [ environmentId, serverThread, setThreadInteractionMode, setThreadRuntimeMode, updateThreadMetadata, ], ); // 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 timelineScrollModeRef = useRef("following-end"); const pendingTimelineAnchorRef = useRef(null); const positionedTimelineAnchorRef = useRef(null); const settledTimelineAnchorRef = useRef(null); const activeTimelineAnchorIndexRef = useRef(null); const anchorUserScrollGenerationRef = useRef(0); const liveFollowUserScrollGenerationRef = useRef(0); const pendingAnchorScrollRestoreRef = useRef<{ readonly messageId: MessageId; readonly offset: number; readonly userScrollGeneration: number; } | null>(null); const anchorScrollRestoreFrameRef = useRef(null); const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { anchorUserScrollGenerationRef.current += 1; timelineScrollModeRef.current = "free-scrolling"; liveFollowUserScrollGenerationRef.current = null; pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; pendingAnchorScrollRestoreRef.current = null; if (anchorScrollRestoreFrameRef.current !== null) { cancelAnimationFrame(anchorScrollRestoreFrameRef.current); anchorScrollRestoreFrameRef.current = null; } }, []); const cancelTimelineLiveFollowForUserNavigationRef = useRef( cancelTimelineLiveFollowForUserNavigation, ); useEffect(() => { cancelTimelineLiveFollowForUserNavigationRef.current = cancelTimelineLiveFollowForUserNavigation; }, [cancelTimelineLiveFollowForUserNavigation]); const getActiveTimelineTurnMetrics = useCallback( (list?: LegendListRef | null) => { const resolvedList = list ?? legendListRef.current; const anchorIndex = activeTimelineAnchorIndexRef.current; const state = resolvedList?.getState(); if (!resolvedList || !state || anchorIndex === null) { return null; } return getAnchoredTurnMetrics({ state, anchorIndex, composerOverlayHeight, anchorOffset: CHAT_LIST_ANCHOR_OFFSET, }); }, [composerOverlayHeight], ); const timelineRealContentOverflowsViewport = useCallback( (list?: LegendListRef | null) => { const resolvedList = list ?? legendListRef.current; const state = resolvedList?.getState(); if (!resolvedList || !state || state.data.length === 0) { return false; } const lastRowIndex = state.data.length - 1; const lastRowTop = state.positionAtIndex(lastRowIndex); const lastRowHeight = state.sizeAtIndex(lastRowIndex); if ( typeof lastRowTop !== "number" || typeof lastRowHeight !== "number" || !Number.isFinite(lastRowTop) || !Number.isFinite(lastRowHeight) ) { return false; } const realContentBottom = lastRowTop + Math.max(1, lastRowHeight); const visibleScrollLength = Math.max( 0, (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_LIST_ANCHOR_OFFSET, ); return realContentBottom > visibleScrollLength; }, [composerOverlayHeight], ); // Live-follow stays active after send/thread-open until an actual list scroll // gesture opts out. const scrollToEnd = useCallback((animated = false) => { isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); void legendListRef.current?.scrollToEnd?.({ animated }); }, []); useEffect(() => { let removeListeners: (() => void) | null = null; const frame = requestAnimationFrame(() => { const scrollNode = legendListRef.current?.getScrollableNode(); if (!scrollNode) { return; } const handleManualNavigation = () => { cancelTimelineLiveFollowForUserNavigationRef.current(); }; scrollNode.addEventListener("wheel", handleManualNavigation, { passive: true, }); scrollNode.addEventListener("touchmove", handleManualNavigation, { passive: true, }); scrollNode.addEventListener("pointerdown", handleManualNavigation, { passive: true, }); removeListeners = () => { scrollNode.removeEventListener("wheel", handleManualNavigation); scrollNode.removeEventListener("touchmove", handleManualNavigation); scrollNode.removeEventListener("pointerdown", handleManualNavigation); }; }); return () => { cancelAnimationFrame(frame); removeListeners?.(); }; }, [activeThread?.id]); const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { if (pendingTimelineAnchorRef.current === messageId) { pendingTimelineAnchorRef.current = null; } activeTimelineAnchorIndexRef.current = anchorIndex; if (positionedTimelineAnchorRef.current === messageId) { return; } positionedTimelineAnchorRef.current = messageId; settledTimelineAnchorRef.current = null; const positionAnchor = (remainingAttempts: number) => { requestAnimationFrame(() => { if (positionedTimelineAnchorRef.current !== messageId) { return; } const list = legendListRef.current; if (!list) { if (remainingAttempts > 0) { positionAnchor(remainingAttempts - 1); } return; } const scrollNode = list.getScrollableNode(); let finished = false; const finishAnimatedPositioning = () => { if (finished) { return; } finished = true; window.clearTimeout(fallbackTimer); scrollNode.removeEventListener("scrollend", finishAnimatedPositioning); if (positionedTimelineAnchorRef.current !== messageId) { return; } const scrollOffset = list.getState().scroll; void list.scrollToOffset({ offset: scrollOffset, animated: false }); settledTimelineAnchorRef.current = messageId; }; const fallbackTimer = window.setTimeout(finishAnimatedPositioning, 750); scrollNode.addEventListener("scrollend", finishAnimatedPositioning, { once: true }); void list.scrollToIndex({ index: anchorIndex, animated: true, viewPosition: 0, viewOffset: CHAT_LIST_ANCHOR_OFFSET, }); }); }; requestAnimationFrame(() => positionAnchor(12)); }, []); const onTimelineAnchorSizeChanged = useCallback((messageId: MessageId) => { if (settledTimelineAnchorRef.current !== messageId) { return; } if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) { return; } const scrollOffset = legendListRef.current?.getState().scroll; if (scrollOffset === undefined) { return; } if (pendingAnchorScrollRestoreRef.current === null) { pendingAnchorScrollRestoreRef.current = { messageId, offset: scrollOffset, userScrollGeneration: anchorUserScrollGenerationRef.current, }; } if (anchorScrollRestoreFrameRef.current !== null) { return; } anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => { anchorScrollRestoreFrameRef.current = null; const pending = pendingAnchorScrollRestoreRef.current; pendingAnchorScrollRestoreRef.current = null; if ( pending && settledTimelineAnchorRef.current === pending.messageId && pending.userScrollGeneration === anchorUserScrollGenerationRef.current ) { const list = legendListRef.current; const currentScrollOffset = list?.getState().scroll; if ( typeof currentScrollOffset === "number" && Math.abs(currentScrollOffset - pending.offset) <= 2 ) { void list?.scrollToOffset({ offset: pending.offset, animated: false }); } } }); }, []); const onIsAtEndChange = useCallback((isAtEnd: boolean) => { if ( !isAtEnd && liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current ) { showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); return; } if (isAtEndRef.current === isAtEnd) return; isAtEndRef.current = isAtEnd; if (isAtEnd) { timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); } else { timelineScrollModeRef.current = "free-scrolling"; liveFollowUserScrollGenerationRef.current = null; showScrollDebouncer.current.maybeExecute(); } }, []); useEffect(() => { if (!activeThread?.id) { return; } if (liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current) { return; } let secondFrame: number | null = null; const frame = requestAnimationFrame(() => { secondFrame = requestAnimationFrame(() => { if (liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current) { return; } if (pendingTimelineAnchorRef.current !== null) { return; } if ( positionedTimelineAnchorRef.current !== null && settledTimelineAnchorRef.current !== positionedTimelineAnchorRef.current ) { return; } const list = legendListRef.current; if (!list) { return; } if (timelineScrollModeRef.current === "anchoring-new-turn") { const metrics = getActiveTimelineTurnMetrics(list); if (!metrics) { return; } if (metrics.scrollDeltaToRevealEnd <= 1) { return; } const nextOffset = list.getState().scroll + metrics.scrollDeltaToRevealEnd; void list.scrollToOffset({ offset: nextOffset, animated: false }); return; } if (timelineScrollModeRef.current !== "following-end") { return; } if (!timelineRealContentOverflowsViewport(list)) { return; } void list.scrollToEnd?.({ animated: false }); }); }); return () => { cancelAnimationFrame(frame); if (secondFrame !== null) { cancelAnimationFrame(secondFrame); } }; }, [ activeThread?.id, timelineEntries, getActiveTimelineTurnMetrics, timelineRealContentOverflowsViewport, ]); useEffect(() => { setPullRequestDialogState(null); isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); if (planSidebarOpenOnNextThreadRef.current) { planSidebarOpenOnNextThreadRef.current = false; if (activeThreadRef) { useRightPanelStore.getState().open(activeThreadRef, "plan"); } } planSidebarDismissedForTurnRef.current = null; // activeThreadRef resets transitively with the active thread. }, [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; if (activeThreadRef) { useRightPanelStore.getState().open(activeThreadRef, "plan"); } }, [ activePlan, activeLatestTurn?.turnId, activeThreadRef, 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 startFromOrigin = isLocalDraftThread ? (draftThread?.startFromOrigin ?? false) : canOverrideServerThreadEnvMode ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ?? primaryServerSettings.newWorktreesStartFromOrigin) : false; const sendEnvMode = resolveSendEnvMode({ requestedEnvMode: envMode, isGitRepo, }); const localCheckoutBranchMismatch = useMemo( () => isServerThread ? resolveLocalCheckoutBranchMismatch({ effectiveEnvMode: envMode, activeWorktreePath, activeThreadBranch, currentGitBranch: gitStatusQuery.data?.refName ?? null, }) : null, [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread], ); // Settled state of the open thread, resolved exactly like the sidebar // partition (same shell, same capability gate, same PR auto-settle input) // so the banner and the sidebar row never disagree. const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); const activeThreadPr = resolveThreadPr({ threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, hasDedicatedWorktree: (activeThread?.worktreePath ?? null) !== null, }); const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; const nowMinute = useNowMinute(); const activeThreadSnoozed = activeThreadShell !== null && supportsSnooze && effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() }); const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); useEffect(() => { void snoozeWakeTick; if (!activeThreadSnoozed) return; const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? ""); if (!Number.isFinite(wakeAtMs)) return; const id = window.setTimeout( () => bumpSnoozeWakeTick((tick) => tick + 1), Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647), ); return () => window.clearTimeout(id); }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]); const activeThreadSettled = useMemo(() => { if (activeThreadShell === null || !supportsSettlement) return false; return effectiveSettled(activeThreadShell, { now: `${nowMinute}:00.000Z`, autoSettleAfterDays, changeRequestState: activeThreadPr?.state ?? null, }); }, [ activeThreadPr?.state, activeThreadShell, autoSettleAfterDays, nowMinute, supportsSettlement, ]); const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { reportFailure: false, }); // Keyed by thread, not a boolean: the pending state must follow the thread // it belongs to across navigation, and a request resolving for thread A // must never clear (or re-enable) thread B's button. const [unsettlingThreadKey, setUnsettlingThreadKey] = useState(null); const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey; const handleUnsettleActiveThread = useCallback(async () => { if (!activeThreadRef) return; const threadKey = scopedThreadKey(activeThreadRef); setUnsettlingThreadKey(threadKey); try { const result = await unsettleThreadMutation({ environmentId: activeThreadRef.environmentId, input: { threadId: activeThreadRef.threadId, reason: "user" }, }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Failed to un-settle thread", description: error instanceof Error ? error.message : "An error occurred.", }), ); } } finally { setUnsettlingThreadKey((current) => (current === threadKey ? null : current)); } }, [activeThreadRef, unsettleThreadMutation]); const unsnoozeThreadMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false, }); const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState(null); const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey; const handleUnsnoozeActiveThread = useCallback(async () => { if (!activeThreadRef) return; const threadKey = scopedThreadKey(activeThreadRef); setUnsnoozingThreadKey(threadKey); try { const result = await unsnoozeThreadMutation({ environmentId: activeThreadRef.environmentId, input: { threadId: activeThreadRef.threadId, reason: "user" }, }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Failed to wake thread", description: error instanceof Error ? error.message : "An error occurred.", }), ); } } finally { setUnsnoozingThreadKey((current) => (current === threadKey ? null : current)); } }, [activeThreadRef, unsnoozeThreadMutation]); const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); // Once revealed for a given mismatch, the banner stays mounted until the // mismatch changes or resolves, so clearing the draft doesn't flicker it. const [revealedBranchMismatchKey, setRevealedBranchMismatchKey] = useState(null); // Dismissal lives in a module-level set (survives remounts); this tick just // forces a re-render so the banner leaves immediately. const [, setBranchMismatchDismissTick] = useState(0); const composerHasDraftContent = useComposerDraftStore((store) => { const draft = store.getComposerDraft(composerDraftTarget); return Boolean( draft && (draft.prompt.trim().length > 0 || draft.images.length > 0 || draft.terminalContexts.length > 0 || draft.elementContexts.length > 0 || draft.previewAnnotations.length > 0 || draft.reviewComments.length > 0), ); }); const activeBranchMismatchKey = branchMismatchKey( activeThread?.id ?? null, localCheckoutBranchMismatch, ); const showBranchMismatchBanner = shouldShowBranchMismatchBanner({ hasMismatch: localCheckoutBranchMismatch !== null, isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey), composerHasContent: composerHasDraftContent, wasShownForCurrentMismatch: revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey, }); useEffect(() => { setRevealedBranchMismatchKey((revealed) => { if (showBranchMismatchBanner) { return activeBranchMismatchKey; } // Hysteresis is scoped to an uninterrupted mismatch: reset when the // mismatch resolves or changes so a recurrence re-gates on intent. return revealed !== null && revealed !== activeBranchMismatchKey ? null : revealed; }); }, [activeBranchMismatchKey, showBranchMismatchBanner]); const handleSwitchCheckoutToThread = useCallback(async () => { if ( !activeProjectCwd || !activeThread || !localCheckoutBranchMismatch || isRestoringThreadBranch ) { return; } setIsRestoringThreadBranch(true); const checkoutResult = await switchGitRef({ environmentId, input: { cwd: activeProjectCwd, refName: localCheckoutBranchMismatch.threadBranch, }, }); if (checkoutResult._tag === "Failure") { setIsRestoringThreadBranch(false); if (!isAtomCommandInterrupted(checkoutResult)) { toastManager.add( stackedThreadToast({ type: "error", title: "Failed to switch checkout", description: chatActionErrorMessage(squashAtomCommandFailure(checkoutResult)), }), ); } return; } const nextBranch = checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch; if (nextBranch !== activeThread.branch) { const updateResult = await updateThreadMetadata({ environmentId, input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null }, }); if (updateResult._tag === "Failure") { setIsRestoringThreadBranch(false); if (!isAtomCommandInterrupted(updateResult)) { toastManager.add( stackedThreadToast({ type: "error", title: "Checkout switched, but the thread could not be updated", description: chatActionErrorMessage(squashAtomCommandFailure(updateResult)), }), ); } gitStatusQuery.refresh(); return; } } gitStatusQuery.refresh(); setIsRestoringThreadBranch(false); scheduleComposerFocus(); }, [ activeProjectCwd, activeThread, environmentId, gitStatusQuery, isRestoringThreadBranch, localCheckoutBranchMismatch, scheduleComposerFocus, switchGitRef, updateThreadMetadata, ]); // The stack renders items[0] front-most and tucks the rest behind hover, so // ordering is priority: system banners, then the branch-mismatch notice, // and the informational parked-thread banner last — it must never cover another. const parkedThreadBannerItem = useMemo(() => { if (!activeThreadSnoozed && !activeThreadSettled) { return null; } const isSnoozed = activeThreadSnoozed; return { id: `thread-${isSnoozed ? "snoozed" : "settled"}:${activeThread?.id ?? "unknown"}`, variant: "info", icon: isSnoozed ? : , title: `This thread is ${isSnoozed ? "snoozed" : "settled"}`, description: isSnoozed ? "Sending a message wakes it and moves it back to Active in the sidebar." : "Sending a message moves it back to Active in the sidebar.", actions: ( ), }; }, [ activeThread?.id, activeThreadSettled, activeThreadSnoozed, handleUnsnoozeActiveThread, handleUnsettleActiveThread, isUnsnoozing, isUnsettling, ]); const handleRestoreThreadBranch = useCallback(() => { if (gitStatusQuery.data?.hasWorkingTreeChanges) { setBranchRestoreConfirmOpen(true); return; } void handleSwitchCheckoutToThread(); }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]); const composerBannerItems = useMemo(() => { const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [...systemComposerBannerItems, ...parkedThreadItems]; } return [ ...systemComposerBannerItems, { id: `branch-mismatch:${activeBranchMismatchKey}`, variant: "info", icon: , title: ( Branch changed — was {localCheckoutBranchMismatch.threadBranch} } /> This thread last ran on {localCheckoutBranchMismatch.threadBranch}. Sending will continue on {localCheckoutBranchMismatch.currentBranch}. ), className: "dark:shadow-none", actions: ( ), dismissLabel: "Dismiss branch change notice", onDismiss: () => { dismissBranchMismatchForSession(activeBranchMismatchKey); setBranchMismatchDismissTick((tick) => tick + 1); }, }, ...parkedThreadItems, ]; }, [ activeBranchMismatchKey, handleRestoreThreadBranch, isRestoringThreadBranch, localCheckoutBranchMismatch, parkedThreadBannerItem, showBranchMismatchBanner, systemComposerBannerItems, ]); 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 || isCommandPaletteOpen()) { return; } const terminalFocusOwner = getTerminalFocusOwner(); if (event.defaultPrevented && terminalFocusOwner === null) { return; } const shortcutContext = { terminalFocus: terminalFocusOwner !== null, terminalOpen: Boolean(terminalUiState.terminalOpen), modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, }; if ( !shortcutContext.terminalFocus && !shortcutContext.modelPickerOpen && shouldTypeToFocusComposer(event) ) { if (composerRef.current?.insertTextAtEnd(event.key)) { event.preventDefault(); event.stopPropagation(); return; } } const command = resolveShortcutCommand(event, keybindings, { context: shortcutContext, }); if (!command) return; if (command === "terminal.toggle") { event.preventDefault(); event.stopPropagation(); toggleTerminalVisibility(); return; } if (command === "rightPanel.toggle") { event.preventDefault(); event.stopPropagation(); toggleRightPanel(); return; } if (command === "terminal.split") { event.preventDefault(); event.stopPropagation(); if (terminalFocusOwner === "right-panel") { splitPanelTerminal(); return; } if (!terminalUiState.terminalOpen) { setTerminalOpen(true); } splitTerminal(); return; } if (command === "terminal.splitVertical") { event.preventDefault(); event.stopPropagation(); if (terminalFocusOwner === "right-panel") { splitPanelTerminal("vertical"); return; } if (!terminalUiState.terminalOpen) { setTerminalOpen(true); } splitTerminal("vertical"); return; } if (command === "terminal.close") { event.preventDefault(); event.stopPropagation(); if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") { closePanelTerminal(activeRightPanelSurface.activeTerminalId); return; } if (!terminalUiState.terminalOpen) return; closeTerminal(terminalUiState.activeTerminalId); return; } if (command === "terminal.new") { event.preventDefault(); event.stopPropagation(); if (terminalFocusOwner === "right-panel") { addTerminalSurface(); return; } 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, activeRightPanelSurface, addTerminalSurface, terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, closeTerminal, closePanelTerminal, createNewTerminal, setTerminalOpen, runProjectScript, splitTerminal, splitPanelTerminal, keybindings, onToggleDiff, toggleRightPanel, toggleTerminalVisibility, composerRef, ]); const onRevertToTurnCount = useCallback( async (turnCount: number) => { const localApi = readLocalApi(); if (!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); const result = await revertThreadCheckpoint({ environmentId, input: { threadId: activeThread.id, turnCount, }, }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); setThreadError( activeThread.id, error instanceof Error ? error.message : "Failed to revert thread state.", ); } setIsRevertingCheckpoint(false); }, [ activeThread, activeEnvironmentUnavailable, activeEnvironmentUnavailableLabel, environmentId, isConnecting, isRevertingCheckpoint, isSendBusy, phase, revertThreadCheckpoint, setThreadError, ], ); const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); if ( !activeThread || isSendBusy || isConnecting || activeEnvironmentUnavailable || sendInFlightRef.current ) return; if (activePendingProgress) { onAdvanceActivePendingUserInput(); return; } const sendCtx = composerRef.current?.getSendContext(); if (!sendCtx?.providerAvailable) return; const { images: composerImages, terminalContexts: composerTerminalContexts, elementContexts: composerElementContexts, previewAnnotations: composerPreviewAnnotations, reviewComments: composerReviewComments, 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, elementContextCount: composerElementContexts.length + composerPreviewAnnotations.length + composerReviewComments.length, }); 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 && composerElementContexts.length === 0 && composerPreviewAnnotations.length === 0 && composerReviewComments.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) { toastManager.add( stackedThreadToast({ type: "warning", title: "Choose a project first", description: "This draft no longer points to an available project.", }), ); 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; if (isDraftHeroState && activeThreadKey) { let resolveDockStarted: (() => void) | undefined; const dockStarted = new Promise((resolve) => { resolveDockStarted = resolve; }); const dockTransition = runMobileComposerTransition(() => { flushSync(() => { captureDraftHeroComposerRect(); setDockedDraftHeroThreadKey(activeThreadKey); }); resolveDockStarted?.(); }); void dockTransition.catch(() => resolveDockStarted?.()); await dockStarted; } beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const composerElementContextsSnapshot = [...composerElementContexts]; const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; const messageTextWithContexts = appendElementContextsToPrompt( appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), composerElementContextsSnapshot, ); const messageTextWithPreviewAnnotations = composerPreviewAnnotationsSnapshot.reduce( (text, annotation) => appendPreviewAnnotationPrompt(text, annotation), messageTextWithContexts, ); const messageTextForSend = appendReviewCommentsToPrompt( messageTextWithPreviewAnnotations, composerReviewCommentsSnapshot, ); 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, })); // Sending always returns to the live edge. The new row becomes the // anchored end-space target so it lands near the top while the response // streams into the reserved space below it. isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); setTimelineAnchor({ threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), messageId: messageIdForSend, }); setOptimisticUserMessages((existing) => [ ...existing, { id: messageIdForSend, role: "user", text: outgoingMessageText, ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), turnId: null, createdAt: messageCreatedAt, updatedAt: 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 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 if (composerElementContextsSnapshot.length > 0) { titleSeed = formatElementContextLabel(composerElementContextsSnapshot[0]!); } else { titleSeed = "New thread"; } } const title = truncate(titleSeed); const threadCreateModelSelection = createModelSelection( ctxSelectedModelSelection.instanceId, ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL, ctxSelectedModelSelection.options, ); let failure: AtomCommandResult | null = null; // Auto-title from first message if (isFirstMessage && isServerThread) { const titleResult = await updateThreadMetadata({ environmentId, input: { threadId: threadIdForSend, title, }, }); if (titleResult._tag === "Failure") { failure = titleResult; } } if (failure === null && isServerThread) { const settingsResult = await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, createdAt: messageCreatedAt, ...(ctxSelectedModel ? { modelSelection: ctxSelectedModelSelection } : {}), ...(localCheckoutBranchMismatch ? { branch: localCheckoutBranchMismatch.currentBranch } : {}), runtimeMode, interactionMode, }); if (settingsResult._tag === "Failure") { failure = settingsResult; } } const turnAttachmentsResult = await settlePromise(() => turnAttachmentsPromise); if (failure === null && turnAttachmentsResult._tag === "Failure") { failure = turnAttachmentsResult; } let turnStartSucceeded = false; if (failure === null && turnAttachmentsResult._tag === "Success") { 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.workspaceRoot, baseBranch: baseBranchForWorktree, branch: buildTemporaryWorktreeBranchName(randomHex), ...(startFromOrigin ? { startFromOrigin: true } : {}), }, runSetupScript: true, } : {}), } : undefined; beginLocalDispatch({ preparingWorktree: false }); const startResult = await startThreadTurn({ environmentId, input: { threadId: threadIdForSend, message: { messageId: messageIdForSend, role: "user", text: outgoingMessageText, attachments: turnAttachmentsResult.value, }, modelSelection: ctxSelectedModelSelection, titleSeed: title, runtimeMode, interactionMode, ...(bootstrap ? { bootstrap } : {}), createdAt: messageCreatedAt, }, }); if (startResult._tag === "Failure") { failure = startResult; } else { turnStartSucceeded = true; } } if (failure !== null) { if ( promptRef.current.length === 0 && composerImagesRef.current.length === 0 && composerTerminalContextsRef.current.length === 0 && composerElementContextsRef.current.length === 0 && (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations .length ?? 0) === 0 && (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments .length ?? 0) === 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; composerElementContextsRef.current = composerElementContextsSnapshot; setComposerDraftPrompt(composerDraftTarget, promptForSend); addComposerDraftImages(composerDraftTarget, retryComposerImages); setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot); setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot); composerRef.current?.resetCursorState({ cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), prompt: promptForSend, detectTrigger: true, }); } if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); setThreadError( threadIdForSend, error instanceof Error ? error.message : "Failed to send message.", ); } } sendInFlightRef.current = false; if (!turnStartSucceeded) { setDockedDraftHeroThreadKey((currentThreadKey) => currentThreadKey === activeThreadKey ? null : currentThreadKey, ); resetLocalDispatch(); } }; const onInterrupt = async () => { if (!activeThread) return; const result = await interruptThreadTurn({ environmentId, input: buildThreadTurnInterruptInput(activeThread), }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); setThreadError( activeThread.id, error instanceof Error ? error.message : "Failed to interrupt the current turn.", ); } }; const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { if (!activeThreadId) return; setRespondingRequestIds((existing) => existing.includes(requestId) ? existing : [...existing, requestId], ); const result = await respondToThreadApproval({ environmentId, input: { threadId: activeThreadId, requestId, decision, }, }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); setThreadError( activeThreadId, error instanceof Error ? error.message : "Failed to submit approval decision.", ); } setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); return result; }, [activeThreadId, environmentId, respondToThreadApproval, setThreadError], ); const onRespondToUserInput = useCallback( async (requestId: ApprovalRequestId, answers: Record) => { if (!activeThreadId) return; setRespondingUserInputRequestIds((existing) => existing.includes(requestId) ? existing : [...existing, requestId], ); const result = await respondToThreadUserInput({ environmentId, input: { threadId: activeThreadId, requestId, answers, }, }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); setThreadError( activeThreadId, error instanceof Error ? error.message : "Failed to submit user input.", ); } setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); return result; }, [activeThreadId, environmentId, respondToThreadUserInput, 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, composerRef], ); 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, composerRef], ); 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"; }) => { if ( !activeThread || !isServerThread || isSendBusy || isConnecting || sendInFlightRef.current ) { return; } const trimmed = text.trim(); if (!trimmed) { return; } const sendCtx = composerRef.current?.getSendContext(); if (!sendCtx?.providerAvailable) { 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); // Position this sent row once LegendList has measured the anchored tail. isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); setTimelineAnchor({ threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), messageId: messageIdForSend, }); setOptimisticUserMessages((existing) => [ ...existing, { id: messageIdForSend, role: "user", text: outgoingMessageText, turnId: null, createdAt: messageCreatedAt, updatedAt: messageCreatedAt, streaming: false, }, ]); const settingsResult = await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, createdAt: messageCreatedAt, modelSelection: ctxSelectedModelSelection, ...(localCheckoutBranchMismatch ? { branch: localCheckoutBranchMismatch.currentBranch } : {}), runtimeMode, interactionMode: nextInteractionMode, }); let failure: AtomCommandResult | null = settingsResult._tag === "Failure" ? settingsResult : null; if (failure === null) { // 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, ); const startResult = await startThreadTurn({ environmentId, input: { 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, }, }); failure = startResult._tag === "Failure" ? startResult : null; } if (failure === null) { // 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; if (activeThreadRef) { useRightPanelStore.getState().open(activeThreadRef, "plan"); } } sendInFlightRef.current = false; return; } setOptimisticUserMessages((existing) => existing.filter((message) => message.id !== messageIdForSend), ); if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); setThreadError( threadIdForSend, error instanceof Error ? error.message : "Failed to send plan follow-up.", ); } sendInFlightRef.current = false; resetLocalDispatch(); }, [ activeThread, activeProposedPlan, beginLocalDispatch, isConnecting, isSendBusy, isServerThread, localCheckoutBranchMismatch, persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, setComposerDraftInteractionMode, setThreadError, startThreadTurn, autoOpenPlanSidebar, environmentId, composerRef, ], ); const onImplementPlanInNewThread = useCallback(async () => { if ( !activeThread || !activeProject || !activeProposedPlan || !isServerThread || isSendBusy || isConnecting || activeEnvironmentUnavailable || sendInFlightRef.current ) { return; } const sendCtx = composerRef.current?.getSendContext(); if (!sendCtx?.providerAvailable) { 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(); }; const createResult = await createThread({ environmentId, input: { threadId: nextThreadId, projectId: activeProject.id, title: nextThreadTitle, modelSelection: nextThreadModelSelection, runtimeMode, interactionMode: "default", branch: activeThreadBranch, worktreePath: activeThread.worktreePath, createdAt, }, }); let failure: AtomCommandResult | null = createResult._tag === "Failure" ? createResult : null; if (failure === null) { const startResult = await startThreadTurn({ environmentId, input: { 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, }, }); failure = startResult._tag === "Failure" ? startResult : null; } if (failure === null) { const startedResult = await settlePromise(() => waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId)), ); failure = startedResult._tag === "Failure" ? startedResult : null; } if (failure === null) { // Signal that the plan sidebar should open on the new thread when enabled. planSidebarOpenOnNextThreadRef.current = autoOpenPlanSidebar; const navigateResult = await settlePromise(() => navigate({ to: "/$environmentId/$threadId", params: { environmentId: activeThread.environmentId, threadId: nextThreadId, }, }), ); failure = navigateResult._tag === "Failure" ? navigateResult : null; } if (failure !== null) { const cleanupResult = await deleteThread({ environmentId, input: { threadId: nextThreadId, }, }); if (cleanupResult._tag === "Failure" && !isAtomCommandInterrupted(cleanupResult)) { console.warn( "Failed to clean up implementation thread after start failure.", squashAtomCommandFailure(cleanupResult), ); } if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); toastManager.add( stackedThreadToast({ type: "error", title: "Could not start implementation thread", description: error instanceof Error ? error.message : "An error occurred while creating the new thread.", }), ); } } finish(); }, [ activeProject, activeProposedPlan, activeThreadBranch, activeThread, beginLocalDispatch, activeEnvironmentUnavailable, createThread, deleteThread, isConnecting, isSendBusy, isServerThread, navigate, resetLocalDispatch, runtimeMode, startThreadTurn, autoOpenPlanSidebar, environmentId, composerRef, ]); const getModelDisabledReason = useCallback( (instanceId: ProviderInstanceId, model: string): string | null => { if (!activeThread) { return null; } const reason = getStartedThreadModelChangeBlockReason({ providers: providerStatuses, hasStartedSession: activeThread.session !== null, currentModelSelection: activeThread.modelSelection, currentProviderInstanceId: activeThread.session?.providerInstanceId ?? null, nextModelSelection: { instanceId, model }, }); return reason ? `${reason.description} Start a new thread to use this model.` : null; }, [activeThread, providerStatuses], ); 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, }; const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({ providers: providerStatuses, hasStartedSession: activeThread.session !== null, currentModelSelection: activeThread.modelSelection, currentProviderInstanceId: activeThread.session?.providerInstanceId ?? null, nextModelSelection, }); if (modelChangeBlockReason) { toastManager.add({ type: "warning", title: modelChangeBlockReason.title, description: modelChangeBlockReason.description, }); scheduleComposerFocus(); return; } 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, startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: mode, newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, }), ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}), }); } scheduleComposerFocus(); }, [ canOverrideServerThreadEnvMode, composerDraftTarget, draftThread?.worktreePath, isLocalDraftThread, primaryServerSettings.newWorktreesStartFromOrigin, setPendingServerThreadEnvMode, scheduleComposerFocus, setDraftThreadContext, ], ); const onStartFromOriginChange = (nextStartFromOrigin: boolean) => { if (canOverrideServerThreadEnvMode && activeThread) { setPendingServerThreadStartFromOriginByThreadId((current) => current[activeThread.id] === nextStartFromOrigin ? current : { ...current, [activeThread.id]: nextStartFromOrigin }, ); return; } if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { startFromOrigin: nextStartFromOrigin, }); } }; const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { if (!isServerThread || !activeThreadRef) return; useDiffPanelStore.getState().selectTurn(activeThreadRef, turnId, filePath); useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); }, [activeThreadRef, isServerThread, onDiffPanelOpen], ); // 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 ; } const panelToggleControls = ( ); const panelLayoutControls = (
{rightPanelOpen && !shouldUsePlanSidebarSheet ? ( ) : null} {panelToggleControls}
); const rightPanelContent = activeThreadRef ? ( activeRightPanelSurface?.kind === "preview" ? ( ) : activeRightPanelSurface?.kind === "terminal" ? ( ) : activeRightPanelSurface?.kind === "diff" ? ( ) : activeRightPanelSurface?.kind === "plan" ? ( ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && activeProject && activeWorkspaceRoot ? ( ) : null ) : null; return (
{rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null}
{/* Top bar */}
{!rightPanelOpen ? panelLayoutControls : null}
setThreadError(activeThread.id, null)} /> {/* Main content area with optional plan sidebar */}
{/* Chat column */}
{/* Provider status overlays the timeline without changing its content height. */}
setDismissedProviderStatusBannerKey(providerStatusBannerKey)} />
{/* Messages Wrapper */}
{/* Messages — LegendList handles virtualization and scrolling internally */} {/* scroll to end pill — shown when user has scrolled away from the live edge */} {showScrollToBottom && (
)}
{/* Input bar — centered hero while a draft has no messages, docked at the bottom otherwise */}
{isDraftHeroState ? (
) : ( )}
{showComposerContextStrip && (
)}
Switch to{" "} {localCheckoutBranchMismatch?.threadBranch ?? ""} ? You have uncommitted changes. They'll carry over to the other branch, or block the switch if they conflict. }>Cancel {pullRequestDialogState ? ( { if (!open) { closePullRequestDialog(); } }} onPrepared={handlePreparedPullRequestThread} /> ) : null}
{/* end chat column */}
{/* end horizontal flex container */} {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( ))}
{!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( {rightPanelContent} ) : null} {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( {rightPanelContent} ) : null} {expandedImage && ( )}
); } export default function ChatView(props: ChatViewProps) { return ( ); }