authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 00:54:14-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 13:13:19-08:00
logf6fd760e4eb9e18dd7b89cf45f478835d20145f8
tree941b0d2c23596edaaafd654404eeb8300f606f99
parent506a5260dca5a5fd6324a33262be497a1d963145

Enable terminals for local draft threads

Adds local terminal state management for draft threads before they're promoted to server threads. Terminal operations (open, split, create, close, activate, resize) now work on draft threads and hydrate into the server thread state when the draft is promoted. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

5 files changed, 990 insertions(+), 94 deletions(-)

apps/web/src/components/ChatView.tsx+266-54
...@@ -61,6 +61,11 @@ import {...@@ -61,6 +61,11 @@ import {
61 type Thread,61 type Thread,
62 type TurnDiffSummary,62 type TurnDiffSummary,
63} from "../types";63} from "../types";
64import {
65 createDefaultDraftThreadTerminalState,
66 reduceDraftThreadTerminalState,
67 type DraftThreadTerminalState,
68} from "../draftThreadTerminalState";
64import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";69import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
65import { useTheme } from "../hooks/useTheme";70import { useTheme } from "../hooks/useTheme";
66import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";71import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
...@@ -204,6 +209,7 @@ function buildExpandedImagePreview(...@@ -204,6 +209,7 @@ function buildExpandedImagePreview(
204function buildLocalDraftThread(209function buildLocalDraftThread(
205 threadId: ThreadId,210 threadId: ThreadId,
206 draftThread: DraftThreadState,211 draftThread: DraftThreadState,
212 draftTerminalState: DraftThreadTerminalState,
207 fallbackModel: string,213 fallbackModel: string,
208 error: string | null,214 error: string | null,
209): Thread {215): Thread {
...@@ -213,18 +219,13 @@ function buildLocalDraftThread(...@@ -213,18 +219,13 @@ function buildLocalDraftThread(
213 projectId: draftThread.projectId,219 projectId: draftThread.projectId,
214 title: "New thread",220 title: "New thread",
215 model: fallbackModel,221 model: fallbackModel,
216 terminalOpen: false,222 terminalOpen: draftTerminalState.terminalOpen,
217 terminalHeight: 280,223 terminalHeight: draftTerminalState.terminalHeight,
218 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],224 terminalIds: draftTerminalState.terminalIds,
219 runningTerminalIds: [],225 runningTerminalIds: draftTerminalState.runningTerminalIds,
220 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,226 activeTerminalId: draftTerminalState.activeTerminalId,
221 terminalGroups: [227 terminalGroups: draftTerminalState.terminalGroups,
222 {228 activeTerminalGroupId: draftTerminalState.activeTerminalGroupId,
223 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
224 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
225 },
226 ],
227 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
228 session: null,229 session: null,
229 messages: [],230 messages: [],
230 error,231 error,
...@@ -238,6 +239,46 @@ function buildLocalDraftThread(...@@ -238,6 +239,46 @@ function buildLocalDraftThread(
238 };239 };
239}240}
240241
242function threadTerminalsMatchState(thread: Thread, terminalState: DraftThreadTerminalState): boolean {
243 if (
244 thread.terminalOpen !== terminalState.terminalOpen ||
245 thread.terminalHeight !== terminalState.terminalHeight ||
246 thread.activeTerminalId !== terminalState.activeTerminalId ||
247 thread.activeTerminalGroupId !== terminalState.activeTerminalGroupId
248 ) {
249 return false;
250 }
251 if (
252 thread.terminalIds.length !== terminalState.terminalIds.length ||
253 thread.runningTerminalIds.length !== terminalState.runningTerminalIds.length ||
254 thread.terminalGroups.length !== terminalState.terminalGroups.length
255 ) {
256 return false;
257 }
258 if (!thread.terminalIds.every((terminalId, index) => terminalId === terminalState.terminalIds[index])) {
259 return false;
260 }
261 if (
262 !thread.runningTerminalIds.every(
263 (terminalId, index) => terminalId === terminalState.runningTerminalIds[index],
264 )
265 ) {
266 return false;
267 }
268 return thread.terminalGroups.every((group, groupIndex) => {
269 const terminalStateGroup = terminalState.terminalGroups[groupIndex];
270 if (!terminalStateGroup || group.id !== terminalStateGroup.id) {
271 return false;
272 }
273 if (group.terminalIds.length !== terminalStateGroup.terminalIds.length) {
274 return false;
275 }
276 return group.terminalIds.every(
277 (terminalId, terminalIdIndex) => terminalId === terminalStateGroup.terminalIds[terminalIdIndex],
278 );
279 });
280}
281
241function revokeBlobPreviewUrl(previewUrl: string | undefined): void {282function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
242 if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) {283 if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) {
243 return;284 return;
...@@ -506,6 +547,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -506,6 +547,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
506 const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<547 const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
507 Record<ThreadId, string | null>548 Record<ThreadId, string | null>
508 >({});549 >({});
550 const [draftTerminalStateByThreadId, setDraftTerminalStateByThreadId] = useState<
551 Record<ThreadId, DraftThreadTerminalState>
552 >({});
509 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");553 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
510 const [isConnecting, _setIsConnecting] = useState(false);554 const [isConnecting, _setIsConnecting] = useState(false);
511 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);555 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
...@@ -541,6 +585,26 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -541,6 +585,26 @@ export default function ChatView({ threadId }: ChatViewProps) {
541 setMessagesScrollElement(element);585 setMessagesScrollElement(element);
542 }, []);586 }, []);
543587
588 const updateDraftTerminalState = useCallback(
589 (
590 targetThreadId: ThreadId,
591 updater: (state: DraftThreadTerminalState) => DraftThreadTerminalState,
592 ) => {
593 setDraftTerminalStateByThreadId((existing) => {
594 const previous = existing[targetThreadId] ?? createDefaultDraftThreadTerminalState();
595 const next = updater(previous);
596 if (next === previous) {
597 return existing;
598 }
599 return {
600 ...existing,
601 [targetThreadId]: next,
602 };
603 });
604 },
605 [],
606 );
607
544 const setPrompt = useCallback(608 const setPrompt = useCallback(
545 (nextPrompt: string) => {609 (nextPrompt: string) => {
546 setComposerDraftPrompt(threadId, nextPrompt);610 setComposerDraftPrompt(threadId, nextPrompt);
...@@ -573,17 +637,19 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -573,17 +637,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
573 );637 );
574638
575 const localDraftError = localDraftErrorsByThreadId[threadId] ?? null;639 const localDraftError = localDraftErrorsByThreadId[threadId] ?? null;
640 const localDraftTerminalState = draftTerminalStateByThreadId[threadId];
576 const localDraftThread = useMemo(641 const localDraftThread = useMemo(
577 () =>642 () =>
578 draftThread643 draftThread
579 ? buildLocalDraftThread(644 ? buildLocalDraftThread(
580 threadId,645 threadId,
581 draftThread,646 draftThread,
647 localDraftTerminalState ?? createDefaultDraftThreadTerminalState(),
582 fallbackDraftProject?.model ?? DEFAULT_MODEL,648 fallbackDraftProject?.model ?? DEFAULT_MODEL,
583 localDraftError,649 localDraftError,
584 )650 )
585 : undefined,651 : undefined,
586 [draftThread, fallbackDraftProject?.model, localDraftError, threadId],652 [draftThread, fallbackDraftProject?.model, localDraftError, localDraftTerminalState, threadId],
587 );653 );
588 const activeThread = serverThread ?? localDraftThread;654 const activeThread = serverThread ?? localDraftThread;
589 const isServerThread = serverThread !== undefined;655 const isServerThread = serverThread !== undefined;
...@@ -614,6 +680,27 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -614,6 +680,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
614 });680 });
615 }, [serverThread, threadId]);681 }, [serverThread, threadId]);
616682
683 useEffect(() => {
684 if (!serverThread || !localDraftTerminalState) {
685 return;
686 }
687 if (!threadTerminalsMatchState(serverThread, localDraftTerminalState)) {
688 dispatch({
689 type: "HYDRATE_THREAD_TERMINALS",
690 threadId: serverThread.id,
691 terminalState: localDraftTerminalState,
692 });
693 return;
694 }
695 setDraftTerminalStateByThreadId((existing) => {
696 if (!(threadId in existing)) {
697 return existing;
698 }
699 const { [threadId]: _removed, ...rest } = existing;
700 return rest as Record<ThreadId, DraftThreadTerminalState>;
701 });
702 }, [dispatch, localDraftTerminalState, serverThread, threadId]);
703
617 useEffect(() => {704 useEffect(() => {
618 if (!activeThread?.id) return;705 if (!activeThread?.id) return;
619 if (!latestTurnSettled) return;706 if (!latestTurnSettled) return;
...@@ -981,6 +1068,29 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -981,6 +1068,29 @@ export default function ChatView({ threadId }: ChatViewProps) {
981 );1068 );
982 const hasReachedTerminalLimit =1069 const hasReachedTerminalLimit =
983 (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT;1070 (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT;
1071 const setThreadError = useCallback(
1072 (targetThreadId: ThreadId | null, error: string | null) => {
1073 if (!targetThreadId) return;
1074 if (state.threads.some((thread) => thread.id === targetThreadId)) {
1075 dispatch({
1076 type: "SET_ERROR",
1077 threadId: targetThreadId,
1078 error,
1079 });
1080 return;
1081 }
1082 setLocalDraftErrorsByThreadId((existing) => {
1083 if ((existing[targetThreadId] ?? null) === error) {
1084 return existing;
1085 }
1086 return {
1087 ...existing,
1088 [targetThreadId]: error,
1089 };
1090 });
1091 },
1092 [dispatch, state.threads],
1093 );
9841094
985 const focusComposer = useCallback(() => {1095 const focusComposer = useCallback(() => {
986 const textarea = textareaRef.current;1096 const textarea = textareaRef.current;
...@@ -994,27 +1104,108 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -994,27 +1104,108 @@ export default function ChatView({ threadId }: ChatViewProps) {
994 focusComposer();1104 focusComposer();
995 });1105 });
996 }, [focusComposer]);1106 }, [focusComposer]);
1107 const setTerminalOpen = useCallback(
1108 (open: boolean) => {
1109 if (!activeThreadId) return;
1110 if (isLocalDraftThread) {
1111 updateDraftTerminalState(activeThreadId, (draftState) =>
1112 reduceDraftThreadTerminalState(draftState, {
1113 type: "set-open",
1114 open,
1115 }),
1116 );
1117 return;
1118 }
1119 dispatch({
1120 type: "SET_THREAD_TERMINAL_OPEN",
1121 threadId: activeThreadId,
1122 open,
1123 });
1124 },
1125 [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState],
1126 );
1127 const setTerminalHeight = useCallback(
1128 (height: number) => {
1129 if (!activeThreadId) return;
1130 if (isLocalDraftThread) {
1131 updateDraftTerminalState(activeThreadId, (draftState) =>
1132 reduceDraftThreadTerminalState(draftState, {
1133 type: "set-height",
1134 height,
1135 }),
1136 );
1137 return;
1138 }
1139 dispatch({
1140 type: "SET_THREAD_TERMINAL_HEIGHT",
1141 threadId: activeThreadId,
1142 height,
1143 });
1144 },
1145 [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState],
1146 );
997 const toggleTerminalVisibility = useCallback(() => {1147 const toggleTerminalVisibility = useCallback(() => {
998 if (!activeThreadId) return;1148 if (!activeThreadId) return;
999 setThreadTerminalOpen(activeThreadId, !activeThread?.terminalOpen);1149 setTerminalOpen(!activeThread?.terminalOpen);
1000 }, [activeThread?.terminalOpen, activeThreadId, setThreadTerminalOpen]);1150 }, [activeThread?.terminalOpen, activeThreadId, setTerminalOpen]);
1001 const splitTerminal = useCallback(() => {1151 const splitTerminal = useCallback(() => {
1002 if (!activeThreadId || hasReachedTerminalLimit) return;1152 if (!activeThreadId || hasReachedTerminalLimit) return;
1003 splitThreadTerminalAction(activeThreadId, `terminal-${crypto.randomUUID()}`);1153 const terminalId = `terminal-${crypto.randomUUID()}`;
1154 if (isLocalDraftThread) {
1155 updateDraftTerminalState(activeThreadId, (draftState) =>
1156 reduceDraftThreadTerminalState(draftState, {
1157 type: "split",
1158 terminalId,
1159 }),
1160 );
1161 } else {
1162 dispatch({
1163 type: "SPLIT_THREAD_TERMINAL",
1164 threadId: activeThreadId,
1165 terminalId,
1166 });
1167 }
1004 setTerminalFocusRequestId((value) => value + 1);1168 setTerminalFocusRequestId((value) => value + 1);
1005 }, [activeThreadId, hasReachedTerminalLimit, splitThreadTerminalAction]);1169 }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, updateDraftTerminalState]);
1006 const createNewTerminal = useCallback(() => {1170 const createNewTerminal = useCallback(() => {
1007 if (!activeThreadId || hasReachedTerminalLimit) return;1171 if (!activeThreadId || hasReachedTerminalLimit) return;
1008 newThreadTerminalAction(activeThreadId, `terminal-${crypto.randomUUID()}`);1172 const terminalId = `terminal-${crypto.randomUUID()}`;
1173 if (isLocalDraftThread) {
1174 updateDraftTerminalState(activeThreadId, (draftState) =>
1175 reduceDraftThreadTerminalState(draftState, {
1176 type: "new",
1177 terminalId,
1178 }),
1179 );
1180 } else {
1181 dispatch({
1182 type: "NEW_THREAD_TERMINAL",
1183 threadId: activeThreadId,
1184 terminalId,
1185 });
1186 }
1009 setTerminalFocusRequestId((value) => value + 1);1187 setTerminalFocusRequestId((value) => value + 1);
1010 }, [activeThreadId, hasReachedTerminalLimit, newThreadTerminalAction]);1188 }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, updateDraftTerminalState]);
1011 const activateTerminal = useCallback(1189 const activateTerminal = useCallback(
1012 (terminalId: string) => {1190 (terminalId: string) => {
1013 if (!activeThreadId) return;1191 if (!activeThreadId) return;
1014 setThreadActiveTerminal(activeThreadId, terminalId);1192 if (isLocalDraftThread) {
1193 updateDraftTerminalState(activeThreadId, (draftState) =>
1194 reduceDraftThreadTerminalState(draftState, {
1195 type: "set-active",
1196 terminalId,
1197 }),
1198 );
1199 } else {
1200 dispatch({
1201 type: "SET_THREAD_ACTIVE_TERMINAL",
1202 threadId: activeThreadId,
1203 terminalId,
1204 });
1205 }
1015 setTerminalFocusRequestId((value) => value + 1);1206 setTerminalFocusRequestId((value) => value + 1);
1016 },1207 },
1017 [activeThreadId, setThreadActiveTerminal],1208 [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState],
1018 );1209 );
1019 const closeTerminal = useCallback(1210 const closeTerminal = useCallback(
1020 (terminalId: string) => {1211 (terminalId: string) => {
...@@ -1037,10 +1228,23 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1037,10 +1228,23 @@ export default function ChatView({ threadId }: ChatViewProps) {
1037 } else {1228 } else {
1038 void fallbackExitWrite();1229 void fallbackExitWrite();
1039 }1230 }
1040 closeThreadTerminalAction(activeThreadId, terminalId);1231 if (isLocalDraftThread) {
1232 updateDraftTerminalState(activeThreadId, (draftState) =>
1233 reduceDraftThreadTerminalState(draftState, {
1234 type: "close",
1235 terminalId,
1236 }),
1237 );
1238 } else {
1239 dispatch({
1240 type: "CLOSE_THREAD_TERMINAL",
1241 threadId: activeThreadId,
1242 terminalId,
1243 });
1244 }
1041 setTerminalFocusRequestId((value) => value + 1);1245 setTerminalFocusRequestId((value) => value + 1);
1042 },1246 },
1043 [activeThread?.terminalIds.length, activeThreadId, closeThreadTerminalAction],1247 [activeThread?.terminalIds.length, activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState],
1044 );1248 );
1045 const runProjectScript = useCallback(1249 const runProjectScript = useCallback(
1046 async (1250 async (
...@@ -1074,11 +1278,37 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1074,11 +1278,37 @@ export default function ChatView({ threadId }: ChatViewProps) {
1074 ? `terminal-${crypto.randomUUID()}`1278 ? `terminal-${crypto.randomUUID()}`
1075 : baseTerminalId;1279 : baseTerminalId;
10761280
1077 setThreadTerminalOpen(activeThreadId, true);1281 setTerminalOpen(true);
1078 if (shouldCreateNewTerminal) {1282 if (shouldCreateNewTerminal) {
1079 newThreadTerminalAction(activeThreadId, targetTerminalId);1283 if (isLocalDraftThread) {
1284 updateDraftTerminalState(activeThreadId, (draftState) =>
1285 reduceDraftThreadTerminalState(draftState, {
1286 type: "new",
1287 terminalId: targetTerminalId,
1288 }),
1289 );
1290 } else {
1291 dispatch({
1292 type: "NEW_THREAD_TERMINAL",
1293 threadId: activeThreadId,
1294 terminalId: targetTerminalId,
1295 });
1296 }
1080 } else {1297 } else {
1081 setThreadActiveTerminal(activeThreadId, targetTerminalId);1298 if (isLocalDraftThread) {
1299 updateDraftTerminalState(activeThreadId, (draftState) =>
1300 reduceDraftThreadTerminalState(draftState, {
1301 type: "set-active",
1302 terminalId: targetTerminalId,
1303 }),
1304 );
1305 } else {
1306 dispatch({
1307 type: "SET_THREAD_ACTIVE_TERMINAL",
1308 threadId: activeThreadId,
1309 terminalId: targetTerminalId,
1310 });
1311 }
1082 }1312 }
1083 setTerminalFocusRequestId((value) => value + 1);1313 setTerminalFocusRequestId((value) => value + 1);
10841314
...@@ -1109,7 +1339,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1109,7 +1339,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1109 data: `${script.command}\r`,1339 data: `${script.command}\r`,
1110 });1340 });
1111 } catch (error) {1341 } catch (error) {
1112 setThreadErrorAction(1342 setThreadError(
1113 activeThreadId,1343 activeThreadId,
1114 error instanceof Error ? error.message : `Failed to run script "${script.name}".`,1344 error instanceof Error ? error.message : `Failed to run script "${script.name}".`,
1115 );1345 );
...@@ -1119,12 +1349,13 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1119,12 +1349,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
1119 activeProject,1349 activeProject,
1120 activeThread,1350 activeThread,
1121 activeThreadId,1351 activeThreadId,
1352 dispatch,
1122 gitCwd,1353 gitCwd,
1354 isLocalDraftThread,
1123 isServerThread,1355 isServerThread,
1124 newThreadTerminalAction,1356 setTerminalOpen,
1125 setThreadActiveTerminal,1357 setThreadError,
1126 setThreadErrorAction,1358 updateDraftTerminalState,
1127 setThreadTerminalOpen,
1128 ],1359 ],
1129 );1360 );
1130 const persistProjectScripts = useCallback(1361 const persistProjectScripts = useCallback(
...@@ -1633,7 +1864,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1633,7 +1864,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1633 event.preventDefault();1864 event.preventDefault();
1634 event.stopPropagation();1865 event.stopPropagation();
1635 if (!activeThread?.terminalOpen) {1866 if (!activeThread?.terminalOpen) {
1636 setThreadTerminalOpen(activeThreadId, true);1867 setTerminalOpen(true);
1637 }1868 }
1638 splitTerminal();1869 splitTerminal();
1639 return;1870 return;
...@@ -1651,7 +1882,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1651,7 +1882,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1651 event.preventDefault();1882 event.preventDefault();
1652 event.stopPropagation();1883 event.stopPropagation();
1653 if (!activeThread?.terminalOpen) {1884 if (!activeThread?.terminalOpen) {
1654 setThreadTerminalOpen(activeThreadId, true);1885 setTerminalOpen(true);
1655 }1886 }
1656 createNewTerminal();1887 createNewTerminal();
1657 return;1888 return;
...@@ -1681,6 +1912,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1681,6 +1912,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1681 activeThreadId,1912 activeThreadId,
1682 closeTerminal,1913 closeTerminal,
1683 createNewTerminal,1914 createNewTerminal,
1915 setTerminalOpen,
1684 runProjectScript,1916 runProjectScript,
1685 splitTerminal,1917 splitTerminal,
1686 keybindings,1918 keybindings,
...@@ -1689,26 +1921,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1689,26 +1921,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
1689 toggleTerminalVisibility,1921 toggleTerminalVisibility,
1690 ]);1922 ]);
16911923
1692 const setThreadError = useCallback(
1693 (threadId: ThreadId | null, error: string | null) => {
1694 if (!threadId) return;
1695 if (useStore.getState().threads.some((thread) => thread.id === threadId)) {
1696 setThreadErrorAction(threadId, error);
1697 return;
1698 }
1699 setLocalDraftErrorsByThreadId((existing) => {
1700 if ((existing[threadId] ?? null) === error) {
1701 return existing;
1702 }
1703 return {
1704 ...existing,
1705 [threadId]: error,
1706 };
1707 });
1708 },
1709 [setThreadErrorAction],
1710 );
1711
1712 const addComposerImages = (files: File[]) => {1924 const addComposerImages = (files: File[]) => {
1713 if (!activeThreadId || files.length === 0) return;1925 if (!activeThreadId || files.length === 0) return;
17141926
...@@ -2658,7 +2870,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2658,7 +2870,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2658 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}2870 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
2659 onActiveTerminalChange={activateTerminal}2871 onActiveTerminalChange={activateTerminal}
2660 onCloseTerminal={closeTerminal}2872 onCloseTerminal={closeTerminal}
2661 onHeightChange={(height) => setThreadTerminalHeight(activeThread.id, height)}2873 onHeightChange={setTerminalHeight}
2662 />2874 />
2663 );2875 );
2664 })()}2876 })()}
apps/web/src/draftThreadTerminalState.test.ts created+87
...@@ -0,0 +1,87 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 createDefaultDraftThreadTerminalState,
5 reduceDraftThreadTerminalState,
6} from "./draftThreadTerminalState";
7
8describe("draftThreadTerminalState", () => {
9 it("creates a closed default terminal state", () => {
10 expect(createDefaultDraftThreadTerminalState()).toEqual({
11 terminalOpen: false,
12 terminalHeight: 280,
13 terminalIds: ["default"],
14 runningTerminalIds: [],
15 activeTerminalId: "default",
16 terminalGroups: [{ id: "group-default", terminalIds: ["default"] }],
17 activeTerminalGroupId: "group-default",
18 });
19 });
20
21 it("opens and splits terminal tabs into the active group", () => {
22 const opened = reduceDraftThreadTerminalState(createDefaultDraftThreadTerminalState(), {
23 type: "set-open",
24 open: true,
25 });
26 const split = reduceDraftThreadTerminalState(opened, {
27 type: "split",
28 terminalId: "terminal-2",
29 });
30
31 expect(split.terminalOpen).toBe(true);
32 expect(split.terminalIds).toEqual(["default", "terminal-2"]);
33 expect(split.activeTerminalId).toBe("terminal-2");
34 expect(split.terminalGroups).toEqual([
35 {
36 id: "group-default",
37 terminalIds: ["default", "terminal-2"],
38 },
39 ]);
40 });
41
42 it("creates new terminals in a new group", () => {
43 const next = reduceDraftThreadTerminalState(createDefaultDraftThreadTerminalState(), {
44 type: "new",
45 terminalId: "terminal-2",
46 });
47
48 expect(next.terminalIds).toEqual(["default", "terminal-2"]);
49 expect(next.activeTerminalId).toBe("terminal-2");
50 expect(next.activeTerminalGroupId).toBe("group-terminal-2");
51 expect(next.terminalGroups).toEqual([
52 { id: "group-default", terminalIds: ["default"] },
53 { id: "group-terminal-2", terminalIds: ["terminal-2"] },
54 ]);
55 });
56
57 it("resets to default when closing the last terminal", () => {
58 const closed = reduceDraftThreadTerminalState(createDefaultDraftThreadTerminalState(), {
59 type: "close",
60 terminalId: "default",
61 });
62
63 expect(closed).toEqual(createDefaultDraftThreadTerminalState());
64 });
65
66 it("keeps a valid active terminal after closing an active split terminal", () => {
67 const splitOnce = reduceDraftThreadTerminalState(createDefaultDraftThreadTerminalState(), {
68 type: "split",
69 terminalId: "terminal-2",
70 });
71 const splitTwice = reduceDraftThreadTerminalState(splitOnce, {
72 type: "split",
73 terminalId: "terminal-3",
74 });
75
76 const closed = reduceDraftThreadTerminalState(splitTwice, {
77 type: "close",
78 terminalId: "terminal-3",
79 });
80
81 expect(closed.activeTerminalId).toBe("terminal-2");
82 expect(closed.terminalIds).toEqual(["default", "terminal-2"]);
83 expect(closed.terminalGroups).toEqual([
84 { id: "group-default", terminalIds: ["default", "terminal-2"] },
85 ]);
86 });
87});
apps/web/src/draftThreadTerminalState.ts created+332
...@@ -0,0 +1,332 @@
1import {
2 DEFAULT_THREAD_TERMINAL_HEIGHT,
3 DEFAULT_THREAD_TERMINAL_ID,
4 MAX_THREAD_TERMINAL_COUNT,
5 type ThreadTerminalGroup,
6} from "./types";
7
8export interface DraftThreadTerminalState {
9 terminalOpen: boolean;
10 terminalHeight: number;
11 terminalIds: string[];
12 runningTerminalIds: string[];
13 activeTerminalId: string;
14 terminalGroups: ThreadTerminalGroup[];
15 activeTerminalGroupId: string;
16}
17
18export type DraftThreadTerminalAction =
19 | { type: "set-open"; open: boolean }
20 | { type: "set-height"; height: number }
21 | { type: "split"; terminalId: string }
22 | { type: "new"; terminalId: string }
23 | { type: "set-active"; terminalId: string }
24 | { type: "close"; terminalId: string }
25 | { type: "set-activity"; terminalId: string; hasRunningSubprocess: boolean };
26
27function normalizeTerminalIds(terminalIds: string[]): string[] {
28 return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
29}
30
31function fallbackGroupId(terminalId: string): string {
32 return `group-${terminalId}`;
33}
34
35function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string {
36 let candidate = baseId;
37 let index = 2;
38 while (usedGroupIds.has(candidate)) {
39 candidate = `${baseId}-${index}`;
40 index += 1;
41 }
42 usedGroupIds.add(candidate);
43 return candidate;
44}
45
46function findGroupIndexByTerminalId(terminalGroups: ThreadTerminalGroup[], terminalId: string): number {
47 return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId));
48}
49
50function normalizeTerminalGroups(
51 terminalGroups: ThreadTerminalGroup[],
52 terminalIds: string[],
53): ThreadTerminalGroup[] {
54 const validTerminalIdSet = new Set(terminalIds);
55 const assignedTerminalIds = new Set<string>();
56 const nextGroups: ThreadTerminalGroup[] = [];
57 const usedGroupIds = new Set<string>();
58
59 for (const group of terminalGroups) {
60 const groupTerminalIds = normalizeTerminalIds(group.terminalIds).filter((terminalId) => {
61 if (!validTerminalIdSet.has(terminalId)) return false;
62 if (assignedTerminalIds.has(terminalId)) return false;
63 return true;
64 });
65 if (groupTerminalIds.length === 0) {
66 continue;
67 }
68
69 for (const terminalId of groupTerminalIds) {
70 assignedTerminalIds.add(terminalId);
71 }
72
73 nextGroups.push({
74 id: assignUniqueGroupId(group.id, usedGroupIds),
75 terminalIds: groupTerminalIds,
76 });
77 }
78
79 for (const terminalId of terminalIds) {
80 if (assignedTerminalIds.has(terminalId)) continue;
81 nextGroups.push({
82 id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds),
83 terminalIds: [terminalId],
84 });
85 }
86
87 if (nextGroups.length === 0) {
88 return [
89 {
90 id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
91 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
92 },
93 ];
94 }
95
96 return nextGroups;
97}
98
99export function createDefaultDraftThreadTerminalState(): DraftThreadTerminalState {
100 return {
101 terminalOpen: false,
102 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
103 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
104 runningTerminalIds: [],
105 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
106 terminalGroups: [
107 {
108 id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
109 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
110 },
111 ],
112 activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
113 };
114}
115
116function normalizeDraftThreadTerminals(state: DraftThreadTerminalState): DraftThreadTerminalState {
117 const terminalIds = normalizeTerminalIds(state.terminalIds);
118 const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID];
119 const runningTerminalIds = normalizeTerminalIds(state.runningTerminalIds).filter((terminalId) =>
120 nextTerminalIds.includes(terminalId),
121 );
122 const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId)
123 ? state.activeTerminalId
124 : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID);
125 const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds);
126 const activeGroupIdFromState = terminalGroups.some((group) => group.id === state.activeTerminalGroupId)
127 ? state.activeTerminalGroupId
128 : null;
129 const activeGroupIdFromTerminal =
130 terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null;
131
132 return {
133 terminalOpen: state.terminalOpen,
134 terminalHeight:
135 Number.isFinite(state.terminalHeight) && state.terminalHeight > 0
136 ? state.terminalHeight
137 : DEFAULT_THREAD_TERMINAL_HEIGHT,
138 terminalIds: nextTerminalIds,
139 runningTerminalIds,
140 activeTerminalId,
141 terminalGroups,
142 activeTerminalGroupId:
143 activeGroupIdFromState ??
144 activeGroupIdFromTerminal ??
145 terminalGroups[0]?.id ??
146 fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID),
147 };
148}
149
150function closeDraftTerminal(
151 state: DraftThreadTerminalState,
152 terminalId: string,
153): DraftThreadTerminalState {
154 if (!state.terminalIds.includes(terminalId)) {
155 return state;
156 }
157
158 const remainingTerminalIds = state.terminalIds.filter((id) => id !== terminalId);
159 if (remainingTerminalIds.length === 0) {
160 return createDefaultDraftThreadTerminalState();
161 }
162
163 const closedTerminalIndex = state.terminalIds.indexOf(terminalId);
164 const nextActiveTerminalId =
165 state.activeTerminalId === terminalId
166 ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ??
167 remainingTerminalIds[0] ??
168 DEFAULT_THREAD_TERMINAL_ID)
169 : state.activeTerminalId;
170
171 const terminalGroups = state.terminalGroups
172 .map((group) => ({
173 ...group,
174 terminalIds: group.terminalIds.filter((id) => id !== terminalId),
175 }))
176 .filter((group) => group.terminalIds.length > 0);
177
178 const nextActiveTerminalGroupId =
179 terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ??
180 terminalGroups[0]?.id ??
181 fallbackGroupId(nextActiveTerminalId);
182
183 return normalizeDraftThreadTerminals({
184 terminalOpen: state.terminalOpen,
185 terminalHeight: state.terminalHeight,
186 terminalIds: remainingTerminalIds,
187 runningTerminalIds: state.runningTerminalIds.filter((id) => id !== terminalId),
188 activeTerminalId: nextActiveTerminalId,
189 terminalGroups,
190 activeTerminalGroupId: nextActiveTerminalGroupId,
191 });
192}
193
194function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] {
195 return groups.map((group) => ({
196 id: group.id,
197 terminalIds: [...group.terminalIds],
198 }));
199}
200
201export function reduceDraftThreadTerminalState(
202 state: DraftThreadTerminalState,
203 action: DraftThreadTerminalAction,
204): DraftThreadTerminalState {
205 const normalized = normalizeDraftThreadTerminals(state);
206
207 if (action.type === "set-open") {
208 if (normalized.terminalOpen === action.open) return normalized;
209 return {
210 ...normalized,
211 terminalOpen: action.open,
212 };
213 }
214
215 if (action.type === "set-height") {
216 if (!Number.isFinite(action.height) || action.height <= 0 || normalized.terminalHeight === action.height) {
217 return normalized;
218 }
219 return {
220 ...normalized,
221 terminalHeight: action.height,
222 };
223 }
224
225 if (action.type === "set-active") {
226 if (!normalized.terminalIds.includes(action.terminalId)) {
227 return normalized;
228 }
229 const activeTerminalGroupId =
230 normalized.terminalGroups.find((group) => group.terminalIds.includes(action.terminalId))?.id ??
231 normalized.activeTerminalGroupId;
232 return {
233 ...normalized,
234 activeTerminalId: action.terminalId,
235 activeTerminalGroupId,
236 };
237 }
238
239 if (action.type === "close") {
240 return closeDraftTerminal(normalized, action.terminalId);
241 }
242
243 if (action.type === "set-activity") {
244 if (!normalized.terminalIds.includes(action.terminalId)) {
245 return normalized;
246 }
247 const runningTerminalIds = new Set(normalized.runningTerminalIds);
248 if (action.hasRunningSubprocess) {
249 runningTerminalIds.add(action.terminalId);
250 } else {
251 runningTerminalIds.delete(action.terminalId);
252 }
253 return {
254 ...normalized,
255 runningTerminalIds: [...runningTerminalIds],
256 };
257 }
258
259 if (!action.terminalId || action.terminalId.trim().length === 0) {
260 return normalized;
261 }
262
263 const isNewTerminal = !normalized.terminalIds.includes(action.terminalId);
264 if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
265 return normalized;
266 }
267
268 const terminalIds = isNewTerminal
269 ? [...normalized.terminalIds, action.terminalId]
270 : normalized.terminalIds;
271 const terminalGroups = copyTerminalGroups(normalized.terminalGroups);
272 const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, action.terminalId);
273 if (existingGroupIndex >= 0) {
274 terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter(
275 (id) => id !== action.terminalId,
276 );
277 if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) {
278 terminalGroups.splice(existingGroupIndex, 1);
279 }
280 }
281
282 if (action.type === "new") {
283 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
284 const nextGroupId = assignUniqueGroupId(fallbackGroupId(action.terminalId), usedGroupIds);
285 terminalGroups.push({ id: nextGroupId, terminalIds: [action.terminalId] });
286 return normalizeDraftThreadTerminals({
287 ...normalized,
288 terminalOpen: true,
289 terminalIds,
290 activeTerminalId: action.terminalId,
291 terminalGroups,
292 activeTerminalGroupId: nextGroupId,
293 });
294 }
295
296 let activeGroupIndex = terminalGroups.findIndex((group) => group.id === normalized.activeTerminalGroupId);
297 if (activeGroupIndex < 0) {
298 activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId);
299 }
300 if (activeGroupIndex < 0) {
301 const usedGroupIds = new Set(terminalGroups.map((group) => group.id));
302 const nextGroupId = assignUniqueGroupId(
303 fallbackGroupId(normalized.activeTerminalId),
304 usedGroupIds,
305 );
306 terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] });
307 activeGroupIndex = terminalGroups.length - 1;
308 }
309
310 const destinationGroup = terminalGroups[activeGroupIndex];
311 if (!destinationGroup) {
312 return normalized;
313 }
314
315 if (!destinationGroup.terminalIds.includes(action.terminalId)) {
316 const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId);
317 if (anchorIndex >= 0) {
318 destinationGroup.terminalIds.splice(anchorIndex + 1, 0, action.terminalId);
319 } else {
320 destinationGroup.terminalIds.push(action.terminalId);
321 }
322 }
323
324 return normalizeDraftThreadTerminals({
325 ...normalized,
326 terminalOpen: true,
327 terminalIds,
328 activeTerminalId: action.terminalId,
329 terminalGroups,
330 activeTerminalGroupId: destinationGroup.id,
331 });
332}
apps/web/src/store.test.ts+39
...@@ -166,4 +166,43 @@ describe("store terminal activity action", () => {...@@ -166,4 +166,43 @@ describe("store terminal activity action", () => {
166166
167 expect(useStore.getState().threads[0]?.runningTerminalIds).toEqual([]);167 expect(useStore.getState().threads[0]?.runningTerminalIds).toEqual([]);
168 });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 });
169});208});
apps/web/src/store.ts+266-40
...@@ -21,6 +21,49 @@ import {...@@ -21,6 +21,49 @@ import {
21 type ThreadTerminalGroup,21 type ThreadTerminalGroup,
22} from "./types";22} from "./types";
2323
24// ── Actions ──────────────────────────────────────────────────────────
25
26type Action =
27 | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel }
28 | {
29 type: "HYDRATE_THREAD_TERMINALS";
30 threadId: ThreadId;
31 terminalState: {
32 terminalOpen: boolean;
33 terminalHeight: number;
34 terminalIds: string[];
35 runningTerminalIds: string[];
36 activeTerminalId: string;
37 terminalGroups: ThreadTerminalGroup[];
38 activeTerminalGroupId: string;
39 };
40 }
41 | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string }
42 | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId }
43 | { type: "TOGGLE_PROJECT"; projectId: Project["id"] }
44 | {
45 type: "SET_THREAD_TERMINAL_ACTIVITY";
46 threadId: ThreadId;
47 terminalId: string;
48 hasRunningSubprocess: boolean;
49 }
50 | { type: "SET_PROJECT_EXPANDED"; projectId: Project["id"]; expanded: boolean }
51 | { type: "TOGGLE_THREAD_TERMINAL"; threadId: ThreadId }
52 | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: ThreadId; open: boolean }
53 | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: ThreadId; height: number }
54 | { type: "SPLIT_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
55 | { type: "NEW_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
56 | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: ThreadId; terminalId: string }
57 | { type: "CLOSE_THREAD_TERMINAL"; threadId: ThreadId; terminalId: string }
58 | { type: "SET_ERROR"; threadId: ThreadId; error: string | null }
59 | {
60 type: "SET_THREAD_BRANCH";
61 threadId: ThreadId;
62 branch: string | null;
63 worktreePath: string | null;
64 }
65 | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode };
66
24// ── State ────────────────────────────────────────────────────────────67// ── State ────────────────────────────────────────────────────────────
2568
26export interface AppState {69export interface AppState {
...@@ -436,46 +479,229 @@ function syncServerReadModelState(state: AppState, readModel: OrchestrationReadM...@@ -436,46 +479,229 @@ function syncServerReadModelState(state: AppState, readModel: OrchestrationReadM
436 .map((thread) => {479 .map((thread) => {
437 const existing = existingThreadById.get(thread.id);480 const existing = existingThreadById.get(thread.id);
438481
439 return normalizeThreadTerminals({482export function reducer(state: AppState, action: Action): AppState {
440 id: thread.id,483 switch (action.type) {
441 codexThreadId: thread.session?.providerThreadId ?? null,484 case "SYNC_SERVER_READ_MODEL": {
442 projectId: thread.projectId,485 const projects = mapProjectsFromReadModel(
443 title: thread.title,486 action.readModel.projects.filter((project) => project.deletedAt === null),
444 model: resolveModelSlug(thread.model),487 state.projects,
445 terminalOpen: existing?.terminalOpen ?? false,488 );
446 terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,489 const existingThreadById = new Map(
447 terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID],490 state.threads.map((thread) => [thread.id, thread] as const),
448 runningTerminalIds: existing?.runningTerminalIds ?? [],491 );
449 activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID,492 const threads = action.readModel.threads
450 terminalGroups: existing?.terminalGroups ?? [493 .filter((thread) => thread.deletedAt === null)
451 {494 .map((thread) => {
452 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,495 const existing = existingThreadById.get(thread.id);
453 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],496
454 },497 return normalizeThreadTerminals({
455 ],498 id: thread.id,
456 activeTerminalGroupId: existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`,499 codexThreadId: thread.session?.providerThreadId ?? null,
457 session: thread.session500 projectId: thread.projectId,
458 ? {501 title: thread.title,
459 sessionId:502 model: resolveModelSlug(thread.model),
460 thread.session.providerSessionId ?? ProviderSessionId.makeUnsafe(`thread:${thread.id}`),503 terminalOpen: existing?.terminalOpen ?? false,
461 provider: toLegacyProvider(thread.session.providerName),504 terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
462 status: toLegacySessionStatus(thread.session.status),505 terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID],
463 orchestrationStatus: thread.session.status,506 runningTerminalIds: existing?.runningTerminalIds ?? [],
464 threadId: thread.session.providerThreadId,507 activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID,
465 activeTurnId: thread.session.activeTurnId ?? undefined,508 terminalGroups: existing?.terminalGroups ?? [
466 createdAt: thread.session.updatedAt,509 {
467 updatedAt: thread.session.updatedAt,510 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
468 ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}),511 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
469 }512 },
470 : null,513 ],
471 messages: thread.messages.map((message) => {514 activeTerminalGroupId:
472 const attachments = message.attachments?.map((attachment) => ({515 existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`,
473 type: "image" as const,516 session: thread.session
474 id: attachment.id,517 ? {
475 name: attachment.name,518 sessionId:
476 mimeType: attachment.mimeType,519 thread.session.providerSessionId ??
477 sizeBytes: attachment.sizeBytes,520 ProviderSessionId.makeUnsafe(`thread:${thread.id}`),
478 previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)),521 provider: toLegacyProvider(thread.session.providerName),
522 status: toLegacySessionStatus(thread.session.status),
523 orchestrationStatus: thread.session.status,
524 threadId: thread.session.providerThreadId,
525 activeTurnId: thread.session.activeTurnId ?? undefined,
526 createdAt: thread.session.updatedAt,
527 updatedAt: thread.session.updatedAt,
528 ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}),
529 }
530 : null,
531 messages: thread.messages.map((message) => {
532 const attachments = message.attachments?.map((attachment) => ({
533 type: "image" as const,
534 id: attachment.id,
535 name: attachment.name,
536 mimeType: attachment.mimeType,
537 sizeBytes: attachment.sizeBytes,
538 previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)),
539 }));
540 const normalizedMessage: ChatMessage = {
541 id: message.id,
542 role: message.role,
543 text: message.text,
544 createdAt: message.createdAt,
545 streaming: message.streaming,
546 ...(message.streaming ? {} : { completedAt: message.updatedAt }),
547 ...(attachments && attachments.length > 0 ? { attachments } : {}),
548 };
549 return normalizedMessage;
550 }),
551 error: thread.session?.lastError ?? null,
552 createdAt: thread.createdAt,
553 latestTurn: thread.latestTurn,
554 lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,
555 branch: thread.branch,
556 worktreePath: thread.worktreePath,
557 turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({
558 turnId: checkpoint.turnId,
559 completedAt: checkpoint.completedAt,
560 status: checkpoint.status,
561 assistantMessageId: checkpoint.assistantMessageId ?? undefined,
562 checkpointTurnCount: checkpoint.checkpointTurnCount,
563 checkpointRef: checkpoint.checkpointRef,
564 files: checkpoint.files.map((file) => ({ ...file })),
565 })),
566 activities: thread.activities.map((activity) => ({ ...activity })),
567 });
568 });
569 return {
570 ...state,
571 projects,
572 threads,
573 threadsHydrated: true,
574 };
575 }
576
577 case "HYDRATE_THREAD_TERMINALS":
578 return {
579 ...state,
580 threads: updateThread(state.threads, action.threadId, (thread) =>
581 normalizeThreadTerminals({
582 ...thread,
583 terminalOpen: action.terminalState.terminalOpen,
584 terminalHeight: action.terminalState.terminalHeight,
585 terminalIds: action.terminalState.terminalIds,
586 runningTerminalIds: action.terminalState.runningTerminalIds,
587 activeTerminalId: action.terminalState.activeTerminalId,
588 terminalGroups: action.terminalState.terminalGroups,
589 activeTerminalGroupId: action.terminalState.activeTerminalGroupId,
590 }),
591 ),
592 };
593
594 case "MARK_THREAD_VISITED": {
595 const visitedAt = action.visitedAt ?? new Date().toISOString();
596 const visitedAtMs = Date.parse(visitedAt);
597 return {
598 ...state,
599 threads: updateThread(state.threads, action.threadId, (thread) => {
600 const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN;
601 if (
602 Number.isFinite(previousVisitedAtMs) &&
603 Number.isFinite(visitedAtMs) &&
604 previousVisitedAtMs >= visitedAtMs
605 ) {
606 return thread;
607 }
608 return {
609 ...thread,
610 lastVisitedAt: visitedAt,
611 };
612 }),
613 };
614 }
615
616 case "MARK_THREAD_UNREAD": {
617 return {
618 ...state,
619 threads: updateThread(state.threads, action.threadId, (thread) => {
620 if (!thread.latestTurn?.completedAt) {
621 return thread;
622 }
623 const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt);
624 if (Number.isNaN(latestTurnCompletedAtMs)) {
625 return thread;
626 }
627 const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString();
628 if (thread.lastVisitedAt === unreadVisitedAt) {
629 return thread;
630 }
631 return {
632 ...thread,
633 lastVisitedAt: unreadVisitedAt,
634 };
635 }),
636 };
637 }
638
639 case "TOGGLE_PROJECT":
640 return {
641 ...state,
642 projects: state.projects.map((p) =>
643 p.id === action.projectId ? { ...p, expanded: !p.expanded } : p,
644 ),
645 };
646
647 case "SET_THREAD_TERMINAL_ACTIVITY":
648 return {
649 ...state,
650 threads: updateThread(state.threads, action.threadId, (thread) =>
651 setThreadTerminalActivity(thread, action.terminalId, action.hasRunningSubprocess),
652 ),
653 };
654
655 case "SET_PROJECT_EXPANDED":
656 return {
657 ...state,
658 projects: state.projects.map((p) =>
659 p.id === action.projectId ? { ...p, expanded: action.expanded } : p,
660 ),
661 };
662
663 case "TOGGLE_THREAD_TERMINAL":
664 return {
665 ...state,
666 threads: updateThread(state.threads, action.threadId, (t) => ({
667 ...t,
668 terminalOpen: !t.terminalOpen,
669 })),
670 };
671
672 case "SET_THREAD_TERMINAL_OPEN":
673 return {
674 ...state,
675 threads: updateThread(state.threads, action.threadId, (t) => ({
676 ...t,
677 terminalOpen: action.open,
678 })),
679 };
680
681 case "SET_THREAD_TERMINAL_HEIGHT":
682 return {
683 ...state,
684 threads: updateThread(state.threads, action.threadId, (t) => ({
685 ...t,
686 terminalHeight: action.height,
687 })),
688 };
689
690 case "SPLIT_THREAD_TERMINAL":
691 return {
692 ...state,
693 threads: updateThread(state.threads, action.threadId, (thread) => {
694 const normalizedThread = normalizeThreadTerminals(thread);
695 const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId);
696 if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) {
697 return normalizedThread;
698 }
699 const terminalIds = normalizedThread.terminalIds.includes(action.terminalId)
700 ? normalizedThread.terminalIds
701 : [...normalizedThread.terminalIds, action.terminalId];
702 const terminalGroups = normalizedThread.terminalGroups.map((group) => ({
703 ...group,
704 terminalIds: [...group.terminalIds],
479 }));705 }));
480 const normalizedMessage: ChatMessage = {706 const normalizedMessage: ChatMessage = {
481 id: message.id,707 id: message.id,