1import { supportsWorkspaceChanges } from "./review/jjReview.logic";
2import { ThreadFind, ThreadFindCanvas, type ThreadFindControls } from "./chat/ThreadFindProvider";
3import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar";
4import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner";
5import {
6 resolveBackgroundDraftWorkspaceOptions,
7 resolveDraftHeroState,
8 shouldDockDraftHeroForSubmission,
9 resolveVisibleWorktreeSetup,
10 resolveWorktreeSetupProgress,
11} from "./ChatView.logic";
12import * as DateTime from "effect/DateTime";
13import { restorePlanFollowUpComposer } from "./ChatView.logic";
14import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
15import { prepareQueuedEditAttachments, recoverQueuedMessageEdit } from "./chat/queuedMessageEdit";
16import {
17 isPaintOnlyThreadTimeline,
18 peekHeldThreadTimeline,
19 peekRememberedThreadTimeline,
20 rememberReadyThreadTimeline,
21 resolveThreadSwitchTimeline,
22 timelineHasEphemeralPreviewUrls,
23 recallCheckoutIsRepo,
24 rememberCheckoutIsRepo,
25} from "./ChatView.logic";
26import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment";
27import { useScratchProject } from "../hooks/useScratchProject";
28import { isScratchProject } from "@t3tools/client-runtime/state/projects";
29import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
30import {
31 latestExecutedRun,
32 latestRootProviderFailure,
33} from "@t3tools/shared/orchestrationV2ThreadError";
34import type { UsageLimitSourceSnapshots } from "@t3tools/contracts";
35import {
36 collectProviderUsageLimits,
37 hasProviderUsageLimits,
38 isUsageLimitsCommand,
39} from "@t3tools/shared/usageLimits";
40import { feedbackBannerItem } from "./chat/ComposerFeedback";
41import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits";
42import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
43import * as Schema from "effect/Schema";
44import {
45 questionAttachmentDraftId,
46 questionAttachmentDraftPrefix,
47 clearQuestionAttachmentDraft,
48 useQuestionAttachmentPreparation,
49} from "../questionAttachments";
50import { useAttachmentUploadStore } from "../lib/attachmentUploadQueue";
51import {
52 AuthOrchestrationOperateScope,
53 AuthSettingsWriteScope,
54 AuthSourceControlWriteScope,
55 AuthPreviewOperateScope,
56 AuthTerminalReadScope,
57 AuthTerminalOperateScope,
58 type AssistantCitation,
59 type ChatFileAttachment,
60 CommandId,
61 DEFAULT_MODEL,
62 isProviderNativeSubagentThread,
63 type ChatAttachment as ContractChatAttachment,
64 EnvironmentAuthorizationError,
65 type EnvironmentId,
66 type MessageId,
67 type ModelSelection,
68 type ProjectScript,
69 type ProjectId,
70 type ProviderApprovalDecision,
71 type PreviewAnnotationPayload,
72 ProviderInstanceId,
73 type ServerProvider,
74 type ResolvedKeybindingsConfig,
75 type ScopedThreadRef,
76 type ThreadId,
77 type ThreadLinkedPullRequest,
78 type RunId,
79 type RuntimeRequestId,
80 type KeybindingCommand,
81 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
82 ProviderInteractionMode,
83 ProviderDriverKind,
84 resolveEnvironmentMachineKind,
85 resolveProjectProviderInstanceEnabled,
86 RuntimeMode,
87 TerminalOpenInput,
88 type WorktreeSetupSnapshot,
89} from "@t3tools/contracts";
90import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
91import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";
92import {
93 wasBootstrapThreadDeleted,
94 wasBootstrapThreadNotCreated,
95} from "@t3tools/client-runtime/errors";
96import { readPastedComposerContext } from "./composerInlineTokenPaste";
97import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste";
98import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
99import { childThreadInputsTitle } from "@t3tools/client-runtime/state/threads";
100import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions";
101import {
102 deriveProviderSubagentStatus,
103 deriveReportedModelSelection,
104 formatModelSelectionEffort,
105 deriveRunlessWorkStartedAt,
106 deriveThreadActivityRun,
107 deriveLatestThreadRun,
108 deriveThreadRuntime,
109 presentPendingBackgroundWork,
110 presentProviderGoal,
111} from "@t3tools/client-runtime/state/thread-execution";
112import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows";
113import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff";
114import { HandoffRow } from "./chat/V2LifecycleRow";
115import {
116 codexFeedbackMessage,
117 parseCodexFeedbackCommand,
118 shouldShowLoadEarlierControl,
119 submitCodexFeedback,
120 type CodexFeedbackSubmission,
121} from "@t3tools/client-runtime/state/threads";
122import { resolveThreadLastVisitedAt } from "./Sidebar.logic";
123import { derivePendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests";
124import {
125 parseScopedThreadKey,
126 scopedThreadKey,
127 scopeProjectRef,
128 scopeThreadRef,
129} from "@t3tools/client-runtime/environment";
130import {
131 applyClaudePromptEffortPrefix,
132 createModelSelection,
133 formatModelSlugName,
134 resolvePromptInjectedEffort,
135 resolveSelectableModel,
136} from "@t3tools/shared/model";
137import {
138 projectScriptCwd,
139 projectScriptRuntimeEnv,
140 resolveProjectScripts,
141} from "@t3tools/shared/projectScripts";
142import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList";
143import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
144import {
145 latestUnheldRun,
146 usageLimitRunPresentedAsLatest,
147} from "@t3tools/shared/orchestrationV2ThreadError";
148import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
149import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
150import { truncate } from "@t3tools/shared/String";
151import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
152import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
153import { Debouncer } from "@tanstack/react-pacer";
154import { useAtomValue } from "@effect/atom-react";
155import { Atom } from "effect/reactivity";
156import {
157 Fragment,
158 lazy,
159 memo,
160 type SetStateAction,
161 Suspense,
162 useCallback,
163 useEffect,
164 useEffectEvent,
165 useLayoutEffect,
166 useMemo,
167 useRef,
168 useState,
169} from "react";
170import { flushSync } from "react-dom";
171import { useLocation, useNavigate } from "@tanstack/react-router";
172import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation";
173import { isMacPlatform } from "../lib/utils";
174import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
175import { useShallow } from "zustand/react/shallow";
176import {
177 isAtomCommandInterrupted,
178 mapAtomCommandResult,
179 settlePromise,
180 squashAtomCommandFailure,
181 type AtomCommandResult,
182} from "@t3tools/client-runtime/state/runtime";
183import * as Cause from "effect/Cause";
184import { AsyncResult } from "effect/reactivity";
185import { isElectron } from "../env";
186import { readLocalApi } from "../localApi";
187import { useDiffPanelStore } from "../diffPanelStore";
188import { useActiveThreadRef } from "../hooks/useActiveThreadRef";
189import {
190 type ComposerSubmissionIntent,
191 collapseExpandedComposerCursor,
192 parseStandaloneComposerSlashCommand,
193} from "../composer-logic";
194import {
195 derivePendingApprovals,
196 derivePendingUserInputs,
197 derivePhase,
198 deriveTimelineEntriesFromVisibleTurnItemsWithState,
199 selectHandoffImageResources,
200 type TimelineEntriesProjection,
201 deriveActivePlanState,
202 deriveActiveWorkStartedAt,
203 deriveCanInterruptRunningThread,
204 findLatestProposedPlan,
205 hasActionableProposedPlan,
206 isLatestRunSettled,
207} from "../session-logic";
208import { type LegendListRef } from "@legendapp/list/react";
209import {
210 CHAT_TIMELINE_ANCHOR_OFFSET,
211 timelineContentOverflowsViewport,
212 readTimelinePosition,
213 observeTimelineRun,
214 type TimelineRunObservation,
215 type TimelineScrollMode,
216} from "./chat/timelineScrollAnchoring";
217import {
218 buildPendingUserInputAnswers,
219 carryDisplacedCustomAnswerIntoPrompt,
220 derivePendingUserInputProgress,
221 projectPendingUserInputAnswer,
222 setPendingUserInputCustomAnswer,
223 togglePendingUserInputOptionSelection,
224 type PendingUserInputDraftAnswer,
225} from "../pendingUserInput";
226import { seedUserInputDraftAnswers } from "@t3tools/client-runtime/state/thread-requests";
227import { useUiStateStore } from "../uiStateStore";
228import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh";
229import {
230 buildPlanImplementationThreadTitle,
231 buildPlanImplementationPrompt,
232 resolvePlanFollowUpSubmission,
233} from "../proposedPlan";
234import {
235 DEFAULT_INTERACTION_MODE,
236 DEFAULT_THREAD_TERMINAL_ID,
237 MAX_TERMINALS_PER_GROUP,
238 type ChatMessage,
239 isBrowserPreviewAttachment,
240 isImageAttachment,
241 type SessionPhase,
242 type Thread,
243} from "../types";
244import { useTheme } from "../hooks/useTheme";
245import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
246import { isCommandPaletteOpen } from "../commandPaletteBus";
247import { settleAndAdvance } from "../settleAndAdvance";
248import { subscribeSnapShotComposerFocus } from "../lib/desktopSnapShot";
249import { useMediaQuery } from "../hooks/useMediaQuery";
250import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
251import { FileMetadataThreadProvider } from "../hooks/FileMetadataThreadProvider";
252import { usePreviewPanelInlineSize } from "../hooks/usePreviewPanelInlineSize";
253import {
254 RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY,
255 type ThreadPanelPresentation,
256} from "../rightPanelLayout";
257import { PopoverCreateHandle } from "./ui/popover";
258import {
259 pullRequestSurface,
260 selectActiveRightPanel,
261 selectActiveRightPanelSurface,
262 selectThreadPanelOpen,
263 selectThreadRightPanelState,
264 type RightPanelSurface,
265 useRightPanelStore,
266} from "../rightPanelStore";
267import {
268 isPreviewSupportedInRuntime,
269 setActivePreviewTab,
270 useThreadPreviewState,
271} from "../previewStateStore";
272import { BrowserSettingsReadError, openUrlInPreview } from "../browser/openFileInPreview";
273import { resolveDiscoveredServerUrl } from "../browser/browserTargetResolver";
274import { previewRuntimeTabId } from "../browser/previewRuntimeTabId";
275import { addBrowserSurface } from "./preview/addBrowserSurface";
276import { closePreviewSession } from "./preview/closePreviewSession";
277import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
278import { BrowserAudioIndicator } from "./preview/BrowserAudioIndicator";
279import { ScriptProgress } from "./chat/ScriptProgress";
280import { usePreviewSession } from "./preview/usePreviewSession";
281import { subscribePreviewAction } from "./preview/previewActionBus";
282import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
283
284import {
285 browserMiniPlayerSource,
286 previewMiniPlayerSourceKey,
287 selectThreadPreviewMiniPlayer,
288 usePreviewMiniPlayerStore,
289} from "../previewMiniPlayerStore";
290import {
291 pullRequestPanelContext,
292 threadPullRequestPanelTarget,
293} from "./pullRequest/pullRequestDetail.logic";
294import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel";
295import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts";
296import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState";
297import { RightPanelTabs } from "./RightPanelTabs";
298import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog";
299import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel";
300import { useDeviceState } from "~/state/device";
301import { DeviceSetup } from "./device/DeviceSetup";
302import { Dialog } from "./ui/dialog";
303import { WizardPopup } from "./ui/wizard";
304import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar";
305import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
306import { isEditableFocused } from "../lib/editableFocus";
307import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
308import { resolveChatShortcutCommand, shortcutLabelForCommand } from "../keybindings";
309import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
310import {
311 AlarmClockIcon,
312 CheckCircle2Icon,
313 MessageCircleIcon,
314 PaperclipIcon,
315 ChevronDownIcon,
316 DownloadIcon,
317 GitBranchIcon,
318 ShieldQuestionIcon,
319 TargetIcon,
320 WifiOffIcon,
321} from "lucide-react";
322import { cn, randomUUID } from "~/lib/utils";
323import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
324import { stackedThreadToast, toastManager } from "./ui/toast";
325import {
326 decodeProjectScriptKeybindingRule,
327 keybindingValueForCommand,
328} from "~/lib/projectScriptKeybindings";
329import { type NewProjectScriptInput } from "./ProjectScriptsControl";
330import {
331 buildProjectScript,
332 commandForProjectScript,
333 nextProjectScriptId,
334 projectScriptIdFromCommand,
335 releaseClaimedRoles,
336} from "~/projectScripts";
337import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
338import { registerFaviconProjectForThread } from "~/browserFaviconStore";
339import { getProviderModelCapabilities } from "../providerModels";
340import {
341 applyProviderInstanceSettings,
342 deriveProviderInstanceEntries,
343 NO_PROVIDER_MODEL_SELECTION,
344 shouldShowInstanceBadge,
345 sortProviderInstanceEntries,
346} from "../providerInstances";
347import {
348 useClientSettings,
349 useClientSettingsHydrated,
350 useEnvironmentSettings,
351} from "../hooks/useSettings";
352import { useNowMinute } from "../hooks/useNowMinute";
353import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";
354import { useNewThreadHandler } from "../hooks/useHandleNewThread";
355import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject";
356import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";
357import { resolveAppModelSelectionForInstance } from "../modelSelection";
358import {
359 getComposerPromptInjectionState,
360 getComposerProviderState,
361} from "./chat/composerProviderState";
362import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm";
363import { isPreviewFocused } from "../lib/previewFocus";
364import { getTerminalFocusOwner } from "../lib/terminalFocus";
365import {
366 preventRepeatedTerminalCloseShortcut,
367 preventTerminalCloseShortcut,
368} from "../lib/terminalCloseShortcut";
369import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
370import {
371 deriveLogicalProjectKeyFromSettings,
372 selectProjectGroupingSettings,
373} from "../logicalProject";
374import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes";
375import {
376 isSameSidebarThreadRef,
377 useSidebarPendingFileDropStore,
378} from "../sidebarPendingFileDropStore";
379import {
380 beginBackgroundDraftSubmissionByRef,
381 clearBackgroundDraftSubmissionByRef,
382 finalizePromotedDraftThreadByRef,
383 markPromotedDraftThreadByRef,
384 restoreFailedBackgroundDraftThread,
385 composerDraftHasUserContent,
386 type ComposerFileAttachment,
387 type ComposerImageAttachment,
388 type DraftThreadEnvMode,
389 useComposerDraftStore,
390 DraftId,
391} from "../composerDraftStore";
392import {
393 formatTerminalContextLabel,
394 type TerminalContextDraft,
395 type TerminalContextSelection,
396} from "../lib/terminalContext";
397import {
398 ensureInlineContextReferences,
399 removeInlineContextReference,
400 stripInlineContextReferences,
401} from "../lib/composerContextReferences";
402import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend";
403import {
404 buildMessageContext,
405 previewAnnotationContextLabel,
406 previewAnnotationContextReference,
407 reviewCommentContextLabel,
408 terminalContextReference,
409} from "../lib/composerContextRecords";
410import { type ReviewCommentContext } from "../reviewCommentContext";
411import { environmentCatalog } from "../connection/catalog";
412import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
413import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnectDelay";
414import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
415import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions";
416import { useEnvironmentQuery } from "../state/query";
417import { useEnvironmentScope, useThreadOrganizationPermission } from "~/state/session";
418import {
419 environmentServerConfigsAtom,
420 environmentCapabilitiesAtom,
421 primaryServerAvailableEditorsAtom,
422 primaryServerKeybindingsAtom,
423 serverEnvironment,
424} from "../state/server";
425import { terminalEnvironment } from "../state/terminal";
426import { threadEnvironment } from "../state/threads";
427import { workspacePreparationRetryRunIds } from "@t3tools/client-runtime/state/turn-item-presentation";
428import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
429import { vcsEnvironment } from "../state/vcs";
430import { sourceControlEnvironment } from "../state/sourceControl";
431import { useProjectClone } from "../state/projectClones";
432import { projectCloneDisplayName, projectCloneProgressSummary } from "@t3tools/contracts";
433import { useEnvironments, usePrimaryEnvironment } from "../state/environments";
434import {
435 resolveThreadDetailRef,
436 useEnvironmentSupportsServerBrowser,
437 useProject,
438 useProjects,
439 useThreadProjection,
440 useThreadStatus,
441 useThreadHistory,
442 useThreadShell,
443 useChildThreadInputs,
444 useThreadRefs,
445 useThreadVisibleTurnItems,
446 waitForThreadShell,
447} from "../state/entities";
448import { environmentShell } from "../state/shell";
449import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
450import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController";
451import { isTimelineScrollTarget } from "./chat/timelineScrollTarget";
452import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
453import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
454import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
455import type { AssistantCitationRequest } from "./chat/AssistantCitationSource";
456import { MessagesTimeline, type MessagesTimelineHistoryControls } from "./chat/MessagesTimeline";
457import { ProviderSubagentBar } from "./chat/ProviderSubagentBar";
458import { getTriggerDisplayModelName } from "./chat/providerIconUtils";
459import { resolveTimelineIsAtEnd, worktreeSetupAgentStarted } from "./chat/MessagesTimeline.logic";
460import {
461 overlayComposerIsResting,
462 resolveComposerTimelineInset,
463 resolveScrollToEndClearance,
464} from "./composerFooterLayout";
465import { ChatHeader } from "./chat/ChatHeader";
466import { useRemoteOpenState } from "~/remoteOpen";
467import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic";
468import { useOpenFavoriteEditorShortcut } from "./chat/OpenInPickerShortcut";
469import {
470 PanelLayoutControls,
471 type PanelLayoutControlsProps,
472 RightPanelMaximizeControl,
473} from "./chat/PanelLayoutControls";
474import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview";
475import { ThreadDetailsPanel, type ThreadDetailsPanelProps } from "./chat/ThreadDetailsPanel";
476import { NoActiveThreadState } from "./NoActiveThreadState";
477import {
478 type EnvironmentOption,
479 resolveEffectiveEnvMode,
480 resolveLocalCheckoutBranchMismatch,
481 shouldShowComposerContextStrip,
482 shouldShowEnvironmentIndicator,
483} from "./BranchToolbar.logic";
484import {
485 getProviderStatusBannerKey,
486 ProviderStatusBanner,
487 shouldShowProviderStatusBanner,
488} from "./chat/ProviderStatusBanner";
489import {
490 dismissThreadErrorBannerForSession,
491 getThreadErrorBannerKey,
492 isThreadErrorBannerDismissedForSession,
493 shouldShowThreadErrorBanner,
494 ThreadErrorBanner,
495} from "./chat/ThreadErrorBanner";
496import {
497 QueuedRunsControl,
498 type QueuedRunsControlHandle,
499 type EditQueuedRunRequest,
500} from "./chat/QueuedRunsControl";
501import { useLinkedThreadPullRequest } from "./ThreadStatusIndicators";
502import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
503import { ThreadStatusLine } from "./chat/ThreadStatusLine";
504import { formatRelativeTimeLabel, formatRelativeTimeUntilLabel } from "../timestampFormat";
505import { ComposerSurface } from "./chat/ComposerSurface";
506import { resolveThreadSyncPhase } from "../threadSync";
507import {
508 hasAvailableCompactionProvider,
509 hasDismissedResumeCompaction,
510 shouldOfferResumeCompaction,
511} from "./chat/ContextWindowMeter.logic";
512import {
513 deriveLatestContextWindowSnapshot,
514 latestProviderTurnTokenUsage,
515} from "../lib/contextWindow";
516import {
517 DRAFT_HERO_TRANSITION_ANIMATION_ID,
518 DRAFT_HERO_TRANSITION_EASING,
519 MOBILE_COMPOSER_VIEW_TRANSITION_NAME,
520 MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME,
521 runMobileComposerTransition,
522} from "./chat/draftHeroTransition";
523import type { ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch";
524import {
525 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
526 agentControlledBrowserCloseConfirmation,
527 branchMismatchKey,
528 buildExpiredTerminalContextToastCopy,
529 buildLocalDraftThread,
530 collectUserMessageBlobPreviewUrls,
531 createLocalDispatchSnapshot,
532 deriveCommittedServerUserMessageIds,
533 deriveComposerSendState,
534 dismissBranchMismatchForSession,
535 hasEnvironmentReconnectWarningGraceElapsed,
536 scheduleEnvironmentReconnectWarning,
537 hasServerAcknowledgedLocalDispatch,
538 isBranchMismatchDismissedForSession,
539 shouldShowBranchMismatchBanner,
540 shouldShowPlanFollowUpPrompt,
541 shouldOpenProactivePullRequest,
542 shouldRetargetThreadPullRequestPanel,
543 shouldOpenProactiveTurnDiff,
544 shouldReleaseTimelineAnchorForToolActivity,
545 shouldRenderPreviewMiniPlayer,
546 getStartedThreadModelChangeBlockReason,
547 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
548 LastInvokedScriptByProjectSchema,
549 type LocalDispatchSnapshot,
550 PullRequestDialogState,
551 cloneComposerImageForRetry,
552 deriveLockedProvider,
553 readFileAsDataUrl,
554 resolveFileAttachmentUrl,
555 prepareRevertedMessageAttachments,
556 waitForRevertedMessage,
557 reconcileMountedTerminalThreadIds,
558 resolveComposerInteractionMode,
559 resolveComposerProviderSelection,
560 getAntigravitySendBlockReason,
561 observeProactivePanelUserChoice,
562 resolveProactiveTurnDiffAction,
563 resolveThreadMetadataUpdateForNextTurn,
564 resolveSendEnvMode,
565 revokeBlobPreviewUrl,
566 revokeUserMessagePreviewUrls,
567 startNewThreadForProject,
568 codexArtifactTemplatePromptToAppend,
569 waitForStartedServerThread,
570 shouldRefocusComposerOnWindowFocus,
571} from "./ChatView.logic";
572import { useLocalStorage } from "~/hooks/useLocalStorage";
573import { useTerminalPlacement } from "~/hooks/useTerminalPlacement";
574import { useComposerHandleContext } from "../composerHandleContext";
575import {
576 awaitAttachmentUploads,
577 getUploadedAttachments,
578 releaseDraftAttachments,
579 startAttachmentUpload,
580} from "../lib/attachmentUploadQueue";
581import { sanitizeThreadErrorMessage } from "~/rpc/transportError";
582import { RightPanelSheet } from "./RightPanelSheet";
583import { previewEnvironment } from "../state/preview";
584import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
585import { appAtomRegistry } from "../rpc/atomRegistry";
586import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles";
587import { assetEnvironment } from "../state/assets";
588import {
589 readEnvironmentScope,
590 readPreparedConnection,
591 readThreadOrganizationPermission,
592} from "../state/session";
593import { useAtomCommand } from "../state/use-atom-command";
594import { useOrchestrationCommand } from "../state/use-orchestration-command";
595import { useAtomQueryRunner } from "../state/use-atom-query-runner";
596import { Button, InlineButton } from "./ui/button";
597import {
598 AlertDialog,
599 AlertDialogClose,
600 AlertDialogDescription,
601 AlertDialogFooter,
602 AlertDialogHeader,
603 AlertDialogPopup,
604 AlertDialogTitle,
605} from "./ui/alert-dialog";
606import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
607import { ServerUpdateAction } from "./ServerUpdateAction";
608import { useAutoBalanceUpdateBanner } from "./chat/useAutoBalanceUpdateBanner";
609import {
610 ComposerServerUpdateIcon,
611 ComposerServerUpdateStatus,
612} from "./chat/ComposerServerUpdateStatus";
613import {
614 buildVersionMismatchDismissalKey,
615 dismissServerUpdateFailure,
616 dismissVersionMismatch,
617 isServerUpdateFailureDismissed,
618 isVersionMismatchDismissed,
619 resolveServerConfigVersionMismatch,
620 resolveServerSelfUpdateCapability,
621 serverUpdateGuidance,
622 supportsDesktopAppUpdate,
623 supportsServerUpdateThreadContinuation,
624} from "../versionSkew";
625import { useAssetUrls } from "../assets/assetUrls";
626import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences";
627import { recallableComposerPrompt } from "./chat/composerPromptHistory";
628import { observeResize } from "~/lib/observeResize";
629
630const EMPTY_PROVIDERS: ServerProvider[] = [];
631const EMPTY_PROVIDER_MODELS: ServerProvider["models"] = [];
632const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = [];
633import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates";
634
635const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({});
636const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray<CodexFeedbackSubmission> = [];
637// During an active turn the thread's updatedAt advances several times per
638// second, and every server-side visit is a full command dispatch plus a
639// broadcast to all shell subscribers. Mid-turn bumps carry no unread signal
640// (unread flips on run completions, which bypass the throttle), so one
641// watermark per interval is plenty.
642const VISIT_DISPATCH_THROTTLE_MS = 10_000;
643const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
644const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
645function useDraftHeroLayoutTransition(
646 isDraftHeroState: boolean,
647 animationsActive: boolean,
648 animationDurationMs: number,
649) {
650 const transitionGroupRef = useRef<HTMLDivElement | null>(null);
651 const composerAnchorRef = useRef<HTMLDivElement | null>(null);
652 const previousStateRef = useRef(isDraftHeroState);
653 const previousComposerRectRef = useRef<DOMRect | null>(null);
654 const animationRef = useRef<Animation | null>(null);
655 const attachTransitionGroupRef = (element: HTMLDivElement | null) => {
656 transitionGroupRef.current = element;
657 };
658 const attachComposerAnchorRef = (element: HTMLDivElement | null) => {
659 composerAnchorRef.current = element;
660 };
661
662 const captureComposerRect = () => {
663 previousComposerRectRef.current = composerAnchorRef.current?.getBoundingClientRect() ?? null;
664 };
665
666 useLayoutEffect(() => {
667 const transitionGroup = transitionGroupRef.current;
668 const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null;
669 const stateChanged = previousStateRef.current !== isDraftHeroState;
670 const mobileComposerTransitionActive =
671 typeof document !== "undefined" &&
672 document.documentElement.dataset.mobileComposerRouteTransition === "true";
673
674 animationRef.current?.cancel();
675 animationRef.current = null;
676 const previousComposerRect = previousComposerRectRef.current;
677 if (
678 stateChanged &&
679 animationsActive &&
680 !mobileComposerTransitionActive &&
681 transitionGroup &&
682 previousComposerRect &&
683 nextComposerRect &&
684 typeof transitionGroup.animate === "function"
685 ) {
686 const translateX = previousComposerRect.left - nextComposerRect.left;
687 const translateY = previousComposerRect.top - nextComposerRect.top;
688 if (Math.abs(translateX) >= 0.5 || Math.abs(translateY) >= 0.5) {
689 const animation = transitionGroup.animate(
690 [
691 { transform: `translate3d(${translateX}px, ${translateY}px, 0)` },
692 { transform: "translate3d(0, 0, 0)" },
693 ],
694 {
695 duration: animationDurationMs,
696 easing: DRAFT_HERO_TRANSITION_EASING,
697 },
698 );
699 animation.id = DRAFT_HERO_TRANSITION_ANIMATION_ID;
700 animationRef.current = animation;
701 void animation.finished
702 .catch(() => undefined)
703 .then(() => {
704 if (animationRef.current === animation) animationRef.current = null;
705 });
706 }
707 }
708 previousStateRef.current = isDraftHeroState;
709 previousComposerRectRef.current = nextComposerRect;
710 }, [animationDurationMs, animationsActive, isDraftHeroState]);
711
712 return {
713 transitionGroupRef: attachTransitionGroupRef,
714 composerAnchorRef: attachComposerAnchorRef,
715 captureComposerRect,
716 } as const;
717}
718
719const PreviewPanel = lazy(() =>
720 import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })),
721);
722const DiffPanel = lazy(() => import("./review/DiffSurface"));
723const DeploymentsPanel = lazy(() =>
724 import("./deployments/DeploymentsPanel").then((module) => ({ default: module.DeploymentsPanel })),
725);
726const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) =>
727 settings.browserAutoShowFloatingPreview;
728const DevicePanel = lazy(() =>
729 import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })),
730);
731const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel"));
732const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet<string> = new Set();
733const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [
734 "input",
735 "textarea",
736 "select",
737 '[contenteditable="true"]',
738 '[contenteditable="plaintext-only"]',
739 '[role="textbox"]',
740].join(",");
741const TYPE_TO_FOCUS_INTERACTIVE_SELECTOR = [
742 "button",
743 "a[href]",
744 "summary",
745 '[role="button"]',
746 '[role="checkbox"]',
747 '[role="menuitem"]',
748 '[role="option"]',
749 '[role="radio"]',
750 '[role="switch"]',
751 '[role="tab"]',
752].join(",");
753// Popups match only while open or closing: some stay mounted when closed,
754// such as the chat header actions menu.
755const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [
756 '[role="dialog"][aria-modal="true"]',
757 '[data-slot="alert-dialog-popup"]:is([data-open],[data-ending-style])',
758 '[data-slot="command-dialog-popup"]:is([data-open],[data-ending-style])',
759 '[data-slot="dialog-popup"]:is([data-open],[data-ending-style])',
760 '[data-slot="sheet-popup"]:is([data-open],[data-ending-style])',
761 '[data-slot="sidebar"][data-mobile="true"]:is([data-open],[data-ending-style])',
762 '[data-slot="menu-popup"]:is([data-open],[data-ending-style])',
763 '[data-slot="select-popup"]:is([data-open],[data-ending-style])',
764 '[data-slot="popover-popup"]:is([data-open],[data-ending-style])',
765 '[data-slot="combobox-popup"]:is([data-open],[data-ending-style])',
766 '[data-slot="autocomplete-popup"]:is([data-open],[data-ending-style])',
767].join(",");
768
769type EnvironmentUnavailableState = {
770 readonly environmentId: EnvironmentId;
771 readonly label: string;
772 readonly connection: EnvironmentConnectionPresentation;
773};
774
775function eventPathContainsSelector(event: Event, selector: string): boolean {
776 const path = event.composedPath();
777 if (path.length === 0 && event.target) {
778 path.push(event.target);
779 }
780 return path.some((target) => target instanceof Element && target.closest(selector));
781}
782
783const SECRET_REQUEST_SELECTOR = '[data-v2-item-type="secret_request"]';
784
785/**
786 * Whether input that landed outside any editable or interactive element
787 * should be redirected into the composer. Shared by type-to-focus and
788 * paste-to-focus so both honour the same surfaces.
789 */
790function shouldRedirectInputToComposer(event: Event): boolean {
791 if (event.defaultPrevented) return false;
792 // Near a pending secret request, input is meant for its private field: it
793 // must never land in the composer draft, which is persisted and sent.
794 if (eventPathContainsSelector(event, SECRET_REQUEST_SELECTOR)) return false;
795 if (eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR)) return false;
796 if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false;
797 if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false;
798 return true;
799}
800
801function shouldTypeToFocusComposer(event: KeyboardEvent): boolean {
802 if (event.isComposing) return false;
803 if (event.metaKey || event.ctrlKey || event.altKey) return false;
804 if (event.key.length !== 1) return false;
805 if (!shouldRedirectInputToComposer(event)) return false;
806
807 return true;
808}
809
810/**
811 * Plain text pasted with nothing editable focused, such as after the resting
812 * composer blurred. Files are left to the composer's own paste handler.
813 */
814function pasteTextToFocusComposer(event: ClipboardEvent): string | null {
815 if (!event.clipboardData || event.clipboardData.files.length > 0) return null;
816 if (!shouldRedirectInputToComposer(event)) return null;
817 const text = event.clipboardData.getData("text/plain");
818 return text.length > 0 ? text : null;
819}
820
821const draftFanoutStateAtom = Atom.family((_routeKey: string) =>
822 Atom.make({
823 selections: null as ReadonlyArray<ModelSelection> | null,
824 sendInFlight: { current: false },
825 uncertainSubmissions: { current: new Map<string, ThreadId>() },
826 }).pipe(Atom.keepAlive),
827);
828
829function formatOutgoingPrompt(params: {
830 provider: ProviderDriverKind;
831 model: string | null;
832 models: ReadonlyArray<ServerProvider["models"][number]>;
833 effort: string | null;
834 text: string;
835}): string {
836 const caps = getProviderModelCapabilities(params.models, params.model, params.provider);
837 const promptEffort = resolvePromptInjectedEffort(caps, params.effort);
838 return applyClaudePromptEffortPrefix(params.text, promptEffort);
839}
840const SCRIPT_TERMINAL_COLS = 120;
841const SCRIPT_TERMINAL_ROWS = 30;
842
843function isCompactCommandMessage(message: ChatMessage): boolean {
844 const text = message.text.trim().toLowerCase();
845 return message.role === "user" && text === "/compact" && !message.attachments?.length;
846}
847
848type ChatViewProps =
849 | {
850 environmentId: EnvironmentId;
851 threadId: ThreadId;
852 onDiffPanelOpen?: () => void;
853 reserveTitleBarControlInset?: boolean;
854 forceExpandedMobileComposer?: boolean;
855 routeKind: "server";
856 draftId?: never;
857 }
858 | {
859 environmentId: EnvironmentId;
860 threadId: ThreadId;
861 onDiffPanelOpen?: () => void;
862 reserveTitleBarControlInset?: boolean;
863 forceExpandedMobileComposer?: boolean;
864 routeKind: "draft";
865 draftId: DraftId;
866 };
867
868interface TerminalLaunchContext {
869 threadId: ThreadId;
870 cwd: string;
871 worktreePath: string | null;
872}
873
874type PersistentTerminalLaunchContext = Pick<TerminalLaunchContext, "cwd" | "worktreePath">;
875
876function useLocalDispatchState(input: {
877 activeThread: Thread | undefined;
878 activeLatestRun: Thread["latestRun"] | null;
879 latestUserMessageId: MessageId | null;
880 phase: SessionPhase;
881 activePendingApproval: RuntimeRequestId | null;
882 activePendingUserInput: RuntimeRequestId | null;
883 threadError: string | null | undefined;
884}) {
885 const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null);
886
887 const resetLocalDispatch = useCallback(() => {
888 setLocalDispatch(null);
889 }, []);
890
891 const serverAcknowledgedLocalDispatch = useMemo(
892 () =>
893 hasServerAcknowledgedLocalDispatch({
894 localDispatch,
895 phase: input.phase,
896 latestRun: input.activeLatestRun,
897 latestUserMessageId: input.latestUserMessageId,
898 runtime: input.activeThread?.runtime ?? null,
899 hasPendingApproval: input.activePendingApproval !== null,
900 hasPendingUserInput: input.activePendingUserInput !== null,
901 threadError: input.threadError,
902 }),
903 [
904 input.activeLatestRun,
905 input.latestUserMessageId,
906 input.activePendingApproval,
907 input.activePendingUserInput,
908 input.activeThread?.runtime,
909 input.phase,
910 input.threadError,
911 localDispatch,
912 ],
913 );
914 const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch;
915 const beginLocalDispatch = useCallback(
916 (options?: { preparingWorktree?: boolean; submissionIntent?: ComposerSubmissionIntent }) => {
917 const preparingWorktree = Boolean(options?.preparingWorktree);
918 setLocalDispatch((current) => {
919 const active = serverAcknowledgedLocalDispatch ? null : current;
920 if (active) {
921 const submissionIntent = options?.submissionIntent ?? active.submissionIntent;
922 return active.preparingWorktree === preparingWorktree &&
923 active.submissionIntent === submissionIntent
924 ? active
925 : { ...active, preparingWorktree, submissionIntent };
926 }
927 return createLocalDispatchSnapshot(input.activeThread, {
928 ...options,
929 latestUserMessageId: input.latestUserMessageId,
930 });
931 });
932 },
933 [input.activeThread, input.latestUserMessageId, serverAcknowledgedLocalDispatch],
934 );
935
936 return {
937 beginLocalDispatch,
938 resetLocalDispatch,
939 localDispatchStartedAt: activeLocalDispatch?.startedAt ?? null,
940 isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false,
941 isSendBusy: activeLocalDispatch !== null,
942 backgroundSubmissionPending: activeLocalDispatch?.submissionIntent === "background",
943 };
944}
945
946/** Same terminal ids (order ignored) — avoids reconcile when only server session ordering differs. */
947function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean {
948 if (left.length !== right.length) {
949 return false;
950 }
951 if (left.length === 0) {
952 return true;
953 }
954 const sortedLeft = left.toSorted((a, b) => a.localeCompare(b));
955 const sortedRight = right.toSorted((a, b) => a.localeCompare(b));
956 for (let index = 0; index < sortedLeft.length; index += 1) {
957 if (sortedLeft[index] !== sortedRight[index]) {
958 return false;
959 }
960 }
961 return true;
962}
963
964/**
965 * Server knows about fewer sessions than the client, but every server id still exists locally.
966 * Typical right after `terminal.open`: known-session list lags; reconciling would drop the new id
967 * and later re-add it as a separate group (no split layout).
968 */
969function serverTerminalIdsStrictSubsetOfClient(
970 serverIds: readonly string[],
971 clientIds: readonly string[],
972): boolean {
973 if (serverIds.length >= clientIds.length || clientIds.length === 0) {
974 return false;
975 }
976 const clientSet = new Set(clientIds);
977 for (const id of serverIds) {
978 if (!clientSet.has(id)) {
979 return false;
980 }
981 }
982 return true;
983}
984
985interface PersistentThreadTerminalDrawerProps {
986 threadRef: { environmentId: EnvironmentId; threadId: ThreadId };
987 threadId: ThreadId;
988 active: boolean;
989 launchContext: PersistentTerminalLaunchContext | null;
990 focusRequestId: number;
991 splitShortcutLabel: string | undefined;
992 splitVerticalShortcutLabel: string | undefined;
993 newShortcutLabel: string | undefined;
994 closeShortcutLabel: string | undefined;
995 keybindings: ResolvedKeybindingsConfig;
996 onAddTerminalContext: (selection: TerminalContextSelection) => void;
997}
998
999const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({
1000 threadRef,
1001 threadId,
1002 active,
1003 launchContext,
1004 focusRequestId,
1005 splitShortcutLabel,
1006 splitVerticalShortcutLabel,
1007 newShortcutLabel,
1008 closeShortcutLabel,
1009 keybindings,
1010 onAddTerminalContext,
1011}: PersistentThreadTerminalDrawerProps) {
1012 const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope);
1013 const hasTerminalWriteAccess = useCallback(
1014 () => readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope),
1015 [threadRef.environmentId],
1016 );
1017 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
1018 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
1019 const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close");
1020 const serverThread = useThreadShell(threadRef);
1021 const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));
1022 const projectRef = serverThread
1023 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
1024 : draftThread
1025 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
1026 : null;
1027 const project = useProject(projectRef);
1028 const terminalUiState = useTerminalUiStateStore((state) =>
1029 selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef),
1030 );
1031 const visible = active && terminalUiState.terminalOpen;
1032 const knownTerminalSessions = useKnownTerminalSessions({
1033 environmentId: threadRef.environmentId,
1034 threadId,
1035 });
1036 const panelSurfaces = useRightPanelStore(
1037 (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces,
1038 );
1039 const panelTerminalIds = useMemo(
1040 () =>
1041 new Set(
1042 panelSurfaces.flatMap((surface) =>
1043 surface.kind === "terminal" ? surface.terminalIds : [],
1044 ),
1045 ),
1046 [panelSurfaces],
1047 );
1048 const drawerTerminalSessions = useMemo(
1049 () =>
1050 knownTerminalSessions?.filter(
1051 (session) => !panelTerminalIds.has(session.target.terminalId),
1052 ) ?? [],
1053 [knownTerminalSessions, panelTerminalIds],
1054 );
1055 const terminalLabelsById = useMemo(() => {
1056 const next = new Map<string, string>();
1057 for (const session of drawerTerminalSessions) {
1058 next.set(
1059 session.target.terminalId,
1060 resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),
1061 );
1062 }
1063 return next;
1064 }, [drawerTerminalSessions]);
1065 const terminalLaunchLocationsById = useMemo(() => {
1066 const next = new Map<
1067 string,
1068 {
1069 readonly cwd: string;
1070 readonly worktreePath: string | null;
1071 readonly runtimeEnv: Record<string, string>;
1072 }
1073 >();
1074 if (!project) {
1075 return next;
1076 }
1077
1078 for (const session of drawerTerminalSessions) {
1079 const summary = session.state.summary;
1080 if (!summary) {
1081 continue;
1082 }
1083 const worktreePathForLaunch =
1084 launchContext !== null ? launchContext.worktreePath : summary.worktreePath;
1085 next.set(session.target.terminalId, {
1086 cwd: launchContext?.cwd ?? summary.cwd,
1087 worktreePath: worktreePathForLaunch,
1088 runtimeEnv: projectScriptRuntimeEnv({
1089 project: { cwd: project.workspaceRoot },
1090 worktreePath: worktreePathForLaunch,
1091 }),
1092 });
1093 }
1094
1095 return next;
1096 }, [drawerTerminalSessions, launchContext, project]);
1097 const serverOrderedTerminalIds = useMemo(
1098 () => drawerTerminalSessions.map((session) => session.target.terminalId),
1099 [drawerTerminalSessions],
1100 );
1101 // Every client-side id source participates in allocation: the server list
1102 // lags fresh opens, and panel terminals are filtered out of the drawer's
1103 // sessions — an id collision attaches two viewports to one PTY session.
1104 const allocatableTerminalIds = useMemo(
1105 () => [
1106 ...new Set([
1107 ...serverOrderedTerminalIds,
1108 ...terminalUiState.terminalIds,
1109 ...panelTerminalIds,
1110 ]),
1111 ],
1112 [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds],
1113 );
1114 const allocateTerminalId = useCallback(
1115 () =>
1116 nextTerminalId(
1117 allocatableTerminalIds,
1118 knownTerminalSessions === null ||
1119 !readEnvironmentScope(threadRef.environmentId, AuthTerminalReadScope)
1120 ? randomUUID()
1121 : undefined,
1122 ),
1123 [allocatableTerminalIds, knownTerminalSessions, threadRef.environmentId],
1124 );
1125 const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight);
1126 const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal);
1127 const storeSplitTerminalVertical = useTerminalUiStateStore(
1128 (state) => state.splitTerminalVertical,
1129 );
1130 const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal);
1131 const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal);
1132 const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal);
1133 const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds);
1134
1135 useEffect(() => {
1136 if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) {
1137 return;
1138 }
1139 if (
1140 serverTerminalIdsStrictSubsetOfClient(serverOrderedTerminalIds, terminalUiState.terminalIds)
1141 ) {
1142 return;
1143 }
1144 reconcileTerminalIds(threadRef, serverOrderedTerminalIds);
1145 }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]);
1146 const [localFocusRequestId, setLocalFocusRequestId] = useState(0);
1147 const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
1148 const effectiveWorktreePath = useMemo(() => {
1149 if (launchContext !== null) {
1150 return launchContext.worktreePath;
1151 }
1152 return worktreePath;
1153 }, [launchContext, worktreePath]);
1154 const cwd = useMemo(
1155 () =>
1156 launchContext?.cwd ??
1157 (project
1158 ? projectScriptCwd({
1159 project: { cwd: project.workspaceRoot },
1160 worktreePath: effectiveWorktreePath,
1161 })
1162 : null),
1163 [effectiveWorktreePath, launchContext?.cwd, project],
1164 );
1165 const runtimeEnv = useMemo(
1166 () =>
1167 project
1168 ? projectScriptRuntimeEnv({
1169 project: { cwd: project.workspaceRoot },
1170 worktreePath: effectiveWorktreePath,
1171 })
1172 : {},
1173 [effectiveWorktreePath, project],
1174 );
1175
1176 const bumpFocusRequestId = useCallback(() => {
1177 if (!visible) {
1178 return;
1179 }
1180 setLocalFocusRequestId((value) => value + 1);
1181 }, [visible]);
1182
1183 const setTerminalHeight = useCallback(
1184 (height: number) => {
1185 storeSetTerminalHeight(threadRef, height);
1186 },
1187 [storeSetTerminalHeight, threadRef],
1188 );
1189
1190 const splitTerminal = useCallback(() => {
1191 if (!hasTerminalWriteAccess() || !cwd) {
1192 return;
1193 }
1194 const terminalId = allocateTerminalId();
1195 storeSplitTerminal(threadRef, terminalId);
1196 bumpFocusRequestId();
1197 void openTerminal({
1198 environmentId: threadRef.environmentId,
1199 input: {
1200 threadId,
1201 terminalId,
1202 cwd,
1203 ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}),
1204 env: runtimeEnv,
1205 },
1206 });
1207 }, [
1208 allocateTerminalId,
1209 bumpFocusRequestId,
1210 cwd,
1211 effectiveWorktreePath,
1212 runtimeEnv,
1213 storeSplitTerminal,
1214 threadId,
1215 threadRef,
1216 openTerminal,
1217 hasTerminalWriteAccess,
1218 ]);
1219 const splitTerminalVertical = useCallback(() => {
1220 if (!hasTerminalWriteAccess() || !cwd) {
1221 return;
1222 }
1223 const terminalId = allocateTerminalId();
1224 storeSplitTerminalVertical(threadRef, terminalId);
1225 bumpFocusRequestId();
1226 void openTerminal({
1227 environmentId: threadRef.environmentId,
1228 input: {
1229 threadId,
1230 terminalId,
1231 cwd,
1232 ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}),
1233 env: runtimeEnv,
1234 },
1235 });
1236 }, [
1237 allocateTerminalId,
1238 bumpFocusRequestId,
1239 cwd,
1240 effectiveWorktreePath,
1241 openTerminal,
1242 hasTerminalWriteAccess,
1243 runtimeEnv,
1244 storeSplitTerminalVertical,
1245 threadId,
1246 threadRef,
1247 ]);
1248
1249 const createNewTerminal = useCallback(() => {
1250 if (!hasTerminalWriteAccess() || !cwd) {
1251 return;
1252 }
1253 const terminalId = allocateTerminalId();
1254 storeNewTerminal(threadRef, terminalId);
1255 bumpFocusRequestId();
1256 void openTerminal({
1257 environmentId: threadRef.environmentId,
1258 input: {
1259 threadId,
1260 terminalId,
1261 cwd,
1262 ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}),
1263 env: runtimeEnv,
1264 },
1265 });
1266 }, [
1267 bumpFocusRequestId,
1268 cwd,
1269 effectiveWorktreePath,
1270 allocateTerminalId,
1271 runtimeEnv,
1272 storeNewTerminal,
1273 threadId,
1274 threadRef,
1275 openTerminal,
1276 hasTerminalWriteAccess,
1277 ]);
1278
1279 const activateTerminal = useCallback(
1280 (terminalId: string) => {
1281 storeSetActiveTerminal(threadRef, terminalId);
1282 bumpFocusRequestId();
1283 },
1284 [bumpFocusRequestId, storeSetActiveTerminal, threadRef],
1285 );
1286
1287 const closeTerminal = useCallback(
1288 (terminalId: string) => {
1289 if (!hasTerminalWriteAccess()) return;
1290 const fallbackExitWrite = () =>
1291 writeTerminal({
1292 environmentId: threadRef.environmentId,
1293 input: { threadId, terminalId, data: "exit\n" },
1294 });
1295
1296 void (async () => {
1297 const closeResult = await closeTerminalMutation({
1298 environmentId: threadRef.environmentId,
1299 input: {
1300 threadId,
1301 terminalId,
1302 deleteHistory: true,
1303 },
1304 });
1305 if (
1306 closeResult._tag === "Failure" &&
1307 !isAtomCommandInterrupted(closeResult) &&
1308 hasTerminalWriteAccess()
1309 ) {
1310 await fallbackExitWrite();
1311 }
1312 })();
1313
1314 storeCloseTerminal(threadRef, terminalId);
1315 bumpFocusRequestId();
1316 },
1317 [
1318 bumpFocusRequestId,
1319 storeCloseTerminal,
1320 threadId,
1321 threadRef,
1322 closeTerminalMutation,
1323 hasTerminalWriteAccess,
1324 writeTerminal,
1325 ],
1326 );
1327
1328 const handleAddTerminalContext = useCallback(
1329 (selection: TerminalContextSelection) => {
1330 if (!visible) {
1331 return;
1332 }
1333 onAddTerminalContext(selection);
1334 },
1335 [onAddTerminalContext, visible],
1336 );
1337
1338 if (!project || (!terminalUiState.terminalOpen && !active) || !cwd) {
1339 return null;
1340 }
1341
1342 return (
1343 <div
1344 className={cn(
1345 "grid shrink-0 overflow-clip",
1346 active ? (visible ? "grid-rows-[1fr]" : "grid-rows-[0fr]") : "hidden",
1347 active &&
1348 "[[data-panel-animations=true]_&]:transition-[grid-template-rows] [[data-panel-animations=true]_&]:duration-(--panel-animation-duration) [[data-panel-animations=true]_&]:ease-out",
1349 active && visible && "[[data-panel-animations=true]_&]:starting:grid-rows-[0fr]!",
1350 )}
1351 >
1352 <div className="min-h-0 overflow-clip">
1353 <ThreadTerminalDrawer
1354 threadRef={threadRef}
1355 threadId={threadId}
1356 cwd={cwd}
1357 worktreePath={effectiveWorktreePath}
1358 runtimeEnv={runtimeEnv}
1359 visible={visible}
1360 height={terminalUiState.terminalHeight}
1361 // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable.
1362 terminalIds={canOperateTerminal ? terminalUiState.terminalIds : serverOrderedTerminalIds}
1363 activeTerminalId={terminalUiState.activeTerminalId}
1364 terminalGroups={terminalUiState.terminalGroups}
1365 activeTerminalGroupId={terminalUiState.activeTerminalGroupId}
1366 focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)}
1367 onSplitTerminal={splitTerminal}
1368 onSplitTerminalVertical={splitTerminalVertical}
1369 onNewTerminal={createNewTerminal}
1370 splitShortcutLabel={visible ? splitShortcutLabel : undefined}
1371 splitVerticalShortcutLabel={visible ? splitVerticalShortcutLabel : undefined}
1372 newShortcutLabel={visible ? newShortcutLabel : undefined}
1373 closeShortcutLabel={visible ? closeShortcutLabel : undefined}
1374 keybindings={keybindings}
1375 onActiveTerminalChange={activateTerminal}
1376 onCloseTerminal={closeTerminal}
1377 onHeightChange={setTerminalHeight}
1378 onAddTerminalContext={handleAddTerminalContext}
1379 terminalLabelsById={terminalLabelsById}
1380 terminalLaunchLocationsById={terminalLaunchLocationsById}
1381 />
1382 </div>
1383 </div>
1384 );
1385});
1386
1387interface PersistentThreadTerminalPanelProps {
1388 visible: boolean;
1389 threadRef: ScopedThreadRef;
1390 surface: Extract<RightPanelSurface, { kind: "terminal" }>;
1391 launchContext: PersistentTerminalLaunchContext | null;
1392 focusRequestId: number;
1393 keybindings: ResolvedKeybindingsConfig;
1394 onAddTerminalContext: (selection: TerminalContextSelection) => void;
1395 onSplitTerminal: () => void;
1396 onSplitTerminalVertical: () => void;
1397 onNewTerminal: () => void;
1398 onActiveTerminalChange: (terminalId: string) => void;
1399 onCloseTerminal: (terminalId: string) => void;
1400 splitShortcutLabel?: string | undefined;
1401 splitVerticalShortcutLabel?: string | undefined;
1402 newShortcutLabel?: string | undefined;
1403 closeShortcutLabel?: string | undefined;
1404}
1405
1406const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({
1407 visible,
1408 threadRef,
1409 surface,
1410 launchContext,
1411 focusRequestId,
1412 keybindings,
1413 onAddTerminalContext,
1414 onSplitTerminal,
1415 onSplitTerminalVertical,
1416 onNewTerminal,
1417 onActiveTerminalChange,
1418 onCloseTerminal,
1419 splitShortcutLabel,
1420 splitVerticalShortcutLabel,
1421 newShortcutLabel,
1422 closeShortcutLabel,
1423}: PersistentThreadTerminalPanelProps) {
1424 const serverThread = useThreadShell(threadRef);
1425 const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef));
1426 const projectRef = serverThread
1427 ? scopeProjectRef(serverThread.environmentId, serverThread.projectId)
1428 : draftThread
1429 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
1430 : null;
1431 const project = useProject(projectRef);
1432 const knownTerminalSessions = useKnownTerminalSessions({
1433 environmentId: threadRef.environmentId,
1434 threadId: threadRef.threadId,
1435 });
1436 const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
1437 const activeSummary =
1438 knownTerminalSessions?.find((session) => session.target.terminalId === surface.activeTerminalId)
1439 ?.state.summary ?? null;
1440 const worktreePath =
1441 launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath;
1442 const cwd = useMemo(
1443 () =>
1444 launchContext?.cwd ??
1445 activeSummary?.cwd ??
1446 (project
1447 ? projectScriptCwd({
1448 project: { cwd: project.workspaceRoot },
1449 worktreePath,
1450 })
1451 : null),
1452 [activeSummary?.cwd, launchContext?.cwd, project, worktreePath],
1453 );
1454 const runtimeEnv = useMemo(
1455 () =>
1456 project
1457 ? projectScriptRuntimeEnv({
1458 project: { cwd: project.workspaceRoot },
1459 worktreePath,
1460 })
1461 : {},
1462 [project, worktreePath],
1463 );
1464 const terminalLabelsById = useMemo(() => {
1465 const labels = new Map<string, string>();
1466 for (const terminalId of surface.terminalIds) {
1467 const summary =
1468 knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state
1469 .summary ?? null;
1470 labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary));
1471 }
1472 return labels;
1473 }, [knownTerminalSessions, surface.terminalIds]);
1474 const terminalLaunchLocationsById = useMemo(() => {
1475 const locations = new Map<
1476 string,
1477 {
1478 readonly cwd: string;
1479 readonly worktreePath: string | null;
1480 readonly runtimeEnv: Record<string, string>;
1481 }
1482 >();
1483 for (const terminalId of surface.terminalIds) {
1484 const summary =
1485 knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state
1486 .summary ?? null;
1487 const terminalWorktreePath =
1488 launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath;
1489 const terminalCwd =
1490 launchContext?.cwd ??
1491 summary?.cwd ??
1492 (project
1493 ? projectScriptCwd({
1494 project: { cwd: project.workspaceRoot },
1495 worktreePath: terminalWorktreePath,
1496 })
1497 : null);
1498 if (!terminalCwd || !project) continue;
1499 locations.set(terminalId, {
1500 cwd: terminalCwd,
1501 worktreePath: terminalWorktreePath,
1502 runtimeEnv: projectScriptRuntimeEnv({
1503 project: { cwd: project.workspaceRoot },
1504 worktreePath: terminalWorktreePath,
1505 }),
1506 });
1507 }
1508 return locations;
1509 }, [
1510 knownTerminalSessions,
1511 launchContext?.cwd,
1512 launchContext?.worktreePath,
1513 project,
1514 surface.terminalIds,
1515 threadWorktreePath,
1516 ]);
1517
1518 if (!project || !cwd) return null;
1519
1520 return (
1521 <ThreadTerminalDrawer
1522 mode="panel"
1523 visible={visible}
1524 threadRef={threadRef}
1525 threadId={threadRef.threadId}
1526 cwd={cwd}
1527 worktreePath={worktreePath}
1528 runtimeEnv={runtimeEnv}
1529 height={0}
1530 terminalIds={surface.terminalIds}
1531 activeTerminalId={surface.activeTerminalId}
1532 terminalGroups={[
1533 {
1534 id: surface.id,
1535 terminalIds: surface.terminalIds,
1536 ...(surface.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}),
1537 },
1538 ]}
1539 activeTerminalGroupId={surface.id}
1540 focusRequestId={focusRequestId}
1541 onSplitTerminal={onSplitTerminal}
1542 onSplitTerminalVertical={onSplitTerminalVertical}
1543 onNewTerminal={onNewTerminal}
1544 splitShortcutLabel={splitShortcutLabel}
1545 splitVerticalShortcutLabel={splitVerticalShortcutLabel}
1546 newShortcutLabel={newShortcutLabel}
1547 closeShortcutLabel={closeShortcutLabel}
1548 onActiveTerminalChange={onActiveTerminalChange}
1549 onCloseTerminal={onCloseTerminal}
1550 onHeightChange={() => undefined}
1551 onAddTerminalContext={onAddTerminalContext}
1552 terminalLabelsById={terminalLabelsById}
1553 terminalLaunchLocationsById={terminalLaunchLocationsById}
1554 keybindings={keybindings}
1555 />
1556 );
1557});
1558
1559// Errors surface through two maps (draft-keyed and thread-keyed) whose entries
1560// can race around promotion, so each write carries its time to let the latest
1561// one win when they collide.
1562type LocalThreadErrorEntry = {
1563 readonly message: string | null;
1564 readonly at: number;
1565};
1566
1567function chatActionErrorMessage(error: unknown): string {
1568 return error instanceof Error ? error.message : "An error occurred.";
1569}
1570
1571const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3;
1572const EMPTY_HELD_TURN_DIFF_SUMMARIES: readonly never[] = [];
1573const noopHeldTurnDiff = (_turnId: RunId, _filePath?: string) => {};
1574const noopHeldRevert = (_targetTurnCount: number) => {};
1575const noopHeldAttachment = (_attachment: ChatFileAttachment) => {};
1576
1577/**
1578 * Drops the send-time anchored end space. That space is what holds a sent
1579 * message near the top while its turn streams, and it keeps LegendList's
1580 * maintainScrollAtEnd switched off for as long as it is installed. Tool
1581 * activity and every return to the live edge release the anchor, otherwise
1582 * the timeline settles into "following-end" with nothing following anything.
1583 */
1584function releaseChatTimelineAnchor<T extends { readonly messageId: MessageId | null }>(
1585 current: T,
1586): T {
1587 return current.messageId === null ? current : { ...current, messageId: null };
1588}
1589
1590/** Runs with a workspace preparation retry in flight, across ChatView instances. */
1591const retryingWorkspacePreparationRunIds = new Set<RunId>();
1592
1593export default function ChatView(props: ChatViewProps) {
1594 const {
1595 environmentId,
1596 threadId,
1597 routeKind,
1598 onDiffPanelOpen,
1599 reserveTitleBarControlInset = true,
1600 forceExpandedMobileComposer = false,
1601 } = props;
1602 const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope);
1603 const canOrganizeThread = useThreadOrganizationPermission(environmentId);
1604 const organizationCapabilities = useAtomValue(environmentCapabilitiesAtom).get(environmentId);
1605 const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope);
1606 const hasTerminalWriteAccess = useCallback(
1607 () => readEnvironmentScope(environmentId, AuthTerminalOperateScope),
1608 [environmentId],
1609 );
1610 const canReadTerminal = useEnvironmentScope(environmentId, AuthTerminalReadScope);
1611 const draftId = routeKind === "draft" ? props.draftId : null;
1612 const handleNewThread = useNewThreadHandler();
1613 const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions();
1614 const routeThreadRef = useMemo(
1615 () => scopeThreadRef(environmentId, threadId),
1616 [environmentId, threadId],
1617 );
1618 const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]);
1619 const currentRouteThreadKeyRef = useRef<string | null>(routeThreadKey);
1620 useLayoutEffect(() => {
1621 currentRouteThreadKeyRef.current = routeThreadKey;
1622 return () => {
1623 currentRouteThreadKeyRef.current = null;
1624 };
1625 }, [routeThreadKey]);
1626 const updateProjectScriptSettings = useAtomCommand(serverEnvironment.updateSettings, {
1627 reportFailure: false,
1628 });
1629 const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {
1630 reportFailure: false,
1631 });
1632 const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, {
1633 reportFailure: false,
1634 });
1635 const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
1636 const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
1637 const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close");
1638 const createThread = useOrchestrationCommand(threadEnvironment.create, { reportFailure: false });
1639 const deleteThread = useOrchestrationCommand(threadEnvironment.delete, { reportFailure: false });
1640 const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
1641 reportFailure: false,
1642 });
1643 const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope);
1644 const switchGitRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });
1645 const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, {
1646 reportFailure: false,
1647 });
1648 const setThreadInteractionMode = useOrchestrationCommand(threadEnvironment.setInteractionMode, {
1649 reportFailure: false,
1650 });
1651 const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, {
1652 reportFailure: false,
1653 });
1654 const resumeThreadQueue = useOrchestrationCommand(threadEnvironment.resumeThreadQueue, {
1655 reportFailure: false,
1656 });
1657 const uploadThreadFeedback = useOrchestrationCommand(threadEnvironment.uploadFeedback, {
1658 reportFailure: false,
1659 });
1660 const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
1661 reportFailure: false,
1662 refresh: true,
1663 });
1664 const interruptThreadTurn = useOrchestrationCommand(threadEnvironment.interruptTurn, {
1665 reportFailure: false,
1666 });
1667 const loadEarlierThreadHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, {
1668 label: "load earlier thread history",
1669 reportFailure: false,
1670 });
1671 const respondToThreadApproval = useOrchestrationCommand(threadEnvironment.respondToApproval, {
1672 reportFailure: false,
1673 });
1674 const respondToThreadUserInput = useOrchestrationCommand(threadEnvironment.respondToUserInput, {
1675 reportFailure: false,
1676 });
1677 const dismissThreadUserInput = useAtomCommand(threadEnvironment.dismissUserInput, {
1678 reportFailure: false,
1679 });
1680 const revertThreadCheckpoint = useOrchestrationCommand(threadEnvironment.revertCheckpoint, {
1681 reportFailure: false,
1682 });
1683 const forkThreadFromRun = useAtomCommand(threadEnvironment.forkFromRun, {
1684 reportFailure: false,
1685 });
1686 const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false });
1687 const closePreview = useAtomCommand(previewEnvironment.close, "preview close");
1688 const { environments } = useEnvironments();
1689 const primaryEnvironment = usePrimaryEnvironment();
1690 const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false });
1691 const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, {
1692 reportFailure: false,
1693 });
1694 const environmentById = useMemo(
1695 () => new Map(environments.map((environment) => [environment.environmentId, environment])),
1696 [environments],
1697 );
1698 // Queued-message edit mode. While active, the composer is retargeted to a
1699 // per-run edit draft so the user's in-progress draft on the thread survives
1700 // untouched; `existingAttachments` tracks which stored attachments the edit
1701 // keeps (removal is client state until save).
1702 const [editingQueuedRun, setEditingQueuedRun] = useState<{
1703 readonly threadId: ThreadId;
1704 readonly runId: RunId;
1705 readonly messageId: MessageId;
1706 readonly originalText: string;
1707 readonly existingAttachments: ReadonlyArray<ContractChatAttachment>;
1708 readonly context?: import("@t3tools/contracts").OrchestrationMessageContext | undefined;
1709 } | null>(null);
1710 const queuedEditDraftTargetFor = useCallback(
1711 (runId: RunId) => DraftId.make(`queued-edit:${scopedThreadKey(routeThreadRef)}:${runId}`),
1712 [routeThreadRef],
1713 );
1714 const baseComposerDraftTarget: ScopedThreadRef | DraftId =
1715 routeKind === "server" ? routeThreadRef : props.draftId;
1716 const composerDraftTarget: ScopedThreadRef | DraftId =
1717 editingQueuedRun === null
1718 ? baseComposerDraftTarget
1719 : queuedEditDraftTargetFor(editingQueuedRun.runId);
1720 const draftThread = useComposerDraftStore((store) =>
1721 routeKind === "server"
1722 ? store.getDraftSessionByRef(routeThreadRef)
1723 : draftId
1724 ? store.getDraftSession(draftId)
1725 : null,
1726 );
1727 const serverThread = useThreadShell(routeThreadRef);
1728 const routeThreadDetailRef = resolveThreadDetailRef(routeThreadRef, {
1729 shellExists: serverThread !== null,
1730 waitForShell: draftThread !== null,
1731 });
1732 const serverThreadProjection = useThreadProjection(routeThreadDetailRef);
1733 const serverProjection = serverThreadProjection?.projection ?? null;
1734 const reportedModelSelection = serverProjection
1735 ? deriveReportedModelSelection(serverProjection)
1736 : null;
1737 const threadStatus = useThreadStatus(routeThreadDetailRef);
1738 const threadSyncPhase = resolveThreadSyncPhase({
1739 detailExists: serverProjection !== null,
1740 shellExists: serverThread !== null,
1741 status: threadStatus,
1742 });
1743 const threadDetailLoading = threadSyncPhase === "loading";
1744 // Latest provider-reported context usage (#8144): the newest report wins;
1745 // stale turns keep the meter alive between turns.
1746 const activeThreadLiveTokenUsage = useMemo(
1747 () => latestProviderTurnTokenUsage(serverProjection?.providerTurns ?? []),
1748 [serverProjection?.providerTurns],
1749 );
1750 const serverVisibleTurnItems = useThreadVisibleTurnItems(routeThreadDetailRef);
1751 const serverThreadHistory = useThreadHistory(routeThreadDetailRef);
1752 const threadHistoryControls = useMemo<MessagesTimelineHistoryControls | undefined>(() => {
1753 if (routeThreadDetailRef === null || !shouldShowLoadEarlierControl(serverThreadHistory)) {
1754 return undefined;
1755 }
1756 return {
1757 hasMoreHistory: serverThreadHistory.hasMoreHistory,
1758 loading: serverThreadHistory.loading,
1759 error: serverThreadHistory.error,
1760 onLoadEarlier: (throughEntryId) => {
1761 void loadEarlierThreadHistory({
1762 environmentId: routeThreadDetailRef.environmentId,
1763 input: {
1764 threadId: routeThreadDetailRef.threadId,
1765 ...(throughEntryId === undefined ? {} : { throughEntryId }),
1766 },
1767 });
1768 },
1769 };
1770 }, [loadEarlierThreadHistory, routeThreadDetailRef, serverThreadHistory]);
1771 const committedServerMessageIds = useMemo(
1772 () => deriveCommittedServerUserMessageIds(serverVisibleTurnItems),
1773 [serverVisibleTurnItems],
1774 );
1775 // Queued messages have no turn item until their run starts, so the
1776 // turn-item-derived set alone can never retire their optimistic rows —
1777 // cancelling such a run would leave a phantom "pending" queue row behind.
1778 // Union in the projection's user messages: once the server holds the
1779 // message, the optimistic copy is redundant everywhere it could render.
1780 const serverAcknowledgedUserMessageIds = useMemo(() => {
1781 const ids = new Set(committedServerMessageIds);
1782 for (const message of serverProjection?.messages ?? []) {
1783 if (message.role === "user") ids.add(message.id);
1784 }
1785 return ids;
1786 }, [committedServerMessageIds, serverProjection]);
1787 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
1788 const activeThreadLocalLastVisitedAt = useUiStateStore(
1789 (store) => store.threadLastVisitedAtById[routeThreadKey],
1790 );
1791 const visitThreadMutation = useAtomCommand(threadEnvironment.visit, { reportFailure: false });
1792 const lastDispatchedVisitRef = useRef<string | null>(null);
1793 const lastVisitDispatchAtRef = useRef(0);
1794 const settings = useEnvironmentSettings(environmentId);
1795 const clientSettingsHydrated = useClientSettingsHydrated();
1796 const setStickyComposerModelSelection = useComposerDraftStore(
1797 (store) => store.setStickyModelSelection,
1798 );
1799 const timestampFormat = settings.timestampFormat;
1800 const navigate = useNavigate();
1801 const citationLocation = useLocation({
1802 select: (location) => ({
1803 href: location.href,
1804 key: location.state.assistantCitationActivation ?? location.state.__TSR_key,
1805 }),
1806 });
1807 const citationRequest = useMemo<AssistantCitationRequest | null>(() => {
1808 const citation = assistantCitationFromLocation(citationLocation.href);
1809 return citation && citation.environmentId === environmentId && citation.threadId === threadId
1810 ? { citation, key: citationLocation.key ?? citationLocation.href }
1811 : null;
1812 }, [citationLocation.href, citationLocation.key, environmentId, threadId]);
1813 const { resolvedTheme } = useTheme();
1814 // Granular store selectors — avoid subscribing to prompt changes.
1815 const composerRuntimeMode = useComposerDraftStore(
1816 (store) => store.getComposerDraft(composerDraftTarget)?.runtimeMode ?? null,
1817 );
1818 const composerInteractionMode = useComposerDraftStore(
1819 (store) => store.getComposerDraft(composerDraftTarget)?.interactionMode ?? null,
1820 );
1821 const composerActiveProvider = useComposerDraftStore(
1822 (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null,
1823 );
1824 const composerSelectedModelSelection = useComposerDraftStore((store) => {
1825 const draft = store.getComposerDraft(composerDraftTarget);
1826 return draft?.activeProvider
1827 ? (draft.modelSelectionByProvider[draft.activeProvider] ?? null)
1828 : null;
1829 });
1830 const composerHasAttachments = useComposerDraftStore((store) => {
1831 const draft = store.getComposerDraft(composerDraftTarget);
1832 return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0;
1833 });
1834 // Anything beyond the prompt text: attachments, terminal or element contexts, annotations.
1835 const composerHasNonPromptContent = useComposerDraftStore((store) => {
1836 const draft = store.getComposerDraft(composerDraftTarget);
1837 return draft ? composerDraftHasUserContent({ ...draft, prompt: "" }) : false;
1838 });
1839 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
1840 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
1841 const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles);
1842 const setComposerDraftTerminalContexts = useComposerDraftStore(
1843 (store) => store.setTerminalContexts,
1844 );
1845 const setComposerDraftPreviewAnnotations = useComposerDraftStore(
1846 (store) => store.setPreviewAnnotations,
1847 );
1848 const setComposerDraftReviewComments = useComposerDraftStore((store) => store.setReviewComments);
1849 const setComposerDraftThreadContexts = useComposerDraftStore((store) => store.setThreadContexts);
1850 const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection);
1851 const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode);
1852 const setComposerDraftInteractionMode = useComposerDraftStore(
1853 (store) => store.setInteractionMode,
1854 );
1855 const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
1856 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
1857 const getDraftSessionByLogicalProjectKey = useComposerDraftStore(
1858 (store) => store.getDraftSessionByLogicalProjectKey,
1859 );
1860 const getDraftSession = useComposerDraftStore((store) => store.getDraftSession);
1861 const setLogicalProjectDraftThreadId = useComposerDraftStore(
1862 (store) => store.setLogicalProjectDraftThreadId,
1863 );
1864 const promptRef = useRef("");
1865 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
1866 const composerFilesRef = useRef<ComposerFileAttachment[]>([]);
1867 const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]);
1868 const localComposerRef = useRef<ChatComposerHandle | null>(null);
1869 const composerRef = useComposerHandleContext() ?? localComposerRef;
1870 const branchToolbarRef = useRef<BranchToolbarHandle>(null);
1871 const pasteAsTextShortcutUntilRef = useRef(0);
1872 const [restingComposerControlsHost, setRestingComposerControlsHost] =
1873 useState<HTMLDivElement | null>(null);
1874 const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false);
1875 const citeAssistantText = useCallback(
1876 (citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => {
1877 const inserted = composerRef.current?.citeAssistantText(citation, sourceAnchor) ?? false;
1878 if (!inserted) {
1879 toastManager.add({
1880 type: "warning",
1881 title: "The composer is not ready",
1882 description:
1883 "Try citing the selection after the connection or pending input is resolved.",
1884 });
1885 }
1886 return inserted;
1887 },
1888 [composerRef],
1889 );
1890 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
1891 const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false);
1892 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
1893 useEffect(() => {
1894 const item = expandedImage?.images[expandedImage.index];
1895 if (item?.type !== "video" || item.src === null || !item.src.startsWith("blob:")) return;
1896 const src = item.src;
1897 return () => revokeBlobPreviewUrl(src);
1898 }, [expandedImage]);
1899 const [optimisticUserMessages, setOptimisticUserMessages] = useState<ChatMessage[]>([]);
1900 // Last live snapshot from the setup stream. The server drops a finished
1901 // snapshot after a grace period and emits null; holding it here bridges the
1902 // gap until the settled activity arrives on the thread projection.
1903 const [worktreeSetupRef, setWorktreeSetupRef] = useState<{
1904 environmentId: EnvironmentId;
1905 threadId: ThreadId;
1906 ownerKey: string;
1907 } | null>(null);
1908 const [heldWorktreeSetup, setHeldWorktreeSetup] = useState<WorktreeSetupSnapshot | null>(null);
1909 // Set by "Work locally": the draft whose restored message should be resent
1910 // once the cancelled dispatch has settled and the draft is in local mode.
1911 // Keyed by draft id so a bootstrap rotating the thread id keeps it, while
1912 // moving to another draft drops it without an effect.
1913 const [workLocallyResendDraftId, setWorkLocallyResendDraftId] = useState<DraftId | null>(null);
1914 // The draft route reuses this component across drafts, so a resend recorded
1915 // for one draft must not fire when the user comes back to it later.
1916 useEffect(() => {
1917 if (workLocallyResendDraftId !== null && workLocallyResendDraftId !== draftId) {
1918 setWorkLocallyResendDraftId(null);
1919 }
1920 }, [draftId, workLocallyResendDraftId]);
1921 const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<
1922 Record<string, ReadonlyArray<CodexFeedbackSubmission>>
1923 >({});
1924 const feedbackSubmissions =
1925 feedbackSubmissionsByThreadKey[routeThreadKey] ?? EMPTY_FEEDBACK_SUBMISSIONS;
1926 const feedbackUploading = feedbackSubmissions.some(
1927 (submission) => submission.status === "uploading",
1928 );
1929 const optimisticUserMessagesRef = useRef(optimisticUserMessages);
1930 optimisticUserMessagesRef.current = optimisticUserMessages;
1931 const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState<
1932 Record<string, LocalThreadErrorEntry>
1933 >({});
1934 const [localServerErrorsByThreadKey, setLocalServerErrorsByThreadKey] = useState<
1935 Record<string, LocalThreadErrorEntry>
1936 >({});
1937 const [isConnecting, _setIsConnecting] = useState(false);
1938 const isRevertingCheckpoint = useComposerDraftStore((store) =>
1939 store.rewindingThreadKeys.has(routeThreadKey),
1940 );
1941 const userInputResponsesInFlight = useRef(new Set<string>());
1942 const [respondingRequestIds, setRespondingRequestIds] = useState<RuntimeRequestId[]>([]);
1943
1944 useEffect(() => {
1945 setIsWorkspaceFileDragActive(false);
1946 }, [draftId, routeThreadKey]);
1947
1948 useEffect(() => {
1949 if (!isWorkspaceFileDragActive) return;
1950 const clearWorkspaceFileDrag = () => setIsWorkspaceFileDragActive(false);
1951 window.addEventListener("dragend", clearWorkspaceFileDrag);
1952 return () => window.removeEventListener("dragend", clearWorkspaceFileDrag);
1953 }, [isWorkspaceFileDragActive]);
1954 const [respondingUserInputRequestIds, setRespondingUserInputRequestIds] = useState<
1955 RuntimeRequestId[]
1956 >([]);
1957 const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState<
1958 Record<string, Record<string, PendingUserInputDraftAnswer>>
1959 >({});
1960 const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] =
1961 useState<Record<string, number>>({});
1962 const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);
1963 const isMobileViewport = useMediaQuery("max-sm");
1964 const [workspaceLayoutElement, setWorkspaceLayoutElement] = useState<HTMLDivElement | null>(null);
1965 const threadPanelPopoverAnchorRef = useRef<HTMLElement | null>(null);
1966 // Tracks whether the user explicitly dismissed the sidebar for the active turn.
1967 // When set, the thread-change reset effect will open the sidebar instead of closing it.
1968 // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation.
1969 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
1970 const [terminalPlacement] = useTerminalPlacement();
1971 const [pullRequestDialogState, setPullRequestDialogState] =
1972 useState<PullRequestDialogState | null>(null);
1973 const [terminalUiLaunchContext, setTerminalUiLaunchContext] =
1974 useState<TerminalLaunchContext | null>(null);
1975 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<
1976 Record<string, string[]>
1977 >({});
1978 const [pendingServerThreadEnvMode, setPendingServerThreadEnvMode] =
1979 useState<DraftThreadEnvMode | null>(null);
1980 const [pendingServerThreadBranch, setPendingServerThreadBranch] = useState<string | null>();
1981 const [
1982 pendingServerThreadStartFromOriginByThreadId,
1983 setPendingServerThreadStartFromOriginByThreadId,
1984 ] = useState<Record<string, boolean>>({});
1985 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage(
1986 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
1987 {},
1988 LastInvokedScriptByProjectSchema,
1989 );
1990 const legendListRef = useRef<LegendListRef | null>(null);
1991 const getTimelineScrollableNode = useCallback(
1992 () => legendListRef.current?.getScrollableNode() ?? null,
1993 [],
1994 );
1995 const [composerOverlayElement, setComposerOverlayElement] = useState<HTMLDivElement | null>(null);
1996 // Space the timeline keeps clear above its end. Tracks the overlay while the
1997 // composer is expanded and holds that height while it rests, so the resting
1998 // composer never exposes rows that its expansion will cover.
1999 const [composerTimelineInset, setComposerTimelineInset] = useState(0);
2000 const composerTimelineInsetRef = useRef(0);
2001 const composerRestingRef = useRef(false);
2002 // False while a status bar stands in for the composer (a native subagent).
2003 const composerMountedRef = useRef(true);
2004 const [scrollToEndClearance, setScrollToEndClearance] = useState(0);
2005 const isAtEndRef = useRef(true);
2006 const isTimelineAtLogicalEnd = useCallback(
2007 () => resolveTimelineIsAtEnd(legendListRef.current?.getState()) ?? isAtEndRef.current,
2008 [],
2009 );
2010 // Whether the timeline's rows extend past the viewport above the composer.
2011 // The composer only rests when there is reading space to give back.
2012 const [timelineOverflows, setTimelineOverflows] = useState(false);
2013 const attachmentPreviewHandoffByMessageIdRef = useRef<Record<string, string[]>>({});
2014 const attachmentPreviewPromotionInFlightByMessageIdRef = useRef<Record<string, true>>({});
2015 const fanoutStateAtom = draftFanoutStateAtom(routeThreadKey);
2016 const fanoutState = useAtomValue(fanoutStateAtom);
2017 const sendInFlightRef = fanoutState.sendInFlight;
2018 const [resumingThreadKeys, setResumingThreadKeys] = useState<ReadonlySet<string>>(
2019 () => new Set(),
2020 );
2021 const isResuming = resumingThreadKeys.has(routeThreadKey);
2022 const composerSendGenerationRef = useRef(0);
2023 const multipleModelSelections = fanoutState.selections;
2024 const setMultipleModelSelections = useCallback(
2025 (selections: SetStateAction<ReadonlyArray<ModelSelection> | null>) => {
2026 appAtomRegistry.update(fanoutStateAtom, (current) => ({
2027 ...current,
2028 selections: typeof selections === "function" ? selections(current.selections) : selections,
2029 }));
2030 },
2031 [fanoutStateAtom],
2032 );
2033 const multipleModelSelectionsRef = useRef(multipleModelSelections);
2034 multipleModelSelectionsRef.current = multipleModelSelections;
2035 const uncertainMultipleSubmissionsRef = fanoutState.uncertainSubmissions;
2036 const environmentUnavailableSendToastSlotRef = useRef(0);
2037 const feedbackUploadsInFlightRef = useRef(new Set<string>());
2038 const terminalUiOpenByThreadRef = useRef<Record<string, boolean>>({});
2039
2040 const terminalUiState = useTerminalUiStateStore((state) =>
2041 selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef),
2042 );
2043 const openTerminalThreadKeys = useTerminalUiStateStore(
2044 useShallow((state) =>
2045 Object.entries(state.terminalUiStateByThreadKey).flatMap(
2046 ([nextThreadKey, nextTerminalUiState]) =>
2047 nextTerminalUiState.terminalOpen ? [nextThreadKey] : [],
2048 ),
2049 ),
2050 );
2051 const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen);
2052 const storeEnsureTerminal = useTerminalUiStateStore((state) => state.ensureTerminal);
2053 const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal);
2054 const storeSplitTerminalVertical = useTerminalUiStateStore((s) => s.splitTerminalVertical);
2055 const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal);
2056 const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal);
2057 const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal);
2058 const serverThreadRefs = useThreadRefs();
2059 const serverThreadKeys = useMemo(() => serverThreadRefs.map(scopedThreadKey), [serverThreadRefs]);
2060 const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey);
2061 const draftThreadKeys = useMemo(
2062 () =>
2063 Object.values(draftThreadsByThreadKey).map((draftThread) =>
2064 scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)),
2065 ),
2066 [draftThreadsByThreadKey],
2067 );
2068 const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState<string[]>([]);
2069 const mountedTerminalThreadRefs = useMemo(
2070 () =>
2071 mountedTerminalThreadKeys.flatMap((mountedThreadKey) => {
2072 const mountedThreadRef = parseScopedThreadKey(mountedThreadKey);
2073 return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : [];
2074 }),
2075 [mountedTerminalThreadKeys],
2076 );
2077
2078 const fallbackDraftProjectRef = draftThread
2079 ? scopeProjectRef(draftThread.environmentId, draftThread.projectId)
2080 : null;
2081 const fallbackDraftProject = useProject(fallbackDraftProjectRef);
2082 const localDraftError = serverThread
2083 ? null
2084 : ((draftId ? localDraftErrorsByDraftId[draftId]?.message : null) ?? null);
2085 const localServerError = localServerErrorsByThreadKey[routeThreadKey]?.message ?? null;
2086 // Draft errors are keyed by draftId while server errors are keyed by thread
2087 // key, so a pending draft entry must migrate when the server thread loads or
2088 // a failed send would silently disappear on promotion. When both keys hold
2089 // an entry, the most recent write wins.
2090 useEffect(() => {
2091 if (!serverThread || !draftId) {
2092 return;
2093 }
2094 const pendingDraftEntry = localDraftErrorsByDraftId[draftId];
2095 if (pendingDraftEntry === undefined) {
2096 return;
2097 }
2098 setLocalDraftErrorsByDraftId((existing) => {
2099 if (existing[draftId] === undefined) {
2100 return existing;
2101 }
2102 const next = { ...existing };
2103 delete next[draftId];
2104 return next;
2105 });
2106 setLocalServerErrorsByThreadKey((existing) => {
2107 const currentEntry = existing[routeThreadKey];
2108 if (
2109 currentEntry !== undefined &&
2110 (currentEntry.at > pendingDraftEntry.at ||
2111 currentEntry.message === pendingDraftEntry.message)
2112 ) {
2113 return existing;
2114 }
2115 return {
2116 ...existing,
2117 [routeThreadKey]: pendingDraftEntry,
2118 };
2119 });
2120 }, [draftId, localDraftErrorsByDraftId, routeThreadKey, serverThread]);
2121 const localDraftThread = useMemo(
2122 () =>
2123 draftThread
2124 ? buildLocalDraftThread(
2125 threadId,
2126 draftThread,
2127 resolveProjectSettings(
2128 settings,
2129 fallbackDraftProject?.id ?? null,
2130 fallbackDraftProject ?? undefined,
2131 ).settings.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION,
2132 )
2133 : undefined,
2134 [draftThread, fallbackDraftProject, settings, threadId],
2135 );
2136 const isServerThread = serverThread !== null;
2137 const activeThread = isServerThread ? serverThread : localDraftThread;
2138 const serverLatestRun = useMemo(
2139 () => (serverProjection === null ? null : deriveLatestThreadRun(serverProjection)),
2140 [serverProjection],
2141 );
2142 const serverActivityRun = useMemo(
2143 () => (serverProjection === null ? null : deriveThreadActivityRun(serverProjection)),
2144 [serverProjection],
2145 );
2146 const serverRuntime = useMemo(
2147 () => (serverProjection === null ? null : deriveThreadRuntime(serverProjection)),
2148 [serverProjection],
2149 );
2150 const runlessWorkStartedAt = useMemo(
2151 () => (serverProjection === null ? null : deriveRunlessWorkStartedAt(serverProjection)),
2152 [serverProjection],
2153 );
2154 const providerSubagentStatus = useMemo(
2155 () => (serverProjection === null ? null : deriveProviderSubagentStatus(serverProjection)),
2156 [serverProjection],
2157 );
2158 const isProviderSubagent =
2159 serverThread !== null && isProviderNativeSubagentThread(serverThread.source);
2160 const supportsProviderSwitchingViaHandoff = useMemo(
2161 () => threadSupportsProviderHandoff(serverProjection),
2162 [serverProjection],
2163 );
2164 const activeLatestRun = isServerThread ? serverLatestRun : (activeThread?.latestRun ?? null);
2165 const activeActivityRun = isServerThread ? serverActivityRun : (activeThread?.latestRun ?? null);
2166 const activeRuntime = isServerThread ? serverRuntime : (activeThread?.runtime ?? null);
2167 const hasHeldQueuedRuns =
2168 isServerThread &&
2169 serverProjection?.runs.some((run) => run.status === "queued" && run.queueHeld === true) ===
2170 true;
2171 const resumableRunId = useMemo(() => {
2172 if (!isServerThread || serverProjection === null) return null;
2173 const run = latestExecutedRun(serverProjection.runs);
2174 if (run?.status === "interrupted") return run.id;
2175 return run?.status === "failed" &&
2176 serverRuntime?.lastErrorClass === "usage_limit" &&
2177 latestRootProviderFailure(run, serverProjection.turnItems)?.class === "usage_limit"
2178 ? run.id
2179 : null;
2180 }, [isServerThread, serverProjection, serverRuntime?.lastErrorClass]);
2181 const parentSubagentThreadId =
2182 activeThread?.lineage.relationshipToParent === "subagent"
2183 ? activeThread.lineage.parentThreadId
2184 : null;
2185 const parentSubagentEnvironmentId = activeThread?.environmentId ?? null;
2186 const parentSubagentThreadRef = useMemo(() => {
2187 if (parentSubagentEnvironmentId === null || parentSubagentThreadId === null) {
2188 return null;
2189 }
2190 return scopeThreadRef(parentSubagentEnvironmentId, parentSubagentThreadId);
2191 }, [parentSubagentEnvironmentId, parentSubagentThreadId]);
2192 const parentSubagentThread = useThreadShell(parentSubagentThreadRef);
2193 const parentThreadLink = useMemo(
2194 () =>
2195 parentSubagentThreadRef === null
2196 ? null
2197 : {
2198 threadId: parentSubagentThreadRef.threadId,
2199 title: parentSubagentThread?.title ?? "Parent thread",
2200 },
2201 [parentSubagentThread?.title, parentSubagentThreadRef],
2202 );
2203 const threadError = isServerThread
2204 ? (localServerError ?? serverRuntime?.lastError ?? null)
2205 : localDraftError;
2206 // Dismissals can only mask the shown error, never clear it: a server thread
2207 // keeps its error in session.lastError, so clearing the local shadow would
2208 // just fall through to the persisted one. Mask the current error until a
2209 // different error arrives, mirroring the provider status banner.
2210 const threadErrorBannerKey = getThreadErrorBannerKey(routeThreadKey, threadError);
2211 const visibleThreadError = shouldShowThreadErrorBanner(
2212 routeThreadKey,
2213 threadError,
2214 isThreadErrorBannerDismissedForSession(threadErrorBannerKey),
2215 )
2216 ? threadError
2217 : null;
2218 // Dismissing only mutates the session-scoped mask set, which does not
2219 // trigger a render on its own; setThreadError(null) can also bail when the
2220 // local shadow is already empty and the banner is driven purely by
2221 // session.lastError. Bump a tick so the banner hides immediately. Mirrors
2222 // the branch mismatch banner.
2223 const [, setThreadErrorBannerDismissTick] = useState(0);
2224 const defaultRuntimeMode = resolveProjectSettings(settings, activeThread?.projectId ?? null)
2225 .settings.defaultRuntimeMode;
2226 // Implicit drafts follow their current project/environment, including retargets.
2227 // Explicit composer choices and existing server threads retain their permissions.
2228 const runtimeMode =
2229 composerRuntimeMode ??
2230 (isServerThread ? activeThread?.runtimeMode : undefined) ??
2231 defaultRuntimeMode;
2232 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
2233 const canCheckoutPullRequestIntoThread = canWriteSourceControl && isLocalDraftThread;
2234 const activeThreadId = activeThread?.id ?? null;
2235 // Prefer the larger of turn-item-committed ids and projection messages so
2236 // env lock does not unlock while turn items lag projection hydration.
2237 const activeMessageCount = isServerThread
2238 ? Math.max(committedServerMessageIds.size, serverProjection?.messages.length ?? 0)
2239 : 0;
2240 const runningTerminalIds = useThreadRunningTerminalIds({
2241 environmentId: activeThread?.environmentId ?? null,
2242 threadId: activeThreadId,
2243 });
2244 const activeThreadKnownSessionsRaw = useKnownTerminalSessions({
2245 environmentId: activeThread?.environmentId ?? null,
2246 threadId: activeThreadId,
2247 });
2248 const activeThreadKnownSessions = useMemo(() => {
2249 if (activeThreadId === null || activeThreadKnownSessionsRaw === null) {
2250 return [];
2251 }
2252 return activeThreadKnownSessionsRaw.filter(
2253 (session) => session.target.threadId === activeThreadId,
2254 );
2255 }, [activeThreadId, activeThreadKnownSessionsRaw]);
2256 const activeServerOrderedTerminalIds = useMemo(
2257 () => activeThreadKnownSessions.map((session) => session.target.terminalId),
2258 [activeThreadKnownSessions],
2259 );
2260 const activeKnownTerminalIds = useMemo(
2261 () => [...new Set([...activeServerOrderedTerminalIds, ...terminalUiState.terminalIds])],
2262 [activeServerOrderedTerminalIds, terminalUiState.terminalIds],
2263 );
2264 const activeTerminalLabelsById = useMemo(() => {
2265 const labels = new Map<string, string>();
2266 for (const session of activeThreadKnownSessions) {
2267 labels.set(
2268 session.target.terminalId,
2269 resolveTerminalSessionLabel(session.target.terminalId, session.state.summary),
2270 );
2271 }
2272 return labels;
2273 }, [activeThreadKnownSessions]);
2274 const activeThreadRef = useActiveThreadRef(activeThread);
2275 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
2276 const activeEnvironmentServerBrowser = useEnvironmentSupportsServerBrowser(
2277 activeThreadRef?.environmentId ?? null,
2278 );
2279 // Electron hosts its own browser tabs; other clients need the environment to host them.
2280 const browserAvailable = isPreviewSupportedInRuntime() || activeEnvironmentServerBrowser;
2281 const previewPanelInlineSize = usePreviewPanelInlineSize(undefined, {
2282 container: workspaceLayoutElement,
2283 widthStorageKey: `t3code:preview-panel-width:${activeThreadKey}`,
2284 });
2285 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
2286 const timelineThreadError =
2287 serverRuntime?.status === "failed" &&
2288 serverRuntime.lastErrorClass === "usage_limit" &&
2289 activeThreadShell?.latestRun &&
2290 visibleThreadError === serverRuntime.lastError
2291 ? null
2292 : visibleThreadError;
2293
2294 const [timelineAnchor, setTimelineAnchor] = useState<{
2295 readonly threadKey: string | null;
2296 readonly messageId: MessageId | null;
2297 }>({ threadKey: activeThreadKey, messageId: null });
2298 if (timelineAnchor.threadKey !== activeThreadKey) {
2299 setTimelineAnchor({ threadKey: activeThreadKey, messageId: null });
2300 }
2301 const timelineAnchorMessageId = timelineAnchor.messageId;
2302 // Release the turn anchor once its run finishes. LegendList sizes the
2303 // anchored end-space filler for the geometry at anchor time and never
2304 // recomputes it, so a lingering anchor leaves stale filler height behind
2305 // the composer — phantom scroll range on threads whose content fits.
2306 const anchorRunSettled = useMemo(() => {
2307 if (timelineAnchorMessageId === null) return false;
2308 const anchorRun = serverProjection?.runs.find(
2309 (run) => run.userMessageId === timelineAnchorMessageId,
2310 );
2311 if (anchorRun === undefined) return false;
2312 return (
2313 anchorRun.status !== "preparing" &&
2314 anchorRun.status !== "queued" &&
2315 anchorRun.status !== "starting" &&
2316 anchorRun.status !== "running" &&
2317 anchorRun.status !== "waiting"
2318 );
2319 }, [serverProjection, timelineAnchorMessageId]);
2320 useEffect(() => {
2321 if (!anchorRunSettled) return;
2322 setTimelineAnchor({ threadKey: activeThreadKey, messageId: null });
2323 }, [anchorRunSettled, activeThreadKey]);
2324 const activeRightPanelKind = useRightPanelStore((state) =>
2325 selectActiveRightPanel(state.byThreadKey, activeThreadRef),
2326 );
2327 const diffOpen = activeRightPanelKind === "diff";
2328 const explicitDiffOpenRef = useRef<ScopedThreadRef | null>(null);
2329 useLayoutEffect(() => {
2330 const explicitThreadRef = explicitDiffOpenRef.current;
2331 explicitDiffOpenRef.current = null;
2332 // Generic openings always show Changes, including tab fallbacks and thread changes.
2333 // A timeline click instead opens the specific turn/file the user requested.
2334 if (diffOpen && activeThreadRef && explicitThreadRef !== activeThreadRef) {
2335 useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch");
2336 }
2337 }, [activeThreadRef, diffOpen]);
2338 const rightPanelState = useRightPanelStore((state) =>
2339 selectThreadRightPanelState(state.byThreadKey, activeThreadRef),
2340 );
2341 const activeRightPanelSurface = useRightPanelStore((state) =>
2342 selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef),
2343 );
2344 const terminalVisible =
2345 terminalUiState.terminalOpen ||
2346 (rightPanelState.isOpen && activeRightPanelSurface?.kind === "terminal");
2347 const activePreviewState = useThreadPreviewState(activeThreadRef);
2348 const activePreviewServerEpoch = activePreviewState.serverEpoch;
2349 const previewSessionsReady = !activeEnvironmentServerBrowser || activePreviewState.listLoaded;
2350 const resolvePreviewRuntimeTabId = useMemo(
2351 () =>
2352 activeThreadRef
2353 ? (tabId: string) => previewRuntimeTabId(activeThreadRef, activePreviewServerEpoch, tabId)
2354 : undefined,
2355 [activeThreadRef, activePreviewServerEpoch],
2356 );
2357 const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) =>
2358 selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef),
2359 );
2360 const panelTerminalIds = useMemo(
2361 () =>
2362 new Set(
2363 rightPanelState.surfaces.flatMap((surface) =>
2364 surface.kind === "terminal" ? surface.terminalIds : [],
2365 ),
2366 ),
2367 [rightPanelState.surfaces],
2368 );
2369 const allocatableActiveTerminalIds = useMemo(
2370 () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])],
2371 [activeKnownTerminalIds, panelTerminalIds],
2372 );
2373 const canReuseTerminal = activeThreadKnownSessionsRaw !== null;
2374 const allocateTerminalId = useCallback(
2375 () =>
2376 nextTerminalId(
2377 allocatableActiveTerminalIds,
2378 canReuseTerminal && readEnvironmentScope(environmentId, AuthTerminalReadScope)
2379 ? undefined
2380 : randomUUID(),
2381 ),
2382 [allocatableActiveTerminalIds, canReuseTerminal, environmentId],
2383 );
2384 const previewPanelOpen = activeRightPanelKind === "preview" && browserAvailable;
2385 const rightPanelOpen = rightPanelState.isOpen;
2386 const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
2387 usePanelAnimationSettings();
2388 const activeTerminalDrawerPresence = usePanelPresence(
2389 Boolean(activeThreadKey && terminalUiState.terminalOpen),
2390 true,
2391 panelAnimationsActive,
2392 activeThreadKey,
2393 panelAnimationDurationMs,
2394 );
2395 const rightPanelPresenceValue = useMemo(
2396 () => ({
2397 activeSurface: activeRightPanelSurface,
2398 surfaces: rightPanelState.surfaces,
2399 }),
2400 [activeRightPanelSurface, rightPanelState.surfaces],
2401 );
2402 const rightPanelPresence = usePanelPresence(
2403 rightPanelOpen && activeThreadRef !== null,
2404 rightPanelPresenceValue,
2405 panelAnimationsActive,
2406 activeThreadKey,
2407 panelAnimationDurationMs,
2408 );
2409 const rightPanelPresent = rightPanelPresence.present;
2410 const rightPanelControlsInPanel =
2411 shouldUsePlanSidebarSheet && rightPanelPresent && rightPanelOpen;
2412 const rightPanelControlsAtRoot = rightPanelPresent && !shouldUsePlanSidebarSheet;
2413 const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null;
2414 const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? [];
2415 const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer(
2416 activePreviewMiniPlayer?.source ?? null,
2417 renderedRightPanelSurface,
2418 );
2419 const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet;
2420 const rightPanelMaximized = canMaximizeRightPanel && rightPanelState.maximized === true;
2421 const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet;
2422 const [threadPanelPresentation, setThreadPanelPresentation] =
2423 useState<ThreadPanelPresentation>("inline");
2424 const [threadPanelPopoverHandle] = useState(PopoverCreateHandle);
2425 const threadPanelOpen = useRightPanelStore((state) =>
2426 selectThreadPanelOpen(
2427 state.threadPanelVisibilityByThreadKey,
2428 activeThreadRef,
2429 threadPanelPresentation,
2430 ),
2431 );
2432
2433 useEffect(() => {
2434 if (!activeThreadRef || !previewSessionsReady) return;
2435 const hiddenTabIds = new Set(
2436 Object.values(activePreviewState.sessions)
2437 .filter((session) => session.runtime === "server" && session.reveal === false)
2438 .map((session) => session.tabId),
2439 );
2440 useRightPanelStore
2441 .getState()
2442 .reconcileBrowserSurfaces(
2443 activeThreadRef,
2444 Object.keys(activePreviewState.sessions),
2445 hiddenTabIds,
2446 );
2447 }, [activePreviewState.sessions, activeThreadRef, previewSessionsReady]);
2448
2449 useEffect(() => {
2450 const source = activePreviewMiniPlayer?.source;
2451 if (
2452 activeThreadRef &&
2453 previewSessionsReady &&
2454 source?.kind === "browser" &&
2455 !activePreviewState.sessions[source.tabId]
2456 ) {
2457 usePreviewMiniPlayerStore.getState().close(activeThreadRef);
2458 }
2459 }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef, previewSessionsReady]);
2460
2461 const existingOpenTerminalThreadKeys = useMemo(() => {
2462 const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]);
2463 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
2464 }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);
2465 useEffect(() => {
2466 setMountedTerminalThreadKeys((currentThreadIds) => {
2467 const nextThreadIds = reconcileMountedTerminalThreadIds({
2468 currentThreadIds,
2469 openThreadIds: existingOpenTerminalThreadKeys,
2470 activeThreadId: activeThreadKey,
2471 activeThreadTerminalOpen: activeTerminalDrawerPresence.present,
2472 maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
2473 });
2474 return currentThreadIds.length === nextThreadIds.length &&
2475 currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index])
2476 ? currentThreadIds
2477 : nextThreadIds;
2478 });
2479 }, [activeTerminalDrawerPresence.present, activeThreadKey, existingOpenTerminalThreadKeys]);
2480 const latestRunSettled = isLatestRunSettled(activeLatestRun, activeRuntime);
2481 const activePlan = useMemo(
2482 () => deriveActivePlanState(serverProjection, activeActivityRun?.runId),
2483 [activeActivityRun?.runId, serverProjection],
2484 );
2485 // Tasks progress for the running turn's own plan only — deriveActivePlanState
2486 // falls back to older runs' plans, which must not label fresh work.
2487 const activeComposerTasksProgress = useMemo(() => {
2488 if (
2489 isLatestRunSettled(activeActivityRun, activeRuntime) ||
2490 !activePlan ||
2491 activePlan.runId !== (activeActivityRun?.runId ?? null)
2492 ) {
2493 return null;
2494 }
2495 const totalSteps = activePlan.steps.length;
2496 if (totalSteps === 0) return null;
2497 const completedSteps = activePlan.steps.filter((step) => step.status === "completed").length;
2498 const step =
2499 activePlan.steps.find((candidate) => candidate.status === "inProgress")?.step ??
2500 activePlan.steps.find((candidate) => candidate.status === "pending")?.step ??
2501 activePlan.steps.at(-1)!.step;
2502 return { step, completedSteps, totalSteps };
2503 }, [activeActivityRun, activePlan, activeRuntime]);
2504 const activeComposerTaskSteps =
2505 activeComposerTasksProgress && activePlan ? activePlan.steps : null;
2506 const activeProjectRef = useMemo(
2507 () =>
2508 activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null,
2509 [activeThread?.environmentId, activeThread?.projectId],
2510 );
2511 const activeProject = useProject(activeProjectRef);
2512 // Environment settings with the active project's overrides applied.
2513 const activeProjectSettings = useMemo(
2514 () => resolveProjectSettings(settings, activeProject?.id ?? null, activeProject ?? undefined),
2515 [activeProject, settings],
2516 );
2517 const activeProjectScripts = useMemo(
2518 () => (activeProject ? resolveProjectScripts(settings, activeProject) : []),
2519 [activeProject, settings],
2520 );
2521 // A project added by cloning exists before its files do. The draft stays
2522 // editable throughout; only sending waits for the clone, and a failed
2523 // clone offers its retry right where the user is looking.
2524 const activeProjectClone = useProjectClone(activeProjectRef);
2525 const cancelProjectClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, {
2526 reportFailure: false,
2527 });
2528 const retryProjectClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, {
2529 reportFailure: false,
2530 });
2531 const removeClonedProject = useRemoveClonedProject();
2532 // The banner mirrors the server's clone state, so a request that never got
2533 // there needs its own feedback.
2534 const runProjectCloneAction = useCallback(
2535 async (
2536 title: string,
2537 action: () => Promise<AtomCommandResult<unknown, unknown>>,
2538 ): Promise<void> => {
2539 const result = await action();
2540 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2541 const error = squashAtomCommandFailure(result);
2542 toastManager.add(
2543 stackedThreadToast({
2544 type: "error",
2545 title,
2546 description: error instanceof Error ? error.message : "An error occurred.",
2547 }),
2548 );
2549 }
2550 },
2551 [],
2552 );
2553 const projectCloneSendBlockReason =
2554 activeProjectClone === null
2555 ? null
2556 : activeProjectClone.phase === "running"
2557 ? "Cloning repository"
2558 : activeProjectClone.phase === "done"
2559 ? null
2560 : "Repository not cloned";
2561 const projectCloneBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
2562 if (!activeProjectClone || !activeProjectRef || activeProjectClone.phase === "done") {
2563 return null;
2564 }
2565 const name = projectCloneDisplayName(activeProjectClone);
2566 const { environmentId, projectId } = activeProjectRef;
2567 if (activeProjectClone.phase === "running") {
2568 return {
2569 id: `project-clone:${projectId}`,
2570 variant: "info",
2571 priority: "activity",
2572 icon: <DownloadIcon />,
2573 title: `Cloning ${name}`,
2574 description: projectCloneProgressSummary(activeProjectClone),
2575 actions: (
2576 <Button
2577 size="xs"
2578 variant="ghost"
2579 onClick={() =>
2580 void runProjectCloneAction("Failed to cancel clone", () =>
2581 cancelProjectClone({ environmentId, input: { projectId } }),
2582 )
2583 }
2584 >
2585 Cancel
2586 </Button>
2587 ),
2588 };
2589 }
2590 const cancelled = activeProjectClone.phase === "cancelled";
2591 return {
2592 id: `project-clone:${projectId}`,
2593 variant: cancelled ? "warning" : "error",
2594 icon: <DownloadIcon />,
2595 title: cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`,
2596 description: cancelled ? "Retry to bring in the repository." : activeProjectClone.error,
2597 actions: (
2598 <>
2599 <Button
2600 size="xs"
2601 variant="ghost"
2602 onClick={() => void removeClonedProject({ environmentId, projectId })}
2603 >
2604 Remove project
2605 </Button>
2606 <Button
2607 size="xs"
2608 variant="ghost"
2609 onClick={() =>
2610 void runProjectCloneAction("Failed to retry clone", () =>
2611 retryProjectClone({ environmentId, input: { projectId } }),
2612 )
2613 }
2614 >
2615 Retry
2616 </Button>
2617 </>
2618 ),
2619 };
2620 }, [
2621 activeProjectClone,
2622 activeProjectRef,
2623 cancelProjectClone,
2624 removeClonedProject,
2625 retryProjectClone,
2626 runProjectCloneAction,
2627 ]);
2628 const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection;
2629 const handleNewThreadInActiveProject = useCallback(() => {
2630 startNewThreadForProject(activeProjectRef, handleNewThread);
2631 }, [activeProjectRef, handleNewThread]);
2632 const projectGroupingSettings = selectProjectGroupingSettings(settings);
2633 const activeDraftLogicalProjectKey =
2634 !isServerThread && activeProject
2635 ? deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings)
2636 : undefined;
2637 const handleOpenDraftProjectSettings = useCallback(() => {
2638 if (!activeDraftLogicalProjectKey) return;
2639 void navigate({
2640 to: "/projects/$projectKey",
2641 params: { projectKey: activeDraftLogicalProjectKey },
2642 });
2643 }, [activeDraftLogicalProjectKey, navigate]);
2644 const activeEnvironmentShell = useEnvironmentQuery(
2645 activeThread ? environmentShell.stateAtom(activeThread.environmentId) : null,
2646 );
2647 const activeEnvironmentBootstrapComplete = activeEnvironmentShell.data?.snapshot._tag === "Some";
2648 const activeProjectKey = activeProject
2649 ? `${activeProject.environmentId}:${activeProject.workspaceRoot}`
2650 : null;
2651 const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState<
2652 ReadonlyMap<string, ReadonlySet<string>>
2653 >(() => new Map());
2654 const pendingFileSurfaceIds = activeProjectKey
2655 ? (pendingFileSurfaceIdsByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS)
2656 : EMPTY_PENDING_FILE_SURFACE_IDS;
2657 const handleFilePendingChange = useCallback(
2658 (relativePath: string, pending: boolean) => {
2659 if (!activeProjectKey) return;
2660 setPendingFileSurfaceIdsByProject((currentByProject) => {
2661 const current = currentByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS;
2662 const surfaceId = `file:${relativePath}`;
2663 if (current.has(surfaceId) === pending) return currentByProject;
2664 const next = new Set(current);
2665 if (pending) next.add(surfaceId);
2666 else next.delete(surfaceId);
2667 const nextByProject = new Map(currentByProject);
2668 if (next.size === 0) nextByProject.delete(activeProjectKey);
2669 else nextByProject.set(activeProjectKey, next);
2670 return nextByProject;
2671 });
2672 },
2673 [activeProjectKey],
2674 );
2675 const configuredPreviewUrls = useMemo(
2676 () => getConfiguredPreviewUrls(activeProjectScripts),
2677 [activeProjectScripts],
2678 );
2679
2680 useEffect(() => {
2681 if (!activeThreadRef || !activeEnvironmentBootstrapComplete) return;
2682 useRightPanelStore.getState().reconcileFileSurfaces(activeThreadRef, activeProject !== null);
2683 }, [activeEnvironmentBootstrapComplete, activeProject, activeThreadRef]);
2684
2685 // Compute the list of environments this logical project spans, used to
2686 // drive the environment picker in BranchToolbar.
2687 const allProjects = useProjects();
2688 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
2689 useEffect(() => {
2690 if (!activeThreadRef || !activeProjectRef) return;
2691 registerFaviconProjectForThread(activeThreadRef, activeProjectRef);
2692 }, [activeProjectRef, activeThreadRef]);
2693 const activeEnvironment =
2694 activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null);
2695 const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available";
2696 const activeEnvironmentUnavailable =
2697 activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected";
2698 const activeReconnectingEnvironmentId =
2699 activeEnvironmentConnectionPhase === "connecting" ||
2700 activeEnvironmentConnectionPhase === "reconnecting"
2701 ? (activeEnvironment?.environmentId ?? null)
2702 : null;
2703 const [reconnectWarningGraceElapsedEnvironmentId, setReconnectWarningGraceElapsedEnvironmentId] =
2704 useState<EnvironmentId | null>(null);
2705 const reconnectWarningGraceElapsed = hasEnvironmentReconnectWarningGraceElapsed(
2706 activeReconnectingEnvironmentId,
2707 reconnectWarningGraceElapsedEnvironmentId,
2708 );
2709 useEffect(() => {
2710 setReconnectWarningGraceElapsedEnvironmentId(null);
2711 if (activeReconnectingEnvironmentId === null) return;
2712 return scheduleEnvironmentReconnectWarning(() =>
2713 setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId),
2714 );
2715 }, [activeReconnectingEnvironmentId]);
2716 const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null;
2717 const activeEnvironmentUnavailableState = useMemo<EnvironmentUnavailableState | null>(() => {
2718 if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) {
2719 return null;
2720 }
2721
2722 return {
2723 environmentId: activeEnvironment.environmentId,
2724 label: activeEnvironmentUnavailableLabel,
2725 connection: activeEnvironment.connection,
2726 };
2727 }, [activeEnvironment, activeEnvironmentUnavailable, activeEnvironmentUnavailableLabel]);
2728 const handleReconnectActiveEnvironment = useCallback(
2729 async (environmentId: EnvironmentId) => {
2730 const result = await retryEnvironment(environmentId);
2731 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2732 const error = squashAtomCommandFailure(result);
2733 toastManager.add(
2734 stackedThreadToast({
2735 type: "error",
2736 title: "Could not reconnect environment",
2737 description: error instanceof Error ? error.message : "Failed to reconnect.",
2738 }),
2739 );
2740 }
2741 },
2742 [retryEnvironment],
2743 );
2744 const disconnectDelayElapsed = useEnvironmentDisconnectDelay(
2745 activeEnvironmentUnavailable ? activeEnvironment.environmentId : null,
2746 );
2747 const canDisconnectActiveEnvironment =
2748 disconnectDelayElapsed &&
2749 activeEnvironment !== null &&
2750 activeEnvironment.entry.target._tag !== "PrimaryConnectionTarget" &&
2751 !isDesktopLocalConnectionTarget(activeEnvironment.entry.target);
2752 const [disconnectingEnvironment, setDisconnectingEnvironment] = useState(false);
2753 const handleDisconnectActiveEnvironment = useCallback(
2754 async (environmentId: EnvironmentId) => {
2755 setDisconnectingEnvironment(true);
2756 const result = await setEnvironmentEnabled({ environmentId, enabled: false });
2757 setDisconnectingEnvironment(false);
2758 if (result._tag === "Failure") {
2759 if (!isAtomCommandInterrupted(result)) {
2760 const error = squashAtomCommandFailure(result);
2761 toastManager.add(
2762 stackedThreadToast({
2763 type: "error",
2764 title: "Could not disconnect server",
2765 description: error instanceof Error ? error.message : "Failed to disconnect.",
2766 }),
2767 );
2768 }
2769 return;
2770 }
2771 void navigate({ to: "/", replace: true });
2772 },
2773 [navigate, setEnvironmentEnabled],
2774 );
2775 const { scratchWorkspaceRootFor, openScratchProject } = useScratchProject();
2776 const activeProjectIsScratch =
2777 activeProject !== null &&
2778 isScratchProject(
2779 activeProject,
2780 environmentById.get(activeProject.environmentId)?.serverConfig?.scratchWorkspaceRoot ?? null,
2781 );
2782 const logicalProjectEnvironments = useMemo(() => {
2783 if (!activeProject) return [];
2784 const envs: EnvironmentOption[] = [];
2785 const pushEnvironment = (environmentId: EnvironmentId, projectId: ProjectId | null) => {
2786 const environment = environmentById.get(environmentId) ?? null;
2787 envs.push({
2788 environmentId,
2789 projectId,
2790 label: environment?.label ?? environmentId,
2791 isPrimary: environmentId === primaryEnvironmentId,
2792 machine: resolveEnvironmentMachineKind(environment?.serverConfig ?? null),
2793 });
2794 };
2795 if (activeProjectIsScratch && draftId) {
2796 // Each machine keeps its own "No project" folder at its own path, so they
2797 // never group as one logical project. Offer every machine that has one.
2798 for (const environment of environments) {
2799 const scratchRoot = scratchWorkspaceRootFor(environment.environmentId);
2800 // Keep the current machine visible so an offline source can still switch away.
2801 if (scratchRoot === null && environment.environmentId !== activeProject.environmentId)
2802 continue;
2803 const scratchProject =
2804 environment.environmentId === activeProject.environmentId
2805 ? activeProject
2806 : allProjects.find(
2807 (p) =>
2808 p.environmentId === environment.environmentId && isScratchProject(p, scratchRoot),
2809 );
2810 pushEnvironment(environment.environmentId, scratchProject?.id ?? null);
2811 }
2812 } else {
2813 const logicalKey = deriveLogicalProjectKeyFromSettings(
2814 activeProject,
2815 projectGroupingSettings,
2816 );
2817 const seen = new Set<string>();
2818 for (const p of allProjects) {
2819 if (seen.has(p.environmentId)) continue;
2820 if (deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) !== logicalKey)
2821 continue;
2822 seen.add(p.environmentId);
2823 pushEnvironment(p.environmentId, p.id);
2824 }
2825 }
2826 // Sort: primary first, then alphabetical
2827 envs.sort((a, b) => {
2828 if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1;
2829 return a.label.localeCompare(b.label);
2830 });
2831 return envs;
2832 }, [
2833 activeProject,
2834 activeProjectIsScratch,
2835 allProjects,
2836 draftId,
2837 environments,
2838 projectGroupingSettings,
2839 primaryEnvironmentId,
2840 environmentById,
2841 scratchWorkspaceRootFor,
2842 ]);
2843 const hasMultipleEnvironments = logicalProjectEnvironments.length > 1;
2844 // Auto balance retargets to an existing project; a machine's "No project"
2845 // folder may not exist until it is picked.
2846 const canAutoBalanceEnvironments = hasMultipleEnvironments && !activeProjectIsScratch;
2847 const activeEnvironmentOption =
2848 logicalProjectEnvironments.find(
2849 (environment) => environment.environmentId === activeThread?.environmentId,
2850 ) ?? null;
2851 const showComposerEnvironmentIndicator = shouldShowEnvironmentIndicator({
2852 activeEnvironment: activeEnvironmentOption,
2853 canPickEnvironment: hasMultipleEnvironments,
2854 });
2855 const openPullRequestDialog = useCallback(
2856 (reference?: string) => {
2857 if (!canCheckoutPullRequestIntoThread) {
2858 return;
2859 }
2860 setPullRequestDialogState({
2861 initialReference: reference ?? null,
2862 key: Date.now(),
2863 });
2864 },
2865 [canCheckoutPullRequestIntoThread],
2866 );
2867
2868 const closePullRequestDialog = useCallback(() => {
2869 setPullRequestDialogState(null);
2870 }, []);
2871
2872 const openOrReuseProjectDraftThread = useCallback(
2873 async (input: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => {
2874 if (!activeProject) {
2875 throw new Error("No active project is available for this pull request.");
2876 }
2877 const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id);
2878 const logicalProjectKey = deriveLogicalProjectKeyFromSettings(
2879 activeProject,
2880 projectGroupingSettings,
2881 );
2882 const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey);
2883 if (storedDraftSession) {
2884 setDraftThreadContext(storedDraftSession.draftId, input);
2885 setLogicalProjectDraftThreadId(
2886 logicalProjectKey,
2887 activeProjectRef,
2888 storedDraftSession.draftId,
2889 {
2890 threadId: storedDraftSession.threadId,
2891 ...input,
2892 },
2893 );
2894 if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) {
2895 await navigate({
2896 to: "/draft/$draftId",
2897 params: buildDraftThreadRouteParams(storedDraftSession.draftId),
2898 });
2899 }
2900 return storedDraftSession.threadId;
2901 }
2902
2903 const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null;
2904 if (
2905 !isServerThread &&
2906 activeDraftSession?.logicalProjectKey === logicalProjectKey &&
2907 draftId
2908 ) {
2909 setDraftThreadContext(draftId, input);
2910 setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, draftId, {
2911 threadId: activeDraftSession.threadId,
2912 createdAt: activeDraftSession.createdAt,
2913 runtimeMode: activeDraftSession.runtimeMode,
2914 interactionMode: activeDraftSession.interactionMode,
2915 ...input,
2916 });
2917 return activeDraftSession.threadId;
2918 }
2919
2920 const nextDraftId = newDraftId();
2921 const nextThreadId = newThreadId();
2922 setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, {
2923 threadId: nextThreadId,
2924 createdAt: new Date().toISOString(),
2925 runtimeMode: resolveProjectSettings(settings, activeProject.id, activeProject).settings
2926 .defaultRuntimeMode,
2927 interactionMode: DEFAULT_INTERACTION_MODE,
2928 ...input,
2929 });
2930 await navigate({
2931 to: "/draft/$draftId",
2932 params: buildDraftThreadRouteParams(nextDraftId),
2933 });
2934 return nextThreadId;
2935 },
2936 [
2937 activeProject,
2938 draftId,
2939 getDraftSession,
2940 getDraftSessionByLogicalProjectKey,
2941 isServerThread,
2942 navigate,
2943 projectGroupingSettings,
2944 routeKind,
2945 settings,
2946 setDraftThreadContext,
2947 setLogicalProjectDraftThreadId,
2948 ],
2949 );
2950
2951 const handlePreparedPullRequestThread = useCallback(
2952 async (input: { branch: string; worktreePath: string | null }) => {
2953 await openOrReuseProjectDraftThread({
2954 branch: input.branch,
2955 worktreePath: input.worktreePath,
2956 envMode: input.worktreePath ? "worktree" : "local",
2957 });
2958 },
2959 [openOrReuseProjectDraftThread],
2960 );
2961
2962 useEffect(() => {
2963 if (!serverThread?.id) return;
2964 const threadUpdatedAt = Date.parse(serverThread.updatedAt);
2965 if (Number.isNaN(threadUpdatedAt)) return;
2966 const effectiveLastVisitedAt = resolveThreadLastVisitedAt(
2967 serverThread.lastVisitedAt,
2968 activeThreadLocalLastVisitedAt,
2969 );
2970 const lastVisitedAt = effectiveLastVisitedAt ? Date.parse(effectiveLastVisitedAt) : NaN;
2971 if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return;
2972
2973 if (serverThread.lastVisitedAt !== undefined) {
2974 // Server-tracked visited state: record the watermark server-side so it
2975 // syncs across every device connected to the environment. Dedupe per
2976 // watermark — the effect re-runs before the command's echo lands. The
2977 // dedupe also keeps a mark-unread on the open thread sticky: the rewind
2978 // leaves updatedAt untouched, so the already-dispatched key skips a
2979 // fresh visit until new activity lands or the thread is reopened.
2980 const dispatchKey = `${routeThreadKey}:${serverThread.updatedAt}`;
2981 if (lastDispatchedVisitRef.current === dispatchKey) return;
2982 const dispatch = () => {
2983 lastDispatchedVisitRef.current = dispatchKey;
2984 lastVisitDispatchAtRef.current = Date.now();
2985 void visitThreadMutation({
2986 environmentId: serverThread.environmentId,
2987 input: { threadId: serverThread.id, visitedAt: serverThread.updatedAt },
2988 });
2989 };
2990 // Unread prominence only flips on run completions (hasUnseenCompletion),
2991 // so an unseen completion publishes immediately; mid-turn activity bumps
2992 // — several per second while a turn streams — ride a trailing throttle,
2993 // each rerun swapping the timer so the trailing dispatch carries the
2994 // newest watermark.
2995 const latestRunCompletedAtMs = serverThread.latestRun?.completedAt
2996 ? Date.parse(serverThread.latestRun.completedAt)
2997 : NaN;
2998 const hasUnseenCompletion =
2999 !Number.isNaN(latestRunCompletedAtMs) &&
3000 (Number.isNaN(lastVisitedAt) || latestRunCompletedAtMs > lastVisitedAt);
3001 const elapsed = Date.now() - lastVisitDispatchAtRef.current;
3002 if (hasUnseenCompletion || elapsed >= VISIT_DISPATCH_THROTTLE_MS) {
3003 dispatch();
3004 return;
3005 }
3006 const timer = setTimeout(dispatch, VISIT_DISPATCH_THROTTLE_MS - elapsed);
3007 return () => clearTimeout(timer);
3008 }
3009
3010 markThreadVisited(
3011 scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)),
3012 serverThread.updatedAt,
3013 );
3014 }, [
3015 activeThreadLocalLastVisitedAt,
3016 markThreadVisited,
3017 routeThreadKey,
3018 serverThread?.environmentId,
3019 serverThread?.id,
3020 serverThread?.lastVisitedAt,
3021 serverThread?.latestRun?.completedAt,
3022 serverThread?.updatedAt,
3023 visitThreadMutation,
3024 ]);
3025
3026 // Once a thread selects an environment, never substitute the primary
3027 // environment's config while the selected environment is still loading.
3028 const serverConfig = activeThread
3029 ? (activeEnvironment?.serverConfig ?? null)
3030 : (primaryEnvironment?.serverConfig ?? null);
3031 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
3032 const selectedProviderByThreadId = composerActiveProvider ?? null;
3033 const threadProvider =
3034 activeThread?.modelSelection.instanceId ??
3035 activeProjectDefaultModelSelection?.instanceId ??
3036 null;
3037 const lockedProvider = deriveLockedProvider({
3038 thread: activeThread,
3039 selectedProvider: selectedProviderByThreadId,
3040 threadProvider,
3041 providers: providerStatuses,
3042 });
3043 const modelPickerLockedProvider = supportsProviderSwitchingViaHandoff ? null : lockedProvider;
3044 const pullRequestsCapabilityKnown = serverConfig !== null;
3045 const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true;
3046 const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null;
3047 const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null;
3048 const supportsQuestionAttachments =
3049 attachmentEnvironmentConfig?.environment.capabilities.questionAttachments === true;
3050 const supportsAttachmentUploads =
3051 attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true;
3052 const advertisedFileAttachmentBytes =
3053 attachmentEnvironmentConfig?.environment.capabilities.fileAttachments?.maxUploadBytes ?? null;
3054 const maxFileAttachmentBytes =
3055 advertisedFileAttachmentBytes === null
3056 ? null
3057 : clampFileAttachmentUploadBytes(advertisedFileAttachmentBytes);
3058 const envLocked = Boolean(activeThread && (activeMessageCount > 0 || activeRuntime !== null));
3059
3060 const loadBalancingSettings = useClientSettings();
3061 const automaticEnvironment = Boolean(
3062 clientSettingsHydrated &&
3063 draftId &&
3064 !envLocked &&
3065 canAutoBalanceEnvironments &&
3066 loadBalancingSettings.loadBalancingEnabled &&
3067 draftThread?.environmentSelection !== "manual" &&
3068 (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) &&
3069 (!draftThread?.branch || draftThread.environmentSelection === "auto") &&
3070 !draftThread?.worktreePath,
3071 );
3072 const autoUpdateEnvironments = useMemo(
3073 () =>
3074 automaticEnvironment
3075 ? logicalProjectEnvironments.flatMap(({ environmentId }) => {
3076 const environment = environmentById.get(environmentId);
3077 return environment ? [environment] : [];
3078 })
3079 : [],
3080 [automaticEnvironment, logicalProjectEnvironments, environmentById],
3081 );
3082 const autoBalanceUpdateBanner = useAutoBalanceUpdateBanner(autoUpdateEnvironments);
3083 const versionMismatch = resolveServerConfigVersionMismatch(serverConfig);
3084 const versionMismatchDismissKey =
3085 versionMismatch && activeThread
3086 ? buildVersionMismatchDismissalKey(activeThread.environmentId, versionMismatch)
3087 : null;
3088 const [dismissedVersionMismatchKey, setDismissedVersionMismatchKey] = useState<string | null>(
3089 null,
3090 );
3091 const versionMismatchDismissed =
3092 versionMismatchDismissKey === dismissedVersionMismatchKey ||
3093 isVersionMismatchDismissed(versionMismatchDismissKey);
3094 const showVersionMismatchBanner =
3095 versionMismatch !== null && versionMismatchDismissKey !== null && !versionMismatchDismissed;
3096 const hasMultipleRegisteredEnvironments = environments.length > 1;
3097 const versionMismatchServerLabel =
3098 hasMultipleRegisteredEnvironments && activeThread
3099 ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server`
3100 : "server";
3101 const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;
3102 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
3103 const versionMismatchInstallation = serverConfig?.environment.capabilities.serverInstallation;
3104 const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig);
3105 const versionMismatchThreadContinuation = supportsServerUpdateThreadContinuation(serverConfig);
3106 const serverUpdateState = useAtomValue(
3107 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),
3108 );
3109 const [dismissedServerUpdateState, setDismissedServerUpdateState] = useState<
3110 typeof serverUpdateState | null
3111 >(null);
3112 const serverUpdateFailureDismissed =
3113 serverUpdateState === dismissedServerUpdateState ||
3114 isServerUpdateFailureDismissed(serverUpdateState);
3115 const systemComposerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
3116 const items: ComposerBannerStackItem[] = [];
3117 const updateRunning = serverUpdateState.status === "running";
3118 const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null;
3119 const disconnectAction =
3120 canDisconnectActiveEnvironment && activeEnvironmentUnavailableState ? (
3121 <Button
3122 size="xs"
3123 variant="ghost"
3124 disabled={disconnectingEnvironment}
3125 title="Hide this server's threads. Switch it on again in Connections."
3126 onClick={() =>
3127 void handleDisconnectActiveEnvironment(activeEnvironmentUnavailableState.environmentId)
3128 }
3129 >
3130 Disconnect server
3131 </Button>
3132 ) : undefined;
3133 const environmentReconnecting =
3134 unavailableConnection !== null &&
3135 (unavailableConnection.phase === "connecting" ||
3136 unavailableConnection.phase === "reconnecting");
3137 // While an update runs, transient connect blips are expected (the server
3138 // restarts) and the update banner already shows progress. Hard failure
3139 // phases still surface so the Reconnect action stays reachable.
3140 const suppressUnavailableBanner =
3141 environmentReconnecting && (updateRunning || !reconnectWarningGraceElapsed);
3142 if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) {
3143 items.push({
3144 id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
3145 variant: unavailableConnection.phase === "error" ? "error" : "warning",
3146 icon: <WifiOffIcon />,
3147 title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`,
3148 actions: (
3149 <>
3150 {!environmentReconnecting ? (
3151 <Button
3152 size="xs"
3153 variant="ghost"
3154 onClick={() =>
3155 void handleReconnectActiveEnvironment(
3156 activeEnvironmentUnavailableState.environmentId,
3157 )
3158 }
3159 >
3160 Reconnect
3161 </Button>
3162 ) : null}
3163 {disconnectAction}
3164 </>
3165 ),
3166 });
3167 }
3168 if (
3169 !automaticEnvironment &&
3170 serverUpdateEnvironmentId &&
3171 (serverUpdateState.status === "idle"
3172 ? showVersionMismatchBanner
3173 : !serverUpdateFailureDismissed)
3174 ) {
3175 const updateInProgress = serverUpdateState.status === "running";
3176 const updateFailed = serverUpdateState.status === "failed";
3177 items.push({
3178 id: `server-version:${serverUpdateEnvironmentId}`,
3179 variant: updateFailed ? "error" : "default",
3180 // Prioritize update progress over passive notices, but keep activity attached.
3181 priority: updateInProgress ? "urgent" : "notice",
3182 icon: <ComposerServerUpdateIcon status={serverUpdateState.status} />,
3183 title:
3184 updateInProgress || updateFailed ? (
3185 <ComposerServerUpdateStatus
3186 state={serverUpdateState}
3187 serverLabel={versionMismatchServerLabel}
3188 />
3189 ) : versionMismatch ? (
3190 <Tooltip>
3191 <TooltipTrigger
3192 render={
3193 <button
3194 type="button"
3195 className="block max-w-full cursor-help truncate rounded-sm text-left"
3196 >
3197 Server update available
3198 </button>
3199 }
3200 />
3201 <TooltipPopup side="top">
3202 {versionMismatchServerLabel} {versionMismatch.serverVersion}{" "}
3203 <span aria-hidden="true">→</span> {versionMismatch.clientVersion}
3204 </TooltipPopup>
3205 </Tooltip>
3206 ) : (
3207 "Server update available"
3208 ),
3209 description:
3210 !updateInProgress &&
3211 !updateFailed &&
3212 versionMismatchSelfUpdate !== null &&
3213 (versionMismatchSelfUpdate !== "desktop-managed" || !versionMismatchDesktopAppUpdate)
3214 ? serverUpdateGuidance(versionMismatchSelfUpdate)
3215 : undefined,
3216 actions: updateInProgress ? (
3217 disconnectAction
3218 ) : !versionMismatch ||
3219 (versionMismatchSelfUpdate === "desktop-managed" &&
3220 !versionMismatchDesktopAppUpdate) ? undefined : (
3221 <ServerUpdateAction
3222 environmentId={serverUpdateEnvironmentId}
3223 serverLabel={versionMismatchServerLabel}
3224 selfUpdate={versionMismatchSelfUpdate}
3225 installation={versionMismatchInstallation}
3226 desktopAppUpdate={versionMismatchDesktopAppUpdate}
3227 threadContinuation={versionMismatchThreadContinuation}
3228 targetVersion={versionMismatch.clientVersion}
3229 label={updateFailed ? "Retry" : "Update"}
3230 variant="ghost"
3231 />
3232 ),
3233 ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey)
3234 ? {}
3235 : {
3236 dismissLabel: "Dismiss update notice",
3237 onDismiss: () => {
3238 if (updateFailed) {
3239 dismissServerUpdateFailure(serverUpdateState);
3240 setDismissedServerUpdateState(serverUpdateState);
3241 }
3242 dismissVersionMismatch(versionMismatchDismissKey);
3243 setDismissedVersionMismatchKey(versionMismatchDismissKey);
3244 },
3245 }),
3246 });
3247 }
3248 if (autoBalanceUpdateBanner) items.push(autoBalanceUpdateBanner);
3249 return items;
3250 }, [
3251 automaticEnvironment,
3252 autoBalanceUpdateBanner,
3253 activeEnvironmentUnavailableState,
3254 handleReconnectActiveEnvironment,
3255 canDisconnectActiveEnvironment,
3256 disconnectingEnvironment,
3257 handleDisconnectActiveEnvironment,
3258 setDismissedVersionMismatchKey,
3259 showVersionMismatchBanner,
3260 reconnectWarningGraceElapsed,
3261 serverUpdateFailureDismissed,
3262 serverUpdateState,
3263 versionMismatch,
3264 versionMismatchDismissKey,
3265 serverUpdateEnvironmentId,
3266 versionMismatchSelfUpdate,
3267 versionMismatchInstallation,
3268 versionMismatchDesktopAppUpdate,
3269 versionMismatchThreadContinuation,
3270 versionMismatchServerLabel,
3271 ]);
3272 const providerInstanceEntries = useMemo(
3273 () =>
3274 sortProviderInstanceEntries(
3275 applyProviderInstanceSettings(
3276 deriveProviderInstanceEntries(providerStatuses),
3277 settings,
3278 activeProject?.id ?? null,
3279 ),
3280 ),
3281 [providerStatuses, settings, activeProject?.id],
3282 );
3283 const { selectedProviderEntry, requestedDriverKind } = useMemo(
3284 () =>
3285 resolveComposerProviderSelection({
3286 entries: providerInstanceEntries,
3287 candidateInstanceIds: [
3288 selectedProviderByThreadId,
3289 activeRuntime?.providerInstanceId,
3290 activeThread?.modelSelection.instanceId,
3291 activeProjectDefaultModelSelection?.instanceId,
3292 ],
3293 lockedProvider,
3294 lockedInstanceId:
3295 activeRuntime?.providerInstanceId ?? activeThread?.modelSelection.instanceId,
3296 }),
3297 [
3298 activeProjectDefaultModelSelection?.instanceId,
3299 activeThread?.modelSelection.instanceId,
3300 activeRuntime?.providerInstanceId,
3301 lockedProvider,
3302 providerInstanceEntries,
3303 selectedProviderByThreadId,
3304 ],
3305 );
3306 const selectedProvider = selectedProviderEntry?.driverKind ?? requestedDriverKind;
3307 const activeProviderInstanceId = selectedProviderEntry?.instanceId ?? null;
3308 const activeProviderStatus = selectedProviderEntry?.snapshot ?? null;
3309 const { enabled: interactionModeEnabled, interactionMode } = resolveComposerInteractionMode({
3310 planModeEnabled: settings.planModeEnabled,
3311 provider: activeProviderStatus,
3312 interactionMode:
3313 composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE,
3314 });
3315 const conversationProviderStatus =
3316 providerStatuses.find((status) => status.instanceId === activeRuntime?.providerInstanceId) ??
3317 activeProviderStatus;
3318 const supportsConversationRollback =
3319 canOperateThread &&
3320 conversationProviderStatus !== null &&
3321 conversationProviderStatus.supportsConversationRollback !== false;
3322 const phase = derivePhase(activeRuntime);
3323 const pendingRequests = useMemo(
3324 () =>
3325 serverProjection === null
3326 ? { approvals: [], userInputs: [] }
3327 : derivePendingThreadRequests(serverProjection),
3328 [serverProjection],
3329 );
3330 const pendingApprovals = useMemo(
3331 () => derivePendingApprovals(pendingRequests.approvals),
3332 [pendingRequests.approvals],
3333 );
3334 const pendingUserInputs = useMemo(
3335 () => derivePendingUserInputs(pendingRequests.userInputs),
3336 [pendingRequests.userInputs],
3337 );
3338 const activePendingUserInput = pendingUserInputs[0] ?? null;
3339 const activePendingRequestKey = JSON.stringify([
3340 environmentId,
3341 activeThreadId,
3342 activePendingUserInput?.requestId,
3343 ]);
3344 const activePendingAnswerDrafts =
3345 pendingUserInputAnswersByRequestId[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS;
3346 if (
3347 activePendingUserInput &&
3348 seedUserInputDraftAnswers(activePendingUserInput.questions, activePendingAnswerDrafts) !==
3349 activePendingAnswerDrafts
3350 ) {
3351 setPendingUserInputAnswersByRequestId((existing) => {
3352 const drafts = existing[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS;
3353 const seeded = seedUserInputDraftAnswers(activePendingUserInput.questions, drafts);
3354 return seeded === drafts ? existing : { ...existing, [activePendingRequestKey]: seeded };
3355 });
3356 }
3357 const pendingQuestionDraftKeys = useMemo(
3358 () =>
3359 activeThreadId
3360 ? pendingUserInputs.flatMap((request) =>
3361 request.questions.map((question) =>
3362 questionAttachmentDraftId(
3363 environmentId,
3364 activeThreadId,
3365 request.requestId,
3366 question.id,
3367 ),
3368 ),
3369 )
3370 : [],
3371 [activeThreadId, environmentId, pendingUserInputs],
3372 );
3373 const questionComposerDrafts = useComposerDraftStore(
3374 useShallow((state) =>
3375 Object.fromEntries(
3376 pendingQuestionDraftKeys.map((key) => [key, state.draftsByThreadKey[key]]),
3377 ),
3378 ),
3379 );
3380 const questionUploadsBlocked = useAttachmentUploadStore(
3381 useShallow((state) =>
3382 Object.fromEntries(
3383 pendingQuestionDraftKeys.map((key) => {
3384 const draft = questionComposerDrafts[key];
3385 const attachments = draft ? [...draft.images, ...draft.files] : [];
3386 return [
3387 key,
3388 attachments.some((attachment) => {
3389 const upload = state.uploadsByImageId[attachment.id];
3390 return upload?.status !== "ready" || upload.environmentId !== environmentId;
3391 }),
3392 ];
3393 }),
3394 ),
3395 ),
3396 );
3397 const questionPreparations = useQuestionAttachmentPreparation(
3398 useShallow((state) =>
3399 Object.fromEntries(pendingQuestionDraftKeys.map((key) => [key, state.counts[key] ?? 0])),
3400 ),
3401 );
3402 useEffect(() => {
3403 if (!activeThread) return;
3404 const questionThread = activeThread;
3405 const currentRequests = pendingUserInputs;
3406 const prefix = questionAttachmentDraftPrefix(environmentId, questionThread.id);
3407 const retained = new Set(
3408 currentRequests.flatMap((request) =>
3409 request.questions.map((question) =>
3410 questionAttachmentDraftId(
3411 environmentId,
3412 questionThread.id,
3413 request.requestId,
3414 question.id,
3415 ),
3416 ),
3417 ),
3418 );
3419 const keys = new Set([
3420 ...Object.keys(useComposerDraftStore.getState().draftsByThreadKey),
3421 ...Object.keys(useQuestionAttachmentPreparation.getState().counts),
3422 ]);
3423 for (const key of keys) {
3424 if (key.startsWith(prefix) && !retained.has(DraftId.make(key)))
3425 clearQuestionAttachmentDraft(DraftId.make(key));
3426 }
3427 }, [environmentId, activeThread, pendingUserInputs]);
3428 const activePendingDraftAnswers = useMemo(() => {
3429 if (!activePendingUserInput || !activeThreadId) return EMPTY_PENDING_USER_INPUT_ANSWERS;
3430 return Object.fromEntries(
3431 activePendingUserInput.questions.map((question) => {
3432 const key = questionAttachmentDraftId(
3433 environmentId,
3434 activeThreadId,
3435 activePendingUserInput.requestId,
3436 question.id,
3437 );
3438 const draft = questionComposerDrafts[key];
3439 const attachments = draft ? [...draft.images, ...draft.files] : [];
3440 return [
3441 question.id,
3442 {
3443 ...pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[question.id],
3444 attachmentCount: attachments.length,
3445 attachmentsBlocked:
3446 (attachments.length > 0 && !supportsQuestionAttachments) ||
3447 (questionPreparations[key] ?? 0) > 0 ||
3448 questionUploadsBlocked[key] === true,
3449 },
3450 ];
3451 }),
3452 );
3453 }, [
3454 activePendingUserInput,
3455 activeThreadId,
3456 environmentId,
3457 questionComposerDrafts,
3458 questionUploadsBlocked,
3459 supportsQuestionAttachments,
3460 questionPreparations,
3461 pendingUserInputAnswersByRequestId,
3462 activePendingRequestKey,
3463 ]);
3464 const activePendingQuestionIndex = activePendingUserInput
3465 ? (pendingUserInputQuestionIndexByRequestId[activePendingRequestKey] ?? 0)
3466 : 0;
3467 const activePendingProgress = useMemo(
3468 () =>
3469 activePendingUserInput
3470 ? derivePendingUserInputProgress(
3471 activePendingUserInput.questions,
3472 activePendingDraftAnswers,
3473 activePendingQuestionIndex,
3474 )
3475 : null,
3476 [activePendingDraftAnswers, activePendingQuestionIndex, activePendingUserInput],
3477 );
3478 const activePendingResolvedAnswers = useMemo(
3479 () =>
3480 activePendingUserInput
3481 ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingDraftAnswers)
3482 : null,
3483 [activePendingDraftAnswers, activePendingUserInput],
3484 );
3485 const activePendingIsResponding = activePendingUserInput
3486 ? activePendingUserInput.responseCapability === "not_resumable" ||
3487 respondingUserInputRequestIds.includes(activePendingUserInput.requestId)
3488 : false;
3489 const activeProposedPlan = useMemo(() => {
3490 if (!latestRunSettled) {
3491 return null;
3492 }
3493 return findLatestProposedPlan(serverProjection, activeLatestRun?.runId ?? null);
3494 }, [activeLatestRun?.runId, latestRunSettled, serverProjection]);
3495 const showPlanFollowUpPrompt = shouldShowPlanFollowUpPrompt({
3496 pendingUserInputCount: pendingUserInputs.length,
3497 interactionMode,
3498 latestTurnSettled: latestRunSettled,
3499 hasActionableProposedPlan: hasActionableProposedPlan(activeProposedPlan),
3500 hasComposerAttachments: composerHasAttachments,
3501 });
3502 const activePendingApproval = pendingApprovals[0] ?? null;
3503 // The open /usage-limits panel for this thread, model and turn. Only the open
3504 // moment is stored: the rows read live provider data, so a redeemed reset
3505 // credit or refreshed probe shows through. Anything that spends quota closes
3506 // it: a new turn from any source, or the agent resuming after an approval or
3507 // answered question.
3508 const [usageLimitsPanel, setUsageLimitsPanel] = useState<{
3509 readonly key: string;
3510 readonly threadKey: string;
3511 readonly now: number;
3512 } | null>(null);
3513 // Null while the provider list or the thread itself is unavailable, such as
3514 // during a reconnect; the panel then stays hidden rather than being dropped.
3515 // A pending approval or question is part of the key: once it is answered,
3516 // from this client or any other, the agent resumes and spends quota.
3517 const usageLimitsKey =
3518 activeProviderInstanceId === null || (isServerThread && activeThread === undefined)
3519 ? null
3520 : [
3521 routeThreadKey,
3522 activeProviderInstanceId,
3523 activeThread?.latestRun?.runId ?? "",
3524 activePendingApproval?.requestId ?? activePendingUserInput?.requestId ?? "",
3525 ].join(":");
3526 // Drop the snapshot as soon as the thread or model changes so it cannot resurface stale.
3527 if (
3528 usageLimitsPanel !== null &&
3529 usageLimitsKey !== null &&
3530 usageLimitsPanel.key !== usageLimitsKey
3531 ) {
3532 setUsageLimitsPanel(null);
3533 }
3534 const usageLimitSources = serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES;
3535 const usageLimitsReport = useMemo(
3536 () =>
3537 usageLimitsPanel !== null &&
3538 usageLimitsKey !== null &&
3539 usageLimitsPanel.key === usageLimitsKey &&
3540 activeProviderInstanceId !== null
3541 ? collectProviderUsageLimits(
3542 activeProviderInstanceId,
3543 providerStatuses,
3544 usageLimitSources,
3545 usageLimitsPanel.now,
3546 )
3547 : null,
3548 [
3549 activeProviderInstanceId,
3550 providerStatuses,
3551 usageLimitSources,
3552 usageLimitsKey,
3553 usageLimitsPanel,
3554 ],
3555 );
3556 const usageLimitsBanner = useMemo(
3557 () =>
3558 usageLimitsReport !== null && usageLimitsPanel !== null
3559 ? // A fresh id per opening: the stack keeps the last dismissed id as "exiting".
3560 usageLimitsBannerItem(
3561 `usage-limits:${usageLimitsPanel.key}:${usageLimitsPanel.now}`,
3562 usageLimitsReport,
3563 environmentId,
3564 () => setUsageLimitsPanel(null),
3565 )
3566 : null,
3567 [environmentId, usageLimitsPanel, usageLimitsReport],
3568 );
3569 // T3 owns /usage-limits only where Limits has data for the selected provider;
3570 // elsewhere the name stays the provider's own and is sent through untouched.
3571 const usageLimitsOffered =
3572 activeProviderStatus !== null &&
3573 hasProviderUsageLimits(activeProviderStatus.driver, providerStatuses, usageLimitSources);
3574 // Answered locally from the last Limits snapshot; the agent never sees it.
3575 const openUsageLimits = useCallback(() => {
3576 const now = Date.now();
3577 const report =
3578 activeProviderInstanceId !== null && usageLimitsKey !== null
3579 ? collectProviderUsageLimits(
3580 activeProviderInstanceId,
3581 providerStatuses,
3582 usageLimitSources,
3583 now,
3584 )
3585 : null;
3586 if (report && usageLimitsKey !== null) {
3587 setUsageLimitsPanel({ key: usageLimitsKey, threadKey: routeThreadKey, now });
3588 return true;
3589 }
3590 setUsageLimitsPanel(null);
3591 toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" });
3592 return false;
3593 }, [
3594 activeProviderInstanceId,
3595 providerStatuses,
3596 routeThreadKey,
3597 usageLimitSources,
3598 usageLimitsKey,
3599 ]);
3600 // Responses can resolve after navigating away; only the originating thread's panel clears.
3601 const clearUsageLimitsFor = useCallback(
3602 (threadKey: string) =>
3603 setUsageLimitsPanel((current) =>
3604 current !== null && current.threadKey === threadKey ? null : current,
3605 ),
3606 [],
3607 );
3608 const {
3609 beginLocalDispatch,
3610 resetLocalDispatch,
3611 localDispatchStartedAt,
3612 isPreparingWorktree: isLocallyPreparingWorktree,
3613 isSendBusy,
3614 backgroundSubmissionPending,
3615 } = useLocalDispatchState({
3616 activeThread,
3617 activeLatestRun,
3618 latestUserMessageId:
3619 serverProjection?.messages.findLast((message) => message.role === "user")?.id ?? null,
3620 phase,
3621 activePendingApproval: activePendingApproval?.requestId ?? null,
3622 activePendingUserInput: activePendingUserInput?.requestId ?? null,
3623 threadError,
3624 });
3625 const optimisticCompactionMessage = optimisticUserMessages.at(-1);
3626 const latestServerUserItem = serverVisibleTurnItems.findLast(
3627 (row) => row.item.type === "user_message",
3628 )?.item;
3629 const compactRequestIsActive =
3630 (isSendBusy &&
3631 optimisticCompactionMessage !== undefined &&
3632 isCompactCommandMessage(optimisticCompactionMessage)) ||
3633 (latestServerUserItem?.type === "user_message" &&
3634 latestServerUserItem.text.trim().toLowerCase() === "/compact" &&
3635 latestServerUserItem.attachments.length === 0 &&
3636 latestServerUserItem.runId === activeActivityRun?.runId &&
3637 !isLatestRunSettled(activeActivityRun, activeRuntime));
3638 const compactionSettled = serverVisibleTurnItems.some(
3639 ({ item }) =>
3640 item.type === "compaction" &&
3641 item.runId === activeActivityRun?.runId &&
3642 item.status === "completed",
3643 );
3644 const isCompacting =
3645 (isSendBusy || phase === "connecting" || phase === "running") &&
3646 compactRequestIsActive &&
3647 !compactionSettled;
3648 // A rewind is not agent work: the composer shows "Rewinding conversation"
3649 // instead of the timeline growing a Thinking row.
3650 const isWorking =
3651 phase === "running" ||
3652 isSendBusy ||
3653 isConnecting ||
3654 isCompacting ||
3655 runlessWorkStartedAt !== null;
3656 const activeContextWindow = useMemo(
3657 () =>
3658 deriveLatestContextWindowSnapshot(
3659 serverVisibleTurnItems ?? [],
3660 activeThreadLiveTokenUsage,
3661 serverProjection?.providerThreads.find(
3662 (thread) => thread.id === serverProjection.thread.activeProviderThreadId,
3663 ),
3664 ),
3665 [activeThreadLiveTokenUsage, serverVisibleTurnItems, serverProjection],
3666 );
3667 const pendingBackgroundTasks = useMemo(() => {
3668 if (serverProjection === null || serverProjection === undefined) {
3669 return [];
3670 }
3671 const sessionError =
3672 serverProjection.providerSessions.findLast(
3673 (session) => session.providerInstanceId === serverProjection.thread.providerInstanceId,
3674 )?.lastError ?? null;
3675 const latestRun =
3676 usageLimitRunPresentedAsLatest(
3677 serverProjection.runs,
3678 serverProjection.turnItems,
3679 sessionError,
3680 ) ?? latestUnheldRun(serverProjection.runs);
3681 return [
3682 ...derivePendingBackgroundWork({
3683 latestRun,
3684 providerThreads: serverProjection.providerThreads,
3685 turnItems: serverProjection.turnItems,
3686 activeProviderThreadId: serverProjection.thread.activeProviderThreadId,
3687 runs: serverProjection.runs,
3688 pullRequests: serverProjection.thread.pullRequests,
3689 }),
3690 ];
3691 }, [serverProjection]);
3692 const activeWorkStartedAt =
3693 deriveActiveWorkStartedAt(activeActivityRun, activeRuntime, localDispatchStartedAt) ??
3694 runlessWorkStartedAt;
3695 // Server-side workspace preparation: unlike the local-dispatch flag this
3696 // survives reloads and shows on remote viewers of the same thread.
3697 const activeRunPreparing = activeActivityRun?.status === "preparing";
3698 // The server queues a message behind a run that is still preparing its
3699 // worktree or starting its agent, so a send during setup waits there and
3700 // starts on its own instead of being refused.
3701 const sendQueuesBehindSetup =
3702 isServerThread && (activeRunPreparing || activeActivityRun?.status === "starting");
3703 // Such a send skips the local dispatch, so this keeps its thread's composer busy while it is
3704 // in flight.
3705 const [setupQueueSendThreadKey, setSetupQueueSendThreadKey] = useState<string | null>(null);
3706 useEffect(() => {
3707 attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
3708 }, [attachmentPreviewHandoffByMessageId]);
3709 const clearAttachmentPreviewHandoff = useCallback(
3710 (messageId: MessageId, previewUrls?: ReadonlyArray<string>) => {
3711 delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId];
3712 const currentPreviewUrls =
3713 previewUrls ?? attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? [];
3714 setAttachmentPreviewHandoffByMessageId((existing) => {
3715 if (!(messageId in existing)) {
3716 return existing;
3717 }
3718 const next = { ...existing };
3719 delete next[messageId];
3720 attachmentPreviewHandoffByMessageIdRef.current = next;
3721 return next;
3722 });
3723 for (const previewUrl of currentPreviewUrls) {
3724 revokeBlobPreviewUrl(previewUrl);
3725 }
3726 },
3727 [],
3728 );
3729 const clearAttachmentPreviewHandoffs = useCallback(() => {
3730 attachmentPreviewPromotionInFlightByMessageIdRef.current = {};
3731 for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) {
3732 for (const previewUrl of previewUrls) {
3733 revokeBlobPreviewUrl(previewUrl);
3734 }
3735 }
3736 attachmentPreviewHandoffByMessageIdRef.current = {};
3737 setAttachmentPreviewHandoffByMessageId({});
3738 }, []);
3739 useEffect(() => {
3740 return () => {
3741 clearAttachmentPreviewHandoffs();
3742 for (const message of optimisticUserMessagesRef.current) {
3743 revokeUserMessagePreviewUrls(message);
3744 }
3745 };
3746 }, [clearAttachmentPreviewHandoffs]);
3747 const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => {
3748 if (previewUrls.length === 0) return;
3749
3750 const previousPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? [];
3751 const nextPreviewUrlSet = new Set(previewUrls);
3752 for (const previewUrl of previousPreviewUrls) {
3753 if (!nextPreviewUrlSet.has(previewUrl)) {
3754 revokeBlobPreviewUrl(previewUrl);
3755 }
3756 }
3757 setAttachmentPreviewHandoffByMessageId((existing) => {
3758 const next = {
3759 ...existing,
3760 [messageId]: previewUrls,
3761 };
3762 attachmentPreviewHandoffByMessageIdRef.current = next;
3763 return next;
3764 });
3765 }, []);
3766 const downloadFileAttachment = useCallback(
3767 async (attachment: ChatFileAttachment) => {
3768 const connection = readPreparedConnection(environmentId);
3769 if (!connection) {
3770 toastManager.add({ type: "error", title: "The environment is not connected." });
3771 return;
3772 }
3773
3774 try {
3775 const url = await resolveFileAttachmentUrl({
3776 attachment,
3777 environmentId,
3778 httpBaseUrl: connection.httpBaseUrl,
3779 createAssetUrl: createAttachmentAssetUrl,
3780 });
3781 const anchor = document.createElement("a");
3782 anchor.href = url;
3783 anchor.download = attachment.name;
3784 anchor.click();
3785 } catch (error) {
3786 toastManager.add({
3787 type: "error",
3788 title: "Could not download " + attachment.name,
3789 description: error instanceof Error ? error.message : "The attachment is unavailable.",
3790 });
3791 }
3792 },
3793 [createAttachmentAssetUrl, environmentId],
3794 );
3795 const openFileAttachment = useCallback(
3796 (attachment: ChatFileAttachment) => {
3797 if (activeThreadRef) {
3798 useRightPanelStore.getState().openAttachment(activeThreadRef, attachment);
3799 return;
3800 }
3801 },
3802 [activeThreadRef],
3803 );
3804 const serverAttachmentResources = useMemo(
3805 () =>
3806 selectHandoffImageResources(
3807 Object.keys(attachmentPreviewHandoffByMessageId).length === 0
3808 ? undefined
3809 : serverVisibleTurnItems.flatMap(({ item }) =>
3810 item.type === "user_message"
3811 ? [{ id: item.messageId, role: "user" as const, attachments: item.attachments }]
3812 : [],
3813 ),
3814 attachmentPreviewHandoffByMessageId,
3815 ),
3816 [serverVisibleTurnItems, attachmentPreviewHandoffByMessageId],
3817 );
3818 const serverAttachmentUrls = useAssetUrls(environmentId, serverAttachmentResources);
3819 const serverAttachmentUrlById = useMemo(
3820 () =>
3821 new Map(
3822 serverAttachmentResources.flatMap((resource, index) => {
3823 const url = serverAttachmentUrls[index];
3824 return url ? [[resource.attachmentId, url] as const] : [];
3825 }),
3826 ),
3827 [serverAttachmentResources, serverAttachmentUrls],
3828 );
3829 useEffect(() => {
3830 if (typeof Image === "undefined" || serverVisibleTurnItems.length === 0) {
3831 return;
3832 }
3833
3834 const cleanups: Array<() => void> = [];
3835 const userMessagesById = new Map(
3836 serverVisibleTurnItems.flatMap((row) =>
3837 row.item.type === "user_message" ? [[String(row.item.messageId), row.item] as const] : [],
3838 ),
3839 );
3840
3841 for (const [messageId, handoffPreviewUrls] of Object.entries(
3842 attachmentPreviewHandoffByMessageId,
3843 )) {
3844 if (attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]) {
3845 continue;
3846 }
3847
3848 const serverMessage = userMessagesById.get(messageId);
3849 if (serverMessage === undefined || serverMessage.attachments.length === 0) {
3850 continue;
3851 }
3852
3853 const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) =>
3854 attachment.type === "image"
3855 ? [serverAttachmentUrlById.get(attachment.id)].filter(
3856 (previewUrl): previewUrl is string => previewUrl !== undefined,
3857 )
3858 : [],
3859 );
3860 if (
3861 serverPreviewUrls.length === 0 ||
3862 serverPreviewUrls.length !== handoffPreviewUrls.length ||
3863 serverPreviewUrls.some((previewUrl) => previewUrl.startsWith("blob:"))
3864 ) {
3865 continue;
3866 }
3867
3868 attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId] = true;
3869
3870 let cancelled = false;
3871 const imageInstances: HTMLImageElement[] = [];
3872
3873 const preloadServerPreviews = Promise.all(
3874 serverPreviewUrls.map(
3875 (previewUrl) =>
3876 new Promise<void>((resolve, reject) => {
3877 const image = new Image();
3878 imageInstances.push(image);
3879 const handleLoad = () => resolve();
3880 const handleError = () =>
3881 reject(new Error(`Failed to load server preview for ${messageId}.`));
3882 image.addEventListener("load", handleLoad, { once: true });
3883 image.addEventListener("error", handleError, { once: true });
3884 image.src = previewUrl;
3885 }),
3886 ),
3887 );
3888
3889 void preloadServerPreviews
3890 .then(() => {
3891 if (cancelled) {
3892 return;
3893 }
3894 clearAttachmentPreviewHandoff(messageId as MessageId, handoffPreviewUrls);
3895 })
3896 .catch(() => {
3897 if (!cancelled) {
3898 delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId];
3899 }
3900 });
3901
3902 cleanups.push(() => {
3903 cancelled = true;
3904 delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId];
3905 for (const image of imageInstances) {
3906 image.src = "";
3907 }
3908 });
3909 }
3910
3911 return () => {
3912 for (const cleanup of cleanups) {
3913 cleanup();
3914 }
3915 };
3916 }, [
3917 attachmentPreviewHandoffByMessageId,
3918 clearAttachmentPreviewHandoff,
3919 serverAttachmentUrlById,
3920 serverVisibleTurnItems,
3921 ]);
3922 const timelineAttachmentUrlById = useMemo(() => {
3923 const urls = new Map(serverAttachmentUrlById);
3924 for (const row of serverVisibleTurnItems) {
3925 if (row.item.type !== "user_message") continue;
3926 const handoffUrls = attachmentPreviewHandoffByMessageId[row.item.messageId];
3927 if (handoffUrls === undefined) continue;
3928 let imageIndex = 0;
3929 for (const attachment of row.item.attachments) {
3930 if (attachment.type !== "image") continue;
3931 const handoffUrl = handoffUrls[imageIndex];
3932 imageIndex += 1;
3933 if (handoffUrl !== undefined) urls.set(attachment.id, handoffUrl);
3934 }
3935 }
3936 return urls;
3937 }, [attachmentPreviewHandoffByMessageId, serverAttachmentUrlById, serverVisibleTurnItems]);
3938 const anchoredTimelineMessages = useMemo(
3939 () =>
3940 feedbackSubmissions.flatMap((submission) =>
3941 submission.status === "interrupted"
3942 ? []
3943 : [
3944 { ...codexFeedbackMessage(submission), runId: null },
3945 { ...codexFeedbackMessage(submission, "assistant"), runId: null },
3946 ],
3947 ),
3948 [feedbackSubmissions],
3949 );
3950 const timelineProjectionRef = useRef<{
3951 readonly threadKey: string | null;
3952 readonly projection: TimelineEntriesProjection;
3953 } | null>(null);
3954 const serverTimelineEntries = useMemo(() => {
3955 const previous = timelineProjectionRef.current;
3956 const projection = deriveTimelineEntriesFromVisibleTurnItemsWithState(
3957 {
3958 visibleTurnItems: serverVisibleTurnItems,
3959 optimisticMessages: optimisticUserMessages,
3960 anchoredMessages: anchoredTimelineMessages,
3961 attachmentUrlById: timelineAttachmentUrlById,
3962 ...(serverProjection === null
3963 ? {}
3964 : {
3965 attempts: serverProjection.attempts,
3966 nodes: serverProjection.nodes,
3967 plans: serverProjection.plans,
3968 }),
3969 },
3970 previous?.threadKey === activeThreadKey ? previous.projection : null,
3971 );
3972 timelineProjectionRef.current = { threadKey: activeThreadKey, projection };
3973 return projection.entries;
3974 }, [
3975 activeThreadKey,
3976 anchoredTimelineMessages,
3977 optimisticUserMessages,
3978 serverVisibleTurnItems,
3979 serverProjection,
3980 timelineAttachmentUrlById,
3981 ]);
3982 const draftTimelineEntries = useMemo(
3983 () =>
3984 optimisticUserMessages.map(
3985 (message) =>
3986 ({
3987 id: message.id,
3988 kind: "message",
3989 createdAt: message.createdAt,
3990 message,
3991 }) as const,
3992 ),
3993 [optimisticUserMessages],
3994 );
3995 const timelineEntries = isServerThread ? serverTimelineEntries : draftTimelineEntries;
3996 const timelineMessages = useMemo(
3997 () => timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
3998 [timelineEntries],
3999 );
4000 const displayedTimeline = resolveThreadSwitchTimeline({
4001 loading: timelineEntries.length === 0 && threadSyncPhase !== null,
4002 activeThreadKey,
4003 nextEntries: timelineEntries,
4004 rememberedForActive: peekRememberedThreadTimeline<typeof timelineEntries>(activeThreadKey),
4005 });
4006 const displayedTimelineKey = displayedTimeline.displayThreadKey ?? routeThreadKey;
4007 const paintOnlyDisplayedTimeline = isPaintOnlyThreadTimeline(
4008 displayedTimeline.displayThreadKey,
4009 activeThreadKey,
4010 );
4011 const displayedThreadRef = parseScopedThreadKey(displayedTimelineKey);
4012 const worktreeSetupOwnerKey = draftId ?? routeThreadKey;
4013 const worktreeSetupActive =
4014 worktreeSetupRef !== null && worktreeSetupRef.ownerKey === worktreeSetupOwnerKey;
4015 // Keep the sending environment during local draft setup; revisiting a
4016 // canonical thread subscribes by its durable identity.
4017 const setupTarget = worktreeSetupActive ? worktreeSetupRef : routeThreadRef;
4018 const worktreeSetupQuery = useEnvironmentQuery(
4019 worktreeSetupActive ||
4020 (activeThread?.id === routeThreadRef.threadId &&
4021 (isLocallyPreparingWorktree || activeRunPreparing || activeThread.worktreePath !== null))
4022 ? vcsEnvironment.worktreeSetup({
4023 environmentId: setupTarget.environmentId,
4024 input: { threadId: setupTarget.threadId },
4025 })
4026 : null,
4027 );
4028 const latestWorktreeSetup = worktreeSetupQuery.data;
4029 useEffect(() => {
4030 if (!latestWorktreeSetup) return;
4031 setHeldWorktreeSetup((current) =>
4032 current?.threadId === latestWorktreeSetup.threadId &&
4033 current.sequence > latestWorktreeSetup.sequence
4034 ? current
4035 : latestWorktreeSetup,
4036 );
4037 }, [latestWorktreeSetup]);
4038 useEffect(() => {
4039 setHeldWorktreeSetup(null);
4040 }, [routeThreadKey]);
4041 const { snapshot: liveWorktreeSetup, isPreparingWorktree } = resolveWorktreeSetupProgress({
4042 threadId: setupTarget.threadId,
4043 localPreparing: isLocallyPreparingWorktree,
4044 runStatus: activeActivityRun?.status,
4045 latest: latestWorktreeSetup,
4046 held: heldWorktreeSetup,
4047 });
4048 const worktreeSetup = resolveVisibleWorktreeSetup({
4049 live: liveWorktreeSetup,
4050 recorded: null,
4051 turnStarted: activeActivityRun?.startedAt != null,
4052 // Counts the optimistic send too, so the row retires the moment the
4053 // follow-up is on screen rather than when the server echoes it back.
4054 followUpSent: timelineMessages.filter((message) => message.role === "user").length > 1,
4055 });
4056 // Sends wait for the agent handoff, not for the setup script: an async
4057 // script keeps the snapshot running while the agent already works, and a
4058 // follow-up must not be held behind a slow install. Before the first
4059 // snapshot arrives the starting session stands in for it. Once the server
4060 // has the thread, a send queues behind the setup run instead.
4061 const worktreeSetupBlocksSend =
4062 !sendQueuesBehindSetup &&
4063 (worktreeSetup !== null
4064 ? worktreeSetup.phase === "running" && !worktreeSetupAgentStarted(worktreeSetup)
4065 : isPreparingWorktree);
4066 const cancelWorktreeSetup = useAtomCommand(vcsEnvironment.cancelWorktreeSetup, {
4067 reportFailure: false,
4068 });
4069 const retryWorkspacePreparation = useAtomCommand(threadEnvironment.retryWorkspacePreparation);
4070 const retryableRunIdsKey = useMemo(
4071 () =>
4072 [
4073 ...workspacePreparationRetryRunIds(
4074 serverProjection?.runs ?? [],
4075 serverProjection?.turnItems ?? [],
4076 ),
4077 ].join("\n"),
4078 [serverProjection?.runs, serverProjection?.turnItems],
4079 );
4080 // Keyed by content so the timeline context only changes when a retry appears or clears.
4081 const retryableWorkspacePreparationRunIds = useMemo(
4082 () => new Set(retryableRunIdsKey === "" ? [] : (retryableRunIdsKey.split("\n") as RunId[])),
4083 [retryableRunIdsKey],
4084 );
4085 const onRetryWorkspacePreparation = useCallback(
4086 (runId: RunId) => {
4087 // One retry per failed run: a second click lands after the run is preparing again.
4088 if (!activeThreadRef || retryingWorkspacePreparationRunIds.has(runId)) return;
4089 retryingWorkspacePreparationRunIds.add(runId);
4090 void retryWorkspacePreparation({
4091 environmentId: activeThreadRef.environmentId,
4092 input: { threadId: activeThreadRef.threadId, runId },
4093 }).finally(() => retryingWorkspacePreparationRunIds.delete(runId));
4094 },
4095 [activeThreadRef, retryWorkspacePreparation],
4096 );
4097 const onCancelWorktreeSetup = useCallback(() => {
4098 if (!worktreeSetup || worktreeSetup.phase !== "running") return;
4099 void cancelWorktreeSetup({
4100 environmentId: setupTarget.environmentId,
4101 input: { threadId: worktreeSetup.threadId },
4102 });
4103 }, [cancelWorktreeSetup, setupTarget.environmentId, worktreeSetup]);
4104 // The setup terminal belongs to the thread that was set up. A failed
4105 // bootstrap deletes that thread and closes its terminals, so only offer the
4106 // terminal while the setup thread is still the active one.
4107 const onOpenWorktreeSetupTerminal = useMemo(() => {
4108 if (!worktreeSetup || !activeThreadRef || worktreeSetup.threadId !== activeThreadRef.threadId) {
4109 return null;
4110 }
4111 const setupThreadRef = activeThreadRef;
4112 return (terminalId: string) => {
4113 storeEnsureTerminal(setupThreadRef, terminalId, { open: true, active: true });
4114 };
4115 }, [activeThreadRef, storeEnsureTerminal, worktreeSetup]);
4116 const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);
4117 const draftHeroDockRequested =
4118 activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey;
4119 const isDraftHeroState = resolveDraftHeroState({
4120 isLocalDraftThread,
4121 hasTimelineEntries: timelineEntries.length > 0,
4122 isWorking,
4123 draftHeroDockRequested,
4124 backgroundSubmissionPending,
4125 hasWorktreeSetupCard: worktreeSetup !== null,
4126 });
4127 const draftHeroTransition = useDraftHeroLayoutTransition(
4128 isDraftHeroState,
4129 panelAnimationsActive,
4130 panelAnimationDurationMs,
4131 );
4132 const captureDraftHeroComposerRect = draftHeroTransition.captureComposerRect;
4133 const { turnDiffSummaries } = useTurnDiffSummaries(serverProjection);
4134
4135 /**
4136 * The latest settled item after which files on disk may have changed.
4137 * File tools are explicit; completed commands are included because a shell
4138 * command can mutate the workspace without reporting the paths it touched.
4139 */
4140 const workspaceMutationId = useMemo(() => {
4141 let itemId: string | null = null;
4142 for (let index = serverVisibleTurnItems.length - 1; index >= 0; index -= 1) {
4143 const item = serverVisibleTurnItems[index]?.item;
4144 if (!item) continue;
4145 if (item.type !== "command_execution" && item.type !== "file_change") continue;
4146 if (item.status === "pending" || item.status === "running" || item.status === "waiting") {
4147 continue;
4148 }
4149 itemId = item.id;
4150 break;
4151 }
4152 const latestCheckpointCompletedAt = turnDiffSummaries.at(-1)?.completedAt ?? null;
4153 return itemId === null && latestCheckpointCompletedAt === null
4154 ? null
4155 : JSON.stringify([itemId, latestCheckpointCompletedAt]);
4156 }, [serverVisibleTurnItems, turnDiffSummaries]);
4157
4158 const gitCwd = activeProject
4159 ? projectScriptCwd({
4160 project: { cwd: activeProject.workspaceRoot },
4161 worktreePath: activeThread?.worktreePath ?? null,
4162 })
4163 : null;
4164 const gitStatusCwd = activeThread?.worktreePath ?? gitCwd;
4165 const gitStatusQuery = useEnvironmentQuery(
4166 gitStatusCwd === null
4167 ? null
4168 : vcsEnvironment.status({
4169 environmentId,
4170 input: { cwd: gitStatusCwd },
4171 }),
4172 );
4173 useWorkspaceMutationRefresh({
4174 enabled: gitStatusCwd !== null,
4175 mutationId: workspaceMutationId,
4176 refresh: gitStatusQuery.refresh,
4177 resourceKey: `git-status:${activeThreadKey ?? ""}:${gitStatusCwd ?? ""}`,
4178 });
4179 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
4180 const scriptKeybindings =
4181 useAtomValue(serverEnvironment.configValueAtom(environmentId))?.keybindings ??
4182 DEFAULT_RESOLVED_KEYBINDINGS;
4183 const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom);
4184 const remoteOpenState = useRemoteOpenState(activeThread?.environmentId ?? environmentId);
4185 const showOpenInPicker = shouldShowOpenInPicker({
4186 activeProjectName: activeProject?.title,
4187 activeThreadEnvironmentId: activeThread?.environmentId ?? environmentId,
4188 primaryEnvironmentId,
4189 remoteOpenMode: remoteOpenState.mode,
4190 });
4191 useOpenFavoriteEditorShortcut({
4192 enabled: showOpenInPicker,
4193 environmentId: activeThread?.environmentId ?? environmentId,
4194 keybindings,
4195 availableEditors,
4196 openInCwd: gitCwd,
4197 });
4198 const manualCompactionProviderAvailable = useMemo(
4199 () =>
4200 hasAvailableCompactionProvider({
4201 providers: providerInstanceEntries,
4202 driverKind: selectedProvider,
4203 instanceId: activeProviderInstanceId,
4204 lockedInstanceId: lockedProvider
4205 ? (activeRuntime?.providerInstanceId ?? activeThread?.modelSelection.instanceId ?? null)
4206 : null,
4207 }),
4208 [
4209 activeProviderInstanceId,
4210 activeThread?.modelSelection.instanceId,
4211 activeRuntime?.providerInstanceId,
4212 lockedProvider,
4213 providerInstanceEntries,
4214 selectedProvider,
4215 ],
4216 );
4217 const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] =
4218 useLocalStorage(
4219 `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`,
4220 false,
4221 Schema.Boolean,
4222 );
4223 const nativeResumeCompactionDismissed = useMemo(
4224 () =>
4225 hasDismissedResumeCompaction(
4226 (serverProjection?.runtimeRequests ?? [])
4227 .filter((request) => request.kind === "user_input" && request.status === "resolved")
4228 .map((request) => ({
4229 kind: "user-input.resolved",
4230 payload: { answers: request.answers },
4231 })),
4232 ),
4233 [serverProjection?.runtimeRequests],
4234 );
4235 useEffect(() => {
4236 if (nativeResumeCompactionDismissed && !resumeCompactionPermanentlyDismissed) {
4237 setResumeCompactionPermanentlyDismissed(true);
4238 }
4239 }, [
4240 nativeResumeCompactionDismissed,
4241 resumeCompactionPermanentlyDismissed,
4242 setResumeCompactionPermanentlyDismissed,
4243 ]);
4244 const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus);
4245 const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState<
4246 string | null
4247 >(null);
4248 useEffect(() => {
4249 if (providerStatusBannerKey === null && dismissedProviderStatusBannerKey !== null) {
4250 setDismissedProviderStatusBannerKey(null);
4251 }
4252 }, [dismissedProviderStatusBannerKey, providerStatusBannerKey]);
4253 const visibleProviderStatus = shouldShowProviderStatusBanner(
4254 activeProviderStatus,
4255 dismissedProviderStatusBannerKey,
4256 )
4257 ? activeProviderStatus
4258 : null;
4259 const hasTimelineTopBanner = Boolean(timelineThreadError) || visibleProviderStatus !== null;
4260 const activeProjectCwd = activeProject?.workspaceRoot ?? null;
4261 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
4262 const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined;
4263 useLayoutEffect(() => {
4264 if (
4265 threadDetailLoading ||
4266 timelineEntries.length === 0 ||
4267 timelineHasEphemeralPreviewUrls(timelineEntries)
4268 ) {
4269 return;
4270 }
4271 rememberReadyThreadTimeline({
4272 threadKey: activeThreadKey,
4273 entries: timelineEntries,
4274 markdownCwd: gitCwd,
4275 workspaceRoot: activeWorkspaceRoot ?? null,
4276 });
4277 }, [activeThreadKey, activeWorkspaceRoot, gitCwd, threadDetailLoading, timelineEntries]);
4278 const heldPaintContext = paintOnlyDisplayedTimeline
4279 ? peekHeldThreadTimeline<typeof timelineEntries>()
4280 : null;
4281 const activeTerminalLaunchContext =
4282 terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null;
4283 // Git status arrives after the composer paints. A checkout seen earlier in
4284 // this session answers from memory, so a non-Git project does not mount the
4285 // branch strip and then drop it. A never-seen checkout assumes Git, which
4286 // is what nearly every project is.
4287 const liveIsGitRepo = gitStatusQuery.data?.isRepo;
4288 useEffect(() => {
4289 if (gitStatusCwd !== null && liveIsGitRepo !== undefined) {
4290 rememberCheckoutIsRepo(environmentId, gitStatusCwd, liveIsGitRepo);
4291 }
4292 }, [environmentId, gitStatusCwd, liveIsGitRepo]);
4293 const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true;
4294 const workspaceChangesAvailable =
4295 isServerThread && supportsWorkspaceChanges({ ...gitStatusQuery.data, isRepo: isGitRepo });
4296 // When context is enabled, keep a hidden, off-flow strip mounted so the composer
4297 // can measure whether its relocated controls fit. The visible chrome remains
4298 // content-driven: Git/environment context or controls that actually fit.
4299 // A provider-native subagent cannot take messages: a status bar replaces the
4300 // composer and its strips. Its approvals and questions are asked on the
4301 // top-level parent thread.
4302 const showProviderSubagentBar = isProviderSubagent;
4303 const composerMounted = !showProviderSubagentBar;
4304 const providerSubagentModels = selectedProviderEntry?.models ?? EMPTY_PROVIDER_MODELS;
4305 // Providers can report a dated id or alias (claude-haiku-4-5-20251001).
4306 const providerSubagentModelSlug = selectedProviderEntry
4307 ? resolveSelectableModel(
4308 selectedProviderEntry.driverKind,
4309 activeThread?.modelSelection.model,
4310 providerSubagentModels,
4311 )
4312 : null;
4313 const providerSubagentCatalogModel = providerSubagentModels.find(
4314 (model) => model.slug === providerSubagentModelSlug,
4315 );
4316 const providerSubagentModelLabel = providerSubagentCatalogModel
4317 ? getTriggerDisplayModelName(providerSubagentCatalogModel)
4318 : formatModelSlugName(activeThread?.modelSelection.model ?? "");
4319 const providerSubagentEffortLabel =
4320 activeThread === undefined
4321 ? null
4322 : formatModelSelectionEffort(
4323 activeThread.modelSelection,
4324 providerSubagentModels,
4325 reportedModelSelection,
4326 );
4327 const mountComposerContextStrip = shouldShowComposerContextStrip({
4328 isDraftHeroState,
4329 persistInActiveThreads: settings.persistComposerContextStrip,
4330 hasActiveProject: activeProject !== null && !showProviderSubagentBar,
4331 isGitRepo,
4332 showEnvironmentIndicator: showComposerEnvironmentIndicator,
4333 hostsRestingComposerControls: routeKind === "server",
4334 });
4335 const showComposerContextStrip = shouldShowComposerContextStrip({
4336 isDraftHeroState,
4337 persistInActiveThreads: settings.persistComposerContextStrip,
4338 hasActiveProject: activeProject !== null && !showProviderSubagentBar,
4339 isGitRepo,
4340 showEnvironmentIndicator: showComposerEnvironmentIndicator,
4341 hostsRestingComposerControls: routeKind === "server" && restingComposerControlsVisible,
4342 });
4343 const mountComposerModelStrip =
4344 routeKind === "server" && !mountComposerContextStrip && !showProviderSubagentBar;
4345 const showComposerModelStrip = mountComposerModelStrip && restingComposerControlsVisible;
4346 const terminalShortcutLabelOptions = useMemo(
4347 () => ({
4348 context: {
4349 terminalFocus: true,
4350 terminalOpen: Boolean(terminalUiState.terminalOpen),
4351 },
4352 }),
4353 [terminalUiState.terminalOpen],
4354 );
4355 const splitTerminalShortcutLabel = useMemo(
4356 () => shortcutLabelForCommand(keybindings, "terminal.split", terminalShortcutLabelOptions),
4357 [keybindings, terminalShortcutLabelOptions],
4358 );
4359 const splitTerminalVerticalShortcutLabel = useMemo(
4360 () =>
4361 shortcutLabelForCommand(keybindings, "terminal.splitVertical", terminalShortcutLabelOptions),
4362 [keybindings, terminalShortcutLabelOptions],
4363 );
4364 const newTerminalShortcutLabel = useMemo(
4365 () => shortcutLabelForCommand(keybindings, "terminal.new", terminalShortcutLabelOptions),
4366 [keybindings, terminalShortcutLabelOptions],
4367 );
4368 const closeTerminalShortcutLabel = useMemo(
4369 () => shortcutLabelForCommand(keybindings, "terminal.close", terminalShortcutLabelOptions),
4370 [keybindings, terminalShortcutLabelOptions],
4371 );
4372 const onToggleDiff = useCallback(() => {
4373 if (!isServerThread) {
4374 return;
4375 }
4376 if (!diffOpen) {
4377 onDiffPanelOpen?.();
4378 }
4379 if (activeThreadRef) {
4380 useRightPanelStore.getState().toggle(activeThreadRef, "diff");
4381 }
4382 }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]);
4383
4384 const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId;
4385 const loadBalancingCandidates = useMemo(
4386 () =>
4387 needsLoadBalancing
4388 ? logicalProjectEnvironments
4389 .filter((candidate) => {
4390 const environment = environmentById.get(candidate.environmentId);
4391 return (
4392 environment?.connection.phase === "connected" &&
4393 (loadBalancingSettings.loadBalancingWeights[candidate.environmentId] ?? 50) > 0 &&
4394 environment.serverConfig?.providers.some(
4395 (provider) =>
4396 (activeProviderInstanceId === null ||
4397 provider.instanceId === activeProviderInstanceId) &&
4398 provider.driver === selectedProvider &&
4399 // The destination project may override the machine switch.
4400 (environment.serverConfig?.settings
4401 ? resolveProjectProviderInstanceEnabled(
4402 environment.serverConfig.settings,
4403 candidate.projectId,
4404 provider.instanceId,
4405 )
4406 : provider.enabled) &&
4407 provider.installed &&
4408 provider.status !== "error" &&
4409 provider.auth.status !== "unauthenticated" &&
4410 provider.availability !== "unavailable",
4411 )
4412 );
4413 })
4414 .map((candidate) => candidate.environmentId)
4415 : [],
4416 [
4417 needsLoadBalancing,
4418 logicalProjectEnvironments,
4419 environmentById,
4420 loadBalancingSettings.loadBalancingWeights,
4421 activeProviderInstanceId,
4422 selectedProvider,
4423 ],
4424 );
4425 const loadBalancing = useLoadBalancedEnvironment(
4426 loadBalancingCandidates,
4427 loadBalancingSettings.loadBalancingWeights,
4428 );
4429 useEffect(() => {
4430 if (!needsLoadBalancing || loadBalancing.pending || !draftId || sendInFlightRef.current) return;
4431 const target = logicalProjectEnvironments.find(
4432 (environment) => environment.environmentId === loadBalancing.environmentId,
4433 );
4434 if (!target?.projectId) return;
4435 setDraftThreadContext(draftId, {
4436 projectRef: scopeProjectRef(target.environmentId, target.projectId),
4437 environmentSelection: "auto",
4438 loadBalancedEnvironmentId: target.environmentId,
4439 });
4440 }, [
4441 needsLoadBalancing,
4442 loadBalancing.pending,
4443 loadBalancing.environmentId,
4444 draftId,
4445 logicalProjectEnvironments,
4446 setDraftThreadContext,
4447 ]);
4448 const onAutoEnvironment = useCallback(() => {
4449 if (envLocked || !draftId) return;
4450 if (composerHasAttachments) {
4451 toastManager.add({
4452 type: "warning",
4453 id: "load-balancing-attachments",
4454 title: "Keep attachments on this machine",
4455 description:
4456 "Remove attachments before choosing automatic routing, then attach them on the selected machine.",
4457 });
4458 return;
4459 }
4460 loadBalancing.refresh(
4461 logicalProjectEnvironments.map((environment) => environment.environmentId),
4462 );
4463 setDraftThreadContext(draftId, {
4464 environmentSelection: "auto",
4465 loadBalancedEnvironmentId: null,
4466 branch: null,
4467 worktreePath: null,
4468 });
4469 }, [
4470 envLocked,
4471 draftId,
4472 setDraftThreadContext,
4473 loadBalancing.refresh,
4474 logicalProjectEnvironments,
4475 composerHasAttachments,
4476 ]);
4477 const autoEnvironmentLabel = automaticEnvironment
4478 ? draftThread?.loadBalancedEnvironmentId
4479 ? "Auto balance"
4480 : loadBalancing.pending
4481 ? "Checking machines…"
4482 : loadBalancing.failed
4483 ? "Auto balance unavailable"
4484 : "Auto balance"
4485 : undefined;
4486
4487 // The machine an in-flight switch is heading to; a newer switch replaces it.
4488 const environmentChangeRef = useRef<{ readonly environmentId: EnvironmentId } | null>(null);
4489 const [isEnvironmentChanging, setIsEnvironmentChanging] = useState(false);
4490 useLayoutEffect(() => {
4491 return () => {
4492 environmentChangeRef.current = null;
4493 setIsEnvironmentChanging(false);
4494 };
4495 }, [draftId, activeProjectKey]);
4496
4497 const onEnvironmentChange = useCallback(
4498 (nextEnvironmentId: EnvironmentId) => {
4499 if (envLocked || !draftId || sendInFlightRef.current) return;
4500 const originalDraft = getDraftSession(draftId);
4501 if (!originalDraft || originalDraft.promotedTo) return;
4502 const target = logicalProjectEnvironments.find(
4503 (env) => env.environmentId === nextEnvironmentId,
4504 );
4505 if (!target) return;
4506 const request = { environmentId: target.environmentId };
4507 environmentChangeRef.current = request;
4508 setIsEnvironmentChanging(false);
4509 const retarget = (project: (typeof allProjects)[number]) => {
4510 const currentDraft = getDraftSession(draftId);
4511 if (
4512 environmentChangeRef.current !== request ||
4513 sendInFlightRef.current ||
4514 !currentDraft ||
4515 currentDraft.promotedTo ||
4516 currentDraft.environmentId !== originalDraft.environmentId ||
4517 currentDraft.projectId !== originalDraft.projectId
4518 )
4519 return;
4520 const projectRef = scopeProjectRef(target.environmentId, project.id);
4521 if (activeProjectIsScratch) {
4522 // Scratch projects are machine-local, so move their logical mapping too.
4523 setLogicalProjectDraftThreadId(
4524 deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings),
4525 projectRef,
4526 draftId,
4527 { environmentSelection: "manual", loadBalancedEnvironmentId: null },
4528 );
4529 } else {
4530 setDraftThreadContext(draftId, {
4531 projectRef,
4532 environmentSelection: "manual",
4533 loadBalancedEnvironmentId: null,
4534 });
4535 }
4536 };
4537 const finish = () => {
4538 if (environmentChangeRef.current === request) {
4539 environmentChangeRef.current = null;
4540 setIsEnvironmentChanging(false);
4541 }
4542 };
4543 if (target.projectId !== null) {
4544 const project = allProjects.find(
4545 (project) =>
4546 project.environmentId === target.environmentId && project.id === target.projectId,
4547 );
4548 if (project) retarget(project);
4549 finish();
4550 return;
4551 }
4552 // Keep send disabled until the destination Scratch project is ready.
4553 setIsEnvironmentChanging(true);
4554 void openScratchProject(target.environmentId, "Could not switch machine")
4555 .then((project) => {
4556 if (project) retarget(project);
4557 })
4558 .finally(finish);
4559 },
4560 [
4561 activeProjectIsScratch,
4562 allProjects,
4563 draftId,
4564 envLocked,
4565 getDraftSession,
4566 logicalProjectEnvironments,
4567 openScratchProject,
4568 projectGroupingSettings,
4569 sendInFlightRef,
4570 setDraftThreadContext,
4571 setLogicalProjectDraftThreadId,
4572 ],
4573 );
4574
4575 const activeTerminalGroup =
4576 terminalUiState.terminalGroups.find(
4577 (group) => group.id === terminalUiState.activeTerminalGroupId,
4578 ) ??
4579 terminalUiState.terminalGroups.find((group) =>
4580 group.terminalIds.includes(terminalUiState.activeTerminalId),
4581 ) ??
4582 null;
4583 const hasReachedSplitLimit =
4584 (activeTerminalGroup?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP;
4585 const setThreadError = useCallback(
4586 (targetThreadId: ThreadId | null, error: string | null) => {
4587 if (!targetThreadId) return;
4588 const nextError = sanitizeThreadErrorMessage(error);
4589 const nextEntry: LocalThreadErrorEntry = { message: nextError, at: Date.now() };
4590 if (
4591 serverThread &&
4592 targetThreadId === routeThreadRef.threadId &&
4593 serverThread.environmentId === routeThreadRef.environmentId &&
4594 serverThread.id === targetThreadId
4595 ) {
4596 setLocalServerErrorsByThreadKey((existing) => {
4597 if ((existing[routeThreadKey]?.message ?? null) === nextError) {
4598 return existing;
4599 }
4600 return {
4601 ...existing,
4602 [routeThreadKey]: nextEntry,
4603 };
4604 });
4605 return;
4606 }
4607 const localDraftErrorKey = draftId ?? targetThreadId;
4608 setLocalDraftErrorsByDraftId((existing) => {
4609 if ((existing[localDraftErrorKey]?.message ?? null) === nextError) {
4610 return existing;
4611 }
4612 return {
4613 ...existing,
4614 [localDraftErrorKey]: nextEntry,
4615 };
4616 });
4617 },
4618 [draftId, routeThreadKey, routeThreadRef, serverThread],
4619 );
4620
4621 const focusComposer = useCallback(() => {
4622 composerRef.current?.focusAtEnd();
4623 }, [composerRef]);
4624 const canInterruptRunningThread =
4625 canOperateThread && deriveCanInterruptRunningThread(activeThread !== undefined, activeRuntime);
4626 const onInterrupt = useCallback(async () => {
4627 if (
4628 !activeThread ||
4629 !readEnvironmentScope(activeThread.environmentId, AuthOrchestrationOperateScope)
4630 )
4631 return;
4632 const result = await interruptThreadTurn({
4633 environmentId,
4634 input: { threadId: activeThread.id },
4635 });
4636 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
4637 const error = squashAtomCommandFailure(result);
4638 setThreadError(
4639 activeThread.id,
4640 error instanceof Error ? error.message : "Failed to interrupt the current turn.",
4641 );
4642 }
4643 }, [activeThread, environmentId, interruptThreadTurn, setThreadError]);
4644 useEffect(() => subscribeSnapShotComposerFocus(focusComposer), [focusComposer]);
4645 const scheduleComposerFocus = useCallback(() => {
4646 window.requestAnimationFrame(() => {
4647 focusComposer();
4648 });
4649 }, [focusComposer]);
4650 const useArtifactTemplate = useCallback(
4651 (template: CodexArtifactTemplate) => {
4652 const composer = composerRef.current;
4653 if (!composer) return;
4654
4655 const currentDraft = composer.getSendContext().prompt;
4656 const prompt = codexArtifactTemplatePromptToAppend(currentDraft, template);
4657 if (prompt !== null && !composer.insertTextAtEnd(prompt, { ensureLeadingBoundary: true })) {
4658 toastManager.add({
4659 type: "error",
4660 title: "Unable to add to chat",
4661 description: "The composer is busy; try again once it is ready.",
4662 });
4663 return;
4664 }
4665 scheduleComposerFocus();
4666 },
4667 [composerRef, scheduleComposerFocus],
4668 );
4669 // An MCP App's approved `ui/message`: queued like a typed message, so it
4670 // never steers or interrupts a running turn.
4671 const sendAppMessage = useCallback(
4672 async (text: string) => {
4673 if (!isServerThread || activeThreadId === null) {
4674 throw new Error("Messages from apps need a started thread.");
4675 }
4676 const result = await startThreadTurn({
4677 environmentId,
4678 input: {
4679 threadId: activeThreadId,
4680 message: { messageId: newMessageId(), role: "user", text, attachments: [] },
4681 runtimeMode,
4682 interactionMode,
4683 dispatchMode: "queue",
4684 },
4685 });
4686 if (result._tag === "Failure") {
4687 const error = squashAtomCommandFailure(result);
4688 throw error instanceof Error ? error : new Error("Could not send the app's message.");
4689 }
4690 },
4691 [activeThreadId, environmentId, interactionMode, isServerThread, runtimeMode, startThreadTurn],
4692 );
4693 const editQueuedRunCommand = useAtomCommand(threadEnvironment.editQueuedRun, {
4694 reportFailure: false,
4695 });
4696 const queuedRunsControlRef = useRef<QueuedRunsControlHandle>(null);
4697 const queuedEditSaveInFlightRef = useRef(false);
4698 const [isSavingQueuedEdit, setIsSavingQueuedEdit] = useState(false);
4699 const queuedEditImageResources = useMemo(
4700 () =>
4701 (editingQueuedRun?.existingAttachments ?? [])
4702 .filter((attachment) => attachment.type === "image")
4703 .map((attachment) => ({ _tag: "attachment" as const, attachmentId: attachment.id })),
4704 [editingQueuedRun],
4705 );
4706 const queuedEditImageUrls = useAssetUrls(environmentId, queuedEditImageResources);
4707 const composerEditingQueuedAttachments = useMemo(() => {
4708 if (editingQueuedRun === null) return null;
4709 const urlByAttachmentId = new Map(
4710 queuedEditImageResources.map((resource, index) => [
4711 resource.attachmentId,
4712 queuedEditImageUrls[index] ?? null,
4713 ]),
4714 );
4715 return editingQueuedRun.existingAttachments.map((attachment) => ({
4716 attachment,
4717 url: urlByAttachmentId.get(attachment.id) ?? null,
4718 }));
4719 }, [editingQueuedRun, queuedEditImageResources, queuedEditImageUrls]);
4720 const beginEditingQueuedRun = useCallback(
4721 (request: EditQueuedRunRequest) => {
4722 if (!activeThread) return;
4723 if (editingQueuedRun !== null && editingQueuedRun.runId !== request.runId) {
4724 clearComposerDraftContent(queuedEditDraftTargetFor(editingQueuedRun.runId));
4725 }
4726 const target = queuedEditDraftTargetFor(request.runId);
4727 clearComposerDraftContent(target);
4728 setComposerDraftPrompt(target, request.text);
4729 setEditingQueuedRun({
4730 threadId: activeThread.id,
4731 runId: request.runId,
4732 messageId: request.messageId,
4733 originalText: request.text,
4734 existingAttachments: request.attachments,
4735 context: serverProjection?.messages.find((message) => message.id === request.messageId)
4736 ?.context,
4737 });
4738 scheduleComposerFocus();
4739 },
4740 [
4741 activeThread,
4742 clearComposerDraftContent,
4743 editingQueuedRun,
4744 queuedEditDraftTargetFor,
4745 serverProjection,
4746 scheduleComposerFocus,
4747 setComposerDraftPrompt,
4748 ],
4749 );
4750 const cancelEditingQueuedRun = useCallback(() => {
4751 if (editingQueuedRun === null) return;
4752 clearComposerDraftContent(queuedEditDraftTargetFor(editingQueuedRun.runId));
4753 setEditingQueuedRun(null);
4754 scheduleComposerFocus();
4755 }, [
4756 clearComposerDraftContent,
4757 editingQueuedRun,
4758 queuedEditDraftTargetFor,
4759 scheduleComposerFocus,
4760 ]);
4761 const removeEditingQueuedAttachment = useCallback((attachmentId: string) => {
4762 setEditingQueuedRun((current) =>
4763 current === null
4764 ? current
4765 : {
4766 ...current,
4767 existingAttachments: current.existingAttachments.filter(
4768 (attachment) => attachment.id !== attachmentId,
4769 ),
4770 },
4771 );
4772 }, []);
4773 // Exit edit mode when the edited run leaves the queue (it started, or was
4774 // cancelled from another client). A dirty edit moves into the thread's own
4775 // draft when that draft is empty; otherwise it is dropped with a toast.
4776 useEffect(() => {
4777 if (editingQueuedRun === null) return;
4778 if (activeThread?.id !== editingQueuedRun.threadId) {
4779 setEditingQueuedRun(null);
4780 return;
4781 }
4782 if (serverProjection === null) return;
4783 const run = serverProjection.runs.find((candidate) => candidate.id === editingQueuedRun.runId);
4784 if (run !== undefined && run.status === "queued") return;
4785 const recovery = recoverQueuedMessageEdit({
4786 editTarget: queuedEditDraftTargetFor(editingQueuedRun.runId),
4787 threadTarget: baseComposerDraftTarget,
4788 originalText: editingQueuedRun.originalText,
4789 });
4790 if (recovery === "kept") {
4791 toastManager.add(
4792 stackedThreadToast({
4793 type: "info",
4794 title: "Queued message is no longer queued",
4795 description: "Your unsaved edit was kept in the composer.",
4796 }),
4797 );
4798 } else if (recovery === "discarded") {
4799 toastManager.add(
4800 stackedThreadToast({
4801 type: "warning",
4802 title: "Queued message is no longer queued",
4803 description: "Your unsaved edit was discarded.",
4804 }),
4805 );
4806 }
4807 setEditingQueuedRun(null);
4808 }, [
4809 activeThread?.id,
4810 baseComposerDraftTarget,
4811 editingQueuedRun,
4812 queuedEditDraftTargetFor,
4813 serverProjection,
4814 ]);
4815 const addTerminalContextToDraft = useCallback(
4816 (selection: TerminalContextSelection) => {
4817 composerRef.current?.addTerminalContext(selection);
4818 },
4819 [composerRef],
4820 );
4821 const setTerminalOpen = useCallback(
4822 (open: boolean) => {
4823 if (!activeThreadRef) return;
4824 storeSetTerminalOpen(activeThreadRef, open);
4825 },
4826 [activeThreadRef, storeSetTerminalOpen],
4827 );
4828 const addTerminalSurface = useCallback(() => {
4829 if (!hasTerminalWriteAccess() || !activeThreadRef || !activeThreadId || !activeProject) return;
4830 const cwd = gitCwd ?? activeProject.workspaceRoot;
4831 const terminalId = allocateTerminalId();
4832 useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId);
4833 setTerminalFocusRequestId((value) => value + 1);
4834 void openTerminal({
4835 environmentId: activeThreadRef.environmentId,
4836 input: {
4837 threadId: activeThreadId,
4838 terminalId,
4839 cwd,
4840 ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
4841 env: projectScriptRuntimeEnv({
4842 project: { cwd: activeProject.workspaceRoot },
4843 worktreePath: activeThreadWorktreePath,
4844 }),
4845 },
4846 });
4847 }, [
4848 activeProject,
4849 activeThreadId,
4850 activeThreadRef,
4851 activeThreadWorktreePath,
4852 allocateTerminalId,
4853 gitCwd,
4854 openTerminal,
4855 hasTerminalWriteAccess,
4856 ]);
4857 const toggleTerminalVisibility = useCallback(() => {
4858 if (!activeThreadRef) return;
4859 if (terminalPlacement === "right" && terminalUiState.terminalIds.length === 0) {
4860 const panels = useRightPanelStore.getState();
4861 const panelState = selectThreadRightPanelState(panels.byThreadKey, activeThreadRef);
4862 const surface = panelState.surfaces.find((entry) => entry.kind === "terminal");
4863 if (surface) {
4864 if (panelState.isOpen && panelState.activeSurfaceId === surface.id) {
4865 panels.close(activeThreadRef);
4866 } else {
4867 if (
4868 !readEnvironmentScope(environmentId, AuthTerminalReadScope) &&
4869 !hasTerminalWriteAccess()
4870 )
4871 return;
4872 panels.activateSurface(activeThreadRef, surface.id);
4873 setTerminalFocusRequestId((value) => value + 1);
4874 }
4875 } else {
4876 if (
4877 !readEnvironmentScope(environmentId, AuthTerminalReadScope) &&
4878 !hasTerminalWriteAccess()
4879 )
4880 return;
4881 const existingTerminalId = runningTerminalIds[0] ?? activeServerOrderedTerminalIds[0];
4882 if (existingTerminalId !== undefined) {
4883 panels.openTerminal(activeThreadRef, existingTerminalId);
4884 setTerminalFocusRequestId((value) => value + 1);
4885 } else {
4886 addTerminalSurface();
4887 }
4888 }
4889 return;
4890 }
4891 const nextOpen = !terminalUiState.terminalOpen;
4892 if (
4893 nextOpen &&
4894 !readEnvironmentScope(environmentId, AuthTerminalReadScope) &&
4895 !hasTerminalWriteAccess()
4896 )
4897 return;
4898 if (nextOpen && hasTerminalWriteAccess() && terminalUiState.terminalIds.length === 0) {
4899 // Server-created setup terminals exist before this client's drawer mounts.
4900 // Reuse them before allocating a shell from the still-empty UI state.
4901 const existingTerminalId =
4902 runningTerminalIds.find((id) => !panelTerminalIds.has(id)) ??
4903 activeServerOrderedTerminalIds.find((id) => !panelTerminalIds.has(id));
4904 if (existingTerminalId !== undefined) {
4905 storeEnsureTerminal(activeThreadRef, existingTerminalId, { open: true, active: true });
4906 return;
4907 }
4908 if (!activeThreadId || !activeProject) {
4909 return;
4910 }
4911 const cwdForOpen = gitCwd ?? activeProject.workspaceRoot;
4912 if (!cwdForOpen) {
4913 return;
4914 }
4915 const terminalId = allocateTerminalId();
4916 storeEnsureTerminal(activeThreadRef, terminalId, { open: true });
4917 void openTerminal({
4918 environmentId,
4919 input: {
4920 threadId: activeThreadId,
4921 terminalId,
4922 cwd: cwdForOpen,
4923 ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
4924 env: projectScriptRuntimeEnv({
4925 project: { cwd: activeProject.workspaceRoot },
4926 worktreePath: activeThreadWorktreePath,
4927 }),
4928 },
4929 });
4930 return;
4931 }
4932 setTerminalOpen(nextOpen);
4933 }, [
4934 addTerminalSurface,
4935 terminalPlacement,
4936 activeServerOrderedTerminalIds,
4937 activeProject,
4938 activeThreadId,
4939 activeThreadRef,
4940 activeThreadWorktreePath,
4941 allocateTerminalId,
4942 environmentId,
4943 gitCwd,
4944 openTerminal,
4945 panelTerminalIds,
4946 runningTerminalIds,
4947 hasTerminalWriteAccess,
4948 setTerminalOpen,
4949 storeEnsureTerminal,
4950 terminalUiState.terminalIds.length,
4951 terminalUiState.terminalOpen,
4952 ]);
4953 const splitTerminal = useCallback(
4954 (direction: "horizontal" | "vertical" = "horizontal") => {
4955 if (
4956 !hasTerminalWriteAccess() ||
4957 !activeThreadRef ||
4958 hasReachedSplitLimit ||
4959 !activeThreadId ||
4960 !activeProject
4961 ) {
4962 return;
4963 }
4964 const cwdForOpen = gitCwd ?? activeProject.workspaceRoot;
4965 if (!cwdForOpen) {
4966 return;
4967 }
4968 const terminalId = allocateTerminalId();
4969 if (direction === "vertical") {
4970 storeSplitTerminalVertical(activeThreadRef, terminalId);
4971 } else {
4972 storeSplitTerminal(activeThreadRef, terminalId);
4973 }
4974 setTerminalFocusRequestId((value) => value + 1);
4975 void openTerminal({
4976 environmentId,
4977 input: {
4978 threadId: activeThreadId,
4979 terminalId,
4980 cwd: cwdForOpen,
4981 ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
4982 env: projectScriptRuntimeEnv({
4983 project: { cwd: activeProject.workspaceRoot },
4984 worktreePath: activeThreadWorktreePath,
4985 }),
4986 },
4987 });
4988 },
4989 [
4990 activeProject,
4991 activeThreadId,
4992 allocateTerminalId,
4993 activeThreadRef,
4994 openTerminal,
4995 hasTerminalWriteAccess,
4996 activeThreadWorktreePath,
4997 environmentId,
4998 gitCwd,
4999 hasReachedSplitLimit,
5000 storeSplitTerminal,
5001 storeSplitTerminalVertical,
5002 ],
5003 );
5004 const createNewTerminal = useCallback(() => {
5005 if (!hasTerminalWriteAccess() || !activeThreadRef || !activeThreadId || !activeProject) {
5006 return;
5007 }
5008 const cwdForOpen = gitCwd ?? activeProject.workspaceRoot;
5009 if (!cwdForOpen) {
5010 return;
5011 }
5012 const terminalId = allocateTerminalId();
5013 storeNewTerminal(activeThreadRef, terminalId);
5014 setTerminalFocusRequestId((value) => value + 1);
5015 void openTerminal({
5016 environmentId,
5017 input: {
5018 threadId: activeThreadId,
5019 terminalId,
5020 cwd: cwdForOpen,
5021 ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
5022 env: projectScriptRuntimeEnv({
5023 project: { cwd: activeProject.workspaceRoot },
5024 worktreePath: activeThreadWorktreePath,
5025 }),
5026 },
5027 });
5028 }, [
5029 activeProject,
5030 activeThreadId,
5031 allocateTerminalId,
5032 activeThreadRef,
5033 openTerminal,
5034 hasTerminalWriteAccess,
5035 activeThreadWorktreePath,
5036 environmentId,
5037 gitCwd,
5038 storeNewTerminal,
5039 ]);
5040 const closeTerminal = useCallback(
5041 (terminalId: string) => {
5042 if (!hasTerminalWriteAccess() || !activeThreadId || !activeThreadRef) return;
5043 const fallbackExitWrite = () =>
5044 writeTerminal({
5045 environmentId,
5046 input: { threadId: activeThreadId, terminalId, data: "exit\n" },
5047 });
5048 void (async () => {
5049 const closeResult = await closeTerminalMutation({
5050 environmentId,
5051 input: {
5052 threadId: activeThreadId,
5053 terminalId,
5054 deleteHistory: true,
5055 },
5056 });
5057 if (
5058 closeResult._tag === "Failure" &&
5059 !isAtomCommandInterrupted(closeResult) &&
5060 hasTerminalWriteAccess()
5061 ) {
5062 await fallbackExitWrite();
5063 }
5064 })();
5065 storeCloseTerminal(activeThreadRef, terminalId);
5066 setTerminalFocusRequestId((value) => value + 1);
5067 },
5068 [
5069 activeThreadId,
5070 activeThreadRef,
5071 closeTerminalMutation,
5072 hasTerminalWriteAccess,
5073 environmentId,
5074 storeCloseTerminal,
5075 writeTerminal,
5076 ],
5077 );
5078 const runProjectScript = useCallback(
5079 async (
5080 script: ProjectScript,
5081 options?: {
5082 cwd?: string;
5083 env?: Record<string, string>;
5084 worktreePath?: string | null;
5085 preferNewTerminal?: boolean;
5086 rememberAsLastInvoked?: boolean;
5087 },
5088 ) => {
5089 if (!hasTerminalWriteAccess() || !activeThreadId || !activeProject || !activeThread) return;
5090 if (options?.rememberAsLastInvoked !== false) {
5091 setLastInvokedScriptByProjectId((current) => {
5092 if (current[activeProject.id] === script.id) return current;
5093 return { ...current, [activeProject.id]: script.id };
5094 });
5095 }
5096 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot;
5097 const baseTerminalId =
5098 terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID;
5099 const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId);
5100 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
5101 const shouldCreateNewTerminal =
5102 wantsNewTerminal ||
5103 !canReuseTerminal ||
5104 !readEnvironmentScope(environmentId, AuthTerminalReadScope);
5105 const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null;
5106
5107 setTerminalUiLaunchContext({
5108 threadId: activeThreadId,
5109 cwd: targetCwd,
5110 worktreePath: targetWorktreePath,
5111 });
5112 if (!activeThreadRef) {
5113 return;
5114 }
5115 setTerminalFocusRequestId((value) => value + 1);
5116
5117 const runtimeEnv = projectScriptRuntimeEnv({
5118 project: {
5119 cwd: activeProject.workspaceRoot,
5120 },
5121 worktreePath: targetWorktreePath,
5122 ...(options?.env ? { extraEnv: options.env } : {}),
5123 });
5124 const targetTerminalId = shouldCreateNewTerminal ? allocateTerminalId() : baseTerminalId;
5125 const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal
5126 ? {
5127 threadId: activeThreadId,
5128 terminalId: targetTerminalId,
5129 cwd: targetCwd,
5130 ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}),
5131 env: runtimeEnv,
5132 cols: SCRIPT_TERMINAL_COLS,
5133 rows: SCRIPT_TERMINAL_ROWS,
5134 }
5135 : {
5136 threadId: activeThreadId,
5137 terminalId: targetTerminalId,
5138 cwd: targetCwd,
5139 ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}),
5140 env: runtimeEnv,
5141 };
5142
5143 if (
5144 terminalPlacement === "right" &&
5145 !terminalUiState.terminalIds.includes(targetTerminalId)
5146 ) {
5147 useRightPanelStore.getState().openTerminal(activeThreadRef, targetTerminalId);
5148 } else {
5149 setTerminalOpen(true);
5150 if (shouldCreateNewTerminal) {
5151 storeNewTerminal(activeThreadRef, targetTerminalId);
5152 } else {
5153 storeSetActiveTerminal(activeThreadRef, targetTerminalId);
5154 }
5155 }
5156
5157 const openResult = await openTerminal({ environmentId, input: openTerminalInput });
5158 if (openResult._tag === "Failure") {
5159 if (!isAtomCommandInterrupted(openResult)) {
5160 const error = squashAtomCommandFailure(openResult);
5161 setThreadError(
5162 activeThreadId,
5163 error instanceof Error ? error.message : `Failed to run script "${script.name}".`,
5164 );
5165 }
5166 return;
5167 }
5168
5169 if (!hasTerminalWriteAccess()) return;
5170 const writeResult = await writeTerminal({
5171 environmentId,
5172 input: {
5173 threadId: activeThreadId,
5174 terminalId: targetTerminalId,
5175 data: `${script.command}\r`,
5176 },
5177 });
5178 if (writeResult._tag === "Failure") {
5179 if (!isAtomCommandInterrupted(writeResult)) {
5180 const error = squashAtomCommandFailure(writeResult);
5181 setThreadError(
5182 activeThreadId,
5183 error instanceof Error ? error.message : `Failed to run script "${script.name}".`,
5184 );
5185 }
5186 return;
5187 }
5188 if (script.autoOpenPreview && script.previewUrl && isPreviewSupportedInRuntime()) {
5189 const previewResult = await openUrlInPreview({
5190 threadRef: activeThreadRef,
5191 url: resolveDiscoveredServerUrl(activeThreadRef.environmentId, script.previewUrl),
5192 openPreview,
5193 });
5194 if (previewResult._tag === "Failure" && !isAtomCommandInterrupted(previewResult)) {
5195 const error = squashAtomCommandFailure(previewResult);
5196 toastManager.add(
5197 stackedThreadToast({
5198 type: "error",
5199 title: "Could not open preview",
5200 description: error instanceof Error ? error.message : "An unexpected error occurred.",
5201 }),
5202 );
5203 }
5204 }
5205 },
5206 [
5207 activeProject,
5208 activeThread,
5209 activeThreadId,
5210 activeThreadRef,
5211 gitCwd,
5212 setTerminalOpen,
5213 setThreadError,
5214 storeNewTerminal,
5215 storeSetActiveTerminal,
5216 setLastInvokedScriptByProjectId,
5217 environmentId,
5218 openTerminal,
5219 openPreview,
5220 hasTerminalWriteAccess,
5221 activeKnownTerminalIds,
5222 canReuseTerminal,
5223 allocateTerminalId,
5224 runningTerminalIds,
5225 terminalPlacement,
5226 terminalUiState.terminalIds,
5227 terminalUiState.activeTerminalId,
5228 writeTerminal,
5229 ],
5230 );
5231
5232 const runProjectScriptRef = useRef(runProjectScript);
5233 useLayoutEffect(() => {
5234 runProjectScriptRef.current = runProjectScript;
5235 }, [runProjectScript]);
5236 const runShellCommand = useCallback((command: string) => {
5237 void runProjectScriptRef.current(
5238 {
5239 id: "chat-code-block",
5240 name: "Chat code block",
5241 command,
5242 icon: "play",
5243 runOnWorktreeCreate: false,
5244 },
5245 { rememberAsLastInvoked: false },
5246 );
5247 }, []);
5248
5249 const supportsProjectSettingsOverrides =
5250 environmentById.get(environmentId)?.serverConfig?.environment.capabilities
5251 .projectSettingsOverrides === true;
5252 const persistProjectScripts = useCallback(
5253 async (input: {
5254 projectId: ProjectId;
5255 projectCwd: string;
5256 previousScripts: ReadonlyArray<ProjectScript>;
5257 nextScripts: ReadonlyArray<ProjectScript>;
5258 keybinding: NewProjectScriptInput["keybinding"];
5259 keybindingCommand: KeybindingCommand | null;
5260 }): Promise<AtomCommandResult<void, unknown>> => {
5261 const previousKeybinding = keybindingValueForCommand(
5262 appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId))?.keybindings ?? [],
5263 input.keybindingCommand,
5264 );
5265 const isDeletingScript = !input.nextScripts.some(
5266 (script) => commandForProjectScript(script.id) === input.keybindingCommand,
5267 );
5268 const changesKeybinding =
5269 input.keybinding !== undefined &&
5270 (input.keybinding?.trim() || null) !== previousKeybinding &&
5271 (!isDeletingScript || readEnvironmentScope(environmentId, AuthSettingsWriteScope));
5272 if (changesKeybinding && !readEnvironmentScope(environmentId, AuthSettingsWriteScope)) {
5273 return AsyncResult.failure(
5274 Cause.fail(
5275 new EnvironmentAuthorizationError({
5276 requiredScope: AuthSettingsWriteScope,
5277 message: "This connection cannot change keyboard shortcuts.",
5278 }),
5279 ),
5280 );
5281 }
5282 const keybindingRule = changesKeybinding
5283 ? decodeProjectScriptKeybindingRule({
5284 keybinding: input.keybinding,
5285 command: input.keybindingCommand,
5286 })
5287 : null;
5288 const updateResult = mapAtomCommandResult(
5289 await updateProjectScriptSettings({
5290 environmentId,
5291 input: {
5292 // The canonical key on servers that understand it; the legacy
5293 // per-project map is still translated on older ones.
5294 patch: supportsProjectSettingsOverrides
5295 ? {
5296 projectSettingsOverrides: {
5297 [input.projectId]: {
5298 ...settings.projectSettingsOverrides[input.projectId],
5299 defaultProjectScripts: input.nextScripts,
5300 },
5301 },
5302 }
5303 : {
5304 projectScriptOverrides: {
5305 [input.projectId]: input.nextScripts,
5306 },
5307 },
5308 },
5309 }),
5310 () => undefined,
5311 );
5312 if (updateResult._tag === "Failure") {
5313 return updateResult;
5314 }
5315
5316 if (!changesKeybinding) return updateResult;
5317 if (!readEnvironmentScope(environmentId, AuthSettingsWriteScope)) {
5318 return AsyncResult.failure(
5319 Cause.fail(
5320 new EnvironmentAuthorizationError({
5321 requiredScope: AuthSettingsWriteScope,
5322 message: isDeletingScript
5323 ? "The script was deleted, but its keyboard shortcut could not be removed because permission changed."
5324 : "The script was saved, but this connection can no longer change keyboard shortcuts.",
5325 }),
5326 ),
5327 );
5328 }
5329
5330 const scriptId = input.keybindingCommand
5331 ? projectScriptIdFromCommand(input.keybindingCommand)
5332 : null;
5333 if (!keybindingRule && !input.previousScripts.some((script) => script.id === scriptId)) {
5334 return updateResult;
5335 }
5336 const retainedElsewhere =
5337 !input.nextScripts.some((script) => script.id === scriptId) &&
5338 (settings.defaultProjectScripts.some((script) => script.id === scriptId) ||
5339 Object.entries(settings.projectSettingsOverrides).some(
5340 ([projectId, entry]) =>
5341 projectId !== input.projectId &&
5342 entry.defaultProjectScripts?.some((script) => script.id === scriptId),
5343 ) ||
5344 allProjects.some(
5345 (other) =>
5346 other.environmentId === environmentId &&
5347 other.id !== input.projectId &&
5348 resolveProjectScripts(settings, other).some((script) => script.id === scriptId),
5349 ));
5350 if (!keybindingRule && retainedElsewhere) return updateResult;
5351
5352 const previousRules = (
5353 environmentById.get(environmentId)?.serverConfig?.keybindings ?? []
5354 ).flatMap((binding) => {
5355 if (binding.command !== input.keybindingCommand || binding.whenAst) return [];
5356 const previous = decodeProjectScriptKeybindingRule({
5357 keybinding: keybindingValueForCommand([binding], input.keybindingCommand),
5358 command: input.keybindingCommand,
5359 });
5360 return previous ? [previous] : [];
5361 });
5362 const previous = previousRules.at(-1);
5363 for (const rule of keybindingRule ? previousRules.slice(0, -1) : previousRules) {
5364 const result = await removeKeybinding({ environmentId, input: rule });
5365 if (result._tag === "Failure") return mapAtomCommandResult(result, () => undefined);
5366 }
5367 return keybindingRule
5368 ? mapAtomCommandResult(
5369 await upsertKeybinding({
5370 environmentId,
5371 input:
5372 previous && previous.key !== keybindingRule.key
5373 ? { ...keybindingRule, replace: previous }
5374 : keybindingRule,
5375 }),
5376 () => undefined,
5377 )
5378 : updateResult;
5379 },
5380 [
5381 allProjects,
5382 environmentById,
5383 environmentId,
5384 removeKeybinding,
5385 settings,
5386 supportsProjectSettingsOverrides,
5387 updateProjectScriptSettings,
5388 upsertKeybinding,
5389 ],
5390 );
5391 const saveProjectScript = useCallback(
5392 async (input: NewProjectScriptInput): Promise<AtomCommandResult<void, unknown>> => {
5393 if (!activeProject) {
5394 return AsyncResult.success(undefined);
5395 }
5396 const nextId = nextProjectScriptId(
5397 input.name,
5398 activeProjectScripts.map((script) => script.id),
5399 );
5400 const nextScript = buildProjectScript(nextId, input);
5401 const nextScripts = [
5402 ...activeProjectScripts.map((script) => releaseClaimedRoles(script, input)),
5403 nextScript,
5404 ];
5405
5406 return persistProjectScripts({
5407 projectId: activeProject.id,
5408 projectCwd: activeProject.workspaceRoot,
5409 previousScripts: activeProjectScripts,
5410 nextScripts,
5411 keybinding: input.keybinding,
5412 keybindingCommand: commandForProjectScript(nextId),
5413 });
5414 },
5415 [activeProject, activeProjectScripts, persistProjectScripts],
5416 );
5417 const updateProjectScript = useCallback(
5418 async (
5419 scriptId: string,
5420 input: NewProjectScriptInput,
5421 ): Promise<AtomCommandResult<void, unknown>> => {
5422 if (!activeProject) {
5423 return AsyncResult.success(undefined);
5424 }
5425 const existingScript = activeProjectScripts.find((script) => script.id === scriptId);
5426 if (!existingScript) {
5427 return AsyncResult.failure(Cause.fail(new Error("Script not found.")));
5428 }
5429
5430 const updatedScript = buildProjectScript(existingScript.id, input);
5431 const nextScripts = activeProjectScripts.map((script) =>
5432 script.id === scriptId ? updatedScript : releaseClaimedRoles(script, input),
5433 );
5434
5435 return persistProjectScripts({
5436 projectId: activeProject.id,
5437 projectCwd: activeProject.workspaceRoot,
5438 previousScripts: activeProjectScripts,
5439 nextScripts,
5440 keybinding: input.keybinding,
5441 keybindingCommand: commandForProjectScript(scriptId),
5442 });
5443 },
5444 [activeProject, activeProjectScripts, persistProjectScripts],
5445 );
5446 const deleteProjectScript = useCallback(
5447 async (scriptId: string): Promise<AtomCommandResult<void, unknown>> => {
5448 if (!activeProject) {
5449 return AsyncResult.success(undefined);
5450 }
5451 const nextScripts = activeProjectScripts.filter((script) => script.id !== scriptId);
5452
5453 const deletedName = activeProjectScripts.find((s) => s.id === scriptId)?.name;
5454
5455 const result = await persistProjectScripts({
5456 projectId: activeProject.id,
5457 projectCwd: activeProject.workspaceRoot,
5458 previousScripts: activeProjectScripts,
5459 nextScripts,
5460 keybinding: null,
5461 keybindingCommand: commandForProjectScript(scriptId),
5462 });
5463 if (result._tag === "Success") {
5464 toastManager.add({
5465 type: "success",
5466 title: `Deleted action "${deletedName ?? "Unknown"}"`,
5467 });
5468 } else if (!isAtomCommandInterrupted(result)) {
5469 const error = squashAtomCommandFailure(result);
5470 toastManager.add(
5471 stackedThreadToast({
5472 type: "error",
5473 title: "Could not delete action",
5474 description: error instanceof Error ? error.message : "An unexpected error occurred.",
5475 }),
5476 );
5477 }
5478 return result;
5479 },
5480 [activeProject, activeProjectScripts, persistProjectScripts],
5481 );
5482
5483 const handleRuntimeModeChange = useCallback(
5484 (mode: RuntimeMode) => {
5485 if (mode === runtimeMode) return;
5486 setComposerDraftRuntimeMode(composerDraftTarget, mode);
5487 if (isLocalDraftThread) {
5488 setDraftThreadContext(composerDraftTarget, { runtimeMode: mode });
5489 }
5490 scheduleComposerFocus();
5491 },
5492 [
5493 isLocalDraftThread,
5494 runtimeMode,
5495 scheduleComposerFocus,
5496 composerDraftTarget,
5497 setComposerDraftRuntimeMode,
5498 setDraftThreadContext,
5499 ],
5500 );
5501
5502 const handleInteractionModeChange = useCallback(
5503 (mode: ProviderInteractionMode) => {
5504 if (mode === "plan" && !interactionModeEnabled) return;
5505 if (mode === interactionMode) return;
5506 setComposerDraftInteractionMode(composerDraftTarget, mode);
5507 if (isLocalDraftThread) {
5508 setDraftThreadContext(composerDraftTarget, { interactionMode: mode });
5509 }
5510 scheduleComposerFocus();
5511 },
5512 [
5513 interactionMode,
5514 interactionModeEnabled,
5515 isLocalDraftThread,
5516 scheduleComposerFocus,
5517 composerDraftTarget,
5518 setComposerDraftInteractionMode,
5519 setDraftThreadContext,
5520 ],
5521 );
5522 const toggleInteractionMode = useCallback(() => {
5523 if (!interactionModeEnabled) return;
5524 handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");
5525 }, [handleInteractionModeChange, interactionMode, interactionModeEnabled]);
5526 const openProviderSetup = useCallback(
5527 (instanceId: ProviderInstanceId) => {
5528 void navigate({
5529 to: "/settings/providers",
5530 search: { environmentId, instanceId },
5531 });
5532 },
5533 [environmentId, navigate],
5534 );
5535 const canOperatePreview = useEnvironmentScope(
5536 activeThreadRef?.environmentId ?? null,
5537 AuthPreviewOperateScope,
5538 );
5539 const createBrowserSurface = useCallback(
5540 (profileId?: string) => {
5541 if (!activeThreadRef || !canOperatePreview) return;
5542 void addBrowserSurface({
5543 threadRef: activeThreadRef,
5544 openPreview,
5545 ...(profileId === undefined ? {} : { profileId }),
5546 }).then((result) => {
5547 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
5548 const error = squashAtomCommandFailure(result);
5549 if (error instanceof BrowserSettingsReadError) {
5550 toastManager.add(
5551 stackedThreadToast({
5552 type: "error",
5553 title: "Unable to open browser",
5554 description: error.message,
5555 }),
5556 );
5557 }
5558 });
5559 },
5560 [activeThreadRef, canOperatePreview, openPreview],
5561 );
5562 const addDiffSurface = useCallback(() => {
5563 if (!activeThreadRef || !workspaceChangesAvailable) return;
5564 useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch");
5565 useRightPanelStore.getState().open(activeThreadRef, "diff");
5566 onDiffPanelOpen?.();
5567 }, [activeThreadRef, workspaceChangesAvailable, onDiffPanelOpen]);
5568 const openChangesFromThreadPanel = useCallback(() => {
5569 addDiffSurface();
5570 }, [addDiffSurface]);
5571 const addFilesSurface = useCallback(() => {
5572 if (!activeThreadRef || !activeProject) return;
5573 useRightPanelStore.getState().open(activeThreadRef, "files");
5574 }, [activeProject, activeThreadRef]);
5575 const supportsThreadPullRequests =
5576 serverConfig?.environment.capabilities.threadPullRequests === true;
5577 const visiblePullRequests = visibleThreadPullRequests(
5578 (activeThreadShell ?? activeThread)?.pullRequests ?? [],
5579 );
5580 const visiblePullRequestCount = visiblePullRequests.length;
5581 const pullRequestsSurfaceAvailable =
5582 isServerThread && supportsThreadPullRequests && visiblePullRequestCount > 0;
5583 const addPullRequestsSurface = useCallback(() => {
5584 if (!activeThreadRef || !pullRequestsSurfaceAvailable) return;
5585 useRightPanelStore.getState().open(activeThreadRef, "pull-requests");
5586 }, [activeThreadRef, pullRequestsSurfaceAvailable]);
5587 const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState(
5588 activeThreadRef?.environmentId ?? null,
5589 );
5590 const [deviceSetupThread, setDeviceSetupThread] = useState<ScopedThreadRef | null>(null);
5591 const addDeviceSurface = useCallback(() => {
5592 if (!activeThreadRef) return;
5593 if (!deviceState.onboardingCompleted || deviceState.hostStatus === "disabled") {
5594 setDeviceSetupThread(activeThreadRef);
5595 return;
5596 }
5597 useRightPanelStore.getState().open(activeThreadRef, "device");
5598 }, [activeThreadRef, deviceState.onboardingCompleted, deviceState.hostStatus]);
5599 // A device the agent opens floats over chat like an agent-driven browser,
5600 // or becomes a panel tab when floating previews are off. Sessions opened by
5601 // another client arrive the same way; sheet layouts get neither. The first
5602 // snapshot is a baseline: persisted tabs restore themselves, and existing
5603 // sessions must not resurrect closed tabs. A session whose device summary
5604 // has not arrived yet stays out of the baseline so a later snapshot opens it.
5605 const autoShowFloatingPreview = useClientSettings(selectAutoShowFloatingPreview);
5606 const previousDeviceSessions = useRef(new Map<string, Set<string>>());
5607 useEffect(() => {
5608 if (!activeThreadRef || !deviceStateLoaded) return;
5609 const threadKey = `${activeThreadRef.environmentId}:${activeThreadRef.threadId}`;
5610 const sessions = deviceState.sessions.filter(
5611 (session) => session.threadId === activeThreadRef.threadId,
5612 );
5613 const key = (session: (typeof sessions)[number]) => `${session.hostId}:${session.deviceId}`;
5614 const deviceFor = (session: (typeof sessions)[number]) =>
5615 deviceState.devices.find(
5616 (entry) => entry.hostId === session.hostId && entry.id === session.deviceId,
5617 );
5618 const previous = previousDeviceSessions.current.get(threadKey);
5619 previousDeviceSessions.current.set(
5620 threadKey,
5621 new Set(sessions.filter((session) => deviceFor(session) !== undefined).map(key)),
5622 );
5623 if (!previous || shouldUsePlanSidebarSheet) return;
5624 for (const session of sessions) {
5625 if (previous.has(key(session))) continue;
5626 const device = deviceFor(session);
5627 if (!device) continue;
5628 const target = {
5629 hostId: session.hostId,
5630 deviceId: session.deviceId,
5631 platform: device.platform,
5632 name: device.name,
5633 };
5634 if (autoShowFloatingPreview) {
5635 usePreviewMiniPlayerStore.getState().open(activeThreadRef, { kind: "device", ...target });
5636 continue;
5637 }
5638 const existing = useRightPanelStore
5639 .getState()
5640 .byThreadKey[scopedThreadKey(activeThreadRef)]?.surfaces.some(
5641 (surface) =>
5642 surface.kind === "device" &&
5643 surface.target?.hostId === session.hostId &&
5644 surface.target.deviceId === session.deviceId,
5645 );
5646 if (existing) continue;
5647 useRightPanelStore.getState().openDevice(activeThreadRef, target, true);
5648 }
5649 }, [
5650 activeThreadRef,
5651 autoShowFloatingPreview,
5652 deviceStateLoaded,
5653 shouldUsePlanSidebarSheet,
5654 deviceState.sessions,
5655 deviceState.devices,
5656 ]);
5657 // Baseline loaded tabs so reloads never reopen previews the user dismissed.
5658 const previousServerPreviewTabs = useRef(new Map<string, Map<string, string | undefined>>());
5659 useEffect(() => {
5660 if (!activeThreadRef || !activeEnvironmentServerBrowser || !activePreviewState.listLoaded)
5661 return;
5662 const threadKey = scopedThreadKey(activeThreadRef);
5663 const serverSessions = Object.values(activePreviewState.sessions).filter(
5664 (session) => session.runtime === "server",
5665 );
5666 const previous = previousServerPreviewTabs.current.get(threadKey);
5667 previousServerPreviewTabs.current.set(
5668 threadKey,
5669 new Map(serverSessions.map((session) => [session.tabId, session.revealRequest?.id])),
5670 );
5671 if (!previous) return;
5672 for (const session of serverSessions) {
5673 const requested = session.revealRequest;
5674 const fresh = requested
5675 ? previous.get(session.tabId) !== requested.id
5676 : !previous.has(session.tabId);
5677 if (!fresh || session.reveal !== true) continue;
5678 if (!autoShowFloatingPreview && requested?.force !== true) continue;
5679 const surface = rightPanelState.surfaces.find(
5680 (surface) => surface.kind === "preview" && surface.resourceId === session.tabId,
5681 );
5682 if (surface && requested?.force === true) {
5683 useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id);
5684 } else if (!surface) {
5685 usePreviewMiniPlayerStore
5686 .getState()
5687 .open(activeThreadRef, browserMiniPlayerSource(session.tabId));
5688 }
5689 }
5690 }, [
5691 activeEnvironmentServerBrowser,
5692 activePreviewState.listLoaded,
5693 activePreviewState.sessions,
5694 activeThreadRef,
5695 autoShowFloatingPreview,
5696 rightPanelState.surfaces,
5697 ]);
5698 // A floating device follows its session: once the agent or another client
5699 // closes the device there is nothing left to stream.
5700 useEffect(() => {
5701 if (!activeThreadRef || !deviceStateLoaded) return;
5702 const source = activePreviewMiniPlayer?.source;
5703 if (source?.kind !== "device") return;
5704 const sessionStillExists = deviceState.sessions.some(
5705 (session) =>
5706 session.threadId === activeThreadRef.threadId &&
5707 session.hostId === source.hostId &&
5708 session.deviceId === source.deviceId,
5709 );
5710 if (!sessionStillExists) usePreviewMiniPlayerStore.getState().close(activeThreadRef);
5711 }, [activePreviewMiniPlayer, activeThreadRef, deviceState.sessions, deviceStateLoaded]);
5712 const openFileSurface = useCallback(
5713 (relativePath: string) => {
5714 if (!activeThreadRef || !activeProject) return;
5715 useRightPanelStore.getState().openFile(activeThreadRef, relativePath);
5716 },
5717 [activeProject, activeThreadRef],
5718 );
5719 // The thread's own change request, placed against the project it belongs to. Without a
5720 // project there is nothing to resolve it against, so the caller falls back to the browser.
5721 const persistedLinkedThreadPullRequest = isServerThread
5722 ? (activeThreadShell?.linkedPullRequest ?? activeThread?.linkedPullRequest ?? null)
5723 : (activeThread?.linkedPullRequest ?? null);
5724 const activeProjectRepository = sourceControlRepositorySelector(
5725 activeProject?.repositoryIdentity,
5726 );
5727 const persistedLinkedThreadPullRequestStatus = useLinkedThreadPullRequest(
5728 activeThreadRef?.environmentId ?? null,
5729 persistedLinkedThreadPullRequest,
5730 );
5731 const replacementLinkedThreadPullRequest = useMemo(() => {
5732 const detected = gitStatusQuery.data?.pr;
5733 const threadBranch = activeThread?.branch;
5734 const projectId = activeProject?.id;
5735 if (
5736 persistedLinkedThreadPullRequest === null ||
5737 (persistedLinkedThreadPullRequestStatus?.pr.state !== "merged" &&
5738 persistedLinkedThreadPullRequestStatus?.pr.state !== "closed") ||
5739 gitStatusQuery.data?.refName !== threadBranch ||
5740 detected?.state !== "open" ||
5741 detected.headRef !== threadBranch ||
5742 projectId === undefined ||
5743 activeProjectRepository === null ||
5744 (persistedLinkedThreadPullRequest.projectId === projectId &&
5745 persistedLinkedThreadPullRequest.repository.toLowerCase() ===
5746 activeProjectRepository.toLowerCase() &&
5747 persistedLinkedThreadPullRequest.number === detected.number)
5748 ) {
5749 return null;
5750 }
5751 return {
5752 projectId,
5753 repository: activeProjectRepository,
5754 number: detected.number,
5755 url: detected.url,
5756 };
5757 }, [
5758 activeProject?.id,
5759 activeProjectRepository,
5760 activeThread?.branch,
5761 gitStatusQuery.data,
5762 persistedLinkedThreadPullRequest,
5763 persistedLinkedThreadPullRequestStatus?.pr.state,
5764 ]);
5765 const linkedThreadPullRequest =
5766 replacementLinkedThreadPullRequest ?? persistedLinkedThreadPullRequest;
5767 const linkedThreadPullRequestKey = linkedThreadPullRequest
5768 ? JSON.stringify([
5769 linkedThreadPullRequest.projectId,
5770 linkedThreadPullRequest.repository,
5771 linkedThreadPullRequest.number,
5772 ])
5773 : null;
5774 const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository;
5775 const threadPrRelinkKeysRef = useRef(new Map<string, string>());
5776 const threadPrRelinkWriteRef = useRef(Promise.resolve());
5777 useEffect(() => {
5778 if (!isServerThread || activeThreadKey === null || activeThreadRef === null) {
5779 return;
5780 }
5781 if (replacementLinkedThreadPullRequest === null) {
5782 threadPrRelinkKeysRef.current.delete(activeThreadKey);
5783 return;
5784 }
5785 const relinkKey = `${replacementLinkedThreadPullRequest.projectId}:${replacementLinkedThreadPullRequest.repository}#${replacementLinkedThreadPullRequest.number}`;
5786 if (threadPrRelinkKeysRef.current.get(activeThreadKey) === relinkKey) return;
5787 threadPrRelinkKeysRef.current.set(activeThreadKey, relinkKey);
5788 const openSurface = selectActiveRightPanelSurface(
5789 useRightPanelStore.getState().byThreadKey,
5790 activeThreadRef,
5791 );
5792 if (
5793 openSurface?.kind === "pull-request" &&
5794 persistedLinkedThreadPullRequest !== null &&
5795 openSurface.projectId === persistedLinkedThreadPullRequest.projectId &&
5796 openSurface.repository.toLowerCase() ===
5797 persistedLinkedThreadPullRequest.repository.toLowerCase() &&
5798 openSurface.number === persistedLinkedThreadPullRequest.number
5799 ) {
5800 useRightPanelStore
5801 .getState()
5802 .openPullRequest(activeThreadRef, replacementLinkedThreadPullRequest);
5803 }
5804
5805 threadPrRelinkWriteRef.current = threadPrRelinkWriteRef.current.then(async () => {
5806 if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return;
5807 const result = await updateThreadMetadata({
5808 environmentId: activeThreadRef.environmentId,
5809 input: {
5810 threadId: activeThreadRef.threadId,
5811 linkedPullRequest: replacementLinkedThreadPullRequest,
5812 },
5813 });
5814 if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return;
5815 if (result._tag !== "Failure") return;
5816 threadPrRelinkKeysRef.current.delete(activeThreadKey);
5817 if (isAtomCommandInterrupted(result)) return;
5818 toastManager.add(
5819 stackedThreadToast({
5820 type: "error",
5821 title: "Unable to update the thread pull request",
5822 description: chatActionErrorMessage(squashAtomCommandFailure(result)),
5823 }),
5824 );
5825 });
5826 }, [
5827 activeThreadKey,
5828 activeThreadRef,
5829 isServerThread,
5830 persistedLinkedThreadPullRequest,
5831 replacementLinkedThreadPullRequest,
5832 updateThreadMetadata,
5833 ]);
5834 const hasLinkedPullRequestDetail = persistedLinkedThreadPullRequest !== null;
5835 const proactivePullRequestsKey = pullRequestsSurfaceAvailable
5836 ? JSON.stringify(
5837 visiblePullRequests.map((link) => [link.host, link.repository, link.number]).sort(),
5838 )
5839 : linkedThreadPullRequestKey;
5840 const activeRunningTurnId = activeRuntime?.activeRunId ?? null;
5841 const proactivePanelObservationRef = useRef<ReturnType<
5842 typeof observeProactivePanelUserChoice
5843 > | null>(null);
5844 const observedThreadPullRequestRef = useRef<{
5845 readonly threadKey: string;
5846 readonly reference: ThreadLinkedPullRequest | null;
5847 } | null>(null);
5848
5849 useEffect(() => {
5850 if (!isServerThread || activeThreadKey === null || activeThreadRef === null) {
5851 proactivePanelObservationRef.current = null;
5852 observedThreadPullRequestRef.current = null;
5853 return;
5854 }
5855 const panels = useRightPanelStore.getState();
5856 const observation = observeProactivePanelUserChoice(proactivePanelObservationRef.current, {
5857 threadKey: activeThreadKey,
5858 runningTurnId: activeRunningTurnId,
5859 userActionRevision: panels.getUserActionRevision(activeThreadRef),
5860 });
5861 proactivePanelObservationRef.current = observation;
5862 const {
5863 runningTurnId: previousRunningTurnId,
5864 targetKey: previousTargetKey,
5865 userActionRevision,
5866 } = observation;
5867 const openSurface = selectActiveRightPanelSurface(panels.byThreadKey, activeThreadRef);
5868 const previousPullRequest = observedThreadPullRequestRef.current;
5869 observedThreadPullRequestRef.current = {
5870 threadKey: activeThreadKey,
5871 reference: linkedThreadPullRequest,
5872 };
5873 const followSelectedPullRequest =
5874 previousPullRequest?.threadKey === activeThreadKey &&
5875 shouldRetargetThreadPullRequestPanel(
5876 previousPullRequest.reference,
5877 linkedThreadPullRequest,
5878 openSurface,
5879 );
5880 // Following the selected linked PR does not open an unrelated panel, so it
5881 // remains available with proactive panels off. It still respects a later choice.
5882 if (followSelectedPullRequest && linkedThreadPullRequest !== null) {
5883 panels.openProactive(
5884 activeThreadRef,
5885 pullRequestSurface(linkedThreadPullRequest),
5886 userActionRevision,
5887 );
5888 }
5889 if (!clientSettingsHydrated) return;
5890
5891 const proactivePanelsEnabled = settings.proactivePanelsEnabled && !shouldUsePlanSidebarSheet;
5892 const eligibleLink =
5893 proactivePanelsEnabled &&
5894 shouldOpenProactivePullRequest(previousTargetKey, proactivePullRequestsKey);
5895 const shouldDeferLink = eligibleLink && !pullRequestsCapabilityKnown;
5896 proactivePanelObservationRef.current = {
5897 ...observation,
5898 targetKey: shouldDeferLink ? (previousTargetKey ?? null) : proactivePullRequestsKey,
5899 };
5900 if (eligibleLink && pullRequestsCapabilityKnown) {
5901 if (
5902 pullRequestsSurfaceAvailable &&
5903 (visiblePullRequestCount > 1 || !hasLinkedPullRequestDetail || !supportsPullRequests)
5904 ) {
5905 panels.openProactive(
5906 activeThreadRef,
5907 { id: "pull-requests", kind: "pull-requests" },
5908 userActionRevision,
5909 );
5910 } else if (
5911 !followSelectedPullRequest &&
5912 supportsPullRequests &&
5913 linkedThreadPullRequest !== null
5914 ) {
5915 panels.openProactive(
5916 activeThreadRef,
5917 pullRequestSurface(linkedThreadPullRequest),
5918 userActionRevision,
5919 );
5920 }
5921 }
5922 if (threadDetailLoading) return;
5923
5924 const settledTurnId = latestRunSettled ? (activeLatestRun?.runId ?? null) : null;
5925 const newlyCompletedTurnId = shouldOpenProactiveTurnDiff({
5926 previousRunningTurnId,
5927 runningTurnId: activeRunningTurnId,
5928 settledTurnId,
5929 turnCompleted: activeLatestRun?.status === "completed",
5930 })
5931 ? settledTurnId
5932 : null;
5933 const eligibleCompletion =
5934 proactivePanelsEnabled &&
5935 newlyCompletedTurnId !== null &&
5936 !(
5937 proactivePullRequestsKey !== null &&
5938 (!pullRequestsCapabilityKnown || supportsPullRequests || pullRequestsSurfaceAvailable)
5939 );
5940 const completedCheckpoint = eligibleCompletion
5941 ? turnDiffSummaries.find((checkpoint) => checkpoint.runId === newlyCompletedTurnId)
5942 : undefined;
5943 const diffAction = eligibleCompletion
5944 ? resolveProactiveTurnDiffAction({
5945 checkpoint: completedCheckpoint,
5946 isGitRepo: gitStatusQuery.data?.isRepo,
5947 activeSurfaceKind: openSurface?.kind ?? null,
5948 })
5949 : "ignore";
5950 proactivePanelObservationRef.current = {
5951 ...proactivePanelObservationRef.current,
5952 // Preserve first-entry eligibility while capabilities, checkpoint or repository load.
5953 runningTurnId:
5954 diffAction === "defer" || shouldDeferLink ? previousRunningTurnId : activeRunningTurnId,
5955 };
5956 if (diffAction !== "open" || newlyCompletedTurnId === null) return;
5957 if (!panels.openProactive(activeThreadRef, { id: "diff", kind: "diff" }, userActionRevision)) {
5958 return;
5959 }
5960 useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch");
5961 onDiffPanelOpen?.();
5962 }, [
5963 turnDiffSummaries,
5964 activeLatestRun?.runId,
5965 activeLatestRun?.status,
5966 activeRunningTurnId,
5967 activeThreadKey,
5968 activeThreadRef,
5969 clientSettingsHydrated,
5970 gitStatusQuery.data?.isRepo,
5971 isServerThread,
5972 latestRunSettled,
5973 linkedThreadPullRequest,
5974 proactivePullRequestsKey,
5975 hasLinkedPullRequestDetail,
5976 onDiffPanelOpen,
5977 pullRequestsCapabilityKnown,
5978 pullRequestsSurfaceAvailable,
5979 visiblePullRequestCount,
5980
5981 settings.proactivePanelsEnabled,
5982 shouldUsePlanSidebarSheet,
5983 threadDetailLoading,
5984 ]);
5985 const closePreviewPanel = useCallback(() => {
5986 if (activeThreadRef) {
5987 // Closing the panel on a live browser or device floats it instead of dropping it.
5988 if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) {
5989 usePreviewMiniPlayerStore
5990 .getState()
5991 .open(activeThreadRef, browserMiniPlayerSource(activeRightPanelSurface.resourceId));
5992 } else if (activeRightPanelSurface?.kind === "device" && activeRightPanelSurface.target) {
5993 usePreviewMiniPlayerStore
5994 .getState()
5995 .open(activeThreadRef, { kind: "device", ...activeRightPanelSurface.target });
5996 }
5997 useRightPanelStore.getState().setMaximized(activeThreadRef, false);
5998 useRightPanelStore.getState().close(activeThreadRef);
5999 }
6000 }, [activeRightPanelSurface, activeThreadRef]);
6001 const togglePreviewPanel = useCallback(() => {
6002 if (!activeThreadRef || !browserAvailable) return;
6003 if (previewPanelOpen) {
6004 closePreviewPanel();
6005 return;
6006 }
6007 if (!canOperatePreview) return;
6008 const activeTabId = activePreviewState.activeTabId;
6009 if (activeTabId) {
6010 useRightPanelStore.getState().openBrowser(activeThreadRef, activeTabId);
6011 } else {
6012 createBrowserSurface();
6013 }
6014 }, [
6015 activePreviewState.activeTabId,
6016 activeThreadRef,
6017 browserAvailable,
6018 canOperatePreview,
6019 closePreviewPanel,
6020 createBrowserSurface,
6021 previewPanelOpen,
6022 ]);
6023 const splitPanelTerminal = useCallback(
6024 (direction: "horizontal" | "vertical" = "horizontal") => {
6025 if (
6026 !hasTerminalWriteAccess() ||
6027 !activeThreadRef ||
6028 !activeThreadId ||
6029 !activeProject ||
6030 activeRightPanelSurface?.kind !== "terminal" ||
6031 activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP
6032 ) {
6033 return;
6034 }
6035 const terminalId = allocateTerminalId();
6036 const cwd = gitCwd ?? activeProject.workspaceRoot;
6037 useRightPanelStore
6038 .getState()
6039 .splitTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId, direction);
6040 setTerminalFocusRequestId((value) => value + 1);
6041 void openTerminal({
6042 environmentId: activeThreadRef.environmentId,
6043 input: {
6044 threadId: activeThreadId,
6045 terminalId,
6046 cwd,
6047 ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
6048 env: projectScriptRuntimeEnv({
6049 project: { cwd: activeProject.workspaceRoot },
6050 worktreePath: activeThreadWorktreePath,
6051 }),
6052 },
6053 });
6054 },
6055 [
6056 activeProject,
6057 activeRightPanelSurface,
6058 activeThreadId,
6059 activeThreadRef,
6060 activeThreadWorktreePath,
6061 allocateTerminalId,
6062 gitCwd,
6063 openTerminal,
6064 hasTerminalWriteAccess,
6065 ],
6066 );
6067 const splitPanelTerminalVertical = useCallback(() => {
6068 splitPanelTerminal("vertical");
6069 }, [splitPanelTerminal]);
6070 const activatePanelTerminal = useCallback(
6071 (terminalId: string) => {
6072 if (!activeThreadRef || activeRightPanelSurface?.kind !== "terminal") return;
6073 useRightPanelStore
6074 .getState()
6075 .activateTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId);
6076 setTerminalFocusRequestId((value) => value + 1);
6077 },
6078 [activeRightPanelSurface, activeThreadRef],
6079 );
6080 const closePanelTerminal = useCallback(
6081 (terminalId: string) => {
6082 if (
6083 !hasTerminalWriteAccess() ||
6084 !activeThreadRef ||
6085 activeRightPanelSurface?.kind !== "terminal"
6086 )
6087 return;
6088 void closeTerminalMutation({
6089 environmentId: activeThreadRef.environmentId,
6090 input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true },
6091 });
6092 storeCloseTerminal(activeThreadRef, terminalId);
6093 useRightPanelStore
6094 .getState()
6095 .closeTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId);
6096 setTerminalFocusRequestId((value) => value + 1);
6097 },
6098 [
6099 hasTerminalWriteAccess,
6100 activeRightPanelSurface,
6101 activeThreadRef,
6102 closeTerminalMutation,
6103 storeCloseTerminal,
6104 ],
6105 );
6106 const requestCloseTerminal = useCallback(
6107 (terminalId: string) => {
6108 if (!hasTerminalWriteAccess() || !activeThreadRef) return;
6109 void confirmTerminalClose(activeThreadRef, [terminalId]).then((confirmed) => {
6110 if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
6111 closeTerminal(terminalId);
6112 }
6113 });
6114 },
6115 [hasTerminalWriteAccess, activeThreadRef, closeTerminal, environmentId],
6116 );
6117 const requestClosePanelTerminal = useCallback(
6118 (terminalId: string) => {
6119 if (!hasTerminalWriteAccess() || !activeThreadRef) return;
6120 void confirmTerminalClose(activeThreadRef, [terminalId]).then((confirmed) => {
6121 if (confirmed && readEnvironmentScope(environmentId, AuthTerminalOperateScope)) {
6122 closePanelTerminal(terminalId);
6123 }
6124 });
6125 },
6126 [hasTerminalWriteAccess, activeThreadRef, closePanelTerminal, environmentId],
6127 );
6128 const activateRightPanelSurface = useCallback(
6129 (surface: RightPanelSurface) => {
6130 if (!activeThreadRef) return;
6131 useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id);
6132 if (surface.kind === "preview" && surface.resourceId) {
6133 setActivePreviewTab(activeThreadRef, surface.resourceId);
6134 }
6135 if (surface.kind === "terminal") {
6136 setTerminalFocusRequestId((value) => value + 1);
6137 }
6138 if (surface.kind === "diff" && !diffOpen) {
6139 onDiffPanelOpen?.();
6140 }
6141 },
6142 [activeThreadRef, diffOpen, onDiffPanelOpen],
6143 );
6144 const toggleRightPanel = useCallback(() => {
6145 if (!activeThreadRef) return;
6146 if (rightPanelOpen) {
6147 closePreviewPanel();
6148 return;
6149 }
6150 useRightPanelStore.getState().toggleVisibility(activeThreadRef);
6151 }, [activeThreadRef, closePreviewPanel, rightPanelOpen]);
6152 const toggleThreadPanel = useCallback(() => {
6153 if (!activeThreadRef) return;
6154 useRightPanelStore.getState().toggleThreadPanel(activeThreadRef, threadPanelPresentation);
6155 }, [activeThreadRef, threadPanelPresentation]);
6156 // A thread started for a link the OS opened shows its browser maximized, like a browser window.
6157 useEffect(() => {
6158 if (!canMaximizeRightPanel) return;
6159 if (useRightPanelStore.getState().consumeMaximizeRequest(routeThreadRef)) {
6160 useRightPanelStore.getState().setMaximized(routeThreadRef, true);
6161 }
6162 }, [canMaximizeRightPanel, routeThreadRef]);
6163 const toggleRightPanelMaximized = useCallback(() => {
6164 if (!canMaximizeRightPanel || !activeThreadRef) return;
6165 useRightPanelStore.getState().setMaximized(activeThreadRef, !rightPanelMaximized);
6166 }, [activeThreadRef, canMaximizeRightPanel, rightPanelMaximized]);
6167 const cleanupRightPanelSurfaces = useCallback(
6168 (surfaces: readonly RightPanelSurface[]) => {
6169 if (!activeThreadRef) return;
6170 for (const surface of surfaces) {
6171 // Without preview access only the local surface goes away. The
6172 // server record is in-memory bookkeeping, not a live view, and stays
6173 // listed until a client with the scope closes it or the server restarts.
6174 if (canOperatePreview && surface.kind === "preview" && surface.resourceId) {
6175 void closePreviewSession({
6176 closePreview,
6177 snapshot: activePreviewState.sessions[surface.resourceId] ?? null,
6178 tabId: surface.resourceId,
6179 threadRef: activeThreadRef,
6180 });
6181 }
6182 if (
6183 surface.kind === "terminal" &&
6184 readEnvironmentScope(activeThreadRef.environmentId, AuthTerminalOperateScope)
6185 ) {
6186 for (const terminalId of surface.terminalIds) {
6187 storeCloseTerminal(activeThreadRef, terminalId);
6188 void closeTerminalMutation({
6189 environmentId: activeThreadRef.environmentId,
6190 input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true },
6191 });
6192 }
6193 }
6194 }
6195 },
6196 [
6197 activeThreadRef,
6198 activePreviewState.sessions,
6199 canOperatePreview,
6200 closePreview,
6201 closeTerminalMutation,
6202 storeCloseTerminal,
6203 ],
6204 );
6205 const closeAfterAgentBrowserConfirmation = useCallback(
6206 (surfaces: readonly RightPanelSurface[], closeSurfaces: () => void) => {
6207 const message = agentControlledBrowserCloseConfirmation(
6208 surfaces,
6209 activePreviewState.desktopByTabId,
6210 );
6211 if (!message) {
6212 closeSurfaces();
6213 return;
6214 }
6215 const localApi = readLocalApi();
6216 if (!localApi) return;
6217 void localApi.dialogs.confirm(message, { variant: "destructive" }).then(
6218 (confirmed) => {
6219 if (confirmed) closeSurfaces();
6220 },
6221 () => undefined,
6222 );
6223 },
6224 [activePreviewState.desktopByTabId],
6225 );
6226 const syncActivePreviewSurface = useCallback(() => {
6227 if (!activeThreadRef) return;
6228 const nextActiveSurface = selectActiveRightPanelSurface(
6229 useRightPanelStore.getState().byThreadKey,
6230 activeThreadRef,
6231 );
6232 if (nextActiveSurface?.kind === "preview" && nextActiveSurface.resourceId) {
6233 setActivePreviewTab(activeThreadRef, nextActiveSurface.resourceId);
6234 }
6235 }, [activeThreadRef]);
6236 const finishRightPanelSurfaceClose = useCallback(
6237 (surfaces: readonly RightPanelSurface[]) => {
6238 if (!activeThreadRef) return;
6239 const store = useRightPanelStore.getState();
6240 const activeId = selectThreadRightPanelState(
6241 store.byThreadKey,
6242 activeThreadRef,
6243 ).activeSurfaceId;
6244 const ordered = surfaces.toSorted(
6245 (left, right) => Number(left.id === activeId) - Number(right.id === activeId),
6246 );
6247 for (const surface of ordered) {
6248 cleanupRightPanelSurfaces([surface]);
6249 store.closeSurface(activeThreadRef, surface.id);
6250 }
6251 syncActivePreviewSurface();
6252 },
6253 [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface],
6254 );
6255 const closeRightPanelSurface = useCallback(
6256 (surface: RightPanelSurface) => {
6257 if (!activeThreadRef) return;
6258 const finishClose = () => finishRightPanelSurfaceClose([surface]);
6259 if (surface.kind === "preview") {
6260 closeAfterAgentBrowserConfirmation([surface], finishClose);
6261 return;
6262 }
6263 if (
6264 surface.kind !== "terminal" ||
6265 !readEnvironmentScope(activeThreadRef.environmentId, AuthTerminalOperateScope)
6266 ) {
6267 finishClose();
6268 return;
6269 }
6270 const otherTerminalIds = surface.terminalIds.filter(
6271 (terminalId) => terminalId !== surface.activeTerminalId,
6272 );
6273 void confirmTerminalClose(activeThreadRef, [
6274 surface.activeTerminalId,
6275 ...otherTerminalIds,
6276 ]).then((confirmed) => {
6277 if (confirmed) {
6278 finishClose();
6279 }
6280 });
6281 },
6282 [activeThreadRef, closeAfterAgentBrowserConfirmation, finishRightPanelSurfaceClose],
6283 );
6284 const closeOtherRightPanelSurfaces = useCallback(
6285 (surface: RightPanelSurface) => {
6286 if (!activeThreadRef) return;
6287 const surfaces = rightPanelState.surfaces.filter((entry) => entry.id !== surface.id);
6288 const finishClose = () => finishRightPanelSurfaceClose(surfaces);
6289 closeAfterAgentBrowserConfirmation(surfaces, finishClose);
6290 },
6291 [
6292 activeThreadRef,
6293 closeAfterAgentBrowserConfirmation,
6294 finishRightPanelSurfaceClose,
6295 rightPanelState.surfaces,
6296 ],
6297 );
6298 const closeRightPanelSurfacesToRight = useCallback(
6299 (surface: RightPanelSurface) => {
6300 if (!activeThreadRef) return;
6301 const surfaceIndex = rightPanelState.surfaces.findIndex((entry) => entry.id === surface.id);
6302 if (surfaceIndex < 0) return;
6303 const surfaces = rightPanelState.surfaces.slice(surfaceIndex + 1);
6304 const finishClose = () => finishRightPanelSurfaceClose(surfaces);
6305 closeAfterAgentBrowserConfirmation(surfaces, finishClose);
6306 },
6307 [
6308 activeThreadRef,
6309 closeAfterAgentBrowserConfirmation,
6310 finishRightPanelSurfaceClose,
6311 rightPanelState.surfaces,
6312 ],
6313 );
6314 const closeAllRightPanelSurfaces = useCallback(() => {
6315 if (!activeThreadRef) return;
6316 const finishClose = () => finishRightPanelSurfaceClose(rightPanelState.surfaces);
6317 closeAfterAgentBrowserConfirmation(rightPanelState.surfaces, finishClose);
6318 }, [
6319 activeThreadRef,
6320 closeAfterAgentBrowserConfirmation,
6321 finishRightPanelSurfaceClose,
6322 rightPanelState.surfaces,
6323 ]);
6324 const moveRightPanelSurface = useCallback(
6325 (surfaceId: string, toIndex: number) => {
6326 if (activeThreadRef)
6327 useRightPanelStore.getState().moveSurface(activeThreadRef, surfaceId, toIndex);
6328 },
6329 [activeThreadRef],
6330 );
6331 const copyRightPanelFilePath = useCallback((relativePath: string) => {
6332 if (typeof window === "undefined" || !navigator.clipboard?.writeText) {
6333 toastManager.add(
6334 stackedThreadToast({
6335 type: "error",
6336 title: "Failed to copy path",
6337 description: "Clipboard API unavailable.",
6338 }),
6339 );
6340 return;
6341 }
6342
6343 void navigator.clipboard.writeText(relativePath).then(
6344 () => {
6345 toastManager.add({
6346 type: "success",
6347 title: "Path copied",
6348 description: relativePath,
6349 });
6350 },
6351 (error) => {
6352 toastManager.add(
6353 stackedThreadToast({
6354 type: "error",
6355 title: "Failed to copy path",
6356 description: error instanceof Error ? error.message : "An error occurred.",
6357 }),
6358 );
6359 },
6360 );
6361 }, []);
6362 useEffect(
6363 () =>
6364 subscribePreviewAction((action) => {
6365 if (action === "toggle-panel") togglePreviewPanel();
6366 }),
6367 [togglePreviewPanel],
6368 );
6369 const persistThreadSettingsForNextTurn = useCallback(
6370 async (input: {
6371 threadId: ThreadId;
6372 createdAt: string;
6373 branch?: string;
6374 runtimeMode: RuntimeMode;
6375 interactionMode: ProviderInteractionMode;
6376 }): Promise<AtomCommandResult<void, unknown>> => {
6377 if (!serverThread) {
6378 return AsyncResult.success(undefined);
6379 }
6380
6381 let result: AtomCommandResult<void, unknown> = AsyncResult.success(undefined);
6382 const metadataUpdate = resolveThreadMetadataUpdateForNextTurn({
6383 currentModelSelection: serverThread.modelSelection,
6384 currentBranch: serverThread.branch,
6385 ...(input.branch ? { nextBranch: input.branch } : {}),
6386 });
6387 if (metadataUpdate) {
6388 result = mapAtomCommandResult(
6389 await updateThreadMetadata({
6390 environmentId,
6391 input: {
6392 threadId: input.threadId,
6393 ...metadataUpdate,
6394 },
6395 }),
6396 () => undefined,
6397 );
6398 if (result._tag === "Failure") {
6399 return result;
6400 }
6401 }
6402
6403 if (input.runtimeMode !== serverThread.runtimeMode) {
6404 result = mapAtomCommandResult(
6405 await setThreadRuntimeMode({
6406 environmentId,
6407 input: {
6408 threadId: input.threadId,
6409 runtimeMode: input.runtimeMode,
6410 createdAt: input.createdAt,
6411 },
6412 }),
6413 () => undefined,
6414 );
6415 if (result._tag === "Failure") {
6416 return result;
6417 }
6418 }
6419
6420 if (input.interactionMode !== serverThread.interactionMode) {
6421 result = mapAtomCommandResult(
6422 await setThreadInteractionMode({
6423 environmentId,
6424 input: {
6425 threadId: input.threadId,
6426 interactionMode: input.interactionMode,
6427 createdAt: input.createdAt,
6428 },
6429 }),
6430 () => undefined,
6431 );
6432 }
6433 return result;
6434 },
6435 [
6436 environmentId,
6437 serverThread,
6438 setThreadInteractionMode,
6439 setThreadRuntimeMode,
6440 updateThreadMetadata,
6441 ],
6442 );
6443
6444 // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during
6445 // thread switches. LegendList fires scroll events with isAtEnd=false while
6446 // initialScrollAtEnd is settling; hiding is always immediate.
6447 const showScrollDebouncer = useRef(
6448 new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }),
6449 );
6450 const timelineScrollIntentRef = useRef<"toward-end" | "away-from-end" | null>(null);
6451 const timelineScrollModeRef = useRef<TimelineScrollMode>("following-end");
6452 // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd
6453 // re-pins on its own (independent of the refs), so the timeline needs a
6454 // render-visible flag to switch it off once the user scrolls away.
6455 const [timelineLiveFollowEnabled, setTimelineLiveFollowEnabled] = useState(true);
6456 const pendingTimelineAnchorRef = useRef<MessageId | null>(null);
6457 const positionedTimelineAnchorRef = useRef<MessageId | null>(null);
6458 const settledTimelineAnchorRef = useRef<MessageId | null>(null);
6459 const activeTimelineAnchorIndexRef = useRef<number | null>(null);
6460 const observedTimelineActivityRef = useRef<TimelineRunObservation | null>(null);
6461 const anchorUserScrollGenerationRef = useRef(0);
6462 const cancelPositionRestoreRef = useRef<(() => void) | null>(null);
6463 const liveFollowUserScrollGenerationRef = useRef<number | null>(0);
6464 const pendingAnchorScrollRestoreRef = useRef<{
6465 readonly messageId: MessageId;
6466 readonly offset: number;
6467 readonly userScrollGeneration: number;
6468 } | null>(null);
6469 const anchorScrollRestoreFrameRef = useRef<number | null>(null);
6470 const cancelTimelineLiveFollowForUserNavigation = useCallback(() => {
6471 cancelPositionRestoreRef.current?.();
6472 anchorUserScrollGenerationRef.current += 1;
6473 const wasProgrammaticScrollMode = timelineScrollModeRef.current !== "free-scrolling";
6474 timelineScrollModeRef.current = "free-scrolling";
6475 liveFollowUserScrollGenerationRef.current = null;
6476 setTimelineLiveFollowEnabled(false);
6477 pendingTimelineAnchorRef.current = null;
6478 positionedTimelineAnchorRef.current = null;
6479 settledTimelineAnchorRef.current = null;
6480 activeTimelineAnchorIndexRef.current = null;
6481 pendingAnchorScrollRestoreRef.current = null;
6482 if (anchorScrollRestoreFrameRef.current !== null) {
6483 cancelAnimationFrame(anchorScrollRestoreFrameRef.current);
6484 anchorScrollRestoreFrameRef.current = null;
6485 }
6486 if (wasProgrammaticScrollMode) {
6487 // While following or anchoring, our scrollToEnd/scrollToOffset calls can
6488 // sit in LegendList's pending-imperative-scroll queue (it defers them
6489 // while layout settles) and fire seconds later with stale targets,
6490 // yanking the view away after the user scrolled. Starting a new
6491 // imperative scroll cancels everything queued; targeting an item that
6492 // is not in the data makes the new request itself resolve without ever
6493 // scrolling, so this is a pure cancel.
6494 void legendListRef.current?.scrollToItem({
6495 item: TIMELINE_SCROLL_CANCEL_SENTINEL,
6496 animated: false,
6497 });
6498 // An already-started animated scroll (behavior: smooth) keeps running in
6499 // the browser regardless of the queue; a same-position instant write is
6500 // the only way to halt it where it is.
6501 const scrollNode = legendListRef.current?.getScrollableNode() as
6502 | { scrollTop?: number }
6503 | null
6504 | undefined;
6505 const currentScrollTop = scrollNode?.scrollTop;
6506 if (scrollNode && typeof currentScrollTop === "number") {
6507 scrollNode.scrollTop = currentScrollTop;
6508 }
6509 }
6510 }, []);
6511 const cancelTimelineLiveFollowForUserNavigationRef = useRef(
6512 cancelTimelineLiveFollowForUserNavigation,
6513 );
6514 useEffect(() => {
6515 cancelTimelineLiveFollowForUserNavigationRef.current =
6516 cancelTimelineLiveFollowForUserNavigation;
6517 }, [cancelTimelineLiveFollowForUserNavigation]);
6518 useEffect(() => {
6519 const previous = observedTimelineActivityRef.current;
6520 const dispatchedUserItem =
6521 previous?.hydrated &&
6522 previous.threadKey === activeThreadKey &&
6523 activeActivityRun !== null &&
6524 previous.runId !== activeActivityRun.runId
6525 ? serverProjection?.visibleTurnItems.find(
6526 (row) => row.item.type === "user_message" && row.item.runId === activeActivityRun.runId,
6527 )
6528 : undefined;
6529 const { observation, anchorMessageId: messageId } = observeTimelineRun(previous, {
6530 threadKey: activeThreadKey,
6531 hydrated: !isServerThread || (serverProjection !== null && threadStatus === "live"),
6532 runId: activeActivityRun?.runId ?? null,
6533 queued: activeActivityRun?.status === "queued",
6534 messageId:
6535 dispatchedUserItem?.item.type === "user_message" ? dispatchedUserItem.item.messageId : null,
6536 });
6537 observedTimelineActivityRef.current = observation;
6538 if (messageId === null) return;
6539 if (
6540 pendingTimelineAnchorRef.current !== null ||
6541 timelineScrollModeRef.current === "free-scrolling"
6542 ) {
6543 return;
6544 }
6545
6546 isAtEndRef.current = true;
6547 timelineScrollModeRef.current = "anchoring-new-turn";
6548 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
6549 pendingTimelineAnchorRef.current = messageId;
6550 activeTimelineAnchorIndexRef.current = null;
6551 showScrollDebouncer.current.cancel();
6552 setShowScrollToBottom(false);
6553 setTimelineAnchor({ threadKey: activeThreadKey, messageId });
6554 }, [activeActivityRun, activeThreadKey, isServerThread, serverProjection, threadStatus]);
6555 const timelineRealContentOverflowsViewport = useCallback(
6556 (list?: LegendListRef | null) =>
6557 timelineContentOverflowsViewport((list ?? legendListRef.current)?.getState(), {
6558 composerInset: composerTimelineInset,
6559 anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET,
6560 }),
6561 [composerTimelineInset],
6562 );
6563 const pageScrollControllerRef = useRef<ReturnType<typeof createPageScrollController> | null>(
6564 null,
6565 );
6566 const handlePageScrollStart = useEffectEvent((key: PageScrollKey) => {
6567 timelineScrollIntentRef.current = key === "PageUp" ? "away-from-end" : "toward-end";
6568 composerRef.current?.collapseForTimelineScrollKey(key);
6569 if ((key === "PageUp" && timelineRealContentOverflowsViewport()) || !isTimelineAtLogicalEnd()) {
6570 cancelTimelineLiveFollowForUserNavigation();
6571 }
6572 });
6573 useEffect(() => {
6574 const controller = createPageScrollController({
6575 getContainer: () => legendListRef.current?.getScrollableNode() ?? null,
6576 getScrollPaddingBottomPx: () => composerOverlayElement?.getBoundingClientRect().height ?? 0,
6577 onScrollStart: handlePageScrollStart,
6578 });
6579 pageScrollControllerRef.current = controller;
6580
6581 return () => {
6582 controller.dispose();
6583 if (pageScrollControllerRef.current === controller) {
6584 pageScrollControllerRef.current = null;
6585 }
6586 };
6587 }, [composerOverlayElement]);
6588 const onComposerPageScrollKeyDown = useCallback((key: PageScrollKey) => {
6589 pageScrollControllerRef.current?.handleKeyDown(key);
6590 }, []);
6591 const onComposerPageScrollKeyUp = useCallback((key: string) => {
6592 pageScrollControllerRef.current?.handleKeyUp(key);
6593 }, []);
6594 const onComposerPageScrollRelease = useCallback(() => {
6595 pageScrollControllerRef.current?.releaseActiveKey();
6596 }, []);
6597 // Live-follow stays active after send/thread-open until an actual list scroll
6598 // gesture opts out.
6599 const scrollToEnd = useCallback((animated = false) => {
6600 cancelPositionRestoreRef.current?.();
6601 isAtEndRef.current = true;
6602 timelineScrollModeRef.current = "following-end";
6603 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
6604 setTimelineLiveFollowEnabled(true);
6605 pendingTimelineAnchorRef.current = null;
6606 activeTimelineAnchorIndexRef.current = null;
6607 showScrollDebouncer.current.cancel();
6608 setShowScrollToBottom(false);
6609 setTimelineAnchor(releaseChatTimelineAnchor);
6610 // The anchored end space must be gone before the scroll measures, or the
6611 // list lands short of the real end (#6519).
6612 requestAnimationFrame(() => {
6613 void legendListRef.current?.scrollToEnd?.({ animated });
6614 });
6615 }, []);
6616 useEffect(() => {
6617 let removeListeners: (() => void) | null = null;
6618 let frame: number | null = null;
6619 const attach = (remainingAttempts: number) => {
6620 frame = requestAnimationFrame(() => {
6621 frame = null;
6622 const scrollNode = legendListRef.current?.getScrollableNode();
6623 if (!scrollNode) {
6624 // The list may not have mounted on the first frame after a thread
6625 // switch — without a retry the opt-out listeners never attach and
6626 // live-follow becomes impossible to escape for the whole thread.
6627 if (remainingAttempts > 0) {
6628 attach(remainingAttempts - 1);
6629 }
6630 return;
6631 }
6632 const handleManualNavigation = () => {
6633 cancelTimelineLiveFollowForUserNavigationRef.current();
6634 };
6635 // The gestures below must only break follow when they can actually
6636 // move the viewport away from the live edge (#5566): a spurious break
6637 // while pinned at the end produces no scroll event, never re-arms,
6638 // and streaming silently stops following. Underflowing content can't
6639 // scroll at all, so nothing there should break follow.
6640 const contentScrollsUp = () => timelineRealContentOverflowsViewport();
6641 // The follow re-arm band, not the strict flag: streaming growth makes
6642 // isAtEnd flicker false for a frame before the follow scroll catches
6643 // up, and a gesture landing in that window while still pinned would
6644 // otherwise break follow with no scroll event left to re-arm it.
6645 const viewportIsAwayFromEnd = () =>
6646 resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false;
6647 // Only an upward wheel is a navigation intent; wheeling down while
6648 // following either does nothing (at the end) or moves toward it.
6649 const handleWheel = (event: WheelEvent) => {
6650 if (event.ctrlKey || !isTimelineScrollTarget(event.target, scrollNode, event.deltaY))
6651 return;
6652 if (event.deltaY > 0) {
6653 timelineScrollIntentRef.current = "toward-end";
6654 if (isAtEndRef.current) {
6655 composerRef.current?.restoreAfterTimelineReachedEnd();
6656 }
6657 } else if (event.deltaY < 0) {
6658 timelineScrollIntentRef.current = "away-from-end";
6659 }
6660 if (event.deltaY < 0 && contentScrollsUp()) {
6661 handleManualNavigation();
6662 }
6663 };
6664 // Touch direction isn't observable here (touchmove fires on any
6665 // finger motion, scrolling or not), so break only once the drag has
6666 // actually carried the viewport out of the end band — an upward flick
6667 // gets there within its first few events and later touchmoves break.
6668 const handleTouchMove = () => {
6669 if (viewportIsAwayFromEnd()) {
6670 handleManualNavigation();
6671 }
6672 };
6673 // Scrollbar drags produce no wheel/touch events; they are the only
6674 // pointerdowns whose target is the scroll node itself rather than a
6675 // message row. Content clicks break follow only away from the end
6676 // (reading or selecting up there must hold position); clicking near
6677 // the live edge keeps following.
6678 const handlePointerDown = (event: PointerEvent) => {
6679 if (event.target === scrollNode) {
6680 if (contentScrollsUp()) {
6681 handleManualNavigation();
6682 }
6683 return;
6684 }
6685 if (viewportIsAwayFromEnd()) {
6686 handleManualNavigation();
6687 }
6688 };
6689 // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and
6690 // pointer events entirely; without this the timeline yanks back to
6691 // the end on the next stream chunk. Clicking message text can leave
6692 // DOM focus on body, so these keys must also be heard at document.
6693 const handleKeyDown = (event: KeyboardEvent) => {
6694 if (
6695 !(event.target instanceof Node) ||
6696 (!scrollNode.contains(event.target) &&
6697 event.target !== document.body &&
6698 event.target !== document.documentElement) ||
6699 event.defaultPrevented ||
6700 event.isComposing ||
6701 event.altKey ||
6702 event.ctrlKey ||
6703 event.metaKey ||
6704 event.shiftKey ||
6705 eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR) ||
6706 document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)
6707 ) {
6708 return;
6709 }
6710 if (!["PageUp", "Home", "ArrowUp", "PageDown", "End", "ArrowDown"].includes(event.key))
6711 return;
6712 const scrollDirection = ["PageUp", "Home", "ArrowUp"].includes(event.key) ? -1 : 1;
6713 if (
6714 scrollNode.contains(event.target) &&
6715 !isTimelineScrollTarget(event.target, scrollNode, scrollDirection)
6716 )
6717 return;
6718 switch (event.key) {
6719 case "PageUp":
6720 case "Home":
6721 case "ArrowUp":
6722 timelineScrollIntentRef.current = "away-from-end";
6723 if (contentScrollsUp()) {
6724 handleManualNavigation();
6725 composerRef.current?.collapseForTimelineScrollKey(event.key);
6726 }
6727 break;
6728 case "PageDown":
6729 case "End":
6730 case "ArrowDown":
6731 timelineScrollIntentRef.current = "toward-end";
6732 if (viewportIsAwayFromEnd()) {
6733 handleManualNavigation();
6734 }
6735 composerRef.current?.collapseForTimelineScrollKey(event.key);
6736 if (isTimelineAtLogicalEnd()) {
6737 composerRef.current?.restoreAfterTimelineReachedEnd();
6738 }
6739 break;
6740 default:
6741 break;
6742 }
6743 };
6744 scrollNode.addEventListener("wheel", handleWheel, {
6745 passive: true,
6746 });
6747 scrollNode.addEventListener("touchmove", handleTouchMove, {
6748 passive: true,
6749 });
6750 scrollNode.addEventListener("pointerdown", handlePointerDown, {
6751 passive: true,
6752 });
6753 document.addEventListener("keydown", handleKeyDown);
6754 removeListeners = () => {
6755 scrollNode.removeEventListener("wheel", handleWheel);
6756 scrollNode.removeEventListener("touchmove", handleTouchMove);
6757 scrollNode.removeEventListener("pointerdown", handlePointerDown);
6758 document.removeEventListener("keydown", handleKeyDown);
6759 };
6760 });
6761 };
6762 attach(12);
6763
6764 return () => {
6765 if (frame !== null) {
6766 cancelAnimationFrame(frame);
6767 }
6768 removeListeners?.();
6769 };
6770 }, [activeThread?.id, isTimelineAtLogicalEnd, timelineRealContentOverflowsViewport]);
6771
6772 const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => {
6773 if (pendingTimelineAnchorRef.current === messageId) {
6774 pendingTimelineAnchorRef.current = null;
6775 }
6776 activeTimelineAnchorIndexRef.current = anchorIndex;
6777 if (positionedTimelineAnchorRef.current === messageId) {
6778 return;
6779 }
6780 positionedTimelineAnchorRef.current = messageId;
6781 settledTimelineAnchorRef.current = null;
6782 const positionAnchor = (remainingAttempts: number) => {
6783 requestAnimationFrame(() => {
6784 if (positionedTimelineAnchorRef.current !== messageId) {
6785 return;
6786 }
6787 const list = legendListRef.current;
6788 if (!list) {
6789 if (remainingAttempts > 0) {
6790 positionAnchor(remainingAttempts - 1);
6791 }
6792 return;
6793 }
6794 const scrollNode = list.getScrollableNode();
6795 let finished = false;
6796 const finishAnimatedPositioning = () => {
6797 if (finished) {
6798 return;
6799 }
6800 finished = true;
6801 window.clearTimeout(fallbackTimer);
6802 scrollNode.removeEventListener("scrollend", finishAnimatedPositioning);
6803 if (positionedTimelineAnchorRef.current !== messageId) {
6804 return;
6805 }
6806 const scrollOffset = list.getState().scroll;
6807 void list.scrollToOffset({ offset: scrollOffset, animated: false });
6808 settledTimelineAnchorRef.current = messageId;
6809 };
6810 const fallbackTimer = window.setTimeout(finishAnimatedPositioning, 750);
6811 scrollNode.addEventListener("scrollend", finishAnimatedPositioning, { once: true });
6812 void list.scrollToIndex({
6813 index: anchorIndex,
6814 animated: true,
6815 viewPosition: 0,
6816 viewOffset: CHAT_LIST_ANCHOR_OFFSET,
6817 });
6818 });
6819 };
6820 requestAnimationFrame(() => positionAnchor(12));
6821 }, []);
6822 const onTimelineAnchorSizeChanged = useCallback((messageId: MessageId) => {
6823 if (settledTimelineAnchorRef.current !== messageId) {
6824 return;
6825 }
6826 if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) {
6827 return;
6828 }
6829 const scrollOffset = legendListRef.current?.getState().scroll;
6830 if (scrollOffset === undefined) {
6831 return;
6832 }
6833 if (pendingAnchorScrollRestoreRef.current === null) {
6834 pendingAnchorScrollRestoreRef.current = {
6835 messageId,
6836 offset: scrollOffset,
6837 userScrollGeneration: anchorUserScrollGenerationRef.current,
6838 };
6839 }
6840 if (anchorScrollRestoreFrameRef.current !== null) {
6841 return;
6842 }
6843 anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => {
6844 anchorScrollRestoreFrameRef.current = null;
6845 const pending = pendingAnchorScrollRestoreRef.current;
6846 pendingAnchorScrollRestoreRef.current = null;
6847 if (
6848 pending &&
6849 settledTimelineAnchorRef.current === pending.messageId &&
6850 pending.userScrollGeneration === anchorUserScrollGenerationRef.current
6851 ) {
6852 const list = legendListRef.current;
6853 const currentScrollOffset = list?.getState().scroll;
6854 if (
6855 typeof currentScrollOffset === "number" &&
6856 Math.abs(currentScrollOffset - pending.offset) <= 2
6857 ) {
6858 void list?.scrollToOffset({ offset: pending.offset, animated: false });
6859 }
6860 }
6861 });
6862 }, []);
6863
6864 const onToolOutputCollapsedAtEnd = useCallback(() => {
6865 composerRef.current?.restoreAfterTimelineReachedEnd();
6866 }, [composerRef]);
6867
6868 const onIsAtEndChange = useCallback((isAtEnd: boolean) => {
6869 if (
6870 !isAtEnd &&
6871 liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current
6872 ) {
6873 showScrollDebouncer.current.cancel();
6874 setShowScrollToBottom(false);
6875 return;
6876 }
6877 if (isAtEndRef.current === isAtEnd) return;
6878 isAtEndRef.current = isAtEnd;
6879 if (isAtEnd) {
6880 if (timelineScrollIntentRef.current === "toward-end") {
6881 composerRef.current?.restoreAfterTimelineReachedEnd();
6882 }
6883 timelineScrollModeRef.current = "following-end";
6884 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
6885 setTimelineLiveFollowEnabled(true);
6886 // Reachable only once manual navigation has already broken follow, so
6887 // the anchored turn framing is over: the user scrolled back to the live
6888 // edge and expects the stream to stick to it again, exactly like the
6889 // scroll-to-bottom pill (#6519).
6890 setTimelineAnchor(releaseChatTimelineAnchor);
6891 showScrollDebouncer.current.cancel();
6892 setShowScrollToBottom(false);
6893 } else {
6894 timelineScrollModeRef.current = "free-scrolling";
6895 liveFollowUserScrollGenerationRef.current = null;
6896 showScrollDebouncer.current.maybeExecute();
6897 }
6898 }, []);
6899
6900 useLayoutEffect(() => {
6901 if (timelineScrollModeRef.current !== "anchoring-new-turn") return;
6902 if (
6903 shouldReleaseTimelineAnchorForToolActivity({
6904 anchorMessageId: timelineAnchorMessageId,
6905 liveFollowEnabled: timelineLiveFollowEnabled,
6906 runningTurnId: activeRunningTurnId,
6907 timelineEntries,
6908 })
6909 ) {
6910 scrollToEnd();
6911 }
6912 }, [
6913 activeRunningTurnId,
6914 scrollToEnd,
6915 timelineAnchorMessageId,
6916 timelineEntries,
6917 timelineLiveFollowEnabled,
6918 ]);
6919
6920 useEffect(() => {
6921 setPullRequestDialogState(null);
6922 const followEnd = readTimelinePosition(routeThreadKey)?.atEnd !== false;
6923 isAtEndRef.current = followEnd;
6924 timelineScrollIntentRef.current = null;
6925 timelineScrollModeRef.current = followEnd ? "following-end" : "free-scrolling";
6926 liveFollowUserScrollGenerationRef.current = followEnd
6927 ? anchorUserScrollGenerationRef.current
6928 : null;
6929 setTimelineLiveFollowEnabled(followEnd);
6930 pendingTimelineAnchorRef.current = null;
6931 positionedTimelineAnchorRef.current = null;
6932 settledTimelineAnchorRef.current = null;
6933 activeTimelineAnchorIndexRef.current = null;
6934 showScrollDebouncer.current.cancel();
6935 setShowScrollToBottom(!followEnd);
6936 // Environment identity is part of the scroll session too.
6937 }, [activeThreadKey]);
6938
6939 useEffect(() => {
6940 if (!activeThread?.id || terminalUiState.terminalOpen) return;
6941 const frame = window.requestAnimationFrame(() => {
6942 focusComposer();
6943 });
6944 return () => {
6945 window.cancelAnimationFrame(frame);
6946 };
6947 }, [activeThread?.id, focusComposer, terminalUiState.terminalOpen]);
6948
6949 // Tabbing back into the app lands focus wherever it last was, often the right panel or the
6950 // body. Put it in the composer unless something that takes typing already holds it. The
6951 // drawer terminal owns keyboard input while it is open, so it opts out here; a right panel
6952 // terminal is a surface and is recognized by the predicate instead. Mobile is left alone so
6953 // returning to the app does not raise the keyboard.
6954 useEffect(() => {
6955 if (!activeThread?.id || terminalUiState.terminalOpen || isMobileViewport) return;
6956 let frame: number | null = null;
6957 const onWindowFocus = () => {
6958 if (frame !== null) window.cancelAnimationFrame(frame);
6959 // The element that held focus receives it again after the window's own event, and the
6960 // composer ignores that same frame so a restored focus does not lift a scroll-collapsed
6961 // composer. Wait one more frame so this focus counts as a request to expand it.
6962 frame = window.requestAnimationFrame(() => {
6963 frame = window.requestAnimationFrame(() => {
6964 frame = null;
6965 // A full-screen app owns the page; refocusing the composer would close it.
6966 if (document.querySelector("[data-mcp-app-fullscreen]") !== null) return;
6967 if (shouldRefocusComposerOnWindowFocus(document.activeElement)) focusComposer();
6968 });
6969 });
6970 };
6971 window.addEventListener("focus", onWindowFocus);
6972 return () => {
6973 window.removeEventListener("focus", onWindowFocus);
6974 if (frame !== null) window.cancelAnimationFrame(frame);
6975 };
6976 }, [activeThread?.id, focusComposer, isMobileViewport, terminalUiState.terminalOpen]);
6977
6978 useEffect(() => {
6979 if (!activeThread?.id) return;
6980 if (activeMessageCount === 0) {
6981 return;
6982 }
6983 const removedMessages = optimisticUserMessages.filter((message) =>
6984 serverAcknowledgedUserMessageIds.has(message.id),
6985 );
6986 if (removedMessages.length === 0) {
6987 return;
6988 }
6989 const timer = window.setTimeout(() => {
6990 setOptimisticUserMessages((existing) =>
6991 existing.filter((message) => !serverAcknowledgedUserMessageIds.has(message.id)),
6992 );
6993 }, 0);
6994 for (const removedMessage of removedMessages) {
6995 const previewUrls = collectUserMessageBlobPreviewUrls(removedMessage);
6996 if (previewUrls.length > 0) {
6997 handoffAttachmentPreviews(removedMessage.id, previewUrls);
6998 continue;
6999 }
7000 revokeUserMessagePreviewUrls(removedMessage);
7001 }
7002 return () => {
7003 window.clearTimeout(timer);
7004 };
7005 }, [
7006 activeMessageCount,
7007 activeThread?.id,
7008 serverAcknowledgedUserMessageIds,
7009 handoffAttachmentPreviews,
7010 optimisticUserMessages,
7011 ]);
7012
7013 // Keyed on the thread, not the draft: a draft's promotion to its server
7014 // route keeps this instance and drops `draftId`, and the send it is still
7015 // dispatching must survive that swap.
7016 useEffect(() => {
7017 setOptimisticUserMessages((existing) => {
7018 for (const message of existing) {
7019 revokeUserMessagePreviewUrls(message);
7020 }
7021 return [];
7022 });
7023 resetLocalDispatch();
7024 setExpandedImage(null);
7025 }, [resetLocalDispatch, threadId]);
7026
7027 const closeExpandedImage = useCallback(() => {
7028 setExpandedImage(null);
7029 }, []);
7030
7031 const activeWorktreePath = activeThread?.worktreePath ?? null;
7032 const derivedEnvMode: DraftThreadEnvMode = resolveEffectiveEnvMode({
7033 activeWorktreePath,
7034 hasServerThread: isServerThread,
7035 draftThreadEnvMode: isLocalDraftThread ? draftThread?.envMode : undefined,
7036 preparingWorktree: isPreparingWorktree,
7037 });
7038 const canOverrideServerThreadEnvMode = Boolean(
7039 isServerThread &&
7040 activeThread &&
7041 activeMessageCount === 0 &&
7042 activeThread.worktreePath === null &&
7043 !envLocked,
7044 );
7045 const envMode: DraftThreadEnvMode = canOverrideServerThreadEnvMode
7046 ? (pendingServerThreadEnvMode ?? draftThread?.envMode ?? derivedEnvMode)
7047 : derivedEnvMode;
7048 const activeThreadBranch =
7049 canOverrideServerThreadEnvMode && pendingServerThreadBranch !== undefined
7050 ? pendingServerThreadBranch
7051 : (activeThread?.branch ?? null);
7052 const startFromOrigin = isLocalDraftThread
7053 ? (draftThread?.startFromOrigin ?? false)
7054 : canOverrideServerThreadEnvMode
7055 ? (pendingServerThreadStartFromOriginByThreadId[activeThread?.id ?? ""] ??
7056 activeProjectSettings.settings.newWorktreesStartFromOrigin)
7057 : false;
7058 const sendEnvMode = resolveSendEnvMode({
7059 requestedEnvMode: envMode,
7060 isGitRepo,
7061 });
7062 const localCheckoutBranchMismatch = useMemo(
7063 () =>
7064 isServerThread
7065 ? resolveLocalCheckoutBranchMismatch({
7066 effectiveEnvMode: envMode,
7067 activeWorktreePath,
7068 activeThreadBranch,
7069 currentGitBranch: gitStatusQuery.data?.refName ?? null,
7070 })
7071 : null,
7072 [activeThreadBranch, activeWorktreePath, envMode, gitStatusQuery.data?.refName, isServerThread],
7073 );
7074 const publishComposerOverlayHeight = useCallback(
7075 (height: number) => {
7076 const nextHeight = Math.ceil(height);
7077 if (nextHeight <= 0) return;
7078 const isResting = overlayComposerIsResting({
7079 composerMounted: composerMountedRef.current,
7080 composerReportedResting: composerRestingRef.current,
7081 });
7082 const modelStrip = composerOverlayElement?.querySelector<HTMLElement>(
7083 '[data-composer-model-strip="true"]',
7084 );
7085 // The model-only strip disappears on expansion; counting it again would
7086 // add 32px of timeline padding as soon as the empty composer collapses.
7087 const restingOnlyHeight =
7088 modelStrip && isResting
7089 ? Math.max(
7090 0,
7091 modelStrip.offsetHeight + Number.parseFloat(getComputedStyle(modelStrip).marginTop),
7092 )
7093 : 0;
7094 const nextInset = resolveComposerTimelineInset({
7095 currentInset: composerTimelineInsetRef.current,
7096 overlayHeight: nextHeight,
7097 isResting,
7098 restingOnlyHeight,
7099 });
7100 if (composerTimelineInsetRef.current !== nextInset) {
7101 composerTimelineInsetRef.current = nextInset;
7102 setComposerTimelineInset(nextInset);
7103 }
7104 const mainSurface = composerOverlayElement?.querySelector<HTMLElement>(
7105 '[data-chat-composer-main-surface="true"]',
7106 );
7107 const button = composerOverlayElement?.parentElement?.querySelector<HTMLElement>(
7108 'button[aria-label="Scroll to end"]',
7109 );
7110 const clearance =
7111 composerOverlayElement && mainSurface && button
7112 ? resolveScrollToEndClearance({
7113 overlayHeight: nextHeight,
7114 mainSurfaceTop: mainSurface.getBoundingClientRect().top,
7115 button: button.getBoundingClientRect(),
7116 attachments: Array.from(
7117 composerOverlayElement.querySelectorAll<HTMLElement>(
7118 '[data-composer-banner-surface="attached"]',
7119 ),
7120 (element) => element.getBoundingClientRect(),
7121 ),
7122 })
7123 : nextHeight;
7124 setScrollToEndClearance(clearance);
7125 },
7126 [composerOverlayElement],
7127 );
7128 // The composer reports its resting flag from a layout effect, which runs
7129 // before this component's own layout effects and before any resize
7130 // observation, so every measurement below sees the flag for its layout.
7131 // Only the flag is stored here: the stored height still belongs to the
7132 // previous layout, and the composer publishes the new layout's height
7133 // itself once it has measured it.
7134 const onComposerRestingChange = useCallback((resting: boolean) => {
7135 composerRestingRef.current = resting;
7136 }, []);
7137 // A held reservation belongs to the previous thread's draft. Rebuild it from
7138 // this thread's overlay so a tall draft elsewhere does not pad this one.
7139 useLayoutEffect(() => {
7140 if (!composerOverlayElement) return;
7141 composerTimelineInsetRef.current = 0;
7142 publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height);
7143 }, [activeThreadKey, composerOverlayElement, publishComposerOverlayHeight]);
7144
7145 useLayoutEffect(() => {
7146 if (!composerOverlayElement) return;
7147
7148 const updateHeight = () => {
7149 publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height);
7150 };
7151
7152 updateHeight();
7153 return observeResize(composerOverlayElement, updateHeight);
7154 }, [composerOverlayElement, publishComposerOverlayHeight, showScrollToBottom]);
7155 // Swapping the composer for the status bar (or back) changes what the
7156 // overlay holds, so rebuild the reservation from the new content.
7157 useLayoutEffect(() => {
7158 if (composerMountedRef.current === composerMounted) return;
7159 composerMountedRef.current = composerMounted;
7160 if (!composerOverlayElement) return;
7161 composerTimelineInsetRef.current = 0;
7162 publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height);
7163 }, [composerMounted, composerOverlayElement, publishComposerOverlayHeight]);
7164 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef);
7165 const activeThreadReferenceCopyTarget = useMemo(
7166 () =>
7167 activeThreadId === null || !isServerThread
7168 ? null
7169 : resolveThreadReferenceCopyTarget({
7170 threadId: activeThreadId,
7171 openPanelPullRequestUrl,
7172 pullRequests: activeThread?.pullRequests,
7173 linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null,
7174 }),
7175 [
7176 activeThreadId,
7177 isServerThread,
7178 activeThread?.pullRequests,
7179 linkedThreadPullRequest?.url,
7180 openPanelPullRequestUrl,
7181 ],
7182 );
7183 const copyActiveThreadReference = useCallback(() => {
7184 const target = activeThreadReferenceCopyTarget;
7185 if (target === null) return;
7186 void writeTextToClipboard(target.value, target.clipboardTarget).then(
7187 (didCopy) => {
7188 if (!didCopy) return;
7189 toastManager.add({
7190 type: "success",
7191 title: target.successTitle,
7192 description: target.value,
7193 });
7194 },
7195 (error) => {
7196 console.error(error);
7197 toastManager.add(
7198 stackedThreadToast({
7199 type: "error",
7200 title: target.failureTitle,
7201 description: error instanceof Error ? error.message : "An error occurred.",
7202 }),
7203 );
7204 },
7205 );
7206 }, [activeThreadReferenceCopyTarget]);
7207 const pullRequestPanelTarget = activeThread
7208 ? threadPullRequestPanelTarget({
7209 projectId: activeThread.projectId,
7210 pullRequests: visiblePullRequests,
7211 linkedPullRequest: linkedThreadPullRequest,
7212 branchPullRequest: activeThreadShell?.branchPullRequest ?? activeThread.branchPullRequest,
7213 })
7214 : null;
7215 const addPullRequestSurface = useCallback(() => {
7216 if (!supportsPullRequests || activeThreadRef === null || pullRequestPanelTarget === null)
7217 return;
7218 useRightPanelStore.getState().openPullRequest(activeThreadRef, pullRequestPanelTarget);
7219 }, [activeThreadRef, pullRequestPanelTarget, supportsPullRequests]);
7220 const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null;
7221 const supportsSettlement = organizationCapabilities?.threadSettlement === true;
7222 const supportsSnooze = organizationCapabilities?.threadSnooze === true;
7223 const supportsPinning = organizationCapabilities?.threadPinning === true;
7224 const activeThreadPinned = supportsPinning && activeThreadShell?.pinnedAt != null;
7225 const activeThreadSnoozed =
7226 activeThreadShell !== null &&
7227 supportsSnooze &&
7228 effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() });
7229 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
7230 useEffect(() => {
7231 void snoozeWakeTick;
7232 if (!activeThreadSnoozed) return;
7233 const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? "");
7234 if (!Number.isFinite(wakeAtMs)) return;
7235 const id = window.setTimeout(
7236 () => bumpSnoozeWakeTick((tick) => tick + 1),
7237 Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647),
7238 );
7239 return () => window.clearTimeout(id);
7240 }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]);
7241 const activeThreadWokeAt =
7242 activeThreadShell !== null && supportsSnooze
7243 ? threadWokeAt(activeThreadShell, { now: new Date().toISOString() })
7244 : null;
7245 const acknowledgeThreadWoke = useAcknowledgeThreadWoke();
7246 const acknowledgeActiveThreadWoke = useCallback(() => {
7247 if (activeThreadRef === null || activeThreadWokeAt === null) return;
7248 acknowledgeThreadWoke(activeThreadRef, activeThreadWokeAt);
7249 }, [acknowledgeThreadWoke, activeThreadRef, activeThreadWokeAt]);
7250 // Mirror of the sidebar's Woke pill for the open thread. Same watermark as
7251 // the sidebar, so an acknowledgement from any device hides it.
7252 const activeThreadLocalVisitedAt = useUiStateStore((store) =>
7253 activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey],
7254 );
7255 const activeThreadLastVisitedAt = resolveThreadLastVisitedAt(
7256 activeThreadShell?.lastVisitedAt,
7257 activeThreadLocalVisitedAt,
7258 );
7259 const activeThreadWokeVisible = useMemo(() => {
7260 if (activeThreadWokeAt === null) return false;
7261 if (activeThreadShell?.settledOverride === "settled") return false;
7262 const wokeAtMs = Date.parse(activeThreadWokeAt);
7263 if (Number.isNaN(wokeAtMs)) return false;
7264 // Having the thread open counts as a visit at completedAt (the effect
7265 // above stamps it); folding that floor in here keeps a completion-
7266 // triggered wake from flashing a banner for one frame before the stamp
7267 // lands. An unparseable stored visit counts as never-visited: corrupt
7268 // data must not eat the wake signal.
7269 const storedVisitMs = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN;
7270 const completedAtMs = activeLatestRun?.completedAt
7271 ? Date.parse(activeLatestRun.completedAt)
7272 : NaN;
7273 const lastVisitedMs = Math.max(
7274 Number.isNaN(storedVisitMs) ? -Infinity : storedVisitMs,
7275 Number.isNaN(completedAtMs) ? -Infinity : completedAtMs,
7276 );
7277 return lastVisitedMs < wokeAtMs;
7278 }, [
7279 activeLatestRun?.completedAt,
7280 activeThreadLastVisitedAt,
7281 activeThreadShell,
7282 activeThreadWokeAt,
7283 ]);
7284 const activeThreadSettled =
7285 supportsSettlement && activeThreadShell?.settledOverride === "settled";
7286 const unsettleThreadMutation = useOrchestrationCommand(threadEnvironment.unsettle, {
7287 organization: true,
7288 reportFailure: false,
7289 });
7290 // Keyed by thread, not a boolean: the pending state must follow the thread
7291 // it belongs to across navigation, and a request resolving for thread A
7292 // must never clear (or re-enable) thread B's button.
7293 const [unsettlingThreadKey, setUnsettlingThreadKey] = useState<string | null>(null);
7294 const isUnsettling = unsettlingThreadKey !== null && unsettlingThreadKey === activeThreadKey;
7295 const handleUnsettleActiveThread = useCallback(async () => {
7296 if (!activeThreadRef || !readThreadOrganizationPermission(activeThreadRef.environmentId))
7297 return;
7298 const threadKey = scopedThreadKey(activeThreadRef);
7299 setUnsettlingThreadKey(threadKey);
7300 try {
7301 const result = await unsettleThreadMutation({
7302 environmentId: activeThreadRef.environmentId,
7303 input: { threadId: activeThreadRef.threadId, reason: "user" },
7304 });
7305 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
7306 const error = squashAtomCommandFailure(result);
7307 toastManager.add(
7308 stackedThreadToast({
7309 type: "error",
7310 title: "Failed to un-settle thread",
7311 description: error instanceof Error ? error.message : "An error occurred.",
7312 }),
7313 );
7314 }
7315 } finally {
7316 setUnsettlingThreadKey((current) => (current === threadKey ? null : current));
7317 }
7318 }, [activeThreadRef, unsettleThreadMutation]);
7319 const unsnoozeThreadMutation = useOrchestrationCommand(threadEnvironment.unsnooze, {
7320 organization: true,
7321 reportFailure: false,
7322 });
7323 const [unsnoozingThreadKey, setUnsnoozingThreadKey] = useState<string | null>(null);
7324 const isUnsnoozing = unsnoozingThreadKey !== null && unsnoozingThreadKey === activeThreadKey;
7325 const handleUnsnoozeActiveThread = useCallback(async () => {
7326 if (!activeThreadRef || !readThreadOrganizationPermission(activeThreadRef.environmentId))
7327 return;
7328 const threadKey = scopedThreadKey(activeThreadRef);
7329 setUnsnoozingThreadKey(threadKey);
7330 try {
7331 const recovery = activeThreadShell?.limitRecovery;
7332 const recoveryOwnsSnooze =
7333 recovery?.snooze === true &&
7334 recovery.runId === activeThreadShell?.latestRun?.runId &&
7335 activeThreadShell?.snoozedUntil != null &&
7336 Date.parse(activeThreadShell.snoozedUntil) === Date.parse(recovery.resetAt);
7337 const result = recoveryOwnsSnooze
7338 ? await updateThreadMetadata({
7339 environmentId: activeThreadRef.environmentId,
7340 input: {
7341 threadId: activeThreadRef.threadId,
7342 limitRecovery: { runId: recovery.runId, resetAt: recovery.resetAt, snooze: false },
7343 },
7344 })
7345 : await unsnoozeThreadMutation({
7346 environmentId: activeThreadRef.environmentId,
7347 input: { threadId: activeThreadRef.threadId, reason: "user" },
7348 });
7349 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
7350 const error = squashAtomCommandFailure(result);
7351 toastManager.add(
7352 stackedThreadToast({
7353 type: "error",
7354 title: "Failed to wake thread",
7355 description: error instanceof Error ? error.message : "An error occurred.",
7356 }),
7357 );
7358 }
7359 } finally {
7360 setUnsnoozingThreadKey((current) => (current === threadKey ? null : current));
7361 }
7362 }, [activeThreadRef, activeThreadShell, unsnoozeThreadMutation, updateThreadMetadata]);
7363 const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false);
7364 const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false);
7365 // Once revealed for a given mismatch, the banner stays mounted until the
7366 // mismatch changes or resolves, so clearing the draft doesn't flicker it.
7367 const [revealedBranchMismatchKey, setRevealedBranchMismatchKey] = useState<string | null>(null);
7368 // Dismissal lives in a module-level set (survives remounts); this tick just
7369 // forces a re-render so the banner leaves immediately.
7370 const [, setBranchMismatchDismissTick] = useState(0);
7371 const composerHasDraftContent = useComposerDraftStore((store) =>
7372 composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)),
7373 );
7374 const nowMinute = useNowMinute();
7375 const activeBranchMismatchKey = branchMismatchKey(
7376 activeThread?.id ?? null,
7377 localCheckoutBranchMismatch,
7378 );
7379 const showBranchMismatchBanner = shouldShowBranchMismatchBanner({
7380 hasMismatch: localCheckoutBranchMismatch !== null,
7381 isDismissed: isBranchMismatchDismissedForSession(activeBranchMismatchKey),
7382 composerHasContent: composerHasDraftContent,
7383 wasShownForCurrentMismatch:
7384 revealedBranchMismatchKey !== null && revealedBranchMismatchKey === activeBranchMismatchKey,
7385 });
7386 useEffect(() => {
7387 setRevealedBranchMismatchKey((revealed) => {
7388 if (showBranchMismatchBanner) {
7389 return activeBranchMismatchKey;
7390 }
7391 // Hysteresis is scoped to an uninterrupted mismatch: reset when the
7392 // mismatch resolves or changes so a recurrence re-gates on intent.
7393 return revealed !== null && revealed !== activeBranchMismatchKey ? null : revealed;
7394 });
7395 }, [activeBranchMismatchKey, showBranchMismatchBanner]);
7396 const handleSwitchCheckoutToThread = useCallback(async () => {
7397 if (!canWriteSourceControl) return;
7398 if (
7399 !activeProjectCwd ||
7400 !activeThread ||
7401 !localCheckoutBranchMismatch ||
7402 isRestoringThreadBranch
7403 ) {
7404 return;
7405 }
7406 setIsRestoringThreadBranch(true);
7407 const checkoutResult = await switchGitRef({
7408 environmentId,
7409 input: {
7410 cwd: activeProjectCwd,
7411 refName: localCheckoutBranchMismatch.threadBranch,
7412 },
7413 });
7414 if (checkoutResult._tag === "Failure") {
7415 setIsRestoringThreadBranch(false);
7416 if (!isAtomCommandInterrupted(checkoutResult)) {
7417 toastManager.add(
7418 stackedThreadToast({
7419 type: "error",
7420 title: "Failed to switch checkout",
7421 description: chatActionErrorMessage(squashAtomCommandFailure(checkoutResult)),
7422 }),
7423 );
7424 }
7425 return;
7426 }
7427
7428 if (!readEnvironmentScope(environmentId, AuthSourceControlWriteScope)) {
7429 setIsRestoringThreadBranch(false);
7430 return;
7431 }
7432 const nextBranch = checkoutResult.value.refName ?? localCheckoutBranchMismatch.threadBranch;
7433 if (nextBranch !== activeThread.branch) {
7434 const updateResult = await updateThreadMetadata({
7435 environmentId,
7436 input: { threadId: activeThread.id, branch: nextBranch, worktreePath: null },
7437 });
7438 if (updateResult._tag === "Failure") {
7439 setIsRestoringThreadBranch(false);
7440 if (!isAtomCommandInterrupted(updateResult)) {
7441 toastManager.add(
7442 stackedThreadToast({
7443 type: "error",
7444 title: "Checkout switched, but the thread could not be updated",
7445 description: chatActionErrorMessage(squashAtomCommandFailure(updateResult)),
7446 }),
7447 );
7448 }
7449 gitStatusQuery.refresh();
7450 return;
7451 }
7452 }
7453 gitStatusQuery.refresh();
7454 setIsRestoringThreadBranch(false);
7455 scheduleComposerFocus();
7456 }, [
7457 canWriteSourceControl,
7458 activeProjectCwd,
7459 activeThread,
7460 environmentId,
7461 gitStatusQuery,
7462 isRestoringThreadBranch,
7463 localCheckoutBranchMismatch,
7464 scheduleComposerFocus,
7465 switchGitRef,
7466 updateThreadMetadata,
7467 ]);
7468 // The stack renders items[0] front-most and tucks the rest behind hover, so
7469 // ordering is priority: system banners, then the branch-mismatch notice,
7470 // and the informational parked-thread banner last — it must never cover another.
7471 // Background work (subagent fleets, workflow runs, watch loops, dev servers) can outlive
7472 // the turn; once it settles, the composer stop button is gone, so this
7473 // banner is the only visible stop affordance. The interrupt path also
7474 // accepts a completed run while its provider still has background work.
7475 const activeBackgroundTasks = !isWorking && activeThread ? pendingBackgroundTasks : [];
7476 const [stoppingBackgroundWorkKey, setStoppingBackgroundWorkKey] = useState<string | null>(null);
7477 const isStoppingBackgroundWork =
7478 stoppingBackgroundWorkKey === `${environmentId}:${activeThreadId}`;
7479 const handleStopBackgroundWork = useCallback(async () => {
7480 if (!activeThread || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
7481 return;
7482 const requestKey = `${environmentId}:${activeThread.id}`;
7483 setStoppingBackgroundWorkKey(requestKey);
7484 const result = await interruptThreadTurn({
7485 environmentId,
7486 input: { threadId: activeThread.id },
7487 });
7488 // Acceptance does not confirm termination. Allow retry while the provider
7489 // finishes stopping the tasks or reports a failure.
7490 setStoppingBackgroundWorkKey((current) => (current === requestKey ? null : current));
7491 if (result._tag === "Failure") {
7492 if (!isAtomCommandInterrupted(result)) {
7493 const error = squashAtomCommandFailure(result);
7494 setThreadError(
7495 activeThread.id,
7496 error instanceof Error ? error.message : "Failed to stop background work.",
7497 );
7498 }
7499 }
7500 }, [activeThread, environmentId, interruptThreadTurn, setThreadError]);
7501 const onOpenRelatedThread = useCallback(
7502 (threadId: ThreadId) => {
7503 void navigate({
7504 to: "/$environmentId/$threadId",
7505 params: buildThreadRouteParams(scopeThreadRef(environmentId, threadId)),
7506 });
7507 },
7508 [environmentId, navigate],
7509 );
7510 const childThreadInputs = useChildThreadInputs(activeThreadRef);
7511 const childInputBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
7512 const first = childThreadInputs[0];
7513 const parentAwaitingUser =
7514 activePendingApproval || activePendingUserInput || activeThreadShell?.hasPendingUserInput;
7515 if (!first || parentAwaitingUser) return null;
7516 return {
7517 id: `child-input:${first.id}`,
7518 variant: "info",
7519 priority: "activity",
7520 icon: first.hasPendingApprovals ? <ShieldQuestionIcon /> : <MessageCircleIcon />,
7521 title: childThreadInputsTitle(childThreadInputs),
7522 description: childThreadInputs.map((child, index) => (
7523 <Fragment key={child.id}>
7524 {index > 0 ? " " : null}
7525 <span className="whitespace-nowrap">
7526 <InlineButton tone="muted" onClick={() => onOpenRelatedThread(child.id)}>
7527 {child.title}
7528 </InlineButton>
7529 {index < childThreadInputs.length - 1 ? "," : null}
7530 </span>
7531 </Fragment>
7532 )),
7533 actions: (
7534 <Button size="xs" variant="ghost" onClick={() => onOpenRelatedThread(first.id)}>
7535 {first.hasPendingApprovals ? "Open approval" : "Open question"}
7536 </Button>
7537 ),
7538 };
7539 }, [
7540 childThreadInputs,
7541 activePendingApproval,
7542 activePendingUserInput,
7543 activeThreadShell?.hasPendingUserInput,
7544 onOpenRelatedThread,
7545 ]);
7546
7547 // Commands such as /compact and /goal clear run as their own turn. The draft
7548 // and its attachments stay local.
7549 const sendStandaloneCommand = useCallback(
7550 async (text: string, failureMessage: string) => {
7551 if (!activeThread || !clientSettingsHydrated || sendInFlightRef.current) return;
7552 const context = composerRef.current?.getSendContext();
7553 if (!context?.providerAvailable) return;
7554
7555 const threadId = activeThread.id;
7556 const messageId = newMessageId();
7557 const createdAt = new Date().toISOString();
7558 sendInFlightRef.current = true;
7559 beginLocalDispatch();
7560 setThreadError(threadId, null);
7561 setOptimisticUserMessages((messages) => [
7562 ...messages,
7563 {
7564 id: messageId,
7565 role: "user",
7566 text,
7567 runId: null,
7568 createdAt,
7569 updatedAt: createdAt,
7570 streaming: false,
7571 },
7572 ]);
7573 scrollToEnd();
7574 try {
7575 const settingsResult = await persistThreadSettingsForNextTurn({
7576 threadId,
7577 createdAt,
7578 ...(localCheckoutBranchMismatch
7579 ? { branch: localCheckoutBranchMismatch.currentBranch }
7580 : {}),
7581 runtimeMode,
7582 interactionMode: context.interactionMode,
7583 });
7584 const result =
7585 settingsResult._tag === "Failure"
7586 ? settingsResult
7587 : await startThreadTurn({
7588 environmentId,
7589 input: {
7590 threadId,
7591 message: { messageId, role: "user", text, attachments: [] },
7592 modelSelection: context.selectedModelSelection,
7593 runtimeMode,
7594 interactionMode: context.interactionMode,
7595 createdAt,
7596 },
7597 });
7598 if (result._tag === "Failure") {
7599 setOptimisticUserMessages((messages) =>
7600 messages.filter((message) => message.id !== messageId),
7601 );
7602 resetLocalDispatch();
7603 if (!isAtomCommandInterrupted(result)) {
7604 const error = squashAtomCommandFailure(result);
7605 setThreadError(threadId, error instanceof Error ? error.message : failureMessage);
7606 }
7607 } else {
7608 clearUsageLimitsFor(routeThreadKey);
7609 }
7610 } finally {
7611 sendInFlightRef.current = false;
7612 }
7613 },
7614 [
7615 activeThread,
7616 beginLocalDispatch,
7617 clientSettingsHydrated,
7618 composerRef,
7619 environmentId,
7620 localCheckoutBranchMismatch,
7621 persistThreadSettingsForNextTurn,
7622 resetLocalDispatch,
7623 routeThreadKey,
7624 runtimeMode,
7625 scrollToEnd,
7626 sendInFlightRef,
7627 setThreadError,
7628 startThreadTurn,
7629 ],
7630 );
7631 // A native /goal keeps the agent working across turns. Stop pauses a Codex
7632 // goal; once the thread is idle the row offers the native follow-ups.
7633 const activeGoal = activeThreadShell?.goal ?? null;
7634 const goalBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
7635 if (!activeThread || activeGoal === null) return null;
7636 const presentation = presentProviderGoal(activeGoal, isWorking);
7637 return {
7638 id: `goal:${activeThread.id}`,
7639 variant: "default",
7640 priority: "activity",
7641 icon: <TargetIcon />,
7642 // Usage stays in the title so a long objective cannot clip it.
7643 title:
7644 presentation.usage === null
7645 ? presentation.title
7646 : `${presentation.title} · ${presentation.usage}`,
7647 description: presentation.objective,
7648 actions: isWorking ? undefined : (
7649 <>
7650 {presentation.canResume ? (
7651 <Button
7652 size="xs"
7653 variant="ghost"
7654 onClick={() => void sendStandaloneCommand("/goal resume", "Failed to resume goal.")}
7655 >
7656 Resume
7657 </Button>
7658 ) : null}
7659 <Button
7660 size="xs"
7661 variant="ghost"
7662 onClick={() => void sendStandaloneCommand("/goal clear", "Failed to clear goal.")}
7663 >
7664 Clear
7665 </Button>
7666 </>
7667 ),
7668 };
7669 }, [activeGoal, activeThread, isWorking, sendStandaloneCommand]);
7670
7671 const backgroundWorkBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
7672 const presentation = presentPendingBackgroundWork(activeBackgroundTasks);
7673 if (presentation === null || !activeThread) {
7674 return null;
7675 }
7676 return {
7677 id: `background-work:${activeThread.id}`,
7678 variant: "default",
7679 priority: "activity",
7680 // A dev server can run for hours after the agent is done, so only work
7681 // that will wake the agent pulses.
7682 icon: (
7683 <span
7684 className={cn(
7685 "size-1.5 rounded-full bg-foreground",
7686 presentation.waiting && "animate-status-pulse",
7687 )}
7688 aria-hidden="true"
7689 />
7690 ),
7691 title: presentation.title,
7692 // A single named item is already in the title.
7693 description:
7694 presentation.items.length === 1 && presentation.items[0]?.childThreadId === undefined
7695 ? undefined
7696 : presentation.items.map((item, index) => {
7697 const childThreadId = item.childThreadId;
7698 const separator = index < presentation.items.length - 1 ? "," : null;
7699 return (
7700 <Fragment key={item.taskId}>
7701 {index > 0 ? " " : null}
7702 {childThreadId === undefined ? (
7703 <>
7704 {item.label}
7705 {separator}
7706 </>
7707 ) : (
7708 <span className="whitespace-nowrap">
7709 <InlineButton
7710 tone="muted"
7711 aria-label={`Open subagent ${item.label}`}
7712 onClick={() => onOpenRelatedThread(childThreadId)}
7713 >
7714 {item.label}
7715 </InlineButton>
7716 {separator}
7717 </span>
7718 )}
7719 </Fragment>
7720 );
7721 }),
7722 actions: (
7723 <Button
7724 size="xs"
7725 variant="ghost"
7726 disabled={!canOperateThread || isStoppingBackgroundWork}
7727 onClick={() => void handleStopBackgroundWork()}
7728 >
7729 {isStoppingBackgroundWork ? "Stopping..." : "Stop"}
7730 </Button>
7731 ),
7732 };
7733 }, [
7734 activeBackgroundTasks,
7735 activeThread,
7736 canOperateThread,
7737 handleStopBackgroundWork,
7738 isStoppingBackgroundWork,
7739 onOpenRelatedThread,
7740 ]);
7741 // Settled, snoozed, and woke are thread state, not composer actions: each
7742 // gets one quiet line after the last message instead of a banner. A woken
7743 // thread announces itself here, not just in the sidebar pill. Dismissing
7744 // marks the wake as seen (same acknowledgment as the pill); sending a
7745 // message clears it as a side effect of the send path.
7746 // Memoized: it is the timeline's list footer, and a new element re-renders that footer.
7747 // nowMinute keeps the relative time fresh.
7748 const threadStatusLine = useMemo(() => {
7749 void nowMinute;
7750 return activeThreadSnoozed ? (
7751 <ThreadStatusLine
7752 icon={<AlarmClockIcon />}
7753 label={
7754 activeThreadShell?.snoozedUntil
7755 ? `Snoozed, ${formatRelativeTimeUntilLabel(activeThreadShell.snoozedUntil)}`
7756 : "Snoozed"
7757 }
7758 actionLabel={isUnsnoozing ? "Waking..." : "Wake now"}
7759 actionDisabled={!canOrganizeThread || isUnsnoozing}
7760 onAction={() => void handleUnsnoozeActiveThread()}
7761 />
7762 ) : activeThreadSettled ? (
7763 <ThreadStatusLine
7764 icon={<CheckCircle2Icon />}
7765 label={
7766 activeThreadShell?.settledAt
7767 ? `Settled ${formatRelativeTimeLabel(activeThreadShell.settledAt)}`
7768 : "Settled"
7769 }
7770 actionLabel={isUnsettling ? "Un-settling..." : "Un-settle"}
7771 actionDisabled={!canOrganizeThread || isUnsettling}
7772 onAction={() => void handleUnsettleActiveThread()}
7773 />
7774 ) : activeThreadWokeVisible ? (
7775 <ThreadStatusLine
7776 icon={<AlarmClockIcon />}
7777 label="Woke from snooze"
7778 actionLabel="Dismiss"
7779 onAction={acknowledgeActiveThreadWoke}
7780 />
7781 ) : null;
7782 }, [
7783 acknowledgeActiveThreadWoke,
7784 activeThreadSettled,
7785 activeThreadShell?.settledAt,
7786 activeThreadShell?.snoozedUntil,
7787 activeThreadSnoozed,
7788 activeThreadWokeVisible,
7789 canOrganizeThread,
7790 handleUnsettleActiveThread,
7791 handleUnsnoozeActiveThread,
7792 isUnsettling,
7793 isUnsnoozing,
7794 nowMinute,
7795 ]);
7796 const activeThreadHasCompactableConversation = serverVisibleTurnItems.some(
7797 ({ item }) =>
7798 item.type === "user_message" &&
7799 (item.text.trim().toLowerCase() !== "/compact" || item.attachments.length > 0),
7800 );
7801 const pendingHandoff = useMemo(
7802 () =>
7803 resolvePendingHandoff(
7804 serverProjection,
7805 composerSelectedModelSelection ?? activeThread?.modelSelection,
7806 providerStatuses,
7807 ),
7808 [
7809 serverProjection,
7810 composerSelectedModelSelection,
7811 activeThread?.modelSelection,
7812 providerStatuses,
7813 ],
7814 );
7815 const compactThreadUnavailable =
7816 !canOperateThread ||
7817 !activeThread ||
7818 !activeThreadHasCompactableConversation ||
7819 !activeProject ||
7820 !isServerThread ||
7821 !manualCompactionProviderAvailable ||
7822 isWorking ||
7823 isRevertingCheckpoint ||
7824 threadDetailLoading ||
7825 isPreparingWorktree ||
7826 activeEnvironmentUnavailable ||
7827 feedbackUploading ||
7828 pendingApprovals.length > 0 ||
7829 pendingUserInputs.length > 0 ||
7830 showPlanFollowUpPrompt;
7831 const compactDisabled = compactThreadUnavailable;
7832 const compactDisabledReason = compactDisabled
7833 ? !canOperateThread
7834 ? "This connection cannot change threads."
7835 : !activeProject
7836 ? "Choose a project before compacting"
7837 : !manualCompactionProviderAvailable
7838 ? "Compaction is unavailable for this provider"
7839 : "Compacting is unavailable right now"
7840 : null;
7841 // Tokens a stale Claude session would re-read on its next turn. While set,
7842 // the composer shows a Compact chip and Enter compacts first; turning the
7843 // chip off sends the next message with full history. Held queues and
7844 // multi-model sends never compact first, so the offer hides for them.
7845 const resumeCompactionTokens =
7846 activeContextWindow &&
7847 !resumeCompactionPermanentlyDismissed &&
7848 !nativeResumeCompactionDismissed &&
7849 !compactDisabled &&
7850 !hasHeldQueuedRuns &&
7851 multipleModelSelections === null &&
7852 shouldOfferResumeCompaction({
7853 provider: selectedProvider,
7854 usedTokens: activeContextWindow.usedTokens,
7855 updatedAt: activeContextWindow.updatedAt,
7856 // Only the live report carries a TTL, and only when it is the window's source.
7857 promptCacheTtlMs: activeThreadLiveTokenUsage?.promptCacheTtlMs,
7858 now: `${nowMinute}:00.000Z`,
7859 })
7860 ? activeContextWindow.usedTokens
7861 : null;
7862 // Threads whose Compact chip is turned off. A send that starts its turn
7863 // clears its thread's entry; a failed send keeps it for the retry.
7864 const [fullHistoryThreadKeys, setFullHistoryThreadKeys] = useState<ReadonlySet<string>>(
7865 () => new Set(),
7866 );
7867 const keepFullHistory = fullHistoryThreadKeys.has(routeThreadKey);
7868 const setKeepFullHistory = useCallback((threadKey: string, keep: boolean) => {
7869 setFullHistoryThreadKeys((current) => {
7870 if (current.has(threadKey) === keep) return current;
7871 const next = new Set(current);
7872 if (keep) next.add(threadKey);
7873 else next.delete(threadKey);
7874 return next;
7875 });
7876 }, []);
7877 const toggleKeepFullHistory = useCallback(
7878 () => setKeepFullHistory(routeThreadKey, !keepFullHistory),
7879 [keepFullHistory, routeThreadKey, setKeepFullHistory],
7880 );
7881 const handleRestoreThreadBranch = useCallback(() => {
7882 if (!canWriteSourceControl) return;
7883 if (gitStatusQuery.data?.hasWorkingTreeChanges) {
7884 setBranchRestoreConfirmOpen(true);
7885 return;
7886 }
7887 void handleSwitchCheckoutToThread();
7888 }, [
7889 canWriteSourceControl,
7890 gitStatusQuery.data?.hasWorkingTreeChanges,
7891 handleSwitchCheckoutToThread,
7892 ]);
7893 const feedbackBannerItems = useMemo(
7894 () =>
7895 feedbackSubmissions.flatMap((submission) => {
7896 const item = feedbackBannerItem(submission, () => {
7897 setFeedbackSubmissionsByThreadKey((current) => ({
7898 ...current,
7899 [routeThreadKey]: (current[routeThreadKey] ?? []).filter(
7900 (entry) => entry.id !== submission.id,
7901 ),
7902 }));
7903 });
7904 return item ? [item] : [];
7905 }),
7906 [feedbackSubmissions, routeThreadKey],
7907 );
7908 const limitRecoveryBanner =
7909 serverRuntime?.status === "failed" &&
7910 serverRuntime.lastErrorClass === "usage_limit" &&
7911 activeThreadShell?.latestRun
7912 ? usageLimitRecoveryBannerItem({
7913 runId: activeThreadShell.latestRun.runId,
7914 resetAt: serverRuntime.usageLimitResetAt ?? null,
7915 stoppedAt: activeThreadShell.latestRun.completedAt ?? activeThreadShell.updatedAt,
7916 recovery: activeThreadShell.limitRecovery ?? null,
7917 snoozedUntil: activeThreadShell.snoozedUntil,
7918 onChange: async (limitRecovery) => {
7919 const result = await updateThreadMetadata({
7920 environmentId,
7921 input: { threadId: activeThreadShell.id, limitRecovery },
7922 });
7923 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
7924 },
7925 })
7926 : null;
7927 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
7928 const limitRecoveryItems = limitRecoveryBanner === null ? [] : [limitRecoveryBanner];
7929 const backgroundWorkItems = [
7930 childInputBannerItem,
7931 goalBannerItem,
7932 backgroundWorkBannerItem,
7933 ].filter((item) => item !== null);
7934 // The user asked for this one, so it leads the notice tier instead of trailing it.
7935 const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner];
7936 const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem];
7937 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
7938 return [
7939 ...feedbackBannerItems,
7940 ...limitRecoveryItems,
7941 ...usageLimitsItems,
7942 ...projectCloneItems,
7943 ...systemComposerBannerItems,
7944 ...backgroundWorkItems,
7945 ];
7946 }
7947 return [
7948 ...feedbackBannerItems,
7949 ...limitRecoveryItems,
7950 ...usageLimitsItems,
7951 ...projectCloneItems,
7952 ...systemComposerBannerItems,
7953 ...backgroundWorkItems,
7954 {
7955 id: `branch-mismatch:${activeBranchMismatchKey}`,
7956 variant: "info",
7957 compact: true,
7958 icon: <GitBranchIcon />,
7959 title: (
7960 <span className="flex min-w-0 items-baseline gap-1.5">
7961 <span className="shrink-0 font-normal text-muted-foreground">Branch changed — was</span>
7962 <Tooltip>
7963 <TooltipTrigger
7964 render={
7965 <code className="min-w-0 truncate font-medium text-foreground">
7966 {localCheckoutBranchMismatch.threadBranch}
7967 </code>
7968 }
7969 />
7970 <TooltipPopup side="top">
7971 This thread last ran on {localCheckoutBranchMismatch.threadBranch}. Sending will
7972 continue on {localCheckoutBranchMismatch.currentBranch}.
7973 </TooltipPopup>
7974 </Tooltip>
7975 </span>
7976 ),
7977 actions: (
7978 <Button
7979 size="xs"
7980 variant="ghost"
7981 disabled={!canWriteSourceControl || isRestoringThreadBranch}
7982 onClick={handleRestoreThreadBranch}
7983 >
7984 {isRestoringThreadBranch ? "Restoring..." : "Restore branch"}
7985 </Button>
7986 ),
7987 dismissLabel: "Dismiss branch change notice",
7988 onDismiss: () => {
7989 dismissBranchMismatchForSession(activeBranchMismatchKey);
7990 setBranchMismatchDismissTick((tick) => tick + 1);
7991 },
7992 },
7993 ];
7994 }, [
7995 activeBranchMismatchKey,
7996 activeThreadShell,
7997 serverRuntime?.usageLimitResetAt,
7998 canWriteSourceControl,
7999 feedbackBannerItems,
8000 limitRecoveryBanner,
8001 handleRestoreThreadBranch,
8002 isRestoringThreadBranch,
8003 backgroundWorkBannerItem,
8004 childInputBannerItem,
8005 goalBannerItem,
8006 localCheckoutBranchMismatch,
8007 projectCloneBannerItem,
8008 showBranchMismatchBanner,
8009 systemComposerBannerItems,
8010 usageLimitsBanner,
8011 ]);
8012
8013 useEffect(() => {
8014 setPendingServerThreadEnvMode(null);
8015 setPendingServerThreadBranch(undefined);
8016 }, [activeThread?.id]);
8017
8018 useEffect(() => {
8019 if (canOverrideServerThreadEnvMode) {
8020 return;
8021 }
8022 setPendingServerThreadEnvMode(null);
8023 setPendingServerThreadBranch(undefined);
8024 }, [canOverrideServerThreadEnvMode]);
8025
8026 useEffect(() => {
8027 if (!activeThreadId) {
8028 setTerminalUiLaunchContext(null);
8029 return;
8030 }
8031 setTerminalUiLaunchContext((current) => {
8032 if (!current) return current;
8033 if (current.threadId === activeThreadId) return current;
8034 return null;
8035 });
8036 }, [activeThreadId]);
8037
8038 useEffect(() => {
8039 if (!activeThreadId || !activeProjectCwd) {
8040 return;
8041 }
8042 setTerminalUiLaunchContext((current) => {
8043 if (!current || current.threadId !== activeThreadId) {
8044 return current;
8045 }
8046 const settledCwd = projectScriptCwd({
8047 project: { cwd: activeProjectCwd },
8048 worktreePath: activeThreadWorktreePath,
8049 });
8050 if (
8051 settledCwd === current.cwd &&
8052 (activeThreadWorktreePath ?? null) === current.worktreePath
8053 ) {
8054 return null;
8055 }
8056 return current;
8057 });
8058 }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath]);
8059
8060 useEffect(() => {
8061 if (terminalUiState.terminalOpen) {
8062 return;
8063 }
8064 setTerminalUiLaunchContext((current) =>
8065 current?.threadId === activeThreadId ? null : current,
8066 );
8067 }, [activeThreadId, terminalUiState.terminalOpen]);
8068
8069 useEffect(() => {
8070 if (!activeThreadKey) return;
8071 const previous = terminalUiOpenByThreadRef.current[activeThreadKey] ?? false;
8072 const current = Boolean(terminalUiState.terminalOpen);
8073
8074 if (!previous && current) {
8075 terminalUiOpenByThreadRef.current[activeThreadKey] = current;
8076 setTerminalFocusRequestId((value) => value + 1);
8077 return;
8078 } else if (previous && !current) {
8079 terminalUiOpenByThreadRef.current[activeThreadKey] = current;
8080 const frame = window.requestAnimationFrame(() => {
8081 focusComposer();
8082 });
8083 return () => {
8084 window.cancelAnimationFrame(frame);
8085 };
8086 }
8087
8088 terminalUiOpenByThreadRef.current[activeThreadKey] = current;
8089 }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]);
8090
8091 const getShortcutContext = useCallback(
8092 (eventTarget: EventTarget | null = document.activeElement) => ({
8093 terminalFocus: getTerminalFocusOwner() !== null,
8094 terminalOpen: Boolean(terminalUiState.terminalOpen),
8095 previewFocus: isPreviewFocused(),
8096 previewOpen: previewPanelOpen,
8097 editableFocus: isEditableFocused(eventTarget),
8098 modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,
8099 composerFocus: document.activeElement?.getAttribute("data-testid") === "composer-editor",
8100 draftThreadRoute: routeKind === "draft",
8101 turnRunning: phase === "running",
8102 isWeb: !isElectron,
8103 isDesktop: isElectron,
8104 }),
8105 [composerRef, previewPanelOpen, terminalUiState.terminalOpen, routeKind, phase],
8106 );
8107 const timelineSkills = activeProviderStatus
8108 ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd)
8109 : EMPTY_PROVIDER_SKILLS;
8110 const threadFindControlsRef = useRef<ThreadFindControls | null>(null);
8111 const [isThreadFindActive, setIsThreadFindActive] = useState(false);
8112 const openThreadFind = useCallback(() => threadFindControlsRef.current?.open(), []);
8113 const closeThreadFind = useCallback(() => threadFindControlsRef.current?.close(), []);
8114
8115 useEffect(() => {
8116 const handler = (event: globalThis.KeyboardEvent) => {
8117 if (preventRepeatedTerminalCloseShortcut(event, keybindings)) {
8118 event.stopPropagation();
8119 return;
8120 }
8121 // Let contextual controls claim Escape before the bubbling find handler.
8122 if (isThreadFindActive && event.key === "Escape") return;
8123 if (isTerminalCloseConfirmPending() && preventTerminalCloseShortcut(event, keybindings)) {
8124 event.stopPropagation();
8125 return;
8126 }
8127 if (!activeThreadId || isCommandPaletteOpen()) {
8128 return;
8129 }
8130 const terminalFocusOwner = getTerminalFocusOwner();
8131 if (event.defaultPrevented && terminalFocusOwner === null) {
8132 return;
8133 }
8134 const shortcutContext = getShortcutContext(event.target);
8135
8136 if (
8137 !shortcutContext.terminalFocus &&
8138 !shortcutContext.modelPickerOpen &&
8139 shouldTypeToFocusComposer(event)
8140 ) {
8141 if (composerRef.current?.insertTextAtEnd(event.key)) {
8142 event.preventDefault();
8143 event.stopPropagation();
8144 return;
8145 }
8146 }
8147
8148 const command = resolveChatShortcutCommand(event, keybindings, scriptKeybindings, {
8149 context: shortcutContext,
8150 });
8151 if (!command) return;
8152
8153 if (command === "thread.copyReference") {
8154 event.preventDefault();
8155 event.stopPropagation();
8156 if (!event.repeat) copyActiveThreadReference();
8157 return;
8158 }
8159
8160 if (command === "thread.settle") {
8161 if (!readThreadOrganizationPermission(environmentId)) return;
8162 event.preventDefault();
8163 event.stopPropagation();
8164 if (!isServerThread || !activeThreadRef || !supportsSettlement) return;
8165 if (activeThreadSettled) {
8166 void handleUnsettleActiveThread();
8167 return;
8168 }
8169 // Same as the sidebar Settle button: settle, then open the next thread.
8170 if (settleAndAdvance(activeThreadRef)) return;
8171
8172 void settleThread(activeThreadRef).then((result) => {
8173 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
8174 const error = squashAtomCommandFailure(result);
8175 toastManager.add(
8176 stackedThreadToast({
8177 type: "error",
8178 title: "Failed to settle thread",
8179 description: error instanceof Error ? error.message : "An error occurred.",
8180 }),
8181 );
8182 });
8183 return;
8184 }
8185
8186 if (command === "thread.pin") {
8187 if (!readThreadOrganizationPermission(environmentId)) return;
8188 event.preventDefault();
8189 event.stopPropagation();
8190 if (!isServerThread || !activeThreadRef || !supportsPinning) return;
8191 const pinned = activeThreadPinned;
8192 void (pinned ? confirmAndUnpinThread(activeThreadRef) : pinThread(activeThreadRef)).then(
8193 (result) => {
8194 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
8195 const error = squashAtomCommandFailure(result);
8196 toastManager.add(
8197 stackedThreadToast({
8198 type: "error",
8199 title: pinned ? "Failed to unpin thread" : "Failed to pin thread",
8200 description: error instanceof Error ? error.message : "An error occurred.",
8201 }),
8202 );
8203 },
8204 );
8205 return;
8206 }
8207
8208 if (command === "terminal.toggle") {
8209 if (!terminalUiState.terminalOpen && !canReadTerminal && !canOperateTerminal) return;
8210 event.preventDefault();
8211 event.stopPropagation();
8212 toggleTerminalVisibility();
8213 return;
8214 }
8215
8216 // Drafts and servers without thread search leave Mod+F to the browser.
8217 if (command === "chat.find" && isServerThread && serverConfig?.threadFind === true) {
8218 event.preventDefault();
8219 event.stopPropagation();
8220 openThreadFind();
8221 return;
8222 }
8223
8224 if (command === "rightPanel.toggle") {
8225 event.preventDefault();
8226 event.stopPropagation();
8227 toggleRightPanel();
8228 return;
8229 }
8230
8231 if (command === "rightPanel.toggleMaximized") {
8232 event.preventDefault();
8233 event.stopPropagation();
8234 if (!event.repeat) toggleRightPanelMaximized();
8235 return;
8236 }
8237
8238 if (command === "threadPanel.toggle") {
8239 event.preventDefault();
8240 event.stopPropagation();
8241 toggleThreadPanel();
8242 return;
8243 }
8244
8245 if (command === "rightPanel.close") {
8246 // Nothing open: leave the event alone so the shortcut keeps its
8247 // native meaning (close window on desktop, close tab in a browser).
8248 if (!activeRightPanelSurface) return;
8249 event.preventDefault();
8250 event.stopPropagation();
8251 if (!event.repeat) closeRightPanelSurface(activeRightPanelSurface);
8252 return;
8253 }
8254
8255 if (command === "terminal.split") {
8256 // Without operate access the key keeps its native meaning, as it
8257 // does when nothing is open.
8258 if (!canOperateTerminal) return;
8259 event.preventDefault();
8260 event.stopPropagation();
8261 if (terminalFocusOwner === "right-panel") {
8262 splitPanelTerminal();
8263 return;
8264 }
8265 if (!terminalUiState.terminalOpen) {
8266 setTerminalOpen(true);
8267 }
8268 splitTerminal();
8269 return;
8270 }
8271
8272 if (command === "terminal.splitVertical") {
8273 // Without operate access the key keeps its native meaning, as it
8274 // does when nothing is open.
8275 if (!canOperateTerminal) return;
8276 event.preventDefault();
8277 event.stopPropagation();
8278 if (terminalFocusOwner === "right-panel") {
8279 splitPanelTerminal("vertical");
8280 return;
8281 }
8282 if (!terminalUiState.terminalOpen) {
8283 setTerminalOpen(true);
8284 }
8285 splitTerminal("vertical");
8286 return;
8287 }
8288
8289 if (command === "terminal.close") {
8290 // Without operate access the key keeps its native meaning, as it
8291 // does when nothing is open.
8292 if (!canOperateTerminal) return;
8293 event.preventDefault();
8294 event.stopPropagation();
8295 if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") {
8296 requestClosePanelTerminal(activeRightPanelSurface.activeTerminalId);
8297 return;
8298 }
8299 if (!terminalUiState.terminalOpen) return;
8300 requestCloseTerminal(terminalUiState.activeTerminalId);
8301 return;
8302 }
8303
8304 if (command === "terminal.new") {
8305 // Without operate access the key keeps its native meaning, as it
8306 // does when nothing is open.
8307 if (!canOperateTerminal) return;
8308 event.preventDefault();
8309 event.stopPropagation();
8310 if (terminalFocusOwner === "right-panel") {
8311 addTerminalSurface();
8312 return;
8313 }
8314 if (!terminalUiState.terminalOpen) {
8315 setTerminalOpen(true);
8316 }
8317 createNewTerminal();
8318 return;
8319 }
8320
8321 if (command === "diff.toggle") {
8322 event.preventDefault();
8323 event.stopPropagation();
8324 onToggleDiff();
8325 return;
8326 }
8327
8328 if (command === "modelPicker.toggle") {
8329 event.preventDefault();
8330 event.stopPropagation();
8331 if (!event.repeat) composerRef.current?.toggleModelPicker();
8332 return;
8333 }
8334
8335 if (
8336 command === "composer.host" ||
8337 command === "composer.effort" ||
8338 command === "composer.mode" ||
8339 command === "composer.workspace"
8340 ) {
8341 event.preventDefault();
8342 event.stopPropagation();
8343 if (!event.repeat) composerRef.current?.openControl(command);
8344 return;
8345 }
8346
8347 if (command === "composer.cycleHost") {
8348 if (envLocked || !draftId || !hasMultipleEnvironments) return;
8349 event.preventDefault();
8350 event.stopPropagation();
8351 if (event.repeat) return;
8352 // Step from where a pending switch is heading, so repeated presses keep advancing.
8353 const currentId = environmentChangeRef.current?.environmentId ?? environmentId;
8354 const index = logicalProjectEnvironments.findIndex(
8355 (env) => env.environmentId === currentId,
8356 );
8357 const next = logicalProjectEnvironments[(index + 1) % logicalProjectEnvironments.length];
8358 if (next) onEnvironmentChange(next.environmentId);
8359 return;
8360 }
8361
8362 if (command === "composer.branch") {
8363 event.preventDefault();
8364 event.stopPropagation();
8365 if (!event.repeat) branchToolbarRef.current?.openBranchPicker();
8366 return;
8367 }
8368
8369 if (command === "composer.previousWorktree") {
8370 event.preventDefault();
8371 event.stopPropagation();
8372 if (!event.repeat) branchToolbarRef.current?.usePreviousWorktree();
8373 return;
8374 }
8375
8376 if (command === "thread.steerQueuedMessage") {
8377 if (routeKind === "draft") return;
8378 if (!queuedRunsControlRef.current?.steerNext(event.repeat)) return;
8379 event.preventDefault();
8380 event.stopPropagation();
8381 return;
8382 }
8383
8384 if (command === "thread.editQueuedMessage") {
8385 if (routeKind === "draft") return;
8386 // Anywhere else in the draft the key keeps moving the caret, so a
8387 // second press from the first paragraph reaches the queue.
8388 if (!composerRef.current?.isCaretAtStart()) return;
8389 if (!queuedRunsControlRef.current?.editLatest(event.repeat)) return;
8390 event.preventDefault();
8391 event.stopPropagation();
8392 return;
8393 }
8394
8395 if (command === "thread.stop") {
8396 // An unavailable command should not shadow contextual shortcuts such as Escape to close a dialog.
8397 if (!canInterruptRunningThread) return;
8398 event.preventDefault();
8399 event.stopPropagation();
8400 if (event.repeat) return;
8401 void onInterrupt();
8402 return;
8403 }
8404
8405 const scriptId = projectScriptIdFromCommand(command);
8406 if (!scriptId || !activeProject || !canOperateTerminal) return;
8407 const script = activeProjectScripts.find((entry) => entry.id === scriptId);
8408 if (!script) return;
8409 event.preventDefault();
8410 event.stopPropagation();
8411 void runProjectScript(script);
8412 };
8413 const dismissFind = (event: KeyboardEvent) => {
8414 if (
8415 event.key !== "Escape" ||
8416 !isThreadFindActive ||
8417 event.defaultPrevented ||
8418 event.isComposing ||
8419 event.keyCode === 229 ||
8420 isCommandPaletteOpen()
8421 )
8422 return;
8423 const context = getShortcutContext(event.target);
8424 if (context.terminalFocus || context.previewFocus || context.modelPickerOpen) return;
8425 event.preventDefault();
8426 closeThreadFind();
8427 focusComposer();
8428 };
8429 window.addEventListener("keydown", handler, true);
8430 window.addEventListener("keydown", dismissFind);
8431 return () => {
8432 window.removeEventListener("keydown", handler, true);
8433 window.removeEventListener("keydown", dismissFind);
8434 };
8435 }, [
8436 activeProject,
8437 activeRightPanelSurface,
8438 activeProjectScripts,
8439 canReadTerminal,
8440 canOperateTerminal,
8441 addTerminalSurface,
8442 activeThreadRef,
8443 activeThreadPinned,
8444 activeThreadSettled,
8445 canInterruptRunningThread,
8446 terminalUiState.terminalOpen,
8447 terminalUiState.activeTerminalId,
8448 activeThreadId,
8449 closeRightPanelSurface,
8450 requestCloseTerminal,
8451 requestClosePanelTerminal,
8452 createNewTerminal,
8453 setTerminalOpen,
8454 runProjectScript,
8455 splitTerminal,
8456 splitPanelTerminal,
8457 keybindings,
8458 scriptKeybindings,
8459 handleUnsettleActiveThread,
8460 isServerThread,
8461 serverConfig?.threadFind,
8462 onInterrupt,
8463 onToggleDiff,
8464 pinThread,
8465 settleThread,
8466 supportsPinning,
8467 supportsSettlement,
8468 confirmAndUnpinThread,
8469 copyActiveThreadReference,
8470 getShortcutContext,
8471 openThreadFind,
8472 closeThreadFind,
8473 isThreadFindActive,
8474 toggleRightPanel,
8475 toggleRightPanelMaximized,
8476 toggleThreadPanel,
8477 toggleTerminalVisibility,
8478 composerRef,
8479 draftId,
8480 environmentId,
8481 envLocked,
8482 hasMultipleEnvironments,
8483 logicalProjectEnvironments,
8484 onEnvironmentChange,
8485 ]);
8486
8487 // A focused desktop browser page forwards these chords as menu actions.
8488 useEffect(() => {
8489 return window.desktopBridge?.onMenuAction((action) => {
8490 if (action === "rightPanel.toggle") toggleRightPanel();
8491 else if (action === "rightPanel.toggleMaximized") toggleRightPanelMaximized();
8492 });
8493 }, [toggleRightPanel, toggleRightPanelMaximized]);
8494
8495 // Paste-to-focus: the resting composer blurs on a click into the timeline,
8496 // so a paste that follows has no editable target and would be dropped.
8497 // Route it to the composer like a typed key, which also expands it.
8498 useEffect(() => {
8499 const keyHandler = (event: KeyboardEvent) => {
8500 if (
8501 shouldRedirectInputToComposer(event) &&
8502 isPasteAsTextShortcut(event, isMacPlatform(navigator.platform))
8503 ) {
8504 pasteAsTextShortcutUntilRef.current = Date.now() + 1_000;
8505 }
8506 };
8507 const handler = (event: ClipboardEvent) => {
8508 if (!activeThreadId || isCommandPaletteOpen()) return;
8509 if (getTerminalFocusOwner() !== null) return;
8510 if (composerRef.current?.isModelPickerOpen()) return;
8511 const text = pasteTextToFocusComposer(event);
8512 const clipboardData = event.clipboardData;
8513 if (text === null || clipboardData === null) return;
8514 const bypassAutoAttachment = Date.now() <= pasteAsTextShortcutUntilRef.current;
8515 pasteAsTextShortcutUntilRef.current = 0;
8516 if (
8517 ((readPastedComposerContext(clipboardData)?.records.length ?? 0) === 0 &&
8518 composerRef.current?.pasteTextAtEnd(text, { bypassAutoAttachment })) ||
8519 composerRef.current?.insertTextAtEnd(text, { clipboardData })
8520 ) {
8521 event.preventDefault();
8522 event.stopPropagation();
8523 }
8524 };
8525 window.addEventListener("keydown", keyHandler, true);
8526 window.addEventListener("paste", handler, true);
8527 return () => {
8528 window.removeEventListener("keydown", keyHandler, true);
8529 window.removeEventListener("paste", handler, true);
8530 };
8531 }, [activeThreadId, composerRef]);
8532
8533 const [pendingRevert, setPendingRevert] = useState<{
8534 turnCount: number;
8535 messageId: MessageId;
8536 routeThreadKey: string;
8537 } | null>(null);
8538
8539 if (pendingRevert && pendingRevert.routeThreadKey !== routeThreadKey) {
8540 setPendingRevert(null);
8541 }
8542
8543 const onRevertToTurnCount = useCallback(
8544 async (turnCount: number, messageId: MessageId, restoreFiles?: boolean) => {
8545 const localApi = readLocalApi();
8546 if (
8547 !localApi ||
8548 !activeThread ||
8549 isRevertingCheckpoint ||
8550 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)
8551 )
8552 return;
8553 const sourceMessage = serverProjection?.messages.find((message) => message.id === messageId);
8554 const message = sourceMessage
8555 ? {
8556 id: sourceMessage.id,
8557 role: sourceMessage.role,
8558 text: sourceMessage.text,
8559 attachments: sourceMessage.attachments,
8560 context: sourceMessage.context,
8561 runId: sourceMessage.runId,
8562 streaming: false,
8563 createdAt: DateTime.formatIso(sourceMessage.createdAt),
8564 updatedAt: DateTime.formatIso(sourceMessage.updatedAt),
8565 }
8566 : undefined;
8567 if (!message || message.role !== "user") return;
8568
8569 if (!supportsConversationRollback) {
8570 setThreadError(
8571 activeThread.id,
8572 "This provider does not support reverting conversation history. Start a new thread instead.",
8573 );
8574 return;
8575 }
8576 if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) {
8577 setThreadError(
8578 activeThread.id,
8579 `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`,
8580 );
8581 return;
8582 }
8583 if (phase === "running" || isSendBusy || isConnecting) {
8584 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
8585 return;
8586 }
8587 if (restoreFiles === undefined) {
8588 setPendingRevert({ turnCount, messageId, routeThreadKey });
8589 return;
8590 }
8591
8592 useComposerDraftStore.setState((store) => ({
8593 rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey),
8594 }));
8595 setThreadError(activeThread.id, null);
8596 try {
8597 if (composerRef.current?.hasPendingAttachments()) {
8598 throw new Error("Wait for attachments to finish preparing before rewinding.");
8599 }
8600 const connection = readPreparedConnection(environmentId);
8601 if (!connection) throw new Error("The environment is not connected.");
8602 const files = await prepareRevertedMessageAttachments({
8603 message,
8604 environmentId,
8605 httpBaseUrl: connection.httpBaseUrl,
8606 createAssetUrl: createAttachmentAssetUrl,
8607 });
8608 const store = useComposerDraftStore.getState();
8609 const draft = store.getComposerDraft(composerDraftTarget);
8610 if (
8611 (draft?.images.length ?? 0) + (draft?.files.length ?? 0) + files.length >
8612 PROVIDER_SEND_TURN_MAX_ATTACHMENTS
8613 ) {
8614 throw new Error(
8615 "Make room for this message's attachments in the composer before rewinding.",
8616 );
8617 }
8618 const commandId = CommandId.make(randomUUID());
8619 await waitForRevertedMessage(routeThreadRef, messageId, turnCount, commandId, async () => {
8620 const result = await revertThreadCheckpoint({
8621 environmentId,
8622 input: { commandId, threadId: activeThread.id, turnCount, restoreFiles },
8623 });
8624 if (result._tag === "Failure") throw squashAtomCommandFailure(result);
8625 });
8626 const currentPrompt = store.getComposerDraft(composerDraftTarget)?.prompt ?? "";
8627 const restoredPrompt = recallableComposerPrompt(message.text);
8628 const nextPrompt =
8629 restoredPrompt.length === 0
8630 ? currentPrompt
8631 : currentPrompt.length > 0
8632 ? `${currentPrompt}\n\n${restoredPrompt}`
8633 : restoredPrompt;
8634 store.setPrompt(composerDraftTarget, nextPrompt);
8635 const images: ComposerImageAttachment[] = [];
8636 const restoredFiles: ComposerFileAttachment[] = [];
8637 files.forEach((file, index) => {
8638 const attachment = {
8639 id: randomUUID(),
8640 name: file.name,
8641 mimeType: file.type,
8642 sizeBytes: file.size,
8643 file,
8644 };
8645 if (message.attachments?.[index]?.type === "image") {
8646 images.push({ ...attachment, type: "image", previewUrl: URL.createObjectURL(file) });
8647 } else {
8648 restoredFiles.push({ ...attachment, type: "file" });
8649 }
8650 });
8651 store.addImages(composerDraftTarget, images, { allowDuplicates: true });
8652 store.addFiles(composerDraftTarget, restoredFiles, { allowDuplicates: true });
8653 if (currentRouteThreadKeyRef.current === routeThreadKey) {
8654 promptRef.current = nextPrompt;
8655 composerRef.current?.resetCursorState({ prompt: nextPrompt, cursor: nextPrompt.length });
8656 requestAnimationFrame(() => {
8657 if (currentRouteThreadKeyRef.current === routeThreadKey)
8658 composerRef.current?.focusAtEnd();
8659 });
8660 }
8661 } catch (error) {
8662 setThreadError(
8663 activeThread.id,
8664 error instanceof Error ? error.message : "Failed to revert thread state.",
8665 );
8666 } finally {
8667 useComposerDraftStore.setState((store) => {
8668 const remaining = new Set(store.rewindingThreadKeys);
8669 remaining.delete(routeThreadKey);
8670 return { rewindingThreadKeys: remaining };
8671 });
8672 }
8673 },
8674 [
8675 activeThread,
8676 activeEnvironmentUnavailable,
8677 activeEnvironmentUnavailableLabel,
8678 composerDraftTarget,
8679 composerRef,
8680 createAttachmentAssetUrl,
8681 environmentId,
8682 isConnecting,
8683 isRevertingCheckpoint,
8684 isSendBusy,
8685 phase,
8686 revertThreadCheckpoint,
8687 routeThreadKey,
8688 routeThreadRef,
8689 setThreadError,
8690 supportsConversationRollback,
8691 serverProjection,
8692 ],
8693 );
8694
8695 const onRollbackCheckpoint = useCallback(
8696 async (input: { readonly checkpointId: string; readonly scopeId: string }) => {
8697 if (!activeThread || isRevertingCheckpoint) return;
8698 if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) {
8699 setThreadError(
8700 activeThread.id,
8701 `Reconnect ${activeEnvironmentUnavailableLabel} before reverting checkpoints.`,
8702 );
8703 return;
8704 }
8705 if (phase === "running" || isSendBusy || isConnecting) {
8706 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
8707 return;
8708 }
8709 const localApi = readLocalApi();
8710 const confirmed =
8711 localApi == null
8712 ? window.confirm("Roll back this thread to the selected checkpoint?")
8713 : await localApi.dialogs.confirm(
8714 "Roll back this thread to the selected checkpoint?\nThis action cannot be undone.",
8715 );
8716 if (!confirmed) return;
8717
8718 useComposerDraftStore.setState((store) => ({
8719 rewindingThreadKeys: new Set(store.rewindingThreadKeys).add(routeThreadKey),
8720 }));
8721 setThreadError(activeThread.id, null);
8722 const result = await revertThreadCheckpoint({
8723 environmentId,
8724 input: {
8725 threadId: activeThread.id,
8726 checkpointId: input.checkpointId,
8727 scopeId: input.scopeId,
8728 },
8729 });
8730 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
8731 const error = squashAtomCommandFailure(result);
8732 setThreadError(
8733 activeThread.id,
8734 error instanceof Error ? error.message : "Failed to revert thread state.",
8735 );
8736 }
8737 useComposerDraftStore.setState((store) => {
8738 const remaining = new Set(store.rewindingThreadKeys);
8739 remaining.delete(routeThreadKey);
8740 return { rewindingThreadKeys: remaining };
8741 });
8742 },
8743 [
8744 activeEnvironmentUnavailable,
8745 activeEnvironmentUnavailableLabel,
8746 activeThread,
8747 environmentId,
8748 isConnecting,
8749 isRevertingCheckpoint,
8750 isSendBusy,
8751 phase,
8752 revertThreadCheckpoint,
8753 setThreadError,
8754 ],
8755 );
8756
8757 const onForkFromRun = useCallback(
8758 async (input: { readonly sourceThreadId: ThreadId; readonly runId: RunId }) => {
8759 if (!activeThread || activeEnvironmentUnavailable) return;
8760 const targetThreadId = newThreadId();
8761 const targetThreadRef = scopeThreadRef(environmentId, targetThreadId);
8762 const result = await forkThreadFromRun({
8763 environmentId,
8764 input: {
8765 sourceThreadId: input.sourceThreadId,
8766 targetThreadId,
8767 runId: input.runId,
8768 title: `${activeThread.title} fork`,
8769 },
8770 });
8771 if (result._tag === "Failure") {
8772 if (!isAtomCommandInterrupted(result)) {
8773 const error = squashAtomCommandFailure(result);
8774 setThreadError(
8775 activeThread.id,
8776 error instanceof Error ? error.message : "Failed to fork this response.",
8777 );
8778 }
8779 return;
8780 }
8781 const targetThreadReady = await waitForThreadShell(targetThreadRef);
8782 if (!targetThreadReady) {
8783 setThreadError(
8784 activeThread.id,
8785 "The fork was created, but its thread data did not reach this client. Reconnect and try opening it from the sidebar.",
8786 );
8787 return;
8788 }
8789 await navigate({
8790 to: "/$environmentId/$threadId",
8791 params: buildThreadRouteParams(targetThreadRef),
8792 });
8793 },
8794 [
8795 activeEnvironmentUnavailable,
8796 activeThread,
8797 environmentId,
8798 forkThreadFromRun,
8799 navigate,
8800 setThreadError,
8801 ],
8802 );
8803 const onCompactContext = () => {
8804 if (compactDisabled) return;
8805 void sendStandaloneCommand("/compact", "Failed to compact context.");
8806 };
8807
8808 const onResume = async () => {
8809 if (
8810 !activeThread ||
8811 (resumableRunId === null && !hasHeldQueuedRuns) ||
8812 isSendBusy ||
8813 isResuming ||
8814 isConnecting ||
8815 isRevertingCheckpoint ||
8816 threadDetailLoading ||
8817 activeEnvironmentUnavailable ||
8818 sendInFlightRef.current
8819 ) {
8820 return;
8821 }
8822 const threadId = activeThread.id;
8823 sendInFlightRef.current = true;
8824 setResumingThreadKeys((current) => new Set(current).add(routeThreadKey));
8825 setThreadError(threadId, null);
8826 try {
8827 const resume = async () => {
8828 if (resumableRunId === null) {
8829 return resumeThreadQueue({ environmentId, input: { threadId } });
8830 }
8831 const createdAt = new Date().toISOString();
8832 const settingsResult = await persistThreadSettingsForNextTurn({
8833 threadId,
8834 createdAt,
8835 ...(localCheckoutBranchMismatch
8836 ? { branch: localCheckoutBranchMismatch.currentBranch }
8837 : {}),
8838 runtimeMode,
8839 interactionMode,
8840 });
8841 if (settingsResult._tag === "Failure") return settingsResult;
8842 const modelSelection = composerRef.current?.getSendContext().selectedModelSelection;
8843 const turnResult = await startThreadTurn({
8844 environmentId,
8845 input: {
8846 threadId,
8847 manualContinuationOfRunId: resumableRunId,
8848 ...(modelSelection ? { modelSelection } : {}),
8849 message: {
8850 messageId: newMessageId(),
8851 role: "user",
8852 text: "Continue where you left off.",
8853 attachments: [],
8854 },
8855 runtimeMode,
8856 interactionMode,
8857 dispatchMode: "start",
8858 },
8859 });
8860 if (turnResult._tag === "Failure" || !hasHeldQueuedRuns) return turnResult;
8861 clearUsageLimitsFor(routeThreadKey);
8862 return resumeThreadQueue({ environmentId, input: { threadId } });
8863 };
8864 const result = await resume();
8865 if (result._tag === "Failure") {
8866 if (!isAtomCommandInterrupted(result)) {
8867 const error = squashAtomCommandFailure(result);
8868 setThreadError(
8869 threadId,
8870 error instanceof Error ? error.message : "Could not resume thread.",
8871 );
8872 }
8873 } else {
8874 clearUsageLimitsFor(routeThreadKey);
8875 if (currentRouteThreadKeyRef.current === routeThreadKey) scrollToEnd();
8876 }
8877 } finally {
8878 sendInFlightRef.current = false;
8879 setResumingThreadKeys((current) => {
8880 const next = new Set(current);
8881 next.delete(routeThreadKey);
8882 return next;
8883 });
8884 }
8885 };
8886
8887 const onSend = async (
8888 e?: { preventDefault: () => void },
8889 dispatchMode: ComposerDispatchMode = "auto",
8890 submissionIntent: ComposerSubmissionIntent = "foreground",
8891 directAnnotation?: {
8892 annotation: PreviewAnnotationPayload;
8893 image: ComposerImageAttachment | null;
8894 },
8895 ) => {
8896 e?.preventDefault();
8897 // Typed out in full rather than picked from the menu. Attachments or contexts
8898 // mean the user is sending a prompt, so those go through as usual.
8899 if (
8900 usageLimitsOffered &&
8901 usageLimitsKey !== null &&
8902 !directAnnotation &&
8903 !composerHasNonPromptContent &&
8904 isUsageLimitsCommand(promptRef.current)
8905 ) {
8906 if (openUsageLimits()) {
8907 promptRef.current = "";
8908 setComposerDraftPrompt(composerDraftTarget, "");
8909 composerRef.current?.resetCursorState();
8910 }
8911 return;
8912 }
8913
8914 const notifyDirectAnnotationAttached = () => {
8915 if (!directAnnotation) return;
8916 toastManager.add(
8917 stackedThreadToast({
8918 type: "info",
8919 title: "Annotation attached to draft",
8920 description: "Sending is unavailable right now. Finish the current action, then send.",
8921 }),
8922 );
8923 };
8924 if (
8925 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
8926 !activeThread ||
8927 (isSendBusy && !sendQueuesBehindSetup) ||
8928 isConnecting ||
8929 isRevertingCheckpoint ||
8930 !clientSettingsHydrated ||
8931 threadDetailLoading ||
8932 environmentChangeRef.current !== null ||
8933 sendInFlightRef.current ||
8934 feedbackUploadsInFlightRef.current.has(routeThreadKey)
8935 ) {
8936 notifyDirectAnnotationAttached();
8937 return;
8938 }
8939 if (needsLoadBalancing) {
8940 toastManager.add({
8941 type: "warning",
8942 title: loadBalancing.pending
8943 ? "Checking machine resources"
8944 : "Choose a machine to continue",
8945 description: loadBalancing.pending
8946 ? "Resource checks are still running. You can choose a machine in the composer."
8947 : "No eligible machine has available resources. Choose a machine in the composer to override.",
8948 });
8949 return;
8950 }
8951 if (activeEnvironmentUnavailable) {
8952 const toastSlot = environmentUnavailableSendToastSlotRef.current;
8953 environmentUnavailableSendToastSlotRef.current =
8954 (toastSlot + 1) % ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE;
8955 toastManager.add({
8956 ...stackedThreadToast({
8957 type: "warning",
8958 title: "Not connected: message not sent",
8959 description: "Reconnecting to the environment. Try again once it is connected.",
8960 }),
8961 id: `chat-send-environment-unavailable:${toastSlot}`,
8962 });
8963 return;
8964 }
8965 const sendCtx = composerRef.current?.getSendContext();
8966 if (activePendingProgress && sendCtx?.answeringPendingUserInput !== false) {
8967 if (directAnnotation) {
8968 notifyDirectAnnotationAttached();
8969 return;
8970 }
8971 onAdvanceActivePendingUserInput();
8972 return;
8973 }
8974 if (!sendCtx?.providerAvailable) {
8975 notifyDirectAnnotationAttached();
8976 return;
8977 }
8978 const multipleModelSelections = sendCtx.multipleModelSelections;
8979 if (
8980 multipleModelSelections !== null &&
8981 serverConfig?.environment.capabilities.requiredWorktreeBootstrap !== true
8982 ) {
8983 setThreadError(activeThread.id, "Update this server before starting multiple models.");
8984 return;
8985 }
8986 if (
8987 multipleModelSelections !== null &&
8988 (!isLocalDraftThread ||
8989 !isGitRepo ||
8990 !activeThreadBranch ||
8991 multipleModelSelections.length === 0)
8992 ) {
8993 toastManager.add(
8994 stackedThreadToast({
8995 type: "warning",
8996 title: "Choose models and a base branch",
8997 description:
8998 "Multiple models need a new thread in a Git project. Each gets its own worktree.",
8999 }),
9000 );
9001 return;
9002 }
9003 const {
9004 images: sendContextImages,
9005 files: composerFiles,
9006 terminalContexts: composerTerminalContexts,
9007 previewAnnotations: sendContextPreviewAnnotations,
9008 reviewComments: composerReviewComments,
9009 threadContexts: composerThreadContexts,
9010 selectedProvider: ctxSelectedProvider,
9011 selectedModel: ctxSelectedModel,
9012 selectedProviderModels: ctxSelectedProviderModels,
9013 selectedPromptEffort: ctxSelectedPromptEffort,
9014 selectedModelSelection: ctxSelectedModelSelection,
9015 interactionMode: sendInteractionMode,
9016 interactionModeEnabled: sendInteractionModeEnabled,
9017 } = sendCtx;
9018 const annotationImageAlreadyAttached =
9019 directAnnotation?.image !== undefined &&
9020 sendContextImages.some((image) => image.id === directAnnotation.image?.id);
9021 // A full composer (e.g. 8 files) cannot take the annotation screenshot;
9022 // over the cap the server rejects the whole turn.
9023 const annotationImageAppended =
9024 directAnnotation?.image !== undefined &&
9025 !annotationImageAlreadyAttached &&
9026 sendContextImages.length + composerFiles.length < PROVIDER_SEND_TURN_MAX_ATTACHMENTS;
9027 const composerImages =
9028 directAnnotation?.image && annotationImageAppended
9029 ? [...sendContextImages, directAnnotation.image]
9030 : sendContextImages;
9031 const composerPreviewAnnotations =
9032 directAnnotation &&
9033 !sendContextPreviewAnnotations.some(
9034 (annotation) => annotation.id === directAnnotation.annotation.id,
9035 )
9036 ? [
9037 ...sendContextPreviewAnnotations,
9038 {
9039 ...directAnnotation.annotation,
9040 // Claim an attached crop only when the screenshot really rides
9041 // along; a cap-dropped image must not produce a lying prompt.
9042 screenshot:
9043 directAnnotation.annotation.screenshot &&
9044 (annotationImageAppended || annotationImageAlreadyAttached)
9045 ? { ...directAnnotation.annotation.screenshot, dataUrl: "" }
9046 : null,
9047 },
9048 ]
9049 : sendContextPreviewAnnotations;
9050 // A direct "send annotation" writes the draft and sends in the same tick; the reference
9051 // must be in the text now, not after the next render.
9052 const promptForSend = directAnnotation
9053 ? ensureInlineContextReferences(promptRef.current, [
9054 previewAnnotationContextReference(directAnnotation.annotation),
9055 ])
9056 : promptRef.current;
9057 if (editingQueuedRun !== null) {
9058 // Edit mode repurposes the composer: sending saves the queued message
9059 // in place instead of dispatching a new turn.
9060 if (queuedEditSaveInFlightRef.current) return;
9061 const editText = promptForSend.trim();
9062 const newEditImages = [...composerImages];
9063 const newEditFiles = [...composerFiles];
9064 const newEditAttachments = [...newEditImages, ...newEditFiles];
9065 if (
9066 editingQueuedRun.existingAttachments.length + newEditAttachments.length >
9067 PROVIDER_SEND_TURN_MAX_ATTACHMENTS
9068 ) {
9069 setThreadError(
9070 editingQueuedRun.threadId,
9071 `A message can have at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments.`,
9072 );
9073 return;
9074 }
9075 if (
9076 editText.length === 0 &&
9077 editingQueuedRun.existingAttachments.length === 0 &&
9078 newEditImages.length === 0 &&
9079 newEditFiles.length === 0
9080 ) {
9081 return;
9082 }
9083 queuedEditSaveInFlightRef.current = true;
9084 setIsSavingQueuedEdit(true);
9085 try {
9086 const uploads = await prepareQueuedEditAttachments({
9087 existingAttachments: editingQueuedRun.existingAttachments,
9088 images: newEditImages,
9089 files: newEditFiles,
9090 readImage: readFileAsDataUrl,
9091 uploadFiles: async (files) => {
9092 const validateFiles = () => {
9093 const config =
9094 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId) ?? null;
9095 const reason = fileAttachmentCapabilityBlockReason({
9096 files,
9097 attachmentUploadsCapabilityKnown: config !== null,
9098 supportsAttachmentUploads:
9099 config?.environment.capabilities.attachmentUploads === true,
9100 maxFileAttachmentBytes:
9101 config?.environment.capabilities.fileAttachments?.maxUploadBytes ?? null,
9102 });
9103 if (reason !== null) throw new Error(reason);
9104 };
9105 validateFiles();
9106 for (const file of files)
9107 startAttachmentUpload({
9108 environmentId,
9109 image: file,
9110 draftTarget: composerDraftTarget,
9111 });
9112 await awaitAttachmentUploads(files.map((file) => file.id));
9113 validateFiles();
9114 const uploaded = getUploadedAttachments({ environmentId, images: files });
9115 if (uploaded === null) throw new Error("Retry or remove failed uploads before saving.");
9116 return uploaded;
9117 },
9118 });
9119 const result = await editQueuedRunCommand({
9120 environmentId: activeThread.environmentId,
9121 input: {
9122 threadId: editingQueuedRun.threadId,
9123 runId: editingQueuedRun.runId,
9124 text: editText.length === 0 ? ATTACHMENT_ONLY_BOOTSTRAP_PROMPT : editText,
9125 edit: {
9126 messageId: editingQueuedRun.messageId,
9127 attachments: uploads,
9128 context: {
9129 version: 1,
9130 records: [
9131 ...new Map(
9132 [
9133 ...(editingQueuedRun.context?.records ?? []).filter(
9134 (record) =>
9135 !("attachmentId" in record) ||
9136 editingQueuedRun.existingAttachments.some(
9137 (attachment) => attachment.id === record.attachmentId,
9138 ),
9139 ),
9140 ...(buildMessageContext({
9141 terminalContexts: composerTerminalContexts.filter(
9142 (context) => context.text.trim().length > 0,
9143 ),
9144 reviewComments: composerReviewComments,
9145 previewAnnotations: composerPreviewAnnotations,
9146 threadContexts: composerThreadContexts,
9147 attachments: newEditAttachments.map((attachment, index) => ({
9148 attachment,
9149 attachmentId:
9150 uploads[editingQueuedRun.existingAttachments.length + index]?.id ??
9151 attachment.id,
9152 })),
9153 })?.records ?? []),
9154 ].map((record) => [record.contextId, record] as const),
9155 ).values(),
9156 ],
9157 },
9158 },
9159 },
9160 });
9161 if (result._tag === "Failure") {
9162 setThreadError(editingQueuedRun.threadId, "Could not save the edited queued message.");
9163 return;
9164 }
9165 setThreadError(editingQueuedRun.threadId, null);
9166 releaseDraftAttachments(newEditAttachments);
9167 promptRef.current = "";
9168 clearComposerDraftContent(queuedEditDraftTargetFor(editingQueuedRun.runId));
9169 composerRef.current?.resetCursorState();
9170 setEditingQueuedRun(null);
9171 scheduleComposerFocus();
9172 } catch (error) {
9173 setThreadError(
9174 editingQueuedRun.threadId,
9175 error instanceof Error ? error.message : "Could not save the edited queued message.",
9176 );
9177 } finally {
9178 queuedEditSaveInFlightRef.current = false;
9179 setIsSavingQueuedEdit(false);
9180 }
9181 return;
9182 }
9183 const {
9184 trimmedPrompt: trimmed,
9185 sendableTerminalContexts: sendableComposerTerminalContexts,
9186 expiredTerminalContextCount,
9187 hasSendableContent,
9188 } = deriveComposerSendState({
9189 prompt: promptForSend,
9190 imageCount: composerImages.length + composerFiles.length,
9191 terminalContexts: composerTerminalContexts,
9192 elementContextCount:
9193 composerPreviewAnnotations.length +
9194 composerReviewComments.length +
9195 composerThreadContexts.length,
9196 });
9197 const feedbackCommand =
9198 ctxSelectedProvider === "codex" &&
9199 composerImages.length === 0 &&
9200 composerFiles.length === 0 &&
9201 sendableComposerTerminalContexts.length === 0 &&
9202 composerPreviewAnnotations.length === 0 &&
9203 composerReviewComments.length === 0 &&
9204 composerThreadContexts.length === 0
9205 ? parseCodexFeedbackCommand(trimmed)
9206 : null;
9207 if (feedbackCommand && multipleModelSelections === null) {
9208 if (!isServerThread || activeThread.activeProviderThreadId === null) {
9209 toastManager.add(
9210 stackedThreadToast({
9211 type: "warning",
9212 title: "Start a Codex thread first",
9213 description: "Send a message before you submit feedback.",
9214 }),
9215 );
9216 return;
9217 }
9218 feedbackUploadsInFlightRef.current.add(routeThreadKey);
9219 await submitCodexFeedback({
9220 submission: {
9221 id: newMessageId(),
9222 command: trimmed,
9223 createdAt: new Date().toISOString(),
9224 },
9225 clearDraft: () => {
9226 promptRef.current = "";
9227 clearComposerDraftContent(composerDraftTarget);
9228 composerRef.current?.resetCursorState();
9229 },
9230 onUpdate: (submission) => {
9231 setFeedbackSubmissionsByThreadKey((current) => {
9232 const existing = current[routeThreadKey] ?? [];
9233 const found = existing.some((entry) => entry.id === submission.id);
9234 return {
9235 ...current,
9236 [routeThreadKey]: found
9237 ? existing.map((entry) => (entry.id === submission.id ? submission : entry))
9238 : [...existing, submission],
9239 };
9240 });
9241 },
9242 upload: () =>
9243 uploadThreadFeedback({
9244 environmentId: activeThread.environmentId,
9245 input: { threadId: activeThread.id, ...feedbackCommand },
9246 }),
9247 }).finally(() => {
9248 feedbackUploadsInFlightRef.current.delete(routeThreadKey);
9249 });
9250
9251 return;
9252 }
9253 if (
9254 !directAnnotation &&
9255 sendInteractionModeEnabled &&
9256 showPlanFollowUpPrompt &&
9257 activeProposedPlan &&
9258 composerImages.length === 0 &&
9259 composerFiles.length === 0
9260 ) {
9261 const followUp = resolvePlanFollowUpSubmission({
9262 draftText: promptForSend,
9263 planMarkdown: activeProposedPlan.planMarkdown,
9264 });
9265 const outgoingFollowUpText = formatOutgoingPrompt({
9266 provider: ctxSelectedProvider,
9267 model: ctxSelectedModel,
9268 models: ctxSelectedProviderModels,
9269 effort: ctxSelectedPromptEffort,
9270 text: followUp.text.trim(),
9271 });
9272 if (composerRef.current?.validateProviderInput(outgoingFollowUpText) === false) {
9273 return;
9274 }
9275 // The composer is cleared before the send resolves, so hold everything it carried: a
9276 // transient failure must give the prose and its context back, as the ordinary send does.
9277 // Snapshot exactly what was sent, copied, so later mutations cannot alias the backup.
9278 const followUpPromptSnapshot = promptRef.current;
9279 const followUpTerminalContexts = [...sendableComposerTerminalContexts];
9280 const followUpReviewComments = [...composerReviewComments];
9281 const followUpPreviewAnnotations = [...composerPreviewAnnotations];
9282 const followUpThreadContexts = [...composerThreadContexts];
9283 promptRef.current = "";
9284 clearComposerDraftContent(composerDraftTarget);
9285 composerRef.current?.resetCursorState();
9286 const followUpSent = await onSubmitPlanFollowUp({
9287 text: followUp.text,
9288 context: buildMessageContext({
9289 terminalContexts: sendableComposerTerminalContexts,
9290 reviewComments: composerReviewComments,
9291 previewAnnotations: composerPreviewAnnotations,
9292 threadContexts: composerThreadContexts,
9293 }),
9294 interactionMode: followUp.interactionMode,
9295 });
9296 if (!followUpSent) {
9297 promptRef.current = followUpPromptSnapshot;
9298 composerTerminalContextsRef.current = [...followUpTerminalContexts];
9299 restorePlanFollowUpComposer({
9300 snapshot: {
9301 prompt: followUpPromptSnapshot,
9302 terminalContexts: followUpTerminalContexts,
9303 reviewComments: followUpReviewComments,
9304 previewAnnotations: followUpPreviewAnnotations,
9305 threadContexts: followUpThreadContexts,
9306 },
9307 writePrompt: (prompt) => setComposerDraftPrompt(composerDraftTarget, prompt),
9308 writeTerminalContexts: (contexts) =>
9309 setComposerDraftTerminalContexts(composerDraftTarget, [...contexts]),
9310 writeReviewComments: (comments) =>
9311 setComposerDraftReviewComments(composerDraftTarget, [...comments]),
9312 writePreviewAnnotations: (annotations) =>
9313 setComposerDraftPreviewAnnotations(composerDraftTarget, [...annotations]),
9314 writeThreadContexts: (records) =>
9315 setComposerDraftThreadContexts(composerDraftTarget, [...records]),
9316 resetCursor: (options) => composerRef.current?.resetCursorState(options),
9317 });
9318 } else if (
9319 submissionIntent === "background" &&
9320 currentRouteThreadKeyRef.current === routeThreadKey
9321 ) {
9322 handleNewThreadInActiveProject();
9323 }
9324 return;
9325 }
9326 // Providers without the legacy toggle receive their native commands unchanged.
9327 const standaloneSlashCommand =
9328 sendInteractionModeEnabled &&
9329 composerImages.length === 0 &&
9330 composerFiles.length === 0 &&
9331 sendableComposerTerminalContexts.length === 0 &&
9332 composerPreviewAnnotations.length === 0 &&
9333 composerReviewComments.length === 0 &&
9334 composerThreadContexts.length === 0
9335 ? parseStandaloneComposerSlashCommand(trimmed)
9336 : null;
9337 if (standaloneSlashCommand && multipleModelSelections === null) {
9338 handleInteractionModeChange(standaloneSlashCommand);
9339 promptRef.current = "";
9340 clearComposerDraftContent(composerDraftTarget);
9341 composerRef.current?.resetCursorState();
9342 return;
9343 }
9344 if (!hasSendableContent) {
9345 if (expiredTerminalContextCount > 0) {
9346 const toastCopy = buildExpiredTerminalContextToastCopy(
9347 expiredTerminalContextCount,
9348 "empty",
9349 );
9350 toastManager.add(
9351 stackedThreadToast({
9352 type: "warning",
9353 title: toastCopy.title,
9354 description: toastCopy.description,
9355 }),
9356 );
9357 }
9358 return;
9359 }
9360 if (!activeProject) {
9361 toastManager.add(
9362 stackedThreadToast({
9363 type: "warning",
9364 title: "Choose a project first",
9365 description: "This draft no longer points to an available project.",
9366 }),
9367 );
9368 return;
9369 }
9370 const threadIdForSend = activeThread.id;
9371 const isFirstMessage = !sendQueuesBehindSetup && (!isServerThread || activeMessageCount === 0);
9372 const baseBranchForWorktree =
9373 isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath
9374 ? activeThreadBranch
9375 : null;
9376
9377 // In worktree mode, require an explicit base branch so we don't silently
9378 // fall back to local execution when branch selection is missing.
9379 const shouldCreateWorktree =
9380 isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath;
9381 if (shouldCreateWorktree && !activeThreadBranch) {
9382 setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
9383 return;
9384 }
9385
9386 const composerImagesSnapshot = [...composerImages];
9387 const composerFilesSnapshot = [...composerFiles];
9388 const composerAttachmentsSnapshot = [...composerImagesSnapshot, ...composerFilesSnapshot];
9389 const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts];
9390 const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations];
9391 const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments];
9392 const composerThreadContextsSnapshot = [...composerThreadContexts];
9393 // Expired terminal excerpts are not sent; their chips leave the text with them.
9394 const messageTextForSend = composerTerminalContexts
9395 .filter((context) => !composerTerminalContextsSnapshot.includes(context))
9396 .reduce(
9397 (text, context) =>
9398 removeInlineContextReference(text, terminalContextReference(context).contextId).prompt,
9399 promptForSend,
9400 )
9401 .trim();
9402 // Records bind attachments by the id each side knows: the local id for the optimistic
9403 // row, the upload id (or local id on the data-URL path) on the wire; the server
9404 // rebinds them to the persisted id.
9405 const buildOutgoingMessageContext = (attachmentIds: ReadonlyArray<string>) =>
9406 buildMessageContext({
9407 terminalContexts: composerTerminalContextsSnapshot,
9408 reviewComments: composerReviewCommentsSnapshot,
9409 previewAnnotations: composerPreviewAnnotationsSnapshot,
9410 threadContexts: composerThreadContextsSnapshot,
9411 attachments: composerAttachmentsSnapshot.map((attachment, index) => ({
9412 attachment,
9413 attachmentId: attachmentIds[index] ?? attachment.id,
9414 })),
9415 });
9416 const outgoingMessageContext = buildOutgoingMessageContext(
9417 composerAttachmentsSnapshot.map((attachment) => attachment.id),
9418 );
9419 const messageIdForSend = newMessageId();
9420 const messageCreatedAt = new Date().toISOString();
9421 // A stale Claude session compacts before this message so the turn does not re-read the
9422 // old history. The message queues behind the /compact run, since steering into it is
9423 // rejected.
9424 const compactBeforeSend =
9425 resumeCompactionTokens !== null &&
9426 !keepFullHistory &&
9427 messageTextForSend.toLowerCase() !== "/compact";
9428 const turnDispatchMode = compactBeforeSend || sendQueuesBehindSetup ? "queue" : dispatchMode;
9429 const shouldQueueBehindActiveRun =
9430 compactBeforeSend ||
9431 sendQueuesBehindSetup ||
9432 (phase === "running" && dispatchMode === "queue");
9433 const outgoingMessageText = formatOutgoingPrompt({
9434 provider: ctxSelectedProvider,
9435 model: ctxSelectedModel,
9436 models: ctxSelectedProviderModels,
9437 effort: ctxSelectedPromptEffort,
9438 text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
9439 });
9440 if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) {
9441 return;
9442 }
9443
9444 const readLiveAttachmentCapabilities = () => {
9445 const config = appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId) ?? null;
9446 const liveSupportsAttachmentUploads =
9447 config?.environment.capabilities.attachmentUploads === true;
9448 return {
9449 supportsAttachmentUploads: liveSupportsAttachmentUploads,
9450 fileBlockReason: fileAttachmentCapabilityBlockReason({
9451 files: composerFilesSnapshot,
9452 attachmentUploadsCapabilityKnown: config !== null,
9453 supportsAttachmentUploads: liveSupportsAttachmentUploads,
9454 maxFileAttachmentBytes:
9455 config?.environment.capabilities.fileAttachments?.maxUploadBytes ?? null,
9456 }),
9457 };
9458 };
9459
9460 const multipleTargets = [];
9461 for (const selection of multipleModelSelections ?? []) {
9462 const provider = providerInstanceEntries.find(
9463 (entry) => entry.instanceId === selection.instanceId,
9464 );
9465 if (!provider?.enabled || !provider.isAvailable || provider.status !== "ready") {
9466 setThreadError(threadIdForSend, `Provider for ${selection.model} is unavailable.`);
9467 return;
9468 }
9469 const providerBlockReason = getAntigravitySendBlockReason(provider.snapshot, selection.model);
9470 if (providerBlockReason) {
9471 setThreadError(threadIdForSend, providerBlockReason);
9472 return;
9473 }
9474 const providerState = getComposerProviderState({
9475 provider: provider.driverKind,
9476 model: selection.model,
9477 models: provider.models,
9478 modelOptions: selection.options,
9479 promptInjectionState: getComposerPromptInjectionState(messageTextForSend),
9480 planModeEnabled: settings.planModeEnabled,
9481 });
9482 const text = formatOutgoingPrompt({
9483 provider: provider.driverKind,
9484 model: selection.model,
9485 models: provider.models,
9486 effort: providerState.promptEffort,
9487 text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT,
9488 });
9489 if (composerRef.current?.validateProviderInput(text) === false) return;
9490 multipleTargets.push({
9491 selection: createModelSelection(
9492 selection.instanceId,
9493 selection.model,
9494 providerState.modelOptionsForDispatch,
9495 ),
9496 text,
9497 interactionMode: resolveComposerInteractionMode({
9498 planModeEnabled: settings.planModeEnabled,
9499 provider: provider.snapshot,
9500 interactionMode: sendInteractionMode,
9501 }).interactionMode,
9502 });
9503 }
9504
9505 sendInFlightRef.current = true;
9506 const sendGeneration = ++composerSendGenerationRef.current;
9507 const attachmentCapabilitiesBeforeUpload = readLiveAttachmentCapabilities();
9508 if (attachmentCapabilitiesBeforeUpload.fileBlockReason !== null) {
9509 sendInFlightRef.current = false;
9510 setThreadError(threadIdForSend, attachmentCapabilitiesBeforeUpload.fileBlockReason);
9511 return;
9512 }
9513 const turnUsesAttachmentUploads =
9514 composerFilesSnapshot.length > 0
9515 ? attachmentCapabilitiesBeforeUpload.supportsAttachmentUploads
9516 : supportsAttachmentUploads;
9517 if (turnUsesAttachmentUploads && composerAttachmentsSnapshot.length > 0) {
9518 for (const attachment of composerAttachmentsSnapshot) {
9519 startAttachmentUpload({
9520 environmentId,
9521 image: attachment,
9522 draftTarget: composerDraftTarget,
9523 });
9524 }
9525 await awaitAttachmentUploads(composerAttachmentsSnapshot.map((attachment) => attachment.id));
9526 const attachmentCapabilitiesAfterUpload = readLiveAttachmentCapabilities();
9527 if (attachmentCapabilitiesAfterUpload.fileBlockReason !== null) {
9528 sendInFlightRef.current = false;
9529 setThreadError(threadIdForSend, attachmentCapabilitiesAfterUpload.fileBlockReason);
9530 return;
9531 }
9532 if (getUploadedAttachments({ environmentId, images: composerAttachmentsSnapshot }) === null) {
9533 sendInFlightRef.current = false;
9534 setThreadError(threadIdForSend, "Retry or remove failed uploads before sending.");
9535 return;
9536 }
9537 }
9538
9539 if (
9540 multipleModelSelections === null &&
9541 shouldDockDraftHeroForSubmission({ isDraftHeroState, activeThreadKey, submissionIntent }) &&
9542 activeThreadKey
9543 ) {
9544 let resolveDockStarted: (() => void) | undefined;
9545 const dockStarted = new Promise<void>((resolve) => {
9546 resolveDockStarted = resolve;
9547 });
9548 const dockTransition = runMobileComposerTransition(
9549 () => {
9550 flushSync(() => {
9551 captureDraftHeroComposerRect();
9552 setDockedDraftHeroThreadKey(activeThreadKey);
9553 });
9554 resolveDockStarted?.();
9555 },
9556 {
9557 active: panelAnimationsActive,
9558 durationMs: panelAnimationDurationMs,
9559 },
9560 );
9561 void dockTransition.catch(() => resolveDockStarted?.());
9562 await dockStarted;
9563 }
9564
9565 const attachmentCapabilitiesBeforeDispatch = readLiveAttachmentCapabilities();
9566 if (
9567 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
9568 attachmentCapabilitiesBeforeDispatch.fileBlockReason !== null
9569 ) {
9570 sendInFlightRef.current = false;
9571 setThreadError(
9572 threadIdForSend,
9573 attachmentCapabilitiesBeforeDispatch.fileBlockReason ??
9574 "This connection cannot change threads.",
9575 );
9576 setDockedDraftHeroThreadKey((currentThreadKey) =>
9577 currentThreadKey === activeThreadKey ? null : currentThreadKey,
9578 );
9579 return;
9580 }
9581 // A send queued behind setup leaves the first send's dispatch and setup card alone.
9582 if (sendQueuesBehindSetup) {
9583 setSetupQueueSendThreadKey(activeThreadKey);
9584 } else {
9585 beginLocalDispatch({
9586 preparingWorktree: multipleModelSelections !== null || Boolean(baseBranchForWorktree),
9587 // Only a draft has a background submission to hide behind its hero.
9588 submissionIntent:
9589 submissionIntent === "background" && !isLocalDraftThread
9590 ? "foreground"
9591 : submissionIntent,
9592 });
9593 setWorktreeSetupRef(
9594 multipleModelSelections === null && baseBranchForWorktree
9595 ? {
9596 environmentId: activeThread.environmentId,
9597 threadId: threadIdForSend,
9598 ownerKey: worktreeSetupOwnerKey,
9599 }
9600 : null,
9601 );
9602 }
9603
9604 const turnAttachmentsPromise = Promise.all(
9605 composerAttachmentsSnapshot.map(async (attachment) => {
9606 if (turnUsesAttachmentUploads) {
9607 const uploaded = getUploadedAttachments({ environmentId, images: [attachment] })?.[0];
9608 if (!uploaded) {
9609 throw new Error(`Attachment '${attachment.name}' did not finish uploading.`);
9610 }
9611 return uploaded;
9612 }
9613 if (attachment.type !== "image") {
9614 throw new Error("This server does not support file attachments.");
9615 }
9616 return {
9617 type: "image" as const,
9618 id: attachment.id,
9619 name: attachment.name,
9620 mimeType: attachment.mimeType,
9621 sizeBytes: attachment.sizeBytes,
9622 dataUrl: await readFileAsDataUrl(attachment.file),
9623 ...(attachment.source ? { source: attachment.source } : {}),
9624 };
9625 }),
9626 );
9627 if (multipleModelSelections !== null) {
9628 const failedSelections: ModelSelection[] = [];
9629 let clearedDraft = false;
9630 let releasedComposer = false;
9631 let canRestoreDraft = () => false;
9632 let startedCount = 0;
9633 try {
9634 const attachments = await turnAttachmentsPromise;
9635 const fileBlockReason = readLiveAttachmentCapabilities().fileBlockReason;
9636 if (fileBlockReason !== null) throw new Error(fileBlockReason);
9637 const context = buildOutgoingMessageContext(
9638 attachments.map((attachment, index) =>
9639 "id" in attachment && attachment.id !== undefined
9640 ? attachment.id
9641 : composerAttachmentsSnapshot[index]!.id,
9642 ),
9643 );
9644 const title = truncate(
9645 assistantCitationsToPlainText(stripInlineContextReferences(trimmed)).trim() ||
9646 composerAttachmentsSnapshot[0]?.name ||
9647 "New thread",
9648 );
9649 promptRef.current = "";
9650 clearComposerDraftContent(composerDraftTarget);
9651 composerRef.current?.resetCursorState();
9652 clearedDraft = true;
9653 const clearedDraftSnapshot = useComposerDraftStore
9654 .getState()
9655 .getComposerDraft(composerDraftTarget);
9656 const submittedSelections = multipleModelSelectionsRef.current;
9657 canRestoreDraft = () =>
9658 currentRouteThreadKeyRef.current === routeThreadKey &&
9659 composerSendGenerationRef.current === sendGeneration &&
9660 useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) ===
9661 clearedDraftSnapshot &&
9662 multipleModelSelectionsRef.current === submittedSelections;
9663 setThreadError(threadIdForSend, null);
9664 const starts = Promise.all(
9665 multipleTargets.map(async (target) => {
9666 const retryKey = JSON.stringify([
9667 routeThreadKey,
9668 target.selection.instanceId,
9669 target.selection.model,
9670 ]);
9671 const uncertainThreadId = uncertainMultipleSubmissionsRef.current.get(retryKey);
9672 const targetThreadId = uncertainThreadId ?? newThreadId();
9673 let requestMayHaveStarted = false;
9674 try {
9675 if (uncertainThreadId) {
9676 throw new Error(
9677 "The previous request may have started. Open its thread to check before sending again.",
9678 );
9679 }
9680 const supportsInlineMessageContext =
9681 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment
9682 .capabilities.inlineMessageContext === true;
9683 requestMayHaveStarted = true;
9684 const result = await startThreadTurn({
9685 environmentId,
9686 input: {
9687 threadId: targetThreadId,
9688 message: {
9689 messageId: newMessageId(),
9690 role: "user",
9691 text:
9692 context && !supportsInlineMessageContext
9693 ? serializeLegacyContextMessage({
9694 text: target.text,
9695 records: context.records,
9696 })
9697 : target.text,
9698 attachments,
9699 ...(context && supportsInlineMessageContext ? { context } : {}),
9700 },
9701 modelSelection: target.selection,
9702 titleSeed: title,
9703 runtimeMode,
9704 interactionMode: target.interactionMode,
9705 bootstrap: {
9706 createThread: {
9707 projectId: activeProject.id,
9708 title,
9709 modelSelection: target.selection,
9710 runtimeMode,
9711 interactionMode: target.interactionMode,
9712 branch: activeThreadBranch,
9713 worktreePath: null,
9714 createdAt: messageCreatedAt,
9715 },
9716 prepareWorktree: {
9717 projectCwd: activeProject.workspaceRoot,
9718 baseBranch: activeThreadBranch!,
9719 requireWorktree: true,
9720 ...(startFromOrigin ? { startFromOrigin: true } : {}),
9721 },
9722 runSetupScript: true,
9723 },
9724 createdAt: messageCreatedAt,
9725 },
9726 });
9727 if (result._tag === "Failure") {
9728 const error = squashAtomCommandFailure(result);
9729 if (wasBootstrapThreadDeleted(error) || wasBootstrapThreadNotCreated(error)) {
9730 requestMayHaveStarted = false;
9731 }
9732 throw error;
9733 }
9734 startedCount += 1;
9735 } catch (error) {
9736 if (requestMayHaveStarted && !uncertainMultipleSubmissionsRef.current.has(retryKey)) {
9737 uncertainMultipleSubmissionsRef.current.set(retryKey, targetThreadId);
9738 }
9739 failedSelections.push(target.selection);
9740 const retainedThreadId = uncertainMultipleSubmissionsRef.current.get(retryKey);
9741 const failureToastId = toastManager.add(
9742 stackedThreadToast({
9743 type: "error",
9744 title: `Could not start ${target.selection.model}`,
9745 description: error instanceof Error ? error.message : "Failed to send message.",
9746 ...(retainedThreadId
9747 ? {
9748 timeout: 0,
9749 data: {
9750 secondaryActionProps: {
9751 children: "Allow retry",
9752 onClick: () => {
9753 void readLocalApi()
9754 ?.dialogs.confirm(
9755 "The previous request may already be running. Check its thread first. Allow another send that could create a duplicate thread?",
9756 )
9757 .then(
9758 (confirmed) => {
9759 if (
9760 confirmed &&
9761 uncertainMultipleSubmissionsRef.current.get(retryKey) ===
9762 retainedThreadId
9763 ) {
9764 uncertainMultipleSubmissionsRef.current.delete(retryKey);
9765 toastManager.close(failureToastId);
9766 }
9767 },
9768 () => undefined,
9769 );
9770 },
9771 },
9772 },
9773 actionProps: {
9774 children: "Open thread",
9775 onClick: () => {
9776 void navigate({
9777 to: "/$environmentId/$threadId",
9778 params: buildThreadRouteParams(
9779 scopeThreadRef(environmentId, retainedThreadId),
9780 ),
9781 });
9782 },
9783 },
9784 }
9785 : {}),
9786 }),
9787 );
9788 }
9789 }),
9790 );
9791 // Each request now owns its background thread. The original draft is
9792 // ready for another prompt while checkout and setup scripts finish.
9793 sendInFlightRef.current = false;
9794 resetLocalDispatch();
9795 releasedComposer = true;
9796 await starts;
9797 if (startedCount > 0) {
9798 toastManager.add(
9799 stackedThreadToast({
9800 type: "success",
9801 title: `Started ${startedCount} ${startedCount === 1 ? "thread" : "threads"} in background`,
9802 }),
9803 );
9804 }
9805 if (failedSelections.length === 0 && turnUsesAttachmentUploads) {
9806 releaseDraftAttachments(composerAttachmentsSnapshot);
9807 }
9808 } catch (error) {
9809 failedSelections.push(...multipleModelSelections);
9810 setThreadError(
9811 threadIdForSend,
9812 error instanceof Error ? error.message : "Failed to send messages.",
9813 );
9814 } finally {
9815 const restoreFailedDraft = () => {
9816 setMultipleModelSelections(failedSelections);
9817 if (clearedDraft) {
9818 setComposerDraftPrompt(composerDraftTarget, messageTextForSend);
9819 addComposerDraftImages(
9820 composerDraftTarget,
9821 composerImagesSnapshot.map(cloneComposerImageForRetry),
9822 );
9823 addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot);
9824 setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot);
9825 setComposerDraftPreviewAnnotations(
9826 composerDraftTarget,
9827 composerPreviewAnnotationsSnapshot,
9828 );
9829 setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot);
9830 setComposerDraftThreadContexts(composerDraftTarget, composerThreadContextsSnapshot);
9831 if (composerRef.current && currentRouteThreadKeyRef.current === routeThreadKey) {
9832 promptRef.current = messageTextForSend;
9833 composerRef.current.resetCursorState({
9834 cursor: collapseExpandedComposerCursor(
9835 messageTextForSend,
9836 messageTextForSend.length,
9837 ),
9838 prompt: messageTextForSend,
9839 detectTrigger: true,
9840 });
9841 }
9842 }
9843 };
9844 if (failedSelections.length > 0) {
9845 if (canRestoreDraft() && composerRef.current) {
9846 restoreFailedDraft();
9847 } else if (clearedDraft) {
9848 const recoveryToastId = toastManager.add(
9849 stackedThreadToast({
9850 type: "error",
9851 title: "A background prompt could not be sent",
9852 description:
9853 "Your newer draft is unchanged. Restore the failed prompt when this composer is empty.",
9854 timeout: 0,
9855 actionProps: {
9856 children: "Restore prompt",
9857 onClick: () => {
9858 if (
9859 !draftId ||
9860 !useComposerDraftStore.getState().getDraftSession(draftId) ||
9861 sendInFlightRef.current ||
9862 composerDraftHasUserContent(
9863 useComposerDraftStore.getState().getComposerDraft(composerDraftTarget),
9864 )
9865 ) {
9866 toastManager.update(recoveryToastId, {
9867 description:
9868 "Return to the original draft and send or clear its current prompt before restoring.",
9869 });
9870 return;
9871 }
9872 restoreFailedDraft();
9873 void navigate({ to: "/draft/$draftId", params: { draftId } });
9874 toastManager.close(recoveryToastId);
9875 },
9876 },
9877 }),
9878 );
9879 }
9880 }
9881 if (!releasedComposer) {
9882 sendInFlightRef.current = false;
9883 resetLocalDispatch();
9884 }
9885 }
9886 return;
9887 }
9888 const optimisticAttachments = composerAttachmentsSnapshot.map((attachment) =>
9889 attachment.type === "image"
9890 ? {
9891 type: "image" as const,
9892 id: attachment.id,
9893 name: attachment.name,
9894 mimeType: attachment.mimeType,
9895 sizeBytes: attachment.sizeBytes,
9896 previewUrl: attachment.previewUrl,
9897 ...(attachment.source ? { source: attachment.source } : {}),
9898 }
9899 : {
9900 type: "file" as const,
9901 id: attachment.id,
9902 name: attachment.name,
9903 mimeType: attachment.mimeType,
9904 sizeBytes: attachment.sizeBytes,
9905 downloadable: false,
9906 ...(attachment.source ? { source: attachment.source } : {}),
9907 },
9908 );
9909 if (!shouldQueueBehindActiveRun) {
9910 // A sent turn returns to the live edge and anchors its new transcript
9911 // row. Queued input stays in the composer queue and must not move the
9912 // timeline away from the provider work already in flight.
9913 isAtEndRef.current = true;
9914 timelineScrollModeRef.current = "anchoring-new-turn";
9915 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
9916 setTimelineLiveFollowEnabled(true);
9917 pendingTimelineAnchorRef.current = messageIdForSend;
9918 activeTimelineAnchorIndexRef.current = null;
9919 showScrollDebouncer.current.cancel();
9920 setShowScrollToBottom(false);
9921 setTimelineAnchor({
9922 threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)),
9923 messageId: messageIdForSend,
9924 });
9925 }
9926 setOptimisticUserMessages((existing) => [
9927 ...existing,
9928 {
9929 id: messageIdForSend,
9930 role: "user",
9931 text: outgoingMessageText,
9932 ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}),
9933 ...(outgoingMessageContext !== undefined ? { context: outgoingMessageContext } : {}),
9934 runId: null,
9935 createdAt: messageCreatedAt,
9936 updatedAt: messageCreatedAt,
9937 streaming: false,
9938 ...(shouldQueueBehindActiveRun
9939 ? { inputIntent: "queued_turn" as const }
9940 : phase === "running" && dispatchMode === "steer"
9941 ? { inputIntent: "steer" as const }
9942 : {}),
9943 },
9944 ]);
9945 setThreadError(threadIdForSend, null);
9946 if (expiredTerminalContextCount > 0) {
9947 const toastCopy = buildExpiredTerminalContextToastCopy(
9948 expiredTerminalContextCount,
9949 "omitted",
9950 );
9951 toastManager.add(
9952 stackedThreadToast({
9953 type: "warning",
9954 title: toastCopy.title,
9955 description: toastCopy.description,
9956 }),
9957 );
9958 }
9959 promptRef.current = "";
9960 clearComposerDraftContent(composerDraftTarget);
9961 composerRef.current?.resetCursorState();
9962
9963 let firstComposerImageName: string | null = null;
9964 if (composerImagesSnapshot.length > 0) {
9965 const firstComposerImage = composerImagesSnapshot[0];
9966 if (firstComposerImage) {
9967 firstComposerImageName = firstComposerImage.name;
9968 }
9969 }
9970 let titleSeed = assistantCitationsToPlainText(stripInlineContextReferences(trimmed)).trim();
9971 if (!titleSeed) {
9972 if (firstComposerImageName) {
9973 titleSeed = `Image: ${firstComposerImageName}`;
9974 } else if (composerFilesSnapshot[0]) {
9975 titleSeed = `File: ${composerFilesSnapshot[0].name}`;
9976 } else if (composerTerminalContextsSnapshot.length > 0) {
9977 titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!);
9978 } else if (composerReviewCommentsSnapshot.length > 0) {
9979 titleSeed = `Review: ${reviewCommentContextLabel(composerReviewCommentsSnapshot[0]!)}`;
9980 } else if (composerPreviewAnnotationsSnapshot.length > 0) {
9981 titleSeed = previewAnnotationContextLabel(composerPreviewAnnotationsSnapshot[0]!);
9982 } else {
9983 titleSeed = "New thread";
9984 }
9985 }
9986 const title = truncate(titleSeed);
9987 const threadCreateModelSelection = createModelSelection(
9988 ctxSelectedModelSelection.instanceId,
9989 ctxSelectedModel || activeProjectDefaultModelSelection?.model || DEFAULT_MODEL,
9990 ctxSelectedModelSelection.options,
9991 );
9992
9993 let failure: AtomCommandResult<unknown, unknown> | null = null;
9994
9995 if (failure === null && isServerThread) {
9996 const settingsResult = await persistThreadSettingsForNextTurn({
9997 threadId: threadIdForSend,
9998 createdAt: messageCreatedAt,
9999 ...(localCheckoutBranchMismatch
10000 ? { branch: localCheckoutBranchMismatch.currentBranch }
10001 : {}),
10002 runtimeMode,
10003 interactionMode: sendInteractionMode,
10004 });
10005 if (settingsResult._tag === "Failure") {
10006 failure = settingsResult;
10007 }
10008 }
10009
10010 const turnAttachmentsResult = await settlePromise(async () => {
10011 const turnAttachments = await turnAttachmentsPromise;
10012 const liveFileBlockReason = readLiveAttachmentCapabilities().fileBlockReason;
10013 if (liveFileBlockReason !== null) {
10014 throw new Error(liveFileBlockReason);
10015 }
10016 return turnAttachments;
10017 });
10018 if (failure === null && turnAttachmentsResult._tag === "Failure") {
10019 failure = turnAttachmentsResult;
10020 }
10021
10022 if (failure === null && compactBeforeSend) {
10023 const compactResult = await startThreadTurn({
10024 environmentId,
10025 input: {
10026 threadId: threadIdForSend,
10027 message: { messageId: newMessageId(), role: "user", text: "/compact", attachments: [] },
10028 modelSelection: ctxSelectedModelSelection,
10029 runtimeMode,
10030 interactionMode: sendInteractionMode,
10031 },
10032 });
10033 if (compactResult._tag === "Failure") {
10034 failure = compactResult;
10035 }
10036 }
10037
10038 let backgroundDraftOpened = false;
10039 let turnStartSucceeded = false;
10040 if (failure === null && turnAttachmentsResult._tag === "Success") {
10041 const bootstrap =
10042 isLocalDraftThread || baseBranchForWorktree
10043 ? {
10044 ...(isLocalDraftThread
10045 ? {
10046 createThread: {
10047 projectId: activeProject.id,
10048 title,
10049 modelSelection: threadCreateModelSelection,
10050 runtimeMode,
10051 interactionMode: sendInteractionMode,
10052 branch: activeThreadBranch,
10053 worktreePath: activeThread.worktreePath,
10054 createdAt: activeThread.createdAt,
10055 },
10056 }
10057 : {}),
10058 ...(baseBranchForWorktree
10059 ? {
10060 prepareWorktree: {
10061 projectCwd: activeProject.workspaceRoot,
10062 baseBranch: baseBranchForWorktree,
10063 ...(startFromOrigin ? { startFromOrigin: true } : {}),
10064 },
10065 runSetupScript: true,
10066 }
10067 : {}),
10068 }
10069 : undefined;
10070 const backgroundThreadRef =
10071 submissionIntent === "background" && isLocalDraftThread
10072 ? scopeThreadRef(environmentId, threadIdForSend)
10073 : null;
10074 if (backgroundThreadRef) beginBackgroundDraftSubmissionByRef(backgroundThreadRef);
10075 const startPromise = startThreadTurn({
10076 environmentId,
10077 input: {
10078 threadId: threadIdForSend,
10079 message: {
10080 messageId: messageIdForSend,
10081 role: "user",
10082 text: outgoingMessageText,
10083 attachments: turnAttachmentsResult.value,
10084 ...(() => {
10085 const context = buildOutgoingMessageContext(
10086 turnAttachmentsResult.value.map((attachment, index) =>
10087 "id" in attachment && attachment.id !== undefined
10088 ? attachment.id
10089 : composerAttachmentsSnapshot[index]!.id,
10090 ),
10091 );
10092 if (context === undefined) return {};
10093 // Read the capability at dispatch time: the upload and persistence
10094 // awaits above can span a server reconnect that changes it. Servers
10095 // from before inline context drop the records and forward the links
10096 // as literal text, so their turns carry the payload the legacy way.
10097 const supportsInlineMessageContext =
10098 appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment
10099 .capabilities.inlineMessageContext === true;
10100 if (!supportsInlineMessageContext) {
10101 return {
10102 text: serializeLegacyContextMessage({
10103 text: outgoingMessageText,
10104 records: context.records,
10105 }),
10106 };
10107 }
10108 return { context };
10109 })(),
10110 },
10111 modelSelection: ctxSelectedModelSelection,
10112 titleSeed: title,
10113 runtimeMode,
10114 interactionMode: sendInteractionMode,
10115 dispatchMode: turnDispatchMode,
10116 ...(bootstrap ? { bootstrap } : {}),
10117 createdAt: messageCreatedAt,
10118 },
10119 });
10120 if (backgroundThreadRef) {
10121 markPromotedDraftThreadByRef(backgroundThreadRef);
10122 try {
10123 backgroundDraftOpened = Boolean(
10124 await handleNewThread(
10125 scopeProjectRef(activeProject.environmentId, activeProject.id),
10126 resolveBackgroundDraftWorkspaceOptions({
10127 envMode: sendEnvMode,
10128 branch: activeThreadBranch,
10129 startFromOrigin,
10130 }),
10131 ),
10132 );
10133 } catch (error) {
10134 clearBackgroundDraftSubmissionByRef(backgroundThreadRef);
10135 toastManager.add(
10136 stackedThreadToast({
10137 type: "warning",
10138 title: "Could not open a fresh composer",
10139 description: error instanceof Error ? error.message : undefined,
10140 }),
10141 );
10142 }
10143 }
10144 const startResult = await startPromise;
10145 if (startResult._tag === "Failure") {
10146 failure = startResult;
10147 } else {
10148 turnStartSucceeded = true;
10149 setKeepFullHistory(routeThreadKey, false);
10150 // The turn is under way and will spend quota, so that thread's limits
10151 // snapshot is stale. Uploads may have outlasted a navigation, so only
10152 // the sending thread's panel clears.
10153 clearUsageLimitsFor(routeThreadKey);
10154 if (turnUsesAttachmentUploads) {
10155 releaseDraftAttachments(composerAttachmentsSnapshot);
10156 }
10157 acknowledgeActiveThreadWoke();
10158 if (backgroundThreadRef) {
10159 if (backgroundDraftOpened || currentRouteThreadKeyRef.current !== routeThreadKey) {
10160 finalizePromotedDraftThreadByRef(backgroundThreadRef);
10161 } else {
10162 clearBackgroundDraftSubmissionByRef(backgroundThreadRef);
10163 }
10164 if (backgroundDraftOpened) {
10165 toastManager.add(
10166 stackedThreadToast({
10167 type: "success",
10168 title: "Started in background",
10169 timeout: 5_000,
10170 actionProps: {
10171 children: "Open",
10172 onClick: () => {
10173 void navigate({
10174 to: "/$environmentId/$threadId",
10175 params: buildThreadRouteParams(backgroundThreadRef),
10176 });
10177 },
10178 },
10179 }),
10180 );
10181 }
10182 } else if (
10183 submissionIntent === "background" &&
10184 currentRouteThreadKeyRef.current === routeThreadKey
10185 ) {
10186 // An existing thread keeps running; open a fresh composer like a draft does.
10187 handleNewThreadInActiveProject();
10188 }
10189 }
10190 }
10191
10192 if (failure !== null) {
10193 if (submissionIntent === "background" && draftId && draftThread) {
10194 restoreFailedBackgroundDraftThread(
10195 draftId,
10196 draftThread,
10197 wasBootstrapThreadDeleted(squashAtomCommandFailure(failure))
10198 ? newThreadId()
10199 : threadIdForSend,
10200 );
10201 clearBackgroundDraftSubmissionByRef(scopeThreadRef(environmentId, threadIdForSend));
10202 }
10203 if (
10204 backgroundDraftOpened
10205 ? !composerDraftHasUserContent(
10206 useComposerDraftStore.getState().getComposerDraft(composerDraftTarget),
10207 )
10208 : promptRef.current.length === 0 &&
10209 composerImagesRef.current.length === 0 &&
10210 composerFilesRef.current.length === 0 &&
10211 composerTerminalContextsRef.current.length === 0 &&
10212 (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)
10213 ?.previewAnnotations.length ?? 0) === 0 &&
10214 (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments
10215 .length ?? 0) === 0 &&
10216 (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.threadContexts
10217 .length ?? 0) === 0
10218 ) {
10219 setOptimisticUserMessages((existing) => {
10220 const removed = existing.filter((message) => message.id === messageIdForSend);
10221 for (const message of removed) {
10222 revokeUserMessagePreviewUrls(message);
10223 }
10224 const next = existing.filter((message) => message.id !== messageIdForSend);
10225 return next.length === existing.length ? existing : next;
10226 });
10227 promptRef.current = messageTextForSend;
10228 const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry);
10229 composerImagesRef.current = retryComposerImages;
10230 composerFilesRef.current = composerFilesSnapshot;
10231 composerTerminalContextsRef.current = composerTerminalContextsSnapshot;
10232 setComposerDraftPrompt(composerDraftTarget, messageTextForSend);
10233 addComposerDraftImages(composerDraftTarget, retryComposerImages);
10234 addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot);
10235 setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot);
10236 setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot);
10237 setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot);
10238 setComposerDraftThreadContexts(composerDraftTarget, composerThreadContextsSnapshot);
10239 composerRef.current?.resetCursorState({
10240 cursor: collapseExpandedComposerCursor(messageTextForSend, messageTextForSend.length),
10241 prompt: messageTextForSend,
10242 detectTrigger: true,
10243 });
10244 }
10245 if (!isAtomCommandInterrupted(failure)) {
10246 const error = squashAtomCommandFailure(failure);
10247 setThreadError(
10248 threadIdForSend,
10249 error instanceof Error ? error.message : "Failed to send message.",
10250 );
10251 if (backgroundDraftOpened && draftId) {
10252 toastManager.add(
10253 stackedThreadToast({
10254 type: "error",
10255 title: "Background task failed",
10256 description: error instanceof Error ? error.message : "Failed to send message.",
10257 actionProps: {
10258 children: "Open draft",
10259 onClick: () => {
10260 void navigate({ to: "/draft/$draftId", params: { draftId } });
10261 },
10262 },
10263 }),
10264 );
10265 }
10266 }
10267 }
10268 sendInFlightRef.current = false;
10269 setSetupQueueSendThreadKey((current) => (current === activeThreadKey ? null : current));
10270 if (!turnStartSucceeded) {
10271 setDockedDraftHeroThreadKey((currentThreadKey) =>
10272 currentThreadKey === activeThreadKey ? null : currentThreadKey,
10273 );
10274 if (!sendQueuesBehindSetup) resetLocalDispatch();
10275 }
10276 };
10277
10278 const onRespondToApproval = useCallback(
10279 async (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => {
10280 if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
10281 return;
10282 if (
10283 pendingApprovals.find((approval) => approval.requestId === requestId)
10284 ?.responseCapability !== "live"
10285 )
10286 return;
10287
10288 setRespondingRequestIds((existing) =>
10289 existing.includes(requestId) ? existing : [...existing, requestId],
10290 );
10291 const result = await respondToThreadApproval({
10292 environmentId,
10293 input: {
10294 threadId: activeThreadId,
10295 requestId,
10296 decision,
10297 },
10298 });
10299 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
10300 const error = squashAtomCommandFailure(result);
10301 setThreadError(
10302 activeThreadId,
10303 error instanceof Error ? error.message : "Failed to submit approval decision.",
10304 );
10305 }
10306 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
10307 return result;
10308 },
10309 [activeThreadId, environmentId, pendingApprovals, respondToThreadApproval, setThreadError],
10310 );
10311
10312 const onRespondToUserInput = useCallback(
10313 async (requestId: RuntimeRequestId, answers: Record<string, unknown>) => {
10314 if (!activeThreadId || !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope))
10315 return;
10316 const pendingInput = pendingUserInputs.find((input) => input.requestId === requestId);
10317 if (!pendingInput || pendingInput.responseCapability === "not_resumable") return;
10318 const responseKey = JSON.stringify([environmentId, activeThreadId, requestId]);
10319 if (userInputResponsesInFlight.current.has(responseKey)) return;
10320 const attachmentsByQuestionId = new Map<
10321 string,
10322 import("@t3tools/contracts").UserInputAttachments[string]
10323 >();
10324 const providerAnswers = { ...answers };
10325 for (const question of pendingInput.questions) {
10326 const target = questionAttachmentDraftId(
10327 environmentId,
10328 activeThreadId,
10329 requestId,
10330 question.id,
10331 );
10332 if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return;
10333 const draft = useComposerDraftStore.getState().getComposerDraft(target);
10334 providerAnswers[question.id] = projectPendingUserInputAnswer(
10335 answers[question.id],
10336 draft?.threadContexts ?? [],
10337 );
10338 const attachments = draft ? [...draft.images, ...draft.files] : [];
10339 if (attachments.length === 0) continue;
10340 const uploaded = getUploadedAttachments({ environmentId, images: attachments });
10341 if (!uploaded) {
10342 setThreadError(
10343 activeThreadId,
10344 "Wait for attachments to finish uploading, or remove failed uploads.",
10345 );
10346 return;
10347 }
10348 attachmentsByQuestionId.set(
10349 question.id,
10350 uploaded as import("@t3tools/contracts").UserInputAttachments[string],
10351 );
10352 }
10353 userInputResponsesInFlight.current.add(responseKey);
10354
10355 setRespondingUserInputRequestIds((existing) =>
10356 existing.includes(requestId) ? existing : [...existing, requestId],
10357 );
10358 const result = await respondToThreadUserInput({
10359 environmentId,
10360 input: {
10361 threadId: activeThreadId,
10362 requestId,
10363 answers: providerAnswers,
10364 ...(attachmentsByQuestionId.size > 0
10365 ? { attachmentsByQuestionId: Object.fromEntries(attachmentsByQuestionId) }
10366 : {}),
10367 },
10368 });
10369 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
10370 const error = squashAtomCommandFailure(result);
10371 setThreadError(
10372 activeThreadId,
10373 error instanceof Error ? error.message : "Failed to submit user input.",
10374 );
10375 }
10376 userInputResponsesInFlight.current.delete(responseKey);
10377 setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
10378 return result;
10379 },
10380 [activeThreadId, environmentId, pendingUserInputs, respondToThreadUserInput, setThreadError],
10381 );
10382
10383 // Closes an async question without messaging the agent. The server records
10384 // the dismissal so every client releases the composer.
10385 const onDismissUserInput = useCallback(
10386 async (requestId: RuntimeRequestId) => {
10387 if (!activeThreadId) return;
10388
10389 setRespondingUserInputRequestIds((existing) =>
10390 existing.includes(requestId) ? existing : [...existing, requestId],
10391 );
10392 const result = await dismissThreadUserInput({
10393 environmentId,
10394 input: { threadId: activeThreadId, requestId },
10395 });
10396 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
10397 const error = squashAtomCommandFailure(result);
10398 setThreadError(
10399 activeThreadId,
10400 error instanceof Error ? error.message : "Failed to dismiss the question.",
10401 );
10402 }
10403 setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId));
10404 return result;
10405 },
10406 [activeThreadId, dismissThreadUserInput, environmentId, setThreadError],
10407 );
10408
10409 const setActivePendingUserInputQuestionIndex = useCallback(
10410 (nextQuestionIndex: number) => {
10411 if (!activePendingUserInput) {
10412 return;
10413 }
10414 setPendingUserInputQuestionIndexByRequestId((existing) => ({
10415 ...existing,
10416 [activePendingRequestKey]: nextQuestionIndex,
10417 }));
10418 },
10419 [activePendingUserInput, activePendingRequestKey],
10420 );
10421
10422 const onSelectActivePendingUserInputOption = useCallback(
10423 (questionId: string, optionValue: string) => {
10424 if (!activePendingUserInput) {
10425 return;
10426 }
10427 // The option replaces the custom answer. Anything typed there is the
10428 // user's text, so it goes back to the thread draft instead of vanishing.
10429 const displacedAnswer =
10430 pendingUserInputAnswersByRequestId[activePendingRequestKey]?.[questionId]?.customAnswer;
10431 const currentPrompt =
10432 useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? "";
10433 const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer);
10434 if (nextPrompt !== currentPrompt) {
10435 setComposerDraftPrompt(composerDraftTarget, nextPrompt);
10436 if (activeThreadId) {
10437 const store = useComposerDraftStore.getState();
10438 const questionTarget = questionAttachmentDraftId(
10439 environmentId,
10440 activeThreadId,
10441 activePendingUserInput.requestId,
10442 questionId,
10443 );
10444 store.addThreadContexts(
10445 composerDraftTarget,
10446 store.getComposerDraft(questionTarget)?.threadContexts ?? [],
10447 { appendReference: false },
10448 );
10449 }
10450 }
10451 setPendingUserInputAnswersByRequestId((existing) => {
10452 const question =
10453 (activePendingProgress?.activeQuestion?.id === questionId
10454 ? activePendingProgress.activeQuestion
10455 : undefined) ??
10456 activePendingUserInput.questions.find((entry) => entry.id === questionId);
10457 if (!question) {
10458 return existing;
10459 }
10460
10461 return {
10462 ...existing,
10463 [activePendingRequestKey]: {
10464 ...existing[activePendingRequestKey],
10465 [questionId]: togglePendingUserInputOptionSelection(
10466 question,
10467 existing[activePendingRequestKey]?.[questionId],
10468 optionValue,
10469 ),
10470 },
10471 };
10472 });
10473 promptRef.current = "";
10474 composerRef.current?.resetCursorState({ cursor: 0 });
10475 },
10476 [
10477 activePendingProgress?.activeQuestion,
10478 activePendingUserInput,
10479 activePendingRequestKey,
10480 activeThreadId,
10481 composerDraftTarget,
10482 composerRef,
10483 environmentId,
10484 pendingUserInputAnswersByRequestId,
10485 setComposerDraftPrompt,
10486 ],
10487 );
10488
10489 const onChangeActivePendingUserInputCustomAnswer = useCallback(
10490 (questionId: string, value: string) => {
10491 if (!activePendingUserInput) {
10492 return;
10493 }
10494 const question = activePendingUserInput.questions.find((entry) => entry.id === questionId);
10495 if (!question || question.allowCustomAnswer === false) {
10496 return;
10497 }
10498 promptRef.current = value;
10499 setPendingUserInputAnswersByRequestId((existing) => ({
10500 ...existing,
10501 [activePendingRequestKey]: {
10502 ...existing[activePendingRequestKey],
10503 [questionId]: setPendingUserInputCustomAnswer(
10504 existing[activePendingRequestKey]?.[questionId],
10505 value,
10506 question,
10507 ),
10508 },
10509 }));
10510 // The editor owns this selection. Resetting it before the answer renders
10511 // can re-enter Lexical with offsets from the previous skill token.
10512 },
10513 [activePendingUserInput, activePendingRequestKey],
10514 );
10515
10516 const onAdvanceActivePendingUserInput = useCallback(() => {
10517 if (
10518 !activePendingUserInput ||
10519 activePendingUserInput.responseCapability === "not_resumable" ||
10520 !activePendingProgress
10521 ) {
10522 return;
10523 }
10524 if (activePendingProgress.isLastQuestion) {
10525 if (activePendingResolvedAnswers) {
10526 void onRespondToUserInput(activePendingUserInput.requestId, activePendingResolvedAnswers);
10527 }
10528 return;
10529 }
10530 setActivePendingUserInputQuestionIndex(activePendingProgress.questionIndex + 1);
10531 }, [
10532 activePendingProgress,
10533 activePendingResolvedAnswers,
10534 activePendingUserInput,
10535 activePendingIsResponding,
10536 onRespondToUserInput,
10537 setActivePendingUserInputQuestionIndex,
10538 ]);
10539
10540 const onPreviousActivePendingUserInputQuestion = useCallback(() => {
10541 if (!activePendingProgress) {
10542 return;
10543 }
10544 setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0));
10545 }, [activePendingProgress, setActivePendingUserInputQuestionIndex]);
10546
10547 async function onSubmitPlanFollowUp({
10548 text,
10549 context,
10550 interactionMode: nextInteractionMode,
10551 }: {
10552 text: string;
10553 context?: ReturnType<typeof buildMessageContext>;
10554 interactionMode: "default" | "plan";
10555 }) {
10556 if (
10557 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
10558 !activeThread ||
10559 !isServerThread ||
10560 isSendBusy ||
10561 isConnecting ||
10562 sendInFlightRef.current
10563 ) {
10564 return false;
10565 }
10566
10567 const trimmed = text.trim();
10568 if (!trimmed) {
10569 return false;
10570 }
10571
10572 const sendCtx = composerRef.current?.getSendContext();
10573 if (!sendCtx?.providerAvailable || !sendCtx.interactionModeEnabled) {
10574 return false;
10575 }
10576 const {
10577 selectedProvider: ctxSelectedProvider,
10578 selectedModel: ctxSelectedModel,
10579 selectedProviderModels: ctxSelectedProviderModels,
10580 selectedPromptEffort: ctxSelectedPromptEffort,
10581 selectedModelSelection: ctxSelectedModelSelection,
10582 } = sendCtx;
10583
10584 const threadIdForSend = activeThread.id;
10585 const messageIdForSend = newMessageId();
10586 const messageCreatedAt = new Date().toISOString();
10587 const outgoingMessageText = formatOutgoingPrompt({
10588 provider: ctxSelectedProvider,
10589 model: ctxSelectedModel,
10590 models: ctxSelectedProviderModels,
10591 effort: ctxSelectedPromptEffort,
10592 text: trimmed,
10593 });
10594
10595 sendInFlightRef.current = true;
10596 beginLocalDispatch({ preparingWorktree: false });
10597 setThreadError(threadIdForSend, null);
10598
10599 // Position this sent row once LegendList has measured the anchored tail.
10600 isAtEndRef.current = true;
10601 timelineScrollModeRef.current = "anchoring-new-turn";
10602 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
10603 setTimelineLiveFollowEnabled(true);
10604 pendingTimelineAnchorRef.current = messageIdForSend;
10605 activeTimelineAnchorIndexRef.current = null;
10606 showScrollDebouncer.current.cancel();
10607 setShowScrollToBottom(false);
10608 setTimelineAnchor({
10609 threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)),
10610 messageId: messageIdForSend,
10611 });
10612
10613 setOptimisticUserMessages((existing) => [
10614 ...existing,
10615 {
10616 id: messageIdForSend,
10617 role: "user",
10618 text: outgoingMessageText,
10619 ...(context ? { context } : {}),
10620 runId: null,
10621 createdAt: messageCreatedAt,
10622 updatedAt: messageCreatedAt,
10623 streaming: false,
10624 },
10625 ]);
10626
10627 const settingsResult = await persistThreadSettingsForNextTurn({
10628 threadId: threadIdForSend,
10629 createdAt: messageCreatedAt,
10630 ...(localCheckoutBranchMismatch ? { branch: localCheckoutBranchMismatch.currentBranch } : {}),
10631 runtimeMode,
10632 interactionMode: nextInteractionMode,
10633 });
10634 let failure: AtomCommandResult<unknown, unknown> | null =
10635 settingsResult._tag === "Failure" ? settingsResult : null;
10636
10637 if (failure === null) {
10638 // Keep the mode toggle and plan-follow-up banner in sync immediately
10639 // while the same-thread implementation turn is starting.
10640 setComposerDraftInteractionMode(
10641 scopeThreadRef(activeThread.environmentId, threadIdForSend),
10642 nextInteractionMode,
10643 );
10644
10645 const startResult = await startThreadTurn({
10646 environmentId,
10647 input: {
10648 threadId: threadIdForSend,
10649 message: {
10650 messageId: messageIdForSend,
10651 role: "user",
10652 text: outgoingMessageText,
10653 ...(context ? { context } : {}),
10654 attachments: [],
10655 },
10656 modelSelection: ctxSelectedModelSelection,
10657 titleSeed: activeThread.title,
10658 runtimeMode,
10659 interactionMode: nextInteractionMode,
10660 ...(nextInteractionMode === "default" && activeProposedPlan
10661 ? {
10662 sourceProposedPlan: {
10663 threadId: activeThread.id,
10664 planId: activeProposedPlan.id,
10665 },
10666 }
10667 : {}),
10668 createdAt: messageCreatedAt,
10669 },
10670 });
10671 failure = startResult._tag === "Failure" ? startResult : null;
10672 }
10673
10674 if (failure === null) {
10675 sendInFlightRef.current = false;
10676 resetLocalDispatch();
10677 return true;
10678 }
10679
10680 setOptimisticUserMessages((existing) =>
10681 existing.filter((message) => message.id !== messageIdForSend),
10682 );
10683 if (!isAtomCommandInterrupted(failure)) {
10684 const error = squashAtomCommandFailure(failure);
10685 setThreadError(
10686 threadIdForSend,
10687 error instanceof Error ? error.message : "Failed to send plan follow-up.",
10688 );
10689 }
10690 sendInFlightRef.current = false;
10691 resetLocalDispatch();
10692 return false;
10693 }
10694
10695 const onImplementPlanInNewThread = useCallback(async () => {
10696 if (
10697 !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope) ||
10698 !activeThread ||
10699 !activeProject ||
10700 !activeProposedPlan ||
10701 !isServerThread ||
10702 isSendBusy ||
10703 isConnecting ||
10704 activeEnvironmentUnavailable ||
10705 sendInFlightRef.current
10706 ) {
10707 return;
10708 }
10709
10710 const sendCtx = composerRef.current?.getSendContext();
10711 if (!sendCtx?.providerAvailable || !sendCtx.interactionModeEnabled) {
10712 return;
10713 }
10714 const {
10715 selectedProvider: ctxSelectedProvider,
10716 selectedModel: ctxSelectedModel,
10717 selectedProviderModels: ctxSelectedProviderModels,
10718 selectedPromptEffort: ctxSelectedPromptEffort,
10719 selectedModelSelection: ctxSelectedModelSelection,
10720 } = sendCtx;
10721
10722 const createdAt = new Date().toISOString();
10723 const nextThreadId = newThreadId();
10724 const planMarkdown = activeProposedPlan.planMarkdown;
10725 const implementationPrompt = buildPlanImplementationPrompt(planMarkdown);
10726 const outgoingImplementationPrompt = formatOutgoingPrompt({
10727 provider: ctxSelectedProvider,
10728 model: ctxSelectedModel,
10729 models: ctxSelectedProviderModels,
10730 effort: ctxSelectedPromptEffort,
10731 text: implementationPrompt,
10732 });
10733 const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown));
10734 const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection;
10735
10736 sendInFlightRef.current = true;
10737 beginLocalDispatch({ preparingWorktree: false });
10738 const finish = () => {
10739 sendInFlightRef.current = false;
10740 resetLocalDispatch();
10741 };
10742
10743 const createResult = await createThread({
10744 environmentId,
10745 input: {
10746 threadId: nextThreadId,
10747 projectId: activeProject.id,
10748 title: nextThreadTitle,
10749 modelSelection: nextThreadModelSelection,
10750 runtimeMode: defaultRuntimeMode,
10751 interactionMode: "default",
10752 branch: activeThreadBranch,
10753 worktreePath: activeThread.worktreePath,
10754 createdAt,
10755 },
10756 });
10757 let failure: AtomCommandResult<unknown, unknown> | null =
10758 createResult._tag === "Failure" ? createResult : null;
10759
10760 if (failure === null) {
10761 const startResult = await startThreadTurn({
10762 environmentId,
10763 input: {
10764 threadId: nextThreadId,
10765 message: {
10766 messageId: newMessageId(),
10767 role: "user",
10768 text: outgoingImplementationPrompt,
10769 attachments: [],
10770 },
10771 modelSelection: ctxSelectedModelSelection,
10772 titleSeed: nextThreadTitle,
10773 runtimeMode: defaultRuntimeMode,
10774 interactionMode: "default",
10775 sourceProposedPlan: {
10776 threadId: activeThread.id,
10777 planId: activeProposedPlan.id,
10778 },
10779 createdAt,
10780 },
10781 });
10782 failure = startResult._tag === "Failure" ? startResult : null;
10783 }
10784
10785 if (failure === null) {
10786 const startedResult = await settlePromise(() =>
10787 waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId)),
10788 );
10789 failure = startedResult._tag === "Failure" ? startedResult : null;
10790 }
10791
10792 if (failure === null) {
10793 const navigateResult = await settlePromise(() =>
10794 navigate({
10795 to: "/$environmentId/$threadId",
10796 params: {
10797 environmentId: activeThread.environmentId,
10798 threadId: nextThreadId,
10799 },
10800 }),
10801 );
10802 failure = navigateResult._tag === "Failure" ? navigateResult : null;
10803 }
10804
10805 if (failure !== null) {
10806 const cleanupResult = await deleteThread({
10807 environmentId,
10808 input: {
10809 threadId: nextThreadId,
10810 },
10811 });
10812 if (cleanupResult._tag === "Failure" && !isAtomCommandInterrupted(cleanupResult)) {
10813 console.warn(
10814 "Failed to clean up implementation thread after start failure.",
10815 squashAtomCommandFailure(cleanupResult),
10816 );
10817 }
10818 if (!isAtomCommandInterrupted(failure)) {
10819 const error = squashAtomCommandFailure(failure);
10820 toastManager.add(
10821 stackedThreadToast({
10822 type: "error",
10823 title: "Could not start implementation thread",
10824 description:
10825 error instanceof Error
10826 ? error.message
10827 : "An error occurred while creating the new thread.",
10828 }),
10829 );
10830 }
10831 }
10832 finish();
10833 }, [
10834 activeProject,
10835 activeProposedPlan,
10836 activeThreadBranch,
10837 activeThread,
10838 beginLocalDispatch,
10839 activeEnvironmentUnavailable,
10840 createThread,
10841 deleteThread,
10842 isConnecting,
10843 isSendBusy,
10844 isServerThread,
10845 navigate,
10846 resetLocalDispatch,
10847 defaultRuntimeMode,
10848 startThreadTurn,
10849 environmentId,
10850 composerRef,
10851 ]);
10852
10853 const getModelDisabledReason = useCallback(
10854 (instanceId: ProviderInstanceId, model: string): string | null => {
10855 if (!activeThread) {
10856 return null;
10857 }
10858 const reason = getStartedThreadModelChangeBlockReason({
10859 providers: providerStatuses,
10860 hasStartedSession: activeRuntime !== null,
10861 supportsProviderSwitchingViaHandoff,
10862 currentModelSelection: activeThread.modelSelection,
10863 currentProviderInstanceId: activeRuntime?.providerInstanceId ?? null,
10864 nextModelSelection: { instanceId, model },
10865 });
10866 return reason ? `${reason.description} Start a new thread to use this model.` : null;
10867 },
10868 [activeRuntime, activeThread, providerStatuses, supportsProviderSwitchingViaHandoff],
10869 );
10870
10871 const onProviderModelSelect = useCallback(
10872 (instanceId: ProviderInstanceId, model: string, options?: { focusComposer?: boolean }) => {
10873 if (!activeThread) return;
10874 // Look up the configured instance so model normalization and custom
10875 // model lookup stay scoped to that exact instance. Unknown instance ids
10876 // are rejected by returning early; the server remains authoritative too.
10877 const entry = providerStatuses.find((snapshot) => snapshot.instanceId === instanceId);
10878 const resolvedDriverKind = entry?.driver ?? null;
10879 if (
10880 !supportsProviderSwitchingViaHandoff &&
10881 lockedProvider !== null &&
10882 resolvedDriverKind !== null &&
10883 resolvedDriverKind !== lockedProvider
10884 ) {
10885 if (options?.focusComposer !== false) scheduleComposerFocus();
10886 return;
10887 }
10888 if (
10889 !supportsProviderSwitchingViaHandoff &&
10890 lockedProvider !== null &&
10891 activeRuntime?.providerInstanceId
10892 ) {
10893 const currentEntry = providerStatuses.find(
10894 (snapshot) => snapshot.instanceId === activeRuntime.providerInstanceId,
10895 );
10896 if (
10897 currentEntry?.continuation?.groupKey &&
10898 entry?.continuation?.groupKey &&
10899 currentEntry.continuation.groupKey !== entry.continuation.groupKey
10900 ) {
10901 if (options?.focusComposer !== false) scheduleComposerFocus();
10902 return;
10903 }
10904 }
10905 const resolvedModel = resolveAppModelSelectionForInstance(
10906 instanceId,
10907 settings,
10908 providerStatuses,
10909 model,
10910 );
10911 if (!resolvedModel) {
10912 if (options?.focusComposer !== false) scheduleComposerFocus();
10913 return;
10914 }
10915 // Restore this model's own remembered options; without any, start it
10916 // from its default rather than carrying the previous model's over.
10917 const rememberedOptions =
10918 useComposerDraftStore.getState().stickyOptionsByModelByProvider[instanceId]?.[
10919 resolvedModel
10920 ];
10921 const nextModelSelection: ModelSelection =
10922 rememberedOptions !== undefined && rememberedOptions.length > 0
10923 ? { instanceId, model: resolvedModel, options: [...rememberedOptions] }
10924 : { instanceId, model: resolvedModel };
10925 const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({
10926 providers: providerStatuses,
10927 hasStartedSession: activeRuntime !== null,
10928 supportsProviderSwitchingViaHandoff,
10929 currentModelSelection: activeThread.modelSelection,
10930 currentProviderInstanceId: activeRuntime?.providerInstanceId ?? null,
10931 nextModelSelection,
10932 });
10933 if (modelChangeBlockReason) {
10934 toastManager.add({
10935 type: "warning",
10936 title: modelChangeBlockReason.title,
10937 description: modelChangeBlockReason.description,
10938 });
10939 if (options?.focusComposer !== false) scheduleComposerFocus();
10940 return;
10941 }
10942 setComposerDraftModelSelection(
10943 scopeThreadRef(activeThread.environmentId, activeThread.id),
10944 nextModelSelection,
10945 // A complete snapshot: an absent options field means "start from the
10946 // model default", not "keep the previous model's options".
10947 { explicit: true, replaceOptions: true },
10948 );
10949 setStickyComposerModelSelection(nextModelSelection);
10950 if (options?.focusComposer !== false) scheduleComposerFocus();
10951 },
10952 [
10953 activeThread,
10954 activeRuntime,
10955 lockedProvider,
10956 supportsProviderSwitchingViaHandoff,
10957 scheduleComposerFocus,
10958 setComposerDraftModelSelection,
10959 setStickyComposerModelSelection,
10960 providerStatuses,
10961 settings,
10962 ],
10963 );
10964 const onEnvModeChange = useCallback(
10965 (mode: DraftThreadEnvMode) => {
10966 if (multipleModelSelections !== null) return;
10967 if (canOverrideServerThreadEnvMode) {
10968 setPendingServerThreadEnvMode(mode);
10969 scheduleComposerFocus();
10970 return;
10971 }
10972 if (isLocalDraftThread) {
10973 setDraftThreadContext(composerDraftTarget, {
10974 envMode: mode,
10975 startFromOrigin: resolveNewDraftStartFromOrigin({
10976 envMode: mode,
10977 newWorktreesStartFromOrigin: activeProjectSettings.settings.newWorktreesStartFromOrigin,
10978 }),
10979 ...(mode === "worktree" && draftThread?.worktreePath ? { worktreePath: null } : {}),
10980 });
10981 }
10982 scheduleComposerFocus();
10983 },
10984 [
10985 canOverrideServerThreadEnvMode,
10986 composerDraftTarget,
10987 draftThread?.worktreePath,
10988 isLocalDraftThread,
10989 multipleModelSelections,
10990 activeProjectSettings.settings.newWorktreesStartFromOrigin,
10991 setPendingServerThreadEnvMode,
10992 scheduleComposerFocus,
10993 setDraftThreadContext,
10994 ],
10995 );
10996
10997 // "Work locally" on the setup card: cancel the bootstrap and remember the
10998 // draft. The cancelled dispatch deletes the half-made thread and puts the
10999 // message back in the composer; the effect below then flips the draft to
11000 // local mode and resends. The draft is a server thread for the whole
11001 // setup (the bootstrap created it), so this keys off the route, not
11002 // `isLocalDraftThread`.
11003 const onWorktreeSetupWorkLocally = useCallback(() => {
11004 if (!worktreeSetup || worktreeSetup.phase !== "running" || !draftId) {
11005 return;
11006 }
11007 const target = {
11008 environmentId: setupTarget.environmentId,
11009 input: { threadId: worktreeSetup.threadId },
11010 };
11011 void (async () => {
11012 const result = await cancelWorktreeSetup(target);
11013 if (result._tag !== "Success" || !result.value.cancelled) return;
11014 setWorkLocallyResendDraftId(draftId);
11015 })();
11016 }, [cancelWorktreeSetup, draftId, setupTarget.environmentId, worktreeSetup]);
11017 const onSendRef = useRef(onSend);
11018 onSendRef.current = onSend;
11019 const onResumeRef = useRef(onResume);
11020 onResumeRef.current = onResume;
11021 const onCompactContextRef = useRef(onCompactContext);
11022 onCompactContextRef.current = onCompactContext;
11023 const onComposerSend = useCallback(
11024 (...args: Parameters<typeof onSend>) => onSendRef.current(...args),
11025 [],
11026 );
11027 const onComposerResume = useCallback(() => onResumeRef.current(), []);
11028 const onComposerCompactContext = useCallback(() => onCompactContextRef.current(), []);
11029 const queuedRunsControl = useMemo(
11030 () =>
11031 isServerThread && activeThread ? (
11032 <QueuedRunsControl
11033 ref={queuedRunsControlRef}
11034 steerShortcutLabel={shortcutLabelForCommand(keybindings, "thread.steerQueuedMessage", {
11035 context: { terminalFocus: false },
11036 })}
11037 editShortcutLabel={shortcutLabelForCommand(keybindings, "thread.editQueuedMessage", {
11038 context: { composerFocus: true },
11039 })}
11040 environmentId={activeThread.environmentId}
11041 threadId={activeThread.id}
11042 optimisticMessages={optimisticUserMessages}
11043 editingRunId={editingQueuedRun?.runId ?? null}
11044 onEditQueuedRun={beginEditingQueuedRun}
11045 onCancelEdit={cancelEditingQueuedRun}
11046 />
11047 ) : null,
11048 [
11049 activeThread,
11050 beginEditingQueuedRun,
11051 cancelEditingQueuedRun,
11052 editingQueuedRun?.runId,
11053 isServerThread,
11054 keybindings,
11055 optimisticUserMessages,
11056 ],
11057 );
11058 // Resend once the cancelled dispatch has settled and the composer is free.
11059 // Every state that makes `onSend` bail and wait is part of the readiness
11060 // check, so the flag survives a reconnect, a reverting checkpoint, or a
11061 // feedback upload in between. What remains inside `onSend` are the checks
11062 // that need the user to change something, and those should not auto retry.
11063 const workLocallyResendReady =
11064 workLocallyResendDraftId !== null &&
11065 workLocallyResendDraftId === draftId &&
11066 isLocalDraftThread &&
11067 !isSendBusy &&
11068 !isConnecting &&
11069 !isRevertingCheckpoint &&
11070 !threadDetailLoading &&
11071 clientSettingsHydrated &&
11072 !needsLoadBalancing &&
11073 !activeEnvironmentUnavailable &&
11074 !activePendingProgress &&
11075 !feedbackUploading;
11076 useEffect(() => {
11077 if (
11078 !workLocallyResendReady ||
11079 sendInFlightRef.current ||
11080 feedbackUploadsInFlightRef.current.has(routeThreadKey)
11081 ) {
11082 return;
11083 }
11084 if (sendEnvMode !== "local") {
11085 // The draft is back; switch it to the project checkout and let the next
11086 // render resend.
11087 setDraftThreadContext(composerDraftTarget, { envMode: "local", startFromOrigin: false });
11088 return;
11089 }
11090 setWorkLocallyResendDraftId(null);
11091 void onSendRef.current();
11092 }, [
11093 composerDraftTarget,
11094 routeThreadKey,
11095 sendEnvMode,
11096 setDraftThreadContext,
11097 workLocallyResendReady,
11098 ]);
11099
11100 const onStartFromOriginChange = (nextStartFromOrigin: boolean) => {
11101 if (canOverrideServerThreadEnvMode && activeThread) {
11102 setPendingServerThreadStartFromOriginByThreadId((current) =>
11103 current[activeThread.id] === nextStartFromOrigin
11104 ? current
11105 : { ...current, [activeThread.id]: nextStartFromOrigin },
11106 );
11107 return;
11108 }
11109 if (isLocalDraftThread) {
11110 setDraftThreadContext(composerDraftTarget, {
11111 startFromOrigin: nextStartFromOrigin,
11112 });
11113 }
11114 };
11115
11116 const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => {
11117 setExpandedImage(preview);
11118 }, []);
11119 const onOpenTurnDiff = useCallback(
11120 (runId: RunId, filePath?: string) => {
11121 if (!isServerThread || !activeThreadRef) return;
11122 const diffOpen =
11123 selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, activeThreadRef) ===
11124 "diff";
11125 explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef;
11126 useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath);
11127 useRightPanelStore.getState().open(activeThreadRef, "diff");
11128 onDiffPanelOpen?.();
11129 },
11130 [activeThreadRef, isServerThread, onDiffPanelOpen],
11131 );
11132 const onTimelineRollbackCheckpoint = useCallback(
11133 (input: { readonly checkpointId: string; readonly scopeId: string }) => {
11134 if (!paintOnlyDisplayedTimeline) void onRollbackCheckpoint(input);
11135 },
11136 [onRollbackCheckpoint, paintOnlyDisplayedTimeline],
11137 );
11138 // The revert handler is read from a ref at call-time so the callback
11139 // reference is fully stable and never busts TimelineRowCtx identity.
11140 const onRevertToTurnCountRef = useRef(onRevertToTurnCount);
11141 onRevertToTurnCountRef.current = onRevertToTurnCount;
11142 const onRevertTimelineTurn = useCallback((targetTurnCount: number, messageId: MessageId) => {
11143 void onRevertToTurnCountRef.current(targetTurnCount, messageId);
11144 }, []);
11145
11146 const pendingSidebarFileDrops = useSidebarPendingFileDropStore((state) => state.pending);
11147 const consumePendingFileDrop = useSidebarPendingFileDropStore(
11148 (state) => state.consumePendingFileDrop,
11149 );
11150 useEffect(() => {
11151 if (pendingSidebarFileDrops.length === 0) return;
11152 if (
11153 typeof composerDraftTarget === "string" ||
11154 !pendingSidebarFileDrops.some((drop) =>
11155 isSameSidebarThreadRef(composerDraftTarget, drop.threadRef),
11156 )
11157 ) {
11158 return;
11159 }
11160 if (!activeThread) return;
11161 if (!composerRef.current) {
11162 const raf = window.requestAnimationFrame(() => {
11163 if (!composerRef.current || typeof composerDraftTarget === "string") return;
11164 const files = consumePendingFileDrop(composerDraftTarget);
11165 if (files !== null) composerRef.current?.addDroppedFiles(files);
11166 });
11167 return () => window.cancelAnimationFrame(raf);
11168 }
11169 const files = consumePendingFileDrop(composerDraftTarget);
11170 if (files !== null) composerRef.current.addDroppedFiles(files);
11171 }, [
11172 activeThread,
11173 composerDraftTarget,
11174 composerRef,
11175 consumePendingFileDrop,
11176 pendingSidebarFileDrops,
11177 ]);
11178
11179 // Empty state: no active thread
11180 if (!activeThread) {
11181 return <NoActiveThreadState />;
11182 }
11183
11184 const rightPanelContent = activeThreadRef ? (
11185 renderedRightPanelSurface?.kind === "preview" ? (
11186 <Suspense fallback={null}>
11187 <PreviewPanel
11188 mode="embedded"
11189 threadRef={activeThreadRef}
11190 tabId={renderedRightPanelSurface.resourceId}
11191 configuredUrls={configuredPreviewUrls}
11192 visible={rightPanelOpen}
11193 onSendAnnotation={(annotation, image) => {
11194 void onSend(undefined, "auto", "foreground", { annotation, image });
11195 }}
11196 />
11197 </Suspense>
11198 ) : renderedRightPanelSurface?.kind === "terminal" ? (
11199 <PersistentThreadTerminalPanel
11200 visible={rightPanelOpen}
11201 threadRef={activeThreadRef}
11202 surface={renderedRightPanelSurface}
11203 launchContext={activeTerminalLaunchContext ?? null}
11204 focusRequestId={terminalFocusRequestId}
11205 keybindings={keybindings}
11206 onAddTerminalContext={addTerminalContextToDraft}
11207 onSplitTerminal={splitPanelTerminal}
11208 onSplitTerminalVertical={splitPanelTerminalVertical}
11209 onNewTerminal={addTerminalSurface}
11210 onActiveTerminalChange={activatePanelTerminal}
11211 onCloseTerminal={closePanelTerminal}
11212 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
11213 splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined}
11214 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
11215 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
11216 />
11217 ) : renderedRightPanelSurface?.kind === "diff" ? (
11218 <Suspense fallback={null}>
11219 <DiffPanel
11220 key={activeThreadKey}
11221 mode="embedded"
11222 composerDraftTarget={composerDraftTarget}
11223 workspaceMutationId={workspaceMutationId}
11224 />
11225 </Suspense>
11226 ) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? (
11227 <PullRequestDetailGhost />
11228 ) : renderedRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? (
11229 <PullRequestsUnavailableState
11230 title="Pull requests unavailable"
11231 error="Update The Knife server on this environment to browse pull requests."
11232 />
11233 ) : renderedRightPanelSurface?.kind === "pull-request" ? (
11234 // No onClose: the surface tab's own X owns closing here, and a second X in the header
11235 // would be the same action twice. The thread context also drops the checkout button, so it
11236 // is only right for the thread's own pull request, whose branch is already under the
11237 // reader's feet. A link the agent wrote can open any other one here, and that one has to be
11238 // checkable out like it is anywhere else.
11239 <PullRequestDetailPanel
11240 getShortcutContext={getShortcutContext}
11241 shortcutsEnabled={
11242 rightPanelOpen && activeRightPanelSurface?.id === renderedRightPanelSurface.id
11243 }
11244 key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}
11245 environmentId={activeThread.environmentId}
11246 onMergebotReady={(tabId) =>
11247 activeThreadRef !== null &&
11248 useRightPanelStore
11249 .getState()
11250 .replacePullRequestWithBrowser(activeThreadRef, renderedRightPanelSurface.id, tabId)
11251 }
11252 onSelectPullRequest={(reference) => {
11253 if (activeThreadRef)
11254 useRightPanelStore.getState().openPullRequest(activeThreadRef, {
11255 projectId: reference.projectId,
11256 repository: reference.repository,
11257 number: reference.number,
11258 ...(reference.host ? { host: reference.host } : {}),
11259 });
11260 }}
11261 threadRef={activeThreadRef}
11262 reference={{
11263 projectId: renderedRightPanelSurface.projectId as ProjectId,
11264 ...(renderedRightPanelSurface.host ? { host: renderedRightPanelSurface.host } : {}),
11265 repository: renderedRightPanelSurface.repository,
11266 number: renderedRightPanelSurface.number,
11267 }}
11268 context={pullRequestPanelContext(
11269 {
11270 projectId: activeThread.projectId,
11271 pullRequests: visiblePullRequests,
11272 linkedPullRequest: linkedThreadPullRequest,
11273 branchPullRequest:
11274 activeThreadShell?.branchPullRequest ?? activeThread.branchPullRequest,
11275 },
11276 renderedRightPanelSurface,
11277 )}
11278 composerDraftTarget={composerDraftTarget}
11279 onBack={
11280 activeThreadRef !== null && pullRequestsSurfaceAvailable && visiblePullRequestCount > 1
11281 ? addPullRequestsSurface
11282 : undefined
11283 }
11284 />
11285 ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? (
11286 <ThreadPullRequestsPanel threadRef={activeThreadRef} />
11287 ) : renderedRightPanelSurface?.kind === "deployments" && activeThreadRef ? (
11288 <Suspense fallback={null}>
11289 <DeploymentsPanel environmentId={activeThreadRef.environmentId} />
11290 </Suspense>
11291 ) : renderedRightPanelSurface?.kind === "device" ? (
11292 <Suspense fallback={null}>
11293 <DevicePanel
11294 mode="embedded"
11295 threadRef={activeThreadRef}
11296 key={renderedRightPanelSurface.id}
11297 surface={renderedRightPanelSurface}
11298 visible={rightPanelOpen}
11299 onDismissSetup={() => {
11300 closeRightPanelSurface(renderedRightPanelSurface);
11301 useRightPanelStore.getState().show(activeThreadRef);
11302 }}
11303 />
11304 </Suspense>
11305 ) : (renderedRightPanelSurface?.kind === "files" ||
11306 renderedRightPanelSurface?.kind === "file") &&
11307 ((activeProject && activeWorkspaceRoot) ||
11308 (renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment)) ? (
11309 <Suspense fallback={null}>
11310 <FilePreviewPanel
11311 key={`${activeThread.environmentId}:${
11312 renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment
11313 ? `attachment:${renderedRightPanelSurface.attachment.id}`
11314 : activeWorkspaceRoot
11315 }`}
11316 environmentId={activeThread.environmentId}
11317 cwd={activeWorkspaceRoot ?? ""}
11318 projectName={activeProject?.title ?? ""}
11319 threadRef={activeThreadRef}
11320 composerDraftTarget={composerDraftTarget}
11321 keybindings={keybindings}
11322 availableEditors={availableEditors}
11323 relativePath={
11324 renderedRightPanelSurface.kind === "file"
11325 ? renderedRightPanelSurface.relativePath
11326 : null
11327 }
11328 {...(renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment
11329 ? { attachment: renderedRightPanelSurface.attachment }
11330 : {})}
11331 revealLine={
11332 renderedRightPanelSurface.kind === "file"
11333 ? (renderedRightPanelSurface.revealLine ?? null)
11334 : null
11335 }
11336 revealRequestId={
11337 renderedRightPanelSurface.kind === "file"
11338 ? renderedRightPanelSurface.revealRequestId
11339 : 0
11340 }
11341 onOpenFile={openFileSurface}
11342 onPendingChange={handleFilePendingChange}
11343 selectedFilePending={
11344 renderedRightPanelSurface.kind === "file" &&
11345 pendingFileSurfaceIds.has(renderedRightPanelSurface.id)
11346 }
11347 workspaceMutationId={workspaceMutationId}
11348 />
11349 </Suspense>
11350 ) : null
11351 ) : null;
11352 const threadDetailsPanelProps: ThreadDetailsPanelProps = {
11353 anchor: threadPanelPopoverAnchorRef,
11354 handle: threadPanelPopoverHandle,
11355 onPresentationChange: setThreadPanelPresentation,
11356 forceNewWorktree: multipleModelSelections !== null,
11357 environmentId: activeThread.environmentId,
11358 threadId: activeThread.id,
11359 ...(draftId ? { draftId } : {}),
11360 activeProjectName: activeProject?.title,
11361 activeProjectScripts: activeProject ? activeProjectScripts : undefined,
11362 preferredScriptId: activeProject
11363 ? (lastInvokedScriptByProjectId[activeProject.id] ?? null)
11364 : null,
11365 keybindings,
11366 availableEditors,
11367 showOpenInPicker,
11368 gitCwd,
11369 isGitRepo,
11370 envLocked,
11371 availableEnvironments: logicalProjectEnvironments,
11372 autoEnvironmentLabel,
11373 onAutoEnvironment:
11374 draftId &&
11375 !envLocked &&
11376 canAutoBalanceEnvironments &&
11377 loadBalancingSettings.loadBalancingEnabled
11378 ? onAutoEnvironment
11379 : undefined,
11380 onEnvironmentChange,
11381 onEnvModeChange,
11382 envMode,
11383 ...(canOverrideServerThreadEnvMode
11384 ? {
11385 activeThreadBranchOverride: activeThreadBranch,
11386 onActiveThreadBranchOverrideChange: setPendingServerThreadBranch,
11387 }
11388 : {}),
11389 startFromOrigin,
11390 onStartFromOriginChange,
11391 ...(canCheckoutPullRequestIntoThread
11392 ? { onCheckoutPullRequestRequest: openPullRequestDialog }
11393 : {}),
11394 onComposerFocusRequest: scheduleComposerFocus,
11395 ...(workspaceChangesAvailable ? { onOpenChanges: openChangesFromThreadPanel } : {}),
11396 onRunProjectScript: runProjectScript,
11397 onAddProjectScript: saveProjectScript,
11398 onUpdateProjectScript: updateProjectScript,
11399 onDeleteProjectScript: deleteProjectScript,
11400 };
11401 const panelToggleControlProps = {
11402 terminalAvailable: activeProject !== null,
11403 terminalOpen: terminalVisible,
11404 terminalShortcutLabel: shortcutLabelForCommand(keybindings, "terminal.toggle"),
11405 threadPanelOpen,
11406 threadPanelPresentation,
11407 threadPanelPopoverHandle,
11408 threadPanelShortcutLabel: shortcutLabelForCommand(keybindings, "threadPanel.toggle"),
11409 rightPanelAvailable: activeProject !== null,
11410 rightPanelOpen,
11411 rightPanelShortcutLabel: shortcutLabelForCommand(keybindings, "rightPanel.toggle"),
11412 onToggleTerminal: toggleTerminalVisibility,
11413 onToggleThreadPanel: toggleThreadPanel,
11414 onToggleRightPanel: toggleRightPanel,
11415 } satisfies PanelLayoutControlsProps;
11416 const panelToggleControls = (
11417 <PanelLayoutControls
11418 {...panelToggleControlProps}
11419 showThreadPanelControl={!inlineRightPanelOwnsTitleBar}
11420 />
11421 );
11422 const threadPanelHeaderControl = (
11423 <div
11424 // Inset from the chat header's edge, not the window's: --workspace-controls-right also
11425 // clears the native window buttons, which sit over the right panel, not this header.
11426 className="absolute top-[var(--workspace-controls-top)] right-[calc(env(safe-area-inset-right)+0.75rem)] z-50 flex h-[var(--workspace-topbar-height)] items-center [-webkit-app-region:no-drag]"
11427 data-workspace-titlebar-controls
11428 >
11429 <PanelLayoutControls
11430 {...panelToggleControlProps}
11431 showTerminalControl={false}
11432 showRightPanelControl={false}
11433 />
11434 </div>
11435 );
11436 const panelLayoutControls = (
11437 <div
11438 className={cn(
11439 // Keep one viewport anchor inside the header's no-drag region. The
11440 // header can shrink behind the right panel without moving the controls.
11441 "pointer-events-none fixed top-[var(--workspace-controls-top)] right-[var(--workspace-controls-right)] z-50 mr-px flex h-[var(--workspace-topbar-height)] items-center gap-1 [-webkit-app-region:no-drag]",
11442 )}
11443 data-workspace-titlebar-controls
11444 >
11445 {!shouldUsePlanSidebarSheet ? (
11446 <span
11447 aria-hidden={!rightPanelOpen}
11448 className={cn(
11449 "flex shrink-0",
11450 panelAnimationsActive &&
11451 "motion-safe:transition-opacity motion-safe:duration-(--panel-animation-duration) motion-safe:ease-out",
11452 // Closed, the control leaves the flex flow so the cluster is only as wide as the two
11453 // toggles the header reserves room for; anchored to the cluster's left edge, it fades
11454 // out where it stood rather than over the terminal toggle.
11455 rightPanelOpen
11456 ? "pointer-events-auto opacity-100"
11457 : "pointer-events-none absolute right-full mr-1 opacity-0",
11458 )}
11459 inert={!rightPanelOpen}
11460 >
11461 <RightPanelMaximizeControl
11462 maximized={rightPanelMaximized}
11463 onToggle={toggleRightPanelMaximized}
11464 />
11465 </span>
11466 ) : null}
11467 <div className="pointer-events-auto flex h-full items-center">{panelToggleControls}</div>
11468 </div>
11469 );
11470 const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({
11471 setDragActive: setIsWorkspaceFileDragActive,
11472 addFiles: (files) => composerRef.current?.addDroppedFiles(files),
11473 addFolders: (folders) => composerRef.current?.addDroppedFolders(folders),
11474 });
11475
11476 const content = (
11477 <div
11478 ref={setWorkspaceLayoutElement}
11479 className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background"
11480 >
11481 <Dialog
11482 open={
11483 deviceSetupThread !== null &&
11484 deviceSetupThread.environmentId === activeThreadRef?.environmentId &&
11485 deviceSetupThread.threadId === activeThreadRef?.threadId
11486 }
11487 onOpenChange={(open) => {
11488 if (!open) setDeviceSetupThread(null);
11489 }}
11490 >
11491 <WizardPopup>
11492 {activeThreadRef ? (
11493 <DeviceSetup
11494 environmentId={activeThreadRef.environmentId}
11495 state={deviceState}
11496 onComplete={() => {
11497 useRightPanelStore.getState().open(activeThreadRef, "device");
11498 setDeviceSetupThread(null);
11499 }}
11500 />
11501 ) : null}
11502 </WizardPopup>
11503 </Dialog>
11504 {rightPanelControlsAtRoot ? panelLayoutControls : null}
11505 <div
11506 className={cn(
11507 "flex min-h-0 min-w-0 flex-col overflow-x-hidden",
11508 rightPanelMaximized ? "w-0 flex-none" : "flex-1",
11509 )}
11510 data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"}
11511 >
11512 {/* Top bar */}
11513 <header
11514 ref={threadPanelPopoverAnchorRef}
11515 data-chat-header
11516 className={cn(
11517 "relative bg-background [[data-panel-animations=true]_&]:motion-safe:transition-[padding-left] [[data-panel-animations=true]_&]:motion-safe:duration-(--panel-animation-duration) [[data-panel-animations=true]_&]:motion-safe:ease-out",
11518 isElectron
11519 ? cn(
11520 "drag-region flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 sm:px-5",
11521 reserveTitleBarControlInset &&
11522 !inlineRightPanelOwnsTitleBar &&
11523 "wco:pr-(--workspace-native-controls-inset)",
11524 )
11525 : "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center pl-(--workspace-gutter-start) pr-(--workspace-gutter-end)",
11526 COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
11527 )}
11528 >
11529 {isElectron && rightPanelControlsAtRoot ? (
11530 <span
11531 aria-hidden
11532 className="pointer-events-none fixed top-[var(--workspace-controls-top)] right-[var(--workspace-controls-right)] h-[var(--workspace-topbar-height)] w-28 [-webkit-app-region:no-drag]"
11533 />
11534 ) : null}
11535 {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null}
11536 {inlineRightPanelOwnsTitleBar ? threadPanelHeaderControl : null}
11537 <ChatHeader
11538 activeThreadEnvironmentId={activeThread.environmentId}
11539 activeThreadId={activeThread.id}
11540 isServerThread={isServerThread}
11541 activeThreadTitle={activeThread.title}
11542 activeProject={activeProject ?? null}
11543 parentThreadLink={parentThreadLink}
11544 onOpenThread={onOpenRelatedThread}
11545 rightPanelOpen={inlineRightPanelOwnsTitleBar}
11546 onNewThreadInProject={handleNewThreadInActiveProject}
11547 {...(activeDraftLogicalProjectKey
11548 ? { onOpenProjectSettings: handleOpenDraftProjectSettings }
11549 : {})}
11550 />
11551 </header>
11552
11553 {/* Main content area with optional plan sidebar */}
11554 <div className="relative flex min-h-0 min-w-0 flex-1">
11555 {/* Chat column */}
11556 <ThreadFindCanvas
11557 findOptions={{
11558 skills: timelineSkills,
11559 progressive: serverConfig?.threadFindProgressive === true,
11560 thread: activeThreadRef,
11561 enabled:
11562 isServerThread && serverConfig?.threadFind === true && !paintOnlyDisplayedTimeline,
11563 content: serverProjection ?? undefined,
11564 }}
11565 controlsRef={threadFindControlsRef}
11566 onOpenChange={setIsThreadFindActive}
11567 detailsCardTopInset={isThreadFindActive ? THREAD_FIND_BAR_RESERVED_HEIGHT : 0}
11568 composerOverlayElement={isDraftHeroState ? null : composerOverlayElement}
11569 data-chat-workspace-drop-target="true"
11570 onDragEnter={workspaceFileDropHandlers.onDragEnter}
11571 onDragOver={workspaceFileDropHandlers.onDragOver}
11572 onDragLeave={workspaceFileDropHandlers.onDragLeave}
11573 onDrop={workspaceFileDropHandlers.onDrop}
11574 >
11575 <ThreadFind onClose={focusComposer} />
11576 {isWorkspaceFileDragActive ? (
11577 <div
11578 className="pointer-events-none absolute inset-2 z-40 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/60 bg-primary/[0.035]"
11579 data-chat-workspace-drop-overlay="true"
11580 >
11581 <div
11582 role="status"
11583 className="flex items-center gap-2 rounded-full border border-primary/25 bg-background/95 px-4 py-2.5 text-sm font-medium text-foreground shadow-lg"
11584 >
11585 <PaperclipIcon className="size-4 text-primary" aria-hidden="true" />
11586 Drop files to attach
11587 </div>
11588 </div>
11589 ) : null}
11590 {/* Banners overlay the timeline without changing its content height. */}
11591 <div className="chat-banner-lane pointer-events-none absolute top-0 z-20 flex flex-col">
11592 <ProviderStatusBanner
11593 status={visibleProviderStatus}
11594 onDismiss={() => setDismissedProviderStatusBannerKey(providerStatusBannerKey)}
11595 onOpenProviderSetup={openProviderSetup}
11596 />
11597 <ThreadErrorBanner
11598 error={timelineThreadError}
11599 errorClass={
11600 localServerError === null && visibleThreadError === serverRuntime?.lastError
11601 ? (serverRuntime?.lastErrorClass ?? null)
11602 : null
11603 }
11604 onDismiss={() => {
11605 setThreadError(activeThread.id, null);
11606 dismissThreadErrorBannerForSession(threadErrorBannerKey);
11607 setThreadErrorBannerDismissTick((tick) => tick + 1);
11608 }}
11609 />
11610 </div>
11611 {/* Messages Wrapper */}
11612 <div className="relative flex min-h-0 flex-1 flex-col bg-background">
11613 {/* Messages — LegendList handles virtualization and scrolling internally */}
11614 <MessagesTimeline
11615 citationRequest={paintOnlyDisplayedTimeline ? null : citationRequest}
11616 citationHistoryLoading={threadDetailLoading}
11617 {...(!paintOnlyDisplayedTimeline
11618 ? {
11619 onCiteAssistantText: citeAssistantText,
11620 ...(activeProject ? { onRunShellCommand: runShellCommand } : {}),
11621 }
11622 : {})}
11623 isWorking={!paintOnlyDisplayedTimeline && isWorking}
11624 runlessWorkActive={runlessWorkStartedAt !== null}
11625 activeTurnInProgress={
11626 !paintOnlyDisplayedTimeline && (isWorking || !latestRunSettled)
11627 }
11628 isCompacting={!paintOnlyDisplayedTimeline && isCompacting}
11629 awaitingUser={
11630 !paintOnlyDisplayedTimeline &&
11631 (activePendingApproval !== null ||
11632 activePendingUserInput !== null ||
11633 // A secret request has no runtime request; the shell carries it.
11634 activeThreadShell?.hasPendingUserInput === true)
11635 }
11636 activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt}
11637 worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup}
11638 onCancelWorktreeSetup={onCancelWorktreeSetup}
11639 {...(paintOnlyDisplayedTimeline
11640 ? {}
11641 : { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation })}
11642 {...(draftId ? { onWorktreeSetupWorkLocally } : {})}
11643 {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})}
11644 isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree}
11645 footer={
11646 paintOnlyDisplayedTimeline ? null : pendingHandoff === null ? (
11647 threadStatusLine
11648 ) : (
11649 <>
11650 <div className="chat-content-lane">
11651 <HandoffRow
11652 endpoints={pendingHandoff}
11653 providers={providerStatuses}
11654 pending
11655 />
11656 </div>
11657 {threadStatusLine}
11658 </>
11659 )
11660 }
11661 listRef={legendListRef}
11662 timelineEntries={displayedTimeline.entries}
11663 providerStatuses={
11664 environmentById.get(
11665 displayedThreadRef?.environmentId ?? activeThread.environmentId,
11666 )?.serverConfig?.providers ?? EMPTY_PROVIDERS
11667 }
11668 runs={paintOnlyDisplayedTimeline ? [] : (serverProjection?.runs ?? [])}
11669 latestRun={paintOnlyDisplayedTimeline ? null : activeActivityRun}
11670 runningRunId={paintOnlyDisplayedTimeline ? null : activeRunningTurnId}
11671 turnDiffSummaries={
11672 paintOnlyDisplayedTimeline ? EMPTY_HELD_TURN_DIFF_SUMMARIES : turnDiffSummaries
11673 }
11674 activeThreadEnvironmentId={
11675 displayedThreadRef?.environmentId ?? activeThread.environmentId
11676 }
11677 routeThreadKey={displayedTimelineKey}
11678 displayThreadKey={displayedTimelineKey}
11679 paintOnly={paintOnlyDisplayedTimeline}
11680 onOpenTurnDiff={paintOnlyDisplayedTimeline ? noopHeldTurnDiff : onOpenTurnDiff}
11681 onOpenThread={onOpenRelatedThread}
11682 parentThreadLink={paintOnlyDisplayedTimeline ? null : parentThreadLink}
11683 onForkFromRun={paintOnlyDisplayedTimeline ? async () => {} : onForkFromRun}
11684 onRollbackCheckpoint={onTimelineRollbackCheckpoint}
11685 supportsConversationRollback={
11686 !paintOnlyDisplayedTimeline && supportsConversationRollback
11687 }
11688 onRevertToTurnCount={
11689 paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurn
11690 }
11691 {...(!paintOnlyDisplayedTimeline
11692 ? { onUseArtifactTemplate: useArtifactTemplate, onSendAppMessage: sendAppMessage }
11693 : {})}
11694 isRevertingCheckpoint={isRevertingCheckpoint}
11695 onImageExpand={onExpandTimelineImage}
11696 onFileOpen={paintOnlyDisplayedTimeline ? noopHeldAttachment : openFileAttachment}
11697 onFileDownload={
11698 paintOnlyDisplayedTimeline ? noopHeldAttachment : downloadFileAttachment
11699 }
11700 markdownCwd={
11701 paintOnlyDisplayedTimeline
11702 ? (heldPaintContext?.markdownCwd ?? undefined)
11703 : (gitCwd ?? undefined)
11704 }
11705 resolvedTheme={resolvedTheme}
11706 timestampFormat={timestampFormat}
11707 workspaceRoot={
11708 paintOnlyDisplayedTimeline
11709 ? (heldPaintContext?.workspaceRoot ?? undefined)
11710 : activeWorkspaceRoot
11711 }
11712 skills={timelineSkills}
11713 anchorMessageId={paintOnlyDisplayedTimeline ? null : timelineAnchorMessageId}
11714 onAnchorReady={onTimelineAnchorReady}
11715 onAnchorSizeChanged={onTimelineAnchorSizeChanged}
11716 contentInsetEndAdjustment={composerTimelineInset}
11717 liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled}
11718 onIsAtEndChange={onIsAtEndChange}
11719 onContentOverflowChange={setTimelineOverflows}
11720 onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd}
11721 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
11722 cancelPositionRestoreRef={cancelPositionRestoreRef}
11723 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
11724 topFadeEnabled={!hasTimelineTopBanner}
11725 {...(paintOnlyDisplayedTimeline || threadHistoryControls === undefined
11726 ? {}
11727 : { historyControls: threadHistoryControls })}
11728 />
11729
11730 {/* scroll to end pill — shown when user has scrolled away from the live edge */}
11731 {showScrollToBottom && !isThreadFindActive && (
11732 <div
11733 className="chat-scroll-to-bottom pointer-events-none absolute z-30 flex justify-center py-1.5"
11734 style={{ bottom: scrollToEndClearance + 4 }}
11735 >
11736 <Button
11737 aria-label="Scroll to end"
11738 onPointerDown={(event) => event.preventDefault()}
11739 onClick={() => {
11740 composerRef.current?.restoreAfterTimelineReachedEnd();
11741 scrollToEnd(true);
11742 }}
11743 className="pointer-events-auto"
11744 size="xs"
11745 variant="glass"
11746 >
11747 <ChevronDownIcon className="size-3.5" />
11748 Scroll to end
11749 </Button>
11750 </div>
11751 )}
11752 </div>
11753
11754 {/* Input bar — centered for an empty draft, docked after sending. */}
11755 <div
11756 ref={setComposerOverlayElement}
11757 inert={isRevertingCheckpoint}
11758 data-chat-composer-overlay="true"
11759 className={
11760 isDraftHeroState
11761 ? "pointer-events-none absolute inset-0 z-20 flex items-center"
11762 : "pointer-events-none absolute inset-x-0 bottom-0 z-20 pt-1.5 sm:pt-2"
11763 }
11764 >
11765 <div
11766 ref={draftHeroTransition.transitionGroupRef}
11767 className="chat-composer-lane w-full"
11768 >
11769 <div
11770 data-chat-composer-stack="true"
11771 className="group/composer-stack pointer-events-auto relative z-10 mx-auto w-full max-w-(--chat-content-max-width)"
11772 >
11773 <BrowserAudioIndicator />
11774 {!paintOnlyDisplayedTimeline ? (
11775 <ScriptProgress
11776 environmentId={activeThread.environmentId}
11777 threadId={activeThread.id}
11778 />
11779 ) : null}
11780 {isDraftHeroState ? (
11781 <div className="absolute inset-x-0 bottom-full">
11782 <div
11783 className="pb-4 group-has-data-[composer-shoulder-tab]/composer-stack:pb-0"
11784 style={
11785 forceExpandedMobileComposer
11786 ? { viewTransitionName: MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME }
11787 : undefined
11788 }
11789 >
11790 <DraftHeroHeadline
11791 draftId={draftId}
11792 activeProjectRef={activeProjectRef}
11793 activeProjectTitle={activeProject?.title ?? null}
11794 />
11795 </div>
11796 </div>
11797 ) : null}
11798 <div
11799 ref={draftHeroTransition.composerAnchorRef}
11800 className="relative z-10"
11801 style={
11802 forceExpandedMobileComposer
11803 ? { viewTransitionName: MOBILE_COMPOSER_VIEW_TRANSITION_NAME }
11804 : undefined
11805 }
11806 >
11807 <ComposerSurface.Shell
11808 contextStrip={showComposerContextStrip || showComposerModelStrip}
11809 >
11810 <ComposerSurface.Host
11811 inert={isSavingQueuedEdit}
11812 aria-busy={isSavingQueuedEdit}
11813 >
11814 <div className="relative z-10">
11815 {showProviderSubagentBar ? (
11816 <ProviderSubagentBar
11817 provider={selectedProviderEntry ?? null}
11818 showInstanceBadge={
11819 selectedProviderEntry !== undefined &&
11820 shouldShowInstanceBadge(
11821 selectedProviderEntry,
11822 providerInstanceEntries,
11823 )
11824 }
11825 modelLabel={providerSubagentModelLabel}
11826 effortLabel={providerSubagentEffortLabel}
11827 status={providerSubagentStatus}
11828 onOpenParent={
11829 parentThreadLink
11830 ? () => onOpenRelatedThread(parentThreadLink.threadId)
11831 : null
11832 }
11833 />
11834 ) : null}
11835 {!composerMounted ? null : (
11836 <ChatComposer
11837 canOperateThread={canOperateThread}
11838 reportedModelSelection={reportedModelSelection}
11839 multipleModelSelections={multipleModelSelections}
11840 supportsMultipleModels={
11841 serverConfig?.environment.capabilities.requiredWorktreeBootstrap ===
11842 true
11843 }
11844 onMultipleModelSelectionsChange={setMultipleModelSelections}
11845 composerRef={composerRef}
11846 composerDraftTarget={composerDraftTarget}
11847 environmentId={environmentId}
11848 attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown}
11849 supportsAttachmentUploads={supportsAttachmentUploads}
11850 supportsQuestionAttachments={supportsQuestionAttachments}
11851 maxFileAttachmentBytes={maxFileAttachmentBytes}
11852 routeKind={routeKind}
11853 routeThreadRef={routeThreadRef}
11854 draftId={draftId}
11855 activeThreadId={activeThreadId}
11856 activeThreadEnvironmentId={activeThread?.environmentId}
11857 activeThread={activeThread}
11858 activeThreadShell={activeThreadShell}
11859 promptHistoryMessages={timelineMessages}
11860 isServerThread={isServerThread}
11861 isLocalDraftThread={isLocalDraftThread}
11862 forceExpandedOnMobile={
11863 forceExpandedMobileComposer && isDraftHeroState
11864 }
11865 projectSelectionRequired={
11866 isLocalDraftThread && activeProject === null
11867 }
11868 phase={phase}
11869 canInterrupt={canInterruptRunningThread}
11870 isConnecting={isConnecting}
11871 isSendBusy={
11872 (isSendBusy && !sendQueuesBehindSetup) ||
11873 (setupQueueSendThreadKey !== null &&
11874 setupQueueSendThreadKey === activeThreadKey) ||
11875 isSavingQueuedEdit ||
11876 isResuming
11877 }
11878 canResume={resumableRunId !== null || hasHeldQueuedRuns}
11879 isRevertingCheckpoint={isRevertingCheckpoint}
11880 sendDisabledReason={
11881 !canOperateThread
11882 ? "This connection cannot change threads."
11883 : isEnvironmentChanging
11884 ? "Preparing machine"
11885 : isRevertingCheckpoint
11886 ? "Rewinding conversation"
11887 : feedbackUploading
11888 ? "Sending feedback"
11889 : threadDetailLoading
11890 ? "Messages loading"
11891 : worktreeSetupBlocksSend
11892 ? "Preparing worktree"
11893 : projectCloneSendBlockReason
11894 }
11895 isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup}
11896 queuedRunsControl={queuedRunsControl}
11897 bannerItems={composerBannerItems}
11898 resumeCompactionTokens={resumeCompactionTokens}
11899 keepFullHistory={keepFullHistory}
11900 onToggleKeepFullHistory={toggleKeepFullHistory}
11901 // With attachments or contexts aboard the pick just inserts the
11902 // text, so it sends as a prompt like the typed path would.
11903 onUsageLimitsCommand={
11904 usageLimitsOffered &&
11905 usageLimitsKey !== null &&
11906 !composerHasNonPromptContent
11907 ? openUsageLimits
11908 : undefined
11909 }
11910 environmentUnavailable={activeEnvironmentUnavailableState}
11911 activePendingApproval={activePendingApproval}
11912 pendingApprovals={pendingApprovals}
11913 pendingUserInputs={pendingUserInputs}
11914 activePendingProgress={activePendingProgress}
11915 activePendingResolvedAnswers={activePendingResolvedAnswers}
11916 activePendingIsResponding={activePendingIsResponding}
11917 activePendingDraftAnswers={activePendingDraftAnswers}
11918 activePendingQuestionIndex={activePendingQuestionIndex}
11919 respondingRequestIds={respondingRequestIds}
11920 showPlanFollowUpPrompt={showPlanFollowUpPrompt}
11921 activeProposedPlan={activeProposedPlan}
11922 threadSyncPhase={
11923 activeEnvironmentUnavailable ? null : threadSyncPhase
11924 }
11925 runtimeMode={runtimeMode}
11926 interactionMode={interactionMode}
11927 lockedProvider={modelPickerLockedProvider}
11928 providerStatuses={providerStatuses as ServerProvider[]}
11929 providerCatalogKnown={serverConfig !== null}
11930 activeProjectDefaultModelSelection={
11931 activeProjectDefaultModelSelection
11932 }
11933 activeThreadModelSelection={activeThread?.modelSelection}
11934 activeProjectId={activeProject?.id ?? null}
11935 activeContextWindow={activeContextWindow}
11936 activeTasksProgress={activeComposerTasksProgress}
11937 activeTaskSteps={activeComposerTaskSteps}
11938 compactThreadUnavailable={compactThreadUnavailable}
11939 compactDisabled={compactDisabled}
11940 compactDisabledReason={compactDisabledReason}
11941 resolvedTheme={resolvedTheme}
11942 settings={settings}
11943 keybindings={keybindings}
11944 terminalOpen={terminalVisible}
11945 gitCwd={gitCwd}
11946 pullRequestProjectId={
11947 supportsPullRequests ? (activeProject?.id ?? null) : null
11948 }
11949 pullRequestRepository={
11950 supportsPullRequests ? activeProjectRepository : null
11951 }
11952 threadPullRequests={(activeThreadShell ?? activeThread)?.pullRequests}
11953 restingControlsHost={restingComposerControlsHost}
11954 restingControlsHaveLeadingContext={
11955 mountComposerContextStrip &&
11956 (isGitRepo || showComposerEnvironmentIndicator)
11957 }
11958 onRestingControlsVisibilityChange={setRestingComposerControlsVisible}
11959 getTimelineScrollableNode={getTimelineScrollableNode}
11960 isTimelineAtLogicalEnd={isTimelineAtLogicalEnd}
11961 timelineOverflows={timelineOverflows}
11962 onComposerOverlayHeightChange={publishComposerOverlayHeight}
11963 onRestingChange={onComposerRestingChange}
11964 promptRef={promptRef}
11965 composerImagesRef={composerImagesRef}
11966 composerFilesRef={composerFilesRef}
11967 composerTerminalContextsRef={composerTerminalContextsRef}
11968 onPageScrollKeyDown={onComposerPageScrollKeyDown}
11969 onPageScrollKeyUp={onComposerPageScrollKeyUp}
11970 onPageScrollRelease={onComposerPageScrollRelease}
11971 onCompactContext={onComposerCompactContext}
11972 onSend={onComposerSend}
11973 onResume={onComposerResume}
11974 onInterrupt={onInterrupt}
11975 onImplementPlanInNewThread={onImplementPlanInNewThread}
11976 onRespondToApproval={onRespondToApproval}
11977 onSelectActivePendingUserInputOption={
11978 onSelectActivePendingUserInputOption
11979 }
11980 onAdvanceActivePendingUserInput={onAdvanceActivePendingUserInput}
11981 onDismissActivePendingUserInput={onDismissUserInput}
11982 onPreviousActivePendingUserInputQuestion={
11983 onPreviousActivePendingUserInputQuestion
11984 }
11985 onChangeActivePendingUserInputCustomAnswer={
11986 onChangeActivePendingUserInputCustomAnswer
11987 }
11988 onProviderModelSelect={onProviderModelSelect}
11989 onOpenProviderSetup={openProviderSetup}
11990 getModelDisabledReason={getModelDisabledReason}
11991 toggleInteractionMode={toggleInteractionMode}
11992 handleRuntimeModeChange={handleRuntimeModeChange}
11993 handleInteractionModeChange={handleInteractionModeChange}
11994 focusComposer={focusComposer}
11995 scheduleComposerFocus={scheduleComposerFocus}
11996 setThreadError={setThreadError}
11997 onExpandImage={onExpandTimelineImage}
11998 onFileOpen={openFileAttachment}
11999 editingQueuedAttachments={composerEditingQueuedAttachments}
12000 onRemoveEditingQueuedAttachment={removeEditingQueuedAttachment}
12001 />
12002 )}
12003 </div>
12004 </ComposerSurface.Host>
12005 <div className="min-h-0">
12006 <div
12007 data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined}
12008 className="relative z-0"
12009 >
12010 {mountComposerModelStrip ? (
12011 <ComposerSurface.ContextStrip
12012 data-composer-model-strip="true"
12013 aria-hidden={showComposerModelStrip ? undefined : true}
12014 inert={showComposerModelStrip ? undefined : true}
12015 className={cn(
12016 "group-data-model-strip-transition/composer-surface:before:backdrop-blur-(--glass-blur) group-data-model-strip-transition/composer-surface:before:bg-(--chat-composer-glass-surface)/(--glass-opacity)",
12017 !showComposerModelStrip &&
12018 "pointer-events-none invisible absolute inset-x-0 top-full",
12019 )}
12020 >
12021 <div
12022 ref={setRestingComposerControlsHost}
12023 className="min-w-0 flex-1"
12024 />
12025 </ComposerSurface.ContextStrip>
12026 ) : null}
12027 {mountComposerContextStrip && (
12028 <div className="pointer-events-auto">
12029 <BranchToolbar
12030 forceNewWorktree={multipleModelSelections !== null}
12031 ref={branchToolbarRef}
12032 environmentId={activeThread.environmentId}
12033 threadId={activeThread.id}
12034 showGitControls={isGitRepo}
12035 {...(routeKind === "draft" && draftId ? { draftId } : {})}
12036 onEnvModeChange={onEnvModeChange}
12037 startFromOrigin={startFromOrigin}
12038 onStartFromOriginChange={onStartFromOriginChange}
12039 envMode={envMode}
12040 {...(canOverrideServerThreadEnvMode
12041 ? {
12042 activeThreadBranchOverride: activeThreadBranch,
12043 onActiveThreadBranchOverrideChange:
12044 setPendingServerThreadBranch,
12045 }
12046 : {})}
12047 envLocked={envLocked}
12048 onComposerFocusRequest={scheduleComposerFocus}
12049 {...(canCheckoutPullRequestIntoThread
12050 ? { onCheckoutPullRequestRequest: openPullRequestDialog }
12051 : {})}
12052 {...(hasMultipleEnvironments ? { onEnvironmentChange } : {})}
12053 autoEnvironmentLabel={autoEnvironmentLabel}
12054 onAutoEnvironment={
12055 draftId &&
12056 !envLocked &&
12057 canAutoBalanceEnvironments &&
12058 loadBalancingSettings.loadBalancingEnabled
12059 ? onAutoEnvironment
12060 : undefined
12061 }
12062 availableEnvironments={logicalProjectEnvironments}
12063 composerControlsHostRef={setRestingComposerControlsHost}
12064 contextStripVisible={showComposerContextStrip}
12065 />
12066 </div>
12067 )}
12068 </div>
12069 </div>
12070 </ComposerSurface.Shell>
12071 <div
12072 aria-hidden
12073 className="h-[calc(env(safe-area-inset-bottom)+1rem)] sm:h-[calc(env(safe-area-inset-bottom)+1.25rem)]"
12074 />
12075 </div>
12076 </div>
12077 </div>
12078 </div>
12079
12080 {canOperatePreview && activeThreadRef && activeEnvironmentServerBrowser ? (
12081 <PreviewSessionSync threadRef={activeThreadRef} />
12082 ) : null}
12083 {activeThreadRef &&
12084 activePreviewMiniPlayer &&
12085 previewMiniPlayerVisible &&
12086 (activePreviewMiniPlayer.source.kind === "device" || canOperatePreview) ? (
12087 <ThreadPreviewMiniPlayer
12088 key={`${activeThreadKey}:${previewMiniPlayerSourceKey(activePreviewMiniPlayer.source)}`}
12089 threadRef={activeThreadRef}
12090 miniPlayer={activePreviewMiniPlayer}
12091 />
12092 ) : null}
12093
12094 <AlertDialog open={branchRestoreConfirmOpen} onOpenChange={setBranchRestoreConfirmOpen}>
12095 <AlertDialogPopup>
12096 <AlertDialogHeader>
12097 <AlertDialogTitle>
12098 Switch to{" "}
12099 <code className="font-medium">
12100 {localCheckoutBranchMismatch?.threadBranch ?? ""}
12101 </code>
12102 ?
12103 </AlertDialogTitle>
12104 <AlertDialogDescription>
12105 You have uncommitted changes. They'll carry over to the other branch, or block
12106 the switch if they conflict.
12107 </AlertDialogDescription>
12108 </AlertDialogHeader>
12109 <AlertDialogFooter>
12110 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
12111 <Button
12112 variant="default"
12113 disabled={!canWriteSourceControl}
12114 onClick={() => {
12115 setBranchRestoreConfirmOpen(false);
12116 void handleSwitchCheckoutToThread();
12117 }}
12118 >
12119 Switch branch
12120 </Button>
12121 </AlertDialogFooter>
12122 </AlertDialogPopup>
12123 </AlertDialog>
12124
12125 <ThreadDetailsPanel {...threadDetailsPanelProps} />
12126
12127 {pullRequestDialogState ? (
12128 <PullRequestThreadDialog
12129 key={pullRequestDialogState.key}
12130 open
12131 environmentId={activeThread.environmentId}
12132 threadId={activeThread.id}
12133 cwd={activeProject?.workspaceRoot ?? null}
12134 initialReference={pullRequestDialogState.initialReference}
12135 onOpenChange={(open) => {
12136 if (!open) {
12137 closePullRequestDialog();
12138 }
12139 }}
12140 onPrepared={handlePreparedPullRequestThread}
12141 />
12142 ) : null}
12143 </ThreadFindCanvas>
12144 {/* end chat column */}
12145 </div>
12146 {/* end horizontal flex container */}
12147
12148 {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => (
12149 <PersistentThreadTerminalDrawer
12150 key={mountedThreadKey}
12151 threadRef={mountedThreadRef}
12152 threadId={mountedThreadRef.threadId}
12153 active={mountedThreadKey === activeThreadKey}
12154 launchContext={
12155 mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null
12156 }
12157 focusRequestId={mountedThreadKey === activeThreadKey ? terminalFocusRequestId : 0}
12158 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
12159 splitVerticalShortcutLabel={splitTerminalVerticalShortcutLabel ?? undefined}
12160 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
12161 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
12162 keybindings={keybindings}
12163 onAddTerminalContext={addTerminalContextToDraft}
12164 />
12165 ))}
12166 </div>
12167
12168 {rightPanelPresent && !shouldUsePlanSidebarSheet && activeThreadRef ? (
12169 <RightPanelTabs
12170 mode="inline"
12171 open={rightPanelOpen}
12172 keybindings={keybindings}
12173 getShortcutContext={getShortcutContext}
12174 maximized={rightPanelMaximized}
12175 inlineSize={previewPanelInlineSize}
12176 surfaces={renderedRightPanelSurfaces}
12177 environmentId={activeThreadRef.environmentId}
12178 activeSurfaceId={renderedRightPanelSurface?.id ?? null}
12179 pendingSurfaceIds={pendingFileSurfaceIds}
12180 previewSessions={activePreviewState.sessions}
12181 desktopByTabId={activePreviewState.desktopByTabId}
12182 previewRuntimeTabId={resolvePreviewRuntimeTabId}
12183 terminalLabelsById={activeTerminalLabelsById}
12184 onActivate={activateRightPanelSurface}
12185 onCloseSurface={closeRightPanelSurface}
12186 onRenameDevice={(surfaceId, title) => {
12187 if (activeThreadRef)
12188 useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title);
12189 }}
12190 onCloseOtherSurfaces={closeOtherRightPanelSurfaces}
12191 onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
12192 onCloseAllSurfaces={closeAllRightPanelSurfaces}
12193 onMoveSurface={moveRightPanelSurface}
12194 onCopyFilePath={copyRightPanelFilePath}
12195 onAddBrowser={() => createBrowserSurface()}
12196 onAddBrowserInProfile={createBrowserSurface}
12197 onAddTerminal={addTerminalSurface}
12198 onAddDiff={addDiffSurface}
12199 onAddFiles={addFilesSurface}
12200 onAddPullRequest={addPullRequestSurface}
12201 onAddPullRequests={addPullRequestsSurface}
12202 onAddDevice={addDeviceSurface}
12203 browserAvailable={canOperatePreview && browserAvailable}
12204 terminalAvailable={activeProject !== null && canOperateTerminal}
12205 diffAvailable={workspaceChangesAvailable}
12206 filesAvailable={activeProject !== null}
12207 pullRequestAvailable={pullRequestSurfaceAvailable}
12208 pullRequestsAvailable={pullRequestsSurfaceAvailable}
12209 deviceAvailable={activeThreadRef !== null}
12210 >
12211 {rightPanelContent}
12212 </RightPanelTabs>
12213 ) : null}
12214 {rightPanelPresent && shouldUsePlanSidebarSheet && activeThreadRef ? (
12215 <RightPanelSheet
12216 animationDurationMs={panelAnimationsActive ? panelAnimationDurationMs : 0}
12217 open={rightPanelOpen}
12218 onClose={closePreviewPanel}
12219 >
12220 <RightPanelTabs
12221 mode="sheet"
12222 open={rightPanelOpen}
12223 keybindings={keybindings}
12224 getShortcutContext={getShortcutContext}
12225 inlineSize={previewPanelInlineSize}
12226 // Same effective inset as the closed-state titlebar controls
12227 // (pr-3 in the tab bar plus this pixel equals the absolute
12228 // right inset plus mr-px), so the cluster does not creep when
12229 // the sheet opens.
12230 layoutControls={
12231 rightPanelOpen ? (
12232 <div className="mr-px flex items-center">{panelToggleControls}</div>
12233 ) : null
12234 }
12235 surfaces={renderedRightPanelSurfaces}
12236 environmentId={activeThreadRef.environmentId}
12237 activeSurfaceId={renderedRightPanelSurface?.id ?? null}
12238 pendingSurfaceIds={pendingFileSurfaceIds}
12239 previewSessions={activePreviewState.sessions}
12240 desktopByTabId={activePreviewState.desktopByTabId}
12241 previewRuntimeTabId={resolvePreviewRuntimeTabId}
12242 terminalLabelsById={activeTerminalLabelsById}
12243 onActivate={activateRightPanelSurface}
12244 onCloseSurface={closeRightPanelSurface}
12245 onRenameDevice={(surfaceId, title) => {
12246 if (activeThreadRef)
12247 useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title);
12248 }}
12249 onCloseOtherSurfaces={closeOtherRightPanelSurfaces}
12250 onCloseSurfacesToRight={closeRightPanelSurfacesToRight}
12251 onCloseAllSurfaces={closeAllRightPanelSurfaces}
12252 onMoveSurface={moveRightPanelSurface}
12253 onCopyFilePath={copyRightPanelFilePath}
12254 onAddBrowser={() => createBrowserSurface()}
12255 onAddBrowserInProfile={createBrowserSurface}
12256 onAddTerminal={addTerminalSurface}
12257 onAddDiff={addDiffSurface}
12258 onAddFiles={addFilesSurface}
12259 onAddPullRequest={addPullRequestSurface}
12260 onAddPullRequests={addPullRequestsSurface}
12261 onAddDevice={addDeviceSurface}
12262 browserAvailable={canOperatePreview && browserAvailable}
12263 terminalAvailable={activeProject !== null && canOperateTerminal}
12264 diffAvailable={workspaceChangesAvailable}
12265 filesAvailable={activeProject !== null}
12266 pullRequestAvailable={pullRequestSurfaceAvailable}
12267 pullRequestsAvailable={pullRequestsSurfaceAvailable}
12268 deviceAvailable={activeThreadRef !== null}
12269 >
12270 {rightPanelContent}
12271 </RightPanelTabs>
12272 </RightPanelSheet>
12273 ) : null}
12274
12275 <AlertDialog
12276 open={pendingRevert !== null && pendingRevert.routeThreadKey === routeThreadKey}
12277 onOpenChange={(open) => {
12278 if (!open) setPendingRevert(null);
12279 }}
12280 >
12281 <AlertDialogPopup>
12282 <AlertDialogHeader>
12283 <AlertDialogTitle>Edit from here?</AlertDialogTitle>
12284 <AlertDialogDescription>
12285 Rewind chat to before this message. Your prompt and attachments return to the
12286 composer.
12287 </AlertDialogDescription>
12288 </AlertDialogHeader>
12289 <AlertDialogFooter>
12290 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
12291 <Button
12292 variant="destructive"
12293 onClick={() => {
12294 if (!pendingRevert || pendingRevert.routeThreadKey !== routeThreadKey) return;
12295 setPendingRevert(null);
12296 void onRevertToTurnCount(pendingRevert.turnCount, pendingRevert.messageId, true);
12297 }}
12298 >
12299 Revert files too
12300 </Button>
12301 <Button
12302 onClick={() => {
12303 if (!pendingRevert || pendingRevert.routeThreadKey !== routeThreadKey) return;
12304 setPendingRevert(null);
12305 void onRevertToTurnCount(pendingRevert.turnCount, pendingRevert.messageId, false);
12306 }}
12307 >
12308 Revert and keep changes
12309 </Button>
12310 </AlertDialogFooter>
12311 </AlertDialogPopup>
12312 </AlertDialog>
12313 <LinkPullRequestDialogHost />
12314 {expandedImage && (
12315 <ExpandedImageDialog
12316 key={expandedImageKey(expandedImage)}
12317 preview={expandedImage}
12318 onClose={closeExpandedImage}
12319 />
12320 )}
12321 </div>
12322 );
12323 return (
12324 <FileMetadataThreadProvider
12325 threadRef={activeThreadRef}
12326 cwd={activeWorkspaceRoot}
12327 checkpoints={serverProjection?.checkpoints}
12328 >
12329 {content}
12330 </FileMetadataThreadProvider>
12331 );
12332}
12333
12334/** Keeps the thread's preview tabs synced while no browser panel is mounted. */
12335function PreviewSessionSync(props: { readonly threadRef: ScopedThreadRef }) {
12336 usePreviewSession(props.threadRef);
12337 return null;
12338}