authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 10:23:53-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 13:13:19-08:00
log7522d3bde34e7178fb1618ee6ca14ea7c1dc6d0d
treed5243f9dc553ef7e6b678d6c40f229a33409d719
parented26b84177b93790481cf34ffe0edd010760e542

refactor(web): remove reducer pattern, use Zustand + pure functions

- Main store: replace useReducer with Zustand; pure state transition functions (syncServerReadModel, markThreadVisited, setError, etc.) called via store methods. Persist via subscribe. - Terminal store: single store keyed by threadId; replace dispatchTerminalAction with direct methods (setTerminalOpen, setTerminalHeight, splitTerminal, newTerminal, setActiveTerminal, closeTerminal, setTerminalActivity). Pure reduceThreadTerminalState used internally. - Update all consumers to call dispatch.method(...) and terminal store methods. Fix store tests to use markThreadUnread pure fn. - Fix Fragment import in store.ts; fix unused get param for lint. Made-with: Cursor

11 files changed, 332 insertions(+), 1090 deletions(-)

apps/web/src/components/BranchToolbar.tsx+3-3
......@@ -119,7 +119,7 @@ export default function BranchToolbar({
119119 });
120120 }
121121 if (hasServerThread) {
122 setThreadBranchAction(activeThreadId, syncedBranch, null);
122 dispatch.setThreadBranch(activeThreadId, syncedBranch, null);
123123 return;
124124 }
125125 setDraftThreadContext(threadId, {
......@@ -142,7 +142,7 @@ export default function BranchToolbar({
142142
143143 const setThreadError = (error: string | null) => {
144144 if (!activeThreadId) return;
145 setThreadErrorAction(activeThreadId, error);
145 dispatch.setError(activeThreadId, error);
146146 };
147147
148148 const setThreadBranch = (branch: string | null, worktreePath: string | null) => {
......@@ -171,7 +171,7 @@ export default function BranchToolbar({
171171 });
172172 }
173173 if (hasServerThread) {
174 setThreadBranchAction(activeThreadId, branch, worktreePath);
174 dispatch.setThreadBranch(activeThreadId, branch, worktreePath);
175175 return;
176176 }
177177 setDraftThreadContext(threadId, {
apps/web/src/components/ChatView.tsx+60-207
......@@ -61,7 +61,6 @@ import {
6161 type Thread,
6262 type TurnDiffSummary,
6363} from "../types";
64import type { ThreadTerminalState } from "../threadTerminalState";
6564import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
6665import { useTheme } from "../hooks/useTheme";
6766import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
......@@ -125,6 +124,7 @@ import {
125124 useComposerDraftStore,
126125 useComposerThreadDraft,
127126} from "../composerDraftStore";
127import { useTerminalStateStore } from "../terminalStateStore";
128128import { clamp } from "effect/Number";
129129
130130function formatMessageMeta(createdAt: string, duration: string | null): string {
......@@ -205,7 +205,6 @@ function buildExpandedImagePreview(
205205function buildLocalDraftThread(
206206 threadId: ThreadId,
207207 draftThread: DraftThreadState,
208 draftTerminalState: ThreadTerminalState,
209208 fallbackModel: string,
210209 error: string | null,
211210): Thread {
......@@ -215,13 +214,6 @@ function buildLocalDraftThread(
215214 projectId: draftThread.projectId,
216215 title: "New thread",
217216 model: fallbackModel,
218 terminalOpen: draftTerminalState.terminalOpen,
219 terminalHeight: draftTerminalState.terminalHeight,
220 terminalIds: draftTerminalState.terminalIds,
221 runningTerminalIds: draftTerminalState.runningTerminalIds,
222 activeTerminalId: draftTerminalState.activeTerminalId,
223 terminalGroups: draftTerminalState.terminalGroups,
224 activeTerminalGroupId: draftTerminalState.activeTerminalGroupId,
225217 session: null,
226218 messages: [],
227219 error,
......@@ -235,46 +227,6 @@ function buildLocalDraftThread(
235227 };
236228}
237229
238function threadTerminalsMatchState(thread: Thread, terminalState: ThreadTerminalState): boolean {
239 if (
240 thread.terminalOpen !== terminalState.terminalOpen ||
241 thread.terminalHeight !== terminalState.terminalHeight ||
242 thread.activeTerminalId !== terminalState.activeTerminalId ||
243 thread.activeTerminalGroupId !== terminalState.activeTerminalGroupId
244 ) {
245 return false;
246 }
247 if (
248 thread.terminalIds.length !== terminalState.terminalIds.length ||
249 thread.runningTerminalIds.length !== terminalState.runningTerminalIds.length ||
250 thread.terminalGroups.length !== terminalState.terminalGroups.length
251 ) {
252 return false;
253 }
254 if (!thread.terminalIds.every((terminalId, index) => terminalId === terminalState.terminalIds[index])) {
255 return false;
256 }
257 if (
258 !thread.runningTerminalIds.every(
259 (terminalId, index) => terminalId === terminalState.runningTerminalIds[index],
260 )
261 ) {
262 return false;
263 }
264 return thread.terminalGroups.every((group, groupIndex) => {
265 const terminalStateGroup = terminalState.terminalGroups[groupIndex];
266 if (!terminalStateGroup || group.id !== terminalStateGroup.id) {
267 return false;
268 }
269 if (group.terminalIds.length !== terminalStateGroup.terminalIds.length) {
270 return false;
271 }
272 return group.terminalIds.every(
273 (terminalId, terminalIdIndex) => terminalId === terminalStateGroup.terminalIds[terminalIdIndex],
274 );
275 });
276}
277
278230function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
279231 if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) {
280232 return;
......@@ -578,13 +530,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
578530 setMessagesScrollElement(element);
579531 }, []);
580532
581 const setDraftThreadTerminalAction = useComposerDraftStore((s) => s.setDraftThreadTerminalAction);
582 const clearDraftThreadTerminalState = useComposerDraftStore(
583 (s) => s.clearDraftThreadTerminalState,
584 );
585 const localDraftTerminalState = useComposerDraftStore((s) =>
586 s.getDraftThreadTerminalState(threadId),
587 );
533 const terminalState = useTerminalStateStore((s) => s.getTerminalState(threadId));
534 const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);
535 const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight);
536 const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);
537 const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal);
538 const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal);
539 const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal);
588540
589541 const setPrompt = useCallback(
590542 (nextPrompt: string) => {
......@@ -624,12 +576,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
624576 ? buildLocalDraftThread(
625577 threadId,
626578 draftThread,
627 localDraftTerminalState,
628579 fallbackDraftProject?.model ?? DEFAULT_MODEL,
629580 localDraftError,
630581 )
631582 : undefined,
632 [draftThread, fallbackDraftProject?.model, localDraftError, localDraftTerminalState, threadId],
583 [draftThread, fallbackDraftProject?.model, localDraftError, threadId],
633584 );
634585 const activeThread = serverThread ?? localDraftThread;
635586 const isServerThread = serverThread !== undefined;
......@@ -660,25 +611,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
660611 });
661612 }, [serverThread, threadId]);
662613
663 useEffect(() => {
664 if (!serverThread) {
665 return;
666 }
667 const draft = useComposerDraftStore.getState().draftThreadsByThreadId[threadId];
668 const draftTerminal = draft?.terminalState;
669 if (draftTerminal === undefined) {
670 return;
671 }
672 if (!threadTerminalsMatchState(serverThread, draftTerminal)) {
673 dispatch({
674 type: "HYDRATE_THREAD_TERMINALS",
675 threadId: serverThread.id,
676 terminalState: draftTerminal,
677 });
678 }
679 clearDraftThreadTerminalState(threadId);
680 }, [clearDraftThreadTerminalState, dispatch, serverThread, threadId]);
681
682614 useEffect(() => {
683615 if (!activeThread?.id) return;
684616 if (!latestTurnSettled) return;
......@@ -688,7 +620,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
688620 const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN;
689621 if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return;
690622
691 markThreadVisited(activeThread.id);
623 dispatch.markThreadVisited(activeThread.id);
692624 }, [
693625 activeThread?.id,
694626 activeThread?.lastVisitedAt,
......@@ -1045,16 +977,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
1045977 (activeThread.session !== null && activeThread.session.status !== "closed")),
1046978 );
1047979 const hasReachedTerminalLimit =
1048 (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT;
980 terminalState.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT;
1049981 const setThreadError = useCallback(
1050982 (targetThreadId: ThreadId | null, error: string | null) => {
1051983 if (!targetThreadId) return;
1052984 if (state.threads.some((thread) => thread.id === targetThreadId)) {
1053 dispatch({
1054 type: "SET_ERROR",
1055 threadId: targetThreadId,
1056 error,
1057 });
985 dispatch.setError(targetThreadId, error);
1058986 return;
1059987 }
1060988 setLocalDraftErrorsByThreadId((existing) => {
......@@ -1085,86 +1013,46 @@ export default function ChatView({ threadId }: ChatViewProps) {
10851013 const setTerminalOpen = useCallback(
10861014 (open: boolean) => {
10871015 if (!activeThreadId) return;
1088 if (isLocalDraftThread) {
1089 setDraftThreadTerminalAction(activeThreadId, { type: "set-open", open });
1090 return;
1091 }
1092 dispatch({
1093 type: "SET_THREAD_TERMINAL_OPEN",
1094 threadId: activeThreadId,
1095 open,
1096 });
1016 storeSetTerminalOpen(activeThreadId, open);
10971017 },
1098 [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction],
1018 [activeThreadId, storeSetTerminalOpen],
10991019 );
11001020 const setTerminalHeight = useCallback(
11011021 (height: number) => {
11021022 if (!activeThreadId) return;
1103 if (isLocalDraftThread) {
1104 setDraftThreadTerminalAction(activeThreadId, { type: "set-height", height });
1105 return;
1106 }
1107 dispatch({
1108 type: "SET_THREAD_TERMINAL_HEIGHT",
1109 threadId: activeThreadId,
1110 height,
1111 });
1023 storeSetTerminalHeight(activeThreadId, height);
11121024 },
1113 [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction],
1025 [activeThreadId, storeSetTerminalHeight],
11141026 );
11151027 const toggleTerminalVisibility = useCallback(() => {
11161028 if (!activeThreadId) return;
1117 setTerminalOpen(!activeThread?.terminalOpen);
1118 }, [activeThread?.terminalOpen, activeThreadId, setTerminalOpen]);
1029 setTerminalOpen(!terminalState.terminalOpen);
1030 }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]);
11191031 const splitTerminal = useCallback(() => {
11201032 if (!activeThreadId || hasReachedTerminalLimit) return;
11211033 const terminalId = `terminal-${crypto.randomUUID()}`;
1122 if (isLocalDraftThread) {
1123 setDraftThreadTerminalAction(activeThreadId, { type: "split", terminalId });
1124 } else {
1125 dispatch({
1126 type: "SPLIT_THREAD_TERMINAL",
1127 threadId: activeThreadId,
1128 terminalId,
1129 });
1130 }
1034 storeSplitTerminal(activeThreadId, terminalId);
11311035 setTerminalFocusRequestId((value) => value + 1);
1132 }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, setDraftThreadTerminalAction]);
1036 }, [activeThreadId, storeSplitTerminal, hasReachedTerminalLimit]);
11331037 const createNewTerminal = useCallback(() => {
11341038 if (!activeThreadId || hasReachedTerminalLimit) return;
11351039 const terminalId = `terminal-${crypto.randomUUID()}`;
1136 if (isLocalDraftThread) {
1137 setDraftThreadTerminalAction(activeThreadId, { type: "new", terminalId });
1138 } else {
1139 dispatch({
1140 type: "NEW_THREAD_TERMINAL",
1141 threadId: activeThreadId,
1142 terminalId,
1143 });
1144 }
1040 storeNewTerminal(activeThreadId, terminalId);
11451041 setTerminalFocusRequestId((value) => value + 1);
1146 }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, setDraftThreadTerminalAction]);
1042 }, [activeThreadId, storeNewTerminal, hasReachedTerminalLimit]);
11471043 const activateTerminal = useCallback(
11481044 (terminalId: string) => {
11491045 if (!activeThreadId) return;
1150 if (isLocalDraftThread) {
1151 setDraftThreadTerminalAction(activeThreadId, { type: "set-active", terminalId });
1152 } else {
1153 dispatch({
1154 type: "SET_THREAD_ACTIVE_TERMINAL",
1155 threadId: activeThreadId,
1156 terminalId,
1157 });
1158 }
1046 storeSetActiveTerminal(activeThreadId, terminalId);
11591047 setTerminalFocusRequestId((value) => value + 1);
11601048 },
1161 [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction],
1049 [activeThreadId, storeSetActiveTerminal],
11621050 );
11631051 const closeTerminal = useCallback(
11641052 (terminalId: string) => {
11651053 const api = readNativeApi();
11661054 if (!activeThreadId || !api) return;
1167 const isFinalTerminal = (activeThread?.terminalIds.length ?? 0) <= 1;
1055 const isFinalTerminal = terminalState.terminalIds.length <= 1;
11681056 const fallbackExitWrite = () =>
11691057 api.terminal
11701058 .write({ threadId: activeThreadId, terminalId, data: "exit\n" })
......@@ -1181,18 +1069,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
11811069 } else {
11821070 void fallbackExitWrite();
11831071 }
1184 if (isLocalDraftThread) {
1185 setDraftThreadTerminalAction(activeThreadId, { type: "close", terminalId });
1186 } else {
1187 dispatch({
1188 type: "CLOSE_THREAD_TERMINAL",
1189 threadId: activeThreadId,
1190 terminalId,
1191 });
1192 }
1072 storeCloseTerminal(activeThreadId, terminalId);
11931073 setTerminalFocusRequestId((value) => value + 1);
11941074 },
1195 [activeThread?.terminalIds.length, activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction],
1075 [activeThreadId, storeCloseTerminal, terminalState.terminalIds.length],
11961076 );
11971077 const runProjectScript = useCallback(
11981078 async (
......@@ -1217,42 +1097,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
12171097 }
12181098 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;
12191099 const baseTerminalId =
1220 activeThread.activeTerminalId || activeThread.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID;
1221 const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId);
1100 terminalState.activeTerminalId ||
1101 terminalState.terminalIds[0] ||
1102 DEFAULT_THREAD_TERMINAL_ID;
1103 const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId);
12221104 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
12231105 const shouldCreateNewTerminal =
1224 wantsNewTerminal && activeThread.terminalIds.length < MAX_THREAD_TERMINAL_COUNT;
1106 wantsNewTerminal && terminalState.terminalIds.length < MAX_THREAD_TERMINAL_COUNT;
12251107 const targetTerminalId = shouldCreateNewTerminal
12261108 ? `terminal-${crypto.randomUUID()}`
12271109 : baseTerminalId;
12281110
12291111 setTerminalOpen(true);
12301112 if (shouldCreateNewTerminal) {
1231 if (isLocalDraftThread) {
1232 setDraftThreadTerminalAction(activeThreadId, {
1233 type: "new",
1234 terminalId: targetTerminalId,
1235 });
1236 } else {
1237 dispatch({
1238 type: "NEW_THREAD_TERMINAL",
1239 threadId: activeThreadId,
1240 terminalId: targetTerminalId,
1241 });
1242 }
1113 storeNewTerminal(activeThreadId, targetTerminalId);
12431114 } else {
1244 if (isLocalDraftThread) {
1245 setDraftThreadTerminalAction(activeThreadId, {
1246 type: "set-active",
1247 terminalId: targetTerminalId,
1248 });
1249 } else {
1250 dispatch({
1251 type: "SET_THREAD_ACTIVE_TERMINAL",
1252 threadId: activeThreadId,
1253 terminalId: targetTerminalId,
1254 });
1255 }
1115 storeSetActiveTerminal(activeThreadId, targetTerminalId);
12561116 }
12571117 setTerminalFocusRequestId((value) => value + 1);
12581118
......@@ -1299,7 +1159,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
12991159 isServerThread,
13001160 setTerminalOpen,
13011161 setThreadError,
1302 setDraftThreadTerminalAction,
1162 storeNewTerminal,
1163 storeSetActiveTerminal,
13031164 ],
13041165 );
13051166 const persistProjectScripts = useCallback(
......@@ -1403,8 +1264,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
14031264 );
14041265
14051266 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
1406 if (mode === runtimeMode) return;
1407 setRuntimeMode(mode);
1267 if (mode === state.runtimeMode) return;
1268 dispatch.setRuntimeMode(mode);
14081269 scheduleComposerFocus();
14091270 const api = readNativeApi();
14101271 if (!api) return;
......@@ -1544,14 +1405,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
15441405 }, [activeThread?.id]);
15451406
15461407 useEffect(() => {
1547 if (!activeThread?.id || activeThread.terminalOpen) return;
1408 if (!activeThread?.id || terminalState.terminalOpen) return;
15481409 const frame = window.requestAnimationFrame(() => {
15491410 focusComposer();
15501411 });
15511412 return () => {
15521413 window.cancelAnimationFrame(frame);
15531414 };
1554 }, [activeThread?.id, activeThread?.terminalOpen, focusComposer]);
1415 }, [activeThread?.id, focusComposer, terminalState.terminalOpen]);
15551416
15561417 useEffect(() => {
15571418 composerImagesRef.current = composerImages;
......@@ -1762,7 +1623,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
17621623 useEffect(() => {
17631624 if (!activeThreadId) return;
17641625 const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false;
1765 const current = Boolean(activeThread?.terminalOpen);
1626 const current = Boolean(terminalState.terminalOpen);
17661627
17671628 if (!previous && current) {
17681629 setTerminalFocusRequestId((value) => value + 1);
......@@ -1777,7 +1638,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
17771638 }
17781639
17791640 terminalOpenByThreadRef.current[activeThreadId] = current;
1780 }, [activeThread?.terminalOpen, activeThreadId, focusComposer]);
1641 }, [activeThreadId, focusComposer, terminalState.terminalOpen]);
17811642
17821643 useEffect(() => {
17831644 const isTerminalFocused = (): boolean => {
......@@ -1791,7 +1652,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
17911652 if (!activeThreadId || event.defaultPrevented) return;
17921653 const shortcutContext = {
17931654 terminalFocus: isTerminalFocused(),
1794 terminalOpen: Boolean(activeThread?.terminalOpen),
1655 terminalOpen: Boolean(terminalState.terminalOpen),
17951656 };
17961657
17971658 const command = resolveShortcutCommand(event, keybindings, { context: shortcutContext });
......@@ -1807,7 +1668,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
18071668 if (command === "terminal.split") {
18081669 event.preventDefault();
18091670 event.stopPropagation();
1810 if (!activeThread?.terminalOpen) {
1671 if (!terminalState.terminalOpen) {
18111672 setTerminalOpen(true);
18121673 }
18131674 splitTerminal();
......@@ -1817,15 +1678,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
18171678 if (command === "terminal.close") {
18181679 event.preventDefault();
18191680 event.stopPropagation();
1820 if (!activeThread?.terminalOpen) return;
1821 closeTerminal(activeThread.activeTerminalId);
1681 if (!terminalState.terminalOpen) return;
1682 closeTerminal(terminalState.activeTerminalId);
18221683 return;
18231684 }
18241685
18251686 if (command === "terminal.new") {
18261687 event.preventDefault();
18271688 event.stopPropagation();
1828 if (!activeThread?.terminalOpen) {
1689 if (!terminalState.terminalOpen) {
18291690 setTerminalOpen(true);
18301691 }
18311692 createNewTerminal();
......@@ -1851,8 +1712,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
18511712 return () => window.removeEventListener("keydown", handler);
18521713 }, [
18531714 activeProject,
1854 activeThread?.terminalOpen,
1855 activeThread?.activeTerminalId,
1715 terminalState.terminalOpen,
1716 terminalState.activeTerminalId,
18561717 activeThreadId,
18571718 closeTerminal,
18581719 createNewTerminal,
......@@ -2028,10 +1889,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
20281889 const shouldCreateWorktree =
20291890 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
20301891 if (shouldCreateWorktree && !activeThread.branch) {
2031 setThreadErrorAction(
2032 threadIdForSend,
2033 "Select a base branch before sending in New worktree mode.",
2034 );
1892 dispatch.setError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
20351893 return;
20361894 }
20371895
......@@ -2104,11 +1962,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
21041962 });
21051963 // Keep local thread state in sync immediately so terminal drawer opens
21061964 // with the worktree cwd/env instead of briefly using the project root.
2107 setThreadBranchAction(
2108 threadIdForSend,
2109 result.worktree.branch,
2110 result.worktree.path,
2111 );
1965 dispatch.setThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path);
21121966 }
21131967 }
21141968
......@@ -2247,10 +2101,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
22472101 createdAt: new Date().toISOString(),
22482102 });
22492103 } catch (err) {
2250 setThreadErrorAction(
2251 activeThreadId,
2252 err instanceof Error ? err.message : "Failed to submit approval decision.",
2253 );
2104 dispatch.setError(activeThreadId, err instanceof Error ? err.message : "Failed to submit approval decision.");
2105 } finally {
2106 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
22542107 }
22552108 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
22562109 },
......@@ -2792,7 +2645,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
27922645 )}
27932646
27942647 {(() => {
2795 if (!activeThread.terminalOpen || !activeProject) {
2648 if (!terminalState.terminalOpen || !activeProject) {
27962649 return null;
27972650 }
27982651 return (
......@@ -2801,11 +2654,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
28012654 threadId={activeThread.id}
28022655 cwd={gitCwd ?? activeProject.cwd}
28032656 runtimeEnv={threadTerminalRuntimeEnv}
2804 height={activeThread.terminalHeight}
2805 terminalIds={activeThread.terminalIds}
2806 activeTerminalId={activeThread.activeTerminalId}
2807 terminalGroups={activeThread.terminalGroups}
2808 activeTerminalGroupId={activeThread.activeTerminalGroupId}
2657 height={terminalState.terminalHeight}
2658 terminalIds={terminalState.terminalIds}
2659 activeTerminalId={terminalState.activeTerminalId}
2660 terminalGroups={terminalState.terminalGroups}
2661 activeTerminalGroupId={terminalState.activeTerminalGroupId}
28092662 focusRequestId={terminalFocusRequestId}
28102663 onSplitTerminal={splitTerminal}
28112664 onNewTerminal={createNewTerminal}
apps/web/src/components/Sidebar.tsx+11-5
......@@ -21,6 +21,7 @@ import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/
2121import { serverConfigQueryOptions } from "../lib/serverReactQuery";
2222import { readNativeApi } from "../nativeApi";
2323import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";
24import { useTerminalStateStore } from "../terminalStateStore";
2425import { toastManager } from "./ui/toast";
2526import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
2627import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
......@@ -126,8 +127,10 @@ function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadS
126127 return null;
127128}
128129
129function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null {
130 if (thread.runningTerminalIds.length === 0) {
130function terminalStatusFromRunningIds(
131 runningTerminalIds: string[],
132): TerminalStatusIndicator | null {
133 if (runningTerminalIds.length === 0) {
131134 return null;
132135 }
133136 return {
......@@ -237,6 +240,7 @@ export default function Sidebar() {
237240 (store) => store.getDraftThreadByProjectId,
238241 );
239242 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
243 const getTerminalState = useTerminalStateStore((s) => s.getTerminalState);
240244 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
241245 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
242246 const clearProjectDraftThreadId = useComposerDraftStore(
......@@ -505,7 +509,7 @@ export default function Sidebar() {
505509 position,
506510 );
507511 if (clicked === "mark-unread") {
508 markThreadUnread(threadId);
512 dispatch.markThreadUnread(threadId);
509513 return;
510514 }
511515 if (clicked !== "delete") return;
......@@ -777,7 +781,7 @@ export default function Sidebar() {
777781 open={project.expanded}
778782 onOpenChange={(open) => {
779783 if (open === project.expanded) return;
780 toggleProject(project.id);
784 dispatch.toggleProject(project.id);
781785 }}
782786 >
783787 <SidebarMenuItem>
......@@ -819,7 +823,9 @@ export default function Sidebar() {
819823 pendingApprovalByThreadId.get(thread.id) === true,
820824 );
821825 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
822 const terminalStatus = terminalStatusIndicator(thread);
826 const terminalStatus = terminalStatusFromRunningIds(
827 getTerminalState(thread.id).runningTerminalIds,
828 );
823829
824830 return (
825831 <SidebarMenuSubItem key={thread.id} className="w-full">
apps/web/src/composerDraftStore.ts-59
......@@ -6,13 +6,6 @@ import {
66 normalizeModelSlug,
77 type ReasoningEffort,
88} from "@t3tools/contracts";
9import {
10 createDefaultThreadTerminalState,
11 getDefaultThreadTerminalState,
12 reduceThreadTerminalState,
13 type ThreadTerminalAction,
14 type ThreadTerminalState,
15} from "./threadTerminalState";
169import type { ChatImageAttachment } from "./types";
1710import { create } from "zustand";
1811import { createJSONStorage, persist } from "zustand/middleware";
......@@ -71,8 +64,6 @@ export interface DraftThreadState {
7164 branch: string | null;
7265 worktreePath: string | null;
7366 envMode: DraftThreadEnvMode;
74 /** Terminal layout for draft thread; same shape as Thread terminal slice. Omitted when not set. */
75 terminalState?: ThreadTerminalState;
7667}
7768
7869interface ProjectDraftThread extends DraftThreadState {
......@@ -122,9 +113,6 @@ interface ComposerDraftStoreState {
122113 ) => void;
123114 clearComposerContent: (threadId: ThreadId) => void;
124115 clearThreadDraft: (threadId: ThreadId) => void;
125 getDraftThreadTerminalState: (threadId: ThreadId) => ThreadTerminalState;
126 setDraftThreadTerminalAction: (threadId: ThreadId, action: ThreadTerminalAction) => void;
127 clearDraftThreadTerminalState: (threadId: ThreadId) => void;
128116}
129117
130118const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = {
......@@ -678,53 +666,6 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
678666 };
679667 });
680668 },
681 getDraftThreadTerminalState: (threadId) => {
682 if (threadId.length === 0) {
683 return getDefaultThreadTerminalState();
684 }
685 const draft = get().draftThreadsByThreadId[threadId];
686 return draft?.terminalState ?? getDefaultThreadTerminalState();
687 },
688 setDraftThreadTerminalAction: (threadId, action) => {
689 if (threadId.length === 0) {
690 return;
691 }
692 set((state) => {
693 const existing = state.draftThreadsByThreadId[threadId];
694 if (!existing) {
695 return state;
696 }
697 const current = existing.terminalState ?? createDefaultThreadTerminalState();
698 const next = reduceThreadTerminalState(current, action);
699 if (next === current) {
700 return state;
701 }
702 return {
703 draftThreadsByThreadId: {
704 ...state.draftThreadsByThreadId,
705 [threadId]: { ...existing, terminalState: next },
706 },
707 };
708 });
709 },
710 clearDraftThreadTerminalState: (threadId) => {
711 if (threadId.length === 0) {
712 return;
713 }
714 set((state) => {
715 const existing = state.draftThreadsByThreadId[threadId];
716 if (!existing || existing.terminalState === undefined) {
717 return state;
718 }
719 const { terminalState: _removed, ...rest } = existing;
720 return {
721 draftThreadsByThreadId: {
722 ...state.draftThreadsByThreadId,
723 [threadId]: rest,
724 },
725 };
726 });
727 },
728669 setPrompt: (threadId, prompt) => {
729670 if (threadId.length === 0) {
730671 return;
apps/web/src/routes/__root.tsx+4-3
......@@ -15,6 +15,7 @@ import { AnchoredToastProvider, ToastProvider, toastManager } from "../component
1515import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
1616import { readNativeApi } from "../nativeApi";
1717import { useStore } from "../store";
18import { useTerminalStateStore } from "../terminalStateStore";
1819import { preferredTerminalEditor } from "../terminal-links";
1920import { terminalRunningSubprocessFromEvent } from "../terminalActivity";
2021import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi";
......@@ -149,7 +150,7 @@ function EventRouter() {
149150 const snapshot = await api.orchestration.getSnapshot();
150151 if (disposed) return;
151152 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
152 syncServerReadModel(snapshot);
153 dispatch.syncServerReadModel(snapshot);
153154 if (pending) {
154155 pending = false;
155156 await flushSnapshotSync();
......@@ -188,7 +189,7 @@ function EventRouter() {
188189 if (hasRunningSubprocess === null) {
189190 return;
190191 }
191 setThreadTerminalActivity(
192 useTerminalStateStore.getState().setTerminalActivity(
192193 ThreadId.makeUnsafe(event.threadId),
193194 event.terminalId,
194195 hasRunningSubprocess,
......@@ -204,7 +205,7 @@ function EventRouter() {
204205 if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {
205206 return;
206207 }
207 setProjectExpanded(payload.bootstrapProjectId, true);
208 dispatch.setProjectExpanded(payload.bootstrapProjectId, true);
208209
209210 if (pathnameRef.current !== "/") {
210211 return;
apps/web/src/routes/_chat.settings.tsx+1-1
......@@ -349,7 +349,7 @@ function SettingsRouteView() {
349349 : "border-border bg-background text-muted-foreground hover:bg-accent"
350350 }`}
351351 onClick={() => {
352 setRuntimeMode(option.value);
352 dispatch.setRuntimeMode(option.value);
353353 }}
354354 >
355355 <span className="flex flex-col">
apps/web/src/store.test.ts+7-131
......@@ -1,8 +1,8 @@
11import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
22import { beforeEach, describe, expect, it } from "vitest";
33
4import { type AppState, useStore } from "./store";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";
4import { markThreadUnread, type AppState } from "./store";
5import type { Thread } from "./types";
66
77function makeThread(overrides: Partial<Thread> = {}): Thread {
88 return {
......@@ -11,18 +11,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
1111 projectId: ProjectId.makeUnsafe("project-1"),
1212 title: "Thread",
1313 model: "gpt-5-codex",
14 terminalOpen: false,
15 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
16 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
17 runningTerminalIds: [],
18 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
19 terminalGroups: [
20 {
21 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
22 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
23 },
24 ],
25 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
2614 session: null,
2715 messages: [],
2816 turnDiffSummaries: [],
......@@ -54,17 +42,8 @@ function makeState(thread: Thread): AppState {
5442 };
5543}
5644
57describe("store markThreadUnread action", () => {
58 beforeEach(() => {
59 useStore.setState({
60 projects: [],
61 threads: [],
62 threadsHydrated: false,
63 runtimeMode: "full-access",
64 });
65 });
66
67 it("marks a completed thread as unread by moving lastVisitedAt before completion", () => {
45describe("store pure functions", () => {
46 it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => {
6847 const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z";
6948 const initialState = makeState(
7049 makeThread({
......@@ -80,8 +59,7 @@ describe("store markThreadUnread action", () => {
8059 }),
8160 );
8261
83 useStore.setState(initialState);
84 useStore.getState().markThreadUnread(ThreadId.makeUnsafe("thread-1"));
62 const next = markThreadUnread(initialState, ThreadId.makeUnsafe("thread-1"));
8563
8664 const updatedThread = useStore.getState().threads[0];
8765 expect(updatedThread).toBeDefined();
......@@ -91,7 +69,7 @@ describe("store markThreadUnread action", () => {
9169 );
9270 });
9371
94 it("does not change a thread without a completed turn", () => {
72 it("markThreadUnread does not change a thread without a completed turn", () => {
9573 const initialState = makeState(
9674 makeThread({
9775 latestTurn: null,
......@@ -99,110 +77,8 @@ describe("store markThreadUnread action", () => {
9977 }),
10078 );
10179
102 useStore.setState(initialState);
103 useStore.getState().markThreadUnread(ThreadId.makeUnsafe("thread-1"));
80 const next = markThreadUnread(initialState, ThreadId.makeUnsafe("thread-1"));
10481
10582 expect(useStore.getState().threads[0]?.lastVisitedAt).toBe("2026-02-25T12:35:00.000Z");
10683 });
10784});
108
109describe("store terminal activity action", () => {
110 beforeEach(() => {
111 useStore.setState({
112 projects: [],
113 threads: [],
114 threadsHydrated: false,
115 runtimeMode: "full-access",
116 });
117 });
118
119 it("adds a terminal to runningTerminalIds when subprocess activity starts", () => {
120 const state = makeState(
121 makeThread({
122 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"],
123 terminalGroups: [
124 {
125 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
126 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"],
127 },
128 ],
129 }),
130 );
131 useStore.setState(state);
132 useStore
133 .getState()
134 .setThreadTerminalActivity(ThreadId.makeUnsafe("thread-1"), "alt", true);
135
136 expect(useStore.getState().threads[0]?.runningTerminalIds).toEqual(["alt"]);
137 });
138
139 it("removes a terminal from runningTerminalIds when subprocess activity stops", () => {
140 const state = makeState(
141 makeThread({
142 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"],
143 terminalGroups: [
144 {
145 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
146 terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "alt"],
147 },
148 ],
149 runningTerminalIds: ["alt"],
150 }),
151 );
152 useStore.setState(state);
153 useStore
154 .getState()
155 .setThreadTerminalActivity(ThreadId.makeUnsafe("thread-1"), "alt", false);
156
157 expect(useStore.getState().threads[0]?.runningTerminalIds).toEqual([]);
158 });
159
160 it("ignores activity events for unknown terminal ids", () => {
161 const state = makeState(makeThread());
162 useStore.setState(state);
163 useStore
164 .getState()
165 .setThreadTerminalActivity(ThreadId.makeUnsafe("thread-1"), "missing", true);
166
167 expect(useStore.getState().threads[0]?.runningTerminalIds).toEqual([]);
168 });
169
170 it("hydrates terminal state for an existing thread", () => {
171 const state = makeState(makeThread());
172 const next = reducer(state, {
173 type: "HYDRATE_THREAD_TERMINALS",
174 threadId: ThreadId.makeUnsafe("thread-1"),
175 terminalState: {
176 terminalOpen: true,
177 terminalHeight: 360,
178 terminalIds: ["default", "terminal-2"],
179 runningTerminalIds: ["terminal-2"],
180 activeTerminalId: "terminal-2",
181 terminalGroups: [
182 {
183 id: "group-default",
184 terminalIds: ["default"],
185 },
186 {
187 id: "group-terminal-2",
188 terminalIds: ["terminal-2"],
189 },
190 ],
191 activeTerminalGroupId: "group-terminal-2",
192 },
193 });
194
195 expect(next.threads[0]).toMatchObject({
196 terminalOpen: true,
197 terminalHeight: 360,
198 terminalIds: ["default", "terminal-2"],
199 runningTerminalIds: ["terminal-2"],
200 activeTerminalId: "terminal-2",
201 terminalGroups: [
202 { id: "group-default", terminalIds: ["default"] },
203 { id: "group-terminal-2", terminalIds: ["terminal-2"] },
204 ],
205 activeTerminalGroupId: "group-terminal-2",
206 });
207 });
208});
apps/web/src/store.ts+135-661
......@@ -1,6 +1,4 @@
1import { type ReactNode } from "react";
2import { create } from "zustand";
3
1import { Fragment, type ReactNode, createElement, useEffect } from "react";
42import {
53 DEFAULT_MODEL,
64 ProviderSessionId,
......@@ -9,12 +7,7 @@ import {
97 type OrchestrationSessionStatus,
108 resolveModelSlug,
119} from "@t3tools/contracts";
12import {
13 createDefaultThreadTerminalState,
14 normalizeThreadTerminalState,
15 reduceThreadTerminalState,
16 type ThreadTerminalState,
17} from "./threadTerminalState";
10import { create } from "zustand";
1811import {
1912 DEFAULT_RUNTIME_MODE,
2013 type ChatMessage,
......@@ -23,37 +16,6 @@ import {
2316 type Thread,
2417} from "./types";
2518
26// ── Actions ──────────────────────────────────────────────────────────
27
28type Action =
29 | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel }
30 | { type: "HYDRATE_THREAD_TERMINALS"; threadId: ThreadId; terminalState: ThreadTerminalState }
31 | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string }
32 | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId }
33 | { type: "TOGGLE_PROJECT"; projectId: Project["id"] }
34 | {
35 type: "SET_THREAD_TERMINAL_ACTIVITY";
36 threadId: ThreadId;
37 terminalId: string;
38 hasRunningSubprocess: boolean;
39 }
40 | { type: "SET_PROJECT_EXPANDED"; projectId: Project["id"]; expanded: boolean }
41 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }
42 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }
43 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }
44 | { type: "SPLIT_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
45 | { type: "NEW_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
46 | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: ThreadId; terminalId: string }
47 | { type: "CLOSE_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
48 | { type: "SET_ERROR"; threadId: ThreadId; error: string | null }
49 | {
50 type: "SET_THREAD_BRANCH";
51 threadId: ThreadId;
52 branch: string | null;
53 worktreePath: string | null;
54 }
55 | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode };
56
5719// ── State ────────────────────────────────────────────────────────────
5820
5921export interface AppState {
......@@ -106,11 +68,10 @@ const initialState: AppState = {
10668};
10769const persistedExpandedProjectCwds = new Set<string>();
10870
109// ── Helpers ──────────────────────────────────────────────────────────
71// ── Persist helpers ──────────────────────────────────────────────────
11072
11173function readPersistedState(): AppState {
11274 if (typeof window === "undefined") return initialState;
113
11475 try {
11576 const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);
11677 if (!raw) return initialState;
......@@ -138,7 +99,6 @@ function readPersistedState(): AppState {
13899
139100function persistState(state: AppState): void {
140101 if (typeof window === "undefined") return;
141
142102 try {
143103 window.localStorage.setItem(
144104 PERSISTED_STATE_KEY,
......@@ -157,6 +117,8 @@ function persistState(state: AppState): void {
157117 }
158118}
159119
120// ── Pure helpers ──────────────────────────────────────────────────────
121
160122function updateThread(
161123 threads: Thread[],
162124 threadId: ThreadId,
......@@ -212,9 +174,7 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" {
212174}
213175
214176function resolveWsHttpOrigin(): string {
215 if (typeof window === "undefined") {
216 return "";
217 }
177 if (typeof window === "undefined") return "";
218178 const bridgeWsUrl = window.desktopBridge?.getWsUrl?.();
219179 const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined;
220180 const wsCandidate =
......@@ -223,10 +183,7 @@ function resolveWsHttpOrigin(): string {
223183 : typeof envWsUrl === "string" && envWsUrl.length > 0
224184 ? envWsUrl
225185 : null;
226 if (!wsCandidate) {
227 return window.location.origin;
228 }
229
186 if (!wsCandidate) return window.location.origin;
230187 try {
231188 const wsUrl = new URL(wsCandidate);
232189 const protocol =
......@@ -252,328 +209,61 @@ function attachmentPreviewRoutePath(attachmentId: string): string {
252209 return `/attachments/${encodeURIComponent(attachmentId)}`;
253210}
254211
255function threadTerminalSlice(thread: Thread): ThreadTerminalState {
256 return {
257 terminalOpen: thread.terminalOpen,
258 terminalHeight: thread.terminalHeight,
259 terminalIds: thread.terminalIds,
260 runningTerminalIds: thread.runningTerminalIds,
261 activeTerminalId: thread.activeTerminalId,
262 terminalGroups: thread.terminalGroups,
263 activeTerminalGroupId: thread.activeTerminalGroupId,
264 };
265}
266
267function applyTerminalState(thread: Thread, terminal: ThreadTerminalState): Thread {
268 return { ...thread, ...terminal };
269}
270
271function normalizeThreadTerminals(thread: Thread): Thread {
272 return applyTerminalState(thread, normalizeThreadTerminalState(threadTerminalSlice(thread)));
273}
212// ── Pure state transition functions ────────────────────────────────────
274213
275function syncServerReadModelState(state: AppState, readModel: OrchestrationReadModel): AppState {
214export function syncServerReadModel(
215 state: AppState,
216 readModel: OrchestrationReadModel,
217): AppState {
276218 const projects = mapProjectsFromReadModel(
277219 readModel.projects.filter((project) => project.deletedAt === null),
278220 state.projects,
279221 );
280 const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const));
222 const existingThreadById = new Map(
223 state.threads.map((thread) => [thread.id, thread] as const),
224 );
281225 const threads = readModel.threads
282226 .filter((thread) => thread.deletedAt === null)
283227 .map((thread) => {
284228 const existing = existingThreadById.get(thread.id);
285
286export function reducer(state: AppState, action: Action): AppState {
287 switch (action.type) {
288 case "SYNC_SERVER_READ_MODEL": {
289 const projects = mapProjectsFromReadModel(
290 action.readModel.projects.filter((project) => project.deletedAt === null),
291 state.projects,
292 );
293 const existingThreadById = new Map(
294 state.threads.map((thread) => [thread.id, thread] as const),
295 );
296 const defaultTerminal = createDefaultThreadTerminalState();
297 const threads = action.readModel.threads
298 .filter((thread) => thread.deletedAt === null)
299 .map((thread) => {
300 const existing = existingThreadById.get(thread.id);
301 const terminalSlice: ThreadTerminalState = existing
302 ? threadTerminalSlice(existing)
303 : defaultTerminal;
304
305 return normalizeThreadTerminals({
306 id: thread.id,
307 codexThreadId: thread.session?.providerThreadId ?? null,
308 projectId: thread.projectId,
309 title: thread.title,
310 model: resolveModelSlug(thread.model),
311 ...terminalSlice,
312 session: thread.session
313 ? {
314 sessionId:
315 thread.session.providerSessionId ??
316 ProviderSessionId.makeUnsafe(`thread:${thread.id}`),
317 provider: toLegacyProvider(thread.session.providerName),
318 status: toLegacySessionStatus(thread.session.status),
319 orchestrationStatus: thread.session.status,
320 threadId: thread.session.providerThreadId,
321 activeTurnId: thread.session.activeTurnId ?? undefined,
322 createdAt: thread.session.updatedAt,
323 updatedAt: thread.session.updatedAt,
324 ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}),
325 }
326 : null,
327 messages: thread.messages.map((message) => {
328 const attachments = message.attachments?.map((attachment) => ({
329 type: "image" as const,
330 id: attachment.id,
331 name: attachment.name,
332 mimeType: attachment.mimeType,
333 sizeBytes: attachment.sizeBytes,
334 previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)),
335 }));
336 const normalizedMessage: ChatMessage = {
337 id: message.id,
338 role: message.role,
339 text: message.text,
340 createdAt: message.createdAt,
341 streaming: message.streaming,
342 ...(message.streaming ? {} : { completedAt: message.updatedAt }),
343 ...(attachments && attachments.length > 0 ? { attachments } : {}),
344 };
345 return normalizedMessage;
346 }),
347 error: thread.session?.lastError ?? null,
348 createdAt: thread.createdAt,
349 latestTurn: thread.latestTurn,
350 lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,
351 branch: thread.branch,
352 worktreePath: thread.worktreePath,
353 turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({
354 turnId: checkpoint.turnId,
355 completedAt: checkpoint.completedAt,
356 status: checkpoint.status,
357 assistantMessageId: checkpoint.assistantMessageId ?? undefined,
358 checkpointTurnCount: checkpoint.checkpointTurnCount,
359 checkpointRef: checkpoint.checkpointRef,
360 files: checkpoint.files.map((file) => ({ ...file })),
361 })),
362 activities: thread.activities.map((activity) => ({ ...activity })),
363 });
364 });
365229 return {
366 ...state,
367 projects,
368 threads,
369 threadsHydrated: true,
370 };
371 }
372
373 case "HYDRATE_THREAD_TERMINALS":
374 return {
375 ...state,
376 threads: updateThread(state.threads, action.threadId, (thread) =>
377 applyTerminalState(thread, normalizeThreadTerminalState(action.terminalState)),
378 ),
379 };
380
381 case "MARK_THREAD_VISITED": {
382 const visitedAt = action.visitedAt ?? new Date().toISOString();
383 const visitedAtMs = Date.parse(visitedAt);
384 return {
385 ...state,
386 threads: updateThread(state.threads, action.threadId, (thread) => {
387 const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN;
388 if (
389 Number.isFinite(previousVisitedAtMs) &&
390 Number.isFinite(visitedAtMs) &&
391 previousVisitedAtMs >= visitedAtMs
392 ) {
393 return thread;
394 }
395 return {
396 ...thread,
397 lastVisitedAt: visitedAt,
398 };
399 }),
400 };
401 }
402
403 case "MARK_THREAD_UNREAD": {
404 return {
405 ...state,
406 threads: updateThread(state.threads, action.threadId, (thread) => {
407 if (!thread.latestTurn?.completedAt) {
408 return thread;
409 }
410 const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt);
411 if (Number.isNaN(latestTurnCompletedAtMs)) {
412 return thread;
413 }
414 const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString();
415 if (thread.lastVisitedAt === unreadVisitedAt) {
416 return thread;
417 }
418 return {
419 ...thread,
420 lastVisitedAt: unreadVisitedAt,
421 };
422 }),
423 };
424 }
425
426 case "TOGGLE_PROJECT":
427 return {
428 ...state,
429 projects: state.projects.map((p) =>
430 p.id === action.projectId ? { ...p, expanded: !p.expanded } : p,
431 ),
432 };
433
434 case "SET_THREAD_TERMINAL_ACTIVITY":
435 return {
436 ...state,
437 threads: updateThread(state.threads, action.threadId, (thread) =>
438 applyTerminalState(
439 thread,
440 reduceThreadTerminalState(threadTerminalSlice(thread), {
441 type: "set-activity",
442 terminalId: action.terminalId,
443 hasRunningSubprocess: action.hasRunningSubprocess,
444 }),
445 ),
446 ),
447 };
448
449 case "SET_PROJECT_EXPANDED":
450 return {
451 ...state,
452 projects: state.projects.map((p) =>
453 p.id === action.projectId ? { ...p, expanded: action.expanded } : p,
454 ),
455 };
456
457 case "TOGGLE_THREAD_TERMINAL":
458 return {
459 ...state,
460 threads: updateThread(state.threads, action.threadId, (thread) =>
461 applyTerminalState(
462 thread,
463 reduceThreadTerminalState(threadTerminalSlice(thread), {
464 type: "set-open",
465 open: !thread.terminalOpen,
466 }),
467 ),
468 ),
469 };
470
471 case "SET_THREAD_TERMINAL_OPEN":
472 return {
473 ...state,
474 threads: updateThread(state.threads, action.threadId, (thread) =>
475 applyTerminalState(
476 thread,
477 reduceThreadTerminalState(threadTerminalSlice(thread), {
478 type: "set-open",
479 open: action.open,
480 }),
481 ),
482 ),
483 };
484
485 case "SET_THREAD_TERMINAL_HEIGHT":
486 return {
487 ...state,
488 threads: updateThread(state.threads, action.threadId, (thread) =>
489 applyTerminalState(
490 thread,
491 reduceThreadTerminalState(threadTerminalSlice(thread), {
492 type: "set-height",
493 height: action.height,
494 }),
495 ),
496 ),
497 };
498
499 case "SPLIT_THREAD_TERMINAL":
500 return {
501 ...state,
502 threads: updateThread(state.threads, action.threadId, (thread) =>
503 applyTerminalState(
504 thread,
505 reduceThreadTerminalState(threadTerminalSlice(thread), {
506 type: "split",
507 terminalId: action.terminalId,
508 }),
509 ),
510 ),
511 };
512
513 case "NEW_THREAD_TERMINAL":
514 return {
515 ...state,
516 threads: updateThread(state.threads, action.threadId, (thread) =>
517 applyTerminalState(
518 thread,
519 reduceThreadTerminalState(threadTerminalSlice(thread), {
520 type: "new",
521 terminalId: action.terminalId,
522 }),
523 ),
524 ),
525 };
526
527 case "SET_THREAD_ACTIVE_TERMINAL":
528 return {
529 ...state,
530 threads: updateThread(state.threads, action.threadId, (thread) =>
531 applyTerminalState(
532 thread,
533 reduceThreadTerminalState(threadTerminalSlice(thread), {
534 type: "set-active",
535 terminalId: action.terminalId,
536 }),
537 ),
538 ),
539 };
540
541 case "CLOSE_THREAD_TERMINAL":
542 return {
543 ...state,
544 threads: updateThread(state.threads, action.threadId, (thread) =>
545 applyTerminalState(
546 thread,
547 reduceThreadTerminalState(threadTerminalSlice(thread), {
548 type: "close",
549 terminalId: action.terminalId,
550 }),
551 ),
552 ),
553 };
554
555 case "SET_ERROR":
556 return {
557 ...state,
558 threads: updateThread(state.threads, action.threadId, (t) => ({
559 ...t,
560 error: action.error,
561 })),
562 };
563
564 case "SET_THREAD_BRANCH": {
565 return {
566 ...state,
567 threads: updateThread(state.threads, action.threadId, (t) => {
568 // When the effective cwd changes (worktreePath differs), the old
569 // session is no longer valid — clear it so ensureSession creates a
570 // new one with the correct cwd on the next message.
571 const cwdChanged = t.worktreePath !== action.worktreePath;
572 return {
573 ...t,
574 branch: action.branch,
575 worktreePath: action.worktreePath,
576 ...(cwdChanged ? { session: null } : {}),
230 id: thread.id,
231 codexThreadId: thread.session?.providerThreadId ?? null,
232 projectId: thread.projectId,
233 title: thread.title,
234 model: resolveModelSlug(thread.model),
235 session: thread.session
236 ? {
237 sessionId:
238 thread.session.providerSessionId ??
239 ProviderSessionId.makeUnsafe(`thread:${thread.id}`),
240 provider: toLegacyProvider(thread.session.providerName),
241 status: toLegacySessionStatus(thread.session.status),
242 orchestrationStatus: thread.session.status,
243 threadId: thread.session.providerThreadId,
244 activeTurnId: thread.session.activeTurnId ?? undefined,
245 createdAt: thread.session.updatedAt,
246 updatedAt: thread.session.updatedAt,
247 ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}),
248 }
249 : null,
250 messages: thread.messages.map((message) => {
251 const attachments = message.attachments?.map((attachment) => ({
252 type: "image" as const,
253 id: attachment.id,
254 name: attachment.name,
255 mimeType: attachment.mimeType,
256 sizeBytes: attachment.sizeBytes,
257 previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)),
258 }));
259 const normalizedMessage: ChatMessage = {
260 id: message.id,
261 role: message.role,
262 text: message.text,
263 createdAt: message.createdAt,
264 streaming: message.streaming,
265 ...(message.streaming ? {} : { completedAt: message.updatedAt }),
266 ...(attachments && attachments.length > 0 ? { attachments } : {}),
577267 };
578268 return normalizedMessage;
579269 }),
......@@ -593,7 +283,7 @@ export function reducer(state: AppState, action: Action): AppState {
593283 files: checkpoint.files.map((file) => ({ ...file })),
594284 })),
595285 activities: thread.activities.map((activity) => ({ ...activity })),
596 });
286 };
597287 });
598288 return {
599289 ...state,
......@@ -603,9 +293,13 @@ export function reducer(state: AppState, action: Action): AppState {
603293 };
604294}
605295
606function markThreadVisitedState(state: AppState, threadId: ThreadId, visitedAt?: string): AppState {
607 const nextVisitedAt = visitedAt ?? new Date().toISOString();
608 const visitedAtMs = Date.parse(nextVisitedAt);
296export function markThreadVisited(
297 state: AppState,
298 threadId: ThreadId,
299 visitedAt?: string,
300): AppState {
301 const at = visitedAt ?? new Date().toISOString();
302 const visitedAtMs = Date.parse(at);
609303 return {
610304 ...state,
611305 threads: updateThread(state.threads, threadId, (thread) => {
......@@ -617,269 +311,55 @@ function markThreadVisitedState(state: AppState, threadId: ThreadId, visitedAt?:
617311 ) {
618312 return thread;
619313 }
620 return {
621 ...thread,
622 lastVisitedAt: nextVisitedAt,
623 };
314 return { ...thread, lastVisitedAt: at };
624315 }),
625316 };
626317}
627318
628function markThreadUnreadState(state: AppState, threadId: ThreadId): AppState {
319export function markThreadUnread(state: AppState, threadId: ThreadId): AppState {
629320 return {
630321 ...state,
631322 threads: updateThread(state.threads, threadId, (thread) => {
632 if (!thread.latestTurn?.completedAt) {
633 return thread;
634 }
323 if (!thread.latestTurn?.completedAt) return thread;
635324 const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt);
636 if (Number.isNaN(latestTurnCompletedAtMs)) {
637 return thread;
638 }
325 if (Number.isNaN(latestTurnCompletedAtMs)) return thread;
639326 const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString();
640 if (thread.lastVisitedAt === unreadVisitedAt) {
641 return thread;
642 }
643 return {
644 ...thread,
645 lastVisitedAt: unreadVisitedAt,
646 };
327 if (thread.lastVisitedAt === unreadVisitedAt) return thread;
328 return { ...thread, lastVisitedAt: unreadVisitedAt };
647329 }),
648330 };
649331}
650332
651function toggleProjectState(state: AppState, projectId: Project["id"]): AppState {
333export function toggleProject(state: AppState, projectId: Project["id"]): AppState {
652334 return {
653335 ...state,
654 projects: state.projects.map((project) =>
655 project.id === projectId ? { ...project, expanded: !project.expanded } : project,
336 projects: state.projects.map((p) =>
337 p.id === projectId ? { ...p, expanded: !p.expanded } : p,
656338 ),
657339 };
658340}
659341
660function setThreadTerminalActivityState(
661 state: AppState,
662 threadId: ThreadId,
663 terminalId: string,
664 hasRunningSubprocess: boolean,
665): AppState {
666 return {
667 ...state,
668 threads: updateThread(state.threads, threadId, (thread) =>
669 setThreadTerminalActivity(thread, terminalId, hasRunningSubprocess),
670 ),
671 };
672}
673
674function setProjectExpandedState(
342export function setProjectExpanded(
675343 state: AppState,
676344 projectId: Project["id"],
677345 expanded: boolean,
678346): AppState {
679347 return {
680348 ...state,
681 projects: state.projects.map((project) =>
682 project.id === projectId ? { ...project, expanded } : project,
349 projects: state.projects.map((p) =>
350 p.id === projectId ? { ...p, expanded } : p,
683351 ),
684352 };
685353}
686354
687function toggleThreadTerminalState(state: AppState, threadId: ThreadId): AppState {
688 return {
689 ...state,
690 threads: updateThread(state.threads, threadId, (thread) => ({
691 ...thread,
692 terminalOpen: !thread.terminalOpen,
693 })),
694 };
695}
696
697function setThreadTerminalOpenState(state: AppState, threadId: ThreadId, open: boolean): AppState {
698 return {
699 ...state,
700 threads: updateThread(state.threads, threadId, (thread) => ({
701 ...thread,
702 terminalOpen: open,
703 })),
704 };
705}
706
707function setThreadTerminalHeightState(
708 state: AppState,
709 threadId: ThreadId,
710 height: number,
711): AppState {
712 return {
713 ...state,
714 threads: updateThread(state.threads, threadId, (thread) => ({
715 ...thread,
716 terminalHeight: height,
717 })),
718 };
719}
720
721function splitThreadTerminalState(
722 state: AppState,
723 threadId: ThreadId,
724 terminalId: string,
725): AppState {
726 return {
727 ...state,
728 threads: updateThread(state.threads, threadId, (thread) => {
729 const normalizedThread = normalizeThreadTerminals(thread);
730 const isNewTerminal = !normalizedThread.terminalIds.includes(terminalId);
731 if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
732 return normalizedThread;
733 }
734 const terminalIds = normalizedThread.terminalIds.includes(terminalId)
735 ? normalizedThread.terminalIds
736 : [...normalizedThread.terminalIds, terminalId];
737 const terminalGroups = normalizedThread.terminalGroups.map((group) => ({
738 ...group,
739 terminalIds: [...group.terminalIds],
740 }));
741 let activeGroupIndex = terminalGroups.findIndex(
742 (group) => group.id === normalizedThread.activeTerminalGroupId,
743 );
744 if (activeGroupIndex < 0) {
745 activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalizedThread.activeTerminalId);
746 }
747 if (activeGroupIndex < 0) {
748 terminalGroups.push({
749 id: fallbackGroupId(normalizedThread.activeTerminalId),
750 terminalIds: [normalizedThread.activeTerminalId],
751 });
752 activeGroupIndex = terminalGroups.length - 1;
753 }
754
755 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId);
756 if (existingGroupIndex >= 0) {
757 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter(
758 (id) => id !== terminalId,
759 );
760 if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {
761 terminalGroups.splice(existingGroupIndex, 1);
762 if (existingGroupIndex < activeGroupIndex) {
763 activeGroupIndex -= 1;
764 }
765 }
766 }
767
768 const destinationGroup = terminalGroups[activeGroupIndex];
769 if (!destinationGroup) {
770 return normalizedThread;
771 }
772 if (!destinationGroup.terminalIds.includes(terminalId)) {
773 const anchorIndex = destinationGroup.terminalIds.indexOf(normalizedThread.activeTerminalId);
774 if (anchorIndex >= 0) {
775 destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId);
776 } else {
777 destinationGroup.terminalIds.push(terminalId);
778 }
779 }
780 return normalizeThreadTerminals({
781 ...normalizedThread,
782 terminalIds,
783 activeTerminalId: terminalId,
784 activeTerminalGroupId: destinationGroup.id,
785 terminalGroups,
786 });
787 }),
788 };
789}
790
791function newThreadTerminalState(
792 state: AppState,
793 threadId: ThreadId,
794 terminalId: string,
795): AppState {
796 return {
797 ...state,
798 threads: updateThread(state.threads, threadId, (thread) => {
799 const normalizedThread = normalizeThreadTerminals(thread);
800 const isNewTerminal = !normalizedThread.terminalIds.includes(terminalId);
801 if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
802 return normalizedThread;
803 }
804 const terminalIds = normalizedThread.terminalIds.includes(terminalId)
805 ? normalizedThread.terminalIds
806 : [...normalizedThread.terminalIds, terminalId];
807 const terminalGroups = normalizedThread.terminalGroups
808 .map((group) => ({
809 ...group,
810 terminalIds: group.terminalIds.filter((id) => id !== terminalId),
811 }))
812 .filter((group) => group.terminalIds.length > 0);
813 const nextGroupId = fallbackGroupId(terminalId);
814 terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] });
815
816 return normalizeThreadTerminals({
817 ...normalizedThread,
818 terminalIds,
819 activeTerminalId: terminalId,
820 activeTerminalGroupId: nextGroupId,
821 terminalGroups,
822 });
823 }),
824 };
825}
826
827function setThreadActiveTerminalState(
828 state: AppState,
829 threadId: ThreadId,
830 terminalId: string,
831): AppState {
832 return {
833 ...state,
834 threads: updateThread(state.threads, threadId, (thread) => {
835 const normalizedThread = normalizeThreadTerminals(thread);
836 if (!normalizedThread.terminalIds.includes(terminalId)) {
837 return thread;
838 }
839 const nextActiveGroupIndex = findGroupIndexByTerminalId(
840 normalizedThread.terminalGroups,
841 terminalId,
842 );
843 const activeTerminalGroupId =
844 nextActiveGroupIndex >= 0
845 ? (normalizedThread.terminalGroups[nextActiveGroupIndex]?.id ??
846 normalizedThread.activeTerminalGroupId)
847 : normalizedThread.activeTerminalGroupId;
848 return normalizeThreadTerminals({
849 ...normalizedThread,
850 activeTerminalId: terminalId,
851 activeTerminalGroupId,
852 });
853 }),
854 };
855}
856
857function closeThreadTerminalState(
858 state: AppState,
859 threadId: ThreadId,
860 terminalId: string,
861): AppState {
862 return {
863 ...state,
864 threads: updateThread(state.threads, threadId, (thread) => closeThreadTerminal(thread, terminalId)),
865 };
866}
867
868function setThreadErrorState(
869 state: AppState,
870 threadId: ThreadId,
871 error: string | null,
872): AppState {
355export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
873356 return {
874357 ...state,
875 threads: updateThread(state.threads, threadId, (thread) => ({
876 ...thread,
877 error,
878 })),
358 threads: updateThread(state.threads, threadId, (t) => ({ ...t, error })),
879359 };
880360}
881361
882function setThreadBranchState(
362export function setThreadBranch(
883363 state: AppState,
884364 threadId: ThreadId,
885365 branch: string | null,
......@@ -887,13 +367,10 @@ function setThreadBranchState(
887367): AppState {
888368 return {
889369 ...state,
890 threads: updateThread(state.threads, threadId, (thread) => {
891 // When the effective cwd changes (worktreePath differs), the old
892 // session is no longer valid — clear it so ensureSession creates a
893 // new one with the correct cwd on the next message.
894 const cwdChanged = thread.worktreePath !== worktreePath;
370 threads: updateThread(state.threads, threadId, (t) => {
371 const cwdChanged = t.worktreePath !== worktreePath;
895372 return {
896 ...thread,
373 ...t,
897374 branch,
898375 worktreePath,
899376 ...(cwdChanged ? { session: null } : {}),
......@@ -902,71 +379,68 @@ function setThreadBranchState(
902379 };
903380}
904381
905function setRuntimeModeState(state: AppState, mode: RuntimeMode): AppState {
906 return {
907 ...state,
908 runtimeMode: mode,
909 };
382export function setRuntimeMode(state: AppState, mode: RuntimeMode): AppState {
383 return { ...state, runtimeMode: mode };
910384}
911385
912function toAppState(store: AppStore): AppState {
913 return {
914 projects: store.projects,
915 threads: store.threads,
916 threadsHydrated: store.threadsHydrated,
917 runtimeMode: store.runtimeMode,
918 };
386// ── Zustand store ────────────────────────────────────────────────────
387
388interface AppStore extends AppState {
389 syncServerReadModel: (readModel: OrchestrationReadModel) => void;
390 markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void;
391 markThreadUnread: (threadId: ThreadId) => void;
392 toggleProject: (projectId: Project["id"]) => void;
393 setProjectExpanded: (projectId: Project["id"], expanded: boolean) => void;
394 setError: (threadId: ThreadId, error: string | null) => void;
395 setThreadBranch: (
396 threadId: ThreadId,
397 branch: string | null,
398 worktreePath: string | null,
399 ) => void;
400 setRuntimeMode: (mode: RuntimeMode) => void;
919401}
920402
921export const useStore = create<AppStore>((set) => {
922 const setAppState = (updater: (state: AppState) => AppState): void => {
923 set((store) => {
924 const currentState = toAppState(store);
925 const nextState = updater(currentState);
926 if (nextState === currentState) {
927 return store;
928 }
929 persistState(nextState);
930 return nextState;
931 });
932 };
403export const useAppStore = create<AppStore>((set, _get) => ({
404 ...readPersistedState(),
405 syncServerReadModel: (readModel) =>
406 set((state) => syncServerReadModel(state, readModel)),
407 markThreadVisited: (threadId, visitedAt) =>
408 set((state) => markThreadVisited(state, threadId, visitedAt)),
409 markThreadUnread: (threadId) =>
410 set((state) => markThreadUnread(state, threadId)),
411 toggleProject: (projectId) =>
412 set((state) => toggleProject(state, projectId)),
413 setProjectExpanded: (projectId, expanded) =>
414 set((state) => setProjectExpanded(state, projectId, expanded)),
415 setError: (threadId, error) =>
416 set((state) => setError(state, threadId, error)),
417 setThreadBranch: (threadId, branch, worktreePath) =>
418 set((state) => setThreadBranch(state, threadId, branch, worktreePath)),
419 setRuntimeMode: (mode) =>
420 set((state) => setRuntimeMode(state, mode)),
421}));
422
423// Persist on every state change (only runtimeMode + expandedProjectCwds)
424useAppStore.subscribe((state) => persistState(state));
425
426// ── useStore (state + store API for call sites that want both) ────────────────
933427
428export function StoreProvider({ children }: { children: ReactNode }) {
429 useEffect(() => {
430 persistState(useAppStore.getState());
431 }, []);
432 return createElement(Fragment, null, children);
433}
434
435export function useStore() {
436 const state = useAppStore((s) => ({
437 projects: s.projects,
438 threads: s.threads,
439 threadsHydrated: s.threadsHydrated,
440 runtimeMode: s.runtimeMode,
441 }));
934442 return {
935 ...readPersistedState(),
936 syncServerReadModel: (readModel) =>
937 setAppState((state) => syncServerReadModelState(state, readModel)),
938 markThreadVisited: (threadId, visitedAt) =>
939 setAppState((state) => markThreadVisitedState(state, threadId, visitedAt)),
940 markThreadUnread: (threadId) => setAppState((state) => markThreadUnreadState(state, threadId)),
941 toggleProject: (projectId) => setAppState((state) => toggleProjectState(state, projectId)),
942 setThreadTerminalActivity: (threadId, terminalId, hasRunningSubprocess) =>
943 setAppState((state) =>
944 setThreadTerminalActivityState(state, threadId, terminalId, hasRunningSubprocess),
945 ),
946 setProjectExpanded: (projectId, expanded) =>
947 setAppState((state) => setProjectExpandedState(state, projectId, expanded)),
948 toggleThreadTerminal: (threadId) =>
949 setAppState((state) => toggleThreadTerminalState(state, threadId)),
950 setThreadTerminalOpen: (threadId, open) =>
951 setAppState((state) => setThreadTerminalOpenState(state, threadId, open)),
952 setThreadTerminalHeight: (threadId, height) =>
953 setAppState((state) => setThreadTerminalHeightState(state, threadId, height)),
954 splitThreadTerminal: (threadId, terminalId) =>
955 setAppState((state) => splitThreadTerminalState(state, threadId, terminalId)),
956 newThreadTerminal: (threadId, terminalId) =>
957 setAppState((state) => newThreadTerminalState(state, threadId, terminalId)),
958 setThreadActiveTerminal: (threadId, terminalId) =>
959 setAppState((state) => setThreadActiveTerminalState(state, threadId, terminalId)),
960 closeThreadTerminal: (threadId, terminalId) =>
961 setAppState((state) => closeThreadTerminalState(state, threadId, terminalId)),
962 setThreadError: (threadId, error) =>
963 setAppState((state) => setThreadErrorState(state, threadId, error)),
964 setThreadBranch: (threadId, branch, worktreePath) =>
965 setAppState((state) => setThreadBranchState(state, threadId, branch, worktreePath)),
966 setRuntimeMode: (mode) => setAppState((state) => setRuntimeModeState(state, mode)),
443 state,
444 dispatch: useAppStore.getState(),
967445 };
968});
969
970export function StoreProvider({ children }: { children: ReactNode }) {
971 return children;
972446}
apps/web/src/terminalStateStore.ts created+110
......@@ -0,0 +1,110 @@
1/**
2 * Single Zustand store for terminal UI state keyed by threadId. Used by both
3 * persisted (server) threads and draft threads; threadId is stable for both.
4 */
5
6import type { ThreadId } from "@t3tools/contracts";
7import { create } from "zustand";
8import { createJSONStorage, persist } from "zustand/middleware";
9import {
10 createDefaultThreadTerminalState,
11 getDefaultThreadTerminalState,
12 reduceThreadTerminalState,
13 type ThreadTerminalAction,
14 type ThreadTerminalState,
15} from "./threadTerminalState";
16
17export const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1";
18
19function applyTerminalAction(
20 state: Record<ThreadId, ThreadTerminalState>,
21 threadId: ThreadId,
22 action: ThreadTerminalAction,
23): Record<ThreadId, ThreadTerminalState> {
24 const current = state[threadId] ?? createDefaultThreadTerminalState();
25 const next = reduceThreadTerminalState(current, action);
26 if (next === current) {
27 return state;
28 }
29 return {
30 ...state,
31 [threadId]: next,
32 };
33}
34
35interface TerminalStateStoreState {
36 terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>;
37 getTerminalState: (threadId: ThreadId) => ThreadTerminalState;
38 setTerminalOpen: (threadId: ThreadId, open: boolean) => void;
39 setTerminalHeight: (threadId: ThreadId, height: number) => void;
40 splitTerminal: (threadId: ThreadId, terminalId: string) => void;
41 newTerminal: (threadId: ThreadId, terminalId: string) => void;
42 setActiveTerminal: (threadId: ThreadId, terminalId: string) => void;
43 closeTerminal: (threadId: ThreadId, terminalId: string) => void;
44 setTerminalActivity: (
45 threadId: ThreadId,
46 terminalId: string,
47 hasRunningSubprocess: boolean,
48 ) => void;
49 clearTerminalState: (threadId: ThreadId) => void;
50}
51
52export const useTerminalStateStore = create<TerminalStateStoreState>()(
53 persist(
54 (set, get) => {
55 const updateTerminal = (threadId: ThreadId, action: ThreadTerminalAction) => {
56 if (threadId.length === 0) return;
57 set((state) => ({
58 terminalStateByThreadId: applyTerminalAction(
59 state.terminalStateByThreadId,
60 threadId,
61 action,
62 ),
63 }));
64 };
65 return {
66 terminalStateByThreadId: {},
67 getTerminalState: (threadId) => {
68 if (threadId.length === 0) {
69 return getDefaultThreadTerminalState();
70 }
71 const state = get().terminalStateByThreadId[threadId];
72 return state ?? getDefaultThreadTerminalState();
73 },
74 setTerminalOpen: (threadId, open) => updateTerminal(threadId, { type: "set-open", open }),
75 setTerminalHeight: (threadId, height) =>
76 updateTerminal(threadId, { type: "set-height", height }),
77 splitTerminal: (threadId, terminalId) =>
78 updateTerminal(threadId, { type: "split", terminalId }),
79 newTerminal: (threadId, terminalId) =>
80 updateTerminal(threadId, { type: "new", terminalId }),
81 setActiveTerminal: (threadId, terminalId) =>
82 updateTerminal(threadId, { type: "set-active", terminalId }),
83 closeTerminal: (threadId, terminalId) =>
84 updateTerminal(threadId, { type: "close", terminalId }),
85 setTerminalActivity: (threadId, terminalId, hasRunningSubprocess) =>
86 updateTerminal(threadId, { type: "set-activity", terminalId, hasRunningSubprocess }),
87 clearTerminalState: (threadId) => {
88 if (threadId.length === 0) return;
89 set((state) => {
90 if (state.terminalStateByThreadId[threadId] === undefined) {
91 return state;
92 }
93 const { [threadId]: _removed, ...rest } = state.terminalStateByThreadId;
94 return {
95 terminalStateByThreadId: rest as Record<ThreadId, ThreadTerminalState>,
96 };
97 });
98 },
99 };
100 },
101 {
102 name: TERMINAL_STATE_STORAGE_KEY,
103 version: 1,
104 storage: createJSONStorage(() => localStorage),
105 partialize: (state) => ({
106 terminalStateByThreadId: state.terminalStateByThreadId,
107 }),
108 },
109 ),
110);
apps/web/src/types.ts-7
......@@ -79,13 +79,6 @@ export interface Thread {
7979 projectId: ProjectId;
8080 title: string;
8181 model: string;
82 terminalOpen: boolean;
83 terminalHeight: number;
84 terminalIds: string[];
85 runningTerminalIds: string[];
86 activeTerminalId: string;
87 terminalGroups: ThreadTerminalGroup[];
88 activeTerminalGroupId: string;
8982 session: ThreadSession | null;
9083 messages: ChatMessage[];
9184 error: string | null;
apps/web/src/worktreeCleanup.test.ts+1-13
......@@ -1,7 +1,7 @@
11import { ProjectId, ThreadId } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33
4import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";
4import type { Thread } from "./types";
55import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup";
66
77function makeThread(overrides: Partial<Thread> = {}): Thread {
......@@ -11,18 +11,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
1111 projectId: ProjectId.makeUnsafe("project-1"),
1212 title: "Thread",
1313 model: "gpt-5.3-codex",
14 terminalOpen: false,
15 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
16 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
17 runningTerminalIds: [],
18 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
19 terminalGroups: [
20 {
21 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
22 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
23 },
24 ],
25 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
2614 session: null,
2715 messages: [],
2816 turnDiffSummaries: [],