import { type ApprovalRequestId, DEFAULT_MODEL, defaultInstanceIdForDriver, type EnvironmentId, type MessageId, type ModelSelection, type ProjectScript, type ProjectId, type ProviderApprovalDecision, type PreviewAnnotationPayload, 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, threadWokeAt, } 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 { clearPlanSidebarDismissal, dismissPlanSidebarForTurn, isPlanSidebarDismissedForTurn, } from "../planSidebarDismissal"; 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 { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; import { RightPanelTabs } from "./RightPanelTabs"; import { AgentsPanel } from "./AgentsPanel"; import { deriveAgentPanelModel, foldSubagentActivities, } from "@t3tools/client-runtime/state/subagentRuntime"; 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, 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 { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; 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, useEnvironmentThread } from "../state/threads"; import { requestOlderThreadTurns, threadHasOlderTurns, } from "@t3tools/client-runtime/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, shouldShowComposerContextStrip, shouldShowEnvironmentIndicator, } 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 { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; 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, buildLoadingThreadFromShell, 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, shouldWriteThreadErrorToCurrentServerThread, startNewThreadForProject, waitForStartedServerThread, } from "./ChatView.logic"; import type { ThreadSyncPhase } from "../threadSync"; 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, ServerUpdateProgress } 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; threadSyncPhase?: ThreadSyncPhase | null; routeKind: "server"; draftId?: never; } | { environmentId: EnvironmentId; threadId: ThreadId; onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; forceExpandedMobileComposer?: boolean; threadSyncPhase?: never; 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 draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); 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], ); // Every client-side id source participates in allocation: the server list // lags fresh opens, and panel terminals are filtered out of the drawer's // sessions — an id collision attaches two viewports to one PTY session. const allocatableTerminalIds = useMemo( () => [ ...new Set([ ...serverOrderedTerminalIds, ...terminalUiState.terminalIds, ...panelTerminalIds, ]), ], [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds], ); 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(allocatableTerminalIds); storeSplitTerminal(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ environmentId: threadRef.environmentId, input: { threadId, terminalId, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), env: runtimeEnv, }, }); }, [ allocatableTerminalIds, bumpFocusRequestId, cwd, effectiveWorktreePath, runtimeEnv, storeSplitTerminal, threadId, threadRef, openTerminal, ]); const splitTerminalVertical = useCallback(() => { if (!cwd) { return; } const terminalId = nextTerminalId(allocatableTerminalIds); storeSplitTerminalVertical(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ environmentId: threadRef.environmentId, input: { threadId, terminalId, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), env: runtimeEnv, }, }); }, [ allocatableTerminalIds, bumpFocusRequestId, cwd, effectiveWorktreePath, openTerminal, runtimeEnv, storeSplitTerminalVertical, threadId, threadRef, ]); const createNewTerminal = useCallback(() => { if (!cwd) { return; } const terminalId = nextTerminalId(allocatableTerminalIds); storeNewTerminal(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ environmentId: threadRef.environmentId, input: { threadId, terminalId, cwd, ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), env: runtimeEnv, }, }); }, [ bumpFocusRequestId, cwd, effectiveWorktreePath, allocatableTerminalIds, runtimeEnv, 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 draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); const serverThread = useThread(threadRef, { waitForShell: draftThread !== null }); 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 threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null; const threadDetailLoading = threadSyncPhase === "loading"; const handleNewThread = useNewThreadHandler(); 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 draftThread = useComposerDraftStore((store) => routeKind === "server" ? store.getDraftSessionByRef(routeThreadRef) : draftId ? store.getDraftSession(draftId) : null, ); const routeServerThreadShell = useThreadShell(routeKind === "server" ? routeThreadRef : null); const serverThread = useThread(routeThreadRef, { waitForShell: draftThread !== null }); const loadingServerThread = useMemo( () => threadDetailLoading && routeServerThreadShell ? buildLoadingThreadFromShell(routeServerThreadShell) : null, [routeServerThreadShell, threadDetailLoading], ); const activeServerThread = serverThread ?? loadingServerThread; // Pagination window state for the routed server thread: drives the // "load earlier turns" header when the loaded window has older history. const routeThreadState = useEnvironmentThread( routeKind === "server" ? routeThreadRef.environmentId : null, routeKind === "server" ? routeThreadRef.threadId : null, ); const loadEarlierTurns = useMemo(() => { if (routeKind !== "server" || !threadHasOlderTurns(routeThreadState)) { return null; } return { loading: routeThreadState.page._tag === "Some" && routeThreadState.page.value.loadingOlder, onLoadEarlier: () => { requestOlderThreadTurns(routeThreadRef.environmentId, routeThreadRef.threadId); }, }; }, [routeKind, routeThreadRef, routeThreadState]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); 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 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); // 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 = activeServerThread ? 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 (!activeServerThread || !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, }; }); }, [activeServerThread, draftId, localDraftErrorsByDraftId, routeThreadKey]); 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 = activeServerThread !== null; const activeThread = activeServerThread ?? localDraftThread; const threadError = isServerThread ? (localServerError ?? activeServerThread?.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 activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef), ); const panelTerminalIds = useMemo( () => new Set( rightPanelState.surfaces.flatMap((surface) => surface.kind === "terminal" ? surface.terminalIds : [], ), ), [rightPanelState.surfaces], ); const allocatableActiveTerminalIds = useMemo( () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])], [activeKnownTerminalIds, panelTerminalIds], ); 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]); useEffect(() => { if (!activeThreadRef || !activePreviewMiniPlayer) return; const miniTabStillExists = Boolean(activePreviewState.sessions[activePreviewMiniPlayer.tabId]); const sameTabOpenInPanel = previewPanelOpen && activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId === activePreviewMiniPlayer.tabId; if (!miniTabStillExists || sameTabOpenInPanel) { usePreviewMiniPlayerStore.getState().close(activeThreadRef); } }, [ activePreviewMiniPlayer, activePreviewState.sessions, activeRightPanelSurface, activeThreadRef, previewPanelOpen, ]); 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 handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); }, [activeProjectRef, handleNewThread]); 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 activeEnvironmentOption = logicalProjectEnvironments.find( (environment) => environment.environmentId === activeThread?.environmentId, ) ?? null; const showComposerEnvironmentIndicator = shouldShowEnvironmentIndicator({ activeEnvironment: activeEnvironmentOption, canPickEnvironment: hasMultipleEnvironments, }); 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], ); 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 serverUpdateEnvironmentId = activeThread?.environmentId ?? null; const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); const serverUpdateState = useAtomValue( serverEnvironment.updateStateAtom(serverUpdateEnvironmentId), ); const systemComposerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; const updateRunning = serverUpdateState.status === "running"; const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null; const environmentReconnecting = unavailableConnection !== null && (unavailableConnection.phase === "connecting" || unavailableConnection.phase === "reconnecting"); // Reconnecting to a version-skewed server with no update in flight // usually means the server is restarting mid-update and a refresh wiped // the in-memory update state. Fold the reconnect and version banners // into one calm line instead of stacking "Failed to connect" on // "versions differ". A failed update never folds: its error and retry // action must stay visible. const reconnectingThroughVersionSkew = serverUpdateState.status === "idle" && environmentReconnecting && versionMismatch !== null; // While an update runs, transient connect blips are expected (the server // restarts) and the update banner already shows progress. Hard failure // phases still surface so the Reconnect action stays reachable. const suppressUnavailableBanner = updateRunning && environmentReconnecting; if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { if (reconnectingThroughVersionSkew) { items.push({ id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, variant: "default", icon: (