From 6749595d9bda1d454195c4020145540bb2fb769b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 2 Apr 2026 15:31:56 -0700 Subject: [PATCH 1/2] Persist terminal drawers across thread switches - Keep hidden thread terminals mounted with an MRU cap - Replay buffered terminal events after snapshot open --- .../web/src/components/ChatView.logic.test.ts | 94 +++++++ apps/web/src/components/ChatView.logic.ts | 32 +++ apps/web/src/components/ChatView.tsx | 266 ++++++++++++++---- .../components/ThreadTerminalDrawer.test.ts | 62 ++++ .../src/components/ThreadTerminalDrawer.tsx | 243 +++++++++++----- apps/web/src/orchestrationRecovery.test.ts | 33 +++ apps/web/src/orchestrationRecovery.ts | 17 +- apps/web/src/routes/__root.tsx | 78 ++--- apps/web/src/rpc/serverState.ts | 24 +- apps/web/src/rpc/serverStateBootstrap.tsx | 10 - apps/web/src/terminalStateStore.test.ts | 86 +++++- apps/web/src/terminalStateStore.ts | 109 ++++++- 12 files changed, 849 insertions(+), 205 deletions(-) delete mode 100644 apps/web/src/rpc/serverStateBootstrap.tsx diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 80c842d91f..8d49bc07f2 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -3,10 +3,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { useStore } from "../store"; import { + MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, createLocalDispatchSnapshot, deriveComposerSendState, hasServerAcknowledgedLocalDispatch, + reconcileMountedTerminalThreadIds, waitForStartedServerThread, } from "./ChatView.logic"; @@ -75,6 +77,98 @@ describe("buildExpiredTerminalContextToastCopy", () => { }); }); +describe("reconcileMountedTerminalThreadIds", () => { + it("keeps previously mounted open threads and adds the active open thread", () => { + expect( + reconcileMountedTerminalThreadIds({ + currentThreadIds: [ + ThreadId.makeUnsafe("thread-hidden"), + ThreadId.makeUnsafe("thread-stale"), + ], + openThreadIds: [ThreadId.makeUnsafe("thread-hidden"), ThreadId.makeUnsafe("thread-active")], + activeThreadId: ThreadId.makeUnsafe("thread-active"), + activeThreadTerminalOpen: true, + }), + ).toEqual([ThreadId.makeUnsafe("thread-hidden"), ThreadId.makeUnsafe("thread-active")]); + }); + + it("drops mounted threads once their terminal drawer is no longer open", () => { + expect( + reconcileMountedTerminalThreadIds({ + currentThreadIds: [ThreadId.makeUnsafe("thread-closed")], + openThreadIds: [], + activeThreadId: ThreadId.makeUnsafe("thread-closed"), + activeThreadTerminalOpen: false, + }), + ).toEqual([]); + }); + + it("keeps only the most recently active hidden terminal threads", () => { + expect( + reconcileMountedTerminalThreadIds({ + currentThreadIds: [ + ThreadId.makeUnsafe("thread-1"), + ThreadId.makeUnsafe("thread-2"), + ThreadId.makeUnsafe("thread-3"), + ], + openThreadIds: [ + ThreadId.makeUnsafe("thread-1"), + ThreadId.makeUnsafe("thread-2"), + ThreadId.makeUnsafe("thread-3"), + ThreadId.makeUnsafe("thread-4"), + ], + activeThreadId: ThreadId.makeUnsafe("thread-4"), + activeThreadTerminalOpen: true, + maxHiddenThreadCount: 2, + }), + ).toEqual([ + ThreadId.makeUnsafe("thread-2"), + ThreadId.makeUnsafe("thread-3"), + ThreadId.makeUnsafe("thread-4"), + ]); + }); + + it("moves the active thread to the end so it is treated as most recently used", () => { + expect( + reconcileMountedTerminalThreadIds({ + currentThreadIds: [ + ThreadId.makeUnsafe("thread-a"), + ThreadId.makeUnsafe("thread-b"), + ThreadId.makeUnsafe("thread-c"), + ], + openThreadIds: [ + ThreadId.makeUnsafe("thread-a"), + ThreadId.makeUnsafe("thread-b"), + ThreadId.makeUnsafe("thread-c"), + ], + activeThreadId: ThreadId.makeUnsafe("thread-a"), + activeThreadTerminalOpen: true, + maxHiddenThreadCount: 2, + }), + ).toEqual([ + ThreadId.makeUnsafe("thread-b"), + ThreadId.makeUnsafe("thread-c"), + ThreadId.makeUnsafe("thread-a"), + ]); + }); + + it("defaults to the hidden mounted terminal cap", () => { + const currentThreadIds = Array.from( + { length: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS + 2 }, + (_, index) => ThreadId.makeUnsafe(`thread-${index + 1}`), + ); + + expect( + reconcileMountedTerminalThreadIds({ + currentThreadIds, + openThreadIds: currentThreadIds, + activeThreadId: null, + activeThreadTerminalOpen: false, + }), + ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS)); + }); +}); + const makeThread = (input?: { id?: ThreadId; latestTurn?: { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1821c65ed9..ca2a671c11 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -11,6 +11,7 @@ import { } from "../lib/terminalContext"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; +export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; const WORKTREE_BRANCH_PREFIX = "t3code"; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); @@ -43,6 +44,37 @@ export function buildLocalDraftThread( }; } +export function reconcileMountedTerminalThreadIds(input: { + currentThreadIds: ReadonlyArray; + openThreadIds: ReadonlyArray; + activeThreadId: ThreadId | null; + activeThreadTerminalOpen: boolean; + maxHiddenThreadCount?: number; +}): ThreadId[] { + const openThreadIdSet = new Set(input.openThreadIds); + const hiddenThreadIds = input.currentThreadIds.filter( + (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId), + ); + const maxHiddenThreadCount = Math.max( + 0, + input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + ); + const nextThreadIds = + hiddenThreadIds.length > maxHiddenThreadCount + ? hiddenThreadIds.slice(-maxHiddenThreadCount) + : hiddenThreadIds; + + if ( + input.activeThreadId && + input.activeThreadTerminalOpen && + !nextThreadIds.includes(input.activeThreadId) + ) { + nextThreadIds.push(input.activeThreadId); + } + + return nextThreadIds; +} + export function revokeBlobPreviewUrl(previewUrl: string | undefined): void { if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { return; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 07561e8e64..c879004bab 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -173,6 +173,7 @@ import { import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { + MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildTemporaryWorktreeBranchName, @@ -186,6 +187,7 @@ import { type LocalDispatchSnapshot, PullRequestDialogState, readFileAsDataUrl, + reconcileMountedTerminalThreadIds, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, threadHasStarted, @@ -404,6 +406,163 @@ function useLocalDispatchState(input: { }; } +interface PersistentThreadTerminalDrawerProps { + threadId: ThreadId; + visible: boolean; + focusRequestId: number; + splitShortcutLabel: string | undefined; + newShortcutLabel: string | undefined; + closeShortcutLabel: string | undefined; + onAddTerminalContext: (selection: TerminalContextSelection) => void; +} + +function PersistentThreadTerminalDrawer({ + threadId, + visible, + focusRequestId, + splitShortcutLabel, + newShortcutLabel, + closeShortcutLabel, + onAddTerminalContext, +}: PersistentThreadTerminalDrawerProps) { + const serverThread = useThreadById(threadId); + const draftThread = useComposerDraftStore( + (store) => store.draftThreadsByThreadId[threadId] ?? null, + ); + const project = useProjectById(serverThread?.projectId ?? draftThread?.projectId); + const terminalState = useTerminalStateStore((state) => + selectThreadTerminalState(state.terminalStateByThreadId, threadId), + ); + const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight); + const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal); + const storeNewTerminal = useTerminalStateStore((state) => state.newTerminal); + const storeSetActiveTerminal = useTerminalStateStore((state) => state.setActiveTerminal); + const storeCloseTerminal = useTerminalStateStore((state) => state.closeTerminal); + const [localFocusRequestId, setLocalFocusRequestId] = useState(0); + const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const cwd = useMemo( + () => + project + ? projectScriptCwd({ + project: { cwd: project.cwd }, + worktreePath, + }) + : null, + [project, worktreePath], + ); + const runtimeEnv = useMemo( + () => + project + ? projectScriptRuntimeEnv({ + project: { cwd: project.cwd }, + worktreePath, + }) + : {}, + [project, worktreePath], + ); + + const bumpFocusRequestId = useCallback(() => { + if (!visible) { + return; + } + setLocalFocusRequestId((value) => value + 1); + }, [visible]); + + const setTerminalHeight = useCallback( + (height: number) => { + storeSetTerminalHeight(threadId, height); + }, + [storeSetTerminalHeight, threadId], + ); + + const splitTerminal = useCallback(() => { + storeSplitTerminal(threadId, `terminal-${randomUUID()}`); + bumpFocusRequestId(); + }, [bumpFocusRequestId, storeSplitTerminal, threadId]); + + const createNewTerminal = useCallback(() => { + storeNewTerminal(threadId, `terminal-${randomUUID()}`); + bumpFocusRequestId(); + }, [bumpFocusRequestId, storeNewTerminal, threadId]); + + const activateTerminal = useCallback( + (terminalId: string) => { + storeSetActiveTerminal(threadId, terminalId); + bumpFocusRequestId(); + }, + [bumpFocusRequestId, storeSetActiveTerminal, threadId], + ); + + const closeTerminal = useCallback( + (terminalId: string) => { + const api = readNativeApi(); + if (!api) return; + const isFinalTerminal = terminalState.terminalIds.length <= 1; + const fallbackExitWrite = () => + api.terminal.write({ threadId, terminalId, data: "exit\n" }).catch(() => undefined); + + if ("close" in api.terminal && typeof api.terminal.close === "function") { + void (async () => { + if (isFinalTerminal) { + await api.terminal.clear({ threadId, terminalId }).catch(() => undefined); + } + await api.terminal.close({ + threadId, + terminalId, + deleteHistory: true, + }); + })().catch(() => fallbackExitWrite()); + } else { + void fallbackExitWrite(); + } + + storeCloseTerminal(threadId, terminalId); + bumpFocusRequestId(); + }, + [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId], + ); + + const handleAddTerminalContext = useCallback( + (selection: TerminalContextSelection) => { + if (!visible) { + return; + } + onAddTerminalContext(selection); + }, + [onAddTerminalContext, visible], + ); + + if (!project || !terminalState.terminalOpen || !cwd) { + return null; + } + + return ( +
+ +
+ ); +} + export default function ChatView({ threadId }: ChatViewProps) { const serverThread = useThreadById(threadId); const setStoreThreadError = useStore((store) => store.setError); @@ -565,15 +724,31 @@ export default function ChatView({ threadId }: ChatViewProps) { setMessagesScrollElement(element); }, []); - const terminalState = useTerminalStateStore((state) => - selectThreadTerminalState(state.terminalStateByThreadId, threadId), + const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); + const terminalState = useMemo( + () => selectThreadTerminalState(terminalStateByThreadId, threadId), + [terminalStateByThreadId, threadId], + ); + const openTerminalThreadIds = useMemo( + () => + Object.entries(terminalStateByThreadId).flatMap(([nextThreadId, nextTerminalState]) => + nextTerminalState.terminalOpen ? [nextThreadId as ThreadId] : [], + ), + [terminalStateByThreadId], ); const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); - const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); + const threads = useStore((state) => state.threads); + const serverThreadIds = useMemo(() => threads.map((thread) => thread.id), [threads]); + const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId); + const draftThreadIds = useMemo( + () => Object.keys(draftThreadsByThreadId) as ThreadId[], + [draftThreadsByThreadId], + ); + const [mountedTerminalThreadIds, setMountedTerminalThreadIds] = useState([]); const setPrompt = useCallback( (nextPrompt: string) => { @@ -655,6 +830,10 @@ export default function ChatView({ threadId }: ChatViewProps) { const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; + const existingOpenTerminalThreadIds = useMemo(() => { + const existingThreadIds = new Set([...serverThreadIds, ...draftThreadIds]); + return openTerminalThreadIds.filter((nextThreadId) => existingThreadIds.has(nextThreadId)); + }, [draftThreadIds, openTerminalThreadIds, serverThreadIds]); const activeLatestTurn = activeThread?.latestTurn ?? null; const threadPlanCatalog = useThreadPlanCatalog( useMemo(() => { @@ -673,6 +852,21 @@ export default function ChatView({ threadId }: ChatViewProps) { () => deriveLatestContextWindowSnapshot(activeThread?.activities ?? []), [activeThread?.activities], ); + useEffect(() => { + setMountedTerminalThreadIds((currentThreadIds) => { + const nextThreadIds = reconcileMountedTerminalThreadIds({ + currentThreadIds, + openThreadIds: existingOpenTerminalThreadIds, + activeThreadId, + activeThreadTerminalOpen: Boolean(activeThreadId && terminalState.terminalOpen), + maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, + }); + return currentThreadIds.length === nextThreadIds.length && + currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) + ? currentThreadIds + : nextThreadIds; + }); + }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProject = useProjectById(activeThread?.projectId); @@ -1326,17 +1520,6 @@ export default function ChatView({ threadId }: ChatViewProps) { () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, [selectedProvider, providerStatuses], ); - const activeProjectCwd = activeProject?.cwd ?? null; - const activeThreadWorktreePath = activeThread?.worktreePath ?? null; - const threadTerminalRuntimeEnv = useMemo(() => { - if (!activeProjectCwd) return {}; - return projectScriptRuntimeEnv({ - project: { - cwd: activeProjectCwd, - }, - worktreePath: activeThreadWorktreePath, - }); - }, [activeProjectCwd, activeThreadWorktreePath]); // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const terminalShortcutLabelOptions = useMemo( @@ -1481,13 +1664,6 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [activeThreadId, storeSetTerminalOpen], ); - const setTerminalHeight = useCallback( - (height: number) => { - if (!activeThreadId) return; - storeSetTerminalHeight(activeThreadId, height); - }, - [activeThreadId, storeSetTerminalHeight], - ); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadId) return; setTerminalOpen(!terminalState.terminalOpen); @@ -1504,14 +1680,6 @@ export default function ChatView({ threadId }: ChatViewProps) { storeNewTerminal(activeThreadId, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadId, storeNewTerminal]); - const activateTerminal = useCallback( - (terminalId: string) => { - if (!activeThreadId) return; - storeSetActiveTerminal(activeThreadId, terminalId); - setTerminalFocusRequestId((value) => value + 1); - }, - [activeThreadId, storeSetActiveTerminal], - ); const closeTerminal = useCallback( (terminalId: string) => { const api = readNativeApi(); @@ -4284,34 +4452,18 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* end horizontal flex container */} - {(() => { - if (!terminalState.terminalOpen || !activeProject) { - return null; - } - return ( - - ); - })()} + {mountedTerminalThreadIds.map((mountedThreadId) => ( + + ))} {expandedImage && expandedImageItem && (
{ expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false); expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false); }); + + it("replays only terminal events newer than the open snapshot", () => { + expect( + selectTerminalEventEntriesAfterSnapshot( + [ + { + id: 1, + event: { + threadId: "thread-1", + terminalId: "default", + createdAt: "2026-04-02T20:00:00.000Z", + type: "output", + data: "before", + }, + }, + { + id: 2, + event: { + threadId: "thread-1", + terminalId: "default", + createdAt: "2026-04-02T20:00:01.000Z", + type: "output", + data: "after", + }, + }, + ], + "2026-04-02T20:00:00.500Z", + ).map((entry) => entry.id), + ).toEqual([2]); + }); + + it("applies only terminal events that have not already been consumed", () => { + expect( + selectPendingTerminalEventEntries( + [ + { + id: 1, + event: { + threadId: "thread-1", + terminalId: "default", + createdAt: "2026-04-02T20:00:00.000Z", + type: "output", + data: "one", + }, + }, + { + id: 2, + event: { + threadId: "thread-1", + terminalId: "default", + createdAt: "2026-04-02T20:00:01.000Z", + type: "output", + data: "two", + }, + }, + ], + 1, + ).map((entry) => entry.id), + ).toEqual([2]); + }); }); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 1bdbfb6ad6..835834bd18 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,12 +1,17 @@ import { FitAddon } from "@xterm/addon-fit"; import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; -import { type ThreadId } from "@t3tools/contracts"; +import { + type TerminalEvent, + type TerminalSessionSnapshot, + type ThreadId, +} from "@t3tools/contracts"; import { Terminal, type ITheme } from "@xterm/xterm"; import { type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, useEffect, + useEffectEvent, useMemo, useRef, useState, @@ -27,6 +32,7 @@ import { type ThreadTerminalGroup, } from "../types"; import { readNativeApi } from "~/nativeApi"; +import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -47,6 +53,27 @@ function writeSystemMessage(terminal: Terminal, message: string): void { terminal.write(`\r\n[terminal] ${message}\r\n`); } +function writeTerminalSnapshot(terminal: Terminal, snapshot: TerminalSessionSnapshot): void { + terminal.write("\u001bc"); + if (snapshot.history.length > 0) { + terminal.write(snapshot.history); + } +} + +export function selectTerminalEventEntriesAfterSnapshot( + entries: ReadonlyArray<{ id: number; event: TerminalEvent }>, + snapshotUpdatedAt: string, +): ReadonlyArray<{ id: number; event: TerminalEvent }> { + return entries.filter((entry) => entry.event.createdAt > snapshotUpdatedAt); +} + +export function selectPendingTerminalEventEntries( + entries: ReadonlyArray<{ id: number; event: TerminalEvent }>, + lastAppliedTerminalEventId: number, +): ReadonlyArray<{ id: number; event: TerminalEvent }> { + return entries.filter((entry) => entry.id > lastAppliedTerminalEventId); +} + function terminalThemeFromApp(): ITheme { const isDark = document.documentElement.classList.contains("dark"); const bodyStyles = getComputedStyle(document.body); @@ -210,27 +237,21 @@ function TerminalViewport({ const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); - const onSessionExitedRef = useRef(onSessionExited); - const onAddTerminalContextRef = useRef(onAddTerminalContext); - const terminalLabelRef = useRef(terminalLabel); const hasHandledExitRef = useRef(false); const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); const selectionGestureActiveRef = useRef(false); const selectionActionRequestIdRef = useRef(0); const selectionActionOpenRef = useRef(false); const selectionActionTimerRef = useRef(null); - - useEffect(() => { - onSessionExitedRef.current = onSessionExited; - }, [onSessionExited]); - - useEffect(() => { - onAddTerminalContextRef.current = onAddTerminalContext; - }, [onAddTerminalContext]); - - useEffect(() => { - terminalLabelRef.current = terminalLabel; - }, [terminalLabel]); + const lastAppliedTerminalEventIdRef = useRef(0); + const terminalHydratedRef = useRef(false); + const handleSessionExited = useEffectEvent(() => { + onSessionExited(); + }); + const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => { + onAddTerminalContext(selection); + }); + const readTerminalLabel = useEffectEvent(() => terminalLabel); useEffect(() => { const mount = containerRef.current; @@ -297,7 +318,7 @@ function TerminalViewport({ position, selection: { terminalId, - terminalLabel: terminalLabelRef.current, + terminalLabel: readTerminalLabel(), lineStart, lineEnd, text: normalizedText, @@ -324,7 +345,7 @@ function TerminalViewport({ if (requestId !== selectionActionRequestIdRef.current || clicked !== "add-to-chat") { return; } - onAddTerminalContextRef.current(nextAction.selection); + handleAddTerminalContext(nextAction.selection); terminalRef.current?.clearSelection(); terminalRef.current?.focus(); } finally { @@ -469,43 +490,15 @@ function TerminalViewport({ attributeFilter: ["class", "style"], }); - const openTerminal = async () => { - try { - const activeTerminal = terminalRef.current; - const activeFitAddon = fitAddonRef.current; - if (!activeTerminal || !activeFitAddon) return; - activeFitAddon.fit(); - const snapshot = await api.terminal.open({ - threadId, - terminalId, - cwd, - cols: activeTerminal.cols, - rows: activeTerminal.rows, - ...(runtimeEnv ? { env: runtimeEnv } : {}), - }); - if (disposed) return; - activeTerminal.write("\u001bc"); - if (snapshot.history.length > 0) { - activeTerminal.write(snapshot.history); - } - if (autoFocus) { - window.requestAnimationFrame(() => { - activeTerminal.focus(); - }); - } - } catch (err) { - if (disposed) return; - writeSystemMessage( - terminal, - err instanceof Error ? err.message : "Failed to open terminal", - ); + const applyTerminalEvent = (event: TerminalEvent) => { + const activeTerminal = terminalRef.current; + if (!activeTerminal) { + return; } - }; - const unsubscribe = api?.terminal.onEvent((event) => { - if (event.threadId !== threadId || event.terminalId !== terminalId) return; - const activeTerminal = terminalRef.current; - if (!activeTerminal) return; + if (event.type === "activity") { + return; + } if (event.type === "output") { activeTerminal.write(event.data); @@ -516,10 +509,7 @@ function TerminalViewport({ if (event.type === "started" || event.type === "restarted") { hasHandledExitRef.current = false; clearSelectionAction(); - activeTerminal.write("\u001bc"); - if (event.snapshot.history.length > 0) { - activeTerminal.write(event.snapshot.history); - } + writeTerminalSnapshot(activeTerminal, event.snapshot); return; } @@ -535,30 +525,112 @@ function TerminalViewport({ return; } - if (event.type === "exited") { - const details = [ - typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, - typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null, - ] - .filter((value): value is string => value !== null) - .join(", "); - writeSystemMessage( - activeTerminal, - details.length > 0 ? `Process exited (${details})` : "Process exited", - ); - if (hasHandledExitRef.current) { + const details = [ + typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, + typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null, + ] + .filter((value): value is string => value !== null) + .join(", "); + writeSystemMessage( + activeTerminal, + details.length > 0 ? `Process exited (${details})` : "Process exited", + ); + if (hasHandledExitRef.current) { + return; + } + hasHandledExitRef.current = true; + window.setTimeout(() => { + if (!hasHandledExitRef.current) { return; } - hasHandledExitRef.current = true; - window.setTimeout(() => { - if (!hasHandledExitRef.current) { - return; - } - onSessionExitedRef.current(); - }, 0); + handleSessionExited(); + }, 0); + }; + const applyPendingTerminalEvents = ( + terminalEventEntries: ReadonlyArray<{ id: number; event: TerminalEvent }>, + ) => { + const pendingEntries = selectPendingTerminalEventEntries( + terminalEventEntries, + lastAppliedTerminalEventIdRef.current, + ); + if (pendingEntries.length === 0) { + return; + } + for (const entry of pendingEntries) { + applyTerminalEvent(entry.event); + } + lastAppliedTerminalEventIdRef.current = + pendingEntries.at(-1)?.id ?? lastAppliedTerminalEventIdRef.current; + }; + + const unsubscribeTerminalEvents = useTerminalStateStore.subscribe((state, previousState) => { + if (!terminalHydratedRef.current) { + return; } + + const previousLastEntryId = + selectTerminalEventEntries( + previousState.terminalEventEntriesByKey, + threadId, + terminalId, + ).at(-1)?.id ?? 0; + const nextEntries = selectTerminalEventEntries( + state.terminalEventEntriesByKey, + threadId, + terminalId, + ); + const nextLastEntryId = nextEntries.at(-1)?.id ?? 0; + if (nextLastEntryId === previousLastEntryId) { + return; + } + + applyPendingTerminalEvents(nextEntries); }); + const openTerminal = async () => { + try { + const activeTerminal = terminalRef.current; + const activeFitAddon = fitAddonRef.current; + if (!activeTerminal || !activeFitAddon) return; + activeFitAddon.fit(); + const snapshot = await api.terminal.open({ + threadId, + terminalId, + cwd, + cols: activeTerminal.cols, + rows: activeTerminal.rows, + ...(runtimeEnv ? { env: runtimeEnv } : {}), + }); + if (disposed) return; + writeTerminalSnapshot(activeTerminal, snapshot); + const bufferedEntries = selectTerminalEventEntries( + useTerminalStateStore.getState().terminalEventEntriesByKey, + threadId, + terminalId, + ); + const replayEntries = selectTerminalEventEntriesAfterSnapshot( + bufferedEntries, + snapshot.updatedAt, + ); + for (const entry of replayEntries) { + applyTerminalEvent(entry.event); + } + lastAppliedTerminalEventIdRef.current = bufferedEntries.at(-1)?.id ?? 0; + terminalHydratedRef.current = true; + if (autoFocus) { + window.requestAnimationFrame(() => { + activeTerminal.focus(); + }); + } + } catch (err) { + if (disposed) return; + writeSystemMessage( + terminal, + err instanceof Error ? err.message : "Failed to open terminal", + ); + } + }; + const fitTimer = window.setTimeout(() => { const activeTerminal = terminalRef.current; const activeFitAddon = fitAddonRef.current; @@ -582,8 +654,10 @@ function TerminalViewport({ return () => { disposed = true; + terminalHydratedRef.current = false; + lastAppliedTerminalEventIdRef.current = 0; + unsubscribeTerminalEvents(); window.clearTimeout(fitTimer); - unsubscribe(); inputDisposable.dispose(); selectionDisposable.dispose(); terminalLinksDisposable.dispose(); @@ -647,6 +721,7 @@ interface ThreadTerminalDrawerProps { threadId: ThreadId; cwd: string; runtimeEnv?: Record; + visible?: boolean; height: number; terminalIds: string[]; activeTerminalId: string; @@ -697,6 +772,7 @@ export default function ThreadTerminalDrawer({ threadId, cwd, runtimeEnv, + visible = true, height, terminalIds, activeTerminalId, @@ -911,6 +987,10 @@ export default function ThreadTerminalDrawer({ ); useEffect(() => { + if (!visible) { + return; + } + const onWindowResize = () => { const clampedHeight = clampDrawerHeight(drawerHeightRef.current); const changed = clampedHeight !== drawerHeightRef.current; @@ -927,7 +1007,14 @@ export default function ThreadTerminalDrawer({ return () => { window.removeEventListener("resize", onWindowResize); }; - }, [syncHeight]); + }, [syncHeight, visible]); + + useEffect(() => { + if (!visible) { + return; + } + setResizeEpoch((value) => value + 1); + }, [visible]); useEffect(() => { return () => { diff --git a/apps/web/src/orchestrationRecovery.test.ts b/apps/web/src/orchestrationRecovery.test.ts index bea16cdbce..fbdbea4ee3 100644 --- a/apps/web/src/orchestrationRecovery.test.ts +++ b/apps/web/src/orchestrationRecovery.test.ts @@ -97,4 +97,37 @@ describe("createOrchestrationRecoveryCoordinator", () => { reason: "replay-failed", }); }); + + it("keeps enough state to explain why bootstrap snapshot recovery requests replay", () => { + const coordinator = createOrchestrationRecoveryCoordinator(); + + expect(coordinator.beginSnapshotRecovery("bootstrap")).toBe(true); + expect(coordinator.classifyDomainEvent(4)).toBe("defer"); + expect(coordinator.completeSnapshotRecovery(2)).toBe(true); + + expect(coordinator.getState()).toMatchObject({ + latestSequence: 2, + highestObservedSequence: 4, + bootstrapped: true, + pendingReplay: false, + inFlight: null, + }); + }); + + it("reports skip state when snapshot recovery is requested while replay is in flight", () => { + const coordinator = createOrchestrationRecoveryCoordinator(); + + coordinator.beginSnapshotRecovery("bootstrap"); + coordinator.completeSnapshotRecovery(3); + expect(coordinator.beginReplayRecovery("sequence-gap")).toBe(true); + + expect(coordinator.beginSnapshotRecovery("bootstrap")).toBe(false); + expect(coordinator.getState()).toMatchObject({ + pendingReplay: true, + inFlight: { + kind: "replay", + reason: "sequence-gap", + }, + }); + }); }); diff --git a/apps/web/src/orchestrationRecovery.ts b/apps/web/src/orchestrationRecovery.ts index ee81d5d539..5af48f85b9 100644 --- a/apps/web/src/orchestrationRecovery.ts +++ b/apps/web/src/orchestrationRecovery.ts @@ -34,11 +34,16 @@ export function createOrchestrationRecoveryCoordinator() { state.highestObservedSequence = Math.max(state.highestObservedSequence, sequence); }; - const shouldReplayAfterRecovery = (): boolean => { - const shouldReplay = - state.pendingReplay || state.highestObservedSequence > state.latestSequence; + const resolveReplayNeedAfterRecovery = () => { + const pendingReplayBeforeReset = state.pendingReplay; + const observedAhead = state.highestObservedSequence > state.latestSequence; + const shouldReplay = pendingReplayBeforeReset || observedAhead; state.pendingReplay = false; - return shouldReplay; + return { + shouldReplay, + pendingReplayBeforeReset, + observedAhead, + }; }; return { @@ -93,7 +98,7 @@ export function createOrchestrationRecoveryCoordinator() { state.highestObservedSequence = Math.max(state.highestObservedSequence, state.latestSequence); state.bootstrapped = true; state.inFlight = null; - return shouldReplayAfterRecovery(); + return resolveReplayNeedAfterRecovery().shouldReplay; }, failSnapshotRecovery(): void { @@ -124,7 +129,7 @@ export function createOrchestrationRecoveryCoordinator() { state.pendingReplay = false; return false; } - return shouldReplayAfterRecovery(); + return resolveReplayNeedAfterRecovery().shouldReplay; }, failReplayRecovery(): void { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index ded4915ad5..e56f0e926b 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -22,12 +22,12 @@ import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { readNativeApi } from "../nativeApi"; import { getServerConfigUpdatedNotification, - type ServerConfigUpdateSource, + ServerConfigUpdatedNotification, + startServerStateSync, useServerConfig, useServerConfigUpdatedSubscription, useServerWelcomeSubscription, } from "../rpc/serverState"; -import { ServerStateBootstrap } from "../rpc/serverStateBootstrap"; import { clearPromotedDraftThread, clearPromotedDraftThreads, @@ -43,6 +43,7 @@ import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects"; import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery"; +import { getWsRpcClient } from "~/wsRpcClient"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -68,18 +69,15 @@ function RootRouteView() { } return ( - <> - - - - - - - - - - - + + + + + + + + + ); } @@ -191,6 +189,12 @@ function coalesceOrchestrationUiEvents( return coalesced; } +function ServerStateBootstrap() { + useEffect(() => startServerStateSync(getWsRpcClient().server), []); + + return null; +} + function EventRouter() { const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); const syncServerReadModel = useStore((store) => store.syncServerReadModel); @@ -205,16 +209,15 @@ function EventRouter() { const queryClient = useQueryClient(); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); - const pathnameRef = useRef(pathname); const handledBootstrapThreadIdRef = useRef(null); const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); const disposedRef = useRef(false); const bootstrapFromSnapshotRef = useRef<() => Promise>(async () => undefined); const serverConfig = useServerConfig(); - pathnameRef.current = pathname; + const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { + if (!payload) return; - const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload) => { migrateLocalSettingsToServer(); void (async () => { await bootstrapFromSnapshotRef.current(); @@ -227,7 +230,7 @@ function EventRouter() { } setProjectExpanded(payload.bootstrapProjectId, true); - if (pathnameRef.current !== "/") { + if (pathname !== "/") { return; } if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) { @@ -243,15 +246,10 @@ function EventRouter() { }); const handleServerConfigUpdated = useEffectEvent( - ({ - id, - payload, - source, - }: { - readonly id: number; - readonly payload: import("@t3tools/contracts").ServerConfigUpdatedPayload; - readonly source: ServerConfigUpdateSource; - }) => { + (notification: ServerConfigUpdatedNotification | null) => { + if (!notification) return; + + const { id, payload, source } = notification; if (id <= seenServerConfigUpdateIdRef.current) { return; } @@ -425,8 +423,9 @@ function EventRouter() { return; } + const fromSequenceExclusive = recovery.getState().latestSequence; try { - const events = await api.orchestration.replayEvents(recovery.getState().latestSequence); + const events = await api.orchestration.replayEvents(fromSequenceExclusive); if (!disposed) { applyEventBatch(events); } @@ -442,7 +441,22 @@ function EventRouter() { }; const runSnapshotRecovery = async (reason: "bootstrap" | "replay-failed"): Promise => { - if (!recovery.beginSnapshotRecovery(reason)) { + const started = recovery.beginSnapshotRecovery(reason); + if (import.meta.env.MODE !== "test") { + const state = recovery.getState(); + console.info("[orchestration-recovery]", "Snapshot recovery requested.", { + reason, + skipped: !started, + ...(started + ? {} + : { + blockedBy: state.inFlight?.kind ?? null, + blockedByReason: state.inFlight?.reason ?? null, + }), + state, + }); + } + if (!started) { return; } @@ -482,6 +496,7 @@ function EventRouter() { } }); const unsubTerminalEvent = api.terminal.onEvent((event) => { + useTerminalStateStore.getState().recordTerminalEvent(event); const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); if (hasRunningSubprocess === null) { return; @@ -522,8 +537,3 @@ function EventRouter() { return null; } - -function DesktopProjectBootstrap() { - // Desktop hydration runs through EventRouter project + orchestration sync. - return null; -} diff --git a/apps/web/src/rpc/serverState.ts b/apps/web/src/rpc/serverState.ts index ff27190399..2a69394062 100644 --- a/apps/web/src/rpc/serverState.ts +++ b/apps/web/src/rpc/serverState.ts @@ -11,6 +11,7 @@ import { type ServerSettings, } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; +import { useCallback, useRef } from "react"; import type { WsRpcClient } from "../wsRpcClient"; import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry"; @@ -242,17 +243,18 @@ function subscribeLatest( function useLatestAtomSubscription( atom: Atom.Atom, listener: (value: NonNullable) => void, -) { - useAtomSubscribe( - atom, - (value) => { - if (value === null) { - return; - } - listener(value as NonNullable); - }, - { immediate: true }, - ); +): void { + const listenerRef = useRef(listener); + listenerRef.current = listener; + + const stableListener = useCallback((value: A | null) => { + if (value === null) { + return; + } + listenerRef.current(value as NonNullable); + }, []); + + useAtomSubscribe(atom, stableListener, { immediate: true }); } export function useServerConfig(): ServerConfig | null { diff --git a/apps/web/src/rpc/serverStateBootstrap.tsx b/apps/web/src/rpc/serverStateBootstrap.tsx deleted file mode 100644 index c5c5c12eae..0000000000 --- a/apps/web/src/rpc/serverStateBootstrap.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { useEffect } from "react"; - -import { getWsRpcClient } from "../wsRpcClient"; -import { startServerStateSync } from "./serverState"; - -export function ServerStateBootstrap() { - useEffect(() => startServerStateSync(getWsRpcClient().server), []); - - return null; -} diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts index d618275682..4a8260b0ce 100644 --- a/apps/web/src/terminalStateStore.test.ts +++ b/apps/web/src/terminalStateStore.test.ts @@ -1,14 +1,64 @@ -import { ThreadId } from "@t3tools/contracts"; +import { ThreadId, type TerminalEvent } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vitest"; -import { selectThreadTerminalState, useTerminalStateStore } from "./terminalStateStore"; +import { + selectTerminalEventEntries, + selectThreadTerminalState, + useTerminalStateStore, +} from "./terminalStateStore"; const THREAD_ID = ThreadId.makeUnsafe("thread-1"); +function makeTerminalEvent( + type: TerminalEvent["type"], + overrides: Partial = {}, +): TerminalEvent { + const base = { + threadId: THREAD_ID, + terminalId: "default", + createdAt: "2026-04-02T20:00:00.000Z", + }; + + switch (type) { + case "output": + return { ...base, type, data: "hello\n", ...overrides } as TerminalEvent; + case "activity": + return { ...base, type, hasRunningSubprocess: true, ...overrides } as TerminalEvent; + case "error": + return { ...base, type, message: "boom", ...overrides } as TerminalEvent; + case "cleared": + return { ...base, type, ...overrides } as TerminalEvent; + case "exited": + return { ...base, type, exitCode: 0, exitSignal: null, ...overrides } as TerminalEvent; + case "started": + case "restarted": + return { + ...base, + type, + snapshot: { + threadId: THREAD_ID, + terminalId: "default", + cwd: "/tmp/workspace", + status: "running", + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: "2026-04-02T20:00:00.000Z", + }, + ...overrides, + } as TerminalEvent; + } +} + describe("terminalStateStore actions", () => { beforeEach(() => { useTerminalStateStore.persist.clearStorage(); - useTerminalStateStore.setState({ terminalStateByThreadId: {} }); + useTerminalStateStore.setState({ + terminalStateByThreadId: {}, + terminalEventEntriesByKey: {}, + nextTerminalEventId: 1, + }); }); it("returns a closed default terminal state for unknown threads", () => { @@ -152,4 +202,34 @@ describe("terminalStateStore actions", () => { { id: "group-default", terminalIds: ["default", "terminal-2"] }, ]); }); + + it("buffers terminal events outside persisted terminal UI state", () => { + const store = useTerminalStateStore.getState(); + store.recordTerminalEvent(makeTerminalEvent("output")); + store.recordTerminalEvent(makeTerminalEvent("activity")); + + const entries = selectTerminalEventEntries( + useTerminalStateStore.getState().terminalEventEntriesByKey, + THREAD_ID, + "default", + ); + + expect(entries).toHaveLength(2); + expect(entries.map((entry) => entry.id)).toEqual([1, 2]); + expect(entries.map((entry) => entry.event.type)).toEqual(["output", "activity"]); + }); + + it("clears buffered terminal events when a thread terminal state is removed", () => { + const store = useTerminalStateStore.getState(); + store.recordTerminalEvent(makeTerminalEvent("output")); + store.removeTerminalState(THREAD_ID); + + const entries = selectTerminalEventEntries( + useTerminalStateStore.getState().terminalEventEntriesByKey, + THREAD_ID, + "default", + ); + + expect(entries).toEqual([]); + }); }); diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index 62e0883516..6119f98821 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -5,7 +5,7 @@ * API constrained to store actions/selectors. */ -import type { ThreadId } from "@t3tools/contracts"; +import type { TerminalEvent, ThreadId } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; @@ -26,7 +26,14 @@ interface ThreadTerminalState { activeTerminalGroupId: string; } +export interface TerminalEventEntry { + id: number; + event: TerminalEvent; +} + const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; +const EMPTY_TERMINAL_EVENT_ENTRIES: ReadonlyArray = []; +const MAX_TERMINAL_EVENT_BUFFER = 200; function createTerminalStateStorage() { return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); @@ -227,6 +234,10 @@ function isValidTerminalId(terminalId: string): boolean { return terminalId.trim().length > 0; } +function terminalEventBufferKey(threadId: ThreadId, terminalId: string): string { + return `${threadId}\u0000${terminalId}`; +} + function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { return groups.map((group) => ({ id: group.id, @@ -471,8 +482,24 @@ function updateTerminalStateByThreadId( }; } +export function selectTerminalEventEntries( + terminalEventEntriesByKey: Record>, + threadId: ThreadId, + terminalId: string, +): ReadonlyArray { + if (threadId.length === 0 || terminalId.trim().length === 0) { + return EMPTY_TERMINAL_EVENT_ENTRIES; + } + return ( + terminalEventEntriesByKey[terminalEventBufferKey(threadId, terminalId)] ?? + EMPTY_TERMINAL_EVENT_ENTRIES + ); +} + interface TerminalStateStoreState { terminalStateByThreadId: Record; + terminalEventEntriesByKey: Record>; + nextTerminalEventId: number; setTerminalOpen: (threadId: ThreadId, open: boolean) => void; setTerminalHeight: (threadId: ThreadId, height: number) => void; splitTerminal: (threadId: ThreadId, terminalId: string) => void; @@ -484,6 +511,7 @@ interface TerminalStateStoreState { terminalId: string, hasRunningSubprocess: boolean, ) => void; + recordTerminalEvent: (event: TerminalEvent) => void; clearTerminalState: (threadId: ThreadId) => void; removeTerminalState: (threadId: ThreadId) => void; removeOrphanedTerminalStates: (activeThreadIds: Set) => void; @@ -513,6 +541,8 @@ export const useTerminalStateStore = create()( return { terminalStateByThreadId: {}, + terminalEventEntriesByKey: {}, + nextTerminalEventId: 1, setTerminalOpen: (threadId, open) => updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)), setTerminalHeight: (threadId, height) => @@ -529,28 +559,95 @@ export const useTerminalStateStore = create()( updateTerminal(threadId, (state) => setThreadTerminalActivity(state, terminalId, hasRunningSubprocess), ), + recordTerminalEvent: (event) => + set((state) => { + const key = terminalEventBufferKey(event.threadId as ThreadId, event.terminalId); + const currentEntries = + state.terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES; + const nextEntry: TerminalEventEntry = { + id: state.nextTerminalEventId, + event, + }; + const nextEntries = + currentEntries.length >= MAX_TERMINAL_EVENT_BUFFER + ? [...currentEntries.slice(1), nextEntry] + : [...currentEntries, nextEntry]; + return { + terminalEventEntriesByKey: { + ...state.terminalEventEntriesByKey, + [key]: nextEntries, + }, + nextTerminalEventId: state.nextTerminalEventId + 1, + }; + }), clearTerminalState: (threadId) => - updateTerminal(threadId, () => createDefaultThreadTerminalState()), + set((state) => { + const nextTerminalStateByThreadId = updateTerminalStateByThreadId( + state.terminalStateByThreadId, + threadId, + () => createDefaultThreadTerminalState(), + ); + const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; + for (const key of Object.keys(nextTerminalEventEntriesByKey)) { + if (key.startsWith(`${threadId}\u0000`)) { + delete nextTerminalEventEntriesByKey[key]; + } + } + if ( + nextTerminalStateByThreadId === state.terminalStateByThreadId && + nextTerminalEventEntriesByKey === state.terminalEventEntriesByKey + ) { + return state; + } + return { + terminalStateByThreadId: nextTerminalStateByThreadId, + terminalEventEntriesByKey: nextTerminalEventEntriesByKey, + }; + }), removeTerminalState: (threadId) => set((state) => { - if (state.terminalStateByThreadId[threadId] === undefined) { + const hasThreadState = state.terminalStateByThreadId[threadId] !== undefined; + const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; + let removedEventEntries = false; + for (const key of Object.keys(nextTerminalEventEntriesByKey)) { + if (key.startsWith(`${threadId}\u0000`)) { + delete nextTerminalEventEntriesByKey[key]; + removedEventEntries = true; + } + } + if (!hasThreadState && !removedEventEntries) { return state; } const next = { ...state.terminalStateByThreadId }; delete next[threadId]; - return { terminalStateByThreadId: next }; + return { + terminalStateByThreadId: next, + terminalEventEntriesByKey: nextTerminalEventEntriesByKey, + }; }), removeOrphanedTerminalStates: (activeThreadIds) => set((state) => { const orphanedIds = Object.keys(state.terminalStateByThreadId).filter( (id) => !activeThreadIds.has(id as ThreadId), ); - if (orphanedIds.length === 0) return state; + const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; + let removedEventEntries = false; + for (const key of Object.keys(nextTerminalEventEntriesByKey)) { + const [threadId] = key.split("\u0000"); + if (threadId && !activeThreadIds.has(threadId as ThreadId)) { + delete nextTerminalEventEntriesByKey[key]; + removedEventEntries = true; + } + } + if (orphanedIds.length === 0 && !removedEventEntries) return state; const next = { ...state.terminalStateByThreadId }; for (const id of orphanedIds) { delete next[id as ThreadId]; } - return { terminalStateByThreadId: next }; + return { + terminalStateByThreadId: next, + terminalEventEntriesByKey: nextTerminalEventEntriesByKey, + }; }), }; }, From 28bae95a96385ac52eb48db57c6f191fcb5dff97 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 2 Apr 2026 15:49:07 -0700 Subject: [PATCH 2/2] Avoid stale pathname during bootstrap handling - Read the latest route before auto-opening bootstrap threads - Skip no-op terminal state clears when nothing changes --- apps/web/src/routes/__root.tsx | 3 ++- apps/web/src/terminalStateStore.test.ts | 9 +++++++++ apps/web/src/terminalStateStore.ts | 4 +++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index e56f0e926b..bf5eb4410c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -209,6 +209,7 @@ function EventRouter() { const queryClient = useQueryClient(); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); + const readPathname = useEffectEvent(() => pathname); const handledBootstrapThreadIdRef = useRef(null); const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); const disposedRef = useRef(false); @@ -230,7 +231,7 @@ function EventRouter() { } setProjectExpanded(payload.bootstrapProjectId, true); - if (pathname !== "/") { + if (readPathname() !== "/") { return; } if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) { diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts index 4a8260b0ce..071d30abc2 100644 --- a/apps/web/src/terminalStateStore.test.ts +++ b/apps/web/src/terminalStateStore.test.ts @@ -232,4 +232,13 @@ describe("terminalStateStore actions", () => { expect(entries).toEqual([]); }); + + it("is a no-op when clearing terminal state for a thread with no state or buffered events", () => { + const store = useTerminalStateStore.getState(); + const before = useTerminalStateStore.getState(); + + store.clearTerminalState(THREAD_ID); + + expect(useTerminalStateStore.getState()).toBe(before); + }); }); diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index 6119f98821..cd17aa295f 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -588,14 +588,16 @@ export const useTerminalStateStore = create()( () => createDefaultThreadTerminalState(), ); const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; + let removedEventEntries = false; for (const key of Object.keys(nextTerminalEventEntriesByKey)) { if (key.startsWith(`${threadId}\u0000`)) { delete nextTerminalEventEntriesByKey[key]; + removedEventEntries = true; } } if ( nextTerminalStateByThreadId === state.terminalStateByThreadId && - nextTerminalEventEntriesByKey === state.terminalEventEntriesByKey + !removedEventEntries ) { return state; }