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({...@@ -119,7 +119,7 @@ export default function BranchToolbar({
119 });119 });
120 }120 }
121 if (hasServerThread) {121 if (hasServerThread) {
122 setThreadBranchAction(activeThreadId, syncedBranch, null);122 dispatch.setThreadBranch(activeThreadId, syncedBranch, null);
123 return;123 return;
124 }124 }
125 setDraftThreadContext(threadId, {125 setDraftThreadContext(threadId, {
...@@ -142,7 +142,7 @@ export default function BranchToolbar({...@@ -142,7 +142,7 @@ export default function BranchToolbar({
142142
143 const setThreadError = (error: string | null) => {143 const setThreadError = (error: string | null) => {
144 if (!activeThreadId) return;144 if (!activeThreadId) return;
145 setThreadErrorAction(activeThreadId, error);145 dispatch.setError(activeThreadId, error);
146 };146 };
147147
148 const setThreadBranch = (branch: string | null, worktreePath: string | null) => {148 const setThreadBranch = (branch: string | null, worktreePath: string | null) => {
...@@ -171,7 +171,7 @@ export default function BranchToolbar({...@@ -171,7 +171,7 @@ export default function BranchToolbar({
171 });171 });
172 }172 }
173 if (hasServerThread) {173 if (hasServerThread) {
174 setThreadBranchAction(activeThreadId, branch, worktreePath);174 dispatch.setThreadBranch(activeThreadId, branch, worktreePath);
175 return;175 return;
176 }176 }
177 setDraftThreadContext(threadId, {177 setDraftThreadContext(threadId, {
apps/web/src/components/ChatView.tsx+60-207
...@@ -61,7 +61,6 @@ import {...@@ -61,7 +61,6 @@ import {
61 type Thread,61 type Thread,
62 type TurnDiffSummary,62 type TurnDiffSummary,
63} from "../types";63} from "../types";
64import type { ThreadTerminalState } from "../threadTerminalState";
65import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";64import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
66import { useTheme } from "../hooks/useTheme";65import { useTheme } from "../hooks/useTheme";
67import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";66import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
...@@ -125,6 +124,7 @@ import {...@@ -125,6 +124,7 @@ import {
125 useComposerDraftStore,124 useComposerDraftStore,
126 useComposerThreadDraft,125 useComposerThreadDraft,
127} from "../composerDraftStore";126} from "../composerDraftStore";
127import { useTerminalStateStore } from "../terminalStateStore";
128import { clamp } from "effect/Number";128import { clamp } from "effect/Number";
129129
130function formatMessageMeta(createdAt: string, duration: string | null): string {130function formatMessageMeta(createdAt: string, duration: string | null): string {
...@@ -205,7 +205,6 @@ function buildExpandedImagePreview(...@@ -205,7 +205,6 @@ function buildExpandedImagePreview(
205function buildLocalDraftThread(205function buildLocalDraftThread(
206 threadId: ThreadId,206 threadId: ThreadId,
207 draftThread: DraftThreadState,207 draftThread: DraftThreadState,
208 draftTerminalState: ThreadTerminalState,
209 fallbackModel: string,208 fallbackModel: string,
210 error: string | null,209 error: string | null,
211): Thread {210): Thread {
...@@ -215,13 +214,6 @@ function buildLocalDraftThread(...@@ -215,13 +214,6 @@ function buildLocalDraftThread(
215 projectId: draftThread.projectId,214 projectId: draftThread.projectId,
216 title: "New thread",215 title: "New thread",
217 model: fallbackModel,216 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,
225 session: null,217 session: null,
226 messages: [],218 messages: [],
227 error,219 error,
...@@ -235,46 +227,6 @@ function buildLocalDraftThread(...@@ -235,46 +227,6 @@ function buildLocalDraftThread(
235 };227 };
236}228}
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
278function revokeBlobPreviewUrl(previewUrl: string | undefined): void {230function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
279 if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) {231 if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) {
280 return;232 return;
...@@ -578,13 +530,13 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -578,13 +530,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
578 setMessagesScrollElement(element);530 setMessagesScrollElement(element);
579 }, []);531 }, []);
580532
581 const setDraftThreadTerminalAction = useComposerDraftStore((s) => s.setDraftThreadTerminalAction);533 const terminalState = useTerminalStateStore((s) => s.getTerminalState(threadId));
582 const clearDraftThreadTerminalState = useComposerDraftStore(534 const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen);
583 (s) => s.clearDraftThreadTerminalState,535 const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight);
584 );536 const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal);
585 const localDraftTerminalState = useComposerDraftStore((s) =>537 const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal);
586 s.getDraftThreadTerminalState(threadId),538 const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal);
587 );539 const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal);
588540
589 const setPrompt = useCallback(541 const setPrompt = useCallback(
590 (nextPrompt: string) => {542 (nextPrompt: string) => {
...@@ -624,12 +576,11 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -624,12 +576,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
624 ? buildLocalDraftThread(576 ? buildLocalDraftThread(
625 threadId,577 threadId,
626 draftThread,578 draftThread,
627 localDraftTerminalState,
628 fallbackDraftProject?.model ?? DEFAULT_MODEL,579 fallbackDraftProject?.model ?? DEFAULT_MODEL,
629 localDraftError,580 localDraftError,
630 )581 )
631 : undefined,582 : undefined,
632 [draftThread, fallbackDraftProject?.model, localDraftError, localDraftTerminalState, threadId],583 [draftThread, fallbackDraftProject?.model, localDraftError, threadId],
633 );584 );
634 const activeThread = serverThread ?? localDraftThread;585 const activeThread = serverThread ?? localDraftThread;
635 const isServerThread = serverThread !== undefined;586 const isServerThread = serverThread !== undefined;
...@@ -660,25 +611,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -660,25 +611,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
660 });611 });
661 }, [serverThread, threadId]);612 }, [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
682 useEffect(() => {614 useEffect(() => {
683 if (!activeThread?.id) return;615 if (!activeThread?.id) return;
684 if (!latestTurnSettled) return;616 if (!latestTurnSettled) return;
...@@ -688,7 +620,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -688,7 +620,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
688 const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN;620 const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN;
689 if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return;621 if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return;
690622
691 markThreadVisited(activeThread.id);623 dispatch.markThreadVisited(activeThread.id);
692 }, [624 }, [
693 activeThread?.id,625 activeThread?.id,
694 activeThread?.lastVisitedAt,626 activeThread?.lastVisitedAt,
...@@ -1045,16 +977,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1045,16 +977,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
1045 (activeThread.session !== null && activeThread.session.status !== "closed")),977 (activeThread.session !== null && activeThread.session.status !== "closed")),
1046 );978 );
1047 const hasReachedTerminalLimit =979 const hasReachedTerminalLimit =
1048 (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT;980 terminalState.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT;
1049 const setThreadError = useCallback(981 const setThreadError = useCallback(
1050 (targetThreadId: ThreadId | null, error: string | null) => {982 (targetThreadId: ThreadId | null, error: string | null) => {
1051 if (!targetThreadId) return;983 if (!targetThreadId) return;
1052 if (state.threads.some((thread) => thread.id === targetThreadId)) {984 if (state.threads.some((thread) => thread.id === targetThreadId)) {
1053 dispatch({985 dispatch.setError(targetThreadId, error);
1054 type: "SET_ERROR",
1055 threadId: targetThreadId,
1056 error,
1057 });
1058 return;986 return;
1059 }987 }
1060 setLocalDraftErrorsByThreadId((existing) => {988 setLocalDraftErrorsByThreadId((existing) => {
...@@ -1085,86 +1013,46 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1085,86 +1013,46 @@ export default function ChatView({ threadId }: ChatViewProps) {
1085 const setTerminalOpen = useCallback(1013 const setTerminalOpen = useCallback(
1086 (open: boolean) => {1014 (open: boolean) => {
1087 if (!activeThreadId) return;1015 if (!activeThreadId) return;
1088 if (isLocalDraftThread) {1016 storeSetTerminalOpen(activeThreadId, open);
1089 setDraftThreadTerminalAction(activeThreadId, { type: "set-open", open });
1090 return;
1091 }
1092 dispatch({
1093 type: "SET_THREAD_TERMINAL_OPEN",
1094 threadId: activeThreadId,
1095 open,
1096 });
1097 },1017 },
1098 [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction],1018 [activeThreadId, storeSetTerminalOpen],
1099 );1019 );
1100 const setTerminalHeight = useCallback(1020 const setTerminalHeight = useCallback(
1101 (height: number) => {1021 (height: number) => {
1102 if (!activeThreadId) return;1022 if (!activeThreadId) return;
1103 if (isLocalDraftThread) {1023 storeSetTerminalHeight(activeThreadId, height);
1104 setDraftThreadTerminalAction(activeThreadId, { type: "set-height", height });
1105 return;
1106 }
1107 dispatch({
1108 type: "SET_THREAD_TERMINAL_HEIGHT",
1109 threadId: activeThreadId,
1110 height,
1111 });
1112 },1024 },
1113 [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction],1025 [activeThreadId, storeSetTerminalHeight],
1114 );1026 );
1115 const toggleTerminalVisibility = useCallback(() => {1027 const toggleTerminalVisibility = useCallback(() => {
1116 if (!activeThreadId) return;1028 if (!activeThreadId) return;
1117 setTerminalOpen(!activeThread?.terminalOpen);1029 setTerminalOpen(!terminalState.terminalOpen);
1118 }, [activeThread?.terminalOpen, activeThreadId, setTerminalOpen]);1030 }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]);
1119 const splitTerminal = useCallback(() => {1031 const splitTerminal = useCallback(() => {
1120 if (!activeThreadId || hasReachedTerminalLimit) return;1032 if (!activeThreadId || hasReachedTerminalLimit) return;
1121 const terminalId = `terminal-${crypto.randomUUID()}`;1033 const terminalId = `terminal-${crypto.randomUUID()}`;
1122 if (isLocalDraftThread) {1034 storeSplitTerminal(activeThreadId, terminalId);
1123 setDraftThreadTerminalAction(activeThreadId, { type: "split", terminalId });
1124 } else {
1125 dispatch({
1126 type: "SPLIT_THREAD_TERMINAL",
1127 threadId: activeThreadId,
1128 terminalId,
1129 });
1130 }
1131 setTerminalFocusRequestId((value) => value + 1);1035 setTerminalFocusRequestId((value) => value + 1);
1132 }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, setDraftThreadTerminalAction]);1036 }, [activeThreadId, storeSplitTerminal, hasReachedTerminalLimit]);
1133 const createNewTerminal = useCallback(() => {1037 const createNewTerminal = useCallback(() => {
1134 if (!activeThreadId || hasReachedTerminalLimit) return;1038 if (!activeThreadId || hasReachedTerminalLimit) return;
1135 const terminalId = `terminal-${crypto.randomUUID()}`;1039 const terminalId = `terminal-${crypto.randomUUID()}`;
1136 if (isLocalDraftThread) {1040 storeNewTerminal(activeThreadId, terminalId);
1137 setDraftThreadTerminalAction(activeThreadId, { type: "new", terminalId });
1138 } else {
1139 dispatch({
1140 type: "NEW_THREAD_TERMINAL",
1141 threadId: activeThreadId,
1142 terminalId,
1143 });
1144 }
1145 setTerminalFocusRequestId((value) => value + 1);1041 setTerminalFocusRequestId((value) => value + 1);
1146 }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, setDraftThreadTerminalAction]);1042 }, [activeThreadId, storeNewTerminal, hasReachedTerminalLimit]);
1147 const activateTerminal = useCallback(1043 const activateTerminal = useCallback(
1148 (terminalId: string) => {1044 (terminalId: string) => {
1149 if (!activeThreadId) return;1045 if (!activeThreadId) return;
1150 if (isLocalDraftThread) {1046 storeSetActiveTerminal(activeThreadId, terminalId);
1151 setDraftThreadTerminalAction(activeThreadId, { type: "set-active", terminalId });
1152 } else {
1153 dispatch({
1154 type: "SET_THREAD_ACTIVE_TERMINAL",
1155 threadId: activeThreadId,
1156 terminalId,
1157 });
1158 }
1159 setTerminalFocusRequestId((value) => value + 1);1047 setTerminalFocusRequestId((value) => value + 1);
1160 },1048 },
1161 [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction],1049 [activeThreadId, storeSetActiveTerminal],
1162 );1050 );
1163 const closeTerminal = useCallback(1051 const closeTerminal = useCallback(
1164 (terminalId: string) => {1052 (terminalId: string) => {
1165 const api = readNativeApi();1053 const api = readNativeApi();
1166 if (!activeThreadId || !api) return;1054 if (!activeThreadId || !api) return;
1167 const isFinalTerminal = (activeThread?.terminalIds.length ?? 0) <= 1;1055 const isFinalTerminal = terminalState.terminalIds.length <= 1;
1168 const fallbackExitWrite = () =>1056 const fallbackExitWrite = () =>
1169 api.terminal1057 api.terminal
1170 .write({ threadId: activeThreadId, terminalId, data: "exit\n" })1058 .write({ threadId: activeThreadId, terminalId, data: "exit\n" })
...@@ -1181,18 +1069,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1181,18 +1069,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
1181 } else {1069 } else {
1182 void fallbackExitWrite();1070 void fallbackExitWrite();
1183 }1071 }
1184 if (isLocalDraftThread) {1072 storeCloseTerminal(activeThreadId, terminalId);
1185 setDraftThreadTerminalAction(activeThreadId, { type: "close", terminalId });
1186 } else {
1187 dispatch({
1188 type: "CLOSE_THREAD_TERMINAL",
1189 threadId: activeThreadId,
1190 terminalId,
1191 });
1192 }
1193 setTerminalFocusRequestId((value) => value + 1);1073 setTerminalFocusRequestId((value) => value + 1);
1194 },1074 },
1195 [activeThread?.terminalIds.length, activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction],1075 [activeThreadId, storeCloseTerminal, terminalState.terminalIds.length],
1196 );1076 );
1197 const runProjectScript = useCallback(1077 const runProjectScript = useCallback(
1198 async (1078 async (
...@@ -1217,42 +1097,22 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1217,42 +1097,22 @@ export default function ChatView({ threadId }: ChatViewProps) {
1217 }1097 }
1218 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;1098 const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd;
1219 const baseTerminalId =1099 const baseTerminalId =
1220 activeThread.activeTerminalId || activeThread.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID;1100 terminalState.activeTerminalId ||
1221 const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId);1101 terminalState.terminalIds[0] ||
1102 DEFAULT_THREAD_TERMINAL_ID;
1103 const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId);
1222 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;1104 const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy;
1223 const shouldCreateNewTerminal =1105 const shouldCreateNewTerminal =
1224 wantsNewTerminal && activeThread.terminalIds.length < MAX_THREAD_TERMINAL_COUNT;1106 wantsNewTerminal && terminalState.terminalIds.length < MAX_THREAD_TERMINAL_COUNT;
1225 const targetTerminalId = shouldCreateNewTerminal1107 const targetTerminalId = shouldCreateNewTerminal
1226 ? `terminal-${crypto.randomUUID()}`1108 ? `terminal-${crypto.randomUUID()}`
1227 : baseTerminalId;1109 : baseTerminalId;
12281110
1229 setTerminalOpen(true);1111 setTerminalOpen(true);
1230 if (shouldCreateNewTerminal) {1112 if (shouldCreateNewTerminal) {
1231 if (isLocalDraftThread) {1113 storeNewTerminal(activeThreadId, targetTerminalId);
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 }
1243 } else {1114 } else {
1244 if (isLocalDraftThread) {1115 storeSetActiveTerminal(activeThreadId, targetTerminalId);
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 }
1256 }1116 }
1257 setTerminalFocusRequestId((value) => value + 1);1117 setTerminalFocusRequestId((value) => value + 1);
12581118
...@@ -1299,7 +1159,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1299,7 +1159,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
1299 isServerThread,1159 isServerThread,
1300 setTerminalOpen,1160 setTerminalOpen,
1301 setThreadError,1161 setThreadError,
1302 setDraftThreadTerminalAction,1162 storeNewTerminal,
1163 storeSetActiveTerminal,
1303 ],1164 ],
1304 );1165 );
1305 const persistProjectScripts = useCallback(1166 const persistProjectScripts = useCallback(
...@@ -1403,8 +1264,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1403,8 +1264,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
1403 );1264 );
14041265
1405 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {1266 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
1406 if (mode === runtimeMode) return;1267 if (mode === state.runtimeMode) return;
1407 setRuntimeMode(mode);1268 dispatch.setRuntimeMode(mode);
1408 scheduleComposerFocus();1269 scheduleComposerFocus();
1409 const api = readNativeApi();1270 const api = readNativeApi();
1410 if (!api) return;1271 if (!api) return;
...@@ -1544,14 +1405,14 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1544,14 +1405,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
1544 }, [activeThread?.id]);1405 }, [activeThread?.id]);
15451406
1546 useEffect(() => {1407 useEffect(() => {
1547 if (!activeThread?.id || activeThread.terminalOpen) return;1408 if (!activeThread?.id || terminalState.terminalOpen) return;
1548 const frame = window.requestAnimationFrame(() => {1409 const frame = window.requestAnimationFrame(() => {
1549 focusComposer();1410 focusComposer();
1550 });1411 });
1551 return () => {1412 return () => {
1552 window.cancelAnimationFrame(frame);1413 window.cancelAnimationFrame(frame);
1553 };1414 };
1554 }, [activeThread?.id, activeThread?.terminalOpen, focusComposer]);1415 }, [activeThread?.id, focusComposer, terminalState.terminalOpen]);
15551416
1556 useEffect(() => {1417 useEffect(() => {
1557 composerImagesRef.current = composerImages;1418 composerImagesRef.current = composerImages;
...@@ -1762,7 +1623,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1762,7 +1623,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1762 useEffect(() => {1623 useEffect(() => {
1763 if (!activeThreadId) return;1624 if (!activeThreadId) return;
1764 const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false;1625 const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false;
1765 const current = Boolean(activeThread?.terminalOpen);1626 const current = Boolean(terminalState.terminalOpen);
17661627
1767 if (!previous && current) {1628 if (!previous && current) {
1768 setTerminalFocusRequestId((value) => value + 1);1629 setTerminalFocusRequestId((value) => value + 1);
...@@ -1777,7 +1638,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1777,7 +1638,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1777 }1638 }
17781639
1779 terminalOpenByThreadRef.current[activeThreadId] = current;1640 terminalOpenByThreadRef.current[activeThreadId] = current;
1780 }, [activeThread?.terminalOpen, activeThreadId, focusComposer]);1641 }, [activeThreadId, focusComposer, terminalState.terminalOpen]);
17811642
1782 useEffect(() => {1643 useEffect(() => {
1783 const isTerminalFocused = (): boolean => {1644 const isTerminalFocused = (): boolean => {
...@@ -1791,7 +1652,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1791,7 +1652,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1791 if (!activeThreadId || event.defaultPrevented) return;1652 if (!activeThreadId || event.defaultPrevented) return;
1792 const shortcutContext = {1653 const shortcutContext = {
1793 terminalFocus: isTerminalFocused(),1654 terminalFocus: isTerminalFocused(),
1794 terminalOpen: Boolean(activeThread?.terminalOpen),1655 terminalOpen: Boolean(terminalState.terminalOpen),
1795 };1656 };
17961657
1797 const command = resolveShortcutCommand(event, keybindings, { context: shortcutContext });1658 const command = resolveShortcutCommand(event, keybindings, { context: shortcutContext });
...@@ -1807,7 +1668,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1807,7 +1668,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1807 if (command === "terminal.split") {1668 if (command === "terminal.split") {
1808 event.preventDefault();1669 event.preventDefault();
1809 event.stopPropagation();1670 event.stopPropagation();
1810 if (!activeThread?.terminalOpen) {1671 if (!terminalState.terminalOpen) {
1811 setTerminalOpen(true);1672 setTerminalOpen(true);
1812 }1673 }
1813 splitTerminal();1674 splitTerminal();
...@@ -1817,15 +1678,15 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1817,15 +1678,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
1817 if (command === "terminal.close") {1678 if (command === "terminal.close") {
1818 event.preventDefault();1679 event.preventDefault();
1819 event.stopPropagation();1680 event.stopPropagation();
1820 if (!activeThread?.terminalOpen) return;1681 if (!terminalState.terminalOpen) return;
1821 closeTerminal(activeThread.activeTerminalId);1682 closeTerminal(terminalState.activeTerminalId);
1822 return;1683 return;
1823 }1684 }
18241685
1825 if (command === "terminal.new") {1686 if (command === "terminal.new") {
1826 event.preventDefault();1687 event.preventDefault();
1827 event.stopPropagation();1688 event.stopPropagation();
1828 if (!activeThread?.terminalOpen) {1689 if (!terminalState.terminalOpen) {
1829 setTerminalOpen(true);1690 setTerminalOpen(true);
1830 }1691 }
1831 createNewTerminal();1692 createNewTerminal();
...@@ -1851,8 +1712,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1851,8 +1712,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
1851 return () => window.removeEventListener("keydown", handler);1712 return () => window.removeEventListener("keydown", handler);
1852 }, [1713 }, [
1853 activeProject,1714 activeProject,
1854 activeThread?.terminalOpen,1715 terminalState.terminalOpen,
1855 activeThread?.activeTerminalId,1716 terminalState.activeTerminalId,
1856 activeThreadId,1717 activeThreadId,
1857 closeTerminal,1718 closeTerminal,
1858 createNewTerminal,1719 createNewTerminal,
...@@ -2028,10 +1889,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2028,10 +1889,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2028 const shouldCreateWorktree =1889 const shouldCreateWorktree =
2029 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;1890 isFirstMessage && envMode === "worktree" && !activeThread.worktreePath;
2030 if (shouldCreateWorktree && !activeThread.branch) {1891 if (shouldCreateWorktree && !activeThread.branch) {
2031 setThreadErrorAction(1892 dispatch.setError(threadIdForSend, "Select a base branch before sending in New worktree mode.");
2032 threadIdForSend,
2033 "Select a base branch before sending in New worktree mode.",
2034 );
2035 return;1893 return;
2036 }1894 }
20371895
...@@ -2104,11 +1962,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2104,11 +1962,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2104 });1962 });
2105 // Keep local thread state in sync immediately so terminal drawer opens1963 // Keep local thread state in sync immediately so terminal drawer opens
2106 // with the worktree cwd/env instead of briefly using the project root.1964 // with the worktree cwd/env instead of briefly using the project root.
2107 setThreadBranchAction(1965 dispatch.setThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path);
2108 threadIdForSend,
2109 result.worktree.branch,
2110 result.worktree.path,
2111 );
2112 }1966 }
2113 }1967 }
21141968
...@@ -2247,10 +2101,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2247,10 +2101,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
2247 createdAt: new Date().toISOString(),2101 createdAt: new Date().toISOString(),
2248 });2102 });
2249 } catch (err) {2103 } catch (err) {
2250 setThreadErrorAction(2104 dispatch.setError(activeThreadId, err instanceof Error ? err.message : "Failed to submit approval decision.");
2251 activeThreadId,2105 } finally {
2252 err instanceof Error ? err.message : "Failed to submit approval decision.",2106 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
2253 );
2254 }2107 }
2255 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));2108 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
2256 },2109 },
...@@ -2792,7 +2645,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2792,7 +2645,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2792 )}2645 )}
27932646
2794 {(() => {2647 {(() => {
2795 if (!activeThread.terminalOpen || !activeProject) {2648 if (!terminalState.terminalOpen || !activeProject) {
2796 return null;2649 return null;
2797 }2650 }
2798 return (2651 return (
...@@ -2801,11 +2654,11 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2801,11 +2654,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
2801 threadId={activeThread.id}2654 threadId={activeThread.id}
2802 cwd={gitCwd ?? activeProject.cwd}2655 cwd={gitCwd ?? activeProject.cwd}
2803 runtimeEnv={threadTerminalRuntimeEnv}2656 runtimeEnv={threadTerminalRuntimeEnv}
2804 height={activeThread.terminalHeight}2657 height={terminalState.terminalHeight}
2805 terminalIds={activeThread.terminalIds}2658 terminalIds={terminalState.terminalIds}
2806 activeTerminalId={activeThread.activeTerminalId}2659 activeTerminalId={terminalState.activeTerminalId}
2807 terminalGroups={activeThread.terminalGroups}2660 terminalGroups={terminalState.terminalGroups}
2808 activeTerminalGroupId={activeThread.activeTerminalGroupId}2661 activeTerminalGroupId={terminalState.activeTerminalGroupId}
2809 focusRequestId={terminalFocusRequestId}2662 focusRequestId={terminalFocusRequestId}
2810 onSplitTerminal={splitTerminal}2663 onSplitTerminal={splitTerminal}
2811 onNewTerminal={createNewTerminal}2664 onNewTerminal={createNewTerminal}
apps/web/src/components/Sidebar.tsx+11-5
...@@ -21,6 +21,7 @@ import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/...@@ -21,6 +21,7 @@ import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/
21import { serverConfigQueryOptions } from "../lib/serverReactQuery";21import { serverConfigQueryOptions } from "../lib/serverReactQuery";
22import { readNativeApi } from "../nativeApi";22import { readNativeApi } from "../nativeApi";
23import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";23import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore";
24import { useTerminalStateStore } from "../terminalStateStore";
24import { toastManager } from "./ui/toast";25import { toastManager } from "./ui/toast";
25import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";26import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
26import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";27import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
...@@ -126,8 +127,10 @@ function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadS...@@ -126,8 +127,10 @@ function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadS
126 return null;127 return null;
127}128}
128129
129function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null {130function terminalStatusFromRunningIds(
130 if (thread.runningTerminalIds.length === 0) {131 runningTerminalIds: string[],
132): TerminalStatusIndicator | null {
133 if (runningTerminalIds.length === 0) {
131 return null;134 return null;
132 }135 }
133 return {136 return {
...@@ -237,6 +240,7 @@ export default function Sidebar() {...@@ -237,6 +240,7 @@ export default function Sidebar() {
237 (store) => store.getDraftThreadByProjectId,240 (store) => store.getDraftThreadByProjectId,
238 );241 );
239 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);242 const getDraftThread = useComposerDraftStore((store) => store.getDraftThread);
243 const getTerminalState = useTerminalStateStore((s) => s.getTerminalState);
240 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);244 const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId);
241 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);245 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
242 const clearProjectDraftThreadId = useComposerDraftStore(246 const clearProjectDraftThreadId = useComposerDraftStore(
...@@ -505,7 +509,7 @@ export default function Sidebar() {...@@ -505,7 +509,7 @@ export default function Sidebar() {
505 position,509 position,
506 );510 );
507 if (clicked === "mark-unread") {511 if (clicked === "mark-unread") {
508 markThreadUnread(threadId);512 dispatch.markThreadUnread(threadId);
509 return;513 return;
510 }514 }
511 if (clicked !== "delete") return;515 if (clicked !== "delete") return;
...@@ -777,7 +781,7 @@ export default function Sidebar() {...@@ -777,7 +781,7 @@ export default function Sidebar() {
777 open={project.expanded}781 open={project.expanded}
778 onOpenChange={(open) => {782 onOpenChange={(open) => {
779 if (open === project.expanded) return;783 if (open === project.expanded) return;
780 toggleProject(project.id);784 dispatch.toggleProject(project.id);
781 }}785 }}
782 >786 >
783 <SidebarMenuItem>787 <SidebarMenuItem>
...@@ -819,7 +823,9 @@ export default function Sidebar() {...@@ -819,7 +823,9 @@ export default function Sidebar() {
819 pendingApprovalByThreadId.get(thread.id) === true,823 pendingApprovalByThreadId.get(thread.id) === true,
820 );824 );
821 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);825 const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null);
822 const terminalStatus = terminalStatusIndicator(thread);826 const terminalStatus = terminalStatusFromRunningIds(
827 getTerminalState(thread.id).runningTerminalIds,
828 );
823829
824 return (830 return (
825 <SidebarMenuSubItem key={thread.id} className="w-full">831 <SidebarMenuSubItem key={thread.id} className="w-full">
apps/web/src/composerDraftStore.ts-59
...@@ -6,13 +6,6 @@ import {...@@ -6,13 +6,6 @@ import {
6 normalizeModelSlug,6 normalizeModelSlug,
7 type ReasoningEffort,7 type ReasoningEffort,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import {
10 createDefaultThreadTerminalState,
11 getDefaultThreadTerminalState,
12 reduceThreadTerminalState,
13 type ThreadTerminalAction,
14 type ThreadTerminalState,
15} from "./threadTerminalState";
16import type { ChatImageAttachment } from "./types";9import type { ChatImageAttachment } from "./types";
17import { create } from "zustand";10import { create } from "zustand";
18import { createJSONStorage, persist } from "zustand/middleware";11import { createJSONStorage, persist } from "zustand/middleware";
...@@ -71,8 +64,6 @@ export interface DraftThreadState {...@@ -71,8 +64,6 @@ export interface DraftThreadState {
71 branch: string | null;64 branch: string | null;
72 worktreePath: string | null;65 worktreePath: string | null;
73 envMode: DraftThreadEnvMode;66 envMode: DraftThreadEnvMode;
74 /** Terminal layout for draft thread; same shape as Thread terminal slice. Omitted when not set. */
75 terminalState?: ThreadTerminalState;
76}67}
7768
78interface ProjectDraftThread extends DraftThreadState {69interface ProjectDraftThread extends DraftThreadState {
...@@ -122,9 +113,6 @@ interface ComposerDraftStoreState {...@@ -122,9 +113,6 @@ interface ComposerDraftStoreState {
122 ) => void;113 ) => void;
123 clearComposerContent: (threadId: ThreadId) => void;114 clearComposerContent: (threadId: ThreadId) => void;
124 clearThreadDraft: (threadId: ThreadId) => void;115 clearThreadDraft: (threadId: ThreadId) => void;
125 getDraftThreadTerminalState: (threadId: ThreadId) => ThreadTerminalState;
126 setDraftThreadTerminalAction: (threadId: ThreadId, action: ThreadTerminalAction) => void;
127 clearDraftThreadTerminalState: (threadId: ThreadId) => void;
128}116}
129117
130const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = {118const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = {
...@@ -678,53 +666,6 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -678,53 +666,6 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
678 };666 };
679 });667 });
680 },668 },
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 },
728 setPrompt: (threadId, prompt) => {669 setPrompt: (threadId, prompt) => {
729 if (threadId.length === 0) {670 if (threadId.length === 0) {
730 return;671 return;
apps/web/src/routes/__root.tsx+4-3
...@@ -15,6 +15,7 @@ import { AnchoredToastProvider, ToastProvider, toastManager } from "../component...@@ -15,6 +15,7 @@ import { AnchoredToastProvider, ToastProvider, toastManager } from "../component
15import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";15import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
16import { readNativeApi } from "../nativeApi";16import { readNativeApi } from "../nativeApi";
17import { useStore } from "../store";17import { useStore } from "../store";
18import { useTerminalStateStore } from "../terminalStateStore";
18import { preferredTerminalEditor } from "../terminal-links";19import { preferredTerminalEditor } from "../terminal-links";
19import { terminalRunningSubprocessFromEvent } from "../terminalActivity";20import { terminalRunningSubprocessFromEvent } from "../terminalActivity";
20import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi";21import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi";
...@@ -149,7 +150,7 @@ function EventRouter() {...@@ -149,7 +150,7 @@ function EventRouter() {
149 const snapshot = await api.orchestration.getSnapshot();150 const snapshot = await api.orchestration.getSnapshot();
150 if (disposed) return;151 if (disposed) return;
151 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);152 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
152 syncServerReadModel(snapshot);153 dispatch.syncServerReadModel(snapshot);
153 if (pending) {154 if (pending) {
154 pending = false;155 pending = false;
155 await flushSnapshotSync();156 await flushSnapshotSync();
...@@ -188,7 +189,7 @@ function EventRouter() {...@@ -188,7 +189,7 @@ function EventRouter() {
188 if (hasRunningSubprocess === null) {189 if (hasRunningSubprocess === null) {
189 return;190 return;
190 }191 }
191 setThreadTerminalActivity(192 useTerminalStateStore.getState().setTerminalActivity(
192 ThreadId.makeUnsafe(event.threadId),193 ThreadId.makeUnsafe(event.threadId),
193 event.terminalId,194 event.terminalId,
194 hasRunningSubprocess,195 hasRunningSubprocess,
...@@ -204,7 +205,7 @@ function EventRouter() {...@@ -204,7 +205,7 @@ function EventRouter() {
204 if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {205 if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) {
205 return;206 return;
206 }207 }
207 setProjectExpanded(payload.bootstrapProjectId, true);208 dispatch.setProjectExpanded(payload.bootstrapProjectId, true);
208209
209 if (pathnameRef.current !== "/") {210 if (pathnameRef.current !== "/") {
210 return;211 return;
apps/web/src/routes/_chat.settings.tsx+1-1
...@@ -349,7 +349,7 @@ function SettingsRouteView() {...@@ -349,7 +349,7 @@ function SettingsRouteView() {
349 : "border-border bg-background text-muted-foreground hover:bg-accent"349 : "border-border bg-background text-muted-foreground hover:bg-accent"
350 }`}350 }`}
351 onClick={() => {351 onClick={() => {
352 setRuntimeMode(option.value);352 dispatch.setRuntimeMode(option.value);
353 }}353 }}
354 >354 >
355 <span className="flex flex-col">355 <span className="flex flex-col">
apps/web/src/store.test.ts+7-131
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";1import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it } from "vitest";2import { beforeEach, describe, expect, it } from "vitest";
33
4import { type AppState, useStore } from "./store";4import { markThreadUnread, type AppState } from "./store";
5import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";5import type { Thread } from "./types";
66
7function makeThread(overrides: Partial<Thread> = {}): Thread {7function makeThread(overrides: Partial<Thread> = {}): Thread {
8 return {8 return {
...@@ -11,18 +11,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -11,18 +11,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
11 projectId: ProjectId.makeUnsafe("project-1"),11 projectId: ProjectId.makeUnsafe("project-1"),
12 title: "Thread",12 title: "Thread",
13 model: "gpt-5-codex",13 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}`,
26 session: null,14 session: null,
27 messages: [],15 messages: [],
28 turnDiffSummaries: [],16 turnDiffSummaries: [],
...@@ -54,17 +42,8 @@ function makeState(thread: Thread): AppState {...@@ -54,17 +42,8 @@ function makeState(thread: Thread): AppState {
54 };42 };
55}43}
5644
57describe("store markThreadUnread action", () => {45describe("store pure functions", () => {
58 beforeEach(() => {46 it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => {
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", () => {
68 const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z";47 const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z";
69 const initialState = makeState(48 const initialState = makeState(
70 makeThread({49 makeThread({
...@@ -80,8 +59,7 @@ describe("store markThreadUnread action", () => {...@@ -80,8 +59,7 @@ describe("store markThreadUnread action", () => {
80 }),59 }),
81 );60 );
8261
83 useStore.setState(initialState);62 const next = markThreadUnread(initialState, ThreadId.makeUnsafe("thread-1"));
84 useStore.getState().markThreadUnread(ThreadId.makeUnsafe("thread-1"));
8563
86 const updatedThread = useStore.getState().threads[0];64 const updatedThread = useStore.getState().threads[0];
87 expect(updatedThread).toBeDefined();65 expect(updatedThread).toBeDefined();
...@@ -91,7 +69,7 @@ describe("store markThreadUnread action", () => {...@@ -91,7 +69,7 @@ describe("store markThreadUnread action", () => {
91 );69 );
92 });70 });
9371
94 it("does not change a thread without a completed turn", () => {72 it("markThreadUnread does not change a thread without a completed turn", () => {
95 const initialState = makeState(73 const initialState = makeState(
96 makeThread({74 makeThread({
97 latestTurn: null,75 latestTurn: null,
...@@ -99,110 +77,8 @@ describe("store markThreadUnread action", () => {...@@ -99,110 +77,8 @@ describe("store markThreadUnread action", () => {
99 }),77 }),
100 );78 );
10179
102 useStore.setState(initialState);80 const next = markThreadUnread(initialState, ThreadId.makeUnsafe("thread-1"));
103 useStore.getState().markThreadUnread(ThreadId.makeUnsafe("thread-1"));
10481
105 expect(useStore.getState().threads[0]?.lastVisitedAt).toBe("2026-02-25T12:35:00.000Z");82 expect(useStore.getState().threads[0]?.lastVisitedAt).toBe("2026-02-25T12:35:00.000Z");
106 });83 });
107});84});
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 @@...@@ -1,6 +1,4 @@
1import { type ReactNode } from "react";1import { Fragment, type ReactNode, createElement, useEffect } from "react";
2import { create } from "zustand";
3
4import {2import {
5 DEFAULT_MODEL,3 DEFAULT_MODEL,
6 ProviderSessionId,4 ProviderSessionId,
...@@ -9,12 +7,7 @@ import {...@@ -9,12 +7,7 @@ import {
9 type OrchestrationSessionStatus,7 type OrchestrationSessionStatus,
10 resolveModelSlug,8 resolveModelSlug,
11} from "@t3tools/contracts";9} from "@t3tools/contracts";
12import {10import { create } from "zustand";
13 createDefaultThreadTerminalState,
14 normalizeThreadTerminalState,
15 reduceThreadTerminalState,
16 type ThreadTerminalState,
17} from "./threadTerminalState";
18import {11import {
19 DEFAULT_RUNTIME_MODE,12 DEFAULT_RUNTIME_MODE,
20 type ChatMessage,13 type ChatMessage,
...@@ -23,37 +16,6 @@ import {...@@ -23,37 +16,6 @@ import {
23 type Thread,16 type Thread,
24} from "./types";17} 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
57// ── State ────────────────────────────────────────────────────────────19// ── State ────────────────────────────────────────────────────────────
5820
59export interface AppState {21export interface AppState {
...@@ -106,11 +68,10 @@ const initialState: AppState = {...@@ -106,11 +68,10 @@ const initialState: AppState = {
106};68};
107const persistedExpandedProjectCwds = new Set<string>();69const persistedExpandedProjectCwds = new Set<string>();
10870
109// ── Helpers ──────────────────────────────────────────────────────────71// ── Persist helpers ──────────────────────────────────────────────────
11072
111function readPersistedState(): AppState {73function readPersistedState(): AppState {
112 if (typeof window === "undefined") return initialState;74 if (typeof window === "undefined") return initialState;
113
114 try {75 try {
115 const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);76 const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);
116 if (!raw) return initialState;77 if (!raw) return initialState;
...@@ -138,7 +99,6 @@ function readPersistedState(): AppState {...@@ -138,7 +99,6 @@ function readPersistedState(): AppState {
13899
139function persistState(state: AppState): void {100function persistState(state: AppState): void {
140 if (typeof window === "undefined") return;101 if (typeof window === "undefined") return;
141
142 try {102 try {
143 window.localStorage.setItem(103 window.localStorage.setItem(
144 PERSISTED_STATE_KEY,104 PERSISTED_STATE_KEY,
...@@ -157,6 +117,8 @@ function persistState(state: AppState): void {...@@ -157,6 +117,8 @@ function persistState(state: AppState): void {
157 }117 }
158}118}
159119
120// ── Pure helpers ──────────────────────────────────────────────────────
121
160function updateThread(122function updateThread(
161 threads: Thread[],123 threads: Thread[],
162 threadId: ThreadId,124 threadId: ThreadId,
...@@ -212,9 +174,7 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" {...@@ -212,9 +174,7 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" {
212}174}
213175
214function resolveWsHttpOrigin(): string {176function resolveWsHttpOrigin(): string {
215 if (typeof window === "undefined") {177 if (typeof window === "undefined") return "";
216 return "";
217 }
218 const bridgeWsUrl = window.desktopBridge?.getWsUrl?.();178 const bridgeWsUrl = window.desktopBridge?.getWsUrl?.();
219 const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined;179 const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined;
220 const wsCandidate =180 const wsCandidate =
...@@ -223,10 +183,7 @@ function resolveWsHttpOrigin(): string {...@@ -223,10 +183,7 @@ function resolveWsHttpOrigin(): string {
223 : typeof envWsUrl === "string" && envWsUrl.length > 0183 : typeof envWsUrl === "string" && envWsUrl.length > 0
224 ? envWsUrl184 ? envWsUrl
225 : null;185 : null;
226 if (!wsCandidate) {186 if (!wsCandidate) return window.location.origin;
227 return window.location.origin;
228 }
229
230 try {187 try {
231 const wsUrl = new URL(wsCandidate);188 const wsUrl = new URL(wsCandidate);
232 const protocol =189 const protocol =
...@@ -252,328 +209,61 @@ function attachmentPreviewRoutePath(attachmentId: string): string {...@@ -252,328 +209,61 @@ function attachmentPreviewRoutePath(attachmentId: string): string {
252 return `/attachments/${encodeURIComponent(attachmentId)}`;209 return `/attachments/${encodeURIComponent(attachmentId)}`;
253}210}
254211
255function threadTerminalSlice(thread: Thread): ThreadTerminalState {212// ── Pure state transition functions ────────────────────────────────────
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}
274213
275function syncServerReadModelState(state: AppState, readModel: OrchestrationReadModel): AppState {214export function syncServerReadModel(
215 state: AppState,
216 readModel: OrchestrationReadModel,
217): AppState {
276 const projects = mapProjectsFromReadModel(218 const projects = mapProjectsFromReadModel(
277 readModel.projects.filter((project) => project.deletedAt === null),219 readModel.projects.filter((project) => project.deletedAt === null),
278 state.projects,220 state.projects,
279 );221 );
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 );
281 const threads = readModel.threads225 const threads = readModel.threads
282 .filter((thread) => thread.deletedAt === null)226 .filter((thread) => thread.deletedAt === null)
283 .map((thread) => {227 .map((thread) => {
284 const existing = existingThreadById.get(thread.id);228 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 });
365 return {229 return {
366 ...state,230 id: thread.id,
367 projects,231 codexThreadId: thread.session?.providerThreadId ?? null,
368 threads,232 projectId: thread.projectId,
369 threadsHydrated: true,233 title: thread.title,
370 };234 model: resolveModelSlug(thread.model),
371 }235 session: thread.session
372236 ? {
373 case "HYDRATE_THREAD_TERMINALS":237 sessionId:
374 return {238 thread.session.providerSessionId ??
375 ...state,239 ProviderSessionId.makeUnsafe(`thread:${thread.id}`),
376 threads: updateThread(state.threads, action.threadId, (thread) =>240 provider: toLegacyProvider(thread.session.providerName),
377 applyTerminalState(thread, normalizeThreadTerminalState(action.terminalState)),241 status: toLegacySessionStatus(thread.session.status),
378 ),242 orchestrationStatus: thread.session.status,
379 };243 threadId: thread.session.providerThreadId,
380244 activeTurnId: thread.session.activeTurnId ?? undefined,
381 case "MARK_THREAD_VISITED": {245 createdAt: thread.session.updatedAt,
382 const visitedAt = action.visitedAt ?? new Date().toISOString();246 updatedAt: thread.session.updatedAt,
383 const visitedAtMs = Date.parse(visitedAt);247 ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}),
384 return {248 }
385 ...state,249 : null,
386 threads: updateThread(state.threads, action.threadId, (thread) => {250 messages: thread.messages.map((message) => {
387 const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN;251 const attachments = message.attachments?.map((attachment) => ({
388 if (252 type: "image" as const,
389 Number.isFinite(previousVisitedAtMs) &&253 id: attachment.id,
390 Number.isFinite(visitedAtMs) &&254 name: attachment.name,
391 previousVisitedAtMs >= visitedAtMs255 mimeType: attachment.mimeType,
392 ) {256 sizeBytes: attachment.sizeBytes,
393 return thread;257 previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)),
394 }258 }));
395 return {259 const normalizedMessage: ChatMessage = {
396 ...thread,260 id: message.id,
397 lastVisitedAt: visitedAt,261 role: message.role,
398 };262 text: message.text,
399 }),263 createdAt: message.createdAt,
400 };264 streaming: message.streaming,
401 }265 ...(message.streaming ? {} : { completedAt: message.updatedAt }),
402266 ...(attachments && attachments.length > 0 ? { attachments } : {}),
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 } : {}),
577 };267 };
578 return normalizedMessage;268 return normalizedMessage;
579 }),269 }),
...@@ -593,7 +283,7 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -593,7 +283,7 @@ export function reducer(state: AppState, action: Action): AppState {
593 files: checkpoint.files.map((file) => ({ ...file })),283 files: checkpoint.files.map((file) => ({ ...file })),
594 })),284 })),
595 activities: thread.activities.map((activity) => ({ ...activity })),285 activities: thread.activities.map((activity) => ({ ...activity })),
596 });286 };
597 });287 });
598 return {288 return {
599 ...state,289 ...state,
...@@ -603,9 +293,13 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -603,9 +293,13 @@ export function reducer(state: AppState, action: Action): AppState {
603 };293 };
604}294}
605295
606function markThreadVisitedState(state: AppState, threadId: ThreadId, visitedAt?: string): AppState {296export function markThreadVisited(
607 const nextVisitedAt = visitedAt ?? new Date().toISOString();297 state: AppState,
608 const visitedAtMs = Date.parse(nextVisitedAt);298 threadId: ThreadId,
299 visitedAt?: string,
300): AppState {
301 const at = visitedAt ?? new Date().toISOString();
302 const visitedAtMs = Date.parse(at);
609 return {303 return {
610 ...state,304 ...state,
611 threads: updateThread(state.threads, threadId, (thread) => {305 threads: updateThread(state.threads, threadId, (thread) => {
...@@ -617,269 +311,55 @@ function markThreadVisitedState(state: AppState, threadId: ThreadId, visitedAt?:...@@ -617,269 +311,55 @@ function markThreadVisitedState(state: AppState, threadId: ThreadId, visitedAt?:
617 ) {311 ) {
618 return thread;312 return thread;
619 }313 }
620 return {314 return { ...thread, lastVisitedAt: at };
621 ...thread,
622 lastVisitedAt: nextVisitedAt,
623 };
624 }),315 }),
625 };316 };
626}317}
627318
628function markThreadUnreadState(state: AppState, threadId: ThreadId): AppState {319export function markThreadUnread(state: AppState, threadId: ThreadId): AppState {
629 return {320 return {
630 ...state,321 ...state,
631 threads: updateThread(state.threads, threadId, (thread) => {322 threads: updateThread(state.threads, threadId, (thread) => {
632 if (!thread.latestTurn?.completedAt) {323 if (!thread.latestTurn?.completedAt) return thread;
633 return thread;
634 }
635 const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt);324 const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt);
636 if (Number.isNaN(latestTurnCompletedAtMs)) {325 if (Number.isNaN(latestTurnCompletedAtMs)) return thread;
637 return thread;
638 }
639 const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString();326 const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString();
640 if (thread.lastVisitedAt === unreadVisitedAt) {327 if (thread.lastVisitedAt === unreadVisitedAt) return thread;
641 return thread;328 return { ...thread, lastVisitedAt: unreadVisitedAt };
642 }
643 return {
644 ...thread,
645 lastVisitedAt: unreadVisitedAt,
646 };
647 }),329 }),
648 };330 };
649}331}
650332
651function toggleProjectState(state: AppState, projectId: Project["id"]): AppState {333export function toggleProject(state: AppState, projectId: Project["id"]): AppState {
652 return {334 return {
653 ...state,335 ...state,
654 projects: state.projects.map((project) =>336 projects: state.projects.map((p) =>
655 project.id === projectId ? { ...project, expanded: !project.expanded } : project,337 p.id === projectId ? { ...p, expanded: !p.expanded } : p,
656 ),338 ),
657 };339 };
658}340}
659341
660function setThreadTerminalActivityState(342export function setProjectExpanded(
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(
675 state: AppState,343 state: AppState,
676 projectId: Project["id"],344 projectId: Project["id"],
677 expanded: boolean,345 expanded: boolean,
678): AppState {346): AppState {
679 return {347 return {
680 ...state,348 ...state,
681 projects: state.projects.map((project) =>349 projects: state.projects.map((p) =>
682 project.id === projectId ? { ...project, expanded } : project,350 p.id === projectId ? { ...p, expanded } : p,
683 ),351 ),
684 };352 };
685}353}
686354
687function toggleThreadTerminalState(state: AppState, threadId: ThreadId): AppState {355export function setError(state: AppState, threadId: ThreadId, error: string | null): 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 {
873 return {356 return {
874 ...state,357 ...state,
875 threads: updateThread(state.threads, threadId, (thread) => ({358 threads: updateThread(state.threads, threadId, (t) => ({ ...t, error })),
876 ...thread,
877 error,
878 })),
879 };359 };
880}360}
881361
882function setThreadBranchState(362export function setThreadBranch(
883 state: AppState,363 state: AppState,
884 threadId: ThreadId,364 threadId: ThreadId,
885 branch: string | null,365 branch: string | null,
...@@ -887,13 +367,10 @@ function setThreadBranchState(...@@ -887,13 +367,10 @@ function setThreadBranchState(
887): AppState {367): AppState {
888 return {368 return {
889 ...state,369 ...state,
890 threads: updateThread(state.threads, threadId, (thread) => {370 threads: updateThread(state.threads, threadId, (t) => {
891 // When the effective cwd changes (worktreePath differs), the old371 const cwdChanged = t.worktreePath !== worktreePath;
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;
895 return {372 return {
896 ...thread,373 ...t,
897 branch,374 branch,
898 worktreePath,375 worktreePath,
899 ...(cwdChanged ? { session: null } : {}),376 ...(cwdChanged ? { session: null } : {}),
...@@ -902,71 +379,68 @@ function setThreadBranchState(...@@ -902,71 +379,68 @@ function setThreadBranchState(
902 };379 };
903}380}
904381
905function setRuntimeModeState(state: AppState, mode: RuntimeMode): AppState {382export function setRuntimeMode(state: AppState, mode: RuntimeMode): AppState {
906 return {383 return { ...state, runtimeMode: mode };
907 ...state,
908 runtimeMode: mode,
909 };
910}384}
911385
912function toAppState(store: AppStore): AppState {386// ── Zustand store ────────────────────────────────────────────────────
913 return {387
914 projects: store.projects,388interface AppStore extends AppState {
915 threads: store.threads,389 syncServerReadModel: (readModel: OrchestrationReadModel) => void;
916 threadsHydrated: store.threadsHydrated,390 markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void;
917 runtimeMode: store.runtimeMode,391 markThreadUnread: (threadId: ThreadId) => void;
918 };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;
919}401}
920402
921export const useStore = create<AppStore>((set) => {403export const useAppStore = create<AppStore>((set, _get) => ({
922 const setAppState = (updater: (state: AppState) => AppState): void => {404 ...readPersistedState(),
923 set((store) => {405 syncServerReadModel: (readModel) =>
924 const currentState = toAppState(store);406 set((state) => syncServerReadModel(state, readModel)),
925 const nextState = updater(currentState);407 markThreadVisited: (threadId, visitedAt) =>
926 if (nextState === currentState) {408 set((state) => markThreadVisited(state, threadId, visitedAt)),
927 return store;409 markThreadUnread: (threadId) =>
928 }410 set((state) => markThreadUnread(state, threadId)),
929 persistState(nextState);411 toggleProject: (projectId) =>
930 return nextState;412 set((state) => toggleProject(state, projectId)),
931 });413 setProjectExpanded: (projectId, expanded) =>
932 };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 }));
934 return {442 return {
935 ...readPersistedState(),443 state,
936 syncServerReadModel: (readModel) =>444 dispatch: useAppStore.getState(),
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)),
967 };445 };
968});
969
970export function StoreProvider({ children }: { children: ReactNode }) {
971 return children;
972}446}
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 {...@@ -79,13 +79,6 @@ export interface Thread {
79 projectId: ProjectId;79 projectId: ProjectId;
80 title: string;80 title: string;
81 model: string;81 model: string;
82 terminalOpen: boolean;
83 terminalHeight: number;
84 terminalIds: string[];
85 runningTerminalIds: string[];
86 activeTerminalId: string;
87 terminalGroups: ThreadTerminalGroup[];
88 activeTerminalGroupId: string;
89 session: ThreadSession | null;82 session: ThreadSession | null;
90 messages: ChatMessage[];83 messages: ChatMessage[];
91 error: string | null;84 error: string | null;
apps/web/src/worktreeCleanup.test.ts+1-13
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";1import { ProjectId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";4import type { Thread } from "./types";
5import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup";5import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup";
66
7function makeThread(overrides: Partial<Thread> = {}): Thread {7function makeThread(overrides: Partial<Thread> = {}): Thread {
...@@ -11,18 +11,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -11,18 +11,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
11 projectId: ProjectId.makeUnsafe("project-1"),11 projectId: ProjectId.makeUnsafe("project-1"),
12 title: "Thread",12 title: "Thread",
13 model: "gpt-5.3-codex",13 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}`,
26 session: null,14 session: null,
27 messages: [],15 messages: [],
28 turnDiffSummaries: [],16 turnDiffSummaries: [],