| 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,11 +61,7 @@ import { |
| 61 | type Thread, | 61 | type Thread, |
| 62 | type TurnDiffSummary, | 62 | type TurnDiffSummary, |
| 63 | } from "../types"; | 63 | } from "../types"; |
| 64 | import { | 64 | import type { ThreadTerminalState } from "../threadTerminalState"; |
| 65 | createDefaultDraftThreadTerminalState, | ||
| 66 | reduceDraftThreadTerminalState, | ||
| 67 | type DraftThreadTerminalState, | ||
| 68 | } from "../draftThreadTerminalState"; | ||
| 69 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; | 65 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; |
| 70 | import { useTheme } from "../hooks/useTheme"; | 66 | import { useTheme } from "../hooks/useTheme"; |
| 71 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | 67 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| ... | @@ -209,7 +205,7 @@ function buildExpandedImagePreview( | ... | @@ -209,7 +205,7 @@ function buildExpandedImagePreview( |
| 209 | function buildLocalDraftThread( | 205 | function buildLocalDraftThread( |
| 210 | threadId: ThreadId, | 206 | threadId: ThreadId, |
| 211 | draftThread: DraftThreadState, | 207 | draftThread: DraftThreadState, |
| 212 | draftTerminalState: DraftThreadTerminalState, | 208 | draftTerminalState: ThreadTerminalState, |
| 213 | fallbackModel: string, | 209 | fallbackModel: string, |
| 214 | error: string | null, | 210 | error: string | null, |
| 215 | ): Thread { | 211 | ): Thread { |
| ... | @@ -239,7 +235,7 @@ function buildLocalDraftThread( | ... | @@ -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 | if ( | 239 | if ( |
| 244 | thread.terminalOpen !== terminalState.terminalOpen || | 240 | thread.terminalOpen !== terminalState.terminalOpen || |
| 245 | thread.terminalHeight !== terminalState.terminalHeight || | 241 | thread.terminalHeight !== terminalState.terminalHeight || |
| ... | @@ -547,9 +543,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -547,9 +543,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 547 | const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< | 543 | const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< |
| 548 | Record<ThreadId, string | null> | 544 | Record<ThreadId, string | null> |
| 549 | >({}); | 545 | >({}); |
| 550 | const [draftTerminalStateByThreadId, setDraftTerminalStateByThreadId] = useState< | ||
| 551 | Record<ThreadId, DraftThreadTerminalState> | ||
| 552 | >({}); | ||
| 553 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); | 546 | const [sendPhase, setSendPhase] = useState<SendPhase>("idle"); |
| 554 | const [isConnecting, _setIsConnecting] = useState(false); | 547 | const [isConnecting, _setIsConnecting] = useState(false); |
| 555 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); | 548 | const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); |
| ... | @@ -585,24 +578,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -585,24 +578,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 585 | setMessagesScrollElement(element); | 578 | setMessagesScrollElement(element); |
| 586 | }, []); | 579 | }, []); |
| 587 | 580 | ||
| 588 | const updateDraftTerminalState = useCallback( | 581 | const setDraftThreadTerminalAction = useComposerDraftStore((s) => s.setDraftThreadTerminalAction); |
| 589 | ( | 582 | const clearDraftThreadTerminalState = useComposerDraftStore( |
| 590 | targetThreadId: ThreadId, | 583 | (s) => s.clearDraftThreadTerminalState, |
| 591 | updater: (state: DraftThreadTerminalState) => DraftThreadTerminalState, | 584 | ); |
| 592 | ) => { | 585 | const localDraftTerminalState = useComposerDraftStore((s) => |
| 593 | setDraftTerminalStateByThreadId((existing) => { | 586 | s.getDraftThreadTerminalState(threadId), |
| 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 | ); | 587 | ); |
| 607 | 588 | ||
| 608 | const setPrompt = useCallback( | 589 | const setPrompt = useCallback( |
| ... | @@ -637,14 +618,13 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -637,14 +618,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 637 | ); | 618 | ); |
| 638 | 619 | ||
| 639 | const localDraftError = localDraftErrorsByThreadId[threadId] ?? null; | 620 | const localDraftError = localDraftErrorsByThreadId[threadId] ?? null; |
| 640 | const localDraftTerminalState = draftTerminalStateByThreadId[threadId]; | ||
| 641 | const localDraftThread = useMemo( | 621 | const localDraftThread = useMemo( |
| 642 | () => | 622 | () => |
| 643 | draftThread | 623 | draftThread |
| 644 | ? buildLocalDraftThread( | 624 | ? buildLocalDraftThread( |
| 645 | threadId, | 625 | threadId, |
| 646 | draftThread, | 626 | draftThread, |
| 647 | localDraftTerminalState ?? createDefaultDraftThreadTerminalState(), | 627 | localDraftTerminalState, |
| 648 | fallbackDraftProject?.model ?? DEFAULT_MODEL, | 628 | fallbackDraftProject?.model ?? DEFAULT_MODEL, |
| 649 | localDraftError, | 629 | localDraftError, |
| 650 | ) | 630 | ) |
| ... | @@ -681,25 +661,23 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -681,25 +661,23 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 681 | }, [serverThread, threadId]); | 661 | }, [serverThread, threadId]); |
| 682 | 662 | ||
| 683 | useEffect(() => { | 663 | useEffect(() => { |
| 684 | if (!serverThread || !localDraftTerminalState) { | 664 | if (!serverThread) { |
| 685 | return; | 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 | dispatch({ | 673 | dispatch({ |
| 689 | type: "HYDRATE_THREAD_TERMINALS", | 674 | type: "HYDRATE_THREAD_TERMINALS", |
| 690 | threadId: serverThread.id, | 675 | threadId: serverThread.id, |
| 691 | terminalState: localDraftTerminalState, | 676 | terminalState: draftTerminal, |
| 692 | }); | 677 | }); |
| 693 | return; | ||
| 694 | } | 678 | } |
| 695 | setDraftTerminalStateByThreadId((existing) => { | 679 | clearDraftThreadTerminalState(threadId); |
| 696 | if (!(threadId in existing)) { | 680 | }, [clearDraftThreadTerminalState, dispatch, serverThread, threadId]); |
| 697 | return existing; | ||
| 698 | } | ||
| 699 | const { [threadId]: _removed, ...rest } = existing; | ||
| 700 | return rest as Record<ThreadId, DraftThreadTerminalState>; | ||
| 701 | }); | ||
| 702 | }, [dispatch, localDraftTerminalState, serverThread, threadId]); | ||
| 703 | 681 | ||
| 704 | useEffect(() => { | 682 | useEffect(() => { |
| 705 | if (!activeThread?.id) return; | 683 | if (!activeThread?.id) return; |
| ... | @@ -1108,12 +1086,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1108,12 +1086,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1108 | (open: boolean) => { | 1086 | (open: boolean) => { |
| 1109 | if (!activeThreadId) return; | 1087 | if (!activeThreadId) return; |
| 1110 | if (isLocalDraftThread) { | 1088 | if (isLocalDraftThread) { |
| 1111 | updateDraftTerminalState(activeThreadId, (draftState) => | 1089 | setDraftThreadTerminalAction(activeThreadId, { type: "set-open", open }); |
| 1112 | reduceDraftThreadTerminalState(draftState, { | ||
| 1113 | type: "set-open", | ||
| 1114 | open, | ||
| 1115 | }), | ||
| 1116 | ); | ||
| 1117 | return; | 1090 | return; |
| 1118 | } | 1091 | } |
| 1119 | dispatch({ | 1092 | dispatch({ |
| ... | @@ -1122,18 +1095,13 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1122,18 +1095,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1122 | open, | 1095 | open, |
| 1123 | }); | 1096 | }); |
| 1124 | }, | 1097 | }, |
| 1125 | [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], | 1098 | [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction], |
| 1126 | ); | 1099 | ); |
| 1127 | const setTerminalHeight = useCallback( | 1100 | const setTerminalHeight = useCallback( |
| 1128 | (height: number) => { | 1101 | (height: number) => { |
| 1129 | if (!activeThreadId) return; | 1102 | if (!activeThreadId) return; |
| 1130 | if (isLocalDraftThread) { | 1103 | if (isLocalDraftThread) { |
| 1131 | updateDraftTerminalState(activeThreadId, (draftState) => | 1104 | setDraftThreadTerminalAction(activeThreadId, { type: "set-height", height }); |
| 1132 | reduceDraftThreadTerminalState(draftState, { | ||
| 1133 | type: "set-height", | ||
| 1134 | height, | ||
| 1135 | }), | ||
| 1136 | ); | ||
| 1137 | return; | 1105 | return; |
| 1138 | } | 1106 | } |
| 1139 | dispatch({ | 1107 | dispatch({ |
| ... | @@ -1142,7 +1110,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1142,7 +1110,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1142 | height, | 1110 | height, |
| 1143 | }); | 1111 | }); |
| 1144 | }, | 1112 | }, |
| 1145 | [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], | 1113 | [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction], |
| 1146 | ); | 1114 | ); |
| 1147 | const toggleTerminalVisibility = useCallback(() => { | 1115 | const toggleTerminalVisibility = useCallback(() => { |
| 1148 | if (!activeThreadId) return; | 1116 | if (!activeThreadId) return; |
| ... | @@ -1152,12 +1120,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1152,12 +1120,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1152 | if (!activeThreadId || hasReachedTerminalLimit) return; | 1120 | if (!activeThreadId || hasReachedTerminalLimit) return; |
| 1153 | const terminalId = `terminal-${crypto.randomUUID()}`; | 1121 | const terminalId = `terminal-${crypto.randomUUID()}`; |
| 1154 | if (isLocalDraftThread) { | 1122 | if (isLocalDraftThread) { |
| 1155 | updateDraftTerminalState(activeThreadId, (draftState) => | 1123 | setDraftThreadTerminalAction(activeThreadId, { type: "split", terminalId }); |
| 1156 | reduceDraftThreadTerminalState(draftState, { | ||
| 1157 | type: "split", | ||
| 1158 | terminalId, | ||
| 1159 | }), | ||
| 1160 | ); | ||
| 1161 | } else { | 1124 | } else { |
| 1162 | dispatch({ | 1125 | dispatch({ |
| 1163 | type: "SPLIT_THREAD_TERMINAL", | 1126 | type: "SPLIT_THREAD_TERMINAL", |
| ... | @@ -1166,17 +1129,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1166,17 +1129,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1166 | }); | 1129 | }); |
| 1167 | } | 1130 | } |
| 1168 | setTerminalFocusRequestId((value) => value + 1); | 1131 | setTerminalFocusRequestId((value) => value + 1); |
| 1169 | }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, updateDraftTerminalState]); | 1132 | }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, setDraftThreadTerminalAction]); |
| 1170 | const createNewTerminal = useCallback(() => { | 1133 | const createNewTerminal = useCallback(() => { |
| 1171 | if (!activeThreadId || hasReachedTerminalLimit) return; | 1134 | if (!activeThreadId || hasReachedTerminalLimit) return; |
| 1172 | const terminalId = `terminal-${crypto.randomUUID()}`; | 1135 | const terminalId = `terminal-${crypto.randomUUID()}`; |
| 1173 | if (isLocalDraftThread) { | 1136 | if (isLocalDraftThread) { |
| 1174 | updateDraftTerminalState(activeThreadId, (draftState) => | 1137 | setDraftThreadTerminalAction(activeThreadId, { type: "new", terminalId }); |
| 1175 | reduceDraftThreadTerminalState(draftState, { | ||
| 1176 | type: "new", | ||
| 1177 | terminalId, | ||
| 1178 | }), | ||
| 1179 | ); | ||
| 1180 | } else { | 1138 | } else { |
| 1181 | dispatch({ | 1139 | dispatch({ |
| 1182 | type: "NEW_THREAD_TERMINAL", | 1140 | type: "NEW_THREAD_TERMINAL", |
| ... | @@ -1185,17 +1143,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1185,17 +1143,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1185 | }); | 1143 | }); |
| 1186 | } | 1144 | } |
| 1187 | setTerminalFocusRequestId((value) => value + 1); | 1145 | setTerminalFocusRequestId((value) => value + 1); |
| 1188 | }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, updateDraftTerminalState]); | 1146 | }, [activeThreadId, dispatch, hasReachedTerminalLimit, isLocalDraftThread, setDraftThreadTerminalAction]); |
| 1189 | const activateTerminal = useCallback( | 1147 | const activateTerminal = useCallback( |
| 1190 | (terminalId: string) => { | 1148 | (terminalId: string) => { |
| 1191 | if (!activeThreadId) return; | 1149 | if (!activeThreadId) return; |
| 1192 | if (isLocalDraftThread) { | 1150 | if (isLocalDraftThread) { |
| 1193 | updateDraftTerminalState(activeThreadId, (draftState) => | 1151 | setDraftThreadTerminalAction(activeThreadId, { type: "set-active", terminalId }); |
| 1194 | reduceDraftThreadTerminalState(draftState, { | ||
| 1195 | type: "set-active", | ||
| 1196 | terminalId, | ||
| 1197 | }), | ||
| 1198 | ); | ||
| 1199 | } else { | 1152 | } else { |
| 1200 | dispatch({ | 1153 | dispatch({ |
| 1201 | type: "SET_THREAD_ACTIVE_TERMINAL", | 1154 | type: "SET_THREAD_ACTIVE_TERMINAL", |
| ... | @@ -1205,7 +1158,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1205,7 +1158,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1205 | } | 1158 | } |
| 1206 | setTerminalFocusRequestId((value) => value + 1); | 1159 | setTerminalFocusRequestId((value) => value + 1); |
| 1207 | }, | 1160 | }, |
| 1208 | [activeThreadId, dispatch, isLocalDraftThread, updateDraftTerminalState], | 1161 | [activeThreadId, dispatch, isLocalDraftThread, setDraftThreadTerminalAction], |
| 1209 | ); | 1162 | ); |
| 1210 | const closeTerminal = useCallback( | 1163 | const closeTerminal = useCallback( |
| 1211 | (terminalId: string) => { | 1164 | (terminalId: string) => { |
| ... | @@ -1229,12 +1182,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1229,12 +1182,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1229 | void fallbackExitWrite(); | 1182 | void fallbackExitWrite(); |
| 1230 | } | 1183 | } |
| 1231 | if (isLocalDraftThread) { | 1184 | if (isLocalDraftThread) { |
| 1232 | updateDraftTerminalState(activeThreadId, (draftState) => | 1185 | setDraftThreadTerminalAction(activeThreadId, { type: "close", terminalId }); |
| 1233 | reduceDraftThreadTerminalState(draftState, { | ||
| 1234 | type: "close", | ||
| 1235 | terminalId, | ||
| 1236 | }), | ||
| 1237 | ); | ||
| 1238 | } else { | 1186 | } else { |
| 1239 | dispatch({ | 1187 | dispatch({ |
| 1240 | type: "CLOSE_THREAD_TERMINAL", | 1188 | type: "CLOSE_THREAD_TERMINAL", |
| ... | @@ -1244,7 +1192,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1244,7 +1192,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1244 | } | 1192 | } |
| 1245 | setTerminalFocusRequestId((value) => value + 1); | 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 | const runProjectScript = useCallback( | 1197 | const runProjectScript = useCallback( |
| 1250 | async ( | 1198 | async ( |
| ... | @@ -1281,12 +1229,10 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1281,12 +1229,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1281 | setTerminalOpen(true); | 1229 | setTerminalOpen(true); |
| 1282 | if (shouldCreateNewTerminal) { | 1230 | if (shouldCreateNewTerminal) { |
| 1283 | if (isLocalDraftThread) { | 1231 | if (isLocalDraftThread) { |
| 1284 | updateDraftTerminalState(activeThreadId, (draftState) => | 1232 | setDraftThreadTerminalAction(activeThreadId, { |
| 1285 | reduceDraftThreadTerminalState(draftState, { | 1233 | type: "new", |
| 1286 | type: "new", | 1234 | terminalId: targetTerminalId, |
| 1287 | terminalId: targetTerminalId, | 1235 | }); |
| 1288 | }), | ||
| 1289 | ); | ||
| 1290 | } else { | 1236 | } else { |
| 1291 | dispatch({ | 1237 | dispatch({ |
| 1292 | type: "NEW_THREAD_TERMINAL", | 1238 | type: "NEW_THREAD_TERMINAL", |
| ... | @@ -1296,12 +1242,10 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1296,12 +1242,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1296 | } | 1242 | } |
| 1297 | } else { | 1243 | } else { |
| 1298 | if (isLocalDraftThread) { | 1244 | if (isLocalDraftThread) { |
| 1299 | updateDraftTerminalState(activeThreadId, (draftState) => | 1245 | setDraftThreadTerminalAction(activeThreadId, { |
| 1300 | reduceDraftThreadTerminalState(draftState, { | 1246 | type: "set-active", |
| 1301 | type: "set-active", | 1247 | terminalId: targetTerminalId, |
| 1302 | terminalId: targetTerminalId, | 1248 | }); |
| 1303 | }), | ||
| 1304 | ); | ||
| 1305 | } else { | 1249 | } else { |
| 1306 | dispatch({ | 1250 | dispatch({ |
| 1307 | type: "SET_THREAD_ACTIVE_TERMINAL", | 1251 | type: "SET_THREAD_ACTIVE_TERMINAL", |
| ... | @@ -1355,7 +1299,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1355,7 +1299,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1355 | isServerThread, | 1299 | isServerThread, |
| 1356 | setTerminalOpen, | 1300 | setTerminalOpen, |
| 1357 | setThreadError, | 1301 | setThreadError, |
| 1358 | updateDraftTerminalState, | 1302 | setDraftThreadTerminalAction, |
| 1359 | ], | 1303 | ], |
| 1360 | ); | 1304 | ); |
| 1361 | const persistProjectScripts = useCallback( | 1305 | const persistProjectScripts = useCallback( |
apps/web/src/composerDraftStore.ts+59| ... | @@ -6,6 +6,13 @@ import { | ... | @@ -6,6 +6,13 @@ import { |
| 6 | normalizeModelSlug, | 6 | normalizeModelSlug, |
| 7 | type ReasoningEffort, | 7 | type ReasoningEffort, |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import { | ||
| 10 | createDefaultThreadTerminalState, | ||
| 11 | getDefaultThreadTerminalState, | ||
| 12 | reduceThreadTerminalState, | ||
| 13 | type ThreadTerminalAction, | ||
| 14 | type ThreadTerminalState, | ||
| 15 | } from "./threadTerminalState"; | ||
| 9 | import type { ChatImageAttachment } from "./types"; | 16 | import type { ChatImageAttachment } from "./types"; |
| 10 | import { create } from "zustand"; | 17 | import { create } from "zustand"; |
| 11 | import { createJSONStorage, persist } from "zustand/middleware"; | 18 | import { createJSONStorage, persist } from "zustand/middleware"; |
| ... | @@ -64,6 +71,8 @@ export interface DraftThreadState { | ... | @@ -64,6 +71,8 @@ export interface DraftThreadState { |
| 64 | branch: string | null; | 71 | branch: string | null; |
| 65 | worktreePath: string | null; | 72 | worktreePath: string | null; |
| 66 | envMode: DraftThreadEnvMode; | 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 | interface ProjectDraftThread extends DraftThreadState { | 78 | interface ProjectDraftThread extends DraftThreadState { |
| ... | @@ -113,6 +122,9 @@ interface ComposerDraftStoreState { | ... | @@ -113,6 +122,9 @@ interface ComposerDraftStoreState { |
| 113 | ) => void; | 122 | ) => void; |
| 114 | clearComposerContent: (threadId: ThreadId) => void; | 123 | clearComposerContent: (threadId: ThreadId) => void; |
| 115 | clearThreadDraft: (threadId: ThreadId) => void; | 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 | const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { | 130 | const EMPTY_PERSISTED_DRAFT_STORE_STATE: PersistedComposerDraftStoreState = { |
| ... | @@ -666,6 +678,53 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -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 | setPrompt: (threadId, prompt) => { | 728 | setPrompt: (threadId, prompt) => { |
| 670 | if (threadId.length === 0) { | 729 | if (threadId.length === 0) { |
| 671 | return; | 730 | return; |
apps/web/src/draftThreadTerminalState.ts+16-328| ... | @@ -1,332 +1,20 @@ | ... | @@ -1,332 +1,20 @@ |
| 1 | import { | 1 | /** |
| 2 | DEFAULT_THREAD_TERMINAL_HEIGHT, | 2 | * Re-exports shared thread terminal state and reducer so draft and persisted |
| 3 | DEFAULT_THREAD_TERMINAL_ID, | 3 | * flows use the same shape and logic. See threadTerminalState.ts. |
| 4 | MAX_THREAD_TERMINAL_COUNT, | 4 | */ |
| 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 | 5 | ||
| 158 | const remainingTerminalIds = state.terminalIds.filter((id) => id !== terminalId); | 6 | import { |
| 159 | if (remainingTerminalIds.length === 0) { | 7 | createDefaultThreadTerminalState, |
| 160 | return createDefaultDraftThreadTerminalState(); | 8 | normalizeThreadTerminalState, |
| 161 | } | 9 | reduceThreadTerminalState, |
| 162 | 10 | type ThreadTerminalAction, | |
| 163 | const closedTerminalIndex = state.terminalIds.indexOf(terminalId); | 11 | type ThreadTerminalState, |
| 164 | const nextActiveTerminalId = | 12 | } from "./threadTerminalState"; |
| 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 | 13 | ||
| 310 | const destinationGroup = terminalGroups[activeGroupIndex]; | 14 | export type DraftThreadTerminalState = ThreadTerminalState; |
| 311 | if (!destinationGroup) { | 15 | export type DraftThreadTerminalAction = ThreadTerminalAction; |
| 312 | return normalized; | ||
| 313 | } | ||
| 314 | 16 | ||
| 315 | if (!destinationGroup.terminalIds.includes(action.terminalId)) { | 17 | export const createDefaultDraftThreadTerminalState = createDefaultThreadTerminalState; |
| 316 | const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); | 18 | export const reduceDraftThreadTerminalState = reduceThreadTerminalState; |
| 317 | if (anchorIndex >= 0) { | ||
| 318 | destinationGroup.terminalIds.splice(anchorIndex + 1, 0, action.terminalId); | ||
| 319 | } else { | ||
| 320 | destinationGroup.terminalIds.push(action.terminalId); | ||
| 321 | } | ||
| 322 | } | ||
| 323 | 19 | ||
| 324 | return normalizeDraftThreadTerminals({ | 20 | export { normalizeThreadTerminalState }; |
| 325 | ...normalized, | ||
| 326 | terminalOpen: true, | ||
| 327 | terminalIds, | ||
| 328 | activeTerminalId: action.terminalId, | ||
| 329 | terminalGroups, | ||
| 330 | activeTerminalGroupId: destinationGroup.id, | ||
| 331 | }); | ||
| 332 | } |
apps/web/src/store.ts+133-270| ... | @@ -10,34 +10,24 @@ import { | ... | @@ -10,34 +10,24 @@ import { |
| 10 | resolveModelSlug, | 10 | resolveModelSlug, |
| 11 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | import { | 12 | import { |
| 13 | DEFAULT_THREAD_TERMINAL_HEIGHT, | 13 | createDefaultThreadTerminalState, |
| 14 | DEFAULT_THREAD_TERMINAL_ID, | 14 | normalizeThreadTerminalState, |
| 15 | reduceThreadTerminalState, | ||
| 16 | type ThreadTerminalState, | ||
| 17 | } from "./threadTerminalState"; | ||
| 18 | import { | ||
| 15 | DEFAULT_RUNTIME_MODE, | 19 | DEFAULT_RUNTIME_MODE, |
| 16 | MAX_THREAD_TERMINAL_COUNT, | ||
| 17 | type ChatMessage, | 20 | type ChatMessage, |
| 18 | type Project, | 21 | type Project, |
| 19 | type RuntimeMode, | 22 | type RuntimeMode, |
| 20 | type Thread, | 23 | type Thread, |
| 21 | type ThreadTerminalGroup, | ||
| 22 | } from "./types"; | 24 | } from "./types"; |
| 23 | 25 | ||
| 24 | // ── Actions ────────────────────────────────────────────────────────── | 26 | // ── Actions ────────────────────────────────────────────────────────── |
| 25 | 27 | ||
| 26 | type Action = | 28 | type Action = |
| 27 | | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } | 29 | | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } |
| 28 | | { | 30 | | { type: "HYDRATE_THREAD_TERMINALS"; threadId: ThreadId; terminalState: ThreadTerminalState } |
| 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 } | 31 | | { type: "MARK_THREAD_VISITED"; threadId: ThreadId; visitedAt?: string } |
| 42 | | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId } | 32 | | { type: "MARK_THREAD_UNREAD"; threadId: ThreadId } |
| 43 | | { type: "TOGGLE_PROJECT"; projectId: Project["id"] } | 33 | | { type: "TOGGLE_PROJECT"; projectId: Project["id"] } |
| ... | @@ -262,210 +252,24 @@ function attachmentPreviewRoutePath(attachmentId: string): string { | ... | @@ -262,210 +252,24 @@ function attachmentPreviewRoutePath(attachmentId: string): string { |
| 262 | return `/attachments/${encodeURIComponent(attachmentId)}`; | 252 | return `/attachments/${encodeURIComponent(attachmentId)}`; |
| 263 | } | 253 | } |
| 264 | 254 | ||
| 265 | function normalizeTerminalIds(terminalIds: string[]): string[] { | 255 | function threadTerminalSlice(thread: Thread): ThreadTerminalState { |
| 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 | |||
| 381 | return { | 256 | return { |
| 382 | ...thread, | 257 | terminalOpen: thread.terminalOpen, |
| 383 | terminalIds, | 258 | terminalHeight: thread.terminalHeight, |
| 384 | runningTerminalIds: normalizeRunningTerminalIds(thread.runningTerminalIds, terminalIds), | 259 | terminalIds: thread.terminalIds, |
| 385 | activeTerminalId, | 260 | runningTerminalIds: thread.runningTerminalIds, |
| 386 | terminalGroups, | 261 | activeTerminalId: thread.activeTerminalId, |
| 387 | activeTerminalGroupId, | 262 | terminalGroups: thread.terminalGroups, |
| 263 | activeTerminalGroupId: thread.activeTerminalGroupId, | ||
| 388 | }; | 264 | }; |
| 389 | } | 265 | } |
| 390 | 266 | ||
| 391 | function closeThreadTerminal(thread: Thread, terminalId: string): Thread { | 267 | function applyTerminalState(thread: Thread, terminal: ThreadTerminalState): Thread { |
| 392 | if (!thread.terminalIds.includes(terminalId)) { | 268 | return { ...thread, ...terminal }; |
| 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 | }); | ||
| 448 | } | 269 | } |
| 449 | 270 | ||
| 450 | function setThreadTerminalActivity( | 271 | function normalizeThreadTerminals(thread: Thread): Thread { |
| 451 | thread: Thread, | 272 | return applyTerminalState(thread, normalizeThreadTerminalState(threadTerminalSlice(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 | }); | ||
| 469 | } | 273 | } |
| 470 | 274 | ||
| 471 | function syncServerReadModelState(state: AppState, readModel: OrchestrationReadModel): AppState { | 275 | function syncServerReadModelState(state: AppState, readModel: OrchestrationReadModel): AppState { |
| ... | @@ -489,10 +293,14 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -489,10 +293,14 @@ export function reducer(state: AppState, action: Action): AppState { |
| 489 | const existingThreadById = new Map( | 293 | const existingThreadById = new Map( |
| 490 | state.threads.map((thread) => [thread.id, thread] as const), | 294 | state.threads.map((thread) => [thread.id, thread] as const), |
| 491 | ); | 295 | ); |
| 296 | const defaultTerminal = createDefaultThreadTerminalState(); | ||
| 492 | const threads = action.readModel.threads | 297 | const threads = action.readModel.threads |
| 493 | .filter((thread) => thread.deletedAt === null) | 298 | .filter((thread) => thread.deletedAt === null) |
| 494 | .map((thread) => { | 299 | .map((thread) => { |
| 495 | const existing = existingThreadById.get(thread.id); | 300 | const existing = existingThreadById.get(thread.id); |
| 301 | const terminalSlice: ThreadTerminalState = existing | ||
| 302 | ? threadTerminalSlice(existing) | ||
| 303 | : defaultTerminal; | ||
| 496 | 304 | ||
| 497 | return normalizeThreadTerminals({ | 305 | return normalizeThreadTerminals({ |
| 498 | id: thread.id, | 306 | id: thread.id, |
| ... | @@ -500,19 +308,7 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -500,19 +308,7 @@ export function reducer(state: AppState, action: Action): AppState { |
| 500 | projectId: thread.projectId, | 308 | projectId: thread.projectId, |
| 501 | title: thread.title, | 309 | title: thread.title, |
| 502 | model: resolveModelSlug(thread.model), | 310 | model: resolveModelSlug(thread.model), |
| 503 | terminalOpen: existing?.terminalOpen ?? false, | 311 | ...terminalSlice, |
| 504 | terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, | ||
| 505 | terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID], | ||
| 506 | runningTerminalIds: existing?.runningTerminalIds ?? [], | ||
| 507 | activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID, | ||
| 508 | terminalGroups: existing?.terminalGroups ?? [ | ||
| 509 | { | ||
| 510 | id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 511 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | ||
| 512 | }, | ||
| 513 | ], | ||
| 514 | activeTerminalGroupId: | ||
| 515 | existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`, | ||
| 516 | session: thread.session | 312 | session: thread.session |
| 517 | ? { | 313 | ? { |
| 518 | sessionId: | 314 | sessionId: |
| ... | @@ -578,16 +374,7 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -578,16 +374,7 @@ export function reducer(state: AppState, action: Action): AppState { |
| 578 | return { | 374 | return { |
| 579 | ...state, | 375 | ...state, |
| 580 | threads: updateThread(state.threads, action.threadId, (thread) => | 376 | threads: updateThread(state.threads, action.threadId, (thread) => |
| 581 | normalizeThreadTerminals({ | 377 | applyTerminalState(thread, normalizeThreadTerminalState(action.terminalState)), |
| 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 | ), | 378 | ), |
| 592 | }; | 379 | }; |
| 593 | 380 | ||
| ... | @@ -648,7 +435,14 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -648,7 +435,14 @@ export function reducer(state: AppState, action: Action): AppState { |
| 648 | return { | 435 | return { |
| 649 | ...state, | 436 | ...state, |
| 650 | threads: updateThread(state.threads, action.threadId, (thread) => | 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,54 +457,123 @@ export function reducer(state: AppState, action: Action): AppState { |
| 663 | case "TOGGLE_THREAD_TERMINAL": | 457 | case "TOGGLE_THREAD_TERMINAL": |
| 664 | return { | 458 | return { |
| 665 | ...state, | 459 | ...state, |
| 666 | threads: updateThread(state.threads, action.threadId, (t) => ({ | 460 | threads: updateThread(state.threads, action.threadId, (thread) => |
| 667 | ...t, | 461 | applyTerminalState( |
| 668 | terminalOpen: !t.terminalOpen, | 462 | thread, |
| 669 | })), | 463 | reduceThreadTerminalState(threadTerminalSlice(thread), { |
| 464 | type: "set-open", | ||
| 465 | open: !thread.terminalOpen, | ||
| 466 | }), | ||
| 467 | ), | ||
| 468 | ), | ||
| 670 | }; | 469 | }; |
| 671 | 470 | ||
| 672 | case "SET_THREAD_TERMINAL_OPEN": | 471 | case "SET_THREAD_TERMINAL_OPEN": |
| 673 | return { | 472 | return { |
| 674 | ...state, | 473 | ...state, |
| 675 | threads: updateThread(state.threads, action.threadId, (t) => ({ | 474 | threads: updateThread(state.threads, action.threadId, (thread) => |
| 676 | ...t, | 475 | applyTerminalState( |
| 677 | terminalOpen: action.open, | 476 | thread, |
| 678 | })), | 477 | reduceThreadTerminalState(threadTerminalSlice(thread), { |
| 478 | type: "set-open", | ||
| 479 | open: action.open, | ||
| 480 | }), | ||
| 481 | ), | ||
| 482 | ), | ||
| 679 | }; | 483 | }; |
| 680 | 484 | ||
| 681 | case "SET_THREAD_TERMINAL_HEIGHT": | 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 | return { | 556 | return { |
| 683 | ...state, | 557 | ...state, |
| 684 | threads: updateThread(state.threads, action.threadId, (t) => ({ | 558 | threads: updateThread(state.threads, action.threadId, (t) => ({ |
| 685 | ...t, | 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 | return { | 565 | return { |
| 692 | ...state, | 566 | ...state, |
| 693 | threads: updateThread(state.threads, action.threadId, (thread) => { | 567 | threads: updateThread(state.threads, action.threadId, (t) => { |
| 694 | const normalizedThread = normalizeThreadTerminals(thread); | 568 | // When the effective cwd changes (worktreePath differs), the old |
| 695 | const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId); | 569 | // session is no longer valid — clear it so ensureSession creates a |
| 696 | if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { | 570 | // new one with the correct cwd on the next message. |
| 697 | return normalizedThread; | 571 | const cwdChanged = t.worktreePath !== action.worktreePath; |
| 698 | } | 572 | return { |
| 699 | const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) | 573 | ...t, |
| 700 | ? normalizedThread.terminalIds | 574 | branch: action.branch, |
| 701 | : [...normalizedThread.terminalIds, action.terminalId]; | 575 | worktreePath: action.worktreePath, |
| 702 | const terminalGroups = normalizedThread.terminalGroups.map((group) => ({ | 576 | ...(cwdChanged ? { session: null } : {}), |
| 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 } : {}), | ||
| 714 | }; | 577 | }; |
| 715 | return normalizedMessage; | 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 | } | ||