| author | |
| committer | |
| log | f6fd760e4eb9e18dd7b89cf45f478835d20145f8 |
| tree | 941b0d2c23596edaaafd654404eeb8300f606f99 |
| parent | 506a5260dca5a5fd6324a33262be497a1d963145 |
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 | 61 | type Thread, |
| 62 | 62 | type TurnDiffSummary, |
| 63 | 63 | } from "../types"; |
| 64 | import { | |
| 65 | createDefaultDraftThreadTerminalState, | |
| 66 | reduceDraftThreadTerminalState, | |
| 67 | type DraftThreadTerminalState, | |
| 68 | } from "../draftThreadTerminalState"; | |
| 64 | 69 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; |
| 65 | 70 | import { useTheme } from "../hooks/useTheme"; |
| 66 | 71 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| ... | ... | @@ -204,6 +209,7 @@ function buildExpandedImagePreview( |
| 204 | 209 | function buildLocalDraftThread( |
| 205 | 210 | threadId: ThreadId, |
| 206 | 211 | draftThread: DraftThreadState, |
| 212 | draftTerminalState: DraftThreadTerminalState, | |
| 207 | 213 | fallbackModel: string, |
| 208 | 214 | error: string | null, |
| 209 | 215 | ): Thread { |
| ... | ... | @@ -213,18 +219,13 @@ function buildLocalDraftThread( |
| 213 | 219 | projectId: draftThread.projectId, |
| 214 | 220 | title: "New thread", |
| 215 | 221 | 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, | |
| 228 | 229 | session: null, |
| 229 | 230 | messages: [], |
| 230 | 231 | error, |
| ... | ... | @@ -238,6 +239,46 @@ function buildLocalDraftThread( |
| 238 | 239 | }; |
| 239 | 240 | } |
| 240 | 241 | |
| 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 | ||
| 241 | 282 | function revokeBlobPreviewUrl(previewUrl: string | undefined): void { |
| 242 | 283 | if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { |
| 243 | 284 | return; |
| ... | ... | @@ -506,6 +547,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 506 | 547 | const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< |
| 507 | 548 | Record<ThreadId, string | null> |
| 508 | 549 | >({}); |
| 550 | const [draftTerminalStateByThreadId, setDraftTerminalStateByThreadId] = useState< | |
| 551 | Record<ThreadId, DraftThreadTerminalState> | |
| 552 | >({}); | |
| 509 | 553 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); |
| 510 | 554 | const [isConnecting, _setIsConnecting] = useState(false); |
| 511 | 555 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| ... | ... | @@ -541,6 +585,26 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 541 | 585 | setMessagesScrollElement(element); |
| 542 | 586 | }, []); |
| 543 | 587 | |
| 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 | 608 | const setPrompt = useCallback( |
| 545 | 609 | (nextPrompt: string) => { |
| 546 | 610 | setComposerDraftPrompt(threadId, nextPrompt); |
| ... | ... | @@ -573,17 +637,19 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 573 | 637 | ); |
| 574 | 638 | |
| 575 | 639 | const localDraftError = localDraftErrorsByThreadId[threadId] ?? null; |
| 640 | const localDraftTerminalState = draftTerminalStateByThreadId[threadId]; | |
| 576 | 641 | const localDraftThread = useMemo( |
| 577 | 642 | () => |
| 578 | 643 | draftThread |
| 579 | 644 | ? buildLocalDraftThread( |
| 580 | 645 | threadId, |
| 581 | 646 | draftThread, |
| 647 | localDraftTerminalState ?? createDefaultDraftThreadTerminalState(), | |
| 582 | 648 | fallbackDraftProject?.model ?? DEFAULT_MODEL, |
| 583 | 649 | localDraftError, |
| 584 | 650 | ) |
| 585 | 651 | : undefined, |
| 586 | [draftThread, fallbackDraftProject?.model, localDraftError, threadId], | |
| 652 | [draftThread, fallbackDraftProject?.model, localDraftError, localDraftTerminalState, threadId], | |
| 587 | 653 | ); |
| 588 | 654 | const activeThread = serverThread ?? localDraftThread; |
| 589 | 655 | const isServerThread = serverThread !== undefined; |
| ... | ... | @@ -614,6 +680,27 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 614 | 680 | }); |
| 615 | 681 | }, [serverThread, threadId]); |
| 616 | 682 | |
| 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 | 704 | useEffect(() => { |
| 618 | 705 | if (!activeThread?.id) return; |
| 619 | 706 | if (!latestTurnSettled) return; |
| ... | ... | @@ -981,6 +1068,29 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 981 | 1068 | ); |
| 982 | 1069 | const hasReachedTerminalLimit = |
| 983 | 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 | ); | |
| 984 | 1094 | |
| 985 | 1095 | const focusComposer = useCallback(() => { |
| 986 | 1096 | const textarea = textareaRef.current; |
| ... | ... | @@ -994,27 +1104,108 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 994 | 1104 | focusComposer(); |
| 995 | 1105 | }); |
| 996 | 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 | 1147 | const toggleTerminalVisibility = useCallback(() => { |
| 998 | 1148 | if (!activeThreadId) return; |
| 999 | setThreadTerminalOpen(activeThreadId, !activeThread?.terminalOpen); | |
| 1000 | }, [activeThread?.terminalOpen, activeThreadId, setThreadTerminalOpen]); | |
| 1149 | setTerminalOpen(!activeThread?.terminalOpen); | |
| 1150 | }, [activeThread?.terminalOpen, activeThreadId, setTerminalOpen]); | |
| 1001 | 1151 | const splitTerminal = useCallback(() => { |
| 1002 | 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 | 1168 | setTerminalFocusRequestId((value) => value + 1); |
| 1005 | }, [activeThreadId, hasReachedTerminalLimit, splitThreadTerminalAction]); | |
| 1169 | }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, updateDraftTerminalState]); | |
| 1006 | 1170 | const createNewTerminal = useCallback(() => { |
| 1007 | 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 | 1187 | setTerminalFocusRequestId((value) => value + 1); |
| 1010 | }, [activeThreadId, hasReachedTerminalLimit, newThreadTerminalAction]); | |
| 1188 | }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, updateDraftTerminalState]); | |
| 1011 | 1189 | const activateTerminal = useCallback( |
| 1012 | 1190 | (terminalId: string) => { |
| 1013 | 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 | 1206 | setTerminalFocusRequestId((value) => value + 1); |
| 1016 | 1207 | }, |
| 1017 | [activeThreadId, setThreadActiveTerminal], | |
| 1208 | [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], | |
| 1018 | 1209 | ); |
| 1019 | 1210 | const closeTerminal = useCallback( |
| 1020 | 1211 | (terminalId: string) => { |
| ... | ... | @@ -1037,10 +1228,23 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1037 | 1228 | } else { |
| 1038 | 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 | 1245 | setTerminalFocusRequestId((value) => value + 1); |
| 1042 | 1246 | }, |
| 1043 | [activeThread?.terminalIds.length, activeThreadId, closeThreadTerminalAction], | |
| 1247 | [activeThread?.terminalIds.length, activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], | |
| 1044 | 1248 | ); |
| 1045 | 1249 | const runProjectScript = useCallback( |
| 1046 | 1250 | async ( |
| ... | ... | @@ -1074,11 +1278,37 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1074 | 1278 | ? `terminal-${crypto.randomUUID()}` |
| 1075 | 1279 | : baseTerminalId; |
| 1076 | 1280 | |
| 1077 | setThreadTerminalOpen(activeThreadId, true); | |
| 1281 | setTerminalOpen(true); | |
| 1078 | 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 | 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 | 1313 | setTerminalFocusRequestId((value) => value + 1); |
| 1084 | 1314 | |
| ... | ... | @@ -1109,7 +1339,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1109 | 1339 | data: `${script.command}\r`, |
| 1110 | 1340 | }); |
| 1111 | 1341 | } catch (error) { |
| 1112 | setThreadErrorAction( | |
| 1342 | setThreadError( | |
| 1113 | 1343 | activeThreadId, |
| 1114 | 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 | 1349 | activeProject, |
| 1120 | 1350 | activeThread, |
| 1121 | 1351 | activeThreadId, |
| 1352 | dispatch, | |
| 1122 | 1353 | gitCwd, |
| 1354 | isLocalDraftThread, | |
| 1123 | 1355 | isServerThread, |
| 1124 | newThreadTerminalAction, | |
| 1125 | setThreadActiveTerminal, | |
| 1126 | setThreadErrorAction, | |
| 1127 | setThreadTerminalOpen, | |
| 1356 | setTerminalOpen, | |
| 1357 | setThreadError, | |
| 1358 | updateDraftTerminalState, | |
| 1128 | 1359 | ], |
| 1129 | 1360 | ); |
| 1130 | 1361 | const persistProjectScripts = useCallback( |
| ... | ... | @@ -1633,7 +1864,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1633 | 1864 | event.preventDefault(); |
| 1634 | 1865 | event.stopPropagation(); |
| 1635 | 1866 | if (!activeThread?.terminalOpen) { |
| 1636 | setThreadTerminalOpen(activeThreadId, true); | |
| 1867 | setTerminalOpen(true); | |
| 1637 | 1868 | } |
| 1638 | 1869 | splitTerminal(); |
| 1639 | 1870 | return; |
| ... | ... | @@ -1651,7 +1882,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1651 | 1882 | event.preventDefault(); |
| 1652 | 1883 | event.stopPropagation(); |
| 1653 | 1884 | if (!activeThread?.terminalOpen) { |
| 1654 | setThreadTerminalOpen(activeThreadId, true); | |
| 1885 | setTerminalOpen(true); | |
| 1655 | 1886 | } |
| 1656 | 1887 | createNewTerminal(); |
| 1657 | 1888 | return; |
| ... | ... | @@ -1681,6 +1912,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1681 | 1912 | activeThreadId, |
| 1682 | 1913 | closeTerminal, |
| 1683 | 1914 | createNewTerminal, |
| 1915 | setTerminalOpen, | |
| 1684 | 1916 | runProjectScript, |
| 1685 | 1917 | splitTerminal, |
| 1686 | 1918 | keybindings, |
| ... | ... | @@ -1689,26 +1921,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1689 | 1921 | toggleTerminalVisibility, |
| 1690 | 1922 | ]); |
| 1691 | 1923 | |
| 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 | 1924 | const addComposerImages = (files: File[]) => { |
| 1713 | 1925 | if (!activeThreadId || files.length === 0) return; |
| 1714 | 1926 | |
| ... | ... | @@ -2658,7 +2870,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2658 | 2870 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} |
| 2659 | 2871 | onActiveTerminalChange={activateTerminal} |
| 2660 | 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 @@ |
| 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 created+332| ... | ... | @@ -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| ... | ... | @@ -166,4 +166,43 @@ describe("store terminal activity action", () => { |
| 166 | 166 | |
| 167 | 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 | 21 | type ThreadTerminalGroup, |
| 22 | 22 | } from "./types"; |
| 23 | 23 | |
| 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 | ||
| 24 | 67 | // ── State ──────────────────────────────────────────────────────────── |
| 25 | 68 | |
| 26 | 69 | export interface AppState { |
| ... | ... | @@ -436,46 +479,229 @@ function syncServerReadModelState(state: AppState, readModel: OrchestrationReadM |
| 436 | 479 | .map((thread) => { |
| 437 | 480 | const existing = existingThreadById.get(thread.id); |
| 438 | 481 | |
| 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], | |
| 479 | 705 | })); |
| 480 | 706 | const normalizedMessage: ChatMessage = { |
| 481 | 707 | id: message.id, |