import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment"; import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; import { collectProviderUsageLimits, hasProviderUsageLimits, isUsageLimitsCommand, } from "@t3tools/shared/usageLimits"; import { feedbackBannerItem } from "./chat/ComposerFeedback"; import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits"; import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; import { questionAttachmentDraftId, questionAttachmentDraftPrefix, clearQuestionAttachmentDraft, useQuestionAttachmentPreparation, } from "../questionAttachments"; import { useAttachmentUploadStore } from "../lib/attachmentUploadQueue"; import { type AssistantCitation, type ApprovalRequestId, type ChatFileAttachment, DEFAULT_MODEL, type EnvironmentId, type MessageId, type ModelSelection, type ProjectScript, type ProjectId, type ProviderApprovalDecision, type PreviewAnnotationPayload, ProviderInstanceId, type ServerProvider, type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, type ThreadLinkedPullRequest, type TurnId, type KeybindingCommand, OrchestrationThreadActivity, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode, ProviderDriverKind, resolveEnvironmentMachineKind, RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { parseCodexFeedbackCommand, submitCodexFeedback, type CodexFeedbackSubmission, } from "@t3tools/client-runtime/state/threads"; import { parseScopedThreadKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { applyClaudePromptEffortPrefix, createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv, resolveProjectScripts, } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { getTerminalLabel, 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 { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; 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 * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; import { collapseExpandedComposerCursor, type ComposerSubmissionIntent, parseStandaloneComposerSlashCommand, } from "../composer-logic"; import { createMessageAttachmentPreviewProjector, derivePhase, deriveTimelineEntriesWithState, deriveActiveWorkStartedAt, deriveActivePlanState, findLatestProposedPlan, deriveWorkLogEntries, hasActionableProposedPlan, isLatestTurnSettled, selectHandoffImageResources, type TimelineEntriesProjection, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; import { CHAT_TIMELINE_ANCHOR_OFFSET, getAnchoredTurnMetrics, timelineContentOverflowsViewport, type TimelineScrollMode, } from "./chat/timelineScrollAnchoring"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { useUiStateStore } from "../uiStateStore"; import { latestWorkspaceMutationId, 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, isImageAttachment, type SessionPhase, type Thread, } from "../types"; import { useTheme } from "../hooks/useTheme"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { pullRequestSurface, selectActiveRightPanel, selectActiveRightPanelSurface, selectThreadRightPanelState, type RightPanelSurface, useRightPanelStore, } from "../rightPanelStore"; import { isPreviewSupportedInRuntime, setActivePreviewTab, useThreadPreviewState, } from "../previewStateStore"; import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; import { BrowserSettingsReadError } from "../browser/openFileInPreview"; 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 { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { isSameSidebarThreadRef, useSidebarPendingFileDropStore, } from "../sidebarPendingFileDropStore"; 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 } from "./RightPanelTabs"; import { AgentsPanel } from "./AgentsPanel"; import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; import { useDeviceState } from "~/state/device"; import { DeviceSetup } from "./device/DeviceSetup"; import { Dialog } from "./ui/dialog"; import { WizardPopup } from "./ui/wizard"; import { deriveAgentPanelModel, foldSubagentActivities, } from "@t3tools/client-runtime/state/subagentRuntime"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, Minimize2Icon, PaperclipIcon, WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; 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 { useBrowserHistoryStore } from "~/browserHistoryStore"; import { registerFaviconProjectForThread } from "~/browserFaviconStore"; import { getProviderModelCapabilities } from "../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, sortProviderInstanceEntries, } from "../providerInstances"; import { useClientSettings, 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 { useThreadActions } from "../hooks/useThreadActions"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; import { isPreviewFocused } from "../lib/previewFocus"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { preventRepeatedTerminalCloseShortcut, preventTerminalCloseShortcut, } from "../lib/terminalCloseShortcut"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { derivePhysicalProjectKey, deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; import { beginBackgroundDraftSubmissionByRef, clearBackgroundDraftSubmissionByRef, composerDraftHasUserContent, type ComposerFileAttachment, type ComposerImageAttachment, type DraftThreadEnvMode, finalizePromotedDraftThreadByRef, markPromotedDraftThreadByRef, 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 { useEnvironmentQuery } from "../state/query"; import { environmentServerConfigsAtom, 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 { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; import { useProject, useProjects, useThread, useThreadRefs, useThreadShell, } 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 { MessagesTimeline } from "./chat/MessagesTimeline"; import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { WorkspacePageHeader } from "./WorkspacePageHeader"; 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 type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ComposerSurface } from "./chat/ComposerSurface"; import { hasAvailableCompactionProvider, hasDismissedResumeCompaction, shouldOfferResumeCompaction, } from "./chat/ContextWindowMeter.logic"; import { deriveLatestContextWindowSnapshot, 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 { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, agentControlledBrowserCloseConfirmation, branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildLoadingThreadFromShell, buildRunningThreadTurnInterruptInput, buildThreadTurnInterruptInput, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, hasEnvironmentReconnectWarningGraceElapsed, latestTurnStartFailureId, scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, shouldOpenProactivePullRequest, shouldRetargetThreadPullRequestPanel, shouldOpenProactiveTurnDiff, shouldRenderPreviewMiniPlayer, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, type LocalDispatchSnapshot, PullRequestDialogState, cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, resolveFileAttachmentUrl, reconcileMountedTerminalThreadIds, recallCheckoutIsRepo, rememberCheckoutIsRepo, resolveBackgroundDraftWorkspaceOptions, resolveComposerInteractionMode, resolveComposerProviderSelection, resolveDraftHeroState, isPaintOnlyThreadTimeline, peekHeldThreadTimeline, peekRememberedThreadTimeline, rememberReadyThreadTimeline, resolveThreadSwitchTimeline, timelineHasEphemeralPreviewUrls, observeProactivePanelUserChoice, resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, shouldWriteThreadErrorToCurrentServerThread, startNewThreadForProject, codexArtifactTemplatePromptToAppend, toolGroupConsumesUpwardNavigation, waitForStartedServerThread, shouldRefocusComposerOnWindowFocus, } from "./ChatView.logic"; import type { ThreadSyncPhase } from "../threadSync"; 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 { useAutoBalanceUpdateBanner } from "./chat/useAutoBalanceUpdateBanner"; 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 { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./chat/composerPromptHistory"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = []; 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 DevicePanel = lazy(() => import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })), ); 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 = [ '[role="dialog"][aria-modal="true"]', '[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; // The right-panel surface launcher claims its shortcut letters while it is // visible (data attribute set in RightPanelTabs); those keys open surfaces // instead of typing into the composer. const launcherKeys = document .querySelector("[data-surface-launcher-keys]") ?.getAttribute("data-surface-launcher-keys"); if (launcherKeys && launcherKeys.toLowerCase().includes(event.key.toLowerCase())) 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; 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 latestUserMessage = input.activeThread?.messages.findLast( (message) => message.role === "user", ); const latestUserMessageId = latestUserMessage?.id ?? null; const currentTurnStartFailureId = localDispatch === null ? null : latestTurnStartFailureId(input.activeThread, latestUserMessageId); 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, latestTurnStartFailureId: currentTurnStartFailureId, threadError: input.threadError, }), [ input.activeLatestTurn, input.activePendingApproval, input.activePendingUserInput, input.activeThread?.session, input.phase, input.threadError, latestUserMessageId, currentTurnStartFailureId, localDispatch, ], ); const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; const beginLocalDispatch = useCallback( (options?: { preparingWorktree?: boolean; submissionIntent?: ComposerSubmissionIntent }) => { const preparingWorktree = Boolean(options?.preparingWorktree); setLocalDispatch((current) => { const active = serverAcknowledgedLocalDispatch ? null : current; if (active) { const submissionIntent = options?.submissionIntent ?? active.submissionIntent; return active.preparingWorktree === preparingWorktree && active.submissionIntent === submissionIntent ? active : { ...active, preparingWorktree, submissionIntent }; } return createLocalDispatchSnapshot(input.activeThread, options); }); }, [input.activeThread, serverAcknowledgedLocalDispatch], ); return { beginLocalDispatch, resetLocalDispatch, localDispatchStartedAt: activeLocalDispatch?.startedAt ?? null, latestUserMessageAt: latestUserMessage?.createdAt ?? null, isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, isSendBusy: activeLocalDispatch !== null, backgroundSubmissionPending: localDispatch?.submissionIntent === "background", }; } /** 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 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 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 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."; } const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; const EMPTY_HELD_TURN_DIFF_SUMMARIES: readonly never[] = []; const noopHeldTurnDiff = (_turnId: TurnId, _filePath?: string) => {}; const noopHeldRevert = (_targetTurnCount: number) => {}; const noopHeldAttachment = (_attachment: ChatFileAttachment) => {}; /** * 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 — ChatView * drives the streaming scrolls itself, but only in "anchoring-new-turn" mode. * So every return to the live edge has to release the anchor too, 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 threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null; const threadDetailLoading = threadSyncPhase === "loading"; const handleNewThread = useNewThreadHandler(); const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); const updateProjectScriptSettings = useAtomCommand(serverEnvironment.updateSettings, { 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 createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, }); const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { reportFailure: false, }); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); const respondToThreadApproval = useAtomCommand(threadEnvironment.respondToApproval, { reportFailure: false, }); const respondToThreadUserInput = useAtomCommand(threadEnvironment.respondToUserInput, { reportFailure: false, }); const dismissThreadUserInput = useAtomCommand(threadEnvironment.dismissUserInput, { 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, cursor: routeThreadState.page._tag === "Some" ? routeThreadState.page.value.beforeCursor : null, onLoadEarlier: () => { requestOlderThreadTurns(routeThreadRef.environmentId, routeThreadRef.threadId); }, }; }, [routeKind, routeThreadRef, routeThreadState]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const settings = useEnvironmentSettings(environmentId); 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 composerHasUnsentContent = useComposerDraftStore((store) => composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)), ); const composerHasAttachments = useComposerDraftStore((store) => { const draft = store.getComposerDraft(composerDraftTarget); return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0; }); // Anything beyond the prompt text: attachments, terminal or element contexts, annotations. const composerHasNonPromptContent = useComposerDraftStore((store) => { const draft = store.getComposerDraft(composerDraftTarget); return draft ? composerDraftHasUserContent({ ...draft, prompt: "" }) : false; }); 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 [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = 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 = useMemo( () => feedbackSubmissionsByThreadKey[routeThreadKey] ?? [], [feedbackSubmissionsByThreadKey, routeThreadKey], ); 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([]); const userInputResponsesInFlight = useRef(new Set()); const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState< ApprovalRequestId[] >([]); useEffect(() => { setIsWorkspaceFileDragActive(false); }, [draftId, routeThreadKey]); useEffect(() => { if (!isWorkspaceFileDragActive) return; const clearWorkspaceFileDrag = () => setIsWorkspaceFileDragActive(false); window.addEventListener("dragend", clearWorkspaceFileDrag); return () => window.removeEventListener("dragend", clearWorkspaceFileDrag); }, [isWorkspaceFileDragActive]); const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< Record> >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); const shouldUseRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const isMobileViewport = useMediaQuery("max-sm"); 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( () => resolveTimelineIsAtEnd(legendListRef.current?.getState()) ?? isAtEndRef.current, [], ); // Whether the timeline's rows extend past the viewport above the composer. // The composer only rests when there is reading space to give back. const [timelineOverflows, setTimelineOverflows] = useState(false); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const environmentUnavailableSendToastSlotRef = useRef(0); const feedbackUploadsInFlightRef = useRef(new Set()); const terminalUiOpenByThreadRef = useRef>({}); const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), ); const openTerminalThreadKeys = useTerminalUiStateStore( useShallow((state) => Object.entries(state.terminalUiStateByThreadKey).flatMap( ([nextThreadKey, nextTerminalUiState]) => nextTerminalUiState.terminalOpen ? [nextThreadKey] : [], ), ), ); const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); const 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 ?? settings.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, ) : undefined, [ draftThread, fallbackDraftProject?.defaultModelSelection, settings.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; // 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; const activeThreadEnvironmentId = activeThread?.environmentId ?? 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( () => activeThreadEnvironmentId && activeThreadId ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) : null, [activeThreadEnvironmentId, activeThreadId], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const activeThreadShell = useThreadShell(isServerThread ? 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 activePreviewState = useThreadPreviewState(activeThreadRef); const activePreviewServerEpoch = activePreviewState.serverEpoch; const resolvePreviewRuntimeTabId = useMemo( () => activeThreadRef ? (tabId: string) => previewRuntimeTabId(activeThreadRef, activePreviewServerEpoch, tabId) : undefined, [activeThreadRef, activePreviewServerEpoch], ); 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 = shouldUseRightPanelSheet && rightPanelPresent && rightPanelOpen; const rightPanelControlsAtRoot = rightPanelPresent && !shouldUseRightPanelSheet; const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( activePreviewMiniPlayer?.tabId ?? null, renderedRightPanelSurface, ); const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUseRightPanelSheet; 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]); const activeLatestTurn = activeThread?.latestTurn ?? null; const activeRunningTurnId = (activeThread?.session?.status === "running" ? activeThread.session.activeTurnId : null) ?? (activeLatestTurn?.state === "running" ? activeLatestTurn.turnId : null); // Reading a finished thread clears the sidebar's Done badge. The visit is // stamped at the turn's completion time — not now/updatedAt — so it clears // exactly the completion the user is looking at: a wake or completion that // lands later still gets its signal (markThreadVisited never moves the // timestamp backwards). useEffect(() => { const completedAt = serverThread?.latestTurn?.completedAt; if (!serverThread?.id || !completedAt) return; markThreadVisited( scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), completedAt, ); }, [ markThreadVisited, serverThread?.environmentId, serverThread?.id, serverThread?.latestTurn?.completedAt, ]); 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 latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProjectRef = useMemo( () => activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null, [activeThread?.environmentId, activeThread?.projectId], ); const activeProject = useProject(activeProjectRef); const activeProjectScripts = useMemo( () => (activeProject ? resolveProjectScripts(settings, activeProject) : []), [activeProject, settings], ); const activeProjectDefaultModelSelection = activeProject?.defaultModelSelection ?? settings.defaultModelSelection; const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); }, [activeProjectRef, handleNewThread]); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); 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 clientSettingsHydrated = useClientSettingsHydrated(); 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(activeProjectScripts), [activeProjectScripts], ); 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]); useEffect(() => { if (!clientSettingsHydrated || !activeThreadRef || !activeProject) return; // Reuse the sidebar's grouping so history follows the project rows the user // sees. Deriving the key from the active project alone would miss the // identity a duplicate row borrows from its siblings. const logicalKeyByPhysicalKey = buildPhysicalToLogicalProjectKeyMap({ projects: allProjects, settings: projectGroupingSettings, primaryEnvironmentId, }); useBrowserHistoryStore .getState() .registerThreadProject( activeThreadRef, logicalKeyByPhysicalKey.get(derivePhysicalProjectKey(activeProject)) ?? deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings), ); }, [ activeProject, activeThreadRef, allProjects, clientSettingsHydrated, primaryEnvironmentId, projectGroupingSettings, ]); 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], ); // 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 providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.instanceId ?? activeProjectDefaultModelSelection?.instanceId ?? null; const lockedProvider = deriveLockedProvider({ thread: activeThread, selectedProvider: selectedProviderByThreadId, threadProvider, providers: providerStatuses, }); const pullRequestsCapabilityKnown = serverConfig !== null; const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null; const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null; const supportsQuestionAttachments = attachmentEnvironmentConfig?.environment.capabilities.questionAttachments === true; const supportsAttachmentUploads = attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true; const advertisedFileAttachmentBytes = attachmentEnvironmentConfig?.environment.capabilities.fileAttachments?.maxUploadBytes ?? null; const maxFileAttachmentBytes = advertisedFileAttachmentBytes === null ? null : clampFileAttachmentUploadBytes(advertisedFileAttachmentBytes); const envLocked = Boolean( activeThread && (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "stopped")), ); const loadBalancingSettings = useClientSettings(); const automaticEnvironment = Boolean( clientSettingsHydrated && draftId && !envLocked && hasMultipleEnvironments && loadBalancingSettings.loadBalancingEnabled && draftThread?.environmentSelection !== "manual" && (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) && (!draftThread?.branch || draftThread.environmentSelection === "auto") && !draftThread?.worktreePath, ); const autoUpdateEnvironments = useMemo( () => automaticEnvironment ? logicalProjectEnvironments.flatMap(({ environmentId }) => { const environment = environmentById.get(environmentId); return environment ? [environment] : []; }) : [], [automaticEnvironment, logicalProjectEnvironments, environmentById], ); const autoBalanceUpdateBanner = useAutoBalanceUpdateBanner(autoUpdateEnvironments); 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 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: (