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 {
6161 type Thread,
6262 type TurnDiffSummary,
6363} from "../types";
64import {
65 createDefaultDraftThreadTerminalState,
66 reduceDraftThreadTerminalState,
67 type DraftThreadTerminalState,
68} from "../draftThreadTerminalState";
6469import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
6570import { useTheme } from "../hooks/useTheme";
6671import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
......@@ -204,6 +209,7 @@ function buildExpandedImagePreview(
204209function buildLocalDraftThread(
205210 threadId: ThreadId,
206211 draftThread: DraftThreadState,
212 draftTerminalState: DraftThreadTerminalState,
207213 fallbackModel: string,
208214 error: string | null,
209215): Thread {
......@@ -213,18 +219,13 @@ function buildLocalDraftThread(
213219 projectId: draftThread.projectId,
214220 title: "New thread",
215221 model: fallbackModel,
216 terminalOpen: false,
217 terminalHeight: 280,
218 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
219 runningTerminalIds: [],
220 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
221 terminalGroups: [
222 {
223 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
224 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
225 },
226 ],
227 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
222 terminalOpen: draftTerminalState.terminalOpen,
223 terminalHeight: draftTerminalState.terminalHeight,
224 terminalIds: draftTerminalState.terminalIds,
225 runningTerminalIds: draftTerminalState.runningTerminalIds,
226 activeTerminalId: draftTerminalState.activeTerminalId,
227 terminalGroups: draftTerminalState.terminalGroups,
228 activeTerminalGroupId: draftTerminalState.activeTerminalGroupId,
228229 session: null,
229230 messages: [],
230231 error,
......@@ -238,6 +239,46 @@ function buildLocalDraftThread(
238239 };
239240}
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
241282function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
242283 if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) {
243284 return;
......@@ -506,6 +547,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
506547 const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState<
507548 Record<ThreadId, string | null>
508549 >({});
550 const [draftTerminalStateByThreadId, setDraftTerminalStateByThreadId] = useState<
551 Record<ThreadId, DraftThreadTerminalState>
552 >({});
509553 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
510554 const [isConnecting, _setIsConnecting] = useState(false);
511555 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
......@@ -541,6 +585,26 @@ export default function ChatView({ threadId }: ChatViewProps) {
541585 setMessagesScrollElement(element);
542586 }, []);
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
544608 const setPrompt = useCallback(
545609 (nextPrompt: string) => {
546610 setComposerDraftPrompt(threadId, nextPrompt);
......@@ -573,17 +637,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
573637 );
574638
575639 const localDraftError = localDraftErrorsByThreadId[threadId] ?? null;
640 const localDraftTerminalState = draftTerminalStateByThreadId[threadId];
576641 const localDraftThread = useMemo(
577642 () =>
578643 draftThread
579644 ? buildLocalDraftThread(
580645 threadId,
581646 draftThread,
647 localDraftTerminalState ?? createDefaultDraftThreadTerminalState(),
582648 fallbackDraftProject?.model ?? DEFAULT_MODEL,
583649 localDraftError,
584650 )
585651 : undefined,
586 [draftThread, fallbackDraftProject?.model, localDraftError, threadId],
652 [draftThread, fallbackDraftProject?.model, localDraftError, localDraftTerminalState, threadId],
587653 );
588654 const activeThread = serverThread ?? localDraftThread;
589655 const isServerThread = serverThread !== undefined;
......@@ -614,6 +680,27 @@ export default function ChatView({ threadId }: ChatViewProps) {
614680 });
615681 }, [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
617704 useEffect(() => {
618705 if (!activeThread?.id) return;
619706 if (!latestTurnSettled) return;
......@@ -981,6 +1068,29 @@ export default function ChatView({ threadId }: ChatViewProps) {
9811068 );
9821069 const hasReachedTerminalLimit =
9831070 (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
9851095 const focusComposer = useCallback(() => {
9861096 const textarea = textareaRef.current;
......@@ -994,27 +1104,108 @@ export default function ChatView({ threadId }: ChatViewProps) {
9941104 focusComposer();
9951105 });
9961106 }, [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 );
9971147 const toggleTerminalVisibility = useCallback(() => {
9981148 if (!activeThreadId) return;
999 setThreadTerminalOpen(activeThreadId, !activeThread?.terminalOpen);
1000 }, [activeThread?.terminalOpen, activeThreadId, setThreadTerminalOpen]);
1149 setTerminalOpen(!activeThread?.terminalOpen);
1150 }, [activeThread?.terminalOpen, activeThreadId, setTerminalOpen]);
10011151 const splitTerminal = useCallback(() => {
10021152 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 }
10041168 setTerminalFocusRequestId((value) => value + 1);
1005 }, [activeThreadId, hasReachedTerminalLimit, splitThreadTerminalAction]);
1169 }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, updateDraftTerminalState]);
10061170 const createNewTerminal = useCallback(() => {
10071171 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 }
10091187 setTerminalFocusRequestId((value) => value + 1);
1010 }, [activeThreadId, hasReachedTerminalLimit, newThreadTerminalAction]);
1188 }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, updateDraftTerminalState]);
10111189 const activateTerminal = useCallback(
10121190 (terminalId: string) => {
10131191 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 }
10151206 setTerminalFocusRequestId((value) => value + 1);
10161207 },
1017 [activeThreadId, setThreadActiveTerminal],
1208 [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState],
10181209 );
10191210 const closeTerminal = useCallback(
10201211 (terminalId: string) => {
......@@ -1037,10 +1228,23 @@ export default function ChatView({ threadId }: ChatViewProps) {
10371228 } else {
10381229 void fallbackExitWrite();
10391230 }
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 }
10411245 setTerminalFocusRequestId((value) => value + 1);
10421246 },
1043 [activeThread?.terminalIds.length, activeThreadId, closeThreadTerminalAction],
1247 [activeThread?.terminalIds.length, activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState],
10441248 );
10451249 const runProjectScript = useCallback(
10461250 async (
......@@ -1074,11 +1278,37 @@ export default function ChatView({ threadId }: ChatViewProps) {
10741278 ? `terminal-${crypto.randomUUID()}`
10751279 : baseTerminalId;
10761280
1077 setThreadTerminalOpen(activeThreadId, true);
1281 setTerminalOpen(true);
10781282 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 }
10801297 } 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 }
10821312 }
10831313 setTerminalFocusRequestId((value) => value + 1);
10841314
......@@ -1109,7 +1339,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
11091339 data: `${script.command}\r`,
11101340 });
11111341 } catch (error) {
1112 setThreadErrorAction(
1342 setThreadError(
11131343 activeThreadId,
11141344 error instanceof Error ? error.message : `Failed to run script "${script.name}".`,
11151345 );
......@@ -1119,12 +1349,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
11191349 activeProject,
11201350 activeThread,
11211351 activeThreadId,
1352 dispatch,
11221353 gitCwd,
1354 isLocalDraftThread,
11231355 isServerThread,
1124 newThreadTerminalAction,
1125 setThreadActiveTerminal,
1126 setThreadErrorAction,
1127 setThreadTerminalOpen,
1356 setTerminalOpen,
1357 setThreadError,
1358 updateDraftTerminalState,
11281359 ],
11291360 );
11301361 const persistProjectScripts = useCallback(
......@@ -1633,7 +1864,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
16331864 event.preventDefault();
16341865 event.stopPropagation();
16351866 if (!activeThread?.terminalOpen) {
1636 setThreadTerminalOpen(activeThreadId, true);
1867 setTerminalOpen(true);
16371868 }
16381869 splitTerminal();
16391870 return;
......@@ -1651,7 +1882,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
16511882 event.preventDefault();
16521883 event.stopPropagation();
16531884 if (!activeThread?.terminalOpen) {
1654 setThreadTerminalOpen(activeThreadId, true);
1885 setTerminalOpen(true);
16551886 }
16561887 createNewTerminal();
16571888 return;
......@@ -1681,6 +1912,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
16811912 activeThreadId,
16821913 closeTerminal,
16831914 createNewTerminal,
1915 setTerminalOpen,
16841916 runProjectScript,
16851917 splitTerminal,
16861918 keybindings,
......@@ -1689,26 +1921,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
16891921 toggleTerminalVisibility,
16901922 ]);
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
17121924 const addComposerImages = (files: File[]) => {
17131925 if (!activeThreadId || files.length === 0) return;
17141926
......@@ -2658,7 +2870,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
26582870 closeShortcutLabel={closeTerminalShortcutLabel ?? undefined}
26592871 onActiveTerminalChange={activateTerminal}
26602872 onCloseTerminal={closeTerminal}
2661 onHeightChange={(height) => setThreadTerminalHeight(activeThread.id, height)}
2873 onHeightChange={setTerminalHeight}
26622874 />
26632875 );
26642876 })()}
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", () => {
166166
167167 expect(useStore.getState().threads[0]?.runningTerminalIds).toEqual([]);
168168 });
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 });
169208});
apps/web/src/store.ts+266-40
......@@ -21,6 +21,49 @@ import {
2121 type ThreadTerminalGroup,
2222} 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
2467// ── State ────────────────────────────────────────────────────────────
2568
2669export interface AppState {
......@@ -436,46 +479,229 @@ function syncServerReadModelState(state: AppState, readModel: OrchestrationReadM
436479 .map((thread) => {
437480 const existing = existingThreadById.get(thread.id);
438481
439 return normalizeThreadTerminals({
440 id: thread.id,
441 codexThreadId: thread.session?.providerThreadId ?? null,
442 projectId: thread.projectId,
443 title: thread.title,
444 model: resolveModelSlug(thread.model),
445 terminalOpen: existing?.terminalOpen ?? false,
446 terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
447 terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID],
448 runningTerminalIds: existing?.runningTerminalIds ?? [],
449 activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID,
450 terminalGroups: existing?.terminalGroups ?? [
451 {
452 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
453 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
454 },
455 ],
456 activeTerminalGroupId: existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`,
457 session: thread.session
458 ? {
459 sessionId:
460 thread.session.providerSessionId ?? ProviderSessionId.makeUnsafe(`thread:${thread.id}`),
461 provider: toLegacyProvider(thread.session.providerName),
462 status: toLegacySessionStatus(thread.session.status),
463 orchestrationStatus: thread.session.status,
464 threadId: thread.session.providerThreadId,
465 activeTurnId: thread.session.activeTurnId ?? undefined,
466 createdAt: thread.session.updatedAt,
467 updatedAt: thread.session.updatedAt,
468 ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}),
469 }
470 : null,
471 messages: thread.messages.map((message) => {
472 const attachments = message.attachments?.map((attachment) => ({
473 type: "image" as const,
474 id: attachment.id,
475 name: attachment.name,
476 mimeType: attachment.mimeType,
477 sizeBytes: attachment.sizeBytes,
478 previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)),
482export function reducer(state: AppState, action: Action): AppState {
483 switch (action.type) {
484 case "SYNC_SERVER_READ_MODEL": {
485 const projects = mapProjectsFromReadModel(
486 action.readModel.projects.filter((project) => project.deletedAt === null),
487 state.projects,
488 );
489 const existingThreadById = new Map(
490 state.threads.map((thread) => [thread.id, thread] as const),
491 );
492 const threads = action.readModel.threads
493 .filter((thread) => thread.deletedAt === null)
494 .map((thread) => {
495 const existing = existingThreadById.get(thread.id);
496
497 return normalizeThreadTerminals({
498 id: thread.id,
499 codexThreadId: thread.session?.providerThreadId ?? null,
500 projectId: thread.projectId,
501 title: thread.title,
502 model: resolveModelSlug(thread.model),
503 terminalOpen: existing?.terminalOpen ?? false,
504 terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT,
505 terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID],
506 runningTerminalIds: existing?.runningTerminalIds ?? [],
507 activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID,
508 terminalGroups: existing?.terminalGroups ?? [
509 {
510 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
511 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
512 },
513 ],
514 activeTerminalGroupId:
515 existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`,
516 session: thread.session
517 ? {
518 sessionId:
519 thread.session.providerSessionId ??
520 ProviderSessionId.makeUnsafe(`thread:${thread.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],
479705 }));
480706 const normalizedMessage: ChatMessage = {
481707 id: message.id,