import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as Schema from "effect/Schema"; import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; import { Minimize2Icon } from "lucide-react"; import { type AssistantCitation, type ChatFileAttachment, DEFAULT_MODEL, type ChatAttachment as ContractChatAttachment, type EnvironmentId, type MessageId, type ModelSelection, type ProjectScript, type ProjectId, type ProviderApprovalDecision, type PreviewAnnotationPayload, ProviderInstanceId, type ServerProvider, type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, type RunId, type RuntimeRequestId, type KeybindingCommand, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode, ProviderDriverKind, resolveEnvironmentMachineKind, RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { useThreadActions } from "../hooks/useThreadActions"; import { deriveThreadActivityRun, deriveLatestThreadRun, deriveThreadRuntime, } from "@t3tools/client-runtime/state/thread-execution"; import { resolveThreadProviderSession } from "@t3tools/client-runtime/state/thread-workflows"; import { codexFeedbackMessage, parseCodexFeedbackCommand, shouldShowLoadEarlierControl, submitCodexFeedback, type CodexFeedbackSubmission, } from "@t3tools/client-runtime/state/threads"; import { resolveThreadLastVisitedAt } from "./Sidebar.logic"; import { derivePendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests"; 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 { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; 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, useEffectEvent, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { flushSync } from "react-dom"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; 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, deriveTimelineEntriesFromVisibleTurnItemsWithState, selectHandoffImageResources, type TimelineEntriesProjection, deriveRevertTurnCountByUserMessageId, deriveActivePlanState, deriveActiveWorkStartedAt, findLatestProposedPlan, hasActionableProposedPlan, isLatestRunSettled, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; import { CHAT_TIMELINE_ANCHOR_OFFSET, type TimelineScrollMode, } from "./chat/timelineScrollAnchoring"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { useUiStateStore } from "../uiStateStore"; import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; import { buildPlanImplementationThreadTitle, buildPlanImplementationPrompt, resolvePlanFollowUpSubmission, } from "../proposedPlan"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, isBrowserPreviewAttachment, type SessionPhase, type Thread, type TurnDiffSummary, } from "../types"; import { useTheme } from "../hooks/useTheme"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { useElementWidth } from "../hooks/useElementWidth"; import { usePreviewPanelInlineSize } from "../hooks/usePreviewPanelInlineSize"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY, resolveThreadPanelPresentation, } from "../rightPanelLayout"; import { selectActiveRightPanel, selectActiveRightPanelSurface, selectThreadPanelOpen, 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 { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic"; import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, CheckCircle2Icon, PaperclipIcon, ChevronDownIcon, GitBranchIcon, WifiOffIcon, } from "lucide-react"; import { cn } 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 { registerFaviconProjectForThread } from "~/browserFaviconStore"; import { getProviderModelCapabilities } from "../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, sortProviderInstanceEntries, } from "../providerInstances"; import { useClientSettingsHydrated, useEnvironmentSettings } from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { deriveLatestContextWindowSnapshot } from "../lib/contextWindow"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { preventRepeatedTerminalCloseShortcut, preventTerminalCloseShortcut, } from "../lib/terminalCloseShortcut"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; import { composerDraftHasUserContent, type ComposerFileAttachment, type ComposerImageAttachment, type DraftThreadEnvMode, useComposerDraftStore, 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 { environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom, primaryServerSettingsAtom, serverEnvironment, } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment } from "../state/threads"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; import { resolveThreadDetailRef, useProject, useProjects, useThreadProjection, useThreadStatus, useThreadHistory, useThreadShell, useThreadRefs, useThreadVisibleTurnItems, waitForThreadShell, } from "../state/entities"; import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { MessagesTimeline, type MessagesTimelineHistoryControls } from "./chat/MessagesTimeline"; import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; import { resolveComposerTimelineInset } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; import { useRemoteOpenState } from "~/remoteOpen"; import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic"; import { useOpenFavoriteEditorShortcut } from "./chat/OpenInPickerShortcut"; import { PanelLayoutControls, type PanelLayoutControlsProps, RightPanelMaximizeControl, } from "./chat/PanelLayoutControls"; import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { ThreadDetailsPanel, type ThreadDetailsPanelProps } from "./chat/ThreadDetailsPanel"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { AgentsPanel } from "./AgentsPanel"; import { deriveAgentPanelModel, projectedSubagentsToRuntime, } from "@t3tools/client-runtime/state/subagentRuntime"; import { type EnvironmentOption, resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch, shouldShowComposerContextStrip, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; import { getProviderStatusBannerKey, ProviderStatusBanner, shouldShowProviderStatusBanner, } from "./chat/ProviderStatusBanner"; import { dismissThreadErrorBannerForSession, getThreadErrorBannerKey, isThreadErrorBannerDismissedForSession, shouldShowThreadErrorBanner, ThreadErrorBanner, } from "./chat/ThreadErrorBanner"; import { QueuedRunsControl, type EditQueuedRunRequest } from "./chat/QueuedRunsControl"; import { resolveDisplayedThreadPr, threadChangeRequestSnapshotsAtom, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ComposerSurface } from "./chat/ComposerSurface"; import { resolveThreadSyncPhase } from "../threadSync"; import { hasAvailableCompactionProvider, hasDismissedResumeCompaction, shouldOfferResumeCompaction, } from "./chat/ContextWindowMeter.logic"; import { formatContextWindowTokens } from "../lib/contextWindow"; 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 type { ComposerDispatchMode } from "./chat/composerDispatch"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, agentControlledBrowserCloseConfirmation, branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveCommittedServerUserMessageIds, deriveComposerSendState, dismissBranchMismatchForSession, hasEnvironmentReconnectWarningGraceElapsed, scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, shouldOpenProactivePullRequest, shouldOpenProactiveTurnDiff, shouldReleaseTimelineAnchorForToolActivity, shouldRenderPreviewMiniPlayer, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, type LocalDispatchSnapshot, PullRequestDialogState, cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, resolveFileAttachmentUrl, reconcileMountedTerminalThreadIds, resolveComposerInteractionMode, resolveComposerProviderSelection, resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, startNewThreadForProject, codexArtifactTemplatePromptToAppend, toolGroupConsumesUpwardNavigation, waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; import { awaitAttachmentUploads, getUploadedAttachments, releaseDraftAttachments, startAttachmentUpload, } from "../lib/attachmentUploadQueue"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; import { assetEnvironment } from "../state/assets"; import { readPreparedConnection } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; 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 { ComposerServerUpdateIcon, ComposerServerUpdateStatus, } from "./chat/ComposerServerUpdateStatus"; import { buildVersionMismatchDismissalKey, dismissServerUpdateFailure, dismissVersionMismatch, isServerUpdateFailureDismissed, isVersionMismatchDismissed, resolveServerConfigVersionMismatch, resolveServerSelfUpdateCapability, serverUpdateGuidance, supportsDesktopAppUpdate, supportsServerUpdateThreadContinuation, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({}); const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]"; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray = []; // During an active turn the thread's updatedAt advances several times per // second, and every server-side visit is a full command dispatch plus a // broadcast to all shell subscribers. Mid-turn bumps carry no unread signal // (unread flips on run completions, which bypass the throttle), so one // watermark per interval is plenty. const VISIT_DISPATCH_THROTTLE_MS = 10_000; 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) animationRef.current = null; }); } } previousStateRef.current = isDraftHeroState; previousComposerRectRef.current = nextComposerRect; }, [isDraftHeroState]); return { transitionGroupRef: attachTransitionGroupRef, composerAnchorRef: 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="alert-dialog-popup"]:is([data-open],[data-ending-style])', '[data-slot="command-dialog-popup"]:is([data-open],[data-ending-style])', '[data-slot="dialog-popup"]:is([data-open],[data-ending-style])', '[data-slot="sheet-popup"]:is([data-open],[data-ending-style])', '[data-slot="sidebar"][data-mobile="true"]:is([data-open],[data-ending-style])', '[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; }; 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)); } /** * Whether input that landed outside any editable or interactive element * should be redirected into the composer. Shared by type-to-focus and * paste-to-focus so both honour the same surfaces. */ function shouldRedirectInputToComposer(event: Event): boolean { if (event.defaultPrevented) 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 shouldTypeToFocusComposer(event: KeyboardEvent): boolean { if (event.isComposing) return false; if (event.metaKey || event.ctrlKey || event.altKey) return false; if (event.key.length !== 1) return false; if (!shouldRedirectInputToComposer(event)) return false; return true; } /** * Plain text pasted with nothing editable focused, such as after the resting * composer blurred. Files are left to the composer's own paste handler. */ function pasteTextToFocusComposer(event: ClipboardEvent): string | null { if (!event.clipboardData || event.clipboardData.files.length > 0) return null; if (!shouldRedirectInputToComposer(event)) return null; const text = event.clipboardData.getData("text/plain"); return text.length > 0 ? text : null; } 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; function isCompactCommandMessage(message: ChatMessage): boolean { const text = message.text.trim().toLowerCase(); return message.role === "user" && text === "/compact" && !message.attachments?.length; } 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; activeLatestRun: Thread["latestRun"] | null; latestUserMessageId: MessageId | null; phase: SessionPhase; activePendingApproval: RuntimeRequestId | null; activePendingUserInput: RuntimeRequestId | null; threadError: string | null | undefined; }) { const [localDispatch, setLocalDispatch] = useState(null); const resetLocalDispatch = useCallback(() => { setLocalDispatch(null); }, []); const serverAcknowledgedLocalDispatch = useMemo( () => hasServerAcknowledgedLocalDispatch({ localDispatch, phase: input.phase, latestRun: input.activeLatestRun, latestUserMessageId: input.latestUserMessageId, runtime: input.activeThread?.runtime ?? null, hasPendingApproval: input.activePendingApproval !== null, hasPendingUserInput: input.activePendingUserInput !== null, threadError: input.threadError, }), [ input.activeLatestRun, input.latestUserMessageId, input.activePendingApproval, input.activePendingUserInput, input.activeThread?.runtime, input.phase, input.threadError, 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, latestUserMessageId: input.latestUserMessageId, }); }); }, [input.activeThread, input.latestUserMessageId, 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; active: 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, active, 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 = useThreadShell(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 visible = active && terminalUiState.terminalOpen; 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 && !active) || !cwd) { return null; } return (
); }); interface PersistentThreadTerminalPanelProps { visible: boolean; 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({ visible, threadRef, surface, launchContext, focusRequestId, keybindings, onAddTerminalContext, onSplitTerminal, onSplitTerminalVertical, onNewTerminal, onActiveTerminalChange, onCloseTerminal, splitShortcutLabel, splitVerticalShortcutLabel, newShortcutLabel, closeShortcutLabel, }: PersistentThreadTerminalPanelProps) { const serverThread = useThreadShell(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."; } const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; /** * Drops the send-time anchored end space. That space is what holds a sent * message near the top while its turn streams, and it keeps LegendList's * maintainScrollAtEnd switched off for as long as it is installed. Tool * activity and every return to the live edge release the anchor, otherwise * the timeline settles into "following-end" with nothing following anything. */ function releaseChatTimelineAnchor( current: T, ): T { return current.messageId === null ? current : { ...current, messageId: null }; } export default function ChatView(props: ChatViewProps) { const { environmentId, threadId, routeKind, onDiffPanelOpen, reserveTitleBarControlInset = true, forceExpandedMobileComposer = false, } = props; const draftId = routeKind === "draft" ? props.draftId : null; const handleNewThread = useNewThreadHandler(); const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); 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 uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { reportFailure: false, }); const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, }); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); const loadEarlierThreadHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, { label: "load earlier thread history", reportFailure: false, }); const respondToThreadApproval = useAtomCommand(threadEnvironment.respondToApproval, { reportFailure: false, }); const respondToThreadUserInput = useAtomCommand(threadEnvironment.respondToUserInput, { reportFailure: false, }); const revertThreadCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, { reportFailure: false, }); const forkThreadFromRun = useAtomCommand(threadEnvironment.forkFromRun, { 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], ); // Queued-message edit mode. While active, the composer is retargeted to a // per-run edit draft so the user's in-progress draft on the thread survives // untouched; `existingAttachments` tracks which stored attachments the edit // keeps (removal is client state until save). const [editingQueuedRun, setEditingQueuedRun] = useState<{ readonly threadId: ThreadId; readonly runId: RunId; readonly messageId: MessageId; readonly originalText: string; readonly existingAttachments: ReadonlyArray; } | null>(null); const queuedEditDraftTargetFor = useCallback( (runId: RunId) => DraftId.make(`queued-edit:${scopedThreadKey(routeThreadRef)}:${runId}`), [routeThreadRef], ); const baseComposerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; const composerDraftTarget: ScopedThreadRef | DraftId = editingQueuedRun === null ? baseComposerDraftTarget : queuedEditDraftTargetFor(editingQueuedRun.runId); const draftThread = useComposerDraftStore((store) => routeKind === "server" ? store.getDraftSessionByRef(routeThreadRef) : draftId ? store.getDraftSession(draftId) : null, ); const serverThread = useThreadShell(routeThreadRef); const routeThreadDetailRef = resolveThreadDetailRef(routeThreadRef, { shellExists: serverThread !== null, waitForShell: draftThread !== null, }); const serverThreadProjection = useThreadProjection(routeThreadDetailRef); const serverProjection = serverThreadProjection?.projection ?? null; const threadStatus = useThreadStatus(routeThreadDetailRef); const threadSyncPhase = resolveThreadSyncPhase({ detailExists: serverProjection !== null, shellExists: serverThread !== null, status: threadStatus, }); const threadDetailLoading = threadSyncPhase === "loading"; // Latest provider-reported context usage (#8144): the newest turn that has // a report wins; stale turns keep the meter alive between turns. const activeThreadLiveTokenUsage = useMemo(() => { const turns = serverProjection?.providerTurns; if (!turns || turns.length === 0) return null; for (let index = turns.length - 1; index >= 0; index -= 1) { const usage = turns[index]?.tokenUsage; if (usage !== undefined) return usage; } return null; }, [serverProjection?.providerTurns]); // Agents surface (#5219): on orchestration-v2 the panel model comes from the // projected subagent entities — the v2 leg of the spec's mapper swap. The // native-activity fold never runs on this branch. const agentPanelModel = useMemo( () => deriveAgentPanelModel({ agents: [], v2Projection: projectedSubagentsToRuntime(serverProjection?.subagents ?? []), }), [serverProjection?.subagents], ); const serverVisibleTurnItems = useThreadVisibleTurnItems(routeThreadDetailRef); const serverThreadHistory = useThreadHistory(routeThreadDetailRef); const threadHistoryControls = useMemo(() => { if (routeThreadDetailRef === null || !shouldShowLoadEarlierControl(serverThreadHistory)) { return undefined; } return { hasMoreHistory: serverThreadHistory.hasMoreHistory, loading: serverThreadHistory.loading, error: serverThreadHistory.error, onLoadEarlier: () => { void loadEarlierThreadHistory({ environmentId: routeThreadDetailRef.environmentId, input: { threadId: routeThreadDetailRef.threadId }, }); }, }; }, [loadEarlierThreadHistory, routeThreadDetailRef, serverThreadHistory]); const committedServerMessageIds = useMemo( () => deriveCommittedServerUserMessageIds(serverVisibleTurnItems), [serverVisibleTurnItems], ); // Queued messages have no turn item until their run starts, so the // turn-item-derived set alone can never retire their optimistic rows — // cancelling such a run would leave a phantom "pending" queue row behind. // Union in the projection's user messages: once the server holds the // message, the optimistic copy is redundant everywhere it could render. const serverAcknowledgedUserMessageIds = useMemo(() => { const ids = new Set(committedServerMessageIds); for (const message of serverProjection?.messages ?? []) { if (message.role === "user") ids.add(message.id); } return ids; }, [committedServerMessageIds, serverProjection]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLocalLastVisitedAt = useUiStateStore( (store) => store.threadLastVisitedAtById[routeThreadKey], ); const visitThreadMutation = useAtomCommand(threadEnvironment.visit, { reportFailure: false }); const lastDispatchedVisitRef = useRef(null); const lastVisitDispatchAtRef = useRef(0); 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 navigate = useNavigate(); const citationLocation = useLocation({ select: (location) => ({ href: location.href, key: location.state.assistantCitationActivation ?? location.state.__TSR_key, }), }); const citationRequest = useMemo(() => { const citation = assistantCitationFromLocation(citationLocation.href); return citation && citation.environmentId === environmentId && citation.threadId === threadId ? { citation, key: citationLocation.key ?? citationLocation.href } : null; }, [citationLocation.href, citationLocation.key, environmentId, threadId]); 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 composerHasAttachments = useComposerDraftStore((store) => { const draft = store.getComposerDraft(composerDraftTarget); return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0; }); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); 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 composerFilesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; const [restingComposerControlsHost, setRestingComposerControlsHost] = useState(null); const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); const citeAssistantText = useCallback( (citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => { const inserted = composerRef.current?.citeAssistantText(citation, sourceAnchor) ?? false; if (!inserted) { toastManager.add({ type: "warning", title: "The composer is not ready", description: "Try citing the selection after the connection or pending input is resolved.", }); } return inserted; }, [composerRef], ); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); const [expandedImage, setExpandedImage] = useState(null); useEffect(() => { const item = expandedImage?.images[expandedImage.index]; if (item?.type !== "video" || item.src === null || !item.src.startsWith("blob:")) return; const src = item.src; return () => revokeBlobPreviewUrl(src); }, [expandedImage]); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< Record> >({}); const feedbackSubmissions = feedbackSubmissionsByThreadKey[routeThreadKey] ?? EMPTY_FEEDBACK_SUBMISSIONS; const feedbackUploading = feedbackSubmissions.some( (submission) => submission.status === "uploading", ); 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([]); useEffect(() => { setIsWorkspaceFileDragActive(false); }, [draftId, routeThreadKey]); useEffect(() => { if (!isWorkspaceFileDragActive) return; const clearWorkspaceFileDrag = () => setIsWorkspaceFileDragActive(false); window.addEventListener("dragend", clearWorkspaceFileDrag); return () => window.removeEventListener("dragend", clearWorkspaceFileDrag); }, [isWorkspaceFileDragActive]); const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< RuntimeRequestId[] >([]); const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< Record> >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const [workspaceLayoutRef, workspaceLayoutWidth] = useElementWidth(); const previewPanelInlineSize = usePreviewPanelInlineSize(); const threadPanelPopoverAnchorRef = useRef(null); // Tracks whether the user explicitly dismissed the sidebar for the active turn. // 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 [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 getTimelineScrollableNode = useCallback( () => legendListRef.current?.getScrollableNode() ?? null, [], ); const [composerOverlayElement, setComposerOverlayElement] = useState(null); const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); const composerOverlayHeightRef = useRef(0); // Space the timeline keeps clear above its end. Tracks the overlay while the // composer is expanded and holds that height while it rests, so the resting // composer never exposes rows that its expansion will cover. const [composerTimelineInset, setComposerTimelineInset] = useState(0); const composerTimelineInsetRef = useRef(0); const composerRestingRef = useRef(false); const [scrollToEndClearance, setScrollToEndClearance] = useState(0); const isAtEndRef = useRef(true); const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const environmentUnavailableSendToastSlotRef = useRef(0); const feedbackUploadsInFlightRef = useRef(new Set()); 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], ); const isServerThread = serverThread !== null; const activeThread = isServerThread ? serverThread : localDraftThread; const serverLatestRun = useMemo( () => (serverProjection === null ? null : deriveLatestThreadRun(serverProjection)), [serverProjection], ); const serverActivityRun = useMemo( () => (serverProjection === null ? null : deriveThreadActivityRun(serverProjection)), [serverProjection], ); const serverRuntime = useMemo( () => (serverProjection === null ? null : deriveThreadRuntime(serverProjection)), [serverProjection], ); const activeProviderSession = useMemo( () => (serverProjection === null ? null : resolveThreadProviderSession(serverProjection)), [serverProjection], ); const supportsProviderSwitchingViaHandoff = activeProviderSession?.capabilities.sessions.supportsProviderSwitchingViaHandoff === true; const activeLatestRun = isServerThread ? serverLatestRun : (activeThread?.latestRun ?? null); const activeActivityRun = isServerThread ? serverActivityRun : (activeThread?.latestRun ?? null); const activeRuntime = isServerThread ? serverRuntime : (activeThread?.runtime ?? null); const parentSubagentThreadId = activeThread?.lineage.relationshipToParent === "subagent" ? activeThread.lineage.parentThreadId : null; const parentSubagentEnvironmentId = activeThread?.environmentId ?? null; const parentSubagentThreadRef = useMemo(() => { if (parentSubagentEnvironmentId === null || parentSubagentThreadId === null) { return null; } return scopeThreadRef(parentSubagentEnvironmentId, parentSubagentThreadId); }, [parentSubagentEnvironmentId, parentSubagentThreadId]); const parentSubagentThread = useThreadShell(parentSubagentThreadRef); const parentThreadLink = useMemo( () => parentSubagentThreadRef === null ? null : { threadId: parentSubagentThreadRef.threadId, title: parentSubagentThread?.title ?? "Parent thread", }, [parentSubagentThread?.title, parentSubagentThreadRef], ); const threadError = isServerThread ? (localServerError ?? serverRuntime?.lastError ?? null) : localDraftError; // Dismissals can only mask the shown error, never clear it: a server thread // keeps its error in session.lastError, so clearing the local shadow would // just fall through to the persisted one. Mask the current error until a // different error arrives, mirroring the provider status banner. const threadErrorBannerKey = getThreadErrorBannerKey(routeThreadKey, threadError); const visibleThreadError = shouldShowThreadErrorBanner( routeThreadKey, threadError, isThreadErrorBannerDismissedForSession(threadErrorBannerKey), ) ? threadError : null; // Dismissing only mutates the session-scoped mask set, which does not // trigger a render on its own; setThreadError(null) can also bail when the // local shadow is already empty and the banner is driven purely by // session.lastError. Bump a tick so the banner hides immediately. Mirrors // the branch mismatch banner. const [, setThreadErrorBannerDismissTick] = useState(0); const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; // Prefer the larger of turn-item-committed ids and projection messages so // env lock does not unlock while turn items lag projection hydration. const activeMessageCount = isServerThread ? Math.max(committedServerMessageIds.size, serverProjection?.messages.length ?? 0) : 0; 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 activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); 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; // Release the turn anchor once its run finishes. LegendList sizes the // anchored end-space filler for the geometry at anchor time and never // recomputes it, so a lingering anchor leaves stale filler height behind // the composer — phantom scroll range on threads whose content fits. const anchorRunSettled = useMemo(() => { if (timelineAnchorMessageId === null) return false; const anchorRun = serverProjection?.runs.find( (run) => run.userMessageId === timelineAnchorMessageId, ); if (anchorRun === undefined) return false; return ( anchorRun.status !== "preparing" && anchorRun.status !== "queued" && anchorRun.status !== "starting" && anchorRun.status !== "running" && anchorRun.status !== "waiting" ); }, [serverProjection, timelineAnchorMessageId]); useEffect(() => { if (!anchorRunSettled) return; setTimelineAnchor({ threadKey: activeThreadKey, messageId: null }); }, [anchorRunSettled, activeThreadKey]); 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 refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); const sidebarPrRefreshKeyRef = useRef(null); const threadPrRelinkKeysRef = useRef(new Map()); const threadPrRelinkWriteRef = useRef(Promise.resolve()); 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 { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); const activeTerminalDrawerPresence = usePanelPresence( Boolean(activeThreadKey && terminalUiState.terminalOpen), true, panelAnimationsActive, activeThreadKey, panelAnimationDurationMs, ); const rightPanelPresenceValue = useMemo( () => ({ activeSurface: activeRightPanelSurface, surfaces: rightPanelState.surfaces, }), [activeRightPanelSurface, rightPanelState.surfaces], ); const rightPanelPresence = usePanelPresence( rightPanelOpen && activeThreadRef !== null, rightPanelPresenceValue, panelAnimationsActive, activeThreadKey, panelAnimationDurationMs, ); const rightPanelPresent = rightPanelPresence.present; const rightPanelControlsInPanel = shouldUsePlanSidebarSheet && rightPanelPresent && rightPanelOpen; const rightPanelControlsAtRoot = rightPanelPresent && !shouldUsePlanSidebarSheet; const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( activePreviewMiniPlayer?.tabId ?? null, renderedRightPanelSurface, ); const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; const threadPanelPresentation = resolveThreadPanelPresentation( workspaceLayoutWidth, inlineRightPanelOwnsTitleBar ? previewPanelInlineSize.width : 0, rightPanelMaximized, ); const threadPanelOpen = useRightPanelStore((state) => selectThreadPanelOpen( state.threadPanelVisibilityByThreadKey, activeThreadRef, threadPanelPresentation, ), ); const inlineThreadPanelOpen = threadPanelOpen && threadPanelPresentation === "inline"; 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]); if (!miniTabStillExists) { usePreviewMiniPlayerStore.getState().close(activeThreadRef); } }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef]); const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); useEffect(() => { setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ currentThreadIds, openThreadIds: existingOpenTerminalThreadKeys, activeThreadId: activeThreadKey, activeThreadTerminalOpen: activeTerminalDrawerPresence.present, maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, }); return currentThreadIds.length === nextThreadIds.length && currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) ? currentThreadIds : nextThreadIds; }); }, [activeTerminalDrawerPresence.present, activeThreadKey, existingOpenTerminalThreadKeys]); const latestRunSettled = isLatestRunSettled(activeLatestRun, activeRuntime); const activePlan = useMemo( () => deriveActivePlanState(serverProjection, activeActivityRun?.runId), [activeActivityRun?.runId, serverProjection], ); // Tasks progress for the running turn's own plan only — deriveActivePlanState // falls back to older runs' plans, which must not label fresh work. const activeComposerTasksProgress = useMemo(() => { if ( isLatestRunSettled(activeActivityRun, activeRuntime) || !activePlan || activePlan.runId !== (activeActivityRun?.runId ?? null) ) { return null; } const totalSteps = activePlan.steps.length; if (totalSteps === 0) return null; const completedSteps = activePlan.steps.filter((step) => step.status === "completed").length; const step = activePlan.steps.find((candidate) => candidate.status === "inProgress")?.step ?? activePlan.steps.find((candidate) => candidate.status === "pending")?.step ?? activePlan.steps.at(-1)!.step; return { step, completedSteps, totalSteps }; }, [activeActivityRun, activePlan, activeRuntime]); const activeComposerTaskSteps = activeComposerTasksProgress && activePlan ? activePlan.steps : null; const activeProjectRef = useMemo( () => activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null, [activeThread?.environmentId, activeThread?.projectId], ); const activeProject = useProject(activeProjectRef); const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); }, [activeProjectRef, handleNewThread]); const projectGroupingSettings = selectProjectGroupingSettings(settings); const activeDraftLogicalProjectKey = !isServerThread && activeProject ? deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings) : undefined; const handleOpenDraftProjectSettings = useCallback(() => { if (!activeDraftLogicalProjectKey) return; void navigate({ to: "/projects/$projectKey", params: { projectKey: activeDraftLogicalProjectKey }, }); }, [activeDraftLogicalProjectKey, navigate]); 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; useEffect(() => { if (!activeThreadRef || !activeProjectRef) return; registerFaviconProjectForThread(activeThreadRef, activeProjectRef); }, [activeProjectRef, activeThreadRef]); const activeEnvironment = activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; const activeEnvironmentUnavailable = activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected"; const activeReconnectingEnvironmentId = activeEnvironmentConnectionPhase === "connecting" || activeEnvironmentConnectionPhase === "reconnecting" ? (activeEnvironment?.environmentId ?? null) : null; const [reconnectWarningGraceElapsedEnvironmentId, setReconnectWarningGraceElapsedEnvironmentId] = useState(null); const reconnectWarningGraceElapsed = hasEnvironmentReconnectWarningGraceElapsed( activeReconnectingEnvironmentId, reconnectWarningGraceElapsedEnvironmentId, ); useEffect(() => { setReconnectWarningGraceElapsedEnvironmentId(null); if (activeReconnectingEnvironmentId === null) return; return scheduleEnvironmentReconnectWarning(() => setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId), ); }, [activeReconnectingEnvironmentId]); 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 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: EnvironmentOption[] = []; for (const p of memberProjects) { if (seen.has(p.environmentId)) continue; seen.add(p.environmentId); const isPrimary = p.environmentId === primaryEnvironmentId; const environment = environmentById.get(p.environmentId) ?? null; envs.push({ environmentId: p.environmentId, projectId: p.id, label: environment?.label ?? p.environmentId, isPrimary, machine: resolveEnvironmentMachineKind(environment?.serverConfig ?? null), }); } // 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], ); useEffect(() => { if (!serverThread?.id) return; const threadUpdatedAt = Date.parse(serverThread.updatedAt); if (Number.isNaN(threadUpdatedAt)) return; const effectiveLastVisitedAt = resolveThreadLastVisitedAt( serverThread.lastVisitedAt, activeThreadLocalLastVisitedAt, ); const lastVisitedAt = effectiveLastVisitedAt ? Date.parse(effectiveLastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return; if (serverThread.lastVisitedAt !== undefined) { // Server-tracked visited state: record the watermark server-side so it // syncs across every device connected to the environment. Dedupe per // watermark — the effect re-runs before the command's echo lands. The // dedupe also keeps a mark-unread on the open thread sticky: the rewind // leaves updatedAt untouched, so the already-dispatched key skips a // fresh visit until new activity lands or the thread is reopened. const dispatchKey = `${routeThreadKey}:${serverThread.updatedAt}`; if (lastDispatchedVisitRef.current === dispatchKey) return; const dispatch = () => { lastDispatchedVisitRef.current = dispatchKey; lastVisitDispatchAtRef.current = Date.now(); void visitThreadMutation({ environmentId: serverThread.environmentId, input: { threadId: serverThread.id, visitedAt: serverThread.updatedAt }, }); }; // Unread prominence only flips on run completions (hasUnseenCompletion), // so an unseen completion publishes immediately; mid-turn activity bumps // — several per second while a turn streams — ride a trailing throttle, // each rerun swapping the timer so the trailing dispatch carries the // newest watermark. const latestRunCompletedAtMs = serverThread.latestRun?.completedAt ? Date.parse(serverThread.latestRun.completedAt) : NaN; const hasUnseenCompletion = !Number.isNaN(latestRunCompletedAtMs) && (Number.isNaN(lastVisitedAt) || latestRunCompletedAtMs > lastVisitedAt); const elapsed = Date.now() - lastVisitDispatchAtRef.current; if (hasUnseenCompletion || elapsed >= VISIT_DISPATCH_THROTTLE_MS) { dispatch(); return; } const timer = setTimeout(dispatch, VISIT_DISPATCH_THROTTLE_MS - elapsed); return () => clearTimeout(timer); } markThreadVisited( scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), serverThread.updatedAt, ); }, [ activeThreadLocalLastVisitedAt, markThreadVisited, routeThreadKey, serverThread?.environmentId, serverThread?.id, serverThread?.lastVisitedAt, serverThread?.latestRun?.completedAt, serverThread?.updatedAt, visitThreadMutation, ]); const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.instanceId ?? activeProject?.defaultModelSelection?.instanceId ?? null; const lockedProvider = deriveLockedProvider({ thread: activeThread, selectedProvider: selectedProviderByThreadId, threadProvider, }); const modelPickerLockedProvider = supportsProviderSwitchingViaHandoff ? null : lockedProvider; // 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 pullRequestsCapabilityKnown = serverConfig !== null; const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null; const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null; const supportsAttachmentUploads = attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true; const advertisedFileAttachmentBytes = attachmentEnvironmentConfig?.environment.capabilities.fileAttachments?.maxUploadBytes ?? null; const maxFileAttachmentBytes = advertisedFileAttachmentBytes === null ? null : clampFileAttachmentUploadBytes(advertisedFileAttachmentBytes); 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 reconnectActiveEnvironment = useCallback(() => { if (!activeEnvironmentUnavailableState) return; void handleReconnectActiveEnvironment(activeEnvironmentUnavailableState.environmentId); }, [activeEnvironmentUnavailableState, handleReconnectActiveEnvironment]); const openConnectionSettings = useCallback(() => { void navigate({ to: "/settings/connections" }); }, [navigate]); const handleDismissVersionMismatch = useCallback(() => { if (!versionMismatchDismissKey) return; dismissVersionMismatch(versionMismatchDismissKey); setDismissedVersionMismatchKey(versionMismatchDismissKey); }, [setDismissedVersionMismatchKey, versionMismatchDismissKey]); const serverUpdateEnvironmentId = activeThread?.environmentId ?? null; const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig); const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig); const serverUpdateState = useAtomValue( serverEnvironment.updateStateAtom(serverUpdateEnvironmentId), ); const [dismissedServerUpdateState, setDismissedServerUpdateState] = useState< typeof serverUpdateState | null >(null); const serverUpdateFailureDismissed = serverUpdateState === dismissedServerUpdateState || isServerUpdateFailureDismissed(serverUpdateState); 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 = environmentReconnecting && (updateRunning || (!reconnectingThroughVersionSkew && !reconnectWarningGraceElapsed)); if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { if (reconnectingThroughVersionSkew) { items.push({ id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, variant: "default", // Prioritize live connection progress among the notices. priority: "urgent", icon: (