/** * 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 { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, 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))]; 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, terminalIds: [...DEFAULT_THREAD_TERMINAL_STATE.terminalIds], runningTerminalIds: [...DEFAULT_THREAD_TERMINAL_STATE.runningTerminalIds], terminalGroups: copyTerminalGroups(DEFAULT_THREAD_TERMINAL_STATE.terminalGroups), }; } 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); 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 ( isNewTerminal && !destinationGroup.terminalIds.includes(terminalId) && destinationGroup.terminalIds.length >= MAX_TERMINALS_PER_GROUP ) { 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] }; } export function selectThreadTerminalState( terminalStateByThreadId: Record, threadId: ThreadId, ): ThreadTerminalState { if (threadId.length === 0) { return getDefaultThreadTerminalState(); } return terminalStateByThreadId[threadId] ?? getDefaultThreadTerminalState(); } function updateTerminalStateByThreadId( terminalStateByThreadId: Record, threadId: ThreadId, updater: (state: ThreadTerminalState) => ThreadTerminalState, ): Record { if (threadId.length === 0) { return terminalStateByThreadId; } const current = selectThreadTerminalState(terminalStateByThreadId, threadId); const next = updater(current); if (next === current) { return terminalStateByThreadId; } if (isDefaultThreadTerminalState(next)) { if (terminalStateByThreadId[threadId] === undefined) { return terminalStateByThreadId; } const { [threadId]: _removed, ...rest } = terminalStateByThreadId; return rest as Record; } return { ...terminalStateByThreadId, [threadId]: next, }; } interface TerminalStateStoreState { terminalStateByThreadId: Record; setTerminalOpen: (threadId: ThreadId, open: boolean) => void; setTerminalHeight: (threadId: ThreadId, height: number) => void; splitTerminal: (threadId: ThreadId, terminalId: string) => void; newTerminal: (threadId: ThreadId, terminalId: string) => void; setActiveTerminal: (threadId: ThreadId, terminalId: string) => void; closeTerminal: (threadId: ThreadId, terminalId: string) => void; setTerminalActivity: ( threadId: ThreadId, terminalId: string, hasRunningSubprocess: boolean, ) => void; clearTerminalState: (threadId: ThreadId) => void; removeOrphanedTerminalStates: (activeThreadIds: Set) => void; } export const useTerminalStateStore = create()( persist( (set) => { const updateTerminal = ( threadId: ThreadId, updater: (state: ThreadTerminalState) => ThreadTerminalState, ) => { set((state) => { const nextTerminalStateByThreadId = updateTerminalStateByThreadId( state.terminalStateByThreadId, threadId, updater, ); if (nextTerminalStateByThreadId === state.terminalStateByThreadId) { return state; } return { terminalStateByThreadId: nextTerminalStateByThreadId, }; }); }; return { terminalStateByThreadId: {}, setTerminalOpen: (threadId, open) => updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)), setTerminalHeight: (threadId, height) => updateTerminal(threadId, (state) => setThreadTerminalHeight(state, height)), splitTerminal: (threadId, terminalId) => updateTerminal(threadId, (state) => splitThreadTerminal(state, terminalId)), newTerminal: (threadId, terminalId) => updateTerminal(threadId, (state) => newThreadTerminal(state, terminalId)), setActiveTerminal: (threadId, terminalId) => updateTerminal(threadId, (state) => setThreadActiveTerminal(state, terminalId)), closeTerminal: (threadId, terminalId) => updateTerminal(threadId, (state) => closeThreadTerminal(state, terminalId)), setTerminalActivity: (threadId, terminalId, hasRunningSubprocess) => updateTerminal(threadId, (state) => setThreadTerminalActivity(state, terminalId, hasRunningSubprocess), ), clearTerminalState: (threadId) => updateTerminal(threadId, () => createDefaultThreadTerminalState()), removeOrphanedTerminalStates: (activeThreadIds) => set((state) => { const orphanedIds = Object.keys(state.terminalStateByThreadId).filter( (id) => !activeThreadIds.has(id as ThreadId), ); if (orphanedIds.length === 0) return state; const next = { ...state.terminalStateByThreadId }; for (const id of orphanedIds) { delete next[id as ThreadId]; } return { terminalStateByThreadId: next }; }), }; }, { name: TERMINAL_STATE_STORAGE_KEY, version: 1, storage: createJSONStorage(() => localStorage), partialize: (state) => ({ terminalStateByThreadId: state.terminalStateByThreadId, }), }, ), );