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";
33import { useStore } from "../store";
44
55import {
6 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
67 buildExpiredTerminalContextToastCopy,
78 createLocalDispatchSnapshot,
89 deriveComposerSendState,
910 hasServerAcknowledgedLocalDispatch,
11 reconcileMountedTerminalThreadIds,
1012 waitForStartedServerThread,
1113} from "./ChatView.logic";
1214
......@@ -75,6 +77,98 @@ describe("buildExpiredTerminalContextToastCopy", () => {
7577 });
7678});
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
78172const makeThread = (input?: {
79173 id?: ThreadId;
80174 latestTurn?: {
apps/web/src/components/ChatView.logic.ts+32
......@@ -11,6 +11,7 @@ import {
1111} from "../lib/terminalContext";
1212
1313export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
14export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
1415const WORKTREE_BRANCH_PREFIX = "t3code";
1516
1617export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
......@@ -43,6 +44,37 @@ export function buildLocalDraftThread(
4344 };
4445}
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
4678export function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
4779 if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) {
4880 return;
apps/web/src/components/ChatView.tsx+209-57
......@@ -173,6 +173,7 @@ import {
173173import { ProviderStatusBanner } from "./chat/ProviderStatusBanner";
174174import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
175175import {
176 MAX_HIDDEN_MOUNTED_TERMINAL_THREADS,
176177 buildExpiredTerminalContextToastCopy,
177178 buildLocalDraftThread,
178179 buildTemporaryWorktreeBranchName,
......@@ -186,6 +187,7 @@ import {
186187 type LocalDispatchSnapshot,
187188 PullRequestDialogState,
188189 readFileAsDataUrl,
190 reconcileMountedTerminalThreadIds,
189191 revokeBlobPreviewUrl,
190192 revokeUserMessagePreviewUrls,
191193 threadHasStarted,
......@@ -404,6 +406,163 @@ function useLocalDispatchState(input: {
404406 };
405407}
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
407566export default function ChatView({ threadId }: ChatViewProps) {
408567 const serverThread = useThreadById(threadId);
409568 const setStoreThreadError = useStore((store) => store.setError);
......@@ -565,15 +724,31 @@ export default function ChatView({ threadId }: ChatViewProps) {
565724 setMessagesScrollElement(element);
566725 }, []);
567726
568 const terminalState = useTerminalStateStore((state) =>
569 selectThreadTerminalState(state.terminalStateByThreadId, threadId),
727 const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId);
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],
570738 );
571739 const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);
572 const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight);
573740 const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);
574741 const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal);
575742 const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal);
576743 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
578753 const setPrompt = useCallback(
579754 (nextPrompt: string) => {
......@@ -655,6 +830,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
655830 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
656831 const diffOpen = rawSearch.diff === "1";
657832 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]);
658837 const activeLatestTurn = activeThread?.latestTurn ?? null;
659838 const threadPlanCatalog = useThreadPlanCatalog(
660839 useMemo(() => {
......@@ -673,6 +852,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
673852 () => deriveLatestContextWindowSnapshot(activeThread?.activities ?? []),
674853 [activeThread?.activities],
675854 );
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]);
676870 const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
677871 const activeProject = useProjectById(activeThread?.projectId);
678872
......@@ -1326,17 +1520,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
13261520 () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null,
13271521 [selectedProvider, providerStatuses],
13281522 );
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]);
13401523 // Default true while loading to avoid toolbar flicker.
13411524 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
13421525 const terminalShortcutLabelOptions = useMemo(
......@@ -1481,13 +1664,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
14811664 },
14821665 [activeThreadId, storeSetTerminalOpen],
14831666 );
1484 const setTerminalHeight = useCallback(
1485 (height: number) => {
1486 if (!activeThreadId) return;
1487 storeSetTerminalHeight(activeThreadId, height);
1488 },
1489 [activeThreadId, storeSetTerminalHeight],
1490 );
14911667 const toggleTerminalVisibility = useCallback(() => {
14921668 if (!activeThreadId) return;
14931669 setTerminalOpen(!terminalState.terminalOpen);
......@@ -1504,14 +1680,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
15041680 storeNewTerminal(activeThreadId, terminalId);
15051681 setTerminalFocusRequestId((value) => value + 1);
15061682 }, [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 );
15151683 const closeTerminal = useCallback(
15161684 (terminalId: string) => {
15171685 const api = readNativeApi();
......@@ -4284,34 +4452,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
42844452 </div>
42854453 {/* end horizontal flex container */}
42864454
4287 {(() => {
4288 if (!terminalState.terminalOpen || !activeProject) {
4289 return null;
4290 }
4291 return (
4292 <ThreadTerminalDrawer
4293 key={activeThread.id}
4294 threadId={activeThread.id}
4295 cwd={gitCwd ?? activeProject.cwd}
4296 runtimeEnv={threadTerminalRuntimeEnv}
4297 height={terminalState.terminalHeight}
4298 terminalIds={terminalState.terminalIds}
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 })()}
4455 {mountedTerminalThreadIds.map((mountedThreadId) => (
4456 <PersistentThreadTerminalDrawer
4457 key={mountedThreadId}
4458 threadId={mountedThreadId}
4459 visible={mountedThreadId === activeThreadId && terminalState.terminalOpen}
4460 focusRequestId={mountedThreadId === activeThreadId ? terminalFocusRequestId : 0}
4461 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
4462 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
4463 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
4464 onAddTerminalContext={addTerminalContextToDraft}
4465 />
4466 ))}
43154467
43164468 {expandedImage && expandedImageItem && (
43174469 <div
apps/web/src/components/ThreadTerminalDrawer.test.ts+62
......@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
22
33import {
44 resolveTerminalSelectionActionPosition,
5 selectPendingTerminalEventEntries,
6 selectTerminalEventEntriesAfterSnapshot,
57 shouldHandleTerminalSelectionMouseUp,
68 terminalSelectionActionDelayForClickCount,
79} from "./ThreadTerminalDrawer";
......@@ -72,4 +74,64 @@ describe("resolveTerminalSelectionActionPosition", () => {
7274 expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false);
7375 expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false);
7476 });
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 });
75137});
apps/web/src/components/ThreadTerminalDrawer.tsx+165-78
......@@ -1,12 +1,17 @@
11import { FitAddon } from "@xterm/addon-fit";
22import { 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";
48import { Terminal, type ITheme } from "@xterm/xterm";
59import {
610 type PointerEvent as ReactPointerEvent,
711 type ReactNode,
812 useCallback,
913 useEffect,
14 useEffectEvent,
1015 useMemo,
1116 useRef,
1217 useState,
......@@ -27,6 +32,7 @@ import {
2732 type ThreadTerminalGroup,
2833} from "../types";
2934import { readNativeApi } from "~/nativeApi";
35import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore";
3036
3137const MIN_DRAWER_HEIGHT = 180;
3238const MAX_DRAWER_HEIGHT_RATIO = 0.75;
......@@ -47,6 +53,27 @@ function writeSystemMessage(terminal: Terminal, message: string): void {
4753 terminal.write(`\r\n[terminal] ${message}\r\n`);
4854}
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
5077function terminalThemeFromApp(): ITheme {
5178 const isDark = document.documentElement.classList.contains("dark");
5279 const bodyStyles = getComputedStyle(document.body);
......@@ -210,27 +237,21 @@ function TerminalViewport({
210237 const containerRef = useRef<HTMLDivElement>(null);
211238 const terminalRef = useRef<Terminal | null>(null);
212239 const fitAddonRef = useRef<FitAddon | null>(null);
213 const onSessionExitedRef = useRef(onSessionExited);
214 const onAddTerminalContextRef = useRef(onAddTerminalContext);
215 const terminalLabelRef = useRef(terminalLabel);
216240 const hasHandledExitRef = useRef(false);
217241 const selectionPointerRef = useRef<{ x: number; y: number } | null>(null);
218242 const selectionGestureActiveRef = useRef(false);
219243 const selectionActionRequestIdRef = useRef(0);
220244 const selectionActionOpenRef = useRef(false);
221245 const selectionActionTimerRef = useRef<number | null>(null);
222
223 useEffect(() => {
224 onSessionExitedRef.current = onSessionExited;
225 }, [onSessionExited]);
226
227 useEffect(() => {
228 onAddTerminalContextRef.current = onAddTerminalContext;
229 }, [onAddTerminalContext]);
230
231 useEffect(() => {
232 terminalLabelRef.current = terminalLabel;
233 }, [terminalLabel]);
246 const lastAppliedTerminalEventIdRef = useRef(0);
247 const terminalHydratedRef = useRef(false);
248 const handleSessionExited = useEffectEvent(() => {
249 onSessionExited();
250 });
251 const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => {
252 onAddTerminalContext(selection);
253 });
254 const readTerminalLabel = useEffectEvent(() => terminalLabel);
234255
235256 useEffect(() => {
236257 const mount = containerRef.current;
......@@ -297,7 +318,7 @@ function TerminalViewport({
297318 position,
298319 selection: {
299320 terminalId,
300 terminalLabel: terminalLabelRef.current,
321 terminalLabel: readTerminalLabel(),
301322 lineStart,
302323 lineEnd,
303324 text: normalizedText,
......@@ -324,7 +345,7 @@ function TerminalViewport({
324345 if (requestId !== selectionActionRequestIdRef.current || clicked !== "add-to-chat") {
325346 return;
326347 }
327 onAddTerminalContextRef.current(nextAction.selection);
348 handleAddTerminalContext(nextAction.selection);
328349 terminalRef.current?.clearSelection();
329350 terminalRef.current?.focus();
330351 } finally {
......@@ -469,43 +490,15 @@ function TerminalViewport({
469490 attributeFilter: ["class", "style"],
470491 });
471492
472 const openTerminal = async () => {
473 try {
474 const activeTerminal = terminalRef.current;
475 const activeFitAddon = fitAddonRef.current;
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 );
493 const applyTerminalEvent = (event: TerminalEvent) => {
494 const activeTerminal = terminalRef.current;
495 if (!activeTerminal) {
496 return;
502497 }
503 };
504498
505 const unsubscribe = api?.terminal.onEvent((event) => {
506 if (event.threadId !== threadId || event.terminalId !== terminalId) return;
507 const activeTerminal = terminalRef.current;
508 if (!activeTerminal) return;
499 if (event.type === "activity") {
500 return;
501 }
509502
510503 if (event.type === "output") {
511504 activeTerminal.write(event.data);
......@@ -516,10 +509,7 @@ function TerminalViewport({
516509 if (event.type === "started" || event.type === "restarted") {
517510 hasHandledExitRef.current = false;
518511 clearSelectionAction();
519 activeTerminal.write("\u001bc");
520 if (event.snapshot.history.length > 0) {
521 activeTerminal.write(event.snapshot.history);
522 }
512 writeTerminalSnapshot(activeTerminal, event.snapshot);
523513 return;
524514 }
525515
......@@ -535,30 +525,112 @@ function TerminalViewport({
535525 return;
536526 }
537527
538 if (event.type === "exited") {
539 const details = [
540 typeof event.exitCode === "number" ? `code ${event.exitCode}` : null,
541 typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null,
542 ]
543 .filter((value): value is string => value !== null)
544 .join(", ");
545 writeSystemMessage(
546 activeTerminal,
547 details.length > 0 ? `Process exited (${details})` : "Process exited",
548 );
549 if (hasHandledExitRef.current) {
528 const details = [
529 typeof event.exitCode === "number" ? `code ${event.exitCode}` : null,
530 typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null,
531 ]
532 .filter((value): value is string => value !== null)
533 .join(", ");
534 writeSystemMessage(
535 activeTerminal,
536 details.length > 0 ? `Process exited (${details})` : "Process exited",
537 );
538 if (hasHandledExitRef.current) {
539 return;
540 }
541 hasHandledExitRef.current = true;
542 window.setTimeout(() => {
543 if (!hasHandledExitRef.current) {
550544 return;
551545 }
552 hasHandledExitRef.current = true;
553 window.setTimeout(() => {
554 if (!hasHandledExitRef.current) {
555 return;
556 }
557 onSessionExitedRef.current();
558 }, 0);
546 handleSessionExited();
547 }, 0);
548 };
549 const applyPendingTerminalEvents = (
550 terminalEventEntries: ReadonlyArray<{ id: number; event: TerminalEvent }>,
551 ) => {
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;
559569 }
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);
560588 });
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
562634 const fitTimer = window.setTimeout(() => {
563635 const activeTerminal = terminalRef.current;
564636 const activeFitAddon = fitAddonRef.current;
......@@ -582,8 +654,10 @@ function TerminalViewport({
582654
583655 return () => {
584656 disposed = true;
657 terminalHydratedRef.current = false;
658 lastAppliedTerminalEventIdRef.current = 0;
659 unsubscribeTerminalEvents();
585660 window.clearTimeout(fitTimer);
586 unsubscribe();
587661 inputDisposable.dispose();
588662 selectionDisposable.dispose();
589663 terminalLinksDisposable.dispose();
......@@ -647,6 +721,7 @@ interface ThreadTerminalDrawerProps {
647721 threadId: ThreadId;
648722 cwd: string;
649723 runtimeEnv?: Record<string, string>;
724 visible?: boolean;
650725 height: number;
651726 terminalIds: string[];
652727 activeTerminalId: string;
......@@ -697,6 +772,7 @@ export default function ThreadTerminalDrawer({
697772 threadId,
698773 cwd,
699774 runtimeEnv,
775 visible = true,
700776 height,
701777 terminalIds,
702778 activeTerminalId,
......@@ -911,6 +987,10 @@ export default function ThreadTerminalDrawer({
911987 );
912988
913989 useEffect(() => {
990 if (!visible) {
991 return;
992 }
993
914994 const onWindowResize = () => {
915995 const clampedHeight = clampDrawerHeight(drawerHeightRef.current);
916996 const changed = clampedHeight !== drawerHeightRef.current;
......@@ -927,7 +1007,14 @@ export default function ThreadTerminalDrawer({
9271007 return () => {
9281008 window.removeEventListener("resize", onWindowResize);
9291009 };
930 }, [syncHeight]);
1010 }, [syncHeight, visible]);
1011
1012 useEffect(() => {
1013 if (!visible) {
1014 return;
1015 }
1016 setResizeEpoch((value) => value + 1);
1017 }, [visible]);
9311018
9321019 useEffect(() => {
9331020 return () => {
apps/web/src/orchestrationRecovery.test.ts+33
......@@ -97,4 +97,37 @@ describe("createOrchestrationRecoveryCoordinator", () => {
9797 reason: "replay-failed",
9898 });
9999 });
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 });
100133});
apps/web/src/orchestrationRecovery.ts+11-6
......@@ -34,11 +34,16 @@ export function createOrchestrationRecoveryCoordinator() {
3434 state.highestObservedSequence = Math.max(state.highestObservedSequence, sequence);
3535 };
3636
37 const shouldReplayAfterRecovery = (): boolean => {
38 const shouldReplay =
39 state.pendingReplay || state.highestObservedSequence > state.latestSequence;
37 const resolveReplayNeedAfterRecovery = () => {
38 const pendingReplayBeforeReset = state.pendingReplay;
39 const observedAhead = state.highestObservedSequence > state.latestSequence;
40 const shouldReplay = pendingReplayBeforeReset || observedAhead;
4041 state.pendingReplay = false;
41 return shouldReplay;
42 return {
43 shouldReplay,
44 pendingReplayBeforeReset,
45 observedAhead,
46 };
4247 };
4348
4449 return {
......@@ -93,7 +98,7 @@ export function createOrchestrationRecoveryCoordinator() {
9398 state.highestObservedSequence = Math.max(state.highestObservedSequence, state.latestSequence);
9499 state.bootstrapped = true;
95100 state.inFlight = null;
96 return shouldReplayAfterRecovery();
101 return resolveReplayNeedAfterRecovery().shouldReplay;
97102 },
98103
99104 failSnapshotRecovery(): void {
......@@ -124,7 +129,7 @@ export function createOrchestrationRecoveryCoordinator() {
124129 state.pendingReplay = false;
125130 return false;
126131 }
127 return shouldReplayAfterRecovery();
132 return resolveReplayNeedAfterRecovery().shouldReplay;
128133 },
129134
130135 failReplayRecovery(): void {
apps/web/src/routes/__root.tsx+45-34
......@@ -22,12 +22,12 @@ import { resolveAndPersistPreferredEditor } from "../editorPreferences";
2222import { readNativeApi } from "../nativeApi";
2323import {
2424 getServerConfigUpdatedNotification,
25 type ServerConfigUpdateSource,
25 ServerConfigUpdatedNotification,
26 startServerStateSync,
2627 useServerConfig,
2728 useServerConfigUpdatedSubscription,
2829 useServerWelcomeSubscription,
2930} from "../rpc/serverState";
30import { ServerStateBootstrap } from "../rpc/serverStateBootstrap";
3131import {
3232 clearPromotedDraftThread,
3333 clearPromotedDraftThreads,
......@@ -43,6 +43,7 @@ import { projectQueryKeys } from "../lib/projectReactQuery";
4343import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup";
4444import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects";
4545import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery";
46import { getWsRpcClient } from "~/wsRpcClient";
4647
4748export const Route = createRootRouteWithContext<{
4849 queryClient: QueryClient;
......@@ -68,18 +69,15 @@ function RootRouteView() {
6869 }
6970
7071 return (
71 <>
72 <ServerStateBootstrap />
73 <ToastProvider>
74 <AnchoredToastProvider>
75 <EventRouter />
76 <DesktopProjectBootstrap />
77 <AppSidebarLayout>
78 <Outlet />
79 </AppSidebarLayout>
80 </AnchoredToastProvider>
81 </ToastProvider>
82 </>
72 <ToastProvider>
73 <AnchoredToastProvider>
74 <ServerStateBootstrap />
75 <EventRouter />
76 <AppSidebarLayout>
77 <Outlet />
78 </AppSidebarLayout>
79 </AnchoredToastProvider>
80 </ToastProvider>
8381 );
8482}
8583
......@@ -191,6 +189,12 @@ function coalesceOrchestrationUiEvents(
191189 return coalesced;
192190}
193191
192function ServerStateBootstrap() {
193 useEffect(() => startServerStateSync(getWsRpcClient().server), []);
194
195 return null;
196}
197
194198function EventRouter() {
195199 const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents);
196200 const syncServerReadModel = useStore((store) => store.syncServerReadModel);
......@@ -205,16 +209,16 @@ function EventRouter() {
205209 const queryClient = useQueryClient();
206210 const navigate = useNavigate();
207211 const pathname = useLocation({ select: (loc) => loc.pathname });
208 const pathnameRef = useRef(pathname);
212 const readPathname = useEffectEvent(() => pathname);
209213 const handledBootstrapThreadIdRef = useRef<string | null>(null);
210214 const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0);
211215 const disposedRef = useRef(false);
212216 const bootstrapFromSnapshotRef = useRef<() => Promise<void>>(async () => undefined);
213217 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) => {
218222 migrateLocalSettingsToServer();
219223 void (async () => {
220224 await bootstrapFromSnapshotRef.current();
......@@ -227,7 +231,7 @@ function EventRouter() {
227231 }
228232 setProjectExpanded(payload.bootstrapProjectId, true);
229233
230 if (pathnameRef.current !== "/") {
234 if (readPathname() !== "/") {
231235 return;
232236 }
233237 if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) {
......@@ -243,15 +247,10 @@ function EventRouter() {
243247 });
244248
245249 const handleServerConfigUpdated = useEffectEvent(
246 ({
247 id,
248 payload,
249 source,
250 }: {
251 readonly id: number;
252 readonly payload: import("@t3tools/contracts").ServerConfigUpdatedPayload;
253 readonly source: ServerConfigUpdateSource;
254 }) => {
250 (notification: ServerConfigUpdatedNotification | null) => {
251 if (!notification) return;
252
253 const { id, payload, source } = notification;
255254 if (id <= seenServerConfigUpdateIdRef.current) {
256255 return;
257256 }
......@@ -425,8 +424,9 @@ function EventRouter() {
425424 return;
426425 }
427426
427 const fromSequenceExclusive = recovery.getState().latestSequence;
428428 try {
429 const events = await api.orchestration.replayEvents(recovery.getState().latestSequence);
429 const events = await api.orchestration.replayEvents(fromSequenceExclusive);
430430 if (!disposed) {
431431 applyEventBatch(events);
432432 }
......@@ -442,7 +442,22 @@ function EventRouter() {
442442 };
443443
444444 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) {
446461 return;
447462 }
448463
......@@ -482,6 +497,7 @@ function EventRouter() {
482497 }
483498 });
484499 const unsubTerminalEvent = api.terminal.onEvent((event) => {
500 useTerminalStateStore.getState().recordTerminalEvent(event);
485501 const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event);
486502 if (hasRunningSubprocess === null) {
487503 return;
......@@ -522,8 +538,3 @@ function EventRouter() {
522538
523539 return null;
524540}
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 {
1111 type ServerSettings,
1212} from "@t3tools/contracts";
1313import { Atom } from "effect/unstable/reactivity";
14import { useCallback, useRef } from "react";
1415
1516import type { WsRpcClient } from "../wsRpcClient";
1617import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry";
......@@ -242,17 +243,18 @@ function subscribeLatest<A>(
242243function useLatestAtomSubscription<A>(
243244 atom: Atom.Atom<A | null>,
244245 listener: (value: NonNullable<A>) => void,
245) {
246 useAtomSubscribe(
247 atom,
248 (value) => {
249 if (value === null) {
250 return;
251 }
252 listener(value as NonNullable<A>);
253 },
254 { immediate: true },
255 );
246): void {
247 const listenerRef = useRef(listener);
248 listenerRef.current = listener;
249
250 const stableListener = useCallback((value: A | null) => {
251 if (value === null) {
252 return;
253 }
254 listenerRef.current(value as NonNullable<A>);
255 }, []);
256
257 useAtomSubscribe(atom, stableListener, { immediate: true });
256258}
257259
258260export 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 @@
1import { ThreadId } from "@t3tools/contracts";
1import { ThreadId, type TerminalEvent } from "@t3tools/contracts";
22import { beforeEach, describe, expect, it } from "vitest";
33
4import { selectThreadTerminalState, useTerminalStateStore } from "./terminalStateStore";
4import {
5 selectTerminalEventEntries,
6 selectThreadTerminalState,
7 useTerminalStateStore,
8} from "./terminalStateStore";
59
610const 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
854describe("terminalStateStore actions", () => {
955 beforeEach(() => {
1056 useTerminalStateStore.persist.clearStorage();
11 useTerminalStateStore.setState({ terminalStateByThreadId: {} });
57 useTerminalStateStore.setState({
58 terminalStateByThreadId: {},
59 terminalEventEntriesByKey: {},
60 nextTerminalEventId: 1,
61 });
1262 });
1363
1464 it("returns a closed default terminal state for unknown threads", () => {
......@@ -152,4 +202,43 @@ describe("terminalStateStore actions", () => {
152202 { id: "group-default", terminalIds: ["default", "terminal-2"] },
153203 ]);
154204 });
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 });
155244});
apps/web/src/terminalStateStore.ts+105-6
......@@ -5,7 +5,7 @@
55 * API constrained to store actions/selectors.
66 */
77
8import type { ThreadId } from "@t3tools/contracts";
8import type { TerminalEvent, ThreadId } from "@t3tools/contracts";
99import { create } from "zustand";
1010import { createJSONStorage, persist } from "zustand/middleware";
1111import { resolveStorage } from "./lib/storage";
......@@ -26,7 +26,14 @@ interface ThreadTerminalState {
2626 activeTerminalGroupId: string;
2727}
2828
29export interface TerminalEventEntry {
30 id: number;
31 event: TerminalEvent;
32}
33
2934const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
35const EMPTY_TERMINAL_EVENT_ENTRIES: ReadonlyArray<TerminalEventEntry> = [];
36const MAX_TERMINAL_EVENT_BUFFER = 200;
3037
3138function createTerminalStateStorage() {
3239 return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined);
......@@ -227,6 +234,10 @@ function isValidTerminalId(terminalId: string): boolean {
227234 return terminalId.trim().length > 0;
228235}
229236
237function terminalEventBufferKey(threadId: ThreadId, terminalId: string): string {
238 return `${threadId}\u0000${terminalId}`;
239}
240
230241function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] {
231242 return groups.map((group) => ({
232243 id: group.id,
......@@ -471,8 +482,24 @@ function updateTerminalStateByThreadId(
471482 };
472483}
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
474499interface TerminalStateStoreState {
475500 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>;
501 terminalEventEntriesByKey: Record<string, ReadonlyArray<TerminalEventEntry>>;
502 nextTerminalEventId: number;
476503 setTerminalOpen: (threadId: ThreadId, open: boolean) => void;
477504 setTerminalHeight: (threadId: ThreadId, height: number) => void;
478505 splitTerminal: (threadId: ThreadId, terminalId: string) => void;
......@@ -484,6 +511,7 @@ interface TerminalStateStoreState {
484511 terminalId: string,
485512 hasRunningSubprocess: boolean,
486513 ) => void;
514 recordTerminalEvent: (event: TerminalEvent) => void;
487515 clearTerminalState: (threadId: ThreadId) => void;
488516 removeTerminalState: (threadId: ThreadId) => void;
489517 removeOrphanedTerminalStates: (activeThreadIds: Set<ThreadId>) => void;
......@@ -513,6 +541,8 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
513541
514542 return {
515543 terminalStateByThreadId: {},
544 terminalEventEntriesByKey: {},
545 nextTerminalEventId: 1,
516546 setTerminalOpen: (threadId, open) =>
517547 updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)),
518548 setTerminalHeight: (threadId, height) =>
......@@ -529,28 +559,97 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()(
529559 updateTerminal(threadId, (state) =>
530560 setThreadTerminalActivity(state, terminalId, hasRunningSubprocess),
531561 ),
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 }),
532583 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 }),
534609 removeTerminalState: (threadId) =>
535610 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) {
537621 return state;
538622 }
539623 const next = { ...state.terminalStateByThreadId };
540624 delete next[threadId];
541 return { terminalStateByThreadId: next };
625 return {
626 terminalStateByThreadId: next,
627 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
628 };
542629 }),
543630 removeOrphanedTerminalStates: (activeThreadIds) =>
544631 set((state) => {
545632 const orphanedIds = Object.keys(state.terminalStateByThreadId).filter(
546633 (id) => !activeThreadIds.has(id as ThreadId),
547634 );
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;
549645 const next = { ...state.terminalStateByThreadId };
550646 for (const id of orphanedIds) {
551647 delete next[id as ThreadId];
552648 }
553 return { terminalStateByThreadId: next };
649 return {
650 terminalStateByThreadId: next,
651 terminalEventEntriesByKey: nextTerminalEventEntriesByKey,
652 };
554653 }),
555654 };
556655 },