| author | |
| committer | |
| log | ad4d12cf94e8b1cbd679372ed2bcf453b531b90f |
| tree | a17ab606a745607cc27b6693c04a62b356493ce0 |
| parent | f6fd760e4eb9e18dd7b89cf45f478835d20145f8 |
- Add shared threadTerminalState.ts: ThreadTerminalState type, default state,
normalize helpers, and single reduceThreadTerminalState() used by both flows
- Refactor store.ts to use shared terminal reducer and helpers; terminal
actions map to reduceThreadTerminalState(threadTerminalSlice(thread), action)
- Make draftThreadTerminalState.ts re-export shared module (same shape/actions)
- Add terminalState to composer draft store: getDraftThreadTerminalState,
setDraftThreadTerminalAction, clearDraftThreadTerminalState; draft thread
shape matches Thread terminal slice
- ChatView: move draft terminal from useState into composer draft store; use
same actions for draft and persisted; hydrate then clear draft terminal
when reconciling server state
Made-with: Cursor5 files changed, 623 insertions(+), 695 deletions(-)
apps/web/src/components/ChatView.tsx+41-97| ... | ... | @@ -61,11 +61,7 @@ import { |
| 61 | 61 | type Thread, |
| 62 | 62 | type TurnDiffSummary, |
| 63 | 63 | } from "../types"; |
| 64 | import { | |
| 65 | createDefaultDraftThreadTerminalState, | |
| 66 | reduceDraftThreadTerminalState, | |
| 67 | type DraftThreadTerminalState, | |
| 68 | } from "../draftThreadTerminalState"; | |
| 64 | import type { ThreadTerminalState } from "../threadTerminalState"; | |
| 69 | 65 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; |
| 70 | 66 | import { useTheme } from "../hooks/useTheme"; |
| 71 | 67 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| ... | ... | @@ -209,7 +205,7 @@ function buildExpandedImagePreview( |
| 209 | 205 | function buildLocalDraftThread( |
| 210 | 206 | threadId: ThreadId, |
| 211 | 207 | draftThread: DraftThreadState, |
| 212 | draftTerminalState: DraftThreadTerminalState, | |
| 208 | draftTerminalState: ThreadTerminalState, | |
| 213 | 209 | fallbackModel: string, |
| 214 | 210 | error: string | null, |
| 215 | 211 | ): Thread { |
| ... | ... | @@ -239,7 +235,7 @@ function buildLocalDraftThread( |
| 239 | 235 | }; |
| 240 | 236 | } |
| 241 | 237 | |
| 242 | function threadTerminalsMatchState(thread: Thread, terminalState: DraftThreadTerminalState): boolean { | |
| 238 | function threadTerminalsMatchState(thread: Thread, terminalState: ThreadTerminalState): boolean { | |
| 243 | 239 | if ( |
| 244 | 240 | thread.terminalOpen !== terminalState.terminalOpen || |
| 245 | 241 | thread.terminalHeight !== terminalState.terminalHeight || |
| ... | ... | @@ -547,9 +543,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 547 | 543 | const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< |
| 548 | 544 | Record<ThreadId, string | null> |
| 549 | 545 | >({}); |
| 550 | const [draftTerminalStateByThreadId, setDraftTerminalStateByThreadId] = useState< | |
| 551 | Record<ThreadId, DraftThreadTerminalState> | |
| 552 | >({}); | |
| 553 | 546 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); |
| 554 | 547 | const [isConnecting, _setIsConnecting] = useState(false); |
| 555 | 548 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| ... | ... | @@ -585,24 +578,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 585 | 578 | setMessagesScrollElement(element); |
| 586 | 579 | }, []); |
| 587 | 580 | |
| 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 | [], | |
| 581 | const setDraftThreadTerminalAction = useComposerDraftStore((s) => s.setDraftThreadTerminalAction); | |
| 582 | const clearDraftThreadTerminalState = useComposerDraftStore( | |
| 583 | (s) => s.clearDraftThreadTerminalState, | |
| 584 | ); | |
| 585 | const localDraftTerminalState = useComposerDraftStore((s) => | |
| 586 | s.getDraftThreadTerminalState(threadId), | |
| 606 | 587 | ); |
| 607 | 588 | |
| 608 | 589 | const setPrompt = useCallback( |
| ... | ... | @@ -637,14 +618,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 637 | 618 | ); |
| 638 | 619 | |
| 639 | 620 | const localDraftError = localDraftErrorsByThreadId[threadId] ?? null; |
| 640 | const localDraftTerminalState = draftTerminalStateByThreadId[threadId]; | |
| 641 | 621 | const localDraftThread = useMemo( |
| 642 | 622 | () => |
| 643 | 623 | draftThread |
| 644 | 624 | ? buildLocalDraftThread( |
| 645 | 625 | threadId, |
| 646 | 626 | draftThread, |
| 647 | localDraftTerminalState ?? createDefaultDraftThreadTerminalState(), | |
| 627 | localDraftTerminalState, | |
| 648 | 628 | fallbackDraftProject?.model ?? DEFAULT_MODEL, |
| 649 | 629 | localDraftError, |
| 650 | 630 | ) |
| ... | ... | @@ -681,25 +661,23 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 681 | 661 | }, [serverThread, threadId]); |
| 682 | 662 | |
| 683 | 663 | useEffect(() => { |
| 684 | if (!serverThread || !localDraftTerminalState) { | |
| 664 | if (!serverThread) { | |
| 685 | 665 | return; |
| 686 | 666 | } |
| 687 | if (!threadTerminalsMatchState(serverThread, localDraftTerminalState)) { | |
| 667 | const draft = useComposerDraftStore.getState().draftThreadsByThreadId[threadId]; | |
| 668 | const draftTerminal = draft?.terminalState; | |
| 669 | if (draftTerminal === undefined) { | |
| 670 | return; | |
| 671 | } | |
| 672 | if (!threadTerminalsMatchState(serverThread, draftTerminal)) { | |
| 688 | 673 | dispatch({ |
| 689 | 674 | type: "HYDRATE_THREAD_TERMINALS", |
| 690 | 675 | threadId: serverThread.id, |
| 691 | terminalState: localDraftTerminalState, | |
| 676 | terminalState: draftTerminal, | |
| 692 | 677 | }); |
| 693 | return; | |
| 694 | 678 | } |
| 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]); | |
| 679 | clearDraftThreadTerminalState(threadId); | |
| 680 | }, [clearDraftThreadTerminalState, dispatch, serverThread, threadId]); | |
| 703 | 681 | |
| 704 | 682 | useEffect(() => { |
| 705 | 683 | if (!activeThread?.id) return; |
| ... | ... | @@ -1108,12 +1086,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1108 | 1086 | (open: boolean) => { |
| 1109 | 1087 | if (!activeThreadId) return; |
| 1110 | 1088 | if (isLocalDraftThread) { |
| 1111 | updateDraftTerminalState(activeThreadId, (draftState) => | |
| 1112 | reduceDraftThreadTerminalState(draftState, { | |
| 1113 | type: "set-open", | |
| 1114 | open, | |
| 1115 | }), | |
| 1116 | ); | |
| 1089 | setDraftThreadTerminalAction(activeThreadId, { type: "set-open", open }); | |
| 1117 | 1090 | return; |
| 1118 | 1091 | } |
| 1119 | 1092 | dispatch({ |
| ... | ... | @@ -1122,18 +1095,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1122 | 1095 | open, |
| 1123 | 1096 | }); |
| 1124 | 1097 | }, |
| 1125 | [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], | |
| 1098 | [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction], | |
| 1126 | 1099 | ); |
| 1127 | 1100 | const setTerminalHeight = useCallback( |
| 1128 | 1101 | (height: number) => { |
| 1129 | 1102 | if (!activeThreadId) return; |
| 1130 | 1103 | if (isLocalDraftThread) { |
| 1131 | updateDraftTerminalState(activeThreadId, (draftState) => | |
| 1132 | reduceDraftThreadTerminalState(draftState, { | |
| 1133 | type: "set-height", | |
| 1134 | height, | |
| 1135 | }), | |
| 1136 | ); | |
| 1104 | setDraftThreadTerminalAction(activeThreadId, { type: "set-height", height }); | |
| 1137 | 1105 | return; |
| 1138 | 1106 | } |
| 1139 | 1107 | dispatch({ |
| ... | ... | @@ -1142,7 +1110,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1142 | 1110 | height, |
| 1143 | 1111 | }); |
| 1144 | 1112 | }, |
| 1145 | [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], | |
| 1113 | [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction], | |
| 1146 | 1114 | ); |
| 1147 | 1115 | const toggleTerminalVisibility = useCallback(() => { |
| 1148 | 1116 | if (!activeThreadId) return; |
| ... | ... | @@ -1152,12 +1120,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1152 | 1120 | if (!activeThreadId || hasReachedTerminalLimit) return; |
| 1153 | 1121 | const terminalId = `terminal-${crypto.randomUUID()}`; |
| 1154 | 1122 | if (isLocalDraftThread) { |
| 1155 | updateDraftTerminalState(activeThreadId, (draftState) => | |
| 1156 | reduceDraftThreadTerminalState(draftState, { | |
| 1157 | type: "split", | |
| 1158 | terminalId, | |
| 1159 | }), | |
| 1160 | ); | |
| 1123 | setDraftThreadTerminalAction(activeThreadId, { type: "split", terminalId }); | |
| 1161 | 1124 | } else { |
| 1162 | 1125 | dispatch({ |
| 1163 | 1126 | type: "SPLIT_THREAD_TERMINAL", |
| ... | ... | @@ -1166,17 +1129,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1166 | 1129 | }); |
| 1167 | 1130 | } |
| 1168 | 1131 | setTerminalFocusRequestId((value) => value + 1); |
| 1169 | }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, updateDraftTerminalState]); | |
| 1132 | }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, setDraftThreadTerminalAction]); | |
| 1170 | 1133 | const createNewTerminal = useCallback(() => { |
| 1171 | 1134 | if (!activeThreadId || hasReachedTerminalLimit) return; |
| 1172 | 1135 | const terminalId = `terminal-${crypto.randomUUID()}`; |
| 1173 | 1136 | if (isLocalDraftThread) { |
| 1174 | updateDraftTerminalState(activeThreadId, (draftState) => | |
| 1175 | reduceDraftThreadTerminalState(draftState, { | |
| 1176 | type: "new", | |
| 1177 | terminalId, | |
| 1178 | }), | |
| 1179 | ); | |
| 1137 | setDraftThreadTerminalAction(activeThreadId, { type: "new", terminalId }); | |
| 1180 | 1138 | } else { |
| 1181 | 1139 | dispatch({ |
| 1182 | 1140 | type: "NEW_THREAD_TERMINAL", |
| ... | ... | @@ -1185,17 +1143,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1185 | 1143 | }); |
| 1186 | 1144 | } |
| 1187 | 1145 | setTerminalFocusRequestId((value) => value + 1); |
| 1188 | }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, updateDraftTerminalState]); | |
| 1146 | }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, setDraftThreadTerminalAction]); | |
| 1189 | 1147 | const activateTerminal = useCallback( |
| 1190 | 1148 | (terminalId: string) => { |
| 1191 | 1149 | if (!activeThreadId) return; |
| 1192 | 1150 | if (isLocalDraftThread) { |
| 1193 | updateDraftTerminalState(activeThreadId, (draftState) => | |
| 1194 | reduceDraftThreadTerminalState(draftState, { | |
| 1195 | type: "set-active", | |
| 1196 | terminalId, | |
| 1197 | }), | |
| 1198 | ); | |
| 1151 | setDraftThreadTerminalAction(activeThreadId, { type: "set-active", terminalId }); | |
| 1199 | 1152 | } else { |
| 1200 | 1153 | dispatch({ |
| 1201 | 1154 | type: "SET_THREAD_ACTIVE_TERMINAL", |
| ... | ... | @@ -1205,7 +1158,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1205 | 1158 | } |
| 1206 | 1159 | setTerminalFocusRequestId((value) => value + 1); |
| 1207 | 1160 | }, |
| 1208 | [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], | |
| 1161 | [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction], | |
| 1209 | 1162 | ); |
| 1210 | 1163 | const closeTerminal = useCallback( |
| 1211 | 1164 | (terminalId: string) => { |
| ... | ... | @@ -1229,12 +1182,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1229 | 1182 | void fallbackExitWrite(); |
| 1230 | 1183 | } |
| 1231 | 1184 | if (isLocalDraftThread) { |
| 1232 | updateDraftTerminalState(activeThreadId, (draftState) => | |
| 1233 | reduceDraftThreadTerminalState(draftState, { | |
| 1234 | type: "close", | |
| 1235 | terminalId, | |
| 1236 | }), | |
| 1237 | ); | |
| 1185 | setDraftThreadTerminalAction(activeThreadId, { type: "close", terminalId }); | |
| 1238 | 1186 | } else { |
| 1239 | 1187 | dispatch({ |
| 1240 | 1188 | type: "CLOSE_THREAD_TERMINAL", |
| ... | ... | @@ -1244,7 +1192,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1244 | 1192 | } |
| 1245 | 1193 | setTerminalFocusRequestId((value) => value + 1); |
| 1246 | 1194 | }, |
| 1247 | [activeThread?.terminalIds.length, activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], | |
| 1195 | [activeThread?.terminalIds.length, activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction], | |
| 1248 | 1196 | ); |
| 1249 | 1197 | const runProjectScript = useCallback( |
| 1250 | 1198 | async ( |
| ... | ... | @@ -1281,12 +1229,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1281 | 1229 | setTerminalOpen(true); |
| 1282 | 1230 | if (shouldCreateNewTerminal) { |
| 1283 | 1231 | if (isLocalDraftThread) { |
| 1284 | updateDraftTerminalState(activeThreadId, (draftState) => | |
| 1285 | reduceDraftThreadTerminalState(draftState, { | |
| 1286 | type: "new", | |
| 1287 | terminalId: targetTerminalId, | |
| 1288 | }), | |
| 1289 | ); | |
| 1232 | setDraftThreadTerminalAction(activeThreadId, { | |
| 1233 | type: "new", | |
| 1234 | terminalId: targetTerminalId, | |
| 1235 | }); | |
| 1290 | 1236 | } else { |
| 1291 | 1237 | dispatch({ |
| 1292 | 1238 | type: "NEW_THREAD_TERMINAL", |
| ... | ... | @@ -1296,12 +1242,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1296 | 1242 | } |
| 1297 | 1243 | } else { |
| 1298 | 1244 | if (isLocalDraftThread) { |
| 1299 | updateDraftTerminalState(activeThreadId, (draftState) => | |
| 1300 | reduceDraftThreadTerminalState(draftState, { | |
| 1301 | type: "set-active", | |
| 1302 | terminalId: targetTerminalId, | |
| 1303 | }), | |
| 1304 | ); | |
| 1245 | setDraftThreadTerminalAction(activeThreadId, { | |
| 1246 | type: "set-active", | |
| 1247 | terminalId: targetTerminalId, | |
| 1248 | }); | |
| 1305 | 1249 | } else { |
| 1306 | 1250 | dispatch({ |
| 1307 | 1251 | type: "SET_THREAD_ACTIVE_TERMINAL", |
| ... | ... | @@ -1355,7 +1299,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1355 | 1299 | isServerThread, |
| 1356 | 1300 | setTerminalOpen, |
| 1357 | 1301 | setThreadError, |
| 1358 | updateDraftTerminalState, | |
| 1302 | setDraftThreadTerminalAction, | |
| 1359 | 1303 | ], |
| 1360 | 1304 | ); |
| 1361 | 1305 | const persistProjectScripts = useCallback( |
apps/web/src/composerDraftStore.ts+59| ... | ... | @@ -6,6 +6,13 @@ import { |
| 6 | 6 | normalizeModelSlug, |
| 7 | 7 | type ReasoningEffort, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | import { | |
| 10 | createDefaultThreadTerminalState, | |
| 11 | getDefaultThreadTerminalState, | |
| 12 | reduceThreadTerminalState, | |
| 13 | type ThreadTerminalAction, | |
| 14 | type ThreadTerminalState, | |
| 15 | } from "./threadTerminalState"; | |
| 9 | 16 | import type { ChatImageAttachment } from "./types"; |
| 10 | 17 | import { create } from "zustand"; |
| 11 | 18 | import { createJSONStorage, persist } from "zustand/middleware"; |
| ... | ... | @@ -64,6 +71,8 @@ export interface DraftThreadState { |
| 64 | 71 | branch: string | null; |
| 65 | 72 | worktreePath: string | null; |
| 66 | 73 | envMode: DraftThreadEnvMode; |
| 74 | /** Terminal layout for draft thread; same shape as Thread terminal slice. Omitted when not set. */ | |
| 75 | terminalState?: ThreadTerminalState; | |
| 67 | 76 | } |
| 68 | 77 | |
| 69 | 78 | interface ProjectDraftThread extends DraftThreadState { |
| ... | ... | @@ -113,6 +122,9 @@ interface ComposerDraftStoreState { |
| 113 | 122 | ) => void; |
| 114 | 123 | clearComposerContent: (threadId: ThreadId) => void; |
| 115 | 124 | clearThreadDraft: (threadId: ThreadId) => void; |
| 125 | getDraftThreadTerminalState: (threadId: ThreadId) => ThreadTerminalState; | |
| 126 | setDraftThreadTerminalAction: (threadId: ThreadId, action: ThreadTerminalAction) => void; | |
| 127 | clearDraftThreadTerminalState: (threadId: ThreadId) => void; | |
| 116 | 128 | } |
| 117 | 129 | |
| 118 | 130 | const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { |
| ... | ... | @@ -666,6 +678,53 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 666 | 678 | }; |
| 667 | 679 | }); |
| 668 | 680 | }, |
| 681 | getDraftThreadTerminalState: (threadId) => { | |
| 682 | if (threadId.length === 0) { | |
| 683 | return getDefaultThreadTerminalState(); | |
| 684 | } | |
| 685 | const draft = get().draftThreadsByThreadId[threadId]; | |
| 686 | return draft?.terminalState ?? getDefaultThreadTerminalState(); | |
| 687 | }, | |
| 688 | setDraftThreadTerminalAction: (threadId, action) => { | |
| 689 | if (threadId.length === 0) { | |
| 690 | return; | |
| 691 | } | |
| 692 | set((state) => { | |
| 693 | const existing = state.draftThreadsByThreadId[threadId]; | |
| 694 | if (!existing) { | |
| 695 | return state; | |
| 696 | } | |
| 697 | const current = existing.terminalState ?? createDefaultThreadTerminalState(); | |
| 698 | const next = reduceThreadTerminalState(current, action); | |
| 699 | if (next === current) { | |
| 700 | return state; | |
| 701 | } | |
| 702 | return { | |
| 703 | draftThreadsByThreadId: { | |
| 704 | ...state.draftThreadsByThreadId, | |
| 705 | [threadId]: { ...existing, terminalState: next }, | |
| 706 | }, | |
| 707 | }; | |
| 708 | }); | |
| 709 | }, | |
| 710 | clearDraftThreadTerminalState: (threadId) => { | |
| 711 | if (threadId.length === 0) { | |
| 712 | return; | |
| 713 | } | |
| 714 | set((state) => { | |
| 715 | const existing = state.draftThreadsByThreadId[threadId]; | |
| 716 | if (!existing || existing.terminalState === undefined) { | |
| 717 | return state; | |
| 718 | } | |
| 719 | const { terminalState: _removed, ...rest } = existing; | |
| 720 | return { | |
| 721 | draftThreadsByThreadId: { | |
| 722 | ...state.draftThreadsByThreadId, | |
| 723 | [threadId]: rest, | |
| 724 | }, | |
| 725 | }; | |
| 726 | }); | |
| 727 | }, | |
| 669 | 728 | setPrompt: (threadId, prompt) => { |
| 670 | 729 | if (threadId.length === 0) { |
| 671 | 730 | return; |
apps/web/src/draftThreadTerminalState.ts+16-328| ... | ... | @@ -1,332 +1,20 @@ |
| 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 | } | |
| 1 | /** | |
| 2 | * Re-exports shared thread terminal state and reducer so draft and persisted | |
| 3 | * flows use the same shape and logic. See threadTerminalState.ts. | |
| 4 | */ | |
| 157 | 5 | |
| 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 | } | |
| 6 | import { | |
| 7 | createDefaultThreadTerminalState, | |
| 8 | normalizeThreadTerminalState, | |
| 9 | reduceThreadTerminalState, | |
| 10 | type ThreadTerminalAction, | |
| 11 | type ThreadTerminalState, | |
| 12 | } from "./threadTerminalState"; | |
| 309 | 13 | |
| 310 | const destinationGroup = terminalGroups[activeGroupIndex]; | |
| 311 | if (!destinationGroup) { | |
| 312 | return normalized; | |
| 313 | } | |
| 14 | export type DraftThreadTerminalState = ThreadTerminalState; | |
| 15 | export type DraftThreadTerminalAction = ThreadTerminalAction; | |
| 314 | 16 | |
| 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 | } | |
| 17 | export const createDefaultDraftThreadTerminalState = createDefaultThreadTerminalState; | |
| 18 | export const reduceDraftThreadTerminalState = reduceThreadTerminalState; | |
| 323 | 19 | |
| 324 | return normalizeDraftThreadTerminals({ | |
| 325 | ...normalized, | |
| 326 | terminalOpen: true, | |
| 327 | terminalIds, | |
| 328 | activeTerminalId: action.terminalId, | |
| 329 | terminalGroups, | |
| 330 | activeTerminalGroupId: destinationGroup.id, | |
| 331 | }); | |
| 332 | } | |
| 20 | export { normalizeThreadTerminalState }; |
apps/web/src/store.ts+133-270| ... | ... | @@ -10,34 +10,24 @@ import { |
| 10 | 10 | resolveModelSlug, |
| 11 | 11 | } from "@t3tools/contracts"; |
| 12 | 12 | import { |
| 13 | DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 14 | DEFAULT_THREAD_TERMINAL_ID, | |
| 13 | createDefaultThreadTerminalState, | |
| 14 | normalizeThreadTerminalState, | |
| 15 | reduceThreadTerminalState, | |
| 16 | type ThreadTerminalState, | |
| 17 | } from "./threadTerminalState"; | |
| 18 | import { | |
| 15 | 19 | DEFAULT_RUNTIME_MODE, |
| 16 | MAX_THREAD_TERMINAL_COUNT, | |
| 17 | 20 | type ChatMessage, |
| 18 | 21 | type Project, |
| 19 | 22 | type RuntimeMode, |
| 20 | 23 | type Thread, |
| 21 | type ThreadTerminalGroup, | |
| 22 | 24 | } from "./types"; |
| 23 | 25 | |
| 24 | 26 | // ── Actions ────────────────────────────────────────────────────────── |
| 25 | 27 | |
| 26 | 28 | type Action = |
| 27 | 29 | | { 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 | } | |
| 30 | | { type: "HYDRATE_THREAD_TERMINALS"; threadId: ThreadId; terminalState: ThreadTerminalState } | |
| 41 | 31 | | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string } |
| 42 | 32 | | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId } |
| 43 | 33 | | { type: "TOGGLE_PROJECT"; projectId: Project["id"] } |
| ... | ... | @@ -262,210 +252,24 @@ function attachmentPreviewRoutePath(attachmentId: string): string { |
| 262 | 252 | return `/attachments/${encodeURIComponent(attachmentId)}`; |
| 263 | 253 | } |
| 264 | 254 | |
| 265 | function normalizeTerminalIds(terminalIds: string[]): string[] { | |
| 266 | const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); | |
| 267 | const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT); | |
| 268 | if (unique.length > 0) { | |
| 269 | return unique; | |
| 270 | } | |
| 271 | return [DEFAULT_THREAD_TERMINAL_ID]; | |
| 272 | } | |
| 273 | ||
| 274 | function normalizeRunningTerminalIds( | |
| 275 | runningTerminalIds: string[], | |
| 276 | terminalIds: string[], | |
| 277 | ): string[] { | |
| 278 | if (runningTerminalIds.length === 0) return []; | |
| 279 | const validTerminalIdSet = new Set(terminalIds); | |
| 280 | return [...new Set(runningTerminalIds)] | |
| 281 | .map((id) => id.trim()) | |
| 282 | .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); | |
| 283 | } | |
| 284 | ||
| 285 | function normalizeTerminalGroupIds(terminalIds: string[]): string[] { | |
| 286 | return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; | |
| 287 | } | |
| 288 | ||
| 289 | function fallbackGroupId(terminalId: string): string { | |
| 290 | return `group-${terminalId}`; | |
| 291 | } | |
| 292 | ||
| 293 | function assignUniqueGroupId(groupId: string, usedGroupIds: Set<string>): string { | |
| 294 | if (!usedGroupIds.has(groupId)) { | |
| 295 | usedGroupIds.add(groupId); | |
| 296 | return groupId; | |
| 297 | } | |
| 298 | let suffix = 2; | |
| 299 | while (usedGroupIds.has(`${groupId}-${suffix}`)) { | |
| 300 | suffix += 1; | |
| 301 | } | |
| 302 | const uniqueGroupId = `${groupId}-${suffix}`; | |
| 303 | usedGroupIds.add(uniqueGroupId); | |
| 304 | return uniqueGroupId; | |
| 305 | } | |
| 306 | ||
| 307 | function normalizeTerminalGroups(thread: Thread, terminalIds: string[]): ThreadTerminalGroup[] { | |
| 308 | const validTerminalIdSet = new Set(terminalIds); | |
| 309 | const assignedTerminalIds = new Set<string>(); | |
| 310 | const usedGroupIds = new Set<string>(); | |
| 311 | const groups: ThreadTerminalGroup[] = []; | |
| 312 | ||
| 313 | for (const group of thread.terminalGroups) { | |
| 314 | const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => { | |
| 315 | if (!validTerminalIdSet.has(terminalId)) return false; | |
| 316 | if (assignedTerminalIds.has(terminalId)) return false; | |
| 317 | return true; | |
| 318 | }); | |
| 319 | if (groupTerminalIds.length === 0) continue; | |
| 320 | for (const terminalId of groupTerminalIds) { | |
| 321 | assignedTerminalIds.add(terminalId); | |
| 322 | } | |
| 323 | const baseGroupId = | |
| 324 | group.id.trim().length > 0 | |
| 325 | ? group.id.trim() | |
| 326 | : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | |
| 327 | groups.push({ | |
| 328 | id: assignUniqueGroupId(baseGroupId, usedGroupIds), | |
| 329 | terminalIds: groupTerminalIds, | |
| 330 | }); | |
| 331 | } | |
| 332 | ||
| 333 | for (const terminalId of terminalIds) { | |
| 334 | if (assignedTerminalIds.has(terminalId)) continue; | |
| 335 | groups.push({ | |
| 336 | id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), | |
| 337 | terminalIds: [terminalId], | |
| 338 | }); | |
| 339 | } | |
| 340 | ||
| 341 | if (groups.length > 0) { | |
| 342 | return groups; | |
| 343 | } | |
| 344 | ||
| 345 | return [ | |
| 346 | { | |
| 347 | id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | |
| 348 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 349 | }, | |
| 350 | ]; | |
| 351 | } | |
| 352 | ||
| 353 | function findGroupIndexByTerminalId( | |
| 354 | terminalGroups: ThreadTerminalGroup[], | |
| 355 | terminalId: string, | |
| 356 | ): number { | |
| 357 | return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId)); | |
| 358 | } | |
| 359 | ||
| 360 | function normalizeThreadTerminals(thread: Thread): Thread { | |
| 361 | const terminalIds = normalizeTerminalIds(thread.terminalIds); | |
| 362 | const activeTerminalId = terminalIds.includes(thread.activeTerminalId) | |
| 363 | ? thread.activeTerminalId | |
| 364 | : (terminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | |
| 365 | const terminalGroups = normalizeTerminalGroups(thread, terminalIds); | |
| 366 | const activeGroupIndexFromId = terminalGroups.findIndex( | |
| 367 | (group) => group.id === thread.activeTerminalGroupId, | |
| 368 | ); | |
| 369 | const activeGroupIndexFromTerminal = findGroupIndexByTerminalId(terminalGroups, activeTerminalId); | |
| 370 | const activeGroupIndex = | |
| 371 | activeGroupIndexFromId >= 0 | |
| 372 | ? activeGroupIndexFromId | |
| 373 | : activeGroupIndexFromTerminal >= 0 | |
| 374 | ? activeGroupIndexFromTerminal | |
| 375 | : 0; | |
| 376 | const activeTerminalGroupId = | |
| 377 | terminalGroups[activeGroupIndex]?.id ?? | |
| 378 | terminalGroups[0]?.id ?? | |
| 379 | fallbackGroupId(activeTerminalId); | |
| 380 | ||
| 255 | function threadTerminalSlice(thread: Thread): ThreadTerminalState { | |
| 381 | 256 | return { |
| 382 | ...thread, | |
| 383 | terminalIds, | |
| 384 | runningTerminalIds: normalizeRunningTerminalIds(thread.runningTerminalIds, terminalIds), | |
| 385 | activeTerminalId, | |
| 386 | terminalGroups, | |
| 387 | activeTerminalGroupId, | |
| 257 | terminalOpen: thread.terminalOpen, | |
| 258 | terminalHeight: thread.terminalHeight, | |
| 259 | terminalIds: thread.terminalIds, | |
| 260 | runningTerminalIds: thread.runningTerminalIds, | |
| 261 | activeTerminalId: thread.activeTerminalId, | |
| 262 | terminalGroups: thread.terminalGroups, | |
| 263 | activeTerminalGroupId: thread.activeTerminalGroupId, | |
| 388 | 264 | }; |
| 389 | 265 | } |
| 390 | 266 | |
| 391 | function closeThreadTerminal(thread: Thread, terminalId: string): Thread { | |
| 392 | if (!thread.terminalIds.includes(terminalId)) { | |
| 393 | return thread; | |
| 394 | } | |
| 395 | ||
| 396 | const remainingTerminalIds = thread.terminalIds.filter((id) => id !== terminalId); | |
| 397 | if (remainingTerminalIds.length === 0) { | |
| 398 | const nextTerminalGroupId = fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID); | |
| 399 | return normalizeThreadTerminals({ | |
| 400 | ...thread, | |
| 401 | terminalOpen: false, | |
| 402 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 403 | runningTerminalIds: [], | |
| 404 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 405 | terminalGroups: [ | |
| 406 | { | |
| 407 | id: nextTerminalGroupId, | |
| 408 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 409 | }, | |
| 410 | ], | |
| 411 | activeTerminalGroupId: nextTerminalGroupId, | |
| 412 | }); | |
| 413 | } | |
| 414 | ||
| 415 | const closedTerminalIndex = thread.terminalIds.indexOf(terminalId); | |
| 416 | const closedTerminalGroup = thread.terminalGroups.find((group) => | |
| 417 | group.terminalIds.includes(terminalId), | |
| 418 | ); | |
| 419 | const closedTerminalGroupIndex = closedTerminalGroup | |
| 420 | ? closedTerminalGroup.terminalIds.indexOf(terminalId) | |
| 421 | : -1; | |
| 422 | const remainingTerminalsInClosedGroup = (closedTerminalGroup?.terminalIds ?? []).filter( | |
| 423 | (id) => id !== terminalId, | |
| 424 | ); | |
| 425 | const nextActiveTerminalId = | |
| 426 | thread.activeTerminalId === terminalId | |
| 427 | ? (remainingTerminalsInClosedGroup[ | |
| 428 | Math.min(closedTerminalGroupIndex, remainingTerminalsInClosedGroup.length - 1) | |
| 429 | ] ?? | |
| 430 | remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? | |
| 431 | remainingTerminalIds[0] ?? | |
| 432 | DEFAULT_THREAD_TERMINAL_ID) | |
| 433 | : thread.activeTerminalId; | |
| 434 | const nextTerminalGroups = thread.terminalGroups | |
| 435 | .map((group) => ({ | |
| 436 | ...group, | |
| 437 | terminalIds: group.terminalIds.filter((id) => id !== terminalId), | |
| 438 | })) | |
| 439 | .filter((group) => group.terminalIds.length > 0); | |
| 440 | ||
| 441 | return normalizeThreadTerminals({ | |
| 442 | ...thread, | |
| 443 | terminalIds: remainingTerminalIds, | |
| 444 | runningTerminalIds: thread.runningTerminalIds.filter((id) => id !== terminalId), | |
| 445 | activeTerminalId: nextActiveTerminalId, | |
| 446 | terminalGroups: nextTerminalGroups, | |
| 447 | }); | |
| 267 | function applyTerminalState(thread: Thread, terminal: ThreadTerminalState): Thread { | |
| 268 | return { ...thread, ...terminal }; | |
| 448 | 269 | } |
| 449 | 270 | |
| 450 | function setThreadTerminalActivity( | |
| 451 | thread: Thread, | |
| 452 | terminalId: string, | |
| 453 | hasRunningSubprocess: boolean, | |
| 454 | ): Thread { | |
| 455 | const normalizedThread = normalizeThreadTerminals(thread); | |
| 456 | if (!normalizedThread.terminalIds.includes(terminalId)) { | |
| 457 | return normalizedThread; | |
| 458 | } | |
| 459 | const runningTerminalIds = new Set(normalizedThread.runningTerminalIds); | |
| 460 | if (hasRunningSubprocess) { | |
| 461 | runningTerminalIds.add(terminalId); | |
| 462 | } else { | |
| 463 | runningTerminalIds.delete(terminalId); | |
| 464 | } | |
| 465 | return normalizeThreadTerminals({ | |
| 466 | ...normalizedThread, | |
| 467 | runningTerminalIds: [...runningTerminalIds], | |
| 468 | }); | |
| 271 | function normalizeThreadTerminals(thread: Thread): Thread { | |
| 272 | return applyTerminalState(thread, normalizeThreadTerminalState(threadTerminalSlice(thread))); | |
| 469 | 273 | } |
| 470 | 274 | |
| 471 | 275 | function syncServerReadModelState(state: AppState, readModel: OrchestrationReadModel): AppState { |
| ... | ... | @@ -489,10 +293,14 @@ export function reducer(state: AppState, action: Action): AppState { |
| 489 | 293 | const existingThreadById = new Map( |
| 490 | 294 | state.threads.map((thread) => [thread.id, thread] as const), |
| 491 | 295 | ); |
| 296 | const defaultTerminal = createDefaultThreadTerminalState(); | |
| 492 | 297 | const threads = action.readModel.threads |
| 493 | 298 | .filter((thread) => thread.deletedAt === null) |
| 494 | 299 | .map((thread) => { |
| 495 | 300 | const existing = existingThreadById.get(thread.id); |
| 301 | const terminalSlice: ThreadTerminalState = existing | |
| 302 | ? threadTerminalSlice(existing) | |
| 303 | : defaultTerminal; | |
| 496 | 304 | |
| 497 | 305 | return normalizeThreadTerminals({ |
| 498 | 306 | id: thread.id, |
| ... | ... | @@ -500,19 +308,7 @@ export function reducer(state: AppState, action: Action): AppState { |
| 500 | 308 | projectId: thread.projectId, |
| 501 | 309 | title: thread.title, |
| 502 | 310 | 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}`, | |
| 311 | ...terminalSlice, | |
| 516 | 312 | session: thread.session |
| 517 | 313 | ? { |
| 518 | 314 | sessionId: |
| ... | ... | @@ -578,16 +374,7 @@ export function reducer(state: AppState, action: Action): AppState { |
| 578 | 374 | return { |
| 579 | 375 | ...state, |
| 580 | 376 | 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 | }), | |
| 377 | applyTerminalState(thread, normalizeThreadTerminalState(action.terminalState)), | |
| 591 | 378 | ), |
| 592 | 379 | }; |
| 593 | 380 | |
| ... | ... | @@ -648,7 +435,14 @@ export function reducer(state: AppState, action: Action): AppState { |
| 648 | 435 | return { |
| 649 | 436 | ...state, |
| 650 | 437 | threads: updateThread(state.threads, action.threadId, (thread) => |
| 651 | setThreadTerminalActivity(thread, action.terminalId, action.hasRunningSubprocess), | |
| 438 | applyTerminalState( | |
| 439 | thread, | |
| 440 | reduceThreadTerminalState(threadTerminalSlice(thread), { | |
| 441 | type: "set-activity", | |
| 442 | terminalId: action.terminalId, | |
| 443 | hasRunningSubprocess: action.hasRunningSubprocess, | |
| 444 | }), | |
| 445 | ), | |
| 652 | 446 | ), |
| 653 | 447 | }; |
| 654 | 448 | |
| ... | ... | @@ -663,54 +457,123 @@ export function reducer(state: AppState, action: Action): AppState { |
| 663 | 457 | case "TOGGLE_THREAD_TERMINAL": |
| 664 | 458 | return { |
| 665 | 459 | ...state, |
| 666 | threads: updateThread(state.threads, action.threadId, (t) => ({ | |
| 667 | ...t, | |
| 668 | terminalOpen: !t.terminalOpen, | |
| 669 | })), | |
| 460 | threads: updateThread(state.threads, action.threadId, (thread) => | |
| 461 | applyTerminalState( | |
| 462 | thread, | |
| 463 | reduceThreadTerminalState(threadTerminalSlice(thread), { | |
| 464 | type: "set-open", | |
| 465 | open: !thread.terminalOpen, | |
| 466 | }), | |
| 467 | ), | |
| 468 | ), | |
| 670 | 469 | }; |
| 671 | 470 | |
| 672 | 471 | case "SET_THREAD_TERMINAL_OPEN": |
| 673 | 472 | return { |
| 674 | 473 | ...state, |
| 675 | threads: updateThread(state.threads, action.threadId, (t) => ({ | |
| 676 | ...t, | |
| 677 | terminalOpen: action.open, | |
| 678 | })), | |
| 474 | threads: updateThread(state.threads, action.threadId, (thread) => | |
| 475 | applyTerminalState( | |
| 476 | thread, | |
| 477 | reduceThreadTerminalState(threadTerminalSlice(thread), { | |
| 478 | type: "set-open", | |
| 479 | open: action.open, | |
| 480 | }), | |
| 481 | ), | |
| 482 | ), | |
| 679 | 483 | }; |
| 680 | 484 | |
| 681 | 485 | case "SET_THREAD_TERMINAL_HEIGHT": |
| 486 | return { | |
| 487 | ...state, | |
| 488 | threads: updateThread(state.threads, action.threadId, (thread) => | |
| 489 | applyTerminalState( | |
| 490 | thread, | |
| 491 | reduceThreadTerminalState(threadTerminalSlice(thread), { | |
| 492 | type: "set-height", | |
| 493 | height: action.height, | |
| 494 | }), | |
| 495 | ), | |
| 496 | ), | |
| 497 | }; | |
| 498 | ||
| 499 | case "SPLIT_THREAD_TERMINAL": | |
| 500 | return { | |
| 501 | ...state, | |
| 502 | threads: updateThread(state.threads, action.threadId, (thread) => | |
| 503 | applyTerminalState( | |
| 504 | thread, | |
| 505 | reduceThreadTerminalState(threadTerminalSlice(thread), { | |
| 506 | type: "split", | |
| 507 | terminalId: action.terminalId, | |
| 508 | }), | |
| 509 | ), | |
| 510 | ), | |
| 511 | }; | |
| 512 | ||
| 513 | case "NEW_THREAD_TERMINAL": | |
| 514 | return { | |
| 515 | ...state, | |
| 516 | threads: updateThread(state.threads, action.threadId, (thread) => | |
| 517 | applyTerminalState( | |
| 518 | thread, | |
| 519 | reduceThreadTerminalState(threadTerminalSlice(thread), { | |
| 520 | type: "new", | |
| 521 | terminalId: action.terminalId, | |
| 522 | }), | |
| 523 | ), | |
| 524 | ), | |
| 525 | }; | |
| 526 | ||
| 527 | case "SET_THREAD_ACTIVE_TERMINAL": | |
| 528 | return { | |
| 529 | ...state, | |
| 530 | threads: updateThread(state.threads, action.threadId, (thread) => | |
| 531 | applyTerminalState( | |
| 532 | thread, | |
| 533 | reduceThreadTerminalState(threadTerminalSlice(thread), { | |
| 534 | type: "set-active", | |
| 535 | terminalId: action.terminalId, | |
| 536 | }), | |
| 537 | ), | |
| 538 | ), | |
| 539 | }; | |
| 540 | ||
| 541 | case "CLOSE_THREAD_TERMINAL": | |
| 542 | return { | |
| 543 | ...state, | |
| 544 | threads: updateThread(state.threads, action.threadId, (thread) => | |
| 545 | applyTerminalState( | |
| 546 | thread, | |
| 547 | reduceThreadTerminalState(threadTerminalSlice(thread), { | |
| 548 | type: "close", | |
| 549 | terminalId: action.terminalId, | |
| 550 | }), | |
| 551 | ), | |
| 552 | ), | |
| 553 | }; | |
| 554 | ||
| 555 | case "SET_ERROR": | |
| 682 | 556 | return { |
| 683 | 557 | ...state, |
| 684 | 558 | threads: updateThread(state.threads, action.threadId, (t) => ({ |
| 685 | 559 | ...t, |
| 686 | terminalHeight: action.height, | |
| 560 | error: action.error, | |
| 687 | 561 | })), |
| 688 | 562 | }; |
| 689 | 563 | |
| 690 | case "SPLIT_THREAD_TERMINAL": | |
| 564 | case "SET_THREAD_BRANCH": { | |
| 691 | 565 | return { |
| 692 | 566 | ...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], | |
| 705 | })); | |
| 706 | const normalizedMessage: ChatMessage = { | |
| 707 | id: message.id, | |
| 708 | role: message.role, | |
| 709 | text: message.text, | |
| 710 | createdAt: message.createdAt, | |
| 711 | streaming: message.streaming, | |
| 712 | ...(message.streaming ? {} : { completedAt: message.updatedAt }), | |
| 713 | ...(attachments && attachments.length > 0 ? { attachments } : {}), | |
| 567 | threads: updateThread(state.threads, action.threadId, (t) => { | |
| 568 | // When the effective cwd changes (worktreePath differs), the old | |
| 569 | // session is no longer valid — clear it so ensureSession creates a | |
| 570 | // new one with the correct cwd on the next message. | |
| 571 | const cwdChanged = t.worktreePath !== action.worktreePath; | |
| 572 | return { | |
| 573 | ...t, | |
| 574 | branch: action.branch, | |
| 575 | worktreePath: action.worktreePath, | |
| 576 | ...(cwdChanged ? { session: null } : {}), | |
| 714 | 577 | }; |
| 715 | 578 | return normalizedMessage; |
| 716 | 579 | }), |
apps/web/src/threadTerminalState.ts created+374| ... | ... | @@ -0,0 +1,374 @@ |
| 1 | /** | |
| 2 | * Shared thread terminal state and reducer. Used by both the main app store | |
| 3 | * (persisted threads) and the composer draft store (draft threads), so we can | |
| 4 | * reuse the same shape and helpers and easily copy terminal state from draft | |
| 5 | * to thread when reconciling server state. | |
| 6 | */ | |
| 7 | ||
| 8 | import { | |
| 9 | DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 10 | DEFAULT_THREAD_TERMINAL_ID, | |
| 11 | MAX_THREAD_TERMINAL_COUNT, | |
| 12 | type ThreadTerminalGroup, | |
| 13 | } from "./types"; | |
| 14 | ||
| 15 | /** Terminal slice shared by Thread and draft thread. Same shape in both stores. */ | |
| 16 | export interface ThreadTerminalState { | |
| 17 | terminalOpen: boolean; | |
| 18 | terminalHeight: number; | |
| 19 | terminalIds: string[]; | |
| 20 | runningTerminalIds: string[]; | |
| 21 | activeTerminalId: string; | |
| 22 | terminalGroups: ThreadTerminalGroup[]; | |
| 23 | activeTerminalGroupId: string; | |
| 24 | } | |
| 25 | ||
| 26 | export type ThreadTerminalAction = | |
| 27 | | { type: "set-open"; open: boolean } | |
| 28 | | { type: "set-height"; height: number } | |
| 29 | | { type: "split"; terminalId: string } | |
| 30 | | { type: "new"; terminalId: string } | |
| 31 | | { type: "set-active"; terminalId: string } | |
| 32 | | { type: "close"; terminalId: string } | |
| 33 | | { type: "set-activity"; terminalId: string; hasRunningSubprocess: boolean }; | |
| 34 | ||
| 35 | // ─── Helpers (exported for store reducers that need to work with Thread) ─── | |
| 36 | ||
| 37 | export function normalizeTerminalIds(terminalIds: string[]): string[] { | |
| 38 | const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))].slice( | |
| 39 | 0, | |
| 40 | MAX_THREAD_TERMINAL_COUNT, | |
| 41 | ); | |
| 42 | return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID]; | |
| 43 | } | |
| 44 | ||
| 45 | export function normalizeRunningTerminalIds( | |
| 46 | runningTerminalIds: string[], | |
| 47 | terminalIds: string[], | |
| 48 | ): string[] { | |
| 49 | if (runningTerminalIds.length === 0) return []; | |
| 50 | const validTerminalIdSet = new Set(terminalIds); | |
| 51 | return [...new Set(runningTerminalIds)] | |
| 52 | .map((id) => id.trim()) | |
| 53 | .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); | |
| 54 | } | |
| 55 | ||
| 56 | export function fallbackGroupId(terminalId: string): string { | |
| 57 | return `group-${terminalId}`; | |
| 58 | } | |
| 59 | ||
| 60 | function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string { | |
| 61 | let candidate = baseId; | |
| 62 | let index = 2; | |
| 63 | while (usedGroupIds.has(candidate)) { | |
| 64 | candidate = `${baseId}-${index}`; | |
| 65 | index += 1; | |
| 66 | } | |
| 67 | usedGroupIds.add(candidate); | |
| 68 | return candidate; | |
| 69 | } | |
| 70 | ||
| 71 | export function findGroupIndexByTerminalId( | |
| 72 | terminalGroups: ThreadTerminalGroup[], | |
| 73 | terminalId: string, | |
| 74 | ): number { | |
| 75 | return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId)); | |
| 76 | } | |
| 77 | ||
| 78 | function normalizeTerminalGroupIds(terminalIds: string[]): string[] { | |
| 79 | return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; | |
| 80 | } | |
| 81 | ||
| 82 | /** Normalize groups to match terminalIds; used by both thread and draft. */ | |
| 83 | export function normalizeTerminalGroups( | |
| 84 | terminalGroups: ThreadTerminalGroup[], | |
| 85 | terminalIds: string[], | |
| 86 | ): ThreadTerminalGroup[] { | |
| 87 | const validTerminalIdSet = new Set(terminalIds); | |
| 88 | const assignedTerminalIds = new Set<string>(); | |
| 89 | const nextGroups: ThreadTerminalGroup[] = []; | |
| 90 | const usedGroupIds = new Set<string>(); | |
| 91 | ||
| 92 | for (const group of terminalGroups) { | |
| 93 | const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => { | |
| 94 | if (!validTerminalIdSet.has(terminalId)) return false; | |
| 95 | if (assignedTerminalIds.has(terminalId)) return false; | |
| 96 | return true; | |
| 97 | }); | |
| 98 | if (groupTerminalIds.length === 0) continue; | |
| 99 | for (const terminalId of groupTerminalIds) { | |
| 100 | assignedTerminalIds.add(terminalId); | |
| 101 | } | |
| 102 | const baseGroupId = | |
| 103 | group.id.trim().length > 0 | |
| 104 | ? group.id.trim() | |
| 105 | : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | |
| 106 | nextGroups.push({ | |
| 107 | id: assignUniqueGroupId(baseGroupId, usedGroupIds), | |
| 108 | terminalIds: groupTerminalIds, | |
| 109 | }); | |
| 110 | } | |
| 111 | ||
| 112 | for (const terminalId of terminalIds) { | |
| 113 | if (assignedTerminalIds.has(terminalId)) continue; | |
| 114 | nextGroups.push({ | |
| 115 | id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), | |
| 116 | terminalIds: [terminalId], | |
| 117 | }); | |
| 118 | } | |
| 119 | ||
| 120 | if (nextGroups.length === 0) { | |
| 121 | return [ | |
| 122 | { | |
| 123 | id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | |
| 124 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 125 | }, | |
| 126 | ]; | |
| 127 | } | |
| 128 | ||
| 129 | return nextGroups; | |
| 130 | } | |
| 131 | ||
| 132 | // ─── Default & normalize ───────────────────────────────────────────────── | |
| 133 | ||
| 134 | const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({ | |
| 135 | terminalOpen: false, | |
| 136 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 137 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 138 | runningTerminalIds: [], | |
| 139 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 140 | terminalGroups: [ | |
| 141 | { | |
| 142 | id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | |
| 143 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 144 | }, | |
| 145 | ], | |
| 146 | activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | |
| 147 | }); | |
| 148 | ||
| 149 | export function createDefaultThreadTerminalState(): ThreadTerminalState { | |
| 150 | return { ...DEFAULT_THREAD_TERMINAL_STATE }; | |
| 151 | } | |
| 152 | ||
| 153 | export function getDefaultThreadTerminalState(): ThreadTerminalState { | |
| 154 | return DEFAULT_THREAD_TERMINAL_STATE; | |
| 155 | } | |
| 156 | ||
| 157 | export function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTerminalState { | |
| 158 | const terminalIds = normalizeTerminalIds(state.terminalIds); | |
| 159 | const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; | |
| 160 | const runningTerminalIds = normalizeRunningTerminalIds( | |
| 161 | state.runningTerminalIds, | |
| 162 | nextTerminalIds, | |
| 163 | ); | |
| 164 | const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) | |
| 165 | ? state.activeTerminalId | |
| 166 | : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | |
| 167 | const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds); | |
| 168 | const activeGroupIdFromState = terminalGroups.some((group) => group.id === state.activeTerminalGroupId) | |
| 169 | ? state.activeTerminalGroupId | |
| 170 | : null; | |
| 171 | const activeGroupIdFromTerminal = | |
| 172 | terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null; | |
| 173 | ||
| 174 | return { | |
| 175 | terminalOpen: state.terminalOpen, | |
| 176 | terminalHeight: | |
| 177 | Number.isFinite(state.terminalHeight) && state.terminalHeight > 0 | |
| 178 | ? state.terminalHeight | |
| 179 | : DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 180 | terminalIds: nextTerminalIds, | |
| 181 | runningTerminalIds, | |
| 182 | activeTerminalId, | |
| 183 | terminalGroups, | |
| 184 | activeTerminalGroupId: | |
| 185 | activeGroupIdFromState ?? | |
| 186 | activeGroupIdFromTerminal ?? | |
| 187 | terminalGroups[0]?.id ?? | |
| 188 | fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | |
| 189 | }; | |
| 190 | } | |
| 191 | ||
| 192 | // ─── Close terminal (shared) ───────────────────────────────────────────── | |
| 193 | ||
| 194 | function closeTerminal( | |
| 195 | state: ThreadTerminalState, | |
| 196 | terminalId: string, | |
| 197 | ): ThreadTerminalState { | |
| 198 | if (!state.terminalIds.includes(terminalId)) { | |
| 199 | return state; | |
| 200 | } | |
| 201 | ||
| 202 | const remainingTerminalIds = state.terminalIds.filter((id) => id !== terminalId); | |
| 203 | if (remainingTerminalIds.length === 0) { | |
| 204 | return createDefaultThreadTerminalState(); | |
| 205 | } | |
| 206 | ||
| 207 | const closedTerminalIndex = state.terminalIds.indexOf(terminalId); | |
| 208 | const nextActiveTerminalId = | |
| 209 | state.activeTerminalId === terminalId | |
| 210 | ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? | |
| 211 | remainingTerminalIds[0] ?? | |
| 212 | DEFAULT_THREAD_TERMINAL_ID) | |
| 213 | : state.activeTerminalId; | |
| 214 | ||
| 215 | const terminalGroups = state.terminalGroups | |
| 216 | .map((group) => ({ | |
| 217 | ...group, | |
| 218 | terminalIds: group.terminalIds.filter((id) => id !== terminalId), | |
| 219 | })) | |
| 220 | .filter((group) => group.terminalIds.length > 0); | |
| 221 | ||
| 222 | const nextActiveTerminalGroupId = | |
| 223 | terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? | |
| 224 | terminalGroups[0]?.id ?? | |
| 225 | fallbackGroupId(nextActiveTerminalId); | |
| 226 | ||
| 227 | return normalizeThreadTerminalState({ | |
| 228 | terminalOpen: state.terminalOpen, | |
| 229 | terminalHeight: state.terminalHeight, | |
| 230 | terminalIds: remainingTerminalIds, | |
| 231 | runningTerminalIds: state.runningTerminalIds.filter((id) => id !== terminalId), | |
| 232 | activeTerminalId: nextActiveTerminalId, | |
| 233 | terminalGroups, | |
| 234 | activeTerminalGroupId: nextActiveTerminalGroupId, | |
| 235 | }); | |
| 236 | } | |
| 237 | ||
| 238 | function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { | |
| 239 | return groups.map((group) => ({ | |
| 240 | id: group.id, | |
| 241 | terminalIds: [...group.terminalIds], | |
| 242 | })); | |
| 243 | } | |
| 244 | ||
| 245 | // ─── Single reducer for both draft and persisted thread ──────────────────── | |
| 246 | ||
| 247 | export function reduceThreadTerminalState( | |
| 248 | state: ThreadTerminalState, | |
| 249 | action: ThreadTerminalAction, | |
| 250 | ): ThreadTerminalState { | |
| 251 | const normalized = normalizeThreadTerminalState(state); | |
| 252 | ||
| 253 | if (action.type === "set-open") { | |
| 254 | if (normalized.terminalOpen === action.open) return normalized; | |
| 255 | return { ...normalized, terminalOpen: action.open }; | |
| 256 | } | |
| 257 | ||
| 258 | if (action.type === "set-height") { | |
| 259 | if ( | |
| 260 | !Number.isFinite(action.height) || | |
| 261 | action.height <= 0 || | |
| 262 | normalized.terminalHeight === action.height | |
| 263 | ) { | |
| 264 | return normalized; | |
| 265 | } | |
| 266 | return { ...normalized, terminalHeight: action.height }; | |
| 267 | } | |
| 268 | ||
| 269 | if (action.type === "set-active") { | |
| 270 | if (!normalized.terminalIds.includes(action.terminalId)) { | |
| 271 | return normalized; | |
| 272 | } | |
| 273 | const activeTerminalGroupId = | |
| 274 | normalized.terminalGroups.find((group) => group.terminalIds.includes(action.terminalId))?.id ?? | |
| 275 | normalized.activeTerminalGroupId; | |
| 276 | return { | |
| 277 | ...normalized, | |
| 278 | activeTerminalId: action.terminalId, | |
| 279 | activeTerminalGroupId, | |
| 280 | }; | |
| 281 | } | |
| 282 | ||
| 283 | if (action.type === "close") { | |
| 284 | return closeTerminal(normalized, action.terminalId); | |
| 285 | } | |
| 286 | ||
| 287 | if (action.type === "set-activity") { | |
| 288 | if (!normalized.terminalIds.includes(action.terminalId)) { | |
| 289 | return normalized; | |
| 290 | } | |
| 291 | const runningTerminalIds = new Set(normalized.runningTerminalIds); | |
| 292 | if (action.hasRunningSubprocess) { | |
| 293 | runningTerminalIds.add(action.terminalId); | |
| 294 | } else { | |
| 295 | runningTerminalIds.delete(action.terminalId); | |
| 296 | } | |
| 297 | return { ...normalized, runningTerminalIds: [...runningTerminalIds] }; | |
| 298 | } | |
| 299 | ||
| 300 | if (!action.terminalId || action.terminalId.trim().length === 0) { | |
| 301 | return normalized; | |
| 302 | } | |
| 303 | ||
| 304 | const isNewTerminal = !normalized.terminalIds.includes(action.terminalId); | |
| 305 | if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { | |
| 306 | return normalized; | |
| 307 | } | |
| 308 | ||
| 309 | const terminalIds = isNewTerminal | |
| 310 | ? [...normalized.terminalIds, action.terminalId] | |
| 311 | : normalized.terminalIds; | |
| 312 | const terminalGroups = copyTerminalGroups(normalized.terminalGroups); | |
| 313 | const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, action.terminalId); | |
| 314 | if (existingGroupIndex >= 0) { | |
| 315 | terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[ | |
| 316 | existingGroupIndex | |
| 317 | ]!.terminalIds.filter((id) => id !== action.terminalId); | |
| 318 | if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { | |
| 319 | terminalGroups.splice(existingGroupIndex, 1); | |
| 320 | } | |
| 321 | } | |
| 322 | ||
| 323 | if (action.type === "new") { | |
| 324 | const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); | |
| 325 | const nextGroupId = assignUniqueGroupId(fallbackGroupId(action.terminalId), usedGroupIds); | |
| 326 | terminalGroups.push({ id: nextGroupId, terminalIds: [action.terminalId] }); | |
| 327 | return normalizeThreadTerminalState({ | |
| 328 | ...normalized, | |
| 329 | terminalOpen: true, | |
| 330 | terminalIds, | |
| 331 | activeTerminalId: action.terminalId, | |
| 332 | terminalGroups, | |
| 333 | activeTerminalGroupId: nextGroupId, | |
| 334 | }); | |
| 335 | } | |
| 336 | ||
| 337 | // split | |
| 338 | let activeGroupIndex = terminalGroups.findIndex((group) => group.id === normalized.activeTerminalGroupId); | |
| 339 | if (activeGroupIndex < 0) { | |
| 340 | activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId); | |
| 341 | } | |
| 342 | if (activeGroupIndex < 0) { | |
| 343 | const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); | |
| 344 | const nextGroupId = assignUniqueGroupId( | |
| 345 | fallbackGroupId(normalized.activeTerminalId), | |
| 346 | usedGroupIds, | |
| 347 | ); | |
| 348 | terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] }); | |
| 349 | activeGroupIndex = terminalGroups.length - 1; | |
| 350 | } | |
| 351 | ||
| 352 | const destinationGroup = terminalGroups[activeGroupIndex]; | |
| 353 | if (!destinationGroup) { | |
| 354 | return normalized; | |
| 355 | } | |
| 356 | ||
| 357 | if (!destinationGroup.terminalIds.includes(action.terminalId)) { | |
| 358 | const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); | |
| 359 | if (anchorIndex >= 0) { | |
| 360 | destinationGroup.terminalIds.splice(anchorIndex + 1, 0, action.terminalId); | |
| 361 | } else { | |
| 362 | destinationGroup.terminalIds.push(action.terminalId); | |
| 363 | } | |
| 364 | } | |
| 365 | ||
| 366 | return normalizeThreadTerminalState({ | |
| 367 | ...normalized, | |
| 368 | terminalOpen: true, | |
| 369 | terminalIds, | |
| 370 | activeTerminalId: action.terminalId, | |
| 371 | terminalGroups, | |
| 372 | activeTerminalGroupId: destinationGroup.id, | |
| 373 | }); | |
| 374 | } |