| 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,8 +43,10 @@ export default function BranchToolbar({ |
| 43 | envLocked, | 43 | envLocked, |
| 44 | onComposerFocusRequest, | 44 | onComposerFocusRequest, |
| 45 | }: BranchToolbarProps) { | 45 | }: BranchToolbarProps) { |
| 46 | const setThreadBranchAction = useStore((state) => state.setThreadBranch); | 46 | const threads = useStore((store) => store.threads); |
| 47 | const setThreadErrorAction = useStore((state) => state.setThreadError); | 47 | const projects = useStore((store) => store.projects); |
| 48 | const setThreadBranchAction = useStore((store) => store.setThreadBranch); | ||
| 49 | const setThreadErrorAction = useStore((store) => store.setError); | ||
| 48 | const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); | 50 | const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); |
| 49 | const serverThread = useStore((state) => | 51 | const serverThread = useStore((state) => |
| 50 | state.threads.find((thread) => thread.id === threadId), | 52 | state.threads.find((thread) => thread.id === threadId), |
| ... | @@ -59,6 +61,9 @@ export default function BranchToolbar({ | ... | @@ -59,6 +61,9 @@ export default function BranchToolbar({ |
| 59 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); | 61 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); |
| 60 | const [branchQuery, setBranchQuery] = useState(""); | 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 | const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); | 67 | const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); |
| 63 | const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; | 68 | const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; |
| 64 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; | 69 | const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; |
| ... | @@ -119,7 +124,7 @@ export default function BranchToolbar({ | ... | @@ -119,7 +124,7 @@ export default function BranchToolbar({ |
| 119 | }); | 124 | }); |
| 120 | } | 125 | } |
| 121 | if (hasServerThread) { | 126 | if (hasServerThread) { |
| 122 | dispatch.setThreadBranch(activeThreadId, syncedBranch, null); | 127 | setThreadBranchAction(activeThreadId, syncedBranch, null); |
| 123 | return; | 128 | return; |
| 124 | } | 129 | } |
| 125 | setDraftThreadContext(threadId, { | 130 | setDraftThreadContext(threadId, { |
| ... | @@ -142,7 +147,7 @@ export default function BranchToolbar({ | ... | @@ -142,7 +147,7 @@ export default function BranchToolbar({ |
| 142 | 147 | ||
| 143 | const setThreadError = (error: string | null) => { | 148 | const setThreadError = (error: string | null) => { |
| 144 | if (!activeThreadId) return; | 149 | if (!activeThreadId) return; |
| 145 | dispatch.setError(activeThreadId, error); | 150 | setThreadErrorAction(activeThreadId, error); |
| 146 | }; | 151 | }; |
| 147 | 152 | ||
| 148 | const setThreadBranch = (branch: string | null, worktreePath: string | null) => { | 153 | const setThreadBranch = (branch: string | null, worktreePath: string | null) => { |
| ... | @@ -171,7 +176,7 @@ export default function BranchToolbar({ | ... | @@ -171,7 +176,7 @@ export default function BranchToolbar({ |
| 171 | }); | 176 | }); |
| 172 | } | 177 | } |
| 173 | if (hasServerThread) { | 178 | if (hasServerThread) { |
| 174 | dispatch.setThreadBranch(activeThreadId, branch, worktreePath); | 179 | setThreadBranchAction(activeThreadId, branch, worktreePath); |
| 175 | return; | 180 | return; |
| 176 | } | 181 | } |
| 177 | setDraftThreadContext(threadId, { | 182 | setDraftThreadContext(threadId, { |
apps/web/src/components/ChatView.tsx+32-38| ... | @@ -124,7 +124,7 @@ import { | ... | @@ -124,7 +124,7 @@ import { |
| 124 | useComposerDraftStore, | 124 | useComposerDraftStore, |
| 125 | useComposerThreadDraft, | 125 | useComposerThreadDraft, |
| 126 | } from "../composerDraftStore"; | 126 | } from "../composerDraftStore"; |
| 127 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 127 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 128 | import { clamp } from "effect/Number"; | 128 | import { clamp } from "effect/Number"; |
| 129 | 129 | ||
| 130 | function formatMessageMeta(createdAt: string, duration: string | null): string { | 130 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| ... | @@ -437,17 +437,13 @@ interface ChatViewProps { | ... | @@ -437,17 +437,13 @@ interface ChatViewProps { |
| 437 | } | 437 | } |
| 438 | 438 | ||
| 439 | export default function ChatView({ threadId }: ChatViewProps) { | 439 | export default function ChatView({ threadId }: ChatViewProps) { |
| 440 | const markThreadVisited = useStore((state) => state.markThreadVisited); | 440 | const threads = useStore((store) => store.threads); |
| 441 | const setThreadTerminalOpen = useStore((state) => state.setThreadTerminalOpen); | 441 | const projects = useStore((store) => store.projects); |
| 442 | const splitThreadTerminalAction = useStore((state) => state.splitThreadTerminal); | 442 | const runtimeMode = useStore((store) => store.runtimeMode); |
| 443 | const newThreadTerminalAction = useStore((state) => state.newThreadTerminal); | 443 | const markThreadVisited = useStore((store) => store.markThreadVisited); |
| 444 | const setThreadActiveTerminal = useStore((state) => state.setThreadActiveTerminal); | 444 | const setStoreThreadError = useStore((store) => store.setError); |
| 445 | const closeThreadTerminalAction = useStore((state) => state.closeThreadTerminal); | 445 | const setStoreRuntimeMode = useStore((store) => store.setRuntimeMode); |
| 446 | const setThreadErrorAction = useStore((state) => state.setThreadError); | 446 | const setStoreThreadBranch = useStore((store) => store.setThreadBranch); |
| 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); | ||
| 451 | const { settings } = useAppSettings(); | 447 | const { settings } = useAppSettings(); |
| 452 | const navigate = useNavigate(); | 448 | const navigate = useNavigate(); |
| 453 | const rawSearch = useSearch({ | 449 | const rawSearch = useSearch({ |
| ... | @@ -530,9 +526,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -530,9 +526,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 530 | setMessagesScrollElement(element); | 526 | setMessagesScrollElement(element); |
| 531 | }, []); | 527 | }, []); |
| 532 | 528 | ||
| 533 | const terminalState = useTerminalStateStore((state) => | 529 | const terminalState = useTerminalStateStore((state) => state.getTerminalState(threadId)); |
| 534 | selectThreadTerminalState(state.terminalStateByThreadId, threadId), | ||
| 535 | ); | ||
| 536 | const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); | 530 | const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); |
| 537 | const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); | 531 | const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); |
| 538 | const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); | 532 | const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); |
| ... | @@ -571,6 +565,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -571,6 +565,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 571 | [removeComposerDraftImage, threadId], | 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 | const localDraftError = localDraftErrorsByThreadId[threadId] ?? null; | 570 | const localDraftError = localDraftErrorsByThreadId[threadId] ?? null; |
| 575 | const localDraftThread = useMemo( | 571 | const localDraftThread = useMemo( |
| 576 | () => | 572 | () => |
| ... | @@ -595,10 +591,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -595,10 +591,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 595 | const activeThreadId = activeThread?.id ?? null; | 591 | const activeThreadId = activeThread?.id ?? null; |
| 596 | const activeLatestTurn = activeThread?.latestTurn ?? null; | 592 | const activeLatestTurn = activeThread?.latestTurn ?? null; |
| 597 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); | 593 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| 598 | const activeProjectId = activeThread?.projectId ?? null; | 594 | const activeProject = projects.find((p) => p.id === activeThread?.projectId); |
| 599 | const activeProject = useStore((state) => | ||
| 600 | activeProjectId ? state.projects.find((project) => project.id === activeProjectId) : undefined, | ||
| 601 | ); | ||
| 602 | 595 | ||
| 603 | useEffect(() => { | 596 | useEffect(() => { |
| 604 | if (!serverThread) { | 597 | if (!serverThread) { |
| ... | @@ -622,7 +615,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -622,7 +615,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 622 | const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; | 615 | const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; |
| 623 | if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; | 616 | if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; |
| 624 | 617 | ||
| 625 | dispatch.markThreadVisited(activeThread.id); | 618 | markThreadVisited(activeThread.id); |
| 626 | }, [ | 619 | }, [ |
| 627 | activeThread?.id, | 620 | activeThread?.id, |
| 628 | activeThread?.lastVisitedAt, | 621 | activeThread?.lastVisitedAt, |
| ... | @@ -983,8 +976,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -983,8 +976,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 983 | const setThreadError = useCallback( | 976 | const setThreadError = useCallback( |
| 984 | (targetThreadId: ThreadId | null, error: string | null) => { | 977 | (targetThreadId: ThreadId | null, error: string | null) => { |
| 985 | if (!targetThreadId) return; | 978 | if (!targetThreadId) return; |
| 986 | if (state.threads.some((thread) => thread.id === targetThreadId)) { | 979 | if (threads.some((thread) => thread.id === targetThreadId)) { |
| 987 | dispatch.setError(targetThreadId, error); | 980 | setStoreThreadError(targetThreadId, error); |
| 988 | return; | 981 | return; |
| 989 | } | 982 | } |
| 990 | setLocalDraftErrorsByThreadId((existing) => { | 983 | setLocalDraftErrorsByThreadId((existing) => { |
| ... | @@ -997,7 +990,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -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 | const focusComposer = useCallback(() => { | 996 | const focusComposer = useCallback(() => { |
| ... | @@ -1155,7 +1148,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1155,7 +1148,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1155 | activeProject, | 1148 | activeProject, |
| 1156 | activeThread, | 1149 | activeThread, |
| 1157 | activeThreadId, | 1150 | activeThreadId, |
| 1158 | dispatch, | ||
| 1159 | gitCwd, | 1151 | gitCwd, |
| 1160 | isLocalDraftThread, | 1152 | isLocalDraftThread, |
| 1161 | isServerThread, | 1153 | isServerThread, |
| ... | @@ -1266,15 +1258,13 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1266,15 +1258,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1266 | ); | 1258 | ); |
| 1267 | 1259 | ||
| 1268 | const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { | 1260 | const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { |
| 1269 | if (mode === state.runtimeMode) return; | 1261 | if (mode === runtimeMode) return; |
| 1270 | dispatch.setRuntimeMode(mode); | 1262 | setStoreRuntimeMode(mode); |
| 1271 | scheduleComposerFocus(); | 1263 | scheduleComposerFocus(); |
| 1272 | const api = readNativeApi(); | 1264 | const api = readNativeApi(); |
| 1273 | if (!api) return; | 1265 | if (!api) return; |
| 1274 | 1266 | ||
| 1275 | const runningThreadIds = useStore | 1267 | const runningThreadIds = threads |
| 1276 | .getState() | ||
| 1277 | .threads | ||
| 1278 | .filter((thread) => thread.session !== null && thread.session.status !== "closed") | 1268 | .filter((thread) => thread.session !== null && thread.session.status !== "closed") |
| 1279 | .map((thread) => thread.id); | 1269 | .map((thread) => thread.id); |
| 1280 | 1270 | ||
| ... | @@ -1891,7 +1881,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1891,7 +1881,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1891 | const shouldCreateWorktree = | 1881 | const shouldCreateWorktree = |
| 1892 | isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; | 1882 | isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; |
| 1893 | if (shouldCreateWorktree && !activeThread.branch) { | 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 | return; | 1885 | return; |
| 1896 | } | 1886 | } |
| 1897 | 1887 | ||
| ... | @@ -1964,7 +1954,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1964,7 +1954,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1964 | }); | 1954 | }); |
| 1965 | // Keep local thread state in sync immediately so terminal drawer opens | 1955 | // Keep local thread state in sync immediately so terminal drawer opens |
| 1966 | // with the worktree cwd/env instead of briefly using the project root. | 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,7 +2005,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2015 | setSendPhase("sending-turn"); | 2005 | setSendPhase("sending-turn"); |
| 2016 | const turnAttachments = await turnAttachmentsPromise; | 2006 | const turnAttachments = await turnAttachmentsPromise; |
| 2017 | const approvalPolicy = runtimeMode === "full-access" ? "never" : "on-request"; | 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 | await api.orchestration.dispatchCommand({ | 2010 | await api.orchestration.dispatchCommand({ |
| 2020 | type: "thread.turn.start", | 2011 | type: "thread.turn.start", |
| 2021 | commandId: newCommandId(), | 2012 | commandId: newCommandId(), |
| ... | @@ -2103,13 +2094,16 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2103,13 +2094,16 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2103 | createdAt: new Date().toISOString(), | 2094 | createdAt: new Date().toISOString(), |
| 2104 | }); | 2095 | }); |
| 2105 | } catch (err) { | 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 | } finally { | 2101 | } finally { |
| 2108 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); | 2102 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); |
| 2109 | } | 2103 | } |
| 2110 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); | 2104 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); |
| 2111 | }, | 2105 | }, |
| 2112 | [activeThreadId, setThreadErrorAction], | 2106 | [activeThreadId, setStoreThreadError], |
| 2113 | ); | 2107 | ); |
| 2114 | 2108 | ||
| 2115 | const onModelSelect = useCallback( | 2109 | const onModelSelect = useCallback( |
| ... | @@ -3544,12 +3538,12 @@ const OpenInPicker = memo(function OpenInPicker({ | ... | @@ -3544,12 +3538,12 @@ const OpenInPicker = memo(function OpenInPicker({ |
| 3544 | ] satisfies { label: string; Icon: Icon; value: EditorId }[]; | 3538 | ] satisfies { label: string; Icon: Icon; value: EditorId }[]; |
| 3545 | const primaryOption = options.find(({ value }) => value === lastEditor); | 3539 | const primaryOption = options.find(({ value }) => value === lastEditor); |
| 3546 | 3540 | ||
| 3547 | const activeThread = useStore((state) => | 3541 | const activeThread = useStore((store) => |
| 3548 | activeThreadId ? state.threads.find((thread) => thread.id === activeThreadId) : undefined, | 3542 | activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, |
| 3549 | ); | 3543 | ); |
| 3550 | const activeProjectId = activeThread?.projectId ?? null; | 3544 | const activeProjectId = activeThread?.projectId ?? null; |
| 3551 | const activeProject = useStore((state) => | 3545 | const activeProject = useStore((store) => |
| 3552 | activeProjectId ? state.projects.find((project) => project.id === activeProjectId) : undefined, | 3546 | activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, |
| 3553 | ); | 3547 | ); |
| 3554 | 3548 | ||
| 3555 | const openInEditor = useCallback( | 3549 | const openInEditor = useCallback( |
apps/web/src/components/DiffPanel.tsx+6-7| ... | @@ -166,15 +166,14 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -166,15 +166,14 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 166 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), | 166 | select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), |
| 167 | }); | 167 | }); |
| 168 | const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); | 168 | const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); |
| 169 | const { state } = useStore(); | ||
| 170 | const activeThreadId = routeThreadId; | 169 | const activeThreadId = routeThreadId; |
| 171 | const activeThread = activeThreadId | 170 | const activeThread = useStore((store) => |
| 172 | ? state.threads.find((thread) => thread.id === activeThreadId) | 171 | activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, |
| 173 | : undefined; | 172 | ); |
| 174 | const activeProjectId = activeThread?.projectId ?? null; | 173 | const activeProjectId = activeThread?.projectId ?? null; |
| 175 | const activeProject = activeProjectId | 174 | const activeProject = useStore((store) => |
| 176 | ? state.projects.find((project) => project.id === activeProjectId) | 175 | activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, |
| 177 | : undefined; | 176 | ); |
| 178 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; | 177 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; |
| 179 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = | 178 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 180 | useTurnDiffSummaries(activeThread); | 179 | useTurnDiffSummaries(activeThread); |
apps/web/src/components/Sidebar.tsx+13-13| ... | @@ -21,7 +21,7 @@ import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/ | ... | @@ -21,7 +21,7 @@ import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/ |
| 21 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 21 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 22 | import { readNativeApi } from "../nativeApi"; | 22 | import { readNativeApi } from "../nativeApi"; |
| 23 | import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; | 23 | import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; |
| 24 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 24 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 25 | import { toastManager } from "./ui/toast"; | 25 | import { toastManager } from "./ui/toast"; |
| 26 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; | 26 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; |
| 27 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 27 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| ... | @@ -231,16 +231,16 @@ function ProjectFavicon({ cwd }: { cwd: string }) { | ... | @@ -231,16 +231,16 @@ function ProjectFavicon({ cwd }: { cwd: string }) { |
| 231 | } | 231 | } |
| 232 | 232 | ||
| 233 | export default function Sidebar() { | 233 | export default function Sidebar() { |
| 234 | const projects = useStore((state) => state.projects); | 234 | const projects = useStore((store) => store.projects); |
| 235 | const threads = useStore((state) => state.threads); | 235 | const threads = useStore((store) => store.threads); |
| 236 | const markThreadUnread = useStore((state) => state.markThreadUnread); | 236 | const markThreadUnread = useStore((store) => store.markThreadUnread); |
| 237 | const toggleProject = useStore((state) => state.toggleProject); | 237 | const toggleProject = useStore((store) => store.toggleProject); |
| 238 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); | 238 | const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); |
| 239 | const getDraftThreadByProjectId = useComposerDraftStore( | 239 | const getDraftThreadByProjectId = useComposerDraftStore( |
| 240 | (store) => store.getDraftThreadByProjectId, | 240 | (store) => store.getDraftThreadByProjectId, |
| 241 | ); | 241 | ); |
| 242 | const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); | 242 | const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); |
| 243 | const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); | 243 | const getTerminalState = useTerminalStateStore((state) => state.getTerminalState); |
| 244 | const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); | 244 | const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); |
| 245 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); | 245 | const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); |
| 246 | const clearProjectDraftThreadId = useComposerDraftStore( | 246 | const clearProjectDraftThreadId = useComposerDraftStore( |
| ... | @@ -445,7 +445,8 @@ export default function Sidebar() { | ... | @@ -445,7 +445,8 @@ export default function Sidebar() { |
| 445 | const api = readNativeApi(); | 445 | const api = readNativeApi(); |
| 446 | 446 | ||
| 447 | setIsAddingProject(true); | 447 | setIsAddingProject(true); |
| 448 | if (api) { | 448 | try { |
| 449 | if (!api) return; | ||
| 449 | const existing = projects.find((project) => project.cwd === cwd); | 450 | const existing = projects.find((project) => project.cwd === cwd); |
| 450 | if (existing) { | 451 | if (existing) { |
| 451 | focusMostRecentThreadForProject(existing.id); | 452 | focusMostRecentThreadForProject(existing.id); |
| ... | @@ -509,7 +510,7 @@ export default function Sidebar() { | ... | @@ -509,7 +510,7 @@ export default function Sidebar() { |
| 509 | position, | 510 | position, |
| 510 | ); | 511 | ); |
| 511 | if (clicked === "mark-unread") { | 512 | if (clicked === "mark-unread") { |
| 512 | dispatch.markThreadUnread(threadId); | 513 | markThreadUnread(threadId); |
| 513 | return; | 514 | return; |
| 514 | } | 515 | } |
| 515 | if (clicked !== "delete") return; | 516 | if (clicked !== "delete") return; |
| ... | @@ -616,9 +617,9 @@ export default function Sidebar() { | ... | @@ -616,9 +617,9 @@ export default function Sidebar() { |
| 616 | clearProjectDraftThreadById, | 617 | clearProjectDraftThreadById, |
| 617 | markThreadUnread, | 618 | markThreadUnread, |
| 618 | navigate, | 619 | navigate, |
| 620 | projects, | ||
| 619 | removeWorktreeMutation, | 621 | removeWorktreeMutation, |
| 620 | routeThreadId, | 622 | routeThreadId, |
| 621 | projects, | ||
| 622 | threads, | 623 | threads, |
| 623 | ], | 624 | ], |
| 624 | ); | 625 | ); |
| ... | @@ -709,7 +710,7 @@ export default function Sidebar() { | ... | @@ -709,7 +710,7 @@ export default function Sidebar() { |
| 709 | return () => { | 710 | return () => { |
| 710 | window.removeEventListener("keydown", onWindowKeyDown); | 711 | window.removeEventListener("keydown", onWindowKeyDown); |
| 711 | }; | 712 | }; |
| 712 | }, [getDraftThread, handleNewThread, keybindings, routeThreadId, projects, threads]); | 713 | }, [getDraftThread, handleNewThread, keybindings, projects, routeThreadId, threads]); |
| 713 | 714 | ||
| 714 | const onCreateThreadClick = () => { | 715 | const onCreateThreadClick = () => { |
| 715 | if (projects.length === 0) { | 716 | if (projects.length === 0) { |
| ... | @@ -781,7 +782,7 @@ export default function Sidebar() { | ... | @@ -781,7 +782,7 @@ export default function Sidebar() { |
| 781 | open={project.expanded} | 782 | open={project.expanded} |
| 782 | onOpenChange={(open) => { | 783 | onOpenChange={(open) => { |
| 783 | if (open === project.expanded) return; | 784 | if (open === project.expanded) return; |
| 784 | dispatch.toggleProject(project.id); | 785 | toggleProject(project.id); |
| 785 | }} | 786 | }} |
| 786 | > | 787 | > |
| 787 | <SidebarMenuItem> | 788 | <SidebarMenuItem> |
| ... | @@ -824,8 +825,7 @@ export default function Sidebar() { | ... | @@ -824,8 +825,7 @@ export default function Sidebar() { |
| 824 | ); | 825 | ); |
| 825 | const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); | 826 | const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); |
| 826 | const terminalStatus = terminalStatusFromRunningIds( | 827 | const terminalStatus = terminalStatusFromRunningIds( |
| 827 | selectThreadTerminalState(terminalStateByThreadId, thread.id) | 828 | getTerminalState(thread.id).runningTerminalIds, |
| 828 | .runningTerminalIds, | ||
| 829 | ); | 829 | ); |
| 830 | 830 | ||
| 831 | return ( | 831 | return ( |
apps/web/src/routes/__root.tsx+5-6| ... | @@ -124,9 +124,8 @@ function errorDetails(error: unknown): string { | ... | @@ -124,9 +124,8 @@ function errorDetails(error: unknown): string { |
| 124 | } | 124 | } |
| 125 | 125 | ||
| 126 | function EventRouter() { | 126 | function EventRouter() { |
| 127 | const syncServerReadModel = useStore((state) => state.syncServerReadModel); | 127 | const syncServerReadModel = useStore((store) => store.syncServerReadModel); |
| 128 | const setThreadTerminalActivity = useStore((state) => state.setThreadTerminalActivity); | 128 | const setProjectExpanded = useStore((store) => store.setProjectExpanded); |
| 129 | const setProjectExpanded = useStore((state) => state.setProjectExpanded); | ||
| 130 | const queryClient = useQueryClient(); | 129 | const queryClient = useQueryClient(); |
| 131 | const navigate = useNavigate(); | 130 | const navigate = useNavigate(); |
| 132 | const pathname = useRouterState({ select: (state) => state.location.pathname }); | 131 | const pathname = useRouterState({ select: (state) => state.location.pathname }); |
| ... | @@ -150,7 +149,7 @@ function EventRouter() { | ... | @@ -150,7 +149,7 @@ function EventRouter() { |
| 150 | const snapshot = await api.orchestration.getSnapshot(); | 149 | const snapshot = await api.orchestration.getSnapshot(); |
| 151 | if (disposed) return; | 150 | if (disposed) return; |
| 152 | latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); | 151 | latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); |
| 153 | dispatch.syncServerReadModel(snapshot); | 152 | syncServerReadModel(snapshot); |
| 154 | if (pending) { | 153 | if (pending) { |
| 155 | pending = false; | 154 | pending = false; |
| 156 | await flushSnapshotSync(); | 155 | await flushSnapshotSync(); |
| ... | @@ -205,7 +204,7 @@ function EventRouter() { | ... | @@ -205,7 +204,7 @@ function EventRouter() { |
| 205 | if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { | 204 | if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { |
| 206 | return; | 205 | return; |
| 207 | } | 206 | } |
| 208 | dispatch.setProjectExpanded(payload.bootstrapProjectId, true); | 207 | setProjectExpanded(payload.bootstrapProjectId, true); |
| 209 | 208 | ||
| 210 | if (pathnameRef.current !== "/") { | 209 | if (pathnameRef.current !== "/") { |
| 211 | return; | 210 | return; |
| ... | @@ -270,7 +269,7 @@ function EventRouter() { | ... | @@ -270,7 +269,7 @@ function EventRouter() { |
| 270 | unsubWelcome(); | 269 | unsubWelcome(); |
| 271 | unsubServerConfigUpdated(); | 270 | unsubServerConfigUpdated(); |
| 272 | }; | 271 | }; |
| 273 | }, [navigate, queryClient, setProjectExpanded, setThreadTerminalActivity, syncServerReadModel]); | 272 | }, [navigate, queryClient, setProjectExpanded, syncServerReadModel]); |
| 274 | 273 | ||
| 275 | return null; | 274 | return null; |
| 276 | } | 275 | } |
apps/web/src/routes/_chat.$threadId.tsx+2-3| ... | @@ -133,14 +133,13 @@ const DiffPanelInlineSidebar = (props: { | ... | @@ -133,14 +133,13 @@ const DiffPanelInlineSidebar = (props: { |
| 133 | }; | 133 | }; |
| 134 | 134 | ||
| 135 | function ChatThreadRouteView() { | 135 | function ChatThreadRouteView() { |
| 136 | const { state } = useStore(); | 136 | const threadsHydrated = useStore((store) => store.threadsHydrated); |
| 137 | const threadsHydrated = state.threadsHydrated; | ||
| 138 | const navigate = useNavigate(); | 137 | const navigate = useNavigate(); |
| 139 | const threadId = Route.useParams({ | 138 | const threadId = Route.useParams({ |
| 140 | select: (params) => ThreadId.makeUnsafe(params.threadId), | 139 | select: (params) => ThreadId.makeUnsafe(params.threadId), |
| 141 | }); | 140 | }); |
| 142 | const search = Route.useSearch(); | 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 | const draftThreadExists = useComposerDraftStore( | 143 | const draftThreadExists = useComposerDraftStore( |
| 145 | (store) => Object.hasOwn(store.draftThreadsByThreadId, threadId), | 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,8 +48,8 @@ const RUNTIME_MODE_OPTIONS = [ |
| 48 | 48 | ||
| 49 | function SettingsRouteView() { | 49 | function SettingsRouteView() { |
| 50 | const { theme, setTheme, resolvedTheme } = useTheme(); | 50 | const { theme, setTheme, resolvedTheme } = useTheme(); |
| 51 | const runtimeMode = useStore((state) => state.runtimeMode); | 51 | const runtimeMode = useStore((store) => store.runtimeMode); |
| 52 | const setRuntimeMode = useStore((state) => state.setRuntimeMode); | 52 | const setRuntimeMode = useStore((store) => store.setRuntimeMode); |
| 53 | const { settings, defaults, updateSettings } = useAppSettings(); | 53 | const { settings, defaults, updateSettings } = useAppSettings(); |
| 54 | const serverConfigQuery = useQuery(serverConfigQueryOptions()); | 54 | const serverConfigQuery = useQuery(serverConfigQueryOptions()); |
| 55 | const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); | 55 | const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); |
| ... | @@ -349,7 +349,7 @@ function SettingsRouteView() { | ... | @@ -349,7 +349,7 @@ function SettingsRouteView() { |
| 349 | : "border-border bg-background text-muted-foreground hover:bg-accent" | 349 | : "border-border bg-background text-muted-foreground hover:bg-accent" |
| 350 | }`} | 350 | }`} |
| 351 | onClick={() => { | 351 | onClick={() => { |
| 352 | dispatch.setRuntimeMode(option.value); | 352 | setRuntimeMode(option.value); |
| 353 | }} | 353 | }} |
| 354 | > | 354 | > |
| 355 | <span className="flex flex-col"> | 355 | <span className="flex flex-col"> |
apps/web/src/store.ts+3-23| ... | @@ -400,7 +400,7 @@ interface AppStore extends AppState { | ... | @@ -400,7 +400,7 @@ interface AppStore extends AppState { |
| 400 | setRuntimeMode: (mode: RuntimeMode) => void; | 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 | ...readPersistedState(), | 404 | ...readPersistedState(), |
| 405 | syncServerReadModel: (readModel) => | 405 | syncServerReadModel: (readModel) => |
| 406 | set((state) => syncServerReadModel(state, readModel)), | 406 | set((state) => syncServerReadModel(state, readModel)), |
| ... | @@ -421,31 +421,11 @@ export const useAppStore = create<AppStore>((set, _get) => ({ | ... | @@ -421,31 +421,11 @@ export const useAppStore = create<AppStore>((set, _get) => ({ |
| 421 | })); | 421 | })); |
| 422 | 422 | ||
| 423 | // Persist on every state change (only runtimeMode + expandedProjectCwds) | 423 | // Persist on every state change (only runtimeMode + expandedProjectCwds) |
| 424 | useAppStore.subscribe((state) => persistState(state)); | 424 | useStore.subscribe((state) => persistState(state)); |
| 425 | |||
| 426 | // ── useStore (state + store API for call sites that want both) ──────────────── | ||
| 427 | 425 | ||
| 428 | export function StoreProvider({ children }: { children: ReactNode }) { | 426 | export function StoreProvider({ children }: { children: ReactNode }) { |
| 429 | useEffect(() => { | 427 | useEffect(() => { |
| 430 | persistState(useAppStore.getState()); | 428 | persistState(useStore.getState()); |
| 431 | }, []); | 429 | }, []); |
| 432 | return createElement(Fragment, null, children); | 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,27 +1,409 @@ |
| 1 | /** | 1 | /** |
| 2 | * Single Zustand store for terminal UI state keyed by threadId. Used by both | 2 | * Single Zustand store for terminal UI state keyed by threadId. |
| 3 | * persisted (server) threads and draft threads; threadId is stable for both. | 3 | * |
| 4 | * Terminal transition helpers are intentionally private to keep the public | ||
| 5 | * API constrained to store actions/selectors. | ||
| 4 | */ | 6 | */ |
| 5 | 7 | ||
| 6 | import type { ThreadId } from "@t3tools/contracts"; | 8 | import type { ThreadId } from "@t3tools/contracts"; |
| 7 | import { create } from "zustand"; | 9 | import { create } from "zustand"; |
| 8 | import { createJSONStorage, persist } from "zustand/middleware"; | 10 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 9 | import { | 11 | import { |
| 10 | closeThreadTerminal, | 12 | DEFAULT_THREAD_TERMINAL_HEIGHT, |
| 11 | getDefaultThreadTerminalState, | 13 | DEFAULT_THREAD_TERMINAL_ID, |
| 12 | isDefaultThreadTerminalState, | 14 | MAX_THREAD_TERMINAL_COUNT, |
| 13 | newThreadTerminal, | 15 | type ThreadTerminalGroup, |
| 14 | setThreadActiveTerminal, | 16 | } from "./types"; |
| 15 | setThreadTerminalActivity, | 17 | |
| 16 | setThreadTerminalHeight, | 18 | interface ThreadTerminalState { |
| 17 | setThreadTerminalOpen, | 19 | terminalOpen: boolean; |
| 18 | splitThreadTerminal, | 20 | terminalHeight: number; |
| 19 | type ThreadTerminalState, | 21 | terminalIds: string[]; |
| 20 | } from "./threadTerminalState"; | 22 | runningTerminalIds: string[]; |
| 21 | 23 | activeTerminalId: string; | |
| 22 | export const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; | 24 | terminalGroups: ThreadTerminalGroup[]; |
| 23 | 25 | activeTerminalGroupId: string; | |
| 24 | export function selectThreadTerminalState( | 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 | terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>, | 407 | terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>, |
| 26 | threadId: ThreadId, | 408 | threadId: ThreadId, |
| 27 | ): ThreadTerminalState { | 409 | ): ThreadTerminalState { |
| ... | @@ -62,6 +444,7 @@ function updateTerminalStateByThreadId( | ... | @@ -62,6 +444,7 @@ function updateTerminalStateByThreadId( |
| 62 | 444 | ||
| 63 | interface TerminalStateStoreState { | 445 | interface TerminalStateStoreState { |
| 64 | terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>; | 446 | terminalStateByThreadId: Record<ThreadId, ThreadTerminalState>; |
| 447 | getTerminalState: (threadId: ThreadId) => ThreadTerminalState; | ||
| 65 | setTerminalOpen: (threadId: ThreadId, open: boolean) => void; | 448 | setTerminalOpen: (threadId: ThreadId, open: boolean) => void; |
| 66 | setTerminalHeight: (threadId: ThreadId, height: number) => void; | 449 | setTerminalHeight: (threadId: ThreadId, height: number) => void; |
| 67 | splitTerminal: (threadId: ThreadId, terminalId: string) => void; | 450 | splitTerminal: (threadId: ThreadId, terminalId: string) => void; |
| ... | @@ -78,7 +461,7 @@ interface TerminalStateStoreState { | ... | @@ -78,7 +461,7 @@ interface TerminalStateStoreState { |
| 78 | 461 | ||
| 79 | export const useTerminalStateStore = create<TerminalStateStoreState>()( | 462 | export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 80 | persist( | 463 | persist( |
| 81 | (set) => { | 464 | (set, get) => { |
| 82 | const updateTerminal = ( | 465 | const updateTerminal = ( |
| 83 | threadId: ThreadId, | 466 | threadId: ThreadId, |
| 84 | updater: (state: ThreadTerminalState) => ThreadTerminalState, | 467 | updater: (state: ThreadTerminalState) => ThreadTerminalState, |
| ... | @@ -97,8 +480,10 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( | ... | @@ -97,8 +480,10 @@ export const useTerminalStateStore = create<TerminalStateStoreState>()( |
| 97 | }; | 480 | }; |
| 98 | }); | 481 | }); |
| 99 | }; | 482 | }; |
| 483 | |||
| 100 | return { | 484 | return { |
| 101 | terminalStateByThreadId: {}, | 485 | terminalStateByThreadId: {}, |
| 486 | getTerminalState: (threadId) => selectThreadTerminalState(get().terminalStateByThreadId, threadId), | ||
| 102 | setTerminalOpen: (threadId, open) => | 487 | setTerminalOpen: (threadId, open) => |
| 103 | updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)), | 488 | updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)), |
| 104 | setTerminalHeight: (threadId, height) => | 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 | } | ||