| 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,6 +61,11 @@ import { |
| 61 | type Thread, | 61 | type Thread, |
| 62 | type TurnDiffSummary, | 62 | type TurnDiffSummary, |
| 63 | } from "../types"; | 63 | } from "../types"; |
| 64 | import { | ||
| 65 | createDefaultDraftThreadTerminalState, | ||
| 66 | reduceDraftThreadTerminalState, | ||
| 67 | type DraftThreadTerminalState, | ||
| 68 | } from "../draftThreadTerminalState"; | ||
| 64 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; | 69 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; |
| 65 | import { useTheme } from "../hooks/useTheme"; | 70 | import { useTheme } from "../hooks/useTheme"; |
| 66 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | 71 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| ... | @@ -204,6 +209,7 @@ function buildExpandedImagePreview( | ... | @@ -204,6 +209,7 @@ function buildExpandedImagePreview( |
| 204 | function buildLocalDraftThread( | 209 | function buildLocalDraftThread( |
| 205 | threadId: ThreadId, | 210 | threadId: ThreadId, |
| 206 | draftThread: DraftThreadState, | 211 | draftThread: DraftThreadState, |
| 212 | draftTerminalState: DraftThreadTerminalState, | ||
| 207 | fallbackModel: string, | 213 | fallbackModel: string, |
| 208 | error: string | null, | 214 | error: string | null, |
| 209 | ): Thread { | 215 | ): Thread { |
| ... | @@ -213,18 +219,13 @@ function buildLocalDraftThread( | ... | @@ -213,18 +219,13 @@ function buildLocalDraftThread( |
| 213 | projectId: draftThread.projectId, | 219 | projectId: draftThread.projectId, |
| 214 | title: "New thread", | 220 | title: "New thread", |
| 215 | model: fallbackModel, | 221 | model: fallbackModel, |
| 216 | terminalOpen: false, | 222 | terminalOpen: draftTerminalState.terminalOpen, |
| 217 | terminalHeight: 280, | 223 | terminalHeight: draftTerminalState.terminalHeight, |
| 218 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | 224 | terminalIds: draftTerminalState.terminalIds, |
| 219 | runningTerminalIds: [], | 225 | runningTerminalIds: draftTerminalState.runningTerminalIds, |
| 220 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | 226 | activeTerminalId: draftTerminalState.activeTerminalId, |
| 221 | terminalGroups: [ | 227 | terminalGroups: draftTerminalState.terminalGroups, |
| 222 | { | 228 | activeTerminalGroupId: draftTerminalState.activeTerminalGroupId, |
| 223 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 224 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | ||
| 225 | }, | ||
| 226 | ], | ||
| 227 | activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 228 | session: null, | 229 | session: null, |
| 229 | messages: [], | 230 | messages: [], |
| 230 | error, | 231 | error, |
| ... | @@ -238,6 +239,46 @@ function buildLocalDraftThread( | ... | @@ -238,6 +239,46 @@ function buildLocalDraftThread( |
| 238 | }; | 239 | }; |
| 239 | } | 240 | } |
| 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 | function revokeBlobPreviewUrl(previewUrl: string | undefined): void { | 282 | function revokeBlobPreviewUrl(previewUrl: string | undefined): void { |
| 242 | if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { | 283 | if (!previewUrl || typeof URL === "undefined" || !previewUrl.startsWith("blob:")) { |
| 243 | return; | 284 | return; |
| ... | @@ -506,6 +547,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -506,6 +547,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 506 | const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< | 547 | const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< |
| 507 | Record<ThreadId, string | null> | 548 | Record<ThreadId, string | null> |
| 508 | >({}); | 549 | >({}); |
| 550 | const [draftTerminalStateByThreadId, setDraftTerminalStateByThreadId] = useState< | ||
| 551 | Record<ThreadId, DraftThreadTerminalState> | ||
| 552 | >({}); | ||
| 509 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); | 553 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); |
| 510 | const [isConnecting, _setIsConnecting] = useState(false); | 554 | const [isConnecting, _setIsConnecting] = useState(false); |
| 511 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); | 555 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| ... | @@ -541,6 +585,26 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -541,6 +585,26 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 541 | setMessagesScrollElement(element); | 585 | setMessagesScrollElement(element); |
| 542 | }, []); | 586 | }, []); |
| 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 | const setPrompt = useCallback( | 608 | const setPrompt = useCallback( |
| 545 | (nextPrompt: string) => { | 609 | (nextPrompt: string) => { |
| 546 | setComposerDraftPrompt(threadId, nextPrompt); | 610 | setComposerDraftPrompt(threadId, nextPrompt); |
| ... | @@ -573,17 +637,19 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -573,17 +637,19 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 573 | ); | 637 | ); |
| 574 | 638 | ||
| 575 | const localDraftError = localDraftErrorsByThreadId[threadId] ?? null; | 639 | const localDraftError = localDraftErrorsByThreadId[threadId] ?? null; |
| 640 | const localDraftTerminalState = draftTerminalStateByThreadId[threadId]; | ||
| 576 | const localDraftThread = useMemo( | 641 | const localDraftThread = useMemo( |
| 577 | () => | 642 | () => |
| 578 | draftThread | 643 | draftThread |
| 579 | ? buildLocalDraftThread( | 644 | ? buildLocalDraftThread( |
| 580 | threadId, | 645 | threadId, |
| 581 | draftThread, | 646 | draftThread, |
| 647 | localDraftTerminalState ?? createDefaultDraftThreadTerminalState(), | ||
| 582 | fallbackDraftProject?.model ?? DEFAULT_MODEL, | 648 | fallbackDraftProject?.model ?? DEFAULT_MODEL, |
| 583 | localDraftError, | 649 | localDraftError, |
| 584 | ) | 650 | ) |
| 585 | : undefined, | 651 | : undefined, |
| 586 | [draftThread, fallbackDraftProject?.model, localDraftError, threadId], | 652 | [draftThread, fallbackDraftProject?.model, localDraftError, localDraftTerminalState, threadId], |
| 587 | ); | 653 | ); |
| 588 | const activeThread = serverThread ?? localDraftThread; | 654 | const activeThread = serverThread ?? localDraftThread; |
| 589 | const isServerThread = serverThread !== undefined; | 655 | const isServerThread = serverThread !== undefined; |
| ... | @@ -614,6 +680,27 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -614,6 +680,27 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 614 | }); | 680 | }); |
| 615 | }, [serverThread, threadId]); | 681 | }, [serverThread, threadId]); |
| 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 | useEffect(() => { | 704 | useEffect(() => { |
| 618 | if (!activeThread?.id) return; | 705 | if (!activeThread?.id) return; |
| 619 | if (!latestTurnSettled) return; | 706 | if (!latestTurnSettled) return; |
| ... | @@ -981,6 +1068,29 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -981,6 +1068,29 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 981 | ); | 1068 | ); |
| 982 | const hasReachedTerminalLimit = | 1069 | const hasReachedTerminalLimit = |
| 983 | (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT; | 1070 | (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT; |
| 1071 | const setThreadError = useCallback( | ||
| 1072 | (targetThreadId: ThreadId | null, error: string | null) => { | ||
| 1073 | if (!targetThreadId) return; | ||
| 1074 | if (state.threads.some((thread) => thread.id === targetThreadId)) { | ||
| 1075 | dispatch({ | ||
| 1076 | type: "SET_ERROR", | ||
| 1077 | threadId: targetThreadId, | ||
| 1078 | error, | ||
| 1079 | }); | ||
| 1080 | return; | ||
| 1081 | } | ||
| 1082 | setLocalDraftErrorsByThreadId((existing) => { | ||
| 1083 | if ((existing[targetThreadId] ?? null) === error) { | ||
| 1084 | return existing; | ||
| 1085 | } | ||
| 1086 | return { | ||
| 1087 | ...existing, | ||
| 1088 | [targetThreadId]: error, | ||
| 1089 | }; | ||
| 1090 | }); | ||
| 1091 | }, | ||
| 1092 | [dispatch, state.threads], | ||
| 1093 | ); | ||
| 984 | 1094 | ||
| 985 | const focusComposer = useCallback(() => { | 1095 | const focusComposer = useCallback(() => { |
| 986 | const textarea = textareaRef.current; | 1096 | const textarea = textareaRef.current; |
| ... | @@ -994,27 +1104,108 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -994,27 +1104,108 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 994 | focusComposer(); | 1104 | focusComposer(); |
| 995 | }); | 1105 | }); |
| 996 | }, [focusComposer]); | 1106 | }, [focusComposer]); |
| 1107 | const setTerminalOpen = useCallback( | ||
| 1108 | (open: boolean) => { | ||
| 1109 | if (!activeThreadId) return; | ||
| 1110 | if (isLocalDraftThread) { | ||
| 1111 | updateDraftTerminalState(activeThreadId, (draftState) => | ||
| 1112 | reduceDraftThreadTerminalState(draftState, { | ||
| 1113 | type: "set-open", | ||
| 1114 | open, | ||
| 1115 | }), | ||
| 1116 | ); | ||
| 1117 | return; | ||
| 1118 | } | ||
| 1119 | dispatch({ | ||
| 1120 | type: "SET_THREAD_TERMINAL_OPEN", | ||
| 1121 | threadId: activeThreadId, | ||
| 1122 | open, | ||
| 1123 | }); | ||
| 1124 | }, | ||
| 1125 | [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], | ||
| 1126 | ); | ||
| 1127 | const setTerminalHeight = useCallback( | ||
| 1128 | (height: number) => { | ||
| 1129 | if (!activeThreadId) return; | ||
| 1130 | if (isLocalDraftThread) { | ||
| 1131 | updateDraftTerminalState(activeThreadId, (draftState) => | ||
| 1132 | reduceDraftThreadTerminalState(draftState, { | ||
| 1133 | type: "set-height", | ||
| 1134 | height, | ||
| 1135 | }), | ||
| 1136 | ); | ||
| 1137 | return; | ||
| 1138 | } | ||
| 1139 | dispatch({ | ||
| 1140 | type: "SET_THREAD_TERMINAL_HEIGHT", | ||
| 1141 | threadId: activeThreadId, | ||
| 1142 | height, | ||
| 1143 | }); | ||
| 1144 | }, | ||
| 1145 | [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], | ||
| 1146 | ); | ||
| 997 | const toggleTerminalVisibility = useCallback(() => { | 1147 | const toggleTerminalVisibility = useCallback(() => { |
| 998 | if (!activeThreadId) return; | 1148 | if (!activeThreadId) return; |
| 999 | setThreadTerminalOpen(activeThreadId, !activeThread?.terminalOpen); | 1149 | setTerminalOpen(!activeThread?.terminalOpen); |
| 1000 | }, [activeThread?.terminalOpen, activeThreadId, setThreadTerminalOpen]); | 1150 | }, [activeThread?.terminalOpen, activeThreadId, setTerminalOpen]); |
| 1001 | const splitTerminal = useCallback(() => { | 1151 | const splitTerminal = useCallback(() => { |
| 1002 | if (!activeThreadId || hasReachedTerminalLimit) return; | 1152 | if (!activeThreadId || hasReachedTerminalLimit) return; |
| 1003 | splitThreadTerminalAction(activeThreadId, `terminal-${crypto.randomUUID()}`); | 1153 | const terminalId = `terminal-${crypto.randomUUID()}`; |
| 1154 | if (isLocalDraftThread) { | ||
| 1155 | updateDraftTerminalState(activeThreadId, (draftState) => | ||
| 1156 | reduceDraftThreadTerminalState(draftState, { | ||
| 1157 | type: "split", | ||
| 1158 | terminalId, | ||
| 1159 | }), | ||
| 1160 | ); | ||
| 1161 | } else { | ||
| 1162 | dispatch({ | ||
| 1163 | type: "SPLIT_THREAD_TERMINAL", | ||
| 1164 | threadId: activeThreadId, | ||
| 1165 | terminalId, | ||
| 1166 | }); | ||
| 1167 | } | ||
| 1004 | setTerminalFocusRequestId((value) => value + 1); | 1168 | setTerminalFocusRequestId((value) => value + 1); |
| 1005 | }, [activeThreadId, hasReachedTerminalLimit, splitThreadTerminalAction]); | 1169 | }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, updateDraftTerminalState]); |
| 1006 | const createNewTerminal = useCallback(() => { | 1170 | const createNewTerminal = useCallback(() => { |
| 1007 | if (!activeThreadId || hasReachedTerminalLimit) return; | 1171 | if (!activeThreadId || hasReachedTerminalLimit) return; |
| 1008 | newThreadTerminalAction(activeThreadId, `terminal-${crypto.randomUUID()}`); | 1172 | const terminalId = `terminal-${crypto.randomUUID()}`; |
| 1173 | if (isLocalDraftThread) { | ||
| 1174 | updateDraftTerminalState(activeThreadId, (draftState) => | ||
| 1175 | reduceDraftThreadTerminalState(draftState, { | ||
| 1176 | type: "new", | ||
| 1177 | terminalId, | ||
| 1178 | }), | ||
| 1179 | ); | ||
| 1180 | } else { | ||
| 1181 | dispatch({ | ||
| 1182 | type: "NEW_THREAD_TERMINAL", | ||
| 1183 | threadId: activeThreadId, | ||
| 1184 | terminalId, | ||
| 1185 | }); | ||
| 1186 | } | ||
| 1009 | setTerminalFocusRequestId((value) => value + 1); | 1187 | setTerminalFocusRequestId((value) => value + 1); |
| 1010 | }, [activeThreadId, hasReachedTerminalLimit, newThreadTerminalAction]); | 1188 | }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, updateDraftTerminalState]); |
| 1011 | const activateTerminal = useCallback( | 1189 | const activateTerminal = useCallback( |
| 1012 | (terminalId: string) => { | 1190 | (terminalId: string) => { |
| 1013 | if (!activeThreadId) return; | 1191 | if (!activeThreadId) return; |
| 1014 | setThreadActiveTerminal(activeThreadId, terminalId); | 1192 | if (isLocalDraftThread) { |
| 1193 | updateDraftTerminalState(activeThreadId, (draftState) => | ||
| 1194 | reduceDraftThreadTerminalState(draftState, { | ||
| 1195 | type: "set-active", | ||
| 1196 | terminalId, | ||
| 1197 | }), | ||
| 1198 | ); | ||
| 1199 | } else { | ||
| 1200 | dispatch({ | ||
| 1201 | type: "SET_THREAD_ACTIVE_TERMINAL", | ||
| 1202 | threadId: activeThreadId, | ||
| 1203 | terminalId, | ||
| 1204 | }); | ||
| 1205 | } | ||
| 1015 | setTerminalFocusRequestId((value) => value + 1); | 1206 | setTerminalFocusRequestId((value) => value + 1); |
| 1016 | }, | 1207 | }, |
| 1017 | [activeThreadId, setThreadActiveTerminal], | 1208 | [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], |
| 1018 | ); | 1209 | ); |
| 1019 | const closeTerminal = useCallback( | 1210 | const closeTerminal = useCallback( |
| 1020 | (terminalId: string) => { | 1211 | (terminalId: string) => { |
| ... | @@ -1037,10 +1228,23 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1037,10 +1228,23 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1037 | } else { | 1228 | } else { |
| 1038 | void fallbackExitWrite(); | 1229 | void fallbackExitWrite(); |
| 1039 | } | 1230 | } |
| 1040 | closeThreadTerminalAction(activeThreadId, terminalId); | 1231 | if (isLocalDraftThread) { |
| 1232 | updateDraftTerminalState(activeThreadId, (draftState) => | ||
| 1233 | reduceDraftThreadTerminalState(draftState, { | ||
| 1234 | type: "close", | ||
| 1235 | terminalId, | ||
| 1236 | }), | ||
| 1237 | ); | ||
| 1238 | } else { | ||
| 1239 | dispatch({ | ||
| 1240 | type: "CLOSE_THREAD_TERMINAL", | ||
| 1241 | threadId: activeThreadId, | ||
| 1242 | terminalId, | ||
| 1243 | }); | ||
| 1244 | } | ||
| 1041 | setTerminalFocusRequestId((value) => value + 1); | 1245 | setTerminalFocusRequestId((value) => value + 1); |
| 1042 | }, | 1246 | }, |
| 1043 | [activeThread?.terminalIds.length, activeThreadId, closeThreadTerminalAction], | 1247 | [activeThread?.terminalIds.length, activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], |
| 1044 | ); | 1248 | ); |
| 1045 | const runProjectScript = useCallback( | 1249 | const runProjectScript = useCallback( |
| 1046 | async ( | 1250 | async ( |
| ... | @@ -1074,11 +1278,37 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1074,11 +1278,37 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1074 | ? `terminal-${crypto.randomUUID()}` | 1278 | ? `terminal-${crypto.randomUUID()}` |
| 1075 | : baseTerminalId; | 1279 | : baseTerminalId; |
| 1076 | 1280 | ||
| 1077 | setThreadTerminalOpen(activeThreadId, true); | 1281 | setTerminalOpen(true); |
| 1078 | if (shouldCreateNewTerminal) { | 1282 | if (shouldCreateNewTerminal) { |
| 1079 | newThreadTerminalAction(activeThreadId, targetTerminalId); | 1283 | if (isLocalDraftThread) { |
| 1284 | updateDraftTerminalState(activeThreadId, (draftState) => | ||
| 1285 | reduceDraftThreadTerminalState(draftState, { | ||
| 1286 | type: "new", | ||
| 1287 | terminalId: targetTerminalId, | ||
| 1288 | }), | ||
| 1289 | ); | ||
| 1290 | } else { | ||
| 1291 | dispatch({ | ||
| 1292 | type: "NEW_THREAD_TERMINAL", | ||
| 1293 | threadId: activeThreadId, | ||
| 1294 | terminalId: targetTerminalId, | ||
| 1295 | }); | ||
| 1296 | } | ||
| 1080 | } else { | 1297 | } else { |
| 1081 | setThreadActiveTerminal(activeThreadId, targetTerminalId); | 1298 | if (isLocalDraftThread) { |
| 1299 | updateDraftTerminalState(activeThreadId, (draftState) => | ||
| 1300 | reduceDraftThreadTerminalState(draftState, { | ||
| 1301 | type: "set-active", | ||
| 1302 | terminalId: targetTerminalId, | ||
| 1303 | }), | ||
| 1304 | ); | ||
| 1305 | } else { | ||
| 1306 | dispatch({ | ||
| 1307 | type: "SET_THREAD_ACTIVE_TERMINAL", | ||
| 1308 | threadId: activeThreadId, | ||
| 1309 | terminalId: targetTerminalId, | ||
| 1310 | }); | ||
| 1311 | } | ||
| 1082 | } | 1312 | } |
| 1083 | setTerminalFocusRequestId((value) => value + 1); | 1313 | setTerminalFocusRequestId((value) => value + 1); |
| 1084 | 1314 | ||
| ... | @@ -1109,7 +1339,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1109,7 +1339,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1109 | data: `${script.command}\r`, | 1339 | data: `${script.command}\r`, |
| 1110 | }); | 1340 | }); |
| 1111 | } catch (error) { | 1341 | } catch (error) { |
| 1112 | setThreadErrorAction( | 1342 | setThreadError( |
| 1113 | activeThreadId, | 1343 | activeThreadId, |
| 1114 | error instanceof Error ? error.message : `Failed to run script "${script.name}".`, | 1344 | error instanceof Error ? error.message : `Failed to run script "${script.name}".`, |
| 1115 | ); | 1345 | ); |
| ... | @@ -1119,12 +1349,13 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1119,12 +1349,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1119 | activeProject, | 1349 | activeProject, |
| 1120 | activeThread, | 1350 | activeThread, |
| 1121 | activeThreadId, | 1351 | activeThreadId, |
| 1352 | dispatch, | ||
| 1122 | gitCwd, | 1353 | gitCwd, |
| 1354 | isLocalDraftThread, | ||
| 1123 | isServerThread, | 1355 | isServerThread, |
| 1124 | newThreadTerminalAction, | 1356 | setTerminalOpen, |
| 1125 | setThreadActiveTerminal, | 1357 | setThreadError, |
| 1126 | setThreadErrorAction, | 1358 | updateDraftTerminalState, |
| 1127 | setThreadTerminalOpen, | ||
| 1128 | ], | 1359 | ], |
| 1129 | ); | 1360 | ); |
| 1130 | const persistProjectScripts = useCallback( | 1361 | const persistProjectScripts = useCallback( |
| ... | @@ -1633,7 +1864,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1633,7 +1864,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1633 | event.preventDefault(); | 1864 | event.preventDefault(); |
| 1634 | event.stopPropagation(); | 1865 | event.stopPropagation(); |
| 1635 | if (!activeThread?.terminalOpen) { | 1866 | if (!activeThread?.terminalOpen) { |
| 1636 | setThreadTerminalOpen(activeThreadId, true); | 1867 | setTerminalOpen(true); |
| 1637 | } | 1868 | } |
| 1638 | splitTerminal(); | 1869 | splitTerminal(); |
| 1639 | return; | 1870 | return; |
| ... | @@ -1651,7 +1882,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1651,7 +1882,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1651 | event.preventDefault(); | 1882 | event.preventDefault(); |
| 1652 | event.stopPropagation(); | 1883 | event.stopPropagation(); |
| 1653 | if (!activeThread?.terminalOpen) { | 1884 | if (!activeThread?.terminalOpen) { |
| 1654 | setThreadTerminalOpen(activeThreadId, true); | 1885 | setTerminalOpen(true); |
| 1655 | } | 1886 | } |
| 1656 | createNewTerminal(); | 1887 | createNewTerminal(); |
| 1657 | return; | 1888 | return; |
| ... | @@ -1681,6 +1912,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1681,6 +1912,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1681 | activeThreadId, | 1912 | activeThreadId, |
| 1682 | closeTerminal, | 1913 | closeTerminal, |
| 1683 | createNewTerminal, | 1914 | createNewTerminal, |
| 1915 | setTerminalOpen, | ||
| 1684 | runProjectScript, | 1916 | runProjectScript, |
| 1685 | splitTerminal, | 1917 | splitTerminal, |
| 1686 | keybindings, | 1918 | keybindings, |
| ... | @@ -1689,26 +1921,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1689,26 +1921,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1689 | toggleTerminalVisibility, | 1921 | toggleTerminalVisibility, |
| 1690 | ]); | 1922 | ]); |
| 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 | const addComposerImages = (files: File[]) => { | 1924 | const addComposerImages = (files: File[]) => { |
| 1713 | if (!activeThreadId || files.length === 0) return; | 1925 | if (!activeThreadId || files.length === 0) return; |
| 1714 | 1926 | ||
| ... | @@ -2658,7 +2870,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2658,7 +2870,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2658 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} | 2870 | closeShortcutLabel={closeTerminalShortcutLabel ?? undefined} |
| 2659 | onActiveTerminalChange={activateTerminal} | 2871 | onActiveTerminalChange={activateTerminal} |
| 2660 | onCloseTerminal={closeTerminal} | 2872 | onCloseTerminal={closeTerminal} |
| 2661 | onHeightChange={(height) => setThreadTerminalHeight(activeThread.id, height)} | 2873 | onHeightChange={setTerminalHeight} |
| 2662 | /> | 2874 | /> |
| 2663 | ); | 2875 | ); |
| 2664 | })()} | 2876 | })()} |
apps/web/src/draftThreadTerminalState.test.ts created+87| ... | @@ -0,0 +1,87 @@ | ||
| 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,4 +166,43 @@ describe("store terminal activity action", () => { |
| 166 | 166 | ||
| 167 | expect(useStore.getState().threads[0]?.runningTerminalIds).toEqual([]); | 167 | expect(useStore.getState().threads[0]?.runningTerminalIds).toEqual([]); |
| 168 | }); | 168 | }); |
| 169 | |||
| 170 | it("hydrates terminal state for an existing thread", () => { | ||
| 171 | const state = makeState(makeThread()); | ||
| 172 | const next = reducer(state, { | ||
| 173 | type: "HYDRATE_THREAD_TERMINALS", | ||
| 174 | threadId: ThreadId.makeUnsafe("thread-1"), | ||
| 175 | terminalState: { | ||
| 176 | terminalOpen: true, | ||
| 177 | terminalHeight: 360, | ||
| 178 | terminalIds: ["default", "terminal-2"], | ||
| 179 | runningTerminalIds: ["terminal-2"], | ||
| 180 | activeTerminalId: "terminal-2", | ||
| 181 | terminalGroups: [ | ||
| 182 | { | ||
| 183 | id: "group-default", | ||
| 184 | terminalIds: ["default"], | ||
| 185 | }, | ||
| 186 | { | ||
| 187 | id: "group-terminal-2", | ||
| 188 | terminalIds: ["terminal-2"], | ||
| 189 | }, | ||
| 190 | ], | ||
| 191 | activeTerminalGroupId: "group-terminal-2", | ||
| 192 | }, | ||
| 193 | }); | ||
| 194 | |||
| 195 | expect(next.threads[0]).toMatchObject({ | ||
| 196 | terminalOpen: true, | ||
| 197 | terminalHeight: 360, | ||
| 198 | terminalIds: ["default", "terminal-2"], | ||
| 199 | runningTerminalIds: ["terminal-2"], | ||
| 200 | activeTerminalId: "terminal-2", | ||
| 201 | terminalGroups: [ | ||
| 202 | { id: "group-default", terminalIds: ["default"] }, | ||
| 203 | { id: "group-terminal-2", terminalIds: ["terminal-2"] }, | ||
| 204 | ], | ||
| 205 | activeTerminalGroupId: "group-terminal-2", | ||
| 206 | }); | ||
| 207 | }); | ||
| 169 | }); | 208 | }); |
apps/web/src/store.ts+266-40| ... | @@ -21,6 +21,49 @@ import { | ... | @@ -21,6 +21,49 @@ import { |
| 21 | type ThreadTerminalGroup, | 21 | type ThreadTerminalGroup, |
| 22 | } from "./types"; | 22 | } from "./types"; |
| 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 | // ── State ──────────────────────────────────────────────────────────── | 67 | // ── State ──────────────────────────────────────────────────────────── |
| 25 | 68 | ||
| 26 | export interface AppState { | 69 | export interface AppState { |
| ... | @@ -436,46 +479,229 @@ function syncServerReadModelState(state: AppState, readModel: OrchestrationReadM | ... | @@ -436,46 +479,229 @@ function syncServerReadModelState(state: AppState, readModel: OrchestrationReadM |
| 436 | .map((thread) => { | 479 | .map((thread) => { |
| 437 | const existing = existingThreadById.get(thread.id); | 480 | const existing = existingThreadById.get(thread.id); |
| 438 | 481 | ||
| 439 | return normalizeThreadTerminals({ | 482 | export function reducer(state: AppState, action: Action): AppState { |
| 440 | id: thread.id, | 483 | switch (action.type) { |
| 441 | codexThreadId: thread.session?.providerThreadId ?? null, | 484 | case "SYNC_SERVER_READ_MODEL": { |
| 442 | projectId: thread.projectId, | 485 | const projects = mapProjectsFromReadModel( |
| 443 | title: thread.title, | 486 | action.readModel.projects.filter((project) => project.deletedAt === null), |
| 444 | model: resolveModelSlug(thread.model), | 487 | state.projects, |
| 445 | terminalOpen: existing?.terminalOpen ?? false, | 488 | ); |
| 446 | terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, | 489 | const existingThreadById = new Map( |
| 447 | terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID], | 490 | state.threads.map((thread) => [thread.id, thread] as const), |
| 448 | runningTerminalIds: existing?.runningTerminalIds ?? [], | 491 | ); |
| 449 | activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID, | 492 | const threads = action.readModel.threads |
| 450 | terminalGroups: existing?.terminalGroups ?? [ | 493 | .filter((thread) => thread.deletedAt === null) |
| 451 | { | 494 | .map((thread) => { |
| 452 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | 495 | const existing = existingThreadById.get(thread.id); |
| 453 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | 496 | |
| 454 | }, | 497 | return normalizeThreadTerminals({ |
| 455 | ], | 498 | id: thread.id, |
| 456 | activeTerminalGroupId: existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`, | 499 | codexThreadId: thread.session?.providerThreadId ?? null, |
| 457 | session: thread.session | 500 | projectId: thread.projectId, |
| 458 | ? { | 501 | title: thread.title, |
| 459 | sessionId: | 502 | model: resolveModelSlug(thread.model), |
| 460 | thread.session.providerSessionId ?? ProviderSessionId.makeUnsafe(`thread:${thread.id}`), | 503 | terminalOpen: existing?.terminalOpen ?? false, |
| 461 | provider: toLegacyProvider(thread.session.providerName), | 504 | terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 462 | status: toLegacySessionStatus(thread.session.status), | 505 | terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID], |
| 463 | orchestrationStatus: thread.session.status, | 506 | runningTerminalIds: existing?.runningTerminalIds ?? [], |
| 464 | threadId: thread.session.providerThreadId, | 507 | activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID, |
| 465 | activeTurnId: thread.session.activeTurnId ?? undefined, | 508 | terminalGroups: existing?.terminalGroups ?? [ |
| 466 | createdAt: thread.session.updatedAt, | 509 | { |
| 467 | updatedAt: thread.session.updatedAt, | 510 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, |
| 468 | ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), | 511 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], |
| 469 | } | 512 | }, |
| 470 | : null, | 513 | ], |
| 471 | messages: thread.messages.map((message) => { | 514 | activeTerminalGroupId: |
| 472 | const attachments = message.attachments?.map((attachment) => ({ | 515 | existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`, |
| 473 | type: "image" as const, | 516 | session: thread.session |
| 474 | id: attachment.id, | 517 | ? { |
| 475 | name: attachment.name, | 518 | sessionId: |
| 476 | mimeType: attachment.mimeType, | 519 | thread.session.providerSessionId ?? |
| 477 | sizeBytes: attachment.sizeBytes, | 520 | ProviderSessionId.makeUnsafe(`thread:${thread.id}`), |
| 478 | previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)), | 521 | provider: toLegacyProvider(thread.session.providerName), |
| 522 | status: toLegacySessionStatus(thread.session.status), | ||
| 523 | orchestrationStatus: thread.session.status, | ||
| 524 | threadId: thread.session.providerThreadId, | ||
| 525 | activeTurnId: thread.session.activeTurnId ?? undefined, | ||
| 526 | createdAt: thread.session.updatedAt, | ||
| 527 | updatedAt: thread.session.updatedAt, | ||
| 528 | ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), | ||
| 529 | } | ||
| 530 | : null, | ||
| 531 | messages: thread.messages.map((message) => { | ||
| 532 | const attachments = message.attachments?.map((attachment) => ({ | ||
| 533 | type: "image" as const, | ||
| 534 | id: attachment.id, | ||
| 535 | name: attachment.name, | ||
| 536 | mimeType: attachment.mimeType, | ||
| 537 | sizeBytes: attachment.sizeBytes, | ||
| 538 | previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)), | ||
| 539 | })); | ||
| 540 | const normalizedMessage: ChatMessage = { | ||
| 541 | id: message.id, | ||
| 542 | role: message.role, | ||
| 543 | text: message.text, | ||
| 544 | createdAt: message.createdAt, | ||
| 545 | streaming: message.streaming, | ||
| 546 | ...(message.streaming ? {} : { completedAt: message.updatedAt }), | ||
| 547 | ...(attachments && attachments.length > 0 ? { attachments } : {}), | ||
| 548 | }; | ||
| 549 | return normalizedMessage; | ||
| 550 | }), | ||
| 551 | error: thread.session?.lastError ?? null, | ||
| 552 | createdAt: thread.createdAt, | ||
| 553 | latestTurn: thread.latestTurn, | ||
| 554 | lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, | ||
| 555 | branch: thread.branch, | ||
| 556 | worktreePath: thread.worktreePath, | ||
| 557 | turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({ | ||
| 558 | turnId: checkpoint.turnId, | ||
| 559 | completedAt: checkpoint.completedAt, | ||
| 560 | status: checkpoint.status, | ||
| 561 | assistantMessageId: checkpoint.assistantMessageId ?? undefined, | ||
| 562 | checkpointTurnCount: checkpoint.checkpointTurnCount, | ||
| 563 | checkpointRef: checkpoint.checkpointRef, | ||
| 564 | files: checkpoint.files.map((file) => ({ ...file })), | ||
| 565 | })), | ||
| 566 | activities: thread.activities.map((activity) => ({ ...activity })), | ||
| 567 | }); | ||
| 568 | }); | ||
| 569 | return { | ||
| 570 | ...state, | ||
| 571 | projects, | ||
| 572 | threads, | ||
| 573 | threadsHydrated: true, | ||
| 574 | }; | ||
| 575 | } | ||
| 576 | |||
| 577 | case "HYDRATE_THREAD_TERMINALS": | ||
| 578 | return { | ||
| 579 | ...state, | ||
| 580 | threads: updateThread(state.threads, action.threadId, (thread) => | ||
| 581 | normalizeThreadTerminals({ | ||
| 582 | ...thread, | ||
| 583 | terminalOpen: action.terminalState.terminalOpen, | ||
| 584 | terminalHeight: action.terminalState.terminalHeight, | ||
| 585 | terminalIds: action.terminalState.terminalIds, | ||
| 586 | runningTerminalIds: action.terminalState.runningTerminalIds, | ||
| 587 | activeTerminalId: action.terminalState.activeTerminalId, | ||
| 588 | terminalGroups: action.terminalState.terminalGroups, | ||
| 589 | activeTerminalGroupId: action.terminalState.activeTerminalGroupId, | ||
| 590 | }), | ||
| 591 | ), | ||
| 592 | }; | ||
| 593 | |||
| 594 | case "MARK_THREAD_VISITED": { | ||
| 595 | const visitedAt = action.visitedAt ?? new Date().toISOString(); | ||
| 596 | const visitedAtMs = Date.parse(visitedAt); | ||
| 597 | return { | ||
| 598 | ...state, | ||
| 599 | threads: updateThread(state.threads, action.threadId, (thread) => { | ||
| 600 | const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN; | ||
| 601 | if ( | ||
| 602 | Number.isFinite(previousVisitedAtMs) && | ||
| 603 | Number.isFinite(visitedAtMs) && | ||
| 604 | previousVisitedAtMs >= visitedAtMs | ||
| 605 | ) { | ||
| 606 | return thread; | ||
| 607 | } | ||
| 608 | return { | ||
| 609 | ...thread, | ||
| 610 | lastVisitedAt: visitedAt, | ||
| 611 | }; | ||
| 612 | }), | ||
| 613 | }; | ||
| 614 | } | ||
| 615 | |||
| 616 | case "MARK_THREAD_UNREAD": { | ||
| 617 | return { | ||
| 618 | ...state, | ||
| 619 | threads: updateThread(state.threads, action.threadId, (thread) => { | ||
| 620 | if (!thread.latestTurn?.completedAt) { | ||
| 621 | return thread; | ||
| 622 | } | ||
| 623 | const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt); | ||
| 624 | if (Number.isNaN(latestTurnCompletedAtMs)) { | ||
| 625 | return thread; | ||
| 626 | } | ||
| 627 | const unreadVisitedAt = new Date(latestTurnCompletedAtMs - 1).toISOString(); | ||
| 628 | if (thread.lastVisitedAt === unreadVisitedAt) { | ||
| 629 | return thread; | ||
| 630 | } | ||
| 631 | return { | ||
| 632 | ...thread, | ||
| 633 | lastVisitedAt: unreadVisitedAt, | ||
| 634 | }; | ||
| 635 | }), | ||
| 636 | }; | ||
| 637 | } | ||
| 638 | |||
| 639 | case "TOGGLE_PROJECT": | ||
| 640 | return { | ||
| 641 | ...state, | ||
| 642 | projects: state.projects.map((p) => | ||
| 643 | p.id === action.projectId ? { ...p, expanded: !p.expanded } : p, | ||
| 644 | ), | ||
| 645 | }; | ||
| 646 | |||
| 647 | case "SET_THREAD_TERMINAL_ACTIVITY": | ||
| 648 | return { | ||
| 649 | ...state, | ||
| 650 | threads: updateThread(state.threads, action.threadId, (thread) => | ||
| 651 | setThreadTerminalActivity(thread, action.terminalId, action.hasRunningSubprocess), | ||
| 652 | ), | ||
| 653 | }; | ||
| 654 | |||
| 655 | case "SET_PROJECT_EXPANDED": | ||
| 656 | return { | ||
| 657 | ...state, | ||
| 658 | projects: state.projects.map((p) => | ||
| 659 | p.id === action.projectId ? { ...p, expanded: action.expanded } : p, | ||
| 660 | ), | ||
| 661 | }; | ||
| 662 | |||
| 663 | case "TOGGLE_THREAD_TERMINAL": | ||
| 664 | return { | ||
| 665 | ...state, | ||
| 666 | threads: updateThread(state.threads, action.threadId, (t) => ({ | ||
| 667 | ...t, | ||
| 668 | terminalOpen: !t.terminalOpen, | ||
| 669 | })), | ||
| 670 | }; | ||
| 671 | |||
| 672 | case "SET_THREAD_TERMINAL_OPEN": | ||
| 673 | return { | ||
| 674 | ...state, | ||
| 675 | threads: updateThread(state.threads, action.threadId, (t) => ({ | ||
| 676 | ...t, | ||
| 677 | terminalOpen: action.open, | ||
| 678 | })), | ||
| 679 | }; | ||
| 680 | |||
| 681 | case "SET_THREAD_TERMINAL_HEIGHT": | ||
| 682 | return { | ||
| 683 | ...state, | ||
| 684 | threads: updateThread(state.threads, action.threadId, (t) => ({ | ||
| 685 | ...t, | ||
| 686 | terminalHeight: action.height, | ||
| 687 | })), | ||
| 688 | }; | ||
| 689 | |||
| 690 | case "SPLIT_THREAD_TERMINAL": | ||
| 691 | return { | ||
| 692 | ...state, | ||
| 693 | threads: updateThread(state.threads, action.threadId, (thread) => { | ||
| 694 | const normalizedThread = normalizeThreadTerminals(thread); | ||
| 695 | const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId); | ||
| 696 | if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { | ||
| 697 | return normalizedThread; | ||
| 698 | } | ||
| 699 | const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) | ||
| 700 | ? normalizedThread.terminalIds | ||
| 701 | : [...normalizedThread.terminalIds, action.terminalId]; | ||
| 702 | const terminalGroups = normalizedThread.terminalGroups.map((group) => ({ | ||
| 703 | ...group, | ||
| 704 | terminalIds: [...group.terminalIds], | ||
| 479 | })); | 705 | })); |
| 480 | const normalizedMessage: ChatMessage = { | 706 | const normalizedMessage: ChatMessage = { |
| 481 | id: message.id, | 707 | id: message.id, |