From f84f2797383fc6e586bb6e5d5b2eb18631152fb1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Mar 2026 10:58:19 -0800 Subject: [PATCH] Refactor Zustand store usage to granular selectors - 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 behavior --- apps/web/src/components/BranchToolbar.tsx | 15 +- apps/web/src/components/ChatView.tsx | 70 ++-- apps/web/src/components/DiffPanel.tsx | 13 +- apps/web/src/components/Sidebar.tsx | 26 +- apps/web/src/routes/__root.tsx | 11 +- apps/web/src/routes/_chat.$threadId.tsx | 5 +- apps/web/src/routes/_chat.settings.tsx | 6 +- apps/web/src/store.ts | 26 +- apps/web/src/terminalStateStore.test.ts | 89 +++++ apps/web/src/terminalStateStore.ts | 421 ++++++++++++++++++++- apps/web/src/threadTerminalState.test.ts | 69 ---- apps/web/src/threadTerminalState.ts | 428 ---------------------- 12 files changed, 566 insertions(+), 613 deletions(-) create mode 100644 apps/web/src/terminalStateStore.test.ts delete mode 100644 apps/web/src/threadTerminalState.test.ts delete mode 100644 apps/web/src/threadTerminalState.ts diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index f3bb37c5cf2fe2341570376ff9500689314c6634..c3bc6c4a31fb75ac73c851161b21e6171e898311 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -43,8 +43,10 @@ export default function BranchToolbar({ envLocked, onComposerFocusRequest, }: BranchToolbarProps) { - const setThreadBranchAction = useStore((state) => state.setThreadBranch); - const setThreadErrorAction = useStore((state) => state.setThreadError); + const threads = useStore((store) => store.threads); + const projects = useStore((store) => store.projects); + const setThreadBranchAction = useStore((store) => store.setThreadBranch); + const setThreadErrorAction = useStore((store) => store.setError); const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); const serverThread = useStore((state) => state.threads.find((thread) => thread.id === threadId), @@ -59,6 +61,9 @@ export default function BranchToolbar({ const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); + const serverThread = threads.find((thread) => thread.id === threadId); + const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; + const activeProject = projects.find((project) => project.id === activeProjectId); const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; @@ -119,7 +124,7 @@ export default function BranchToolbar({ }); } if (hasServerThread) { - dispatch.setThreadBranch(activeThreadId, syncedBranch, null); + setThreadBranchAction(activeThreadId, syncedBranch, null); return; } setDraftThreadContext(threadId, { @@ -142,7 +147,7 @@ export default function BranchToolbar({ const setThreadError = (error: string | null) => { if (!activeThreadId) return; - dispatch.setError(activeThreadId, error); + setThreadErrorAction(activeThreadId, error); }; const setThreadBranch = (branch: string | null, worktreePath: string | null) => { @@ -171,7 +176,7 @@ export default function BranchToolbar({ }); } if (hasServerThread) { - dispatch.setThreadBranch(activeThreadId, branch, worktreePath); + setThreadBranchAction(activeThreadId, branch, worktreePath); return; } setDraftThreadContext(threadId, { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 494c21328f471ec8aec2d61ebc3f597cfc897d46..e7feeffa92e83761f616be81314088b0a37a02f5 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 { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { useTerminalStateStore } from "../terminalStateStore"; import { clamp } from "effect/Number"; function formatMessageMeta(createdAt: string, duration: string | null): string { @@ -437,17 +437,13 @@ interface ChatViewProps { } export default function ChatView({ threadId }: ChatViewProps) { - const markThreadVisited = useStore((state) => state.markThreadVisited); - const setThreadTerminalOpen = useStore((state) => state.setThreadTerminalOpen); - const splitThreadTerminalAction = useStore((state) => state.splitThreadTerminal); - const newThreadTerminalAction = useStore((state) => state.newThreadTerminal); - const setThreadActiveTerminal = useStore((state) => state.setThreadActiveTerminal); - const closeThreadTerminalAction = useStore((state) => state.closeThreadTerminal); - const setThreadErrorAction = useStore((state) => state.setThreadError); - const setThreadBranchAction = useStore((state) => state.setThreadBranch); - const setRuntimeMode = useStore((state) => state.setRuntimeMode); - const setThreadTerminalHeight = useStore((state) => state.setThreadTerminalHeight); - const runtimeMode = useStore((state) => state.runtimeMode); + const threads = useStore((store) => store.threads); + const projects = useStore((store) => store.projects); + const runtimeMode = useStore((store) => store.runtimeMode); + const markThreadVisited = useStore((store) => store.markThreadVisited); + const setStoreThreadError = useStore((store) => store.setError); + const setStoreRuntimeMode = useStore((store) => store.setRuntimeMode); + const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); const navigate = useNavigate(); const rawSearch = useSearch({ @@ -530,9 +526,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setMessagesScrollElement(element); }, []); - const terminalState = useTerminalStateStore((state) => - selectThreadTerminalState(state.terminalStateByThreadId, threadId), - ); + const terminalState = useTerminalStateStore((state) => state.getTerminalState(threadId)); const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); const storeSetTerminalHeight = useTerminalStateStore((s) => s.setTerminalHeight); const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); @@ -571,6 +565,8 @@ export default function ChatView({ threadId }: ChatViewProps) { [removeComposerDraftImage, threadId], ); + const serverThread = threads.find((t) => t.id === threadId); + const fallbackDraftProject = projects.find((project) => project.id === draftThread?.projectId); const localDraftError = localDraftErrorsByThreadId[threadId] ?? null; const localDraftThread = useMemo( () => @@ -595,10 +591,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const activeThreadId = activeThread?.id ?? null; const activeLatestTurn = activeThread?.latestTurn ?? null; const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); - const activeProjectId = activeThread?.projectId ?? null; - const activeProject = useStore((state) => - activeProjectId ? state.projects.find((project) => project.id === activeProjectId) : undefined, - ); + const activeProject = projects.find((p) => p.id === activeThread?.projectId); useEffect(() => { if (!serverThread) { @@ -622,7 +615,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; - dispatch.markThreadVisited(activeThread.id); + markThreadVisited(activeThread.id); }, [ activeThread?.id, activeThread?.lastVisitedAt, @@ -983,8 +976,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const setThreadError = useCallback( (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; - if (state.threads.some((thread) => thread.id === targetThreadId)) { - dispatch.setError(targetThreadId, error); + if (threads.some((thread) => thread.id === targetThreadId)) { + setStoreThreadError(targetThreadId, error); return; } setLocalDraftErrorsByThreadId((existing) => { @@ -997,7 +990,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }; }); }, - [dispatch, state.threads], + [setStoreThreadError, threads], ); const focusComposer = useCallback(() => { @@ -1155,7 +1148,6 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProject, activeThread, activeThreadId, - dispatch, gitCwd, isLocalDraftThread, isServerThread, @@ -1266,15 +1258,13 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { - if (mode === state.runtimeMode) return; - dispatch.setRuntimeMode(mode); + if (mode === runtimeMode) return; + setStoreRuntimeMode(mode); scheduleComposerFocus(); const api = readNativeApi(); if (!api) return; - const runningThreadIds = useStore - .getState() - .threads + const runningThreadIds = threads .filter((thread) => thread.session !== null && thread.session.status !== "closed") .map((thread) => thread.id); @@ -1891,7 +1881,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const shouldCreateWorktree = isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; if (shouldCreateWorktree && !activeThread.branch) { - dispatch.setError(threadIdForSend, "Select a base branch before sending in New worktree mode."); + setStoreThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; } @@ -1964,7 +1954,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); // Keep local thread state in sync immediately so terminal drawer opens // with the worktree cwd/env instead of briefly using the project root. - dispatch.setThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); + setStoreThreadBranch(threadIdForSend, result.worktree.branch, result.worktree.path); } } @@ -2015,7 +2005,8 @@ export default function ChatView({ threadId }: ChatViewProps) { setSendPhase("sending-turn"); const turnAttachments = await turnAttachmentsPromise; const approvalPolicy = runtimeMode === "full-access" ? "never" : "on-request"; - const sandboxMode = runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; + const sandboxMode = + runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; await api.orchestration.dispatchCommand({ type: "thread.turn.start", commandId: newCommandId(), @@ -2103,13 +2094,16 @@ export default function ChatView({ threadId }: ChatViewProps) { createdAt: new Date().toISOString(), }); } catch (err) { - dispatch.setError(activeThreadId, err instanceof Error ? err.message : "Failed to submit approval decision."); + setStoreThreadError( + activeThreadId, + err instanceof Error ? err.message : "Failed to submit approval decision.", + ); } finally { setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); } setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); }, - [activeThreadId, setThreadErrorAction], + [activeThreadId, setStoreThreadError], ); const onModelSelect = useCallback( @@ -3544,12 +3538,12 @@ const OpenInPicker = memo(function OpenInPicker({ ] satisfies { label: string; Icon: Icon; value: EditorId }[]; const primaryOption = options.find(({ value }) => value === lastEditor); - const activeThread = useStore((state) => - activeThreadId ? state.threads.find((thread) => thread.id === activeThreadId) : undefined, + const activeThread = useStore((store) => + activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, ); const activeProjectId = activeThread?.projectId ?? null; - const activeProject = useStore((state) => - activeProjectId ? state.projects.find((project) => project.id === activeProjectId) : undefined, + const activeProject = useStore((store) => + activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, ); const openInEditor = useCallback( diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index ed4559d3bfb1605bee982d763a0f9d3d2d8f799c..058fa8492d8f60f6dc898cfafcc4eee37f469766 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -166,15 +166,14 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), }); const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); - const { state } = useStore(); const activeThreadId = routeThreadId; - const activeThread = activeThreadId - ? state.threads.find((thread) => thread.id === activeThreadId) - : undefined; + const activeThread = useStore((store) => + activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, + ); const activeProjectId = activeThread?.projectId ?? null; - const activeProject = activeProjectId - ? state.projects.find((project) => project.id === activeProjectId) - : undefined; + const activeProject = useStore((store) => + activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, + ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index af5da74c6e7037b9070b150e338fa093407d0dd7..e6eccbd89e42686038aad5842512cb5b80c3031a 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 { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { useTerminalStateStore } from "../terminalStateStore"; import { toastManager } from "./ui/toast"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -231,16 +231,16 @@ function ProjectFavicon({ cwd }: { cwd: string }) { } export default function Sidebar() { - const projects = useStore((state) => state.projects); - const threads = useStore((state) => state.threads); - const markThreadUnread = useStore((state) => state.markThreadUnread); - const toggleProject = useStore((state) => state.toggleProject); + const projects = useStore((store) => store.projects); + const threads = useStore((store) => store.threads); + const markThreadUnread = useStore((store) => store.markThreadUnread); + const toggleProject = useStore((store) => store.toggleProject); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearThreadDraft); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, ); const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); - const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); + const getTerminalState = useTerminalStateStore((state) => state.getTerminalState); const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const clearProjectDraftThreadId = useComposerDraftStore( @@ -445,7 +445,8 @@ export default function Sidebar() { const api = readNativeApi(); setIsAddingProject(true); - if (api) { + try { + if (!api) return; const existing = projects.find((project) => project.cwd === cwd); if (existing) { focusMostRecentThreadForProject(existing.id); @@ -509,7 +510,7 @@ export default function Sidebar() { position, ); if (clicked === "mark-unread") { - dispatch.markThreadUnread(threadId); + markThreadUnread(threadId); return; } if (clicked !== "delete") return; @@ -616,9 +617,9 @@ export default function Sidebar() { clearProjectDraftThreadById, markThreadUnread, navigate, + projects, removeWorktreeMutation, routeThreadId, - projects, threads, ], ); @@ -709,7 +710,7 @@ export default function Sidebar() { return () => { window.removeEventListener("keydown", onWindowKeyDown); }; - }, [getDraftThread, handleNewThread, keybindings, routeThreadId, projects, threads]); + }, [getDraftThread, handleNewThread, keybindings, projects, routeThreadId, threads]); const onCreateThreadClick = () => { if (projects.length === 0) { @@ -781,7 +782,7 @@ export default function Sidebar() { open={project.expanded} onOpenChange={(open) => { if (open === project.expanded) return; - dispatch.toggleProject(project.id); + toggleProject(project.id); }} > @@ -824,8 +825,7 @@ export default function Sidebar() { ); const prStatus = prStatusIndicator(prByThreadId.get(thread.id) ?? null); const terminalStatus = terminalStatusFromRunningIds( - selectThreadTerminalState(terminalStateByThreadId, thread.id) - .runningTerminalIds, + getTerminalState(thread.id).runningTerminalIds, ); return ( diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index ec2f636b45ccf308813d51eea1b50397d57a21be..fd31b495361f3dc240d5537b1b0fc72d1ab16ea1 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -124,9 +124,8 @@ function errorDetails(error: unknown): string { } function EventRouter() { - const syncServerReadModel = useStore((state) => state.syncServerReadModel); - const setThreadTerminalActivity = useStore((state) => state.setThreadTerminalActivity); - const setProjectExpanded = useStore((state) => state.setProjectExpanded); + const syncServerReadModel = useStore((store) => store.syncServerReadModel); + const setProjectExpanded = useStore((store) => store.setProjectExpanded); const queryClient = useQueryClient(); const navigate = useNavigate(); const pathname = useRouterState({ select: (state) => state.location.pathname }); @@ -150,7 +149,7 @@ function EventRouter() { const snapshot = await api.orchestration.getSnapshot(); if (disposed) return; latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); - dispatch.syncServerReadModel(snapshot); + syncServerReadModel(snapshot); if (pending) { pending = false; await flushSnapshotSync(); @@ -205,7 +204,7 @@ function EventRouter() { if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { return; } - dispatch.setProjectExpanded(payload.bootstrapProjectId, true); + setProjectExpanded(payload.bootstrapProjectId, true); if (pathnameRef.current !== "/") { return; @@ -270,7 +269,7 @@ function EventRouter() { unsubWelcome(); unsubServerConfigUpdated(); }; - }, [navigate, queryClient, setProjectExpanded, setThreadTerminalActivity, syncServerReadModel]); + }, [navigate, queryClient, setProjectExpanded, syncServerReadModel]); return null; } diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 3cafa85ceccabac303096b5295f005aa72b235c3..282d1005409838fe4cb5d9ce6f3c06d20ef1f524 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -133,14 +133,13 @@ const DiffPanelInlineSidebar = (props: { }; function ChatThreadRouteView() { - const { state } = useStore(); - const threadsHydrated = state.threadsHydrated; + const threadsHydrated = useStore((store) => store.threadsHydrated); const navigate = useNavigate(); const threadId = Route.useParams({ select: (params) => ThreadId.makeUnsafe(params.threadId), }); const search = Route.useSearch(); - const threadExists = state.threads.some((thread) => thread.id === threadId); + const threadExists = useStore((store) => store.threads.some((thread) => thread.id === threadId)); const draftThreadExists = useComposerDraftStore( (store) => Object.hasOwn(store.draftThreadsByThreadId, threadId), ); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index d4e8add9ccac88ac5d48d55235143deed06a16ed..e05df25805cfa84f797b0a50caf9b99625552f01 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -48,8 +48,8 @@ const RUNTIME_MODE_OPTIONS = [ function SettingsRouteView() { const { theme, setTheme, resolvedTheme } = useTheme(); - const runtimeMode = useStore((state) => state.runtimeMode); - const setRuntimeMode = useStore((state) => state.setRuntimeMode); + const runtimeMode = useStore((store) => store.runtimeMode); + const setRuntimeMode = useStore((store) => store.setRuntimeMode); const { settings, defaults, updateSettings } = useAppSettings(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); @@ -349,7 +349,7 @@ function SettingsRouteView() { : "border-border bg-background text-muted-foreground hover:bg-accent" }`} onClick={() => { - dispatch.setRuntimeMode(option.value); + setRuntimeMode(option.value); }} > diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index c76309a60a5d7a494d9c05371fc57acaa2c4a372..790b5a24e64a22debaea496f86dd388f4736e562 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -400,7 +400,7 @@ interface AppStore extends AppState { setRuntimeMode: (mode: RuntimeMode) => void; } -export const useAppStore = create((set, _get) => ({ +export const useStore = create((set, _get) => ({ ...readPersistedState(), syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)), @@ -421,31 +421,11 @@ export const useAppStore = create((set, _get) => ({ })); // Persist on every state change (only runtimeMode + expandedProjectCwds) -useAppStore.subscribe((state) => persistState(state)); - -// ── useStore (state + store API for call sites that want both) ──────────────── +useStore.subscribe((state) => persistState(state)); export function StoreProvider({ children }: { children: ReactNode }) { useEffect(() => { - persistState(useAppStore.getState()); + persistState(useStore.getState()); }, []); return createElement(Fragment, null, children); } - -type UseStoreCombinedResult = { - state: Pick; - dispatch: AppStore; -}; - -export function useStore(): UseStoreCombinedResult { - const state = useAppStore((s) => ({ - projects: s.projects, - threads: s.threads, - threadsHydrated: s.threadsHydrated, - runtimeMode: s.runtimeMode, - })); - return { - state, - dispatch: useAppStore.getState(), - }; -} diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..314b0e23c3e03ea12089067d9e92bc33bb5b6889 --- /dev/null +++ b/apps/web/src/terminalStateStore.test.ts @@ -0,0 +1,89 @@ +import { ThreadId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { useTerminalStateStore } from "./terminalStateStore"; + +const THREAD_ID = ThreadId.makeUnsafe("thread-1"); + +describe("terminalStateStore actions", () => { + beforeEach(() => { + if (typeof localStorage !== "undefined") { + localStorage.clear(); + } + useTerminalStateStore.setState({ terminalStateByThreadId: {} }); + }); + + it("returns a closed default terminal state for unknown threads", () => { + const terminalState = useTerminalStateStore.getState().getTerminalState(THREAD_ID); + expect(terminalState).toEqual({ + terminalOpen: false, + terminalHeight: 280, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + }); + }); + + it("opens and splits terminals into the active group", () => { + const store = useTerminalStateStore.getState(); + store.setTerminalOpen(THREAD_ID, true); + store.splitTerminal(THREAD_ID, "terminal-2"); + + const terminalState = useTerminalStateStore.getState().getTerminalState(THREAD_ID); + expect(terminalState.terminalOpen).toBe(true); + expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); + expect(terminalState.activeTerminalId).toBe("terminal-2"); + expect(terminalState.terminalGroups).toEqual([ + { id: "group-default", terminalIds: ["default", "terminal-2"] }, + ]); + }); + + it("creates new terminals in a separate group", () => { + useTerminalStateStore.getState().newTerminal(THREAD_ID, "terminal-2"); + + const terminalState = useTerminalStateStore.getState().getTerminalState(THREAD_ID); + expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); + expect(terminalState.activeTerminalId).toBe("terminal-2"); + expect(terminalState.activeTerminalGroupId).toBe("group-terminal-2"); + expect(terminalState.terminalGroups).toEqual([ + { id: "group-default", terminalIds: ["default"] }, + { id: "group-terminal-2", terminalIds: ["terminal-2"] }, + ]); + }); + + it("tracks and clears terminal subprocess activity", () => { + const store = useTerminalStateStore.getState(); + store.splitTerminal(THREAD_ID, "terminal-2"); + store.setTerminalActivity(THREAD_ID, "terminal-2", true); + expect(useTerminalStateStore.getState().getTerminalState(THREAD_ID).runningTerminalIds).toEqual([ + "terminal-2", + ]); + + store.setTerminalActivity(THREAD_ID, "terminal-2", false); + expect(useTerminalStateStore.getState().getTerminalState(THREAD_ID).runningTerminalIds).toEqual([]); + }); + + it("resets to default and clears persisted entry when closing the last terminal", () => { + const store = useTerminalStateStore.getState(); + store.closeTerminal(THREAD_ID, "default"); + + expect(useTerminalStateStore.getState().terminalStateByThreadId[THREAD_ID]).toBeUndefined(); + expect(useTerminalStateStore.getState().getTerminalState(THREAD_ID).terminalIds).toEqual(["default"]); + }); + + it("keeps a valid active terminal after closing an active split terminal", () => { + const store = useTerminalStateStore.getState(); + store.splitTerminal(THREAD_ID, "terminal-2"); + store.splitTerminal(THREAD_ID, "terminal-3"); + store.closeTerminal(THREAD_ID, "terminal-3"); + + const terminalState = useTerminalStateStore.getState().getTerminalState(THREAD_ID); + expect(terminalState.activeTerminalId).toBe("terminal-2"); + expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); + expect(terminalState.terminalGroups).toEqual([ + { id: "group-default", terminalIds: ["default", "terminal-2"] }, + ]); + }); +}); diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index c8582f1441b93354fd6035b4bd25c7cd9ab0bcc9..3f3621803bb201ce71d556725c8c0abfbb48f2ed 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -1,27 +1,409 @@ /** - * Single Zustand store for terminal UI state keyed by threadId. Used by both - * persisted (server) threads and draft threads; threadId is stable for both. + * Single Zustand store for terminal UI state keyed by threadId. + * + * Terminal transition helpers are intentionally private to keep the public + * API constrained to store actions/selectors. */ import type { ThreadId } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { - closeThreadTerminal, - getDefaultThreadTerminalState, - isDefaultThreadTerminalState, - newThreadTerminal, - setThreadActiveTerminal, - setThreadTerminalActivity, - setThreadTerminalHeight, - setThreadTerminalOpen, - splitThreadTerminal, - type ThreadTerminalState, -} from "./threadTerminalState"; - -export const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; - -export function selectThreadTerminalState( + DEFAULT_THREAD_TERMINAL_HEIGHT, + DEFAULT_THREAD_TERMINAL_ID, + MAX_THREAD_TERMINAL_COUNT, + type ThreadTerminalGroup, +} from "./types"; + +interface ThreadTerminalState { + terminalOpen: boolean; + terminalHeight: number; + terminalIds: string[]; + runningTerminalIds: string[]; + activeTerminalId: string; + terminalGroups: ThreadTerminalGroup[]; + activeTerminalGroupId: string; +} + +const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; + +function normalizeTerminalIds(terminalIds: string[]): string[] { + const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))].slice( + 0, + MAX_THREAD_TERMINAL_COUNT, + ); + return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID]; +} + +function normalizeRunningTerminalIds(runningTerminalIds: string[], terminalIds: string[]): string[] { + if (runningTerminalIds.length === 0) return []; + const validTerminalIdSet = new Set(terminalIds); + return [...new Set(runningTerminalIds)] + .map((id) => id.trim()) + .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); +} + +function fallbackGroupId(terminalId: string): string { + return `group-${terminalId}`; +} + +function assignUniqueGroupId(baseId: string, usedGroupIds: Set): string { + let candidate = baseId; + let index = 2; + while (usedGroupIds.has(candidate)) { + candidate = `${baseId}-${index}`; + index += 1; + } + usedGroupIds.add(candidate); + return candidate; +} + +function findGroupIndexByTerminalId(terminalGroups: ThreadTerminalGroup[], terminalId: string): number { + return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId)); +} + +function normalizeTerminalGroupIds(terminalIds: string[]): string[] { + return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; +} + +function normalizeTerminalGroups( + terminalGroups: ThreadTerminalGroup[], + terminalIds: string[], +): ThreadTerminalGroup[] { + const validTerminalIdSet = new Set(terminalIds); + const assignedTerminalIds = new Set(); + const nextGroups: ThreadTerminalGroup[] = []; + const usedGroupIds = new Set(); + + for (const group of terminalGroups) { + const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => { + if (!validTerminalIdSet.has(terminalId)) return false; + if (assignedTerminalIds.has(terminalId)) return false; + return true; + }); + if (groupTerminalIds.length === 0) continue; + for (const terminalId of groupTerminalIds) { + assignedTerminalIds.add(terminalId); + } + const baseGroupId = + group.id.trim().length > 0 + ? group.id.trim() + : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); + nextGroups.push({ + id: assignUniqueGroupId(baseGroupId, usedGroupIds), + terminalIds: groupTerminalIds, + }); + } + + for (const terminalId of terminalIds) { + if (assignedTerminalIds.has(terminalId)) continue; + nextGroups.push({ + id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), + terminalIds: [terminalId], + }); + } + + if (nextGroups.length === 0) { + return [ + { + id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ]; + } + + 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) + ); +} + +const DEFAULT_THREAD_TERMINAL_STATE: ThreadTerminalState = Object.freeze({ + terminalOpen: false, + terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + runningTerminalIds: [], + activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, + terminalGroups: [ + { + id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ], + activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), +}); + +function createDefaultThreadTerminalState(): ThreadTerminalState { + return { ...DEFAULT_THREAD_TERMINAL_STATE }; +} + +function getDefaultThreadTerminalState(): ThreadTerminalState { + return DEFAULT_THREAD_TERMINAL_STATE; +} + +function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTerminalState { + const terminalIds = normalizeTerminalIds(state.terminalIds); + const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; + const runningTerminalIds = normalizeRunningTerminalIds(state.runningTerminalIds, nextTerminalIds); + const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) + ? state.activeTerminalId + : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); + const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds); + const activeGroupIdFromState = terminalGroups.some((group) => group.id === state.activeTerminalGroupId) + ? state.activeTerminalGroupId + : null; + const activeGroupIdFromTerminal = + terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null; + + const normalized: ThreadTerminalState = { + terminalOpen: state.terminalOpen, + terminalHeight: + Number.isFinite(state.terminalHeight) && state.terminalHeight > 0 + ? state.terminalHeight + : DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: nextTerminalIds, + runningTerminalIds, + activeTerminalId, + terminalGroups, + activeTerminalGroupId: + activeGroupIdFromState ?? + activeGroupIdFromTerminal ?? + terminalGroups[0]?.id ?? + fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), + }; + return threadTerminalStateEqual(state, normalized) ? state : normalized; +} + +function isDefaultThreadTerminalState(state: ThreadTerminalState): boolean { + const normalized = normalizeThreadTerminalState(state); + return threadTerminalStateEqual(normalized, DEFAULT_THREAD_TERMINAL_STATE); +} + +function isValidTerminalId(terminalId: string): boolean { + return terminalId.trim().length > 0; +} + +function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { + return groups.map((group) => ({ + id: group.id, + terminalIds: [...group.terminalIds], + })); +} + +function upsertTerminalIntoGroups( + state: ThreadTerminalState, + terminalId: string, + mode: "split" | "new", +): ThreadTerminalState { + const normalized = normalizeThreadTerminalState(state); + if (!isValidTerminalId(terminalId)) { + return normalized; + } + + const isNewTerminal = !normalized.terminalIds.includes(terminalId); + if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { + return normalized; + } + + const terminalIds = isNewTerminal ? [...normalized.terminalIds, terminalId] : normalized.terminalIds; + const terminalGroups = copyTerminalGroups(normalized.terminalGroups); + + const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId); + if (existingGroupIndex >= 0) { + terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter( + (id) => id !== terminalId, + ); + if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { + terminalGroups.splice(existingGroupIndex, 1); + } + } + + if (mode === "new") { + const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); + const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds); + terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] }); + return normalizeThreadTerminalState({ + ...normalized, + terminalOpen: true, + terminalIds, + activeTerminalId: terminalId, + terminalGroups, + activeTerminalGroupId: nextGroupId, + }); + } + + 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); + terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] }); + activeGroupIndex = terminalGroups.length - 1; + } + + const destinationGroup = terminalGroups[activeGroupIndex]; + if (!destinationGroup) { + return normalized; + } + + if (!destinationGroup.terminalIds.includes(terminalId)) { + const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); + if (anchorIndex >= 0) { + destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId); + } else { + destinationGroup.terminalIds.push(terminalId); + } + } + + return normalizeThreadTerminalState({ + ...normalized, + terminalOpen: true, + terminalIds, + activeTerminalId: terminalId, + terminalGroups, + activeTerminalGroupId: destinationGroup.id, + }); +} + +function setThreadTerminalOpen(state: ThreadTerminalState, open: boolean): ThreadTerminalState { + const normalized = normalizeThreadTerminalState(state); + if (normalized.terminalOpen === open) return normalized; + return { ...normalized, terminalOpen: open }; +} + +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 }; +} + +function splitThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState { + return upsertTerminalIntoGroups(state, terminalId, "split"); +} + +function newThreadTerminal(state: ThreadTerminalState, terminalId: string): ThreadTerminalState { + return upsertTerminalIntoGroups(state, terminalId, "new"); +} + +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, + }; +} + +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, + }); +} + +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] }; +} + +function selectThreadTerminalState( terminalStateByThreadId: Record, threadId: ThreadId, ): ThreadTerminalState { @@ -62,6 +444,7 @@ function updateTerminalStateByThreadId( 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; @@ -78,7 +461,7 @@ interface TerminalStateStoreState { export const useTerminalStateStore = create()( persist( - (set) => { + (set, get) => { const updateTerminal = ( threadId: ThreadId, updater: (state: ThreadTerminalState) => ThreadTerminalState, @@ -97,8 +480,10 @@ export const useTerminalStateStore = create()( }; }); }; + return { terminalStateByThreadId: {}, + getTerminalState: (threadId) => selectThreadTerminalState(get().terminalStateByThreadId, threadId), setTerminalOpen: (threadId, open) => updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)), setTerminalHeight: (threadId, height) => diff --git a/apps/web/src/threadTerminalState.test.ts b/apps/web/src/threadTerminalState.test.ts deleted file mode 100644 index e186a914dea9ba3f948d80a9fee7344ca0b41d50..0000000000000000000000000000000000000000 --- a/apps/web/src/threadTerminalState.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - closeThreadTerminal, - createDefaultThreadTerminalState, - newThreadTerminal, - setThreadTerminalOpen, - splitThreadTerminal, -} from "./threadTerminalState"; - -describe("threadTerminalState", () => { - it("creates a closed default terminal state", () => { - expect(createDefaultThreadTerminalState()).toEqual({ - terminalOpen: false, - terminalHeight: 280, - terminalIds: ["default"], - runningTerminalIds: [], - activeTerminalId: "default", - terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], - activeTerminalGroupId: "group-default", - }); - }); - - it("opens and splits terminal tabs into the active group", () => { - const opened = setThreadTerminalOpen(createDefaultThreadTerminalState(), true); - const split = splitThreadTerminal(opened, "terminal-2"); - - expect(split.terminalOpen).toBe(true); - expect(split.terminalIds).toEqual(["default", "terminal-2"]); - expect(split.activeTerminalId).toBe("terminal-2"); - expect(split.terminalGroups).toEqual([ - { - id: "group-default", - terminalIds: ["default", "terminal-2"], - }, - ]); - }); - - it("creates new terminals in a new group", () => { - const next = newThreadTerminal(createDefaultThreadTerminalState(), "terminal-2"); - - expect(next.terminalIds).toEqual(["default", "terminal-2"]); - expect(next.activeTerminalId).toBe("terminal-2"); - expect(next.activeTerminalGroupId).toBe("group-terminal-2"); - expect(next.terminalGroups).toEqual([ - { id: "group-default", terminalIds: ["default"] }, - { id: "group-terminal-2", terminalIds: ["terminal-2"] }, - ]); - }); - - it("resets to default when closing the last terminal", () => { - const closed = closeThreadTerminal(createDefaultThreadTerminalState(), "default"); - - expect(closed).toEqual(createDefaultThreadTerminalState()); - }); - - it("keeps a valid active terminal after closing an active split terminal", () => { - const splitOnce = splitThreadTerminal(createDefaultThreadTerminalState(), "terminal-2"); - const splitTwice = splitThreadTerminal(splitOnce, "terminal-3"); - - const closed = closeThreadTerminal(splitTwice, "terminal-3"); - - expect(closed.activeTerminalId).toBe("terminal-2"); - expect(closed.terminalIds).toEqual(["default", "terminal-2"]); - expect(closed.terminalGroups).toEqual([ - { id: "group-default", terminalIds: ["default", "terminal-2"] }, - ]); - }); -}); diff --git a/apps/web/src/threadTerminalState.ts b/apps/web/src/threadTerminalState.ts deleted file mode 100644 index 0d7e0fd18b91ca67a375559aab54120b6bd27680..0000000000000000000000000000000000000000 --- a/apps/web/src/threadTerminalState.ts +++ /dev/null @@ -1,428 +0,0 @@ -/** - * Shared thread terminal state utilities used by the terminal Zustand store. - */ - -import { - DEFAULT_THREAD_TERMINAL_HEIGHT, - DEFAULT_THREAD_TERMINAL_ID, - MAX_THREAD_TERMINAL_COUNT, - type ThreadTerminalGroup, -} from "./types"; - -/** Terminal slice shared by Thread and draft thread. Same shape in both stores. */ -export interface ThreadTerminalState { - terminalOpen: boolean; - terminalHeight: number; - terminalIds: string[]; - runningTerminalIds: string[]; - activeTerminalId: string; - terminalGroups: ThreadTerminalGroup[]; - activeTerminalGroupId: string; -} - -// ─── 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( - 0, - MAX_THREAD_TERMINAL_COUNT, - ); - return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID]; -} - -export function normalizeRunningTerminalIds( - runningTerminalIds: string[], - terminalIds: string[], -): string[] { - if (runningTerminalIds.length === 0) return []; - const validTerminalIdSet = new Set(terminalIds); - return [...new Set(runningTerminalIds)] - .map((id) => id.trim()) - .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); -} - -export function fallbackGroupId(terminalId: string): string { - return `group-${terminalId}`; -} - -function assignUniqueGroupId(baseId: string, usedGroupIds: Set): string { - let candidate = baseId; - let index = 2; - while (usedGroupIds.has(candidate)) { - candidate = `${baseId}-${index}`; - index += 1; - } - usedGroupIds.add(candidate); - return candidate; -} - -export function findGroupIndexByTerminalId( - terminalGroups: ThreadTerminalGroup[], - terminalId: string, -): number { - return terminalGroups.findIndex((group) => group.terminalIds.includes(terminalId)); -} - -function normalizeTerminalGroupIds(terminalIds: string[]): string[] { - return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; -} - -/** Normalize groups to match terminalIds; used by both thread and draft. */ -export function normalizeTerminalGroups( - terminalGroups: ThreadTerminalGroup[], - terminalIds: string[], -): ThreadTerminalGroup[] { - const validTerminalIdSet = new Set(terminalIds); - const assignedTerminalIds = new Set(); - const nextGroups: ThreadTerminalGroup[] = []; - const usedGroupIds = new Set(); - - for (const group of terminalGroups) { - const groupTerminalIds = normalizeTerminalGroupIds(group.terminalIds).filter((terminalId) => { - if (!validTerminalIdSet.has(terminalId)) return false; - if (assignedTerminalIds.has(terminalId)) return false; - return true; - }); - if (groupTerminalIds.length === 0) continue; - for (const terminalId of groupTerminalIds) { - assignedTerminalIds.add(terminalId); - } - const baseGroupId = - group.id.trim().length > 0 - ? group.id.trim() - : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); - nextGroups.push({ - id: assignUniqueGroupId(baseGroupId, usedGroupIds), - terminalIds: groupTerminalIds, - }); - } - - for (const terminalId of terminalIds) { - if (assignedTerminalIds.has(terminalId)) continue; - nextGroups.push({ - id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), - terminalIds: [terminalId], - }); - } - - if (nextGroups.length === 0) { - return [ - { - id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ]; - } - - 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({ - terminalOpen: false, - terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - runningTerminalIds: [], - activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - terminalGroups: [ - { - id: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ], - activeTerminalGroupId: fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), -}); - -export function createDefaultThreadTerminalState(): ThreadTerminalState { - return { ...DEFAULT_THREAD_TERMINAL_STATE }; -} - -export function getDefaultThreadTerminalState(): ThreadTerminalState { - return DEFAULT_THREAD_TERMINAL_STATE; -} - -export function normalizeThreadTerminalState(state: ThreadTerminalState): ThreadTerminalState { - const terminalIds = normalizeTerminalIds(state.terminalIds); - const nextTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; - const runningTerminalIds = normalizeRunningTerminalIds( - state.runningTerminalIds, - nextTerminalIds, - ); - const activeTerminalId = nextTerminalIds.includes(state.activeTerminalId) - ? state.activeTerminalId - : (nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); - const terminalGroups = normalizeTerminalGroups(state.terminalGroups, nextTerminalIds); - const activeGroupIdFromState = terminalGroups.some((group) => group.id === state.activeTerminalGroupId) - ? state.activeTerminalGroupId - : null; - const activeGroupIdFromTerminal = - terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? null; - - const normalized: ThreadTerminalState = { - terminalOpen: state.terminalOpen, - terminalHeight: - Number.isFinite(state.terminalHeight) && state.terminalHeight > 0 - ? state.terminalHeight - : DEFAULT_THREAD_TERMINAL_HEIGHT, - terminalIds: nextTerminalIds, - runningTerminalIds, - activeTerminalId, - terminalGroups, - activeTerminalGroupId: - activeGroupIdFromState ?? - activeGroupIdFromTerminal ?? - terminalGroups[0]?.id ?? - fallbackGroupId(DEFAULT_THREAD_TERMINAL_ID), - }; - return threadTerminalStateEqual(state, normalized) ? state : normalized; -} - -export function isDefaultThreadTerminalState(state: ThreadTerminalState): boolean { - const normalized = normalizeThreadTerminalState(state); - return threadTerminalStateEqual(normalized, DEFAULT_THREAD_TERMINAL_STATE); -} - -function isValidTerminalId(terminalId: string): boolean { - return terminalId.trim().length > 0; -} - -function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { - return groups.map((group) => ({ - id: group.id, - terminalIds: [...group.terminalIds], - })); -} - -function upsertTerminalIntoGroups( - state: ThreadTerminalState, - terminalId: string, - mode: "split" | "new", -): ThreadTerminalState { - const normalized = normalizeThreadTerminalState(state); - if (!isValidTerminalId(terminalId)) { - return normalized; - } - - const isNewTerminal = !normalized.terminalIds.includes(terminalId); - if (isNewTerminal && normalized.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { - return normalized; - } - - const terminalIds = isNewTerminal ? [...normalized.terminalIds, terminalId] : normalized.terminalIds; - const terminalGroups = copyTerminalGroups(normalized.terminalGroups); - - const existingGroupIndex = findGroupIndexByTerminalId(terminalGroups, terminalId); - if (existingGroupIndex >= 0) { - terminalGroups[existingGroupIndex]!.terminalIds = terminalGroups[existingGroupIndex]!.terminalIds.filter( - (id) => id !== terminalId, - ); - if (terminalGroups[existingGroupIndex]!.terminalIds.length === 0) { - terminalGroups.splice(existingGroupIndex, 1); - } - } - - if (mode === "new") { - const usedGroupIds = new Set(terminalGroups.map((group) => group.id)); - const nextGroupId = assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds); - terminalGroups.push({ id: nextGroupId, terminalIds: [terminalId] }); - return normalizeThreadTerminalState({ - ...normalized, - terminalOpen: true, - terminalIds, - activeTerminalId: terminalId, - terminalGroups, - activeTerminalGroupId: nextGroupId, - }); - } - - 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); - terminalGroups.push({ id: nextGroupId, terminalIds: [normalized.activeTerminalId] }); - activeGroupIndex = terminalGroups.length - 1; - } - - const destinationGroup = terminalGroups[activeGroupIndex]; - if (!destinationGroup) { - return normalized; - } - - if (!destinationGroup.terminalIds.includes(terminalId)) { - const anchorIndex = destinationGroup.terminalIds.indexOf(normalized.activeTerminalId); - if (anchorIndex >= 0) { - destinationGroup.terminalIds.splice(anchorIndex + 1, 0, terminalId); - } else { - destinationGroup.terminalIds.push(terminalId); - } - } - - return normalizeThreadTerminalState({ - ...normalized, - terminalOpen: true, - terminalIds, - 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] }; -} -- 2.54.0