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.
Unified · Split
| @@ -61,6 +61,11 @@ import { · 60 unmodified lines | ||
| 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( · 137 unmodified lines | ||
| 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( · 3 unmodified lines | ||
| 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( · 7 unmodified lines | ||
| 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) { · 262 unmodified lines | ||
| 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) { · 29 unmodified lines | ||
| 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) { · 26 unmodified lines | ||
| 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) { · 24 unmodified lines | ||
| 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) { · 361 unmodified lines | ||
| 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) { · 7 unmodified lines | ||
| 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) { · 16 unmodified lines | ||
| 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) { · 27 unmodified lines | ||
| 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) { · 24 unmodified lines | ||
| 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) { · 3 unmodified lines | ||
| 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) { · 502 unmodified lines | ||
| 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) { · 11 unmodified lines | ||
| 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) { · 23 unmodified lines | ||
| 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) { · 2 unmodified lines | ||
| 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) { · 943 unmodified lines | ||
| 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 | })()} |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -166,4 +166,43 @@ describe("store terminal activity action", () => { · 165 unmodified lines | ||
| 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 | }); |
| @@ -21,6 +21,49 @@ import { · 20 unmodified lines | ||
| 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 · 409 unmodified lines | ||
| 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, |