diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0233425d0e270918018011023a07d7800a36319c..494c21328f471ec8aec2d61ebc3f597cfc897d46 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -124,7 +124,7 @@ import { useComposerDraftStore, useComposerThreadDraft, } from "../composerDraftStore"; -import { useTerminalStateStore } from "../terminalStateStore"; +import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { clamp } from "effect/Number"; function formatMessageMeta(createdAt: string, duration: string | null): string { @@ -530,7 +530,9 @@ export default function ChatView({ threadId }: ChatViewProps) { setMessagesScrollElement(element); }, []); - const terminalState = useTerminalStateStore((s) => s.getTerminalState(threadId)); + const terminalState = useTerminalStateStore((state) => + selectThreadTerminalState(state.terminalStateByThreadId, threadId), + ); const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 284f56ae3711f641074af5b7fe72d0540389a434..af5da74c6e7037b9070b150e338fa093407d0dd7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -21,7 +21,7 @@ import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; -import { useTerminalStateStore } from "../terminalStateStore"; +import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -240,7 +240,7 @@ export default function Sidebar() { (store) => store.getDraftThreadByProjectId, ); const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); - const getTerminalState = useTerminalStateStore((s) => s.getTerminalState); + const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const clearProjectDraftThreadId = useComposerDraftStore( @@ -824,7 +824,8 @@ export default function Sidebar() { ); const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); const terminalStatus = terminalStatusFromRunningIds( - getTerminalState(thread.id).runningTerminalIds, + selectThreadTerminalState(terminalStateByThreadId, thread.id) + .runningTerminalIds, ); return ( diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index e630821b1ebfb7e6387772a7a08361c388e186cb..c8582f1441b93354fd6035b4bd25c7cd9ab0bcc9 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -7,34 +7,61 @@ import type { ThreadId } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { - createDefaultThreadTerminalState, + closeThreadTerminal, getDefaultThreadTerminalState, - reduceThreadTerminalState, - type ThreadTerminalAction, + isDefaultThreadTerminalState, + newThreadTerminal, + setThreadActiveTerminal, + setThreadTerminalActivity, + setThreadTerminalHeight, + setThreadTerminalOpen, + splitThreadTerminal, type ThreadTerminalState, } from "./threadTerminalState"; export const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; -function applyTerminalAction( - state: Record, +export function selectThreadTerminalState( + terminalStateByThreadId: Record, threadId: ThreadId, - action: ThreadTerminalAction, +): ThreadTerminalState { + if (threadId.length === 0) { + return getDefaultThreadTerminalState(); + } + return terminalStateByThreadId[threadId] ?? getDefaultThreadTerminalState(); +} + +function updateTerminalStateByThreadId( + terminalStateByThreadId: Record, + threadId: ThreadId, + updater: (state: ThreadTerminalState) => ThreadTerminalState, ): Record { - const current = state[threadId] ?? createDefaultThreadTerminalState(); - const next = reduceThreadTerminalState(current, action); + if (threadId.length === 0) { + return terminalStateByThreadId; + } + + const current = selectThreadTerminalState(terminalStateByThreadId, threadId); + const next = updater(current); if (next === current) { - return state; + return terminalStateByThreadId; } + + if (isDefaultThreadTerminalState(next)) { + if (terminalStateByThreadId[threadId] === undefined) { + return terminalStateByThreadId; + } + const { [threadId]: _removed, ...rest } = terminalStateByThreadId; + return rest as Record; + } + return { - ...state, + ...terminalStateByThreadId, [threadId]: next, }; } interface TerminalStateStoreState { terminalStateByThreadId: Record; - getTerminalState: (threadId: ThreadId) => ThreadTerminalState; setTerminalOpen: (threadId: ThreadId, open: boolean) => void; setTerminalHeight: (threadId: ThreadId, height: number) => void; splitTerminal: (threadId: ThreadId, terminalId: string) => void; @@ -51,51 +78,45 @@ interface TerminalStateStoreState { export const useTerminalStateStore = create()( persist( - (set, get) => { - const updateTerminal = (threadId: ThreadId, action: ThreadTerminalAction) => { - if (threadId.length === 0) return; - set((state) => ({ - terminalStateByThreadId: applyTerminalAction( + (set) => { + const updateTerminal = ( + threadId: ThreadId, + updater: (state: ThreadTerminalState) => ThreadTerminalState, + ) => { + set((state) => { + const nextTerminalStateByThreadId = updateTerminalStateByThreadId( state.terminalStateByThreadId, threadId, - action, - ), - })); + updater, + ); + if (nextTerminalStateByThreadId === state.terminalStateByThreadId) { + return state; + } + return { + terminalStateByThreadId: nextTerminalStateByThreadId, + }; + }); }; return { terminalStateByThreadId: {}, - getTerminalState: (threadId) => { - if (threadId.length === 0) { - return getDefaultThreadTerminalState(); - } - const state = get().terminalStateByThreadId[threadId]; - return state ?? getDefaultThreadTerminalState(); - }, - setTerminalOpen: (threadId, open) => updateTerminal(threadId, { type: "set-open", open }), + setTerminalOpen: (threadId, open) => + updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)), setTerminalHeight: (threadId, height) => - updateTerminal(threadId, { type: "set-height", height }), + updateTerminal(threadId, (state) => setThreadTerminalHeight(state, height)), splitTerminal: (threadId, terminalId) => - updateTerminal(threadId, { type: "split", terminalId }), + updateTerminal(threadId, (state) => splitThreadTerminal(state, terminalId)), newTerminal: (threadId, terminalId) => - updateTerminal(threadId, { type: "new", terminalId }), + updateTerminal(threadId, (state) => newThreadTerminal(state, terminalId)), setActiveTerminal: (threadId, terminalId) => - updateTerminal(threadId, { type: "set-active", terminalId }), + updateTerminal(threadId, (state) => setThreadActiveTerminal(state, terminalId)), closeTerminal: (threadId, terminalId) => - updateTerminal(threadId, { type: "close", terminalId }), + updateTerminal(threadId, (state) => closeThreadTerminal(state, terminalId)), setTerminalActivity: (threadId, terminalId, hasRunningSubprocess) => - updateTerminal(threadId, { type: "set-activity", terminalId, hasRunningSubprocess }), - clearTerminalState: (threadId) => { - if (threadId.length === 0) return; - set((state) => { - if (state.terminalStateByThreadId[threadId] === undefined) { - return state; - } - const { [threadId]: _removed, ...rest } = state.terminalStateByThreadId; - return { - terminalStateByThreadId: rest as Record, - }; - }); - }, + updateTerminal(threadId, (state) => + setThreadTerminalActivity(state, terminalId, hasRunningSubprocess), + ), + clearTerminalState: (threadId) => + updateTerminal(threadId, () => getDefaultThreadTerminalState()), }; }, { diff --git a/apps/web/src/threadTerminalState.test.ts b/apps/web/src/threadTerminalState.test.ts index f5a18ffa9807e9bb62ac8938f1ba3d25363a3141..e186a914dea9ba3f948d80a9fee7344ca0b41d50 100644 --- a/apps/web/src/threadTerminalState.test.ts +++ b/apps/web/src/threadTerminalState.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "vitest"; import { + closeThreadTerminal, createDefaultThreadTerminalState, - reduceThreadTerminalState, + newThreadTerminal, + setThreadTerminalOpen, + splitThreadTerminal, } from "./threadTerminalState"; describe("threadTerminalState", () => { @@ -19,14 +22,8 @@ describe("threadTerminalState", () => { }); it("opens and splits terminal tabs into the active group", () => { - const opened = reduceThreadTerminalState(createDefaultThreadTerminalState(), { - type: "set-open", - open: true, - }); - const split = reduceThreadTerminalState(opened, { - type: "split", - terminalId: "terminal-2", - }); + const opened = setThreadTerminalOpen(createDefaultThreadTerminalState(), true); + const split = splitThreadTerminal(opened, "terminal-2"); expect(split.terminalOpen).toBe(true); expect(split.terminalIds).toEqual(["default", "terminal-2"]); @@ -40,10 +37,7 @@ describe("threadTerminalState", () => { }); it("creates new terminals in a new group", () => { - const next = reduceThreadTerminalState(createDefaultThreadTerminalState(), { - type: "new", - terminalId: "terminal-2", - }); + const next = newThreadTerminal(createDefaultThreadTerminalState(), "terminal-2"); expect(next.terminalIds).toEqual(["default", "terminal-2"]); expect(next.activeTerminalId).toBe("terminal-2"); @@ -55,28 +49,16 @@ describe("threadTerminalState", () => { }); it("resets to default when closing the last terminal", () => { - const closed = reduceThreadTerminalState(createDefaultThreadTerminalState(), { - type: "close", - terminalId: "default", - }); + const closed = closeThreadTerminal(createDefaultThreadTerminalState(), "default"); expect(closed).toEqual(createDefaultThreadTerminalState()); }); it("keeps a valid active terminal after closing an active split terminal", () => { - const splitOnce = reduceThreadTerminalState(createDefaultThreadTerminalState(), { - type: "split", - terminalId: "terminal-2", - }); - const splitTwice = reduceThreadTerminalState(splitOnce, { - type: "split", - terminalId: "terminal-3", - }); + const splitOnce = splitThreadTerminal(createDefaultThreadTerminalState(), "terminal-2"); + const splitTwice = splitThreadTerminal(splitOnce, "terminal-3"); - const closed = reduceThreadTerminalState(splitTwice, { - type: "close", - terminalId: "terminal-3", - }); + const closed = closeThreadTerminal(splitTwice, "terminal-3"); expect(closed.activeTerminalId).toBe("terminal-2"); expect(closed.terminalIds).toEqual(["default", "terminal-2"]); diff --git a/apps/web/src/threadTerminalState.ts b/apps/web/src/threadTerminalState.ts index be0b6466b559fe147268b695edf24048facb6427..0d7e0fd18b91ca67a375559aab54120b6bd27680 100644 --- a/apps/web/src/threadTerminalState.ts +++ b/apps/web/src/threadTerminalState.ts @@ -1,8 +1,5 @@ /** - * Shared thread terminal state and reducer. Used by both the main app store - * (persisted threads) and the composer draft store (draft threads), so we can - * reuse the same shape and helpers and easily copy terminal state from draft - * to thread when reconciling server state. + * Shared thread terminal state utilities used by the terminal Zustand store. */ import { @@ -23,16 +20,7 @@ export interface ThreadTerminalState { activeTerminalGroupId: string; } -export type ThreadTerminalAction = - | { type: "set-open"; open: boolean } - | { type: "set-height"; height: number } - | { type: "split"; terminalId: string } - | { type: "new"; terminalId: string } - | { type: "set-active"; terminalId: string } - | { type: "close"; terminalId: string } - | { type: "set-activity"; terminalId: string; hasRunningSubprocess: boolean }; - -// ─── Helpers (exported for store reducers that need to work with Thread) ─── +// ─── Helpers shared by terminal updaters ─────────────────────────────────── export function normalizeTerminalIds(terminalIds: string[]): string[] { const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))].slice( @@ -129,6 +117,38 @@ export function normalizeTerminalGroups( return nextGroups; } +function arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let index = 0; index < a.length; index += 1) { + if (a[index] !== b[index]) return false; + } + return true; +} + +function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalGroup[]): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + const leftGroup = left[index]; + const rightGroup = right[index]; + if (!leftGroup || !rightGroup) return false; + if (leftGroup.id !== rightGroup.id) return false; + if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false; + } + return true; +} + +function threadTerminalStateEqual(left: ThreadTerminalState, right: ThreadTerminalState): boolean { + return ( + left.terminalOpen === right.terminalOpen && + left.terminalHeight === right.terminalHeight && + left.activeTerminalId === right.activeTerminalId && + left.activeTerminalGroupId === right.activeTerminalGroupId && + arraysEqual(left.terminalIds, right.terminalIds) && + arraysEqual(left.runningTerminalIds, right.runningTerminalIds) && + terminalGroupsEqual(left.terminalGroups, right.terminalGroups) + ); +} + // ─── Default & normalize ───────────────────────────────────────────────── const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({ @@ -171,7 +191,7 @@ export function normalizeThreadTerminalState(state: ThreadTerminalState): Thread const activeGroupIdFromTerminal = terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null; - return { + const normalized: ThreadTerminalState = { terminalOpen: state.terminalOpen, terminalHeight: Number.isFinite(state.terminalHeight) && state.terminalHeight > 0 @@ -187,52 +207,16 @@ export function normalizeThreadTerminalState(state: ThreadTerminalState): Thread terminalGroups[0]?.id ?? fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), }; + return threadTerminalStateEqual(state, normalized) ? state : normalized; } -// ─── Close terminal (shared) ───────────────────────────────────────────── - -function closeTerminal( - state: ThreadTerminalState, - terminalId: string, -): ThreadTerminalState { - if (!state.terminalIds.includes(terminalId)) { - return state; - } - - const remainingTerminalIds = state.terminalIds.filter((id) => id !== terminalId); - if (remainingTerminalIds.length === 0) { - return createDefaultThreadTerminalState(); - } - - const closedTerminalIndex = state.terminalIds.indexOf(terminalId); - const nextActiveTerminalId = - state.activeTerminalId === terminalId - ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? - remainingTerminalIds[0] ?? - DEFAULT_THREAD_TERMINAL_ID) - : state.activeTerminalId; - - const terminalGroups = state.terminalGroups - .map((group) => ({ - ...group, - terminalIds: group.terminalIds.filter((id) => id !== terminalId), - })) - .filter((group) => group.terminalIds.length > 0); - - const nextActiveTerminalGroupId = - terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? - terminalGroups[0]?.id ?? - fallbackGroupId(nextActiveTerminalId); +export function isDefaultThreadTerminalState(state: ThreadTerminalState): boolean { + const normalized = normalizeThreadTerminalState(state); + return threadTerminalStateEqual(normalized, DEFAULT_THREAD_TERMINAL_STATE); +} - return normalizeThreadTerminalState({ - terminalOpen: state.terminalOpen, - terminalHeight: state.terminalHeight, - terminalIds: remainingTerminalIds, - runningTerminalIds: state.runningTerminalIds.filter((id) => id !== terminalId), - activeTerminalId: nextActiveTerminalId, - terminalGroups, - activeTerminalGroupId: nextActiveTerminalGroupId, - }); +function isValidTerminalId(terminalId: string): boolean { + return terminalId.trim().length > 0; } function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { @@ -242,109 +226,55 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[ })); } -// ─── Single reducer for both draft and persisted thread ──────────────────── - -export function reduceThreadTerminalState( +function upsertTerminalIntoGroups( state: ThreadTerminalState, - action: ThreadTerminalAction, + terminalId: string, + mode: "split" | "new", ): ThreadTerminalState { const normalized = normalizeThreadTerminalState(state); - - if (action.type === "set-open") { - if (normalized.terminalOpen === action.open) return normalized; - return { ...normalized, terminalOpen: action.open }; - } - - if (action.type === "set-height") { - if ( - !Number.isFinite(action.height) || - action.height <= 0 || - normalized.terminalHeight === action.height - ) { - return normalized; - } - return { ...normalized, terminalHeight: action.height }; - } - - if (action.type === "set-active") { - if (!normalized.terminalIds.includes(action.terminalId)) { - return normalized; - } - const activeTerminalGroupId = - normalized.terminalGroups.find((group) => group.terminalIds.includes(action.terminalId))?.id ?? - normalized.activeTerminalGroupId; - return { - ...normalized, - activeTerminalId: action.terminalId, - activeTerminalGroupId, - }; - } - - if (action.type === "close") { - return closeTerminal(normalized, action.terminalId); - } - - if (action.type === "set-activity") { - if (!normalized.terminalIds.includes(action.terminalId)) { - return normalized; - } - const runningTerminalIds = new Set(normalized.runningTerminalIds); - if (action.hasRunningSubprocess) { - runningTerminalIds.add(action.terminalId); - } else { - runningTerminalIds.delete(action.terminalId); - } - return { ...normalized, runningTerminalIds: [...runningTerminalIds] }; - } - - if (!action.terminalId || action.terminalId.trim().length === 0) { + if (!isValidTerminalId(terminalId)) { return normalized; } - const isNewTerminal = !normalized.terminalIds.includes(action.terminalId); + const isNewTerminal = !normalized.terminalIds.includes(terminalId); if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { return normalized; } - const terminalIds = isNewTerminal - ? [...normalized.terminalIds, action.terminalId] - : normalized.terminalIds; + const terminalIds = isNewTerminal ? [...normalized.terminalIds, terminalId] : normalized.terminalIds; const terminalGroups = copyTerminalGroups(normalized.terminalGroups); - const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, action.terminalId); + + const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId); if (existingGroupIndex >= 0) { - terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[ - existingGroupIndex - ]!.terminalIds.filter((id) => id !== action.terminalId); + terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter( + (id) => id !== terminalId, + ); if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { terminalGroups.splice(existingGroupIndex, 1); } } - if (action.type === "new") { + if (mode === "new") { const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); - const nextGroupId = assignUniqueGroupId(fallbackGroupId(action.terminalId), usedGroupIds); - terminalGroups.push({ id: nextGroupId, terminalIds: [action.terminalId] }); + const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds); + terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] }); return normalizeThreadTerminalState({ ...normalized, terminalOpen: true, terminalIds, - activeTerminalId: action.terminalId, + activeTerminalId: terminalId, terminalGroups, activeTerminalGroupId: nextGroupId, }); } - // split let activeGroupIndex = terminalGroups.findIndex((group) => group.id === normalized.activeTerminalGroupId); if (activeGroupIndex < 0) { activeGroupIndex = findGroupIndexByTerminalId(terminalGroups, normalized.activeTerminalId); } if (activeGroupIndex < 0) { const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); - const nextGroupId = assignUniqueGroupId( - fallbackGroupId(normalized.activeTerminalId), - usedGroupIds, - ); + const nextGroupId = assignUniqueGroupId(fallbackGroupId(normalized.activeTerminalId), usedGroupIds); terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] }); activeGroupIndex = terminalGroups.length - 1; } @@ -354,12 +284,12 @@ export function reduceThreadTerminalState( return normalized; } - if (!destinationGroup.terminalIds.includes(action.terminalId)) { + if (!destinationGroup.terminalIds.includes(terminalId)) { const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); if (anchorIndex >= 0) { - destinationGroup.terminalIds.splice(anchorIndex + 1, 0, action.terminalId); + destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId); } else { - destinationGroup.terminalIds.push(action.terminalId); + destinationGroup.terminalIds.push(terminalId); } } @@ -367,8 +297,132 @@ export function reduceThreadTerminalState( ...normalized, terminalOpen: true, terminalIds, - activeTerminalId: action.terminalId, + activeTerminalId: terminalId, terminalGroups, activeTerminalGroupId: destinationGroup.id, }); } + +// ─── Pure terminal operations ────────────────────────────────────────────── + +export function setThreadTerminalOpen(state: ThreadTerminalState, open: boolean): ThreadTerminalState { + const normalized = normalizeThreadTerminalState(state); + if (normalized.terminalOpen === open) return normalized; + return { ...normalized, terminalOpen: open }; +} + +export function setThreadTerminalHeight( + state: ThreadTerminalState, + height: number, +): ThreadTerminalState { + const normalized = normalizeThreadTerminalState(state); + if (!Number.isFinite(height) || height <= 0 || normalized.terminalHeight === height) { + return normalized; + } + return { ...normalized, terminalHeight: height }; +} + +export function splitThreadTerminal( + state: ThreadTerminalState, + terminalId: string, +): ThreadTerminalState { + return upsertTerminalIntoGroups(state, terminalId, "split"); +} + +export function newThreadTerminal( + state: ThreadTerminalState, + terminalId: string, +): ThreadTerminalState { + return upsertTerminalIntoGroups(state, terminalId, "new"); +} + +export function setThreadActiveTerminal( + state: ThreadTerminalState, + terminalId: string, +): ThreadTerminalState { + const normalized = normalizeThreadTerminalState(state); + if (!normalized.terminalIds.includes(terminalId)) { + return normalized; + } + const activeTerminalGroupId = + normalized.terminalGroups.find((group) => group.terminalIds.includes(terminalId))?.id ?? + normalized.activeTerminalGroupId; + if ( + normalized.activeTerminalId === terminalId && + normalized.activeTerminalGroupId === activeTerminalGroupId + ) { + return normalized; + } + return { + ...normalized, + activeTerminalId: terminalId, + activeTerminalGroupId, + }; +} + +export function closeThreadTerminal( + state: ThreadTerminalState, + terminalId: string, +): ThreadTerminalState { + const normalized = normalizeThreadTerminalState(state); + if (!normalized.terminalIds.includes(terminalId)) { + return normalized; + } + + const remainingTerminalIds = normalized.terminalIds.filter((id) => id !== terminalId); + if (remainingTerminalIds.length === 0) { + return createDefaultThreadTerminalState(); + } + + const closedTerminalIndex = normalized.terminalIds.indexOf(terminalId); + const nextActiveTerminalId = + normalized.activeTerminalId === terminalId + ? (remainingTerminalIds[Math.min(closedTerminalIndex, remainingTerminalIds.length - 1)] ?? + remainingTerminalIds[0] ?? + DEFAULT_THREAD_TERMINAL_ID) + : normalized.activeTerminalId; + + const terminalGroups = normalized.terminalGroups + .map((group) => ({ + ...group, + terminalIds: group.terminalIds.filter((id) => id !== terminalId), + })) + .filter((group) => group.terminalIds.length > 0); + + const nextActiveTerminalGroupId = + terminalGroups.find((group) => group.terminalIds.includes(nextActiveTerminalId))?.id ?? + terminalGroups[0]?.id ?? + fallbackGroupId(nextActiveTerminalId); + + return normalizeThreadTerminalState({ + terminalOpen: normalized.terminalOpen, + terminalHeight: normalized.terminalHeight, + terminalIds: remainingTerminalIds, + runningTerminalIds: normalized.runningTerminalIds.filter((id) => id !== terminalId), + activeTerminalId: nextActiveTerminalId, + terminalGroups, + activeTerminalGroupId: nextActiveTerminalGroupId, + }); +} + +export function setThreadTerminalActivity( + state: ThreadTerminalState, + terminalId: string, + hasRunningSubprocess: boolean, +): ThreadTerminalState { + const normalized = normalizeThreadTerminalState(state); + if (!normalized.terminalIds.includes(terminalId)) { + return normalized; + } + const alreadyRunning = normalized.runningTerminalIds.includes(terminalId); + if (hasRunningSubprocess === alreadyRunning) { + return normalized; + } + const runningTerminalIds = new Set(normalized.runningTerminalIds); + if (hasRunningSubprocess) { + runningTerminalIds.add(terminalId); + } else { + runningTerminalIds.delete(terminalId); + } + return { ...normalized, runningTerminalIds: [...runningTerminalIds] }; +}