authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-02 15:56:53-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-02 15:56:53-07:00
logda107f315c71e376a196ff8eb2b6c4f1aa15c163
tree6b30389f61cee09e6d5f2d31c4fe904c2d1d17ee
parentd2822a8883fc30791264a25124e5ae2708b88a8b
signature Signed by PGP key B5690EEEBB952194

Fix websocket closing and reopening connections too eagerly (#1701)


12 files changed, 861 insertions(+), 205 deletions(-)

apps/web/src/components/ChatView.logic.test.ts+94
...@@ -3,10 +3,12 @@ import { afterEach, describe, expect, it, vi } from "vitest";...@@ -3,10 +3,12 @@ import { afterEach, describe, expect, it, vi } from "vitest";
3import { useStore } from "../store";3import { useStore } from "../store";
44
5import {5import {
6 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
6 buildExpiredTerminalContextToastCopy,7 buildExpiredTerminalContextToastCopy,
7 createLocalDispatchSnapshot,8 createLocalDispatchSnapshot,
8 deriveComposerSendState,9 deriveComposerSendState,
9 hasServerAcknowledgedLocalDispatch,10 hasServerAcknowledgedLocalDispatch,
11 reconcileMountedTerminalThreadIds,
10 waitForStartedServerThread,12 waitForStartedServerThread,
11} from "./ChatView.logic";13} from "./ChatView.logic";
1214
...@@ -75,6 +77,98 @@ describe("buildExpiredTerminalContextToastCopy", () => {...@@ -75,6 +77,98 @@ describe("buildExpiredTerminalContextToastCopy", () => {
75 });77 });
76});78});
7779
80describe("reconcileMountedTerminalThreadIds", () => {
81 it("keeps previously mounted open threads and adds the active open thread", () => {
82 expect(
83 reconcileMountedTerminalThreadIds({
84 currentThreadIds: [
85 ThreadId.makeUnsafe("thread-hidden"),
86 ThreadId.makeUnsafe("thread-stale"),
87 ],
88 openThreadIds: [ThreadId.makeUnsafe("thread-hidden"), ThreadId.makeUnsafe("thread-active")],
89 activeThreadId: ThreadId.makeUnsafe("thread-active"),
90 activeThreadTerminalOpen: true,
91 }),
92 ).toEqual([ThreadId.makeUnsafe("thread-hidden"), ThreadId.makeUnsafe("thread-active")]);
93 });
94
95 it("drops mounted threads once their terminal drawer is no longer open", () => {
96 expect(
97 reconcileMountedTerminalThreadIds({
98 currentThreadIds: [ThreadId.makeUnsafe("thread-closed")],
99 openThreadIds: [],
100 activeThreadId: ThreadId.makeUnsafe("thread-closed"),
101 activeThreadTerminalOpen: false,
102 }),
103 ).toEqual([]);
104 });
105
106 it("keeps only the most recently active hidden terminal threads", () => {
107 expect(
108 reconcileMountedTerminalThreadIds({
109 currentThreadIds: [
110 ThreadId.makeUnsafe("thread-1"),
111 ThreadId.makeUnsafe("thread-2"),
112 ThreadId.makeUnsafe("thread-3"),
113 ],
114 openThreadIds: [
115 ThreadId.makeUnsafe("thread-1"),
116 ThreadId.makeUnsafe("thread-2"),
117 ThreadId.makeUnsafe("thread-3"),
118 ThreadId.makeUnsafe("thread-4"),
119 ],
120 activeThreadId: ThreadId.makeUnsafe("thread-4"),
121 activeThreadTerminalOpen: true,
122 maxHiddenThreadCount: 2,
123 }),
124 ).toEqual([
125 ThreadId.makeUnsafe("thread-2"),
126 ThreadId.makeUnsafe("thread-3"),
127 ThreadId.makeUnsafe("thread-4"),
128 ]);
129 });
130
131 it("moves the active thread to the end so it is treated as most recently used", () => {
132 expect(
133 reconcileMountedTerminalThreadIds({
134 currentThreadIds: [
135 ThreadId.makeUnsafe("thread-a"),
136 ThreadId.makeUnsafe("thread-b"),
137 ThreadId.makeUnsafe("thread-c"),
138 ],
139 openThreadIds: [
140 ThreadId.makeUnsafe("thread-a"),
141 ThreadId.makeUnsafe("thread-b"),
142 ThreadId.makeUnsafe("thread-c"),
143 ],
144 activeThreadId: ThreadId.makeUnsafe("thread-a"),
145 activeThreadTerminalOpen: true,
146 maxHiddenThreadCount: 2,
147 }),
148 ).toEqual([
149 ThreadId.makeUnsafe("thread-b"),
150 ThreadId.makeUnsafe("thread-c"),
151 ThreadId.makeUnsafe("thread-a"),
152 ]);
153 });
154
155 it("defaults to the hidden mounted terminal cap", () => {
156 const currentThreadIds = Array.from(
157 { length: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS + 2 },
158 (_, index) => ThreadId.makeUnsafe(`thread-${index + 1}`),
159 );
160
161 expect(
162 reconcileMountedTerminalThreadIds({
163 currentThreadIds,
164 openThreadIds: currentThreadIds,
165 activeThreadId: null,
166 activeThreadTerminalOpen: false,
167 }),
168 ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS));
169 });
170});
171
78const makeThread = (input?: {172const makeThread = (input?: {
79 id?: ThreadId;173 id?: ThreadId;
80 latestTurn?: {174 latestTurn?: {
apps/web/src/components/ChatView.logic.ts+32
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11} from "../lib/terminalContext";11} from "../lib/terminalContext";
1212
13export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";13export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
14export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
14const WORKTREE_BRANCH_PREFIX = "t3code";15const WORKTREE_BRANCH_PREFIX = "t3code";
1516
16export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);17export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
...@@ -43,6 +44,37 @@ export function buildLocalDraftThread(...@@ -43,6 +44,37 @@ export function buildLocalDraftThread(
43 };44 };
44}45}
4546
47export function reconcileMountedTerminalThreadIds(input: {
48 currentThreadIds: ReadonlyArray<ThreadId>;
49 openThreadIds: ReadonlyArray<ThreadId>;
50 activeThreadId: ThreadId | null;
51 activeThreadTerminalOpen: boolean;
52 maxHiddenThreadCount?: number;
53}): ThreadId[] {
54 const openThreadIdSet = new Set(input.openThreadIds);
55 const hiddenThreadIds = input.currentThreadIds.filter(
56 (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId),
57 );
58 const maxHiddenThreadCount = Math.max(
59 0,
60 input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
61 );
62 const nextThreadIds =
63 hiddenThreadIds.length > maxHiddenThreadCount
64 ? hiddenThreadIds.slice(-maxHiddenThreadCount)
65 : hiddenThreadIds;
66
67 if (
68 input.activeThreadId &&
69 input.activeThreadTerminalOpen &&
70 !nextThreadIds.includes(input.activeThreadId)
71 ) {
72 nextThreadIds.push(input.activeThreadId);
73 }
74
75 return nextThreadIds;
76}
77
46export function revokeBlobPreviewUrl(previewUrl: string | undefined): void {78export function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
47 if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) {79 if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) {
48 return;80 return;
apps/web/src/components/ChatView.tsx+209-57
...@@ -173,6 +173,7 @@ import {...@@ -173,6 +173,7 @@ import {
173import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";173import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
174import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";174import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
175import {175import {
176 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
176 buildExpiredTerminalContextToastCopy,177 buildExpiredTerminalContextToastCopy,
177 buildLocalDraftThread,178 buildLocalDraftThread,
178 buildTemporaryWorktreeBranchName,179 buildTemporaryWorktreeBranchName,
...@@ -186,6 +187,7 @@ import {...@@ -186,6 +187,7 @@ import {
186 type LocalDispatchSnapshot,187 type LocalDispatchSnapshot,
187 PullRequestDialogState,188 PullRequestDialogState,
188 readFileAsDataUrl,189 readFileAsDataUrl,
190 reconcileMountedTerminalThreadIds,
189 revokeBlobPreviewUrl,191 revokeBlobPreviewUrl,
190 revokeUserMessagePreviewUrls,192 revokeUserMessagePreviewUrls,
191 threadHasStarted,193 threadHasStarted,
...@@ -404,6 +406,163 @@ function useLocalDispatchState(input: {...@@ -404,6 +406,163 @@ function useLocalDispatchState(input: {
404 };406 };
405}407}
406408
409interface PersistentThreadTerminalDrawerProps {
410 threadId: ThreadId;
411 visible: boolean;
412 focusRequestId: number;
413 splitShortcutLabel: string | undefined;
414 newShortcutLabel: string | undefined;
415 closeShortcutLabel: string | undefined;
416 onAddTerminalContext: (selection: TerminalContextSelection) => void;
417}
418
419function PersistentThreadTerminalDrawer({
420 threadId,
421 visible,
422 focusRequestId,
423 splitShortcutLabel,
424 newShortcutLabel,
425 closeShortcutLabel,
426 onAddTerminalContext,
427}: PersistentThreadTerminalDrawerProps) {
428 const serverThread = useThreadById(threadId);
429 const draftThread = useComposerDraftStore(
430 (store) => store.draftThreadsByThreadId[threadId] ?? null,
431 );
432 const project = useProjectById(serverThread?.projectId ?? draftThread?.projectId);
433 const terminalState = useTerminalStateStore((state) =>
434 selectThreadTerminalState(state.terminalStateByThreadId, threadId),
435 );
436 const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight);
437 const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal);
438 const storeNewTerminal = useTerminalStateStore((state) => state.newTerminal);
439 const storeSetActiveTerminal = useTerminalStateStore((state) => state.setActiveTerminal);
440 const storeCloseTerminal = useTerminalStateStore((state) => state.closeTerminal);
441 const [localFocusRequestId, setLocalFocusRequestId] = useState(0);
442 const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
443 const cwd = useMemo(
444 () =>
445 project
446 ? projectScriptCwd({
447 project: { cwd: project.cwd },
448 worktreePath,
449 })
450 : null,
451 [project, worktreePath],
452 );
453 const runtimeEnv = useMemo(
454 () =>
455 project
456 ? projectScriptRuntimeEnv({
457 project: { cwd: project.cwd },
458 worktreePath,
459 })
460 : {},
461 [project, worktreePath],
462 );
463
464 const bumpFocusRequestId = useCallback(() => {
465 if (!visible) {
466 return;
467 }
468 setLocalFocusRequestId((value) => value + 1);
469 }, [visible]);
470
471 const setTerminalHeight = useCallback(
472 (height: number) => {
473 storeSetTerminalHeight(threadId, height);
474 },
475 [storeSetTerminalHeight, threadId],
476 );
477
478 const splitTerminal = useCallback(() => {
479 storeSplitTerminal(threadId, `terminal-${randomUUID()}`);
480 bumpFocusRequestId();
481 }, [bumpFocusRequestId, storeSplitTerminal, threadId]);
482
483 const createNewTerminal = useCallback(() => {
484 storeNewTerminal(threadId, `terminal-${randomUUID()}`);
485 bumpFocusRequestId();
486 }, [bumpFocusRequestId, storeNewTerminal, threadId]);
487
488 const activateTerminal = useCallback(
489 (terminalId: string) => {
490 storeSetActiveTerminal(threadId, terminalId);
491 bumpFocusRequestId();
492 },
493 [bumpFocusRequestId, storeSetActiveTerminal, threadId],
494 );
495
496 const closeTerminal = useCallback(
497 (terminalId: string) => {
498 const api = readNativeApi();
499 if (!api) return;
500 const isFinalTerminal = terminalState.terminalIds.length <= 1;
501 const fallbackExitWrite = () =>
502 api.terminal.write({ threadId, terminalId, data: "exit\n" }).catch(() => undefined);
503
504 if ("close" in api.terminal && typeof api.terminal.close === "function") {
505 void (async () => {
506 if (isFinalTerminal) {
507 await api.terminal.clear({ threadId, terminalId }).catch(() => undefined);
508 }
509 await api.terminal.close({
510 threadId,
511 terminalId,
512 deleteHistory: true,
513 });
514 })().catch(() => fallbackExitWrite());
515 } else {
516 void fallbackExitWrite();
517 }
518
519 storeCloseTerminal(threadId, terminalId);
520 bumpFocusRequestId();
521 },
522 [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId],
523 );
524
525 const handleAddTerminalContext = useCallback(
526 (selection: TerminalContextSelection) => {
527 if (!visible) {
528 return;
529 }
530 onAddTerminalContext(selection);
531 },
532 [onAddTerminalContext, visible],
533 );
534
535 if (!project || !terminalState.terminalOpen || !cwd) {
536 return null;
537 }
538
539 return (
540 <div className={visible ? undefined : "hidden"}>
541 <ThreadTerminalDrawer
542 threadId={threadId}
543 cwd={cwd}
544 runtimeEnv={runtimeEnv}
545 visible={visible}
546 height={terminalState.terminalHeight}
547 terminalIds={terminalState.terminalIds}
548 activeTerminalId={terminalState.activeTerminalId}
549 terminalGroups={terminalState.terminalGroups}
550 activeTerminalGroupId={terminalState.activeTerminalGroupId}
551 focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)}
552 onSplitTerminal={splitTerminal}
553 onNewTerminal={createNewTerminal}
554 splitShortcutLabel={visible ? splitShortcutLabel : undefined}
555 newShortcutLabel={visible ? newShortcutLabel : undefined}
556 closeShortcutLabel={visible ? closeShortcutLabel : undefined}
557 onActiveTerminalChange={activateTerminal}
558 onCloseTerminal={closeTerminal}
559 onHeightChange={setTerminalHeight}
560 onAddTerminalContext={handleAddTerminalContext}
561 />
562 </div>
563 );
564}
565
407export default function ChatView({ threadId }: ChatViewProps) {566export default function ChatView({ threadId }: ChatViewProps) {
408 const serverThread = useThreadById(threadId);567 const serverThread = useThreadById(threadId);
409 const setStoreThreadError = useStore((store) => store.setError);568 const setStoreThreadError = useStore((store) => store.setError);
...@@ -565,15 +724,31 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -565,15 +724,31 @@ export default function ChatView({ threadId }: ChatViewProps) {
565 setMessagesScrollElement(element);724 setMessagesScrollElement(element);
566 }, []);725 }, []);
567726
568 const terminalState = useTerminalStateStore((state) =>727 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
569 selectThreadTerminalState(state.terminalStateByThreadId, threadId),728 const terminalState = useMemo(
729 () => selectThreadTerminalState(terminalStateByThreadId, threadId),
730 [terminalStateByThreadId, threadId],
731 );
732 const openTerminalThreadIds = useMemo(
733 () =>
734 Object.entries(terminalStateByThreadId).flatMap(([nextThreadId, nextTerminalState]) =>
735 nextTerminalState.terminalOpen ? [nextThreadId as ThreadId] : [],
736 ),
737 [terminalStateByThreadId],
570 );738 );
571 const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);739 const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);
572 const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight);
573 const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);740 const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);
574 const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal);741 const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal);
575 const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal);742 const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal);
576 const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal);743 const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal);
744 const threads = useStore((state) => state.threads);
745 const serverThreadIds = useMemo(() => threads.map((thread) => thread.id), [threads]);
746 const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId);
747 const draftThreadIds = useMemo(
748 () => Object.keys(draftThreadsByThreadId) as ThreadId[],
749 [draftThreadsByThreadId],
750 );
751 const [mountedTerminalThreadIds, setMountedTerminalThreadIds] = useState<ThreadId[]>([]);
577752
578 const setPrompt = useCallback(753 const setPrompt = useCallback(
579 (nextPrompt: string) => {754 (nextPrompt: string) => {
...@@ -655,6 +830,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -655,6 +830,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
655 const canCheckoutPullRequestIntoThread = isLocalDraftThread;830 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
656 const diffOpen = rawSearch.diff === "1";831 const diffOpen = rawSearch.diff === "1";
657 const activeThreadId = activeThread?.id ?? null;832 const activeThreadId = activeThread?.id ?? null;
833 const existingOpenTerminalThreadIds = useMemo(() => {
834 const existingThreadIds = new Set<ThreadId>([...serverThreadIds, ...draftThreadIds]);
835 return openTerminalThreadIds.filter((nextThreadId) => existingThreadIds.has(nextThreadId));
836 }, [draftThreadIds, openTerminalThreadIds, serverThreadIds]);
658 const activeLatestTurn = activeThread?.latestTurn ?? null;837 const activeLatestTurn = activeThread?.latestTurn ?? null;
659 const threadPlanCatalog = useThreadPlanCatalog(838 const threadPlanCatalog = useThreadPlanCatalog(
660 useMemo(() => {839 useMemo(() => {
...@@ -673,6 +852,21 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -673,6 +852,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
673 () => deriveLatestContextWindowSnapshot(activeThread?.activities ?? []),852 () => deriveLatestContextWindowSnapshot(activeThread?.activities ?? []),
674 [activeThread?.activities],853 [activeThread?.activities],
675 );854 );
855 useEffect(() => {
856 setMountedTerminalThreadIds((currentThreadIds) => {
857 const nextThreadIds = reconcileMountedTerminalThreadIds({
858 currentThreadIds,
859 openThreadIds: existingOpenTerminalThreadIds,
860 activeThreadId,
861 activeThreadTerminalOpen: Boolean(activeThreadId && terminalState.terminalOpen),
862 maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
863 });
864 return currentThreadIds.length === nextThreadIds.length &&
865 currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index])
866 ? currentThreadIds
867 : nextThreadIds;
868 });
869 }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]);
676 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);870 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
677 const activeProject = useProjectById(activeThread?.projectId);871 const activeProject = useProjectById(activeThread?.projectId);
678872
...@@ -1326,17 +1520,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1326,17 +1520,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
1326 () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null,1520 () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null,
1327 [selectedProvider, providerStatuses],1521 [selectedProvider, providerStatuses],
1328 );1522 );
1329 const activeProjectCwd = activeProject?.cwd ?? null;
1330 const activeThreadWorktreePath = activeThread?.worktreePath ?? null;
1331 const threadTerminalRuntimeEnv = useMemo(() => {
1332 if (!activeProjectCwd) return {};
1333 return projectScriptRuntimeEnv({
1334 project: {
1335 cwd: activeProjectCwd,
1336 },
1337 worktreePath: activeThreadWorktreePath,
1338 });
1339 }, [activeProjectCwd, activeThreadWorktreePath]);
1340 // Default true while loading to avoid toolbar flicker.1523 // Default true while loading to avoid toolbar flicker.
1341 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;1524 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
1342 const terminalShortcutLabelOptions = useMemo(1525 const terminalShortcutLabelOptions = useMemo(
...@@ -1481,13 +1664,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1481,13 +1664,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
1481 },1664 },
1482 [activeThreadId, storeSetTerminalOpen],1665 [activeThreadId, storeSetTerminalOpen],
1483 );1666 );
1484 const setTerminalHeight = useCallback(
1485 (height: number) => {
1486 if (!activeThreadId) return;
1487 storeSetTerminalHeight(activeThreadId, height);
1488 },
1489 [activeThreadId, storeSetTerminalHeight],
1490 );
1491 const toggleTerminalVisibility = useCallback(() => {1667 const toggleTerminalVisibility = useCallback(() => {
1492 if (!activeThreadId) return;1668 if (!activeThreadId) return;
1493 setTerminalOpen(!terminalState.terminalOpen);1669 setTerminalOpen(!terminalState.terminalOpen);
...@@ -1504,14 +1680,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1504,14 +1680,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
1504 storeNewTerminal(activeThreadId, terminalId);1680 storeNewTerminal(activeThreadId, terminalId);
1505 setTerminalFocusRequestId((value) => value + 1);1681 setTerminalFocusRequestId((value) => value + 1);
1506 }, [activeThreadId, storeNewTerminal]);1682 }, [activeThreadId, storeNewTerminal]);
1507 const activateTerminal = useCallback(
1508 (terminalId: string) => {
1509 if (!activeThreadId) return;
1510 storeSetActiveTerminal(activeThreadId, terminalId);
1511 setTerminalFocusRequestId((value) => value + 1);
1512 },
1513 [activeThreadId, storeSetActiveTerminal],
1514 );
1515 const closeTerminal = useCallback(1683 const closeTerminal = useCallback(
1516 (terminalId: string) => {1684 (terminalId: string) => {
1517 const api = readNativeApi();1685 const api = readNativeApi();
...@@ -4284,34 +4452,18 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -4284,34 +4452,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
4284 </div>4452 </div>
4285 {/* end horizontal flex container */}4453 {/* end horizontal flex container */}
42864454
4287 {(() => {4455 {mountedTerminalThreadIds.map((mountedThreadId) => (
4288 if (!terminalState.terminalOpen || !activeProject) {4456 <PersistentThreadTerminalDrawer
4289 return null;4457 key={mountedThreadId}
4290 }4458 threadId={mountedThreadId}
4291 return (4459 visible={mountedThreadId === activeThreadId && terminalState.terminalOpen}
4292 <ThreadTerminalDrawer4460 focusRequestId={mountedThreadId === activeThreadId ? terminalFocusRequestId : 0}
4293 key={activeThread.id}4461 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
4294 threadId={activeThread.id}4462 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
4295 cwd={gitCwd ?? activeProject.cwd}4463 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
4296 runtimeEnv={threadTerminalRuntimeEnv}4464 onAddTerminalContext={addTerminalContextToDraft}
4297 height={terminalState.terminalHeight}4465 />
4298 terminalIds={terminalState.terminalIds}4466 ))}
4299 activeTerminalId={terminalState.activeTerminalId}
4300 terminalGroups={terminalState.terminalGroups}
4301 activeTerminalGroupId={terminalState.activeTerminalGroupId}
4302 focusRequestId={terminalFocusRequestId}
4303 onSplitTerminal={splitTerminal}
4304 onNewTerminal={createNewTerminal}
4305 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
4306 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
4307 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
4308 onActiveTerminalChange={activateTerminal}
4309 onCloseTerminal={closeTerminal}
4310 onHeightChange={setTerminalHeight}
4311 onAddTerminalContext={addTerminalContextToDraft}
4312 />
4313 );
4314 })()}
43154467
4316 {expandedImage && expandedImageItem && (4468 {expandedImage && expandedImageItem && (
4317 <div4469 <div
apps/web/src/components/ThreadTerminalDrawer.test.ts+62
...@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";...@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
22
3import {3import {
4 resolveTerminalSelectionActionPosition,4 resolveTerminalSelectionActionPosition,
5 selectPendingTerminalEventEntries,
6 selectTerminalEventEntriesAfterSnapshot,
5 shouldHandleTerminalSelectionMouseUp,7 shouldHandleTerminalSelectionMouseUp,
6 terminalSelectionActionDelayForClickCount,8 terminalSelectionActionDelayForClickCount,
7} from "./ThreadTerminalDrawer";9} from "./ThreadTerminalDrawer";
...@@ -72,4 +74,64 @@ describe("resolveTerminalSelectionActionPosition", () => {...@@ -72,4 +74,64 @@ describe("resolveTerminalSelectionActionPosition", () => {
72 expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false);74 expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false);
73 expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false);75 expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false);
74 });76 });
77
78 it("replays only terminal events newer than the open snapshot", () => {
79 expect(
80 selectTerminalEventEntriesAfterSnapshot(
81 [
82 {
83 id: 1,
84 event: {
85 threadId: "thread-1",
86 terminalId: "default",
87 createdAt: "2026-04-02T20:00:00.000Z",
88 type: "output",
89 data: "before",
90 },
91 },
92 {
93 id: 2,
94 event: {
95 threadId: "thread-1",
96 terminalId: "default",
97 createdAt: "2026-04-02T20:00:01.000Z",
98 type: "output",
99 data: "after",
100 },
101 },
102 ],
103 "2026-04-02T20:00:00.500Z",
104 ).map((entry) => entry.id),
105 ).toEqual([2]);
106 });
107
108 it("applies only terminal events that have not already been consumed", () => {
109 expect(
110 selectPendingTerminalEventEntries(
111 [
112 {
113 id: 1,
114 event: {
115 threadId: "thread-1",
116 terminalId: "default",
117 createdAt: "2026-04-02T20:00:00.000Z",
118 type: "output",
119 data: "one",
120 },
121 },
122 {
123 id: 2,
124 event: {
125 threadId: "thread-1",
126 terminalId: "default",
127 createdAt: "2026-04-02T20:00:01.000Z",
128 type: "output",
129 data: "two",
130 },
131 },
132 ],
133 1,
134 ).map((entry) => entry.id),
135 ).toEqual([2]);
136 });
75});137});
apps/web/src/components/ThreadTerminalDrawer.tsx+165-78
...@@ -1,12 +1,17 @@...@@ -1,12 +1,17 @@
1import { FitAddon } from "@xterm/addon-fit";1import { FitAddon } from "@xterm/addon-fit";
2import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react";2import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react";
3import { type ThreadId } from "@t3tools/contracts";3import {
4 type TerminalEvent,
5 type TerminalSessionSnapshot,
6 type ThreadId,
7} from "@t3tools/contracts";
4import { Terminal, type ITheme } from "@xterm/xterm";8import { Terminal, type ITheme } from "@xterm/xterm";
5import {9import {
6 type PointerEvent as ReactPointerEvent,10 type PointerEvent as ReactPointerEvent,
7 type ReactNode,11 type ReactNode,
8 useCallback,12 useCallback,
9 useEffect,13 useEffect,
14 useEffectEvent,
10 useMemo,15 useMemo,
11 useRef,16 useRef,
12 useState,17 useState,
...@@ -27,6 +32,7 @@ import {...@@ -27,6 +32,7 @@ import {
27 type ThreadTerminalGroup,32 type ThreadTerminalGroup,
28} from "../types";33} from "../types";
29import { readNativeApi } from "~/nativeApi";34import { readNativeApi } from "~/nativeApi";
35import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore";
3036
31const MIN_DRAWER_HEIGHT = 180;37const MIN_DRAWER_HEIGHT = 180;
32const MAX_DRAWER_HEIGHT_RATIO = 0.75;38const MAX_DRAWER_HEIGHT_RATIO = 0.75;
...@@ -47,6 +53,27 @@ function writeSystemMessage(terminal: Terminal, message: string): void {...@@ -47,6 +53,27 @@ function writeSystemMessage(terminal: Terminal, message: string): void {
47 terminal.write(`\r\n[terminal] ${message}\r\n`);53 terminal.write(`\r\n[terminal] ${message}\r\n`);
48}54}
4955
56function writeTerminalSnapshot(terminal: Terminal, snapshot: TerminalSessionSnapshot): void {
57 terminal.write("\u001bc");
58 if (snapshot.history.length > 0) {
59 terminal.write(snapshot.history);
60 }
61}
62
63export function selectTerminalEventEntriesAfterSnapshot(
64 entries: ReadonlyArray<{ id: number; event: TerminalEvent }>,
65 snapshotUpdatedAt: string,
66): ReadonlyArray<{ id: number; event: TerminalEvent }> {
67 return entries.filter((entry) => entry.event.createdAt > snapshotUpdatedAt);
68}
69
70export function selectPendingTerminalEventEntries(
71 entries: ReadonlyArray<{ id: number; event: TerminalEvent }>,
72 lastAppliedTerminalEventId: number,
73): ReadonlyArray<{ id: number; event: TerminalEvent }> {
74 return entries.filter((entry) => entry.id > lastAppliedTerminalEventId);
75}
76
50function terminalThemeFromApp(): ITheme {77function terminalThemeFromApp(): ITheme {
51 const isDark = document.documentElement.classList.contains("dark");78 const isDark = document.documentElement.classList.contains("dark");
52 const bodyStyles = getComputedStyle(document.body);79 const bodyStyles = getComputedStyle(document.body);
...@@ -210,27 +237,21 @@ function TerminalViewport({...@@ -210,27 +237,21 @@ function TerminalViewport({
210 const containerRef = useRef<HTMLDivElement>(null);237 const containerRef = useRef<HTMLDivElement>(null);
211 const terminalRef = useRef<Terminal | null>(null);238 const terminalRef = useRef<Terminal | null>(null);
212 const fitAddonRef = useRef<FitAddon | null>(null);239 const fitAddonRef = useRef<FitAddon | null>(null);
213 const onSessionExitedRef = useRef(onSessionExited);
214 const onAddTerminalContextRef = useRef(onAddTerminalContext);
215 const terminalLabelRef = useRef(terminalLabel);
216 const hasHandledExitRef = useRef(false);240 const hasHandledExitRef = useRef(false);
217 const selectionPointerRef = useRef<{ x: number; y: number } | null>(null);241 const selectionPointerRef = useRef<{ x: number; y: number } | null>(null);
218 const selectionGestureActiveRef = useRef(false);242 const selectionGestureActiveRef = useRef(false);
219 const selectionActionRequestIdRef = useRef(0);243 const selectionActionRequestIdRef = useRef(0);
220 const selectionActionOpenRef = useRef(false);244 const selectionActionOpenRef = useRef(false);
221 const selectionActionTimerRef = useRef<number | null>(null);245 const selectionActionTimerRef = useRef<number | null>(null);
222246 const lastAppliedTerminalEventIdRef = useRef(0);
223 useEffect(() => {247 const terminalHydratedRef = useRef(false);
224 onSessionExitedRef.current = onSessionExited;248 const handleSessionExited = useEffectEvent(() => {
225 }, [onSessionExited]);249 onSessionExited();
226250 });
227 useEffect(() => {251 const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => {
228 onAddTerminalContextRef.current = onAddTerminalContext;252 onAddTerminalContext(selection);
229 }, [onAddTerminalContext]);253 });
230254 const readTerminalLabel = useEffectEvent(() => terminalLabel);
231 useEffect(() => {
232 terminalLabelRef.current = terminalLabel;
233 }, [terminalLabel]);
234255
235 useEffect(() => {256 useEffect(() => {
236 const mount = containerRef.current;257 const mount = containerRef.current;
...@@ -297,7 +318,7 @@ function TerminalViewport({...@@ -297,7 +318,7 @@ function TerminalViewport({
297 position,318 position,
298 selection: {319 selection: {
299 terminalId,320 terminalId,
300 terminalLabel: terminalLabelRef.current,321 terminalLabel: readTerminalLabel(),
301 lineStart,322 lineStart,
302 lineEnd,323 lineEnd,
303 text: normalizedText,324 text: normalizedText,
...@@ -324,7 +345,7 @@ function TerminalViewport({...@@ -324,7 +345,7 @@ function TerminalViewport({
324 if (requestId !== selectionActionRequestIdRef.current || clicked !== "add-to-chat") {345 if (requestId !== selectionActionRequestIdRef.current || clicked !== "add-to-chat") {
325 return;346 return;
326 }347 }
327 onAddTerminalContextRef.current(nextAction.selection);348 handleAddTerminalContext(nextAction.selection);
328 terminalRef.current?.clearSelection();349 terminalRef.current?.clearSelection();
329 terminalRef.current?.focus();350 terminalRef.current?.focus();
330 } finally {351 } finally {
...@@ -469,43 +490,15 @@ function TerminalViewport({...@@ -469,43 +490,15 @@ function TerminalViewport({
469 attributeFilter: ["class", "style"],490 attributeFilter: ["class", "style"],
470 });491 });
471492
472 const openTerminal = async () => {493 const applyTerminalEvent = (event: TerminalEvent) => {
473 try {494 const activeTerminal = terminalRef.current;
474 const activeTerminal = terminalRef.current;495 if (!activeTerminal) {
475 const activeFitAddon = fitAddonRef.current;496 return;
476 if (!activeTerminal || !activeFitAddon) return;
477 activeFitAddon.fit();
478 const snapshot = await api.terminal.open({
479 threadId,
480 terminalId,
481 cwd,
482 cols: activeTerminal.cols,
483 rows: activeTerminal.rows,
484 ...(runtimeEnv ? { env: runtimeEnv } : {}),
485 });
486 if (disposed) return;
487 activeTerminal.write("\u001bc");
488 if (snapshot.history.length > 0) {
489 activeTerminal.write(snapshot.history);
490 }
491 if (autoFocus) {
492 window.requestAnimationFrame(() => {
493 activeTerminal.focus();
494 });
495 }
496 } catch (err) {
497 if (disposed) return;
498 writeSystemMessage(
499 terminal,
500 err instanceof Error ? err.message : "Failed to open terminal",
501 );
502 }497 }
503 };
504498
505 const unsubscribe = api?.terminal.onEvent((event) => {499 if (event.type === "activity") {
506 if (event.threadId !== threadId || event.terminalId !== terminalId) return;500 return;
507 const activeTerminal = terminalRef.current;501 }
508 if (!activeTerminal) return;
509502
510 if (event.type === "output") {503 if (event.type === "output") {
511 activeTerminal.write(event.data);504 activeTerminal.write(event.data);
...@@ -516,10 +509,7 @@ function TerminalViewport({...@@ -516,10 +509,7 @@ function TerminalViewport({
516 if (event.type === "started" || event.type === "restarted") {509 if (event.type === "started" || event.type === "restarted") {
517 hasHandledExitRef.current = false;510 hasHandledExitRef.current = false;
518 clearSelectionAction();511 clearSelectionAction();
519 activeTerminal.write("\u001bc");512 writeTerminalSnapshot(activeTerminal, event.snapshot);
520 if (event.snapshot.history.length > 0) {
521 activeTerminal.write(event.snapshot.history);
522 }
523 return;513 return;
524 }514 }
525515
...@@ -535,30 +525,112 @@ function TerminalViewport({...@@ -535,30 +525,112 @@ function TerminalViewport({
535 return;525 return;
536 }526 }
537527
538 if (event.type === "exited") {528 const details = [
539 const details = [529 typeof event.exitCode === "number" ? `code ${event.exitCode}` : null,
540 typeof event.exitCode === "number" ? `code ${event.exitCode}` : null,530 typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null,
541 typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null,531 ]
542 ]532 .filter((value): value is string => value !== null)
543 .filter((value): value is string => value !== null)533 .join(", ");
544 .join(", ");534 writeSystemMessage(
545 writeSystemMessage(535 activeTerminal,
546 activeTerminal,536 details.length > 0 ? `Process exited (${details})` : "Process exited",
547 details.length > 0 ? `Process exited (${details})` : "Process exited",537 );
548 );538 if (hasHandledExitRef.current) {
549 if (hasHandledExitRef.current) {539 return;
540 }
541 hasHandledExitRef.current = true;
542 window.setTimeout(() => {
543 if (!hasHandledExitRef.current) {
550 return;544 return;
551 }545 }
552 hasHandledExitRef.current = true;546 handleSessionExited();
553 window.setTimeout(() => {547 }, 0);
554 if (!hasHandledExitRef.current) {548 };
555 return;549 const applyPendingTerminalEvents = (
556 }550 terminalEventEntries: ReadonlyArray<{ id: number; event: TerminalEvent }>,
557 onSessionExitedRef.current();551 ) => {
558 }, 0);552 const pendingEntries = selectPendingTerminalEventEntries(
553 terminalEventEntries,
554 lastAppliedTerminalEventIdRef.current,
555 );
556 if (pendingEntries.length === 0) {
557 return;
558 }
559 for (const entry of pendingEntries) {
560 applyTerminalEvent(entry.event);
561 }
562 lastAppliedTerminalEventIdRef.current =
563 pendingEntries.at(-1)?.id ?? lastAppliedTerminalEventIdRef.current;
564 };
565
566 const unsubscribeTerminalEvents = useTerminalStateStore.subscribe((state, previousState) => {
567 if (!terminalHydratedRef.current) {
568 return;
559 }569 }
570
571 const previousLastEntryId =
572 selectTerminalEventEntries(
573 previousState.terminalEventEntriesByKey,
574 threadId,
575 terminalId,
576 ).at(-1)?.id ?? 0;
577 const nextEntries = selectTerminalEventEntries(
578 state.terminalEventEntriesByKey,
579 threadId,
580 terminalId,
581 );
582 const nextLastEntryId = nextEntries.at(-1)?.id ?? 0;
583 if (nextLastEntryId === previousLastEntryId) {
584 return;
585 }
586
587 applyPendingTerminalEvents(nextEntries);
560 });588 });
561589
590 const openTerminal = async () => {
591 try {
592 const activeTerminal = terminalRef.current;
593 const activeFitAddon = fitAddonRef.current;
594 if (!activeTerminal || !activeFitAddon) return;
595 activeFitAddon.fit();
596 const snapshot = await api.terminal.open({
597 threadId,
598 terminalId,
599 cwd,
600 cols: activeTerminal.cols,
601 rows: activeTerminal.rows,
602 ...(runtimeEnv ? { env: runtimeEnv } : {}),
603 });
604 if (disposed) return;
605 writeTerminalSnapshot(activeTerminal, snapshot);
606 const bufferedEntries = selectTerminalEventEntries(
607 useTerminalStateStore.getState().terminalEventEntriesByKey,
608 threadId,
609 terminalId,
610 );
611 const replayEntries = selectTerminalEventEntriesAfterSnapshot(
612 bufferedEntries,
613 snapshot.updatedAt,
614 );
615 for (const entry of replayEntries) {
616 applyTerminalEvent(entry.event);
617 }
618 lastAppliedTerminalEventIdRef.current = bufferedEntries.at(-1)?.id ?? 0;
619 terminalHydratedRef.current = true;
620 if (autoFocus) {
621 window.requestAnimationFrame(() => {
622 activeTerminal.focus();
623 });
624 }
625 } catch (err) {
626 if (disposed) return;
627 writeSystemMessage(
628 terminal,
629 err instanceof Error ? err.message : "Failed to open terminal",
630 );
631 }
632 };
633
562 const fitTimer = window.setTimeout(() => {634 const fitTimer = window.setTimeout(() => {
563 const activeTerminal = terminalRef.current;635 const activeTerminal = terminalRef.current;
564 const activeFitAddon = fitAddonRef.current;636 const activeFitAddon = fitAddonRef.current;
...@@ -582,8 +654,10 @@ function TerminalViewport({...@@ -582,8 +654,10 @@ function TerminalViewport({
582654
583 return () => {655 return () => {
584 disposed = true;656 disposed = true;
657 terminalHydratedRef.current = false;
658 lastAppliedTerminalEventIdRef.current = 0;
659 unsubscribeTerminalEvents();
585 window.clearTimeout(fitTimer);660 window.clearTimeout(fitTimer);
586 unsubscribe();
587 inputDisposable.dispose();661 inputDisposable.dispose();
588 selectionDisposable.dispose();662 selectionDisposable.dispose();
589 terminalLinksDisposable.dispose();663 terminalLinksDisposable.dispose();
...@@ -647,6 +721,7 @@ interface ThreadTerminalDrawerProps {...@@ -647,6 +721,7 @@ interface ThreadTerminalDrawerProps {
647 threadId: ThreadId;721 threadId: ThreadId;
648 cwd: string;722 cwd: string;
649 runtimeEnv?: Record<string, string>;723 runtimeEnv?: Record<string, string>;
724 visible?: boolean;
650 height: number;725 height: number;
651 terminalIds: string[];726 terminalIds: string[];
652 activeTerminalId: string;727 activeTerminalId: string;
...@@ -697,6 +772,7 @@ export default function ThreadTerminalDrawer({...@@ -697,6 +772,7 @@ export default function ThreadTerminalDrawer({
697 threadId,772 threadId,
698 cwd,773 cwd,
699 runtimeEnv,774 runtimeEnv,
775 visible = true,
700 height,776 height,
701 terminalIds,777 terminalIds,
702 activeTerminalId,778 activeTerminalId,
...@@ -911,6 +987,10 @@ export default function ThreadTerminalDrawer({...@@ -911,6 +987,10 @@ export default function ThreadTerminalDrawer({
911 );987 );
912988
913 useEffect(() => {989 useEffect(() => {
990 if (!visible) {
991 return;
992 }
993
914 const onWindowResize = () => {994 const onWindowResize = () => {
915 const clampedHeight = clampDrawerHeight(drawerHeightRef.current);995 const clampedHeight = clampDrawerHeight(drawerHeightRef.current);
916 const changed = clampedHeight !== drawerHeightRef.current;996 const changed = clampedHeight !== drawerHeightRef.current;
...@@ -927,7 +1007,14 @@ export default function ThreadTerminalDrawer({...@@ -927,7 +1007,14 @@ export default function ThreadTerminalDrawer({
927 return () => {1007 return () => {
928 window.removeEventListener("resize", onWindowResize);1008 window.removeEventListener("resize", onWindowResize);
929 };1009 };
930 }, [syncHeight]);1010 }, [syncHeight, visible]);
1011
1012 useEffect(() => {
1013 if (!visible) {
1014 return;
1015 }
1016 setResizeEpoch((value) => value + 1);
1017 }, [visible]);
9311018
932 useEffect(() => {1019 useEffect(() => {
933 return () => {1020 return () => {
apps/web/src/orchestrationRecovery.test.ts+33
...@@ -97,4 +97,37 @@ describe("createOrchestrationRecoveryCoordinator", () => {...@@ -97,4 +97,37 @@ describe("createOrchestrationRecoveryCoordinator", () => {
97 reason: "replay-failed",97 reason: "replay-failed",
98 });98 });
99 });99 });
100
101 it("keeps enough state to explain why bootstrap snapshot recovery requests replay", () => {
102 const coordinator = createOrchestrationRecoveryCoordinator();
103
104 expect(coordinator.beginSnapshotRecovery("bootstrap")).toBe(true);
105 expect(coordinator.classifyDomainEvent(4)).toBe("defer");
106 expect(coordinator.completeSnapshotRecovery(2)).toBe(true);
107
108 expect(coordinator.getState()).toMatchObject({
109 latestSequence: 2,
110 highestObservedSequence: 4,
111 bootstrapped: true,
112 pendingReplay: false,
113 inFlight: null,
114 });
115 });
116
117 it("reports skip state when snapshot recovery is requested while replay is in flight", () => {
118 const coordinator = createOrchestrationRecoveryCoordinator();
119
120 coordinator.beginSnapshotRecovery("bootstrap");
121 coordinator.completeSnapshotRecovery(3);
122 expect(coordinator.beginReplayRecovery("sequence-gap")).toBe(true);
123
124 expect(coordinator.beginSnapshotRecovery("bootstrap")).toBe(false);
125 expect(coordinator.getState()).toMatchObject({
126 pendingReplay: true,
127 inFlight: {
128 kind: "replay",
129 reason: "sequence-gap",
130 },
131 });
132 });
100});133});
apps/web/src/orchestrationRecovery.ts+11-6
...@@ -34,11 +34,16 @@ export function createOrchestrationRecoveryCoordinator() {...@@ -34,11 +34,16 @@ export function createOrchestrationRecoveryCoordinator() {
34 state.highestObservedSequence = Math.max(state.highestObservedSequence, sequence);34 state.highestObservedSequence = Math.max(state.highestObservedSequence, sequence);
35 };35 };
3636
37 const shouldReplayAfterRecovery = (): boolean => {37 const resolveReplayNeedAfterRecovery = () => {
38 const shouldReplay =38 const pendingReplayBeforeReset = state.pendingReplay;
39 state.pendingReplay || state.highestObservedSequence > state.latestSequence;39 const observedAhead = state.highestObservedSequence > state.latestSequence;
40 const shouldReplay = pendingReplayBeforeReset || observedAhead;
40 state.pendingReplay = false;41 state.pendingReplay = false;
41 return shouldReplay;42 return {
43 shouldReplay,
44 pendingReplayBeforeReset,
45 observedAhead,
46 };
42 };47 };
4348
44 return {49 return {
...@@ -93,7 +98,7 @@ export function createOrchestrationRecoveryCoordinator() {...@@ -93,7 +98,7 @@ export function createOrchestrationRecoveryCoordinator() {
93 state.highestObservedSequence = Math.max(state.highestObservedSequence, state.latestSequence);98 state.highestObservedSequence = Math.max(state.highestObservedSequence, state.latestSequence);
94 state.bootstrapped = true;99 state.bootstrapped = true;
95 state.inFlight = null;100 state.inFlight = null;
96 return shouldReplayAfterRecovery();101 return resolveReplayNeedAfterRecovery().shouldReplay;
97 },102 },
98103
99 failSnapshotRecovery(): void {104 failSnapshotRecovery(): void {
...@@ -124,7 +129,7 @@ export function createOrchestrationRecoveryCoordinator() {...@@ -124,7 +129,7 @@ export function createOrchestrationRecoveryCoordinator() {
124 state.pendingReplay = false;129 state.pendingReplay = false;
125 return false;130 return false;
126 }131 }
127 return shouldReplayAfterRecovery();132 return resolveReplayNeedAfterRecovery().shouldReplay;
128 },133 },
129134
130 failReplayRecovery(): void {135 failReplayRecovery(): void {
apps/web/src/routes/__root.tsx+45-34
...@@ -22,12 +22,12 @@ import { resolveAndPersistPreferredEditor } from "../editorPreferences";...@@ -22,12 +22,12 @@ import { resolveAndPersistPreferredEditor } from "../editorPreferences";
22import { readNativeApi } from "../nativeApi";22import { readNativeApi } from "../nativeApi";
23import {23import {
24 getServerConfigUpdatedNotification,24 getServerConfigUpdatedNotification,
25 type ServerConfigUpdateSource,25 ServerConfigUpdatedNotification,
26 startServerStateSync,
26 useServerConfig,27 useServerConfig,
27 useServerConfigUpdatedSubscription,28 useServerConfigUpdatedSubscription,
28 useServerWelcomeSubscription,29 useServerWelcomeSubscription,
29} from "../rpc/serverState";30} from "../rpc/serverState";
30import { ServerStateBootstrap } from "../rpc/serverStateBootstrap";
31import {31import {
32 clearPromotedDraftThread,32 clearPromotedDraftThread,
33 clearPromotedDraftThreads,33 clearPromotedDraftThreads,
...@@ -43,6 +43,7 @@ import { projectQueryKeys } from "../lib/projectReactQuery";...@@ -43,6 +43,7 @@ import { projectQueryKeys } from "../lib/projectReactQuery";
43import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup";43import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup";
44import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects";44import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects";
45import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery";45import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery";
46import { getWsRpcClient } from "~/wsRpcClient";
4647
47export const Route = createRootRouteWithContext<{48export const Route = createRootRouteWithContext<{
48 queryClient: QueryClient;49 queryClient: QueryClient;
...@@ -68,18 +69,15 @@ function RootRouteView() {...@@ -68,18 +69,15 @@ function RootRouteView() {
68 }69 }
6970
70 return (71 return (
71 <>72 <ToastProvider>
72 <ServerStateBootstrap />73 <AnchoredToastProvider>
73 <ToastProvider>74 <ServerStateBootstrap />
74 <AnchoredToastProvider>75 <EventRouter />
75 <EventRouter />76 <AppSidebarLayout>
76 <DesktopProjectBootstrap />77 <Outlet />
77 <AppSidebarLayout>78 </AppSidebarLayout>
78 <Outlet />79 </AnchoredToastProvider>
79 </AppSidebarLayout>80 </ToastProvider>
80 </AnchoredToastProvider>
81 </ToastProvider>
82 </>
83 );81 );
84}82}
8583
...@@ -191,6 +189,12 @@ function coalesceOrchestrationUiEvents(...@@ -191,6 +189,12 @@ function coalesceOrchestrationUiEvents(
191 return coalesced;189 return coalesced;
192}190}
193191
192function ServerStateBootstrap() {
193 useEffect(() => startServerStateSync(getWsRpcClient().server), []);
194
195 return null;
196}
197
194function EventRouter() {198function EventRouter() {
195 const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents);199 const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents);
196 const syncServerReadModel = useStore((store) => store.syncServerReadModel);200 const syncServerReadModel = useStore((store) => store.syncServerReadModel);
...@@ -205,16 +209,16 @@ function EventRouter() {...@@ -205,16 +209,16 @@ function EventRouter() {
205 const queryClient = useQueryClient();209 const queryClient = useQueryClient();
206 const navigate = useNavigate();210 const navigate = useNavigate();
207 const pathname = useLocation({ select: (loc) => loc.pathname });211 const pathname = useLocation({ select: (loc) => loc.pathname });
208 const pathnameRef = useRef(pathname);212 const readPathname = useEffectEvent(() => pathname);
209 const handledBootstrapThreadIdRef = useRef<string | null>(null);213 const handledBootstrapThreadIdRef = useRef<string | null>(null);
210 const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0);214 const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0);
211 const disposedRef = useRef(false);215 const disposedRef = useRef(false);
212 const bootstrapFromSnapshotRef = useRef<() => Promise<void>>(async () => undefined);216 const bootstrapFromSnapshotRef = useRef<() => Promise<void>>(async () => undefined);
213 const serverConfig = useServerConfig();217 const serverConfig = useServerConfig();
214218
215 pathnameRef.current = pathname;219 const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => {
220 if (!payload) return;
216221
217 const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload) => {
218 migrateLocalSettingsToServer();222 migrateLocalSettingsToServer();
219 void (async () => {223 void (async () => {
220 await bootstrapFromSnapshotRef.current();224 await bootstrapFromSnapshotRef.current();
...@@ -227,7 +231,7 @@ function EventRouter() {...@@ -227,7 +231,7 @@ function EventRouter() {
227 }231 }
228 setProjectExpanded(payload.bootstrapProjectId, true);232 setProjectExpanded(payload.bootstrapProjectId, true);
229233
230 if (pathnameRef.current !== "/") {234 if (readPathname() !== "/") {
231 return;235 return;
232 }236 }
233 if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) {237 if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) {
...@@ -243,15 +247,10 @@ function EventRouter() {...@@ -243,15 +247,10 @@ function EventRouter() {
243 });247 });
244248
245 const handleServerConfigUpdated = useEffectEvent(249 const handleServerConfigUpdated = useEffectEvent(
246 ({250 (notification: ServerConfigUpdatedNotification | null) => {
247 id,251 if (!notification) return;
248 payload,252
249 source,253 const { id, payload, source } = notification;
250 }: {
251 readonly id: number;
252 readonly payload: import("@t3tools/contracts").ServerConfigUpdatedPayload;
253 readonly source: ServerConfigUpdateSource;
254 }) => {
255 if (id <= seenServerConfigUpdateIdRef.current) {254 if (id <= seenServerConfigUpdateIdRef.current) {
256 return;255 return;
257 }256 }
...@@ -425,8 +424,9 @@ function EventRouter() {...@@ -425,8 +424,9 @@ function EventRouter() {
425 return;424 return;
426 }425 }
427426
427 const fromSequenceExclusive = recovery.getState().latestSequence;
428 try {428 try {
429 const events = await api.orchestration.replayEvents(recovery.getState().latestSequence);429 const events = await api.orchestration.replayEvents(fromSequenceExclusive);
430 if (!disposed) {430 if (!disposed) {
431 applyEventBatch(events);431 applyEventBatch(events);
432 }432 }
...@@ -442,7 +442,22 @@ function EventRouter() {...@@ -442,7 +442,22 @@ function EventRouter() {
442 };442 };
443443
444 const runSnapshotRecovery = async (reason: "bootstrap" | "replay-failed"): Promise<void> => {444 const runSnapshotRecovery = async (reason: "bootstrap" | "replay-failed"): Promise<void> => {
445 if (!recovery.beginSnapshotRecovery(reason)) {445 const started = recovery.beginSnapshotRecovery(reason);
446 if (import.meta.env.MODE !== "test") {
447 const state = recovery.getState();
448 console.info("[orchestration-recovery]", "Snapshot recovery requested.", {
449 reason,
450 skipped: !started,
451 ...(started
452 ? {}
453 : {
454 blockedBy: state.inFlight?.kind ?? null,
455 blockedByReason: state.inFlight?.reason ?? null,
456 }),
457 state,
458 });
459 }
460 if (!started) {
446 return;461 return;
447 }462 }
448463
...@@ -482,6 +497,7 @@ function EventRouter() {...@@ -482,6 +497,7 @@ function EventRouter() {
482 }497 }
483 });498 });
484 const unsubTerminalEvent = api.terminal.onEvent((event) => {499 const unsubTerminalEvent = api.terminal.onEvent((event) => {
500 useTerminalStateStore.getState().recordTerminalEvent(event);
485 const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event);501 const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event);
486 if (hasRunningSubprocess === null) {502 if (hasRunningSubprocess === null) {
487 return;503 return;
...@@ -522,8 +538,3 @@ function EventRouter() {...@@ -522,8 +538,3 @@ function EventRouter() {
522538
523 return null;539 return null;
524}540}
525
526function DesktopProjectBootstrap() {
527 // Desktop hydration runs through EventRouter project + orchestration sync.
528 return null;
529}
apps/web/src/rpc/serverState.ts+13-11
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 type ServerSettings,11 type ServerSettings,
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import { Atom } from "effect/unstable/reactivity";13import { Atom } from "effect/unstable/reactivity";
14import { useCallback, useRef } from "react";
1415
15import type { WsRpcClient } from "../wsRpcClient";16import type { WsRpcClient } from "../wsRpcClient";
16import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry";17import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry";
...@@ -242,17 +243,18 @@ function subscribeLatest<A>(...@@ -242,17 +243,18 @@ function subscribeLatest<A>(
242function useLatestAtomSubscription<A>(243function useLatestAtomSubscription<A>(
243 atom: Atom.Atom<A | null>,244 atom: Atom.Atom<A | null>,
244 listener: (value: NonNullable<A>) => void,245 listener: (value: NonNullable<A>) => void,
245) {246): void {
246 useAtomSubscribe(247 const listenerRef = useRef(listener);
247 atom,248 listenerRef.current = listener;
248 (value) => {249
249 if (value === null) {250 const stableListener = useCallback((value: A | null) => {
250 return;251 if (value === null) {
251 }252 return;
252 listener(value as NonNullable<A>);253 }
253 },254 listenerRef.current(value as NonNullable<A>);
254 { immediate: true },255 }, []);
255 );256
257 useAtomSubscribe(atom, stableListener, { immediate: true });
256}258}
257259
258export function useServerConfig(): ServerConfig | null {260export function useServerConfig(): ServerConfig | null {
apps/web/src/rpc/serverStateBootstrap.tsx deleted-10
...@@ -1,10 +0,0 @@
1import { useEffect } from "react";
2
3import { getWsRpcClient } from "../wsRpcClient";
4import { startServerStateSync } from "./serverState";
5
6export function ServerStateBootstrap() {
7 useEffect(() => startServerStateSync(getWsRpcClient().server), []);
8
9 return null;
10}
apps/web/src/terminalStateStore.test.ts+92-3
...@@ -1,14 +1,64 @@...@@ -1,14 +1,64 @@
1import { ThreadId } from "@t3tools/contracts";1import { ThreadId, type TerminalEvent } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it } from "vitest";2import { beforeEach, describe, expect, it } from "vitest";
33
4import { selectThreadTerminalState, useTerminalStateStore } from "./terminalStateStore";4import {
5 selectTerminalEventEntries,
6 selectThreadTerminalState,
7 useTerminalStateStore,
8} from "./terminalStateStore";
59
6const THREAD_ID = ThreadId.makeUnsafe("thread-1");10const THREAD_ID = ThreadId.makeUnsafe("thread-1");
711
12function makeTerminalEvent(
13 type: TerminalEvent["type"],
14 overrides: Partial<TerminalEvent> = {},
15): TerminalEvent {
16 const base = {
17 threadId: THREAD_ID,
18 terminalId: "default",
19 createdAt: "2026-04-02T20:00:00.000Z",
20 };
21
22 switch (type) {
23 case "output":
24 return { ...base, type, data: "hello\n", ...overrides } as TerminalEvent;
25 case "activity":
26 return { ...base, type, hasRunningSubprocess: true, ...overrides } as TerminalEvent;
27 case "error":
28 return { ...base, type, message: "boom", ...overrides } as TerminalEvent;
29 case "cleared":
30 return { ...base, type, ...overrides } as TerminalEvent;
31 case "exited":
32 return { ...base, type, exitCode: 0, exitSignal: null, ...overrides } as TerminalEvent;
33 case "started":
34 case "restarted":
35 return {
36 ...base,
37 type,
38 snapshot: {
39 threadId: THREAD_ID,
40 terminalId: "default",
41 cwd: "/tmp/workspace",
42 status: "running",
43 pid: 123,
44 history: "",
45 exitCode: null,
46 exitSignal: null,
47 updatedAt: "2026-04-02T20:00:00.000Z",
48 },
49 ...overrides,
50 } as TerminalEvent;
51 }
52}
53
8describe("terminalStateStore actions", () => {54describe("terminalStateStore actions", () => {
9 beforeEach(() => {55 beforeEach(() => {
10 useTerminalStateStore.persist.clearStorage();56 useTerminalStateStore.persist.clearStorage();
11 useTerminalStateStore.setState({ terminalStateByThreadId: {} });57 useTerminalStateStore.setState({
58 terminalStateByThreadId: {},
59 terminalEventEntriesByKey: {},
60 nextTerminalEventId: 1,
61 });
12 });62 });
1363
14 it("returns a closed default terminal state for unknown threads", () => {64 it("returns a closed default terminal state for unknown threads", () => {
...@@ -152,4 +202,43 @@ describe("terminalStateStore actions", () => {...@@ -152,4 +202,43 @@ describe("terminalStateStore actions", () => {
152 { id: "group-default", terminalIds: ["default", "terminal-2"] },202 { id: "group-default", terminalIds: ["default", "terminal-2"] },
153 ]);203 ]);
154 });204 });
205
206 it("buffers terminal events outside persisted terminal UI state", () => {
207 const store = useTerminalStateStore.getState();
208 store.recordTerminalEvent(makeTerminalEvent("output"));
209 store.recordTerminalEvent(makeTerminalEvent("activity"));
210
211 const entries = selectTerminalEventEntries(
212 useTerminalStateStore.getState().terminalEventEntriesByKey,
213 THREAD_ID,
214 "default",
215 );
216
217 expect(entries).toHaveLength(2);
218 expect(entries.map((entry) => entry.id)).toEqual([1, 2]);
219 expect(entries.map((entry) => entry.event.type)).toEqual(["output", "activity"]);
220 });
221
222 it("clears buffered terminal events when a thread terminal state is removed", () => {
223 const store = useTerminalStateStore.getState();
224 store.recordTerminalEvent(makeTerminalEvent("output"));
225 store.removeTerminalState(THREAD_ID);
226
227 const entries = selectTerminalEventEntries(
228 useTerminalStateStore.getState().terminalEventEntriesByKey,
229 THREAD_ID,
230 "default",
231 );
232
233 expect(entries).toEqual([]);
234 });
235
236 it("is a no-op when clearing terminal state for a thread with no state or buffered events", () => {
237 const store = useTerminalStateStore.getState();
238 const before = useTerminalStateStore.getState();
239
240 store.clearTerminalState(THREAD_ID);
241
242 expect(useTerminalStateStore.getState()).toBe(before);
243 });
155});244});
apps/web/src/terminalStateStore.ts+105-6
...@@ -5,7 +5,7 @@...@@ -5,7 +5,7 @@
5 * API constrained to store actions/selectors.5 * API constrained to store actions/selectors.
6 */6 */
77
8import type { ThreadId } from "@t3tools/contracts";8import type { TerminalEvent, ThreadId } from "@t3tools/contracts";
9import { create } from "zustand";9import { create } from "zustand";
10import { createJSONStorage, persist } from "zustand/middleware";10import { createJSONStorage, persist } from "zustand/middleware";
11import { resolveStorage } from "./lib/storage";11import { resolveStorage } from "./lib/storage";
...@@ -26,7 +26,14 @@ interface ThreadTerminalState {...@@ -26,7 +26,14 @@ interface ThreadTerminalState {
26 activeTerminalGroupId: string;26 activeTerminalGroupId: string;
27}27}
2828
29export interface TerminalEventEntry {
30 id: number;
31 event: TerminalEvent;
32}
33
29const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";34const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
35const EMPTY_TERMINAL_EVENT_ENTRIES: ReadonlyArray<TerminalEventEntry> = [];
36const MAX_TERMINAL_EVENT_BUFFER = 200;
3037
31function createTerminalStateStorage() {38function createTerminalStateStorage() {
32 return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined);39 return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined);
...@@ -227,6 +234,10 @@ function isValidTerminalId(terminalId: string): boolean {...@@ -227,6 +234,10 @@ function isValidTerminalId(terminalId: string): boolean {
227 return terminalId.trim().length > 0;234 return terminalId.trim().length > 0;
228}235}
229236
237function terminalEventBufferKey(threadId: ThreadId, terminalId: string): string {
238 return `${threadId}\u0000${terminalId}`;
239}
240
230function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] {241function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] {
231 return groups.map((group) => ({242 return groups.map((group) => ({
232 id: group.id,243 id: group.id,
...@@ -471,8 +482,24 @@ function updateTerminalStateByThreadId(...@@ -471,8 +482,24 @@ function updateTerminalStateByThreadId(
471 };482 };
472}483}
473484
485export function selectTerminalEventEntries(
486 terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>,
487 threadId: ThreadId,
488 terminalId: string,
489): ReadonlyArray<TerminalEventEntry> {
490 if (threadId.length === 0 || terminalId.trim().length === 0) {
491 return EMPTY_TERMINAL_EVENT_ENTRIES;
492 }
493 return (
494 terminalEventEntriesByKey[terminalEventBufferKey(threadId, terminalId)] ??
495 EMPTY_TERMINAL_EVENT_ENTRIES
496 );
497}
498
474interface TerminalStateStoreState {499interface TerminalStateStoreState {
475 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>;500 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>;
501 terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>;
502 nextTerminalEventId: number;
476 setTerminalOpen: (threadId: ThreadId, open: boolean) => void;503 setTerminalOpen: (threadId: ThreadId, open: boolean) => void;
477 setTerminalHeight: (threadId: ThreadId, height: number) => void;504 setTerminalHeight: (threadId: ThreadId, height: number) => void;
478 splitTerminal: (threadId: ThreadId, terminalId: string) => void;505 splitTerminal: (threadId: ThreadId, terminalId: string) => void;
...@@ -484,6 +511,7 @@ interface TerminalStateStoreState {...@@ -484,6 +511,7 @@ interface TerminalStateStoreState {
484 terminalId: string,511 terminalId: string,
485 hasRunningSubprocess: boolean,512 hasRunningSubprocess: boolean,
486 ) => void;513 ) => void;
514 recordTerminalEvent: (event: TerminalEvent) => void;
487 clearTerminalState: (threadId: ThreadId) => void;515 clearTerminalState: (threadId: ThreadId) => void;
488 removeTerminalState: (threadId: ThreadId) => void;516 removeTerminalState: (threadId: ThreadId) => void;
489 removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;517 removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;
...@@ -513,6 +541,8 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -513,6 +541,8 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
513541
514 return {542 return {
515 terminalStateByThreadId: {},543 terminalStateByThreadId: {},
544 terminalEventEntriesByKey: {},
545 nextTerminalEventId: 1,
516 setTerminalOpen: (threadId, open) =>546 setTerminalOpen: (threadId, open) =>
517 updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)),547 updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)),
518 setTerminalHeight: (threadId, height) =>548 setTerminalHeight: (threadId, height) =>
...@@ -529,28 +559,97 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(...@@ -529,28 +559,97 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
529 updateTerminal(threadId, (state) =>559 updateTerminal(threadId, (state) =>
530 setThreadTerminalActivity(state, terminalId, hasRunningSubprocess),560 setThreadTerminalActivity(state, terminalId, hasRunningSubprocess),
531 ),561 ),
562 recordTerminalEvent: (event) =>
563 set((state) => {
564 const key = terminalEventBufferKey(event.threadId as ThreadId, event.terminalId);
565 const currentEntries =
566 state.terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES;
567 const nextEntry: TerminalEventEntry = {
568 id: state.nextTerminalEventId,
569 event,
570 };
571 const nextEntries =
572 currentEntries.length >= MAX_TERMINAL_EVENT_BUFFER
573 ? [...currentEntries.slice(1), nextEntry]
574 : [...currentEntries, nextEntry];
575 return {
576 terminalEventEntriesByKey: {
577 ...state.terminalEventEntriesByKey,
578 [key]: nextEntries,
579 },
580 nextTerminalEventId: state.nextTerminalEventId + 1,
581 };
582 }),
532 clearTerminalState: (threadId) =>583 clearTerminalState: (threadId) =>
533 updateTerminal(threadId, () => createDefaultThreadTerminalState()),584 set((state) => {
585 const nextTerminalStateByThreadId = updateTerminalStateByThreadId(
586 state.terminalStateByThreadId,
587 threadId,
588 () => createDefaultThreadTerminalState(),
589 );
590 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };
591 let removedEventEntries = false;
592 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {
593 if (key.startsWith(`${threadId}\u0000`)) {
594 delete nextTerminalEventEntriesByKey[key];
595 removedEventEntries = true;
596 }
597 }
598 if (
599 nextTerminalStateByThreadId === state.terminalStateByThreadId &&
600 !removedEventEntries
601 ) {
602 return state;
603 }
604 return {
605 terminalStateByThreadId: nextTerminalStateByThreadId,
606 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
607 };
608 }),
534 removeTerminalState: (threadId) =>609 removeTerminalState: (threadId) =>
535 set((state) => {610 set((state) => {
536 if (state.terminalStateByThreadId[threadId] === undefined) {611 const hasThreadState = state.terminalStateByThreadId[threadId] !== undefined;
612 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };
613 let removedEventEntries = false;
614 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {
615 if (key.startsWith(`${threadId}\u0000`)) {
616 delete nextTerminalEventEntriesByKey[key];
617 removedEventEntries = true;
618 }
619 }
620 if (!hasThreadState && !removedEventEntries) {
537 return state;621 return state;
538 }622 }
539 const next = { ...state.terminalStateByThreadId };623 const next = { ...state.terminalStateByThreadId };
540 delete next[threadId];624 delete next[threadId];
541 return { terminalStateByThreadId: next };625 return {
626 terminalStateByThreadId: next,
627 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
628 };
542 }),629 }),
543 removeOrphanedTerminalStates: (activeThreadIds) =>630 removeOrphanedTerminalStates: (activeThreadIds) =>
544 set((state) => {631 set((state) => {
545 const orphanedIds = Object.keys(state.terminalStateByThreadId).filter(632 const orphanedIds = Object.keys(state.terminalStateByThreadId).filter(
546 (id) => !activeThreadIds.has(id as ThreadId),633 (id) => !activeThreadIds.has(id as ThreadId),
547 );634 );
548 if (orphanedIds.length === 0) return state;635 const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey };
636 let removedEventEntries = false;
637 for (const key of Object.keys(nextTerminalEventEntriesByKey)) {
638 const [threadId] = key.split("\u0000");
639 if (threadId && !activeThreadIds.has(threadId as ThreadId)) {
640 delete nextTerminalEventEntriesByKey[key];
641 removedEventEntries = true;
642 }
643 }
644 if (orphanedIds.length === 0 && !removedEventEntries) return state;
549 const next = { ...state.terminalStateByThreadId };645 const next = { ...state.terminalStateByThreadId };
550 for (const id of orphanedIds) {646 for (const id of orphanedIds) {
551 delete next[id as ThreadId];647 delete next[id as ThreadId];
552 }648 }
553 return { terminalStateByThreadId: next };649 return {
650 terminalStateByThreadId: next,
651 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
652 };
554 }),653 }),
555 };654 };
556 },655 },