| author | |
| committer | |
| log | f84f2797383fc6e586bb6e5d5b2eb18631152fb1 |
| tree | a2c309a7b423cc7453617e5d861686e20715588c |
| parent | 246624ad3cce74f0fc14e8b98eb23b2fc1db585c |
- Replace combined `useStore` state/dispatch wrapper with direct selector/action hooks across chat UI components
- Inline and harden terminal state transitions inside `terminalStateStore` and remove `threadTerminalState`
- Add `terminalStateStore` action tests covering defaults, splits, groups, activity, and close/reset behavior12 files changed, 566 insertions(+), 613 deletions(-)
apps/web/src/components/BranchToolbar.tsx+10-5| ... | ... | @@ -43,8 +43,10 @@ export default function BranchToolbar({ |
| 43 | 43 | envLocked, |
| 44 | 44 | onComposerFocusRequest, |
| 45 | 45 | }: BranchToolbarProps) { |
| 46 | const setThreadBranchAction = useStore((state) => state.setThreadBranch); | |
| 47 | const setThreadErrorAction = useStore((state) => state.setThreadError); | |
| 46 | const threads = useStore((store) => store.threads); | |
| 47 | const projects = useStore((store) => store.projects); | |
| 48 | const setThreadBranchAction = useStore((store) => store.setThreadBranch); | |
| 49 | const setThreadErrorAction = useStore((store) => store.setError); | |
| 48 | 50 | const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); |
| 49 | 51 | const serverThread = useStore((state) => |
| 50 | 52 | state.threads.find((thread) => thread.id === threadId), |
| ... | ... | @@ -59,6 +61,9 @@ export default function BranchToolbar({ |
| 59 | 61 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); |
| 60 | 62 | const [branchQuery, setBranchQuery] = useState(""); |
| 61 | 63 | |
| 64 | const serverThread = threads.find((thread) => thread.id === threadId); | |
| 65 | const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; | |
| 66 | const activeProject = projects.find((project) => project.id === activeProjectId); | |
| 62 | 67 | const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); |
| 63 | 68 | const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; |
| 64 | 69 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; |
| ... | ... | @@ -119,7 +124,7 @@ export default function BranchToolbar({ |
| 119 | 124 | }); |
| 120 | 125 | } |
| 121 | 126 | if (hasServerThread) { |
| 122 | dispatch.setThreadBranch(activeThreadId, syncedBranch, null); | |
| 127 | setThreadBranchAction(activeThreadId, syncedBranch, null); | |
| 123 | 128 | return; |
| 124 | 129 | } |
| 125 | 130 | setDraftThreadContext(threadId, { |
| ... | ... | @@ -142,7 +147,7 @@ export default function BranchToolbar({ |
| 142 | 147 | |
| 143 | 148 | const setThreadError = (error: string | null) => { |
| 144 | 149 | if (!activeThreadId) return; |
| 145 | dispatch.setError(activeThreadId, error); | |
| 150 | setThreadErrorAction(activeThreadId, error); | |
| 146 | 151 | }; |
| 147 | 152 | |
| 148 | 153 | const setThreadBranch = (branch: string | null, worktreePath: string | null) => { |
| ... | ... | @@ -171,7 +176,7 @@ export default function BranchToolbar({ |
| 171 | 176 | }); |
| 172 | 177 | } |
| 173 | 178 | if (hasServerThread) { |
| 174 | dispatch.setThreadBranch(activeThreadId, branch, worktreePath); | |
| 179 | setThreadBranchAction(activeThreadId, branch, worktreePath); | |
| 175 | 180 | return; |
| 176 | 181 | } |
| 177 | 182 | setDraftThreadContext(threadId, { |
apps/web/src/components/ChatView.tsx+32-38| ... | ... | @@ -124,7 +124,7 @@ import { |
| 124 | 124 | useComposerDraftStore, |
| 125 | 125 | useComposerThreadDraft, |
| 126 | 126 | } from "../composerDraftStore"; |
| 127 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | |
| 127 | import { useTerminalStateStore } from "../terminalStateStore"; | |
| 128 | 128 | import { clamp } from "effect/Number"; |
| 129 | 129 | |
| 130 | 130 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| ... | ... | @@ -437,17 +437,13 @@ interface ChatViewProps { |
| 437 | 437 | } |
| 438 | 438 | |
| 439 | 439 | export default function ChatView({ threadId }: ChatViewProps) { |
| 440 | const markThreadVisited = useStore((state) => state.markThreadVisited); | |
| 441 | const setThreadTerminalOpen = useStore((state) => state.setThreadTerminalOpen); | |
| 442 | const splitThreadTerminalAction = useStore((state) => state.splitThreadTerminal); | |
| 443 | const newThreadTerminalAction = useStore((state) => state.newThreadTerminal); | |
| 444 | const setThreadActiveTerminal = useStore((state) => state.setThreadActiveTerminal); | |
| 445 | const closeThreadTerminalAction = useStore((state) => state.closeThreadTerminal); | |
| 446 | const setThreadErrorAction = useStore((state) => state.setThreadError); | |
| 447 | const setThreadBranchAction = useStore((state) => state.setThreadBranch); | |
| 448 | const setRuntimeMode = useStore((state) => state.setRuntimeMode); | |
| 449 | const setThreadTerminalHeight = useStore((state) => state.setThreadTerminalHeight); | |
| 450 | const runtimeMode = useStore((state) => state.runtimeMode); | |
| 440 | const threads = useStore((store) => store.threads); | |
| 441 | const projects = useStore((store) => store.projects); | |
| 442 | const runtimeMode = useStore((store) => store.runtimeMode); | |
| 443 | const markThreadVisited = useStore((store) => store.markThreadVisited); | |
| 444 | const setStoreThreadError = useStore((store) => store.setError); | |
| 445 | const setStoreRuntimeMode = useStore((store) => store.setRuntimeMode); | |
| 446 | const setStoreThreadBranch = useStore((store) => store.setThreadBranch); | |
| 451 | 447 | const { settings } = useAppSettings(); |
| 452 | 448 | const navigate = useNavigate(); |
| 453 | 449 | const rawSearch = useSearch({ |
| ... | ... | @@ -530,9 +526,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 530 | 526 | setMessagesScrollElement(element); |
| 531 | 527 | }, []); |
| 532 | 528 | |
| 533 | const terminalState = useTerminalStateStore((state) => | |
| 534 | selectThreadTerminalState(state.terminalStateByThreadId, threadId), | |
| 535 | ); | |
| 529 | const terminalState = useTerminalStateStore((state) => state.getTerminalState(threadId)); | |
| 536 | 530 | const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); |
| 537 | 531 | const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); |
| 538 | 532 | const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); |
| ... | ... | @@ -571,6 +565,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 571 | 565 | [removeComposerDraftImage, threadId], |
| 572 | 566 | ); |
| 573 | 567 | |
| 568 | const serverThread = threads.find((t) => t.id === threadId); | |
| 569 | const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId); | |
| 574 | 570 | const localDraftError = localDraftErrorsByThreadId[threadId] ?? null; |
| 575 | 571 | const localDraftThread = useMemo( |
| 576 | 572 | () => |
| ... | ... | @@ -595,10 +591,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 595 | 591 | const activeThreadId = activeThread?.id ?? null; |
| 596 | 592 | const activeLatestTurn = activeThread?.latestTurn ?? null; |
| 597 | 593 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| 598 | const activeProjectId = activeThread?.projectId ?? null; | |
| 599 | const activeProject = useStore((state) => | |
| 600 | activeProjectId ? state.projects.find((project) => project.id === activeProjectId) : undefined, | |
| 601 | ); | |
| 594 | const activeProject = projects.find((p) => p.id === activeThread?.projectId); | |
| 602 | 595 | |
| 603 | 596 | useEffect(() => { |
| 604 | 597 | if (!serverThread) { |
| ... | ... | @@ -622,7 +615,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 622 | 615 | const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; |
| 623 | 616 | if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; |
| 624 | 617 | |
| 625 | dispatch.markThreadVisited(activeThread.id); | |
| 618 | markThreadVisited(activeThread.id); | |
| 626 | 619 | }, [ |
| 627 | 620 | activeThread?.id, |
| 628 | 621 | activeThread?.lastVisitedAt, |
| ... | ... | @@ -983,8 +976,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 983 | 976 | const setThreadError = useCallback( |
| 984 | 977 | (targetThreadId: ThreadId | null, error: string | null) => { |
| 985 | 978 | if (!targetThreadId) return; |
| 986 | if (state.threads.some((thread) => thread.id === targetThreadId)) { | |
| 987 | dispatch.setError(targetThreadId, error); | |
| 979 | if (threads.some((thread) => thread.id === targetThreadId)) { | |
| 980 | setStoreThreadError(targetThreadId, error); | |
| 988 | 981 | return; |
| 989 | 982 | } |
| 990 | 983 | setLocalDraftErrorsByThreadId((existing) => { |
| ... | ... | @@ -997,7 +990,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 997 | 990 | }; |
| 998 | 991 | }); |
| 999 | 992 | }, |
| 1000 | [dispatch, state.threads], | |
| 993 | [setStoreThreadError, threads], | |
| 1001 | 994 | ); |
| 1002 | 995 | |
| 1003 | 996 | const focusComposer = useCallback(() => { |
| ... | ... | @@ -1155,7 +1148,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1155 | 1148 | activeProject, |
| 1156 | 1149 | activeThread, |
| 1157 | 1150 | activeThreadId, |
| 1158 | dispatch, | |
| 1159 | 1151 | gitCwd, |
| 1160 | 1152 | isLocalDraftThread, |
| 1161 | 1153 | isServerThread, |
| ... | ... | @@ -1266,15 +1258,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1266 | 1258 | ); |
| 1267 | 1259 | |
| 1268 | 1260 | const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { |
| 1269 | if (mode === state.runtimeMode) return; | |
| 1270 | dispatch.setRuntimeMode(mode); | |
| 1261 | if (mode === runtimeMode) return; | |
| 1262 | setStoreRuntimeMode(mode); | |
| 1271 | 1263 | scheduleComposerFocus(); |
| 1272 | 1264 | const api = readNativeApi(); |
| 1273 | 1265 | if (!api) return; |
| 1274 | 1266 | |
| 1275 | const runningThreadIds = useStore | |
| 1276 | .getState() | |
| 1277 | .threads | |
| 1267 | const runningThreadIds = threads | |
| 1278 | 1268 | .filter((thread) => thread.session !== null && thread.session.status !== "closed") |
| 1279 | 1269 | .map((thread) => thread.id); |
| 1280 | 1270 | |
| ... | ... | @@ -1891,7 +1881,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1891 | 1881 | const shouldCreateWorktree = |
| 1892 | 1882 | isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; |
| 1893 | 1883 | if (shouldCreateWorktree && !activeThread.branch) { |
| 1894 | dispatch.setError(threadIdForSend, "Select a base branch before sending in New worktree mode."); | |
| 1884 | setStoreThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); | |
| 1895 | 1885 | return; |
| 1896 | 1886 | } |
| 1897 | 1887 | |
| ... | ... | @@ -1964,7 +1954,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1964 | 1954 | }); |
| 1965 | 1955 | // Keep local thread state in sync immediately so terminal drawer opens |
| 1966 | 1956 | // with the worktree cwd/env instead of briefly using the project root. |
| 1967 | dispatch.setThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); | |
| 1957 | setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); | |
| 1968 | 1958 | } |
| 1969 | 1959 | } |
| 1970 | 1960 | |
| ... | ... | @@ -2015,7 +2005,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2015 | 2005 | setSendPhase("sending-turn"); |
| 2016 | 2006 | const turnAttachments = await turnAttachmentsPromise; |
| 2017 | 2007 | const approvalPolicy = runtimeMode === "full-access" ? "never" : "on-request"; |
| 2018 | const sandboxMode = runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; | |
| 2008 | const sandboxMode = | |
| 2009 | runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; | |
| 2019 | 2010 | await api.orchestration.dispatchCommand({ |
| 2020 | 2011 | type: "thread.turn.start", |
| 2021 | 2012 | commandId: newCommandId(), |
| ... | ... | @@ -2103,13 +2094,16 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2103 | 2094 | createdAt: new Date().toISOString(), |
| 2104 | 2095 | }); |
| 2105 | 2096 | } catch (err) { |
| 2106 | dispatch.setError(activeThreadId, err instanceof Error ? err.message : "Failed to submit approval decision."); | |
| 2097 | setStoreThreadError( | |
| 2098 | activeThreadId, | |
| 2099 | err instanceof Error ? err.message : "Failed to submit approval decision.", | |
| 2100 | ); | |
| 2107 | 2101 | } finally { |
| 2108 | 2102 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); |
| 2109 | 2103 | } |
| 2110 | 2104 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); |
| 2111 | 2105 | }, |
| 2112 | [activeThreadId, setThreadErrorAction], | |
| 2106 | [activeThreadId, setStoreThreadError], | |
| 2113 | 2107 | ); |
| 2114 | 2108 | |
| 2115 | 2109 | const onModelSelect = useCallback( |
| ... | ... | @@ -3544,12 +3538,12 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 3544 | 3538 | ] satisfies { label: string; Icon: Icon; value: EditorId }[]; |
| 3545 | 3539 | const primaryOption = options.find(({ value }) => value === lastEditor); |
| 3546 | 3540 | |
| 3547 | const activeThread = useStore((state) => | |
| 3548 | activeThreadId ? state.threads.find((thread) => thread.id === activeThreadId) : undefined, | |
| 3541 | const activeThread = useStore((store) => | |
| 3542 | activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, | |
| 3549 | 3543 | ); |
| 3550 | 3544 | const activeProjectId = activeThread?.projectId ?? null; |
| 3551 | const activeProject = useStore((state) => | |
| 3552 | activeProjectId ? state.projects.find((project) => project.id === activeProjectId) : undefined, | |
| 3545 | const activeProject = useStore((store) => | |
| 3546 | activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, | |
| 3553 | 3547 | ); |
| 3554 | 3548 | |
| 3555 | 3549 | const openInEditor = useCallback( |
apps/web/src/components/DiffPanel.tsx+6-7| ... | ... | @@ -166,15 +166,14 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 166 | 166 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), |
| 167 | 167 | }); |
| 168 | 168 | const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); |
| 169 | const { state } = useStore(); | |
| 170 | 169 | const activeThreadId = routeThreadId; |
| 171 | const activeThread = activeThreadId | |
| 172 | ? state.threads.find((thread) => thread.id === activeThreadId) | |
| 173 | : undefined; | |
| 170 | const activeThread = useStore((store) => | |
| 171 | activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, | |
| 172 | ); | |
| 174 | 173 | const activeProjectId = activeThread?.projectId ?? null; |
| 175 | const activeProject = activeProjectId | |
| 176 | ? state.projects.find((project) => project.id === activeProjectId) | |
| 177 | : undefined; | |
| 174 | const activeProject = useStore((store) => | |
| 175 | activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, | |
| 176 | ); | |
| 178 | 177 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; |
| 179 | 178 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 180 | 179 | useTurnDiffSummaries(activeThread); |
apps/web/src/components/Sidebar.tsx+13-13| ... | ... | @@ -21,7 +21,7 @@ import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/ |
| 21 | 21 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 22 | 22 | import { readNativeApi } from "../nativeApi"; |
| 23 | 23 | import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; |
| 24 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | |
| 24 | import { useTerminalStateStore } from "../terminalStateStore"; | |
| 25 | 25 | import { toastManager } from "./ui/toast"; |
| 26 | 26 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; |
| 27 | 27 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| ... | ... | @@ -231,16 +231,16 @@ function ProjectFavicon({ cwd }: { cwd: string }) { |
| 231 | 231 | } |
| 232 | 232 | |
| 233 | 233 | export default function Sidebar() { |
| 234 | const projects = useStore((state) => state.projects); | |
| 235 | const threads = useStore((state) => state.threads); | |
| 236 | const markThreadUnread = useStore((state) => state.markThreadUnread); | |
| 237 | const toggleProject = useStore((state) => state.toggleProject); | |
| 234 | const projects = useStore((store) => store.projects); | |
| 235 | const threads = useStore((store) => store.threads); | |
| 236 | const markThreadUnread = useStore((store) => store.markThreadUnread); | |
| 237 | const toggleProject = useStore((store) => store.toggleProject); | |
| 238 | 238 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); |
| 239 | 239 | const getDraftThreadByProjectId = useComposerDraftStore( |
| 240 | 240 | (store) => store.getDraftThreadByProjectId, |
| 241 | 241 | ); |
| 242 | 242 | const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); |
| 243 | const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); | |
| 243 | const getTerminalState = useTerminalStateStore((state) => state.getTerminalState); | |
| 244 | 244 | const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); |
| 245 | 245 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 246 | 246 | const clearProjectDraftThreadId = useComposerDraftStore( |
| ... | ... | @@ -445,7 +445,8 @@ export default function Sidebar() { |
| 445 | 445 | const api = readNativeApi(); |
| 446 | 446 | |
| 447 | 447 | setIsAddingProject(true); |
| 448 | if (api) { | |
| 448 | try { | |
| 449 | if (!api) return; | |
| 449 | 450 | const existing = projects.find((project) => project.cwd === cwd); |
| 450 | 451 | if (existing) { |
| 451 | 452 | focusMostRecentThreadForProject(existing.id); |
| ... | ... | @@ -509,7 +510,7 @@ export default function Sidebar() { |
| 509 | 510 | position, |
| 510 | 511 | ); |
| 511 | 512 | if (clicked === "mark-unread") { |
| 512 | dispatch.markThreadUnread(threadId); | |
| 513 | markThreadUnread(threadId); | |
| 513 | 514 | return; |
| 514 | 515 | } |
| 515 | 516 | if (clicked !== "delete") return; |
| ... | ... | @@ -616,9 +617,9 @@ export default function Sidebar() { |
| 616 | 617 | clearProjectDraftThreadById, |
| 617 | 618 | markThreadUnread, |
| 618 | 619 | navigate, |
| 620 | projects, | |
| 619 | 621 | removeWorktreeMutation, |
| 620 | 622 | routeThreadId, |
| 621 | projects, | |
| 622 | 623 | threads, |
| 623 | 624 | ], |
| 624 | 625 | ); |
| ... | ... | @@ -709,7 +710,7 @@ export default function Sidebar() { |
| 709 | 710 | return () => { |
| 710 | 711 | window.removeEventListener("keydown", onWindowKeyDown); |
| 711 | 712 | }; |
| 712 | }, [getDraftThread, handleNewThread, keybindings, routeThreadId, projects, threads]); | |
| 713 | }, [getDraftThread, handleNewThread, keybindings, projects, routeThreadId, threads]); | |
| 713 | 714 | |
| 714 | 715 | const onCreateThreadClick = () => { |
| 715 | 716 | if (projects.length === 0) { |
| ... | ... | @@ -781,7 +782,7 @@ export default function Sidebar() { |
| 781 | 782 | open={project.expanded} |
| 782 | 783 | onOpenChange={(open) => { |
| 783 | 784 | if (open === project.expanded) return; |
| 784 | dispatch.toggleProject(project.id); | |
| 785 | toggleProject(project.id); | |
| 785 | 786 | }} |
| 786 | 787 | > |
| 787 | 788 | <SidebarMenuItem> |
| ... | ... | @@ -824,8 +825,7 @@ export default function Sidebar() { |
| 824 | 825 | ); |
| 825 | 826 | const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); |
| 826 | 827 | const terminalStatus = terminalStatusFromRunningIds( |
| 827 | selectThreadTerminalState(terminalStateByThreadId, thread.id) | |
| 828 | .runningTerminalIds, | |
| 828 | getTerminalState(thread.id).runningTerminalIds, | |
| 829 | 829 | ); |
| 830 | 830 | |
| 831 | 831 | return ( |
apps/web/src/routes/__root.tsx+5-6| ... | ... | @@ -124,9 +124,8 @@ function errorDetails(error: unknown): string { |
| 124 | 124 | } |
| 125 | 125 | |
| 126 | 126 | function EventRouter() { |
| 127 | const syncServerReadModel = useStore((state) => state.syncServerReadModel); | |
| 128 | const setThreadTerminalActivity = useStore((state) => state.setThreadTerminalActivity); | |
| 129 | const setProjectExpanded = useStore((state) => state.setProjectExpanded); | |
| 127 | const syncServerReadModel = useStore((store) => store.syncServerReadModel); | |
| 128 | const setProjectExpanded = useStore((store) => store.setProjectExpanded); | |
| 130 | 129 | const queryClient = useQueryClient(); |
| 131 | 130 | const navigate = useNavigate(); |
| 132 | 131 | const pathname = useRouterState({ select: (state) => state.location.pathname }); |
| ... | ... | @@ -150,7 +149,7 @@ function EventRouter() { |
| 150 | 149 | const snapshot = await api.orchestration.getSnapshot(); |
| 151 | 150 | if (disposed) return; |
| 152 | 151 | latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); |
| 153 | dispatch.syncServerReadModel(snapshot); | |
| 152 | syncServerReadModel(snapshot); | |
| 154 | 153 | if (pending) { |
| 155 | 154 | pending = false; |
| 156 | 155 | await flushSnapshotSync(); |
| ... | ... | @@ -205,7 +204,7 @@ function EventRouter() { |
| 205 | 204 | if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { |
| 206 | 205 | return; |
| 207 | 206 | } |
| 208 | dispatch.setProjectExpanded(payload.bootstrapProjectId, true); | |
| 207 | setProjectExpanded(payload.bootstrapProjectId, true); | |
| 209 | 208 | |
| 210 | 209 | if (pathnameRef.current !== "/") { |
| 211 | 210 | return; |
| ... | ... | @@ -270,7 +269,7 @@ function EventRouter() { |
| 270 | 269 | unsubWelcome(); |
| 271 | 270 | unsubServerConfigUpdated(); |
| 272 | 271 | }; |
| 273 | }, [navigate, queryClient, setProjectExpanded, setThreadTerminalActivity, syncServerReadModel]); | |
| 272 | }, [navigate, queryClient, setProjectExpanded, syncServerReadModel]); | |
| 274 | 273 | |
| 275 | 274 | return null; |
| 276 | 275 | } |
apps/web/src/routes/_chat.$threadId.tsx+2-3| ... | ... | @@ -133,14 +133,13 @@ const DiffPanelInlineSidebar = (props: { |
| 133 | 133 | }; |
| 134 | 134 | |
| 135 | 135 | function ChatThreadRouteView() { |
| 136 | const { state } = useStore(); | |
| 137 | const threadsHydrated = state.threadsHydrated; | |
| 136 | const threadsHydrated = useStore((store) => store.threadsHydrated); | |
| 138 | 137 | const navigate = useNavigate(); |
| 139 | 138 | const threadId = Route.useParams({ |
| 140 | 139 | select: (params) => ThreadId.makeUnsafe(params.threadId), |
| 141 | 140 | }); |
| 142 | 141 | const search = Route.useSearch(); |
| 143 | const threadExists = state.threads.some((thread) => thread.id === threadId); | |
| 142 | const threadExists = useStore((store) => store.threads.some((thread) => thread.id === threadId)); | |
| 144 | 143 | const draftThreadExists = useComposerDraftStore( |
| 145 | 144 | (store) => Object.hasOwn(store.draftThreadsByThreadId, threadId), |
| 146 | 145 | ); |
apps/web/src/routes/_chat.settings.tsx+3-3| ... | ... | @@ -48,8 +48,8 @@ const RUNTIME_MODE_OPTIONS = [ |
| 48 | 48 | |
| 49 | 49 | function SettingsRouteView() { |
| 50 | 50 | const { theme, setTheme, resolvedTheme } = useTheme(); |
| 51 | const runtimeMode = useStore((state) => state.runtimeMode); | |
| 52 | const setRuntimeMode = useStore((state) => state.setRuntimeMode); | |
| 51 | const runtimeMode = useStore((store) => store.runtimeMode); | |
| 52 | const setRuntimeMode = useStore((store) => store.setRuntimeMode); | |
| 53 | 53 | const { settings, defaults, updateSettings } = useAppSettings(); |
| 54 | 54 | const serverConfigQuery = useQuery(serverConfigQueryOptions()); |
| 55 | 55 | const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); |
| ... | ... | @@ -349,7 +349,7 @@ function SettingsRouteView() { |
| 349 | 349 | : "border-border bg-background text-muted-foreground hover:bg-accent" |
| 350 | 350 | }`} |
| 351 | 351 | onClick={() => { |
| 352 | dispatch.setRuntimeMode(option.value); | |
| 352 | setRuntimeMode(option.value); | |
| 353 | 353 | }} |
| 354 | 354 | > |
| 355 | 355 | <span className="flex flex-col"> |
apps/web/src/store.ts+3-23| ... | ... | @@ -400,7 +400,7 @@ interface AppStore extends AppState { |
| 400 | 400 | setRuntimeMode: (mode: RuntimeMode) => void; |
| 401 | 401 | } |
| 402 | 402 | |
| 403 | export const useAppStore = create<AppStore>((set, _get) => ({ | |
| 403 | export const useStore = create<AppStore>((set, _get) => ({ | |
| 404 | 404 | ...readPersistedState(), |
| 405 | 405 | syncServerReadModel: (readModel) => |
| 406 | 406 | set((state) => syncServerReadModel(state, readModel)), |
| ... | ... | @@ -421,31 +421,11 @@ export const useAppStore = create<AppStore>((set, _get) => ({ |
| 421 | 421 | })); |
| 422 | 422 | |
| 423 | 423 | // Persist on every state change (only runtimeMode + expandedProjectCwds) |
| 424 | useAppStore.subscribe((state) => persistState(state)); | |
| 425 | ||
| 426 | // ── useStore (state + store API for call sites that want both) ──────────────── | |
| 424 | useStore.subscribe((state) => persistState(state)); | |
| 427 | 425 | |
| 428 | 426 | export function StoreProvider({ children }: { children: ReactNode }) { |
| 429 | 427 | useEffect(() => { |
| 430 | persistState(useAppStore.getState()); | |
| 428 | persistState(useStore.getState()); | |
| 431 | 429 | }, []); |
| 432 | 430 | return createElement(Fragment, null, children); |
| 433 | 431 | } |
| 434 | ||
| 435 | type UseStoreCombinedResult = { | |
| 436 | state: Pick<AppStore, "projects" | "threads" | "threadsHydrated" | "runtimeMode">; | |
| 437 | dispatch: AppStore; | |
| 438 | }; | |
| 439 | ||
| 440 | export function useStore(): UseStoreCombinedResult { | |
| 441 | const state = useAppStore((s) => ({ | |
| 442 | projects: s.projects, | |
| 443 | threads: s.threads, | |
| 444 | threadsHydrated: s.threadsHydrated, | |
| 445 | runtimeMode: s.runtimeMode, | |
| 446 | })); | |
| 447 | return { | |
| 448 | state, | |
| 449 | dispatch: useAppStore.getState(), | |
| 450 | }; | |
| 451 | } |
apps/web/src/terminalStateStore.test.ts created+89| ... | ... | @@ -0,0 +1,89 @@ |
| 1 | import { ThreadId } from "@t3tools/contracts"; | |
| 2 | import { beforeEach, describe, expect, it } from "vitest"; | |
| 3 | ||
| 4 | import { useTerminalStateStore } from "./terminalStateStore"; | |
| 5 | ||
| 6 | const THREAD_ID = ThreadId.makeUnsafe("thread-1"); | |
| 7 | ||
| 8 | describe("terminalStateStore actions", () => { | |
| 9 | beforeEach(() => { | |
| 10 | if (typeof localStorage !== "undefined") { | |
| 11 | localStorage.clear(); | |
| 12 | } | |
| 13 | useTerminalStateStore.setState({ terminalStateByThreadId: {} }); | |
| 14 | }); | |
| 15 | ||
| 16 | it("returns a closed default terminal state for unknown threads", () => { | |
| 17 | const terminalState = useTerminalStateStore.getState().getTerminalState(THREAD_ID); | |
| 18 | expect(terminalState).toEqual({ | |
| 19 | terminalOpen: false, | |
| 20 | terminalHeight: 280, | |
| 21 | terminalIds: ["default"], | |
| 22 | runningTerminalIds: [], | |
| 23 | activeTerminalId: "default", | |
| 24 | terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], | |
| 25 | activeTerminalGroupId: "group-default", | |
| 26 | }); | |
| 27 | }); | |
| 28 | ||
| 29 | it("opens and splits terminals into the active group", () => { | |
| 30 | const store = useTerminalStateStore.getState(); | |
| 31 | store.setTerminalOpen(THREAD_ID, true); | |
| 32 | store.splitTerminal(THREAD_ID, "terminal-2"); | |
| 33 | ||
| 34 | const terminalState = useTerminalStateStore.getState().getTerminalState(THREAD_ID); | |
| 35 | expect(terminalState.terminalOpen).toBe(true); | |
| 36 | expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); | |
| 37 | expect(terminalState.activeTerminalId).toBe("terminal-2"); | |
| 38 | expect(terminalState.terminalGroups).toEqual([ | |
| 39 | { id: "group-default", terminalIds: ["default", "terminal-2"] }, | |
| 40 | ]); | |
| 41 | }); | |
| 42 | ||
| 43 | it("creates new terminals in a separate group", () => { | |
| 44 | useTerminalStateStore.getState().newTerminal(THREAD_ID, "terminal-2"); | |
| 45 | ||
| 46 | const terminalState = useTerminalStateStore.getState().getTerminalState(THREAD_ID); | |
| 47 | expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); | |
| 48 | expect(terminalState.activeTerminalId).toBe("terminal-2"); | |
| 49 | expect(terminalState.activeTerminalGroupId).toBe("group-terminal-2"); | |
| 50 | expect(terminalState.terminalGroups).toEqual([ | |
| 51 | { id: "group-default", terminalIds: ["default"] }, | |
| 52 | { id: "group-terminal-2", terminalIds: ["terminal-2"] }, | |
| 53 | ]); | |
| 54 | }); | |
| 55 | ||
| 56 | it("tracks and clears terminal subprocess activity", () => { | |
| 57 | const store = useTerminalStateStore.getState(); | |
| 58 | store.splitTerminal(THREAD_ID, "terminal-2"); | |
| 59 | store.setTerminalActivity(THREAD_ID, "terminal-2", true); | |
| 60 | expect(useTerminalStateStore.getState().getTerminalState(THREAD_ID).runningTerminalIds).toEqual([ | |
| 61 | "terminal-2", | |
| 62 | ]); | |
| 63 | ||
| 64 | store.setTerminalActivity(THREAD_ID, "terminal-2", false); | |
| 65 | expect(useTerminalStateStore.getState().getTerminalState(THREAD_ID).runningTerminalIds).toEqual([]); | |
| 66 | }); | |
| 67 | ||
| 68 | it("resets to default and clears persisted entry when closing the last terminal", () => { | |
| 69 | const store = useTerminalStateStore.getState(); | |
| 70 | store.closeTerminal(THREAD_ID, "default"); | |
| 71 | ||
| 72 | expect(useTerminalStateStore.getState().terminalStateByThreadId[THREAD_ID]).toBeUndefined(); | |
| 73 | expect(useTerminalStateStore.getState().getTerminalState(THREAD_ID).terminalIds).toEqual(["default"]); | |
| 74 | }); | |
| 75 | ||
| 76 | it("keeps a valid active terminal after closing an active split terminal", () => { | |
| 77 | const store = useTerminalStateStore.getState(); | |
| 78 | store.splitTerminal(THREAD_ID, "terminal-2"); | |
| 79 | store.splitTerminal(THREAD_ID, "terminal-3"); | |
| 80 | store.closeTerminal(THREAD_ID, "terminal-3"); | |
| 81 | ||
| 82 | const terminalState = useTerminalStateStore.getState().getTerminalState(THREAD_ID); | |
| 83 | expect(terminalState.activeTerminalId).toBe("terminal-2"); | |
| 84 | expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); | |
| 85 | expect(terminalState.terminalGroups).toEqual([ | |
| 86 | { id: "group-default", terminalIds: ["default", "terminal-2"] }, | |
| 87 | ]); | |
| 88 | }); | |
| 89 | }); |
apps/web/src/terminalStateStore.ts+403-18| ... | ... | @@ -1,27 +1,409 @@ |
| 1 | 1 | /** |
| 2 | * Single Zustand store for terminal UI state keyed by threadId. Used by both | |
| 3 | * persisted (server) threads and draft threads; threadId is stable for both. | |
| 2 | * Single Zustand store for terminal UI state keyed by threadId. | |
| 3 | * | |
| 4 | * Terminal transition helpers are intentionally private to keep the public | |
| 5 | * API constrained to store actions/selectors. | |
| 4 | 6 | */ |
| 5 | 7 | |
| 6 | 8 | import type { ThreadId } from "@t3tools/contracts"; |
| 7 | 9 | import { create } from "zustand"; |
| 8 | 10 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 9 | 11 | import { |
| 10 | closeThreadTerminal, | |
| 11 | getDefaultThreadTerminalState, | |
| 12 | isDefaultThreadTerminalState, | |
| 13 | newThreadTerminal, | |
| 14 | setThreadActiveTerminal, | |
| 15 | setThreadTerminalActivity, | |
| 16 | setThreadTerminalHeight, | |
| 17 | setThreadTerminalOpen, | |
| 18 | splitThreadTerminal, | |
| 19 | type ThreadTerminalState, | |
| 20 | } from "./threadTerminalState"; | |
| 21 | ||
| 22 | export const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; | |
| 23 | ||
| 24 | export function selectThreadTerminalState( | |
| 12 | DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 13 | DEFAULT_THREAD_TERMINAL_ID, | |
| 14 | MAX_THREAD_TERMINAL_COUNT, | |
| 15 | type ThreadTerminalGroup, | |
| 16 | } from "./types"; | |
| 17 | ||
| 18 | interface ThreadTerminalState { | |
| 19 | terminalOpen: boolean; | |
| 20 | terminalHeight: number; | |
| 21 | terminalIds: string[]; | |
| 22 | runningTerminalIds: string[]; | |
| 23 | activeTerminalId: string; | |
| 24 | terminalGroups: ThreadTerminalGroup[]; | |
| 25 | activeTerminalGroupId: string; | |
| 26 | } | |
| 27 | ||
| 28 | const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; | |
| 29 | ||
| 30 | function normalizeTerminalIds(terminalIds: string[]): string[] { | |
| 31 | const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))].slice( | |
| 32 | 0, | |
| 33 | MAX_THREAD_TERMINAL_COUNT, | |
| 34 | ); | |
| 35 | return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID]; | |
| 36 | } | |
| 37 | ||
| 38 | function normalizeRunningTerminalIds(runningTerminalIds: string[], terminalIds: string[]): string[] { | |
| 39 | if (runningTerminalIds.length === 0) return []; | |
| 40 | const validTerminalIdSet = new Set(terminalIds); | |
| 41 | return [...new Set(runningTerminalIds)] | |
| 42 | .map((id) => id.trim()) | |
| 43 | .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); | |
| 44 | } | |
| 45 | ||
| 46 | function fallbackGroupId(terminalId: string): string { | |
| 47 | return `group-${terminalId}`; | |
| 48 | } | |
| 49 | ||
| 50 | function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string { | |
| 51 | let candidate = baseId; | |
| 52 | let index = 2; | |
| 53 | while (usedGroupIds.has(candidate)) { | |
| 54 | candidate = `${baseId}-${index}`; | |
| 55 | index += 1; | |
| 56 | } | |
| 57 | usedGroupIds.add(candidate); | |
| 58 | return candidate; | |
| 59 | } | |
| 60 | ||
| 61 | function findGroupIndexByTerminalId(terminalGroups: ThreadTerminalGroup[], terminalId: string): number { | |
| 62 | return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId)); | |
| 63 | } | |
| 64 | ||
| 65 | function normalizeTerminalGroupIds(terminalIds: string[]): string[] { | |
| 66 | return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; | |
| 67 | } | |
| 68 | ||
| 69 | function normalizeTerminalGroups( | |
| 70 | terminalGroups: ThreadTerminalGroup[], | |
| 71 | terminalIds: string[], | |
| 72 | ): ThreadTerminalGroup[] { | |
| 73 | const validTerminalIdSet = new Set(terminalIds); | |
| 74 | const assignedTerminalIds = new Set<string>(); | |
| 75 | const nextGroups: ThreadTerminalGroup[] = []; | |
| 76 | const usedGroupIds = new Set<string>(); | |
| 77 | ||
| 78 | for (const group of terminalGroups) { | |
| 79 | const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => { | |
| 80 | if (!validTerminalIdSet.has(terminalId)) return false; | |
| 81 | if (assignedTerminalIds.has(terminalId)) return false; | |
| 82 | return true; | |
| 83 | }); | |
| 84 | if (groupTerminalIds.length === 0) continue; | |
| 85 | for (const terminalId of groupTerminalIds) { | |
| 86 | assignedTerminalIds.add(terminalId); | |
| 87 | } | |
| 88 | const baseGroupId = | |
| 89 | group.id.trim().length > 0 | |
| 90 | ? group.id.trim() | |
| 91 | : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | |
| 92 | nextGroups.push({ | |
| 93 | id: assignUniqueGroupId(baseGroupId, usedGroupIds), | |
| 94 | terminalIds: groupTerminalIds, | |
| 95 | }); | |
| 96 | } | |
| 97 | ||
| 98 | for (const terminalId of terminalIds) { | |
| 99 | if (assignedTerminalIds.has(terminalId)) continue; | |
| 100 | nextGroups.push({ | |
| 101 | id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), | |
| 102 | terminalIds: [terminalId], | |
| 103 | }); | |
| 104 | } | |
| 105 | ||
| 106 | if (nextGroups.length === 0) { | |
| 107 | return [ | |
| 108 | { | |
| 109 | id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | |
| 110 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 111 | }, | |
| 112 | ]; | |
| 113 | } | |
| 114 | ||
| 115 | return nextGroups; | |
| 116 | } | |
| 117 | ||
| 118 | function arraysEqual(a: string[], b: string[]): boolean { | |
| 119 | if (a.length !== b.length) return false; | |
| 120 | for (let index = 0; index < a.length; index += 1) { | |
| 121 | if (a[index] !== b[index]) return false; | |
| 122 | } | |
| 123 | return true; | |
| 124 | } | |
| 125 | ||
| 126 | function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalGroup[]): boolean { | |
| 127 | if (left.length !== right.length) return false; | |
| 128 | for (let index = 0; index < left.length; index += 1) { | |
| 129 | const leftGroup = left[index]; | |
| 130 | const rightGroup = right[index]; | |
| 131 | if (!leftGroup || !rightGroup) return false; | |
| 132 | if (leftGroup.id !== rightGroup.id) return false; | |
| 133 | if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false; | |
| 134 | } | |
| 135 | return true; | |
| 136 | } | |
| 137 | ||
| 138 | function threadTerminalStateEqual(left: ThreadTerminalState, right: ThreadTerminalState): boolean { | |
| 139 | return ( | |
| 140 | left.terminalOpen === right.terminalOpen && | |
| 141 | left.terminalHeight === right.terminalHeight && | |
| 142 | left.activeTerminalId === right.activeTerminalId && | |
| 143 | left.activeTerminalGroupId === right.activeTerminalGroupId && | |
| 144 | arraysEqual(left.terminalIds, right.terminalIds) && | |
| 145 | arraysEqual(left.runningTerminalIds, right.runningTerminalIds) && | |
| 146 | terminalGroupsEqual(left.terminalGroups, right.terminalGroups) | |
| 147 | ); | |
| 148 | } | |
| 149 | ||
| 150 | const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({ | |
| 151 | terminalOpen: false, | |
| 152 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 153 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 154 | runningTerminalIds: [], | |
| 155 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 156 | terminalGroups: [ | |
| 157 | { | |
| 158 | id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | |
| 159 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 160 | }, | |
| 161 | ], | |
| 162 | activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | |
| 163 | }); | |
| 164 | ||
| 165 | function createDefaultThreadTerminalState(): ThreadTerminalState { | |
| 166 | return { ...DEFAULT_THREAD_TERMINAL_STATE }; | |
| 167 | } | |
| 168 | ||
| 169 | function getDefaultThreadTerminalState(): ThreadTerminalState { | |
| 170 | return DEFAULT_THREAD_TERMINAL_STATE; | |
| 171 | } | |
| 172 | ||
| 173 | function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTerminalState { | |
| 174 | const terminalIds = normalizeTerminalIds(state.terminalIds); | |
| 175 | const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; | |
| 176 | const runningTerminalIds = normalizeRunningTerminalIds(state.runningTerminalIds, nextTerminalIds); | |
| 177 | const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) | |
| 178 | ? state.activeTerminalId | |
| 179 | : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | |
| 180 | const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds); | |
| 181 | const activeGroupIdFromState = terminalGroups.some((group) => group.id === state.activeTerminalGroupId) | |
| 182 | ? state.activeTerminalGroupId | |
| 183 | : null; | |
| 184 | const activeGroupIdFromTerminal = | |
| 185 | terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null; | |
| 186 | ||
| 187 | const normalized: ThreadTerminalState = { | |
| 188 | terminalOpen: state.terminalOpen, | |
| 189 | terminalHeight: | |
| 190 | Number.isFinite(state.terminalHeight) && state.terminalHeight > 0 | |
| 191 | ? state.terminalHeight | |
| 192 | : DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 193 | terminalIds: nextTerminalIds, | |
| 194 | runningTerminalIds, | |
| 195 | activeTerminalId, | |
| 196 | terminalGroups, | |
| 197 | activeTerminalGroupId: | |
| 198 | activeGroupIdFromState ?? | |
| 199 | activeGroupIdFromTerminal ?? | |
| 200 | terminalGroups[0]?.id ?? | |
| 201 | fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | |
| 202 | }; | |
| 203 | return threadTerminalStateEqual(state, normalized) ? state : normalized; | |
| 204 | } | |
| 205 | ||
| 206 | function isDefaultThreadTerminalState(state: ThreadTerminalState): boolean { | |
| 207 | const normalized = normalizeThreadTerminalState(state); | |
| 208 | return threadTerminalStateEqual(normalized, DEFAULT_THREAD_TERMINAL_STATE); | |
| 209 | } | |
| 210 | ||
| 211 | function isValidTerminalId(terminalId: string): boolean { | |
| 212 | return terminalId.trim().length > 0; | |
| 213 | } | |
| 214 | ||
| 215 | function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { | |
| 216 | return groups.map((group) => ({ | |
| 217 | id: group.id, | |
| 218 | terminalIds: [...group.terminalIds], | |
| 219 | })); | |
| 220 | } | |
| 221 | ||
| 222 | function upsertTerminalIntoGroups( | |
| 223 | state: ThreadTerminalState, | |
| 224 | terminalId: string, | |
| 225 | mode: "split" | "new", | |
| 226 | ): ThreadTerminalState { | |
| 227 | const normalized = normalizeThreadTerminalState(state); | |
| 228 | if (!isValidTerminalId(terminalId)) { | |
| 229 | return normalized; | |
| 230 | } | |
| 231 | ||
| 232 | const isNewTerminal = !normalized.terminalIds.includes(terminalId); | |
| 233 | if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { | |
| 234 | return normalized; | |
| 235 | } | |
| 236 | ||
| 237 | const terminalIds = isNewTerminal ? [...normalized.terminalIds, terminalId] : normalized.terminalIds; | |
| 238 | const terminalGroups = copyTerminalGroups(normalized.terminalGroups); | |
| 239 | ||
| 240 | const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId); | |
| 241 | if (existingGroupIndex >= 0) { | |
| 242 | terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter( | |
| 243 | (id) => id !== terminalId, | |
| 244 | ); | |
| 245 | if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { | |
| 246 | terminalGroups.splice(existingGroupIndex, 1); | |
| 247 | } | |
| 248 | } | |
| 249 | ||
| 250 | if (mode === "new") { | |
| 251 | const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); | |
| 252 | const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds); | |
| 253 | terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] }); | |
| 254 | return normalizeThreadTerminalState({ | |
| 255 | ...normalized, | |
| 256 | terminalOpen: true, | |
| 257 | terminalIds, | |
| 258 | activeTerminalId: terminalId, | |
| 259 | terminalGroups, | |
| 260 | activeTerminalGroupId: nextGroupId, | |
| 261 | }); | |
| 262 | } | |
| 263 | ||
| 264 | let activeGroupIndex = terminalGroups.findIndex((group) => group.id === normalized.activeTerminalGroupId); | |
| 265 | if (activeGroupIndex < 0) { | |
| 266 | activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId); | |
| 267 | } | |
| 268 | if (activeGroupIndex < 0) { | |
| 269 | const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); | |
| 270 | const nextGroupId = assignUniqueGroupId(fallbackGroupId(normalized.activeTerminalId), usedGroupIds); | |
| 271 | terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] }); | |
| 272 | activeGroupIndex = terminalGroups.length - 1; | |
| 273 | } | |
| 274 | ||
| 275 | const destinationGroup = terminalGroups[activeGroupIndex]; | |
| 276 | if (!destinationGroup) { | |
| 277 | return normalized; | |
| 278 | } | |
| 279 | ||
| 280 | if (!destinationGroup.terminalIds.includes(terminalId)) { | |
| 281 | const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); | |
| 282 | if (anchorIndex >= 0) { | |
| 283 | destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId); | |
| 284 | } else { | |
| 285 | destinationGroup.terminalIds.push(terminalId); | |
| 286 | } | |
| 287 | } | |
| 288 | ||
| 289 | return normalizeThreadTerminalState({ | |
| 290 | ...normalized, | |
| 291 | terminalOpen: true, | |
| 292 | terminalIds, | |
| 293 | activeTerminalId: terminalId, | |
| 294 | terminalGroups, | |
| 295 | activeTerminalGroupId: destinationGroup.id, | |
| 296 | }); | |
| 297 | } | |
| 298 | ||
| 299 | function setThreadTerminalOpen(state: ThreadTerminalState, open: boolean): ThreadTerminalState { | |
| 300 | const normalized = normalizeThreadTerminalState(state); | |
| 301 | if (normalized.terminalOpen === open) return normalized; | |
| 302 | return { ...normalized, terminalOpen: open }; | |
| 303 | } | |
| 304 | ||
| 305 | function setThreadTerminalHeight(state: ThreadTerminalState, height: number): ThreadTerminalState { | |
| 306 | const normalized = normalizeThreadTerminalState(state); | |
| 307 | if (!Number.isFinite(height) || height <= 0 || normalized.terminalHeight === height) { | |
| 308 | return normalized; | |
| 309 | } | |
| 310 | return { ...normalized, terminalHeight: height }; | |
| 311 | } | |
| 312 | ||
| 313 | function splitThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState { | |
| 314 | return upsertTerminalIntoGroups(state, terminalId, "split"); | |
| 315 | } | |
| 316 | ||
| 317 | function newThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState { | |
| 318 | return upsertTerminalIntoGroups(state, terminalId, "new"); | |
| 319 | } | |
| 320 | ||
| 321 | function setThreadActiveTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState { | |
| 322 | const normalized = normalizeThreadTerminalState(state); | |
| 323 | if (!normalized.terminalIds.includes(terminalId)) { | |
| 324 | return normalized; | |
| 325 | } | |
| 326 | const activeTerminalGroupId = | |
| 327 | normalized.terminalGroups.find((group) => group.terminalIds.includes(terminalId))?.id ?? | |
| 328 | normalized.activeTerminalGroupId; | |
| 329 | if ( | |
| 330 | normalized.activeTerminalId === terminalId && | |
| 331 | normalized.activeTerminalGroupId === activeTerminalGroupId | |
| 332 | ) { | |
| 333 | return normalized; | |
| 334 | } | |
| 335 | return { | |
| 336 | ...normalized, | |
| 337 | activeTerminalId: terminalId, | |
| 338 | activeTerminalGroupId, | |
| 339 | }; | |
| 340 | } | |
| 341 | ||
| 342 | function closeThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState { | |
| 343 | const normalized = normalizeThreadTerminalState(state); | |
| 344 | if (!normalized.terminalIds.includes(terminalId)) { | |
| 345 | return normalized; | |
| 346 | } | |
| 347 | ||
| 348 | const remainingTerminalIds = normalized.terminalIds.filter((id) => id !== terminalId); | |
| 349 | if (remainingTerminalIds.length === 0) { | |
| 350 | return createDefaultThreadTerminalState(); | |
| 351 | } | |
| 352 | ||
| 353 | const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId); | |
| 354 | const nextActiveTerminalId = | |
| 355 | normalized.activeTerminalId === terminalId | |
| 356 | ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? | |
| 357 | remainingTerminalIds[0] ?? | |
| 358 | DEFAULT_THREAD_TERMINAL_ID) | |
| 359 | : normalized.activeTerminalId; | |
| 360 | ||
| 361 | const terminalGroups = normalized.terminalGroups | |
| 362 | .map((group) => ({ | |
| 363 | ...group, | |
| 364 | terminalIds: group.terminalIds.filter((id) => id !== terminalId), | |
| 365 | })) | |
| 366 | .filter((group) => group.terminalIds.length > 0); | |
| 367 | ||
| 368 | const nextActiveTerminalGroupId = | |
| 369 | terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? | |
| 370 | terminalGroups[0]?.id ?? | |
| 371 | fallbackGroupId(nextActiveTerminalId); | |
| 372 | ||
| 373 | return normalizeThreadTerminalState({ | |
| 374 | terminalOpen: normalized.terminalOpen, | |
| 375 | terminalHeight: normalized.terminalHeight, | |
| 376 | terminalIds: remainingTerminalIds, | |
| 377 | runningTerminalIds: normalized.runningTerminalIds.filter((id) => id !== terminalId), | |
| 378 | activeTerminalId: nextActiveTerminalId, | |
| 379 | terminalGroups, | |
| 380 | activeTerminalGroupId: nextActiveTerminalGroupId, | |
| 381 | }); | |
| 382 | } | |
| 383 | ||
| 384 | function setThreadTerminalActivity( | |
| 385 | state: ThreadTerminalState, | |
| 386 | terminalId: string, | |
| 387 | hasRunningSubprocess: boolean, | |
| 388 | ): ThreadTerminalState { | |
| 389 | const normalized = normalizeThreadTerminalState(state); | |
| 390 | if (!normalized.terminalIds.includes(terminalId)) { | |
| 391 | return normalized; | |
| 392 | } | |
| 393 | const alreadyRunning = normalized.runningTerminalIds.includes(terminalId); | |
| 394 | if (hasRunningSubprocess === alreadyRunning) { | |
| 395 | return normalized; | |
| 396 | } | |
| 397 | const runningTerminalIds = new Set(normalized.runningTerminalIds); | |
| 398 | if (hasRunningSubprocess) { | |
| 399 | runningTerminalIds.add(terminalId); | |
| 400 | } else { | |
| 401 | runningTerminalIds.delete(terminalId); | |
| 402 | } | |
| 403 | return { ...normalized, runningTerminalIds: [...runningTerminalIds] }; | |
| 404 | } | |
| 405 | ||
| 406 | function selectThreadTerminalState( | |
| 25 | 407 | terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>, |
| 26 | 408 | threadId: ThreadId, |
| 27 | 409 | ): ThreadTerminalState { |
| ... | ... | @@ -62,6 +444,7 @@ function updateTerminalStateByThreadId( |
| 62 | 444 | |
| 63 | 445 | interface TerminalStateStoreState { |
| 64 | 446 | terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>; |
| 447 | getTerminalState: (threadId: ThreadId) => ThreadTerminalState; | |
| 65 | 448 | setTerminalOpen: (threadId: ThreadId, open: boolean) => void; |
| 66 | 449 | setTerminalHeight: (threadId: ThreadId, height: number) => void; |
| 67 | 450 | splitTerminal: (threadId: ThreadId, terminalId: string) => void; |
| ... | ... | @@ -78,7 +461,7 @@ interface TerminalStateStoreState { |
| 78 | 461 | |
| 79 | 462 | export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 80 | 463 | persist( |
| 81 | (set) => { | |
| 464 | (set, get) => { | |
| 82 | 465 | const updateTerminal = ( |
| 83 | 466 | threadId: ThreadId, |
| 84 | 467 | updater: (state: ThreadTerminalState) => ThreadTerminalState, |
| ... | ... | @@ -97,8 +480,10 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 97 | 480 | }; |
| 98 | 481 | }); |
| 99 | 482 | }; |
| 483 | ||
| 100 | 484 | return { |
| 101 | 485 | terminalStateByThreadId: {}, |
| 486 | getTerminalState: (threadId) => selectThreadTerminalState(get().terminalStateByThreadId, threadId), | |
| 102 | 487 | setTerminalOpen: (threadId, open) => |
| 103 | 488 | updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)), |
| 104 | 489 | setTerminalHeight: (threadId, height) => |
apps/web/src/threadTerminalState.test.ts deleted-69| ... | ... | @@ -1,69 +0,0 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | closeThreadTerminal, | |
| 5 | createDefaultThreadTerminalState, | |
| 6 | newThreadTerminal, | |
| 7 | setThreadTerminalOpen, | |
| 8 | splitThreadTerminal, | |
| 9 | } from "./threadTerminalState"; | |
| 10 | ||
| 11 | describe("threadTerminalState", () => { | |
| 12 | it("creates a closed default terminal state", () => { | |
| 13 | expect(createDefaultThreadTerminalState()).toEqual({ | |
| 14 | terminalOpen: false, | |
| 15 | terminalHeight: 280, | |
| 16 | terminalIds: ["default"], | |
| 17 | runningTerminalIds: [], | |
| 18 | activeTerminalId: "default", | |
| 19 | terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], | |
| 20 | activeTerminalGroupId: "group-default", | |
| 21 | }); | |
| 22 | }); | |
| 23 | ||
| 24 | it("opens and splits terminal tabs into the active group", () => { | |
| 25 | const opened = setThreadTerminalOpen(createDefaultThreadTerminalState(), true); | |
| 26 | const split = splitThreadTerminal(opened, "terminal-2"); | |
| 27 | ||
| 28 | expect(split.terminalOpen).toBe(true); | |
| 29 | expect(split.terminalIds).toEqual(["default", "terminal-2"]); | |
| 30 | expect(split.activeTerminalId).toBe("terminal-2"); | |
| 31 | expect(split.terminalGroups).toEqual([ | |
| 32 | { | |
| 33 | id: "group-default", | |
| 34 | terminalIds: ["default", "terminal-2"], | |
| 35 | }, | |
| 36 | ]); | |
| 37 | }); | |
| 38 | ||
| 39 | it("creates new terminals in a new group", () => { | |
| 40 | const next = newThreadTerminal(createDefaultThreadTerminalState(), "terminal-2"); | |
| 41 | ||
| 42 | expect(next.terminalIds).toEqual(["default", "terminal-2"]); | |
| 43 | expect(next.activeTerminalId).toBe("terminal-2"); | |
| 44 | expect(next.activeTerminalGroupId).toBe("group-terminal-2"); | |
| 45 | expect(next.terminalGroups).toEqual([ | |
| 46 | { id: "group-default", terminalIds: ["default"] }, | |
| 47 | { id: "group-terminal-2", terminalIds: ["terminal-2"] }, | |
| 48 | ]); | |
| 49 | }); | |
| 50 | ||
| 51 | it("resets to default when closing the last terminal", () => { | |
| 52 | const closed = closeThreadTerminal(createDefaultThreadTerminalState(), "default"); | |
| 53 | ||
| 54 | expect(closed).toEqual(createDefaultThreadTerminalState()); | |
| 55 | }); | |
| 56 | ||
| 57 | it("keeps a valid active terminal after closing an active split terminal", () => { | |
| 58 | const splitOnce = splitThreadTerminal(createDefaultThreadTerminalState(), "terminal-2"); | |
| 59 | const splitTwice = splitThreadTerminal(splitOnce, "terminal-3"); | |
| 60 | ||
| 61 | const closed = closeThreadTerminal(splitTwice, "terminal-3"); | |
| 62 | ||
| 63 | expect(closed.activeTerminalId).toBe("terminal-2"); | |
| 64 | expect(closed.terminalIds).toEqual(["default", "terminal-2"]); | |
| 65 | expect(closed.terminalGroups).toEqual([ | |
| 66 | { id: "group-default", terminalIds: ["default", "terminal-2"] }, | |
| 67 | ]); | |
| 68 | }); | |
| 69 | }); |
apps/web/src/threadTerminalState.ts deleted-428| ... | ... | @@ -1,428 +0,0 @@ |
| 1 | /** | |
| 2 | * Shared thread terminal state utilities used by the terminal Zustand store. | |
| 3 | */ | |
| 4 | ||
| 5 | import { | |
| 6 | DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 7 | DEFAULT_THREAD_TERMINAL_ID, | |
| 8 | MAX_THREAD_TERMINAL_COUNT, | |
| 9 | type ThreadTerminalGroup, | |
| 10 | } from "./types"; | |
| 11 | ||
| 12 | /** Terminal slice shared by Thread and draft thread. Same shape in both stores. */ | |
| 13 | export interface ThreadTerminalState { | |
| 14 | terminalOpen: boolean; | |
| 15 | terminalHeight: number; | |
| 16 | terminalIds: string[]; | |
| 17 | runningTerminalIds: string[]; | |
| 18 | activeTerminalId: string; | |
| 19 | terminalGroups: ThreadTerminalGroup[]; | |
| 20 | activeTerminalGroupId: string; | |
| 21 | } | |
| 22 | ||
| 23 | // ─── Helpers shared by terminal updaters ─────────────────────────────────── | |
| 24 | ||
| 25 | export function normalizeTerminalIds(terminalIds: string[]): string[] { | |
| 26 | const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))].slice( | |
| 27 | 0, | |
| 28 | MAX_THREAD_TERMINAL_COUNT, | |
| 29 | ); | |
| 30 | return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID]; | |
| 31 | } | |
| 32 | ||
| 33 | export function normalizeRunningTerminalIds( | |
| 34 | runningTerminalIds: string[], | |
| 35 | terminalIds: string[], | |
| 36 | ): string[] { | |
| 37 | if (runningTerminalIds.length === 0) return []; | |
| 38 | const validTerminalIdSet = new Set(terminalIds); | |
| 39 | return [...new Set(runningTerminalIds)] | |
| 40 | .map((id) => id.trim()) | |
| 41 | .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); | |
| 42 | } | |
| 43 | ||
| 44 | export function fallbackGroupId(terminalId: string): string { | |
| 45 | return `group-${terminalId}`; | |
| 46 | } | |
| 47 | ||
| 48 | function assignUniqueGroupId(baseId: string, usedGroupIds: Set<string>): string { | |
| 49 | let candidate = baseId; | |
| 50 | let index = 2; | |
| 51 | while (usedGroupIds.has(candidate)) { | |
| 52 | candidate = `${baseId}-${index}`; | |
| 53 | index += 1; | |
| 54 | } | |
| 55 | usedGroupIds.add(candidate); | |
| 56 | return candidate; | |
| 57 | } | |
| 58 | ||
| 59 | export function findGroupIndexByTerminalId( | |
| 60 | terminalGroups: ThreadTerminalGroup[], | |
| 61 | terminalId: string, | |
| 62 | ): number { | |
| 63 | return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId)); | |
| 64 | } | |
| 65 | ||
| 66 | function normalizeTerminalGroupIds(terminalIds: string[]): string[] { | |
| 67 | return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; | |
| 68 | } | |
| 69 | ||
| 70 | /** Normalize groups to match terminalIds; used by both thread and draft. */ | |
| 71 | export function normalizeTerminalGroups( | |
| 72 | terminalGroups: ThreadTerminalGroup[], | |
| 73 | terminalIds: string[], | |
| 74 | ): ThreadTerminalGroup[] { | |
| 75 | const validTerminalIdSet = new Set(terminalIds); | |
| 76 | const assignedTerminalIds = new Set<string>(); | |
| 77 | const nextGroups: ThreadTerminalGroup[] = []; | |
| 78 | const usedGroupIds = new Set<string>(); | |
| 79 | ||
| 80 | for (const group of terminalGroups) { | |
| 81 | const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => { | |
| 82 | if (!validTerminalIdSet.has(terminalId)) return false; | |
| 83 | if (assignedTerminalIds.has(terminalId)) return false; | |
| 84 | return true; | |
| 85 | }); | |
| 86 | if (groupTerminalIds.length === 0) continue; | |
| 87 | for (const terminalId of groupTerminalIds) { | |
| 88 | assignedTerminalIds.add(terminalId); | |
| 89 | } | |
| 90 | const baseGroupId = | |
| 91 | group.id.trim().length > 0 | |
| 92 | ? group.id.trim() | |
| 93 | : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | |
| 94 | nextGroups.push({ | |
| 95 | id: assignUniqueGroupId(baseGroupId, usedGroupIds), | |
| 96 | terminalIds: groupTerminalIds, | |
| 97 | }); | |
| 98 | } | |
| 99 | ||
| 100 | for (const terminalId of terminalIds) { | |
| 101 | if (assignedTerminalIds.has(terminalId)) continue; | |
| 102 | nextGroups.push({ | |
| 103 | id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), | |
| 104 | terminalIds: [terminalId], | |
| 105 | }); | |
| 106 | } | |
| 107 | ||
| 108 | if (nextGroups.length === 0) { | |
| 109 | return [ | |
| 110 | { | |
| 111 | id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | |
| 112 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 113 | }, | |
| 114 | ]; | |
| 115 | } | |
| 116 | ||
| 117 | return nextGroups; | |
| 118 | } | |
| 119 | ||
| 120 | function arraysEqual(a: string[], b: string[]): boolean { | |
| 121 | if (a.length !== b.length) return false; | |
| 122 | for (let index = 0; index < a.length; index += 1) { | |
| 123 | if (a[index] !== b[index]) return false; | |
| 124 | } | |
| 125 | return true; | |
| 126 | } | |
| 127 | ||
| 128 | function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalGroup[]): boolean { | |
| 129 | if (left.length !== right.length) return false; | |
| 130 | for (let index = 0; index < left.length; index += 1) { | |
| 131 | const leftGroup = left[index]; | |
| 132 | const rightGroup = right[index]; | |
| 133 | if (!leftGroup || !rightGroup) return false; | |
| 134 | if (leftGroup.id !== rightGroup.id) return false; | |
| 135 | if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false; | |
| 136 | } | |
| 137 | return true; | |
| 138 | } | |
| 139 | ||
| 140 | function threadTerminalStateEqual(left: ThreadTerminalState, right: ThreadTerminalState): boolean { | |
| 141 | return ( | |
| 142 | left.terminalOpen === right.terminalOpen && | |
| 143 | left.terminalHeight === right.terminalHeight && | |
| 144 | left.activeTerminalId === right.activeTerminalId && | |
| 145 | left.activeTerminalGroupId === right.activeTerminalGroupId && | |
| 146 | arraysEqual(left.terminalIds, right.terminalIds) && | |
| 147 | arraysEqual(left.runningTerminalIds, right.runningTerminalIds) && | |
| 148 | terminalGroupsEqual(left.terminalGroups, right.terminalGroups) | |
| 149 | ); | |
| 150 | } | |
| 151 | ||
| 152 | // ─── Default & normalize ───────────────────────────────────────────────── | |
| 153 | ||
| 154 | const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({ | |
| 155 | terminalOpen: false, | |
| 156 | terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 157 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 158 | runningTerminalIds: [], | |
| 159 | activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, | |
| 160 | terminalGroups: [ | |
| 161 | { | |
| 162 | id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | |
| 163 | terminalIds: [DEFAULT_THREAD_TERMINAL_ID], | |
| 164 | }, | |
| 165 | ], | |
| 166 | activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | |
| 167 | }); | |
| 168 | ||
| 169 | export function createDefaultThreadTerminalState(): ThreadTerminalState { | |
| 170 | return { ...DEFAULT_THREAD_TERMINAL_STATE }; | |
| 171 | } | |
| 172 | ||
| 173 | export function getDefaultThreadTerminalState(): ThreadTerminalState { | |
| 174 | return DEFAULT_THREAD_TERMINAL_STATE; | |
| 175 | } | |
| 176 | ||
| 177 | export function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTerminalState { | |
| 178 | const terminalIds = normalizeTerminalIds(state.terminalIds); | |
| 179 | const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; | |
| 180 | const runningTerminalIds = normalizeRunningTerminalIds( | |
| 181 | state.runningTerminalIds, | |
| 182 | nextTerminalIds, | |
| 183 | ); | |
| 184 | const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) | |
| 185 | ? state.activeTerminalId | |
| 186 | : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); | |
| 187 | const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds); | |
| 188 | const activeGroupIdFromState = terminalGroups.some((group) => group.id === state.activeTerminalGroupId) | |
| 189 | ? state.activeTerminalGroupId | |
| 190 | : null; | |
| 191 | const activeGroupIdFromTerminal = | |
| 192 | terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null; | |
| 193 | ||
| 194 | const normalized: ThreadTerminalState = { | |
| 195 | terminalOpen: state.terminalOpen, | |
| 196 | terminalHeight: | |
| 197 | Number.isFinite(state.terminalHeight) && state.terminalHeight > 0 | |
| 198 | ? state.terminalHeight | |
| 199 | : DEFAULT_THREAD_TERMINAL_HEIGHT, | |
| 200 | terminalIds: nextTerminalIds, | |
| 201 | runningTerminalIds, | |
| 202 | activeTerminalId, | |
| 203 | terminalGroups, | |
| 204 | activeTerminalGroupId: | |
| 205 | activeGroupIdFromState ?? | |
| 206 | activeGroupIdFromTerminal ?? | |
| 207 | terminalGroups[0]?.id ?? | |
| 208 | fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), | |
| 209 | }; | |
| 210 | return threadTerminalStateEqual(state, normalized) ? state : normalized; | |
| 211 | } | |
| 212 | ||
| 213 | export function isDefaultThreadTerminalState(state: ThreadTerminalState): boolean { | |
| 214 | const normalized = normalizeThreadTerminalState(state); | |
| 215 | return threadTerminalStateEqual(normalized, DEFAULT_THREAD_TERMINAL_STATE); | |
| 216 | } | |
| 217 | ||
| 218 | function isValidTerminalId(terminalId: string): boolean { | |
| 219 | return terminalId.trim().length > 0; | |
| 220 | } | |
| 221 | ||
| 222 | function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { | |
| 223 | return groups.map((group) => ({ | |
| 224 | id: group.id, | |
| 225 | terminalIds: [...group.terminalIds], | |
| 226 | })); | |
| 227 | } | |
| 228 | ||
| 229 | function upsertTerminalIntoGroups( | |
| 230 | state: ThreadTerminalState, | |
| 231 | terminalId: string, | |
| 232 | mode: "split" | "new", | |
| 233 | ): ThreadTerminalState { | |
| 234 | const normalized = normalizeThreadTerminalState(state); | |
| 235 | if (!isValidTerminalId(terminalId)) { | |
| 236 | return normalized; | |
| 237 | } | |
| 238 | ||
| 239 | const isNewTerminal = !normalized.terminalIds.includes(terminalId); | |
| 240 | if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { | |
| 241 | return normalized; | |
| 242 | } | |
| 243 | ||
| 244 | const terminalIds = isNewTerminal ? [...normalized.terminalIds, terminalId] : normalized.terminalIds; | |
| 245 | const terminalGroups = copyTerminalGroups(normalized.terminalGroups); | |
| 246 | ||
| 247 | const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId); | |
| 248 | if (existingGroupIndex >= 0) { | |
| 249 | terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter( | |
| 250 | (id) => id !== terminalId, | |
| 251 | ); | |
| 252 | if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { | |
| 253 | terminalGroups.splice(existingGroupIndex, 1); | |
| 254 | } | |
| 255 | } | |
| 256 | ||
| 257 | if (mode === "new") { | |
| 258 | const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); | |
| 259 | const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds); | |
| 260 | terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] }); | |
| 261 | return normalizeThreadTerminalState({ | |
| 262 | ...normalized, | |
| 263 | terminalOpen: true, | |
| 264 | terminalIds, | |
| 265 | activeTerminalId: terminalId, | |
| 266 | terminalGroups, | |
| 267 | activeTerminalGroupId: nextGroupId, | |
| 268 | }); | |
| 269 | } | |
| 270 | ||
| 271 | let activeGroupIndex = terminalGroups.findIndex((group) => group.id === normalized.activeTerminalGroupId); | |
| 272 | if (activeGroupIndex < 0) { | |
| 273 | activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId); | |
| 274 | } | |
| 275 | if (activeGroupIndex < 0) { | |
| 276 | const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); | |
| 277 | const nextGroupId = assignUniqueGroupId(fallbackGroupId(normalized.activeTerminalId), usedGroupIds); | |
| 278 | terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] }); | |
| 279 | activeGroupIndex = terminalGroups.length - 1; | |
| 280 | } | |
| 281 | ||
| 282 | const destinationGroup = terminalGroups[activeGroupIndex]; | |
| 283 | if (!destinationGroup) { | |
| 284 | return normalized; | |
| 285 | } | |
| 286 | ||
| 287 | if (!destinationGroup.terminalIds.includes(terminalId)) { | |
| 288 | const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); | |
| 289 | if (anchorIndex >= 0) { | |
| 290 | destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId); | |
| 291 | } else { | |
| 292 | destinationGroup.terminalIds.push(terminalId); | |
| 293 | } | |
| 294 | } | |
| 295 | ||
| 296 | return normalizeThreadTerminalState({ | |
| 297 | ...normalized, | |
| 298 | terminalOpen: true, | |
| 299 | terminalIds, | |
| 300 | activeTerminalId: terminalId, | |
| 301 | terminalGroups, | |
| 302 | activeTerminalGroupId: destinationGroup.id, | |
| 303 | }); | |
| 304 | } | |
| 305 | ||
| 306 | // ─── Pure terminal operations ────────────────────────────────────────────── | |
| 307 | ||
| 308 | export function setThreadTerminalOpen(state: ThreadTerminalState, open: boolean): ThreadTerminalState { | |
| 309 | const normalized = normalizeThreadTerminalState(state); | |
| 310 | if (normalized.terminalOpen === open) return normalized; | |
| 311 | return { ...normalized, terminalOpen: open }; | |
| 312 | } | |
| 313 | ||
| 314 | export function setThreadTerminalHeight( | |
| 315 | state: ThreadTerminalState, | |
| 316 | height: number, | |
| 317 | ): ThreadTerminalState { | |
| 318 | const normalized = normalizeThreadTerminalState(state); | |
| 319 | if (!Number.isFinite(height) || height <= 0 || normalized.terminalHeight === height) { | |
| 320 | return normalized; | |
| 321 | } | |
| 322 | return { ...normalized, terminalHeight: height }; | |
| 323 | } | |
| 324 | ||
| 325 | export function splitThreadTerminal( | |
| 326 | state: ThreadTerminalState, | |
| 327 | terminalId: string, | |
| 328 | ): ThreadTerminalState { | |
| 329 | return upsertTerminalIntoGroups(state, terminalId, "split"); | |
| 330 | } | |
| 331 | ||
| 332 | export function newThreadTerminal( | |
| 333 | state: ThreadTerminalState, | |
| 334 | terminalId: string, | |
| 335 | ): ThreadTerminalState { | |
| 336 | return upsertTerminalIntoGroups(state, terminalId, "new"); | |
| 337 | } | |
| 338 | ||
| 339 | export function setThreadActiveTerminal( | |
| 340 | state: ThreadTerminalState, | |
| 341 | terminalId: string, | |
| 342 | ): ThreadTerminalState { | |
| 343 | const normalized = normalizeThreadTerminalState(state); | |
| 344 | if (!normalized.terminalIds.includes(terminalId)) { | |
| 345 | return normalized; | |
| 346 | } | |
| 347 | const activeTerminalGroupId = | |
| 348 | normalized.terminalGroups.find((group) => group.terminalIds.includes(terminalId))?.id ?? | |
| 349 | normalized.activeTerminalGroupId; | |
| 350 | if ( | |
| 351 | normalized.activeTerminalId === terminalId && | |
| 352 | normalized.activeTerminalGroupId === activeTerminalGroupId | |
| 353 | ) { | |
| 354 | return normalized; | |
| 355 | } | |
| 356 | return { | |
| 357 | ...normalized, | |
| 358 | activeTerminalId: terminalId, | |
| 359 | activeTerminalGroupId, | |
| 360 | }; | |
| 361 | } | |
| 362 | ||
| 363 | export function closeThreadTerminal( | |
| 364 | state: ThreadTerminalState, | |
| 365 | terminalId: string, | |
| 366 | ): ThreadTerminalState { | |
| 367 | const normalized = normalizeThreadTerminalState(state); | |
| 368 | if (!normalized.terminalIds.includes(terminalId)) { | |
| 369 | return normalized; | |
| 370 | } | |
| 371 | ||
| 372 | const remainingTerminalIds = normalized.terminalIds.filter((id) => id !== terminalId); | |
| 373 | if (remainingTerminalIds.length === 0) { | |
| 374 | return createDefaultThreadTerminalState(); | |
| 375 | } | |
| 376 | ||
| 377 | const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId); | |
| 378 | const nextActiveTerminalId = | |
| 379 | normalized.activeTerminalId === terminalId | |
| 380 | ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? | |
| 381 | remainingTerminalIds[0] ?? | |
| 382 | DEFAULT_THREAD_TERMINAL_ID) | |
| 383 | : normalized.activeTerminalId; | |
| 384 | ||
| 385 | const terminalGroups = normalized.terminalGroups | |
| 386 | .map((group) => ({ | |
| 387 | ...group, | |
| 388 | terminalIds: group.terminalIds.filter((id) => id !== terminalId), | |
| 389 | })) | |
| 390 | .filter((group) => group.terminalIds.length > 0); | |
| 391 | ||
| 392 | const nextActiveTerminalGroupId = | |
| 393 | terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? | |
| 394 | terminalGroups[0]?.id ?? | |
| 395 | fallbackGroupId(nextActiveTerminalId); | |
| 396 | ||
| 397 | return normalizeThreadTerminalState({ | |
| 398 | terminalOpen: normalized.terminalOpen, | |
| 399 | terminalHeight: normalized.terminalHeight, | |
| 400 | terminalIds: remainingTerminalIds, | |
| 401 | runningTerminalIds: normalized.runningTerminalIds.filter((id) => id !== terminalId), | |
| 402 | activeTerminalId: nextActiveTerminalId, | |
| 403 | terminalGroups, | |
| 404 | activeTerminalGroupId: nextActiveTerminalGroupId, | |
| 405 | }); | |
| 406 | } | |
| 407 | ||
| 408 | export function setThreadTerminalActivity( | |
| 409 | state: ThreadTerminalState, | |
| 410 | terminalId: string, | |
| 411 | hasRunningSubprocess: boolean, | |
| 412 | ): ThreadTerminalState { | |
| 413 | const normalized = normalizeThreadTerminalState(state); | |
| 414 | if (!normalized.terminalIds.includes(terminalId)) { | |
| 415 | return normalized; | |
| 416 | } | |
| 417 | const alreadyRunning = normalized.runningTerminalIds.includes(terminalId); | |
| 418 | if (hasRunningSubprocess === alreadyRunning) { | |
| 419 | return normalized; | |
| 420 | } | |
| 421 | const runningTerminalIds = new Set(normalized.runningTerminalIds); | |
| 422 | if (hasRunningSubprocess) { | |
| 423 | runningTerminalIds.add(terminalId); | |
| 424 | } else { | |
| 425 | runningTerminalIds.delete(terminalId); | |
| 426 | } | |
| 427 | return { ...normalized, runningTerminalIds: [...runningTerminalIds] }; | |
| 428 | } |