History

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.

Julius Marminge committed 8 months ago

Commit f6fd7601 parent 506a5265 files · +990 −94Browse files

Unified · Split

5 files
  1. apps/web/src/components/ChatView.tsx +266 −54
  2. apps/web/src/draftThreadTerminalState.test.ts +87 −0
  3. apps/web/src/draftThreadTerminalState.ts +332 −0
  4. apps/web/src/store.test.ts +39 −0
  5. apps/web/src/store.ts +266 −40
apps/web/src/components/ChatView.tsx+266−54
@@ -61,6 +61,11 @@ import { · 60 unmodified lines
6161 type Thread,
6262 type TurnDiffSummary,
6363 } from "../types";
64+import {
65+ createDefaultDraftThreadTerminalState,
66+ reduceDraftThreadTerminalState,
67+ type DraftThreadTerminalState,
68+} from "../draftThreadTerminalState";
6469 import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
6570 import { useTheme } from "../hooks/useTheme";
6671 import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
@@ -204,6 +209,7 @@ function buildExpandedImagePreview( · 137 unmodified lines
204209 function 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( · 3 unmodified lines
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( · 7 unmodified lines
238239 };
239240 }
240241
242+function 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+
241282 function 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) { · 262 unmodified lines
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) { · 29 unmodified lines
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) { · 26 unmodified lines
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) { · 24 unmodified lines
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) { · 361 unmodified lines
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) { · 7 unmodified lines
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) { · 16 unmodified lines
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) { · 27 unmodified lines
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) { · 24 unmodified lines
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) { · 3 unmodified lines
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) { · 502 unmodified lines
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) { · 11 unmodified lines
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) { · 23 unmodified lines
16811912 activeThreadId,
16821913 closeTerminal,
16831914 createNewTerminal,
1915+ setTerminalOpen,
16841916 runProjectScript,
16851917 splitTerminal,
16861918 keybindings,
@@ -1689,26 +1921,6 @@ export default function ChatView({ threadId }: ChatViewProps) { · 2 unmodified lines
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) { · 943 unmodified lines
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+87−0
@@ -0,0 +1,87 @@
1+import { describe, expect, it } from "vitest";
2+
3+import {
4+ createDefaultDraftThreadTerminalState,
5+ reduceDraftThreadTerminalState,
6+} from "./draftThreadTerminalState";
7+
8+describe("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+332−0
@@ -0,0 +1,332 @@
1+import {
2+ DEFAULT_THREAD_TERMINAL_HEIGHT,
3+ DEFAULT_THREAD_TERMINAL_ID,
4+ MAX_THREAD_TERMINAL_COUNT,
5+ type ThreadTerminalGroup,
6+} from "./types";
7+
8+export 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+
18+export 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+
27+function normalizeTerminalIds(terminalIds: string[]): string[] {
28+ return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))];
29+}
30+
31+function fallbackGroupId(terminalId: string): string {
32+ return `group-${terminalId}`;
33+}
34+
35+function 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+
46+function findGroupIndexByTerminalId(terminalGroups: ThreadTerminalGroup[], terminalId: string): number {
47+ return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId));
48+}
49+
50+function 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+
99+export 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+
116+function 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+
150+function 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+
194+function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] {
195+ return groups.map((group) => ({
196+ id: group.id,
197+ terminalIds: [...group.terminalIds],
198+ }));
199+}
200+
201+export 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−0
@@ -166,4 +166,43 @@ describe("store terminal activity action", () => { · 165 unmodified lines
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 { · 20 unmodified lines
2121 type ThreadTerminalGroup,
2222 } from "./types";
2323
24+// ── Actions ──────────────────────────────────────────────────────────
25+
26+type 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
2669 export interface AppState {
@@ -436,46 +479,229 @@ function syncServerReadModelState(state: AppState, readModel: OrchestrationReadM · 409 unmodified lines
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)),
482+export 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,