import { create } from 'zustand'; import { devtools, persist, createJSONStorage } from 'zustand/middleware'; import type { PersistStorage } from 'zustand/middleware'; import { z } from 'zod'; import { getSafeSessionStorage } from '@/stores/utils/safeStorage'; import type { TerminalServerSession } from '@/lib/api/types'; import { normalizeTerminalDirectory } from '@/lib/pathNormalization'; export type TerminalChunkSize = { cols: number; rows: number }; export interface TerminalChunk { id: number; data: string; replayData?: string; byteLength: number; /** * PTY size this chunk was drawn for. Only snapshot history carries it: the * viewport replays such a chunk at this size and then re-fits, because * shell output laid out for one width turns into stray fragments when it is * written into an emulator of another width. */ size?: TerminalChunkSize; } /** * Scrollback lives outside `sessions` because PTY output arrives at streaming * frequency. Keeping it here leaves tab metadata referentially stable, so * output cannot rerender the tab strip or rewrite the persisted snapshot. */ export type TerminalBuffer = { chunks: TerminalChunk[]; byteLength: number; lastSequence: number; }; const EMPTY_TERMINAL_BUFFER: TerminalBuffer = Object.freeze({ chunks: Object.freeze([]) as unknown as TerminalChunk[], byteLength: 0, lastSequence: -1, }); export type TerminalTabLifecycle = 'idle' | 'starting' | 'running' | 'stopping' | 'exited'; export const ACTIVE_PROJECT_ACTION_LIFECYCLES: ReadonlySet = new Set([ 'starting', 'running', 'stopping', ]); export type TerminalTabPurpose = | { type: 'terminal' } | { type: 'project-action'; actionId: string; executionId: string | null }; export type TerminalTab = { id: string; terminalSessionId: string | null; lifecycle: TerminalTabLifecycle; purpose: TerminalTabPurpose; label: string; iconKey: string | null; isConnecting: boolean; createdAt: number; previewUrl: string | null; previewAutoOpened: boolean; previewUrlLocked: boolean; }; export type DirectoryTerminalState = { tabs: TerminalTab[]; activeTabId: string | null; }; export const directoryMayHaveActiveProjectAction = (state: DirectoryTerminalState | undefined): boolean => Boolean(state?.tabs.some((tab) => isProjectActionPurpose(tab.purpose) && tab.lifecycle !== 'exited')); type TerminalActionMutationRevisions = ReadonlyMap; type ReconcileServerSessionsOptions = { startedActionMutationRevisions?: TerminalActionMutationRevisions; }; interface TerminalStore { sessions: Map; buffers: Map; actionMutationRevisions: Map; nextActionMutationRevision: number; nextChunkId: number; nextTabId: number; hasHydrated: boolean; ensureDirectory: (directory: string) => void; getDirectoryState: (directory: string) => DirectoryTerminalState | undefined; getActiveTab: (directory: string) => TerminalTab | undefined; getBuffer: (directory: string, tabId: string) => TerminalBuffer; matchesActionExecution: (directory: string, tabId: string, executionId: string | null | undefined) => boolean; captureStartedActionMutationRevisions: (directory: string) => TerminalActionMutationRevisions; createTab: (directory: string) => string; reconcileServerSessions: (directory: string, serverSessions: TerminalServerSession[], options?: ReconcileServerSessionsOptions) => void; setActiveTab: (directory: string, tabId: string) => void; setTabLabel: (directory: string, tabId: string, label: string) => void; setTabIconKey: (directory: string, tabId: string, iconKey: string | null) => void; closeTab: (directory: string, tabId: string) => void; setTabPurpose: (directory: string, tabId: string, purpose: TerminalTabPurpose) => void; allocateActionExecution: (directory: string, tabId: string, actionId: string) => string | null; setTabSessionId: (directory: string, tabId: string, sessionId: string | null, options?: { expectedExecutionId?: string | null }) => void; setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle, options?: { expectedExecutionId?: string | null }) => void; setConnecting: (directory: string, tabId: string, isConnecting: boolean, options?: { expectedExecutionId?: string | null }) => void; replaceBuffer: (directory: string, tabId: string, content: string, sequence: number, size?: TerminalChunkSize) => void; appendToBuffer: (directory: string, tabId: string, chunk: string, sequence?: number, replayData?: string) => void; setTabPreviewUrl: (directory: string, tabId: string, url: string | null, options?: { locked?: boolean; autoOpened?: boolean; expectedExecutionId?: string | null }) => void; markPreviewAutoOpened: (directory: string, tabId: string) => void; removeDirectory: (directory: string) => void; clearAll: () => void; } const TERMINAL_BUFFER_LIMIT = 512 * 1024; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); /** One encode per chunk: the trimmed text and its UTF-8 size are needed together. */ const trimToBufferLimit = (value: string): { text: string; byteLength: number } => { const bytes = textEncoder.encode(value); if (bytes.byteLength <= TERMINAL_BUFFER_LIMIT) return { text: value, byteLength: bytes.byteLength }; let start = bytes.byteLength - TERMINAL_BUFFER_LIMIT; while (start < bytes.byteLength && (bytes[start] & 0xc0) === 0x80) start += 1; const retained = bytes.subarray(start); return { text: textDecoder.decode(retained), byteLength: retained.byteLength }; }; // NUL cannot appear in a directory path or a tab id, so the composite key is unambiguous. const bufferKey = (directory: string, tabId: string): string => `${directory}\u0000${tabId}`; const dropBufferKeys = ( buffers: Map, matches: (key: string) => boolean, ): Map | null => { let next: Map | null = null; for (const key of buffers.keys()) { if (!matches(key)) continue; next ??= new Map(buffers); next.delete(key); } return next; }; const TERMINAL_STORE_NAME = 'terminal-store'; let hydrationListenerAttached = false; let fallbackTabId = 0; const persistedProjectActionPurposeSchema = z.object({ type: z.literal('project-action'), actionId: z.string(), }); const createTerminalTabId = (): string => { if (typeof globalThis.crypto?.randomUUID === 'function') return `tab-${globalThis.crypto.randomUUID()}`; fallbackTabId += 1; return `tab-${Date.now().toString(36)}-${fallbackTabId.toString(36)}`; }; type PersistedTerminalTabPurpose = | { type: 'terminal' } | { type: 'project-action'; actionId: string }; type PersistedTerminalTab = Pick & { purpose?: PersistedTerminalTabPurpose; }; type PersistedDirectoryTerminalState = { tabs: PersistedTerminalTab[]; activeTabId: string | null; }; type PersistedTerminalStoreState = { sessions: Array<[string, PersistedDirectoryTerminalState]>; nextTabId: number; }; const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; const isProjectActionPurpose = (purpose: TerminalTabPurpose): purpose is Extract => purpose.type === 'project-action'; const shouldApplyExecutionGuard = (tab: TerminalTab, expectedExecutionId: string | null | undefined): boolean => { if (!isProjectActionPurpose(tab.purpose)) { return expectedExecutionId == null; } if (expectedExecutionId === undefined) { return true; } return tab.purpose.executionId === expectedExecutionId; }; const createExecutionId = (): string => createTerminalTabId().replace(/^tab-/, 'exec-'); const toActionLifecycle = (status: TerminalServerSession['status']): TerminalTabLifecycle => status === 'running' ? 'running' : 'exited'; const toActionPurposeFromSession = ( purpose: Extract, status: TerminalServerSession['status'], ): Extract => ({ type: 'project-action', actionId: purpose.actionId, executionId: status === 'running' ? purpose.executionId : null, }); const authoritativeTerminalSessions = (sessions: TerminalServerSession[]): TerminalServerSession[] => { const actions = new Map(); const terminals: TerminalServerSession[] = []; for (const session of sessions) { if (session.purpose?.type !== 'project-action') { terminals.push(session); continue; } const previous = actions.get(session.purpose.actionId); if (!previous || (session.status === 'running' && previous.status !== 'running') || (session.status === previous.status && ( (session.createdAt ?? 0) > (previous.createdAt ?? 0) || (session.createdAt === previous.createdAt && session.sessionId > previous.sessionId) ))) actions.set(session.purpose.actionId, session); } return [...terminals, ...actions.values()]; }; const resetActionPreview = { previewUrl: null, previewAutoOpened: false, previewUrlLocked: false }; const toAdoptedActionLabel = (actionId: string): string => { const trimmed = actionId.trim(); return trimmed || 'Action'; }; const tabIdNumber = (tabId: string): number | null => { const match = /^tab-(\d+)$/.exec(tabId); if (!match) return null; const num = Number(match[1]); return Number.isFinite(num) ? num : null; }; const normalizeDirectory = normalizeTerminalDirectory; const actionMutationRevisionKey = (directory: string, actionId: string): string => `${directory}\u0000${actionId}`; const updateActionMutationRevision = ( revisions: Map, directory: string, actionId: string, revision: number, ) => { revisions.set(actionMutationRevisionKey(directory, actionId), revision); }; const hasActionMutatedSinceRequestStarted = ( state: Pick, directory: string, actionId: string, startedActionMutationRevisions: TerminalActionMutationRevisions | undefined, ): boolean => { if (!startedActionMutationRevisions) { return false; } const revisionKey = actionMutationRevisionKey(directory, actionId); const currentRevision = state.actionMutationRevisions.get(revisionKey) ?? 0; const startedRevision = startedActionMutationRevisions.get(revisionKey) ?? 0; return currentRevision > startedRevision; }; const DEFAULT_TAB_LABEL_PATTERN = /^Terminal(?: (\d+))?$/; /** * Default labels must stay unique among the directory's open tabs even after * closes (#2718), so number from the highest existing "Terminal N" suffix * instead of the live tab count. Labels are persisted with the tabs, so the * derivation also survives reloads without a dedicated counter. User-renamed * labels only participate when they match the default pattern; they are never * rewritten. */ const nextDefaultTabLabel = (tabs: readonly TerminalTab[]): string => { let highest = 0; for (const tab of tabs) { const match = DEFAULT_TAB_LABEL_PATTERN.exec(tab.label); if (!match) continue; const value = match[1] ? Number.parseInt(match[1], 10) : 1; if (Number.isSafeInteger(value)) highest = Math.max(highest, value); } return highest === 0 ? 'Terminal' : `Terminal ${highest + 1}`; }; /** A project action the user would expect to see marked as running. */ export const isActiveProjectActionTab = (tab: TerminalTab): boolean => tab.purpose.type === 'project-action' && tab.purpose.executionId !== null && ACTIVE_PROJECT_ACTION_LIFECYCLES.has(tab.lifecycle); const isLiveRunningTerminal = (tab: TerminalTab | undefined): boolean => Boolean(tab && tab.terminalSessionId !== null && tab.lifecycle === 'running'); const createEmptyTab = (id: string, label: string): TerminalTab => ({ id, terminalSessionId: null, lifecycle: 'idle', purpose: { type: 'terminal' }, label, iconKey: null, isConnecting: false, createdAt: Date.now(), previewUrl: null, previewAutoOpened: false, previewUrlLocked: false, }); const createEmptyDirectoryState = (firstTab: TerminalTab): DirectoryTerminalState => ({ tabs: [firstTab], activeTabId: firstTab.id, }); const findTabIndex = (state: DirectoryTerminalState, tabId: string): number => state.tabs.findIndex((t) => t.id === tabId); /** * Zustand persist runs `partialize` and writes storage after every `set`, and * terminal output calls `set` at streaming frequency. Only `sessions` and * `nextTabId` are persisted, so reuse the previous projection whenever both are * referentially unchanged and skip the write for an unchanged projection. */ let lastPartializeInput: { sessions: unknown; nextTabId: number } | null = null; let lastPartializeResult: PersistedTerminalStoreState | null = null; const partializeTerminalStore = (state: TerminalStore): PersistedTerminalStoreState => { if ( lastPartializeResult && lastPartializeInput?.sessions === state.sessions && lastPartializeInput.nextTabId === state.nextTabId ) { return lastPartializeResult; } const result: PersistedTerminalStoreState = { sessions: Array.from(state.sessions.entries()).map(([directory, dirState]) => [ directory, { activeTabId: dirState.activeTabId, tabs: dirState.tabs.map((tab) => ({ id: tab.id, label: tab.label, iconKey: tab.iconKey, createdAt: tab.createdAt, purpose: tab.purpose.type === 'project-action' ? { type: 'project-action', actionId: tab.purpose.actionId } : { type: 'terminal' }, })), }, ]), nextTabId: state.nextTabId, }; lastPartializeInput = { sessions: state.sessions, nextTabId: state.nextTabId }; lastPartializeResult = result; return result; }; const createDedupedTerminalStorage = (): PersistStorage | undefined => { const base = createJSONStorage(() => getSafeSessionStorage()); if (!base) return undefined; let lastWrittenState: PersistedTerminalStoreState | null = null; return { getItem: (name) => base.getItem(name), setItem: (name, value) => { if (value.state === lastWrittenState) return; lastWrittenState = value.state; return base.setItem(name, value); }, removeItem: (name) => { lastWrittenState = null; return base.removeItem(name); }, }; }; export const useTerminalStore = create()( devtools( persist( (set, get) => ({ sessions: new Map(), buffers: new Map(), actionMutationRevisions: new Map(), nextActionMutationRevision: 1, nextChunkId: 1, nextTabId: 1, hasHydrated: typeof window === 'undefined', ensureDirectory: (directory: string) => { const key = normalizeDirectory(directory); if (!key) return; set((state) => { if (state.sessions.has(key)) { return state; } const newSessions = new Map(state.sessions); const tabId = createTerminalTabId(); const firstTab = createEmptyTab(tabId, 'Terminal'); newSessions.set(key, createEmptyDirectoryState(firstTab)); return { sessions: newSessions, nextTabId: state.nextTabId + 1 }; }); }, getDirectoryState: (directory: string) => { const key = normalizeDirectory(directory); return get().sessions.get(key); }, getActiveTab: (directory: string) => { const key = normalizeDirectory(directory); const entry = get().sessions.get(key); if (!entry) return undefined; const activeId = entry.activeTabId; if (!activeId) return entry.tabs[0]; return entry.tabs.find((t) => t.id === activeId) ?? entry.tabs[0]; }, getBuffer: (directory: string, tabId: string) => get().buffers.get(bufferKey(normalizeDirectory(directory), tabId)) ?? EMPTY_TERMINAL_BUFFER, matchesActionExecution: (directory, tabId, executionId) => { const tab = get().sessions.get(normalizeDirectory(directory))?.tabs.find((entry) => entry.id === tabId); return Boolean(tab && isProjectActionPurpose(tab.purpose) && tab.purpose.executionId === executionId); }, captureStartedActionMutationRevisions: (directory) => { const key = normalizeDirectory(directory); const prefix = actionMutationRevisionKey(key, ''); const snapshot = new Map(); for (const [actionKey, revision] of get().actionMutationRevisions.entries()) { if (!actionKey.startsWith(prefix)) continue; snapshot.set(actionKey, revision); } return snapshot; }, createTab: (directory: string) => { const key = normalizeDirectory(directory); if (!key) { return 'tab-invalid'; } const tabId = createTerminalTabId(); set((state) => { const newSessions = new Map(state.sessions); const existing = newSessions.get(key); const nextTabId = state.nextTabId + 1; const label = nextDefaultTabLabel(existing?.tabs ?? []); const tab = createEmptyTab(tabId, label); if (!existing) { newSessions.set(key, createEmptyDirectoryState(tab)); } else { newSessions.set(key, { ...existing, tabs: [...existing.tabs, tab], }); } return { sessions: newSessions, nextTabId }; }); return tabId; }, reconcileServerSessions: (directory, serverSessions, options) => { const key = normalizeDirectory(directory); if (!key) return; set((state) => { const existing = state.sessions.get(key); if (!existing && serverSessions.length === 0) { return state; } const tabs = [...(existing?.tabs ?? [])]; let buffers = state.buffers; let tabsChanged = false; let activationCandidate: { tabId: string; createdAt: number; index: number } | null = null; const listedActionIds = new Set(); const matchedTabIds = new Set(); const placeholder = tabs.length === 1 && tabs[0].terminalSessionId === null && tabs[0].lifecycle === 'idle' && tabs[0].purpose.type === 'terminal' && !state.buffers.has(bufferKey(key, tabs[0].id)) ? tabs[0] : null; if (placeholder && serverSessions.length > 0) tabs.length = 0; for (const session of authoritativeTerminalSessions(serverSessions)) { let matchIndex = tabs.findIndex((tab) => tab.terminalSessionId === session.sessionId || tab.id === session.sessionId); const sessionPurpose = session.purpose; const staleActionAuthority = sessionPurpose?.type === 'project-action' && hasActionMutatedSinceRequestStarted(state, key, sessionPurpose.actionId, options?.startedActionMutationRevisions); if (sessionPurpose?.type === 'project-action') { listedActionIds.add(sessionPurpose.actionId); const actionMatchIndex = tabs.findIndex((tab) => ( isProjectActionPurpose(tab.purpose) && tab.purpose.actionId === sessionPurpose.actionId )); if (staleActionAuthority) { if (actionMatchIndex >= 0) { matchedTabIds.add(tabs[actionMatchIndex]!.id); } continue; } if (matchIndex < 0) { matchIndex = actionMatchIndex; } } const nextPurpose: TerminalTabPurpose = sessionPurpose?.type === 'project-action' ? toActionPurposeFromSession(sessionPurpose, session.status) : { type: 'terminal' }; if (matchIndex >= 0) { const current = tabs[matchIndex]!; let nextLifecycle = current.purpose.type === 'project-action' || sessionPurpose?.type === 'project-action' ? toActionLifecycle(session.status) : session.status; if (current.lifecycle === 'stopping' && session.status === 'running' && current.purpose.type === 'project-action' && nextPurpose.type === 'project-action' && current.purpose.executionId === nextPurpose.executionId) nextLifecycle = 'stopping'; const nextCreatedAt = session.createdAt ?? current.createdAt; const executionChanged = sessionPurpose?.type === 'project-action' && (current.terminalSessionId !== session.sessionId || (nextPurpose.type === 'project-action' && nextPurpose.executionId !== null && (current.purpose.type !== 'project-action' || current.purpose.executionId !== nextPurpose.executionId))); if (executionChanged) { const keyToDrop = bufferKey(key, current.id); buffers = dropBufferKeys(buffers, (entry) => entry === keyToDrop) ?? buffers; } const activatesRunningAction = sessionPurpose?.type === 'project-action' && session.status === 'running' && (current.terminalSessionId !== session.sessionId || current.lifecycle !== 'running'); const purposeChanged = current.purpose.type !== nextPurpose.type || (current.purpose.type === 'project-action' && nextPurpose.type === 'project-action' && (current.purpose.actionId !== nextPurpose.actionId || current.purpose.executionId !== nextPurpose.executionId)); if ( current.terminalSessionId !== session.sessionId || current.lifecycle !== nextLifecycle || current.isConnecting !== false || current.createdAt !== nextCreatedAt || purposeChanged ) { tabs[matchIndex] = { ...current, terminalSessionId: session.sessionId, lifecycle: nextLifecycle, purpose: nextPurpose, isConnecting: false, createdAt: nextCreatedAt, }; if (executionChanged) Object.assign(tabs[matchIndex], resetActionPreview); tabsChanged = true; } if (activatesRunningAction) { const candidate = { tabId: current.id, createdAt: nextCreatedAt, index: matchIndex }; if ( !activationCandidate || candidate.createdAt > activationCandidate.createdAt || (candidate.createdAt === activationCandidate.createdAt && candidate.index > activationCandidate.index) ) { activationCandidate = candidate; } } matchedTabIds.add(current.id); continue; } const label = sessionPurpose?.type === 'project-action' ? toAdoptedActionLabel(sessionPurpose.actionId) : (placeholder && tabs.length === 0 ? placeholder.label : nextDefaultTabLabel(tabs)); const iconKey = sessionPurpose?.type === 'project-action' ? 'play' : null; const tab: TerminalTab = { ...createEmptyTab(session.sessionId, label), terminalSessionId: session.sessionId, lifecycle: sessionPurpose?.type === 'project-action' ? toActionLifecycle(session.status) : session.status, purpose: nextPurpose, iconKey, createdAt: session.createdAt ?? Date.now(), }; tabs.push(tab); tabsChanged = true; if (sessionPurpose?.type === 'project-action' && session.status === 'running') { const candidate = { tabId: tab.id, createdAt: tab.createdAt, index: tabs.length - 1 }; if ( !activationCandidate || candidate.createdAt > activationCandidate.createdAt || (candidate.createdAt === activationCandidate.createdAt && candidate.index > activationCandidate.index) ) { activationCandidate = candidate; } } matchedTabIds.add(tab.id); } let changed = tabsChanged || Boolean(placeholder && serverSessions.length > 0); const reconciledTabs: TerminalTab[] = tabs.map((tab) => { if (!isProjectActionPurpose(tab.purpose)) return tab; if (listedActionIds.has(tab.purpose.actionId) || matchedTabIds.has(tab.id)) return tab; if ( tab.lifecycle === 'starting' || hasActionMutatedSinceRequestStarted(state, key, tab.purpose.actionId, options?.startedActionMutationRevisions) ) { return tab; } if (tab.lifecycle === 'exited' && !tab.isConnecting) return tab; changed = true; return { ...tab, lifecycle: 'exited', isConnecting: false, purpose: { type: 'project-action', actionId: tab.purpose.actionId, executionId: null }, }; }); const previousActive = existing?.activeTabId ?? null; const resolvedActiveTabId = previousActive && reconciledTabs.some((tab) => tab.id === previousActive) ? previousActive : reconciledTabs[0]?.id ?? null; const resolvedActiveTab = resolvedActiveTabId ? reconciledTabs.find((tab) => tab.id === resolvedActiveTabId) : reconciledTabs[0]; const activeTabId = activationCandidate && !isLiveRunningTerminal(resolvedActiveTab) ? activationCandidate.tabId : resolvedActiveTabId; // Activation transitions always rewrite the adopted tab, so today // `changed` is true whenever `activeTabId` moved; the explicit // activeTabId comparison keeps this guard honest if a future // activation path stops touching the tabs array. if ( !changed && existing && activeTabId === existing.activeTabId && reconciledTabs.length === existing.tabs.length && reconciledTabs.every((tab, index) => tab === existing.tabs[index]) ) { return state; } const newSessions = new Map(state.sessions); newSessions.set(key, { tabs: reconciledTabs, activeTabId }); return { sessions: newSessions, buffers }; }); }, setActiveTab: (directory: string, tabId: string) => { const key = normalizeDirectory(directory); set((state) => { const newSessions = new Map(state.sessions); const existing = newSessions.get(key); if (!existing) { return state; } if (existing.activeTabId === tabId) { return state; } if (findTabIndex(existing, tabId) < 0) { return state; } newSessions.set(key, { ...existing, activeTabId: tabId }); return { sessions: newSessions }; }); }, setTabLabel: (directory: string, tabId: string, label: string) => { const key = normalizeDirectory(directory); const normalizedLabel = label.trim(); if (!normalizedLabel) { return; } set((state) => { const newSessions = new Map(state.sessions); const existing = newSessions.get(key); if (!existing) { return state; } const idx = findTabIndex(existing, tabId); if (idx < 0) { return state; } if (existing.tabs[idx]?.label === normalizedLabel) { return state; } const nextTabs = [...existing.tabs]; nextTabs[idx] = { ...nextTabs[idx], label: normalizedLabel, }; newSessions.set(key, { ...existing, tabs: nextTabs, }); return { sessions: newSessions }; }); }, setTabIconKey: (directory: string, tabId: string, iconKey: string | null) => { const key = normalizeDirectory(directory); set((state) => { const newSessions = new Map(state.sessions); const existing = newSessions.get(key); if (!existing) { return state; } const idx = findTabIndex(existing, tabId); if (idx < 0) { return state; } const normalizedIconKey = iconKey?.trim() || null; if (existing.tabs[idx]?.iconKey === normalizedIconKey) { return state; } const nextTabs = [...existing.tabs]; nextTabs[idx] = { ...nextTabs[idx], iconKey: normalizedIconKey, }; newSessions.set(key, { ...existing, tabs: nextTabs, }); return { sessions: newSessions }; }); }, closeTab: (directory: string, tabId: string) => { const key = normalizeDirectory(directory); set((state) => { const newSessions = new Map(state.sessions); const existing = newSessions.get(key); if (!existing) { return state; } const idx = findTabIndex(existing, tabId); if (idx < 0) { return state; } const closedPurpose = existing.tabs[idx].purpose; let actionMutationRevisions = state.actionMutationRevisions; let nextActionMutationRevision = state.nextActionMutationRevision; if (closedPurpose.type === 'project-action') { actionMutationRevisions = new Map(actionMutationRevisions); updateActionMutationRevision(actionMutationRevisions, key, closedPurpose.actionId, nextActionMutationRevision); nextActionMutationRevision += 1; } const mutationState = { actionMutationRevisions, nextActionMutationRevision }; const nextTabs = existing.tabs.filter((t) => t.id !== tabId); const closedBufferKey = bufferKey(key, tabId); const nextBuffers = state.buffers.has(closedBufferKey) ? dropBufferKeys(state.buffers, (bufferEntryKey) => bufferEntryKey === closedBufferKey) : null; if (nextTabs.length === 0) { const newTabId = createTerminalTabId(); const newTab = createEmptyTab(newTabId, 'Terminal'); newSessions.set(key, createEmptyDirectoryState(newTab)); const nextState: Pick & { buffers?: Map } = { sessions: newSessions, nextTabId: state.nextTabId + 1, }; if (nextBuffers) { nextState.buffers = nextBuffers; } return { ...nextState, ...mutationState }; } let nextActive = existing.activeTabId; if (existing.activeTabId === tabId) { const fallback = nextTabs[Math.min(idx, nextTabs.length - 1)]; nextActive = fallback?.id ?? nextTabs[0]?.id ?? null; } newSessions.set(key, { ...existing, tabs: nextTabs, activeTabId: nextActive, }); const nextState: Pick & { buffers?: Map } = { sessions: newSessions, }; if (nextBuffers) { nextState.buffers = nextBuffers; } return { ...nextState, ...mutationState }; }); }, setTabPurpose: (directory, tabId, purpose) => { const key = normalizeDirectory(directory); set((state) => { const existing = state.sessions.get(key); if (!existing) return state; const idx = findTabIndex(existing, tabId); if (idx < 0) return state; const current = existing.tabs[idx]!; if (JSON.stringify(current.purpose) === JSON.stringify(purpose)) { return state; } const nextTabs = [...existing.tabs]; const executionChanged = purpose.type === 'project-action' && purpose.executionId !== null && (current.purpose.type !== 'project-action' || current.purpose.executionId !== purpose.executionId); nextTabs[idx] = { ...current, purpose }; if (executionChanged) Object.assign(nextTabs[idx], resetActionPreview); const keyToDrop = bufferKey(key, tabId); const buffers = executionChanged ? dropBufferKeys(state.buffers, (entry) => entry === keyToDrop) ?? state.buffers : state.buffers; const sessions = new Map(state.sessions); sessions.set(key, { ...existing, tabs: nextTabs }); if (purpose.type !== 'project-action') { return { sessions }; } const actionMutationRevisions = new Map(state.actionMutationRevisions); updateActionMutationRevision(actionMutationRevisions, key, purpose.actionId, state.nextActionMutationRevision); return { sessions, buffers, actionMutationRevisions, nextActionMutationRevision: state.nextActionMutationRevision + 1 }; }); }, allocateActionExecution: (directory, tabId, actionId) => { const key = normalizeDirectory(directory); const existing = get().sessions.get(key); if (!existing || findTabIndex(existing, tabId) < 0) return null; const executionId = createExecutionId(); set((state) => { const current = state.sessions.get(key); if (!current) return state; const idx = findTabIndex(current, tabId); if (idx < 0) return state; const nextTabs = [...current.tabs]; nextTabs[idx] = { ...nextTabs[idx]!, purpose: { type: 'project-action', actionId, executionId }, lifecycle: 'starting', terminalSessionId: null, ...resetActionPreview, isConnecting: false, }; const sessions = new Map(state.sessions); sessions.set(key, { ...current, tabs: nextTabs }); const actionMutationRevisions = new Map(state.actionMutationRevisions); updateActionMutationRevision(actionMutationRevisions, key, actionId, state.nextActionMutationRevision); const keyToDrop = bufferKey(key, tabId); const buffers = dropBufferKeys(state.buffers, (entry) => entry === keyToDrop) ?? state.buffers; return { sessions, buffers, actionMutationRevisions, nextActionMutationRevision: state.nextActionMutationRevision + 1 }; }); return executionId; }, setTabSessionId: (directory: string, tabId: string, sessionId: string | null, options) => { const key = normalizeDirectory(directory); set((state) => { const newSessions = new Map(state.sessions); const existing = newSessions.get(key); if (!existing) { return state; } const idx = findTabIndex(existing, tabId); if (idx < 0) { return state; } const tab = existing.tabs[idx]; if (!shouldApplyExecutionGuard(tab, options?.expectedExecutionId)) { return state; } const shouldResetBuffer = sessionId !== null && tab.terminalSessionId !== sessionId; const nextLifecycle = sessionId ? 'running' : (tab.terminalSessionId ? 'exited' : tab.lifecycle); const nextTab: TerminalTab = { ...tab, terminalSessionId: sessionId, lifecycle: nextLifecycle, isConnecting: false, }; const resetKey = bufferKey(key, tabId); const nextBuffers = shouldResetBuffer && state.buffers.has(resetKey) ? dropBufferKeys(state.buffers, (bufferEntryKey) => bufferEntryKey === resetKey) : null; const nextTabs = [...existing.tabs]; nextTabs[idx] = nextTab; newSessions.set(key, { ...existing, tabs: nextTabs }); const nextState: Pick & { buffers?: Map; actionMutationRevisions?: Map; } = { sessions: newSessions, nextActionMutationRevision: state.nextActionMutationRevision, }; if (isProjectActionPurpose(tab.purpose)) { const actionMutationRevisions = new Map(state.actionMutationRevisions); updateActionMutationRevision(actionMutationRevisions, key, tab.purpose.actionId, state.nextActionMutationRevision); nextState.actionMutationRevisions = actionMutationRevisions; nextState.nextActionMutationRevision = state.nextActionMutationRevision + 1; } if (nextBuffers) { nextState.buffers = nextBuffers; } return nextState; }); }, setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle, options) => { const key = normalizeDirectory(directory); set((state) => { const newSessions = new Map(state.sessions); const existing = newSessions.get(key); if (!existing) { return state; } const idx = findTabIndex(existing, tabId); if (idx < 0) { return state; } if (!shouldApplyExecutionGuard(existing.tabs[idx]!, options?.expectedExecutionId)) { return state; } const current = existing.tabs[idx]!; if (current.lifecycle === lifecycle && !current.isConnecting) return state; const nextTabs = [...existing.tabs]; nextTabs[idx] = { ...current, lifecycle, isConnecting: false }; newSessions.set(key, { ...existing, tabs: nextTabs }); if (current.purpose.type !== 'project-action' || current.lifecycle === lifecycle) return { sessions: newSessions }; const actionMutationRevisions = new Map(state.actionMutationRevisions); updateActionMutationRevision(actionMutationRevisions, key, current.purpose.actionId, state.nextActionMutationRevision); return { sessions: newSessions, actionMutationRevisions, nextActionMutationRevision: state.nextActionMutationRevision + 1 }; }); }, setConnecting: (directory: string, tabId: string, isConnecting: boolean, options) => { const key = normalizeDirectory(directory); set((state) => { const newSessions = new Map(state.sessions); const existing = newSessions.get(key); if (!existing) { return state; } const idx = findTabIndex(existing, tabId); if (idx < 0) { return state; } if (!shouldApplyExecutionGuard(existing.tabs[idx]!, options?.expectedExecutionId)) { return state; } const nextTabs = [...existing.tabs]; nextTabs[idx] = { ...nextTabs[idx], isConnecting }; newSessions.set(key, { ...existing, tabs: nextTabs }); return { sessions: newSessions }; }); }, replaceBuffer: (directory: string, tabId: string, content: string, sequence: number, size?: TerminalChunkSize) => { const key = normalizeDirectory(directory); set((state) => { const existing = state.sessions.get(key); if (!existing || findTabIndex(existing, tabId) < 0) return state; const entryKey = bufferKey(key, tabId); const buffer = state.buffers.get(entryKey) ?? EMPTY_TERMINAL_BUFFER; if (buffer.lastSequence > sequence) return state; const retained = trimToBufferLimit(content); const previousSize = buffer.chunks[0]?.size; if ( buffer.lastSequence === sequence && buffer.byteLength === retained.byteLength && buffer.chunks.map((chunk) => chunk.data).join('') === retained.text && previousSize?.cols === size?.cols && previousSize?.rows === size?.rows ) { return state; } const chunkId = state.nextChunkId; const buffers = new Map(state.buffers); buffers.set(entryKey, { chunks: retained.text ? [{ id: chunkId, data: retained.text, byteLength: retained.byteLength, ...(size ? { size } : {}) }] : [], byteLength: retained.byteLength, lastSequence: sequence, }); return { buffers, nextChunkId: retained.text ? chunkId + 1 : chunkId }; }); }, appendToBuffer: (directory: string, tabId: string, chunk: string, sequence?: number, replayData?: string) => { if (!chunk) { return; } const key = normalizeDirectory(directory); set((state) => { const existing = state.sessions.get(key); if (!existing || findTabIndex(existing, tabId) < 0) { return state; } const entryKey = bufferKey(key, tabId); const buffer = state.buffers.get(entryKey) ?? EMPTY_TERMINAL_BUFFER; if (sequence !== undefined && sequence <= buffer.lastSequence) return state; const chunkId = state.nextChunkId; const retainedChunk = trimToBufferLimit(chunk); const retainedReplayData = replayData !== undefined && replayData !== chunk ? trimToBufferLimit(replayData).text : undefined; const chunkEntry: TerminalChunk = { id: chunkId, data: retainedChunk.text, ...(retainedReplayData !== undefined ? { replayData: retainedReplayData } : {}), byteLength: retainedChunk.byteLength, }; const chunks = [...buffer.chunks, chunkEntry]; let bufferLength = buffer.byteLength + chunkEntry.byteLength; while (bufferLength > TERMINAL_BUFFER_LIMIT && chunks.length > 1) { const removed = chunks.shift(); if (!removed) { break; } bufferLength -= removed.byteLength; } const buffers = new Map(state.buffers); buffers.set(entryKey, { chunks, byteLength: bufferLength, lastSequence: sequence ?? buffer.lastSequence, }); return { buffers, nextChunkId: chunkId + 1 }; }); }, setTabPreviewUrl: (directory: string, tabId: string, url: string | null, options = {}) => { const key = normalizeDirectory(directory); set((state) => { const newSessions = new Map(state.sessions); const existing = newSessions.get(key); if (!existing) { return state; } const idx = findTabIndex(existing, tabId); if (idx < 0) { return state; } const tab = existing.tabs[idx]; if (!shouldApplyExecutionGuard(tab, options.expectedExecutionId)) { return state; } const nextPreviewAutoOpened = options.autoOpened ?? tab.previewAutoOpened; const nextPreviewUrlLocked = options.locked ?? tab.previewUrlLocked; if (tab.previewUrl === url && tab.previewAutoOpened === nextPreviewAutoOpened && tab.previewUrlLocked === nextPreviewUrlLocked) { return state; } const nextTabs = [...existing.tabs]; nextTabs[idx] = { ...tab, previewUrl: url, previewAutoOpened: nextPreviewAutoOpened, previewUrlLocked: nextPreviewUrlLocked, }; newSessions.set(key, { ...existing, tabs: nextTabs }); return { sessions: newSessions }; }); }, markPreviewAutoOpened: (directory: string, tabId: string) => { const key = normalizeDirectory(directory); set((state) => { const newSessions = new Map(state.sessions); const existing = newSessions.get(key); if (!existing) { return state; } const idx = findTabIndex(existing, tabId); if (idx < 0) { return state; } const tab = existing.tabs[idx]; if (!tab.previewUrl || tab.previewAutoOpened) { return state; } const nextTabs = [...existing.tabs]; nextTabs[idx] = { ...tab, previewAutoOpened: true }; newSessions.set(key, { ...existing, tabs: nextTabs }); return { sessions: newSessions }; }); }, removeDirectory: (directory: string) => { const key = normalizeDirectory(directory); set((state) => { const newSessions = new Map(state.sessions); newSessions.delete(key); const prefix = bufferKey(key, ''); const nextBuffers = dropBufferKeys(state.buffers, (entryKey) => entryKey.startsWith(prefix)); const revisionPrefix = actionMutationRevisionKey(key, ''); let actionMutationRevisions: Map | undefined; for (const actionKey of state.actionMutationRevisions.keys()) { if (!actionKey.startsWith(revisionPrefix)) continue; actionMutationRevisions ??= new Map(state.actionMutationRevisions); actionMutationRevisions.delete(actionKey); } const nextState: Pick & { buffers?: Map; actionMutationRevisions?: Map; } = { sessions: newSessions, }; if (nextBuffers) { nextState.buffers = nextBuffers; } if (actionMutationRevisions) { nextState.actionMutationRevisions = actionMutationRevisions; } return nextState; }); }, clearAll: () => { set({ sessions: new Map(), buffers: new Map(), actionMutationRevisions: new Map(), nextActionMutationRevision: 1, nextChunkId: 1, nextTabId: 1 }); }, }), { name: TERMINAL_STORE_NAME, storage: createDedupedTerminalStorage(), partialize: partializeTerminalStore, merge: (persistedState, currentState) => { if (!isRecord(persistedState)) { return currentState; } const rawSessions = Array.isArray(persistedState.sessions) ? (persistedState.sessions as PersistedTerminalStoreState['sessions']) : []; const sessions = new Map(); let maxTabNum = 0; for (const entry of rawSessions) { if (!Array.isArray(entry) || entry.length !== 2) { continue; } const [directory, rawState] = entry as [unknown, unknown]; if (typeof directory !== 'string' || !isRecord(rawState)) { continue; } const rawTabs = Array.isArray(rawState.tabs) ? (rawState.tabs as unknown[]) : []; const tabs: TerminalTab[] = []; const migratedTabIds = new Map(); for (const rawTab of rawTabs) { if (!isRecord(rawTab)) { continue; } const persistedId = typeof rawTab.id === 'string' ? rawTab.id : null; if (!persistedId) { continue; } const num = tabIdNumber(persistedId); if (num !== null) { maxTabNum = Math.max(maxTabNum, num); } const id = num === null ? persistedId : createTerminalTabId(); migratedTabIds.set(persistedId, id); const persistedPurpose = persistedProjectActionPurposeSchema.safeParse(rawTab.purpose).data; const purpose: TerminalTabPurpose = persistedPurpose ? { type: 'project-action', actionId: persistedPurpose.actionId, executionId: null } : { type: 'terminal' }; tabs.push({ id, label: typeof rawTab.label === 'string' ? rawTab.label : 'Terminal', iconKey: typeof rawTab.iconKey === 'string' ? rawTab.iconKey : null, terminalSessionId: null, lifecycle: 'idle', purpose, createdAt: typeof rawTab.createdAt === 'number' ? rawTab.createdAt : Date.now(), isConnecting: false, previewUrl: null, previewAutoOpened: false, previewUrlLocked: false, }); } if (tabs.length === 0) { continue; } const activeTabId = typeof rawState.activeTabId === 'string' ? (rawState.activeTabId as string) : null; const migratedActiveTabId = activeTabId ? (migratedTabIds.get(activeTabId) ?? activeTabId) : null; const activeExists = migratedActiveTabId ? tabs.some((t) => t.id === migratedActiveTabId) : false; sessions.set(directory, { tabs, activeTabId: activeExists ? migratedActiveTabId : tabs[0].id, }); } const persistedNextTabId = typeof persistedState.nextTabId === 'number' && Number.isFinite(persistedState.nextTabId) ? (persistedState.nextTabId as number) : 1; const nextTabId = Math.max(currentState.nextTabId, persistedNextTabId, maxTabNum + 1); return { ...currentState, sessions, buffers: new Map(), actionMutationRevisions: new Map(), nextActionMutationRevision: 1, nextChunkId: 1, nextTabId, hasHydrated: true, }; }, } ) ) ); // Ensure hydration completes even when no persisted state exists. if (typeof window !== 'undefined' && !hydrationListenerAttached) { hydrationListenerAttached = true; const persistApi = ( useTerminalStore as unknown as { persist?: { hasHydrated?: () => boolean; onFinishHydration?: (cb: () => void) => (() => void) | void; }; } ).persist; const markHydrated = () => { if (!useTerminalStore.getState().hasHydrated) { useTerminalStore.setState({ hasHydrated: true }); } }; if (persistApi?.hasHydrated?.()) { markHydrated(); } else if (persistApi?.onFinishHydration) { persistApi.onFinishHydration(markHydrated); } else { markHydrated(); } }