import { create } from 'zustand'; import type { Event, SessionStatus } from '@opencode-ai/sdk/v2/client'; import { normalizeProjectPath } from '@/lib/projectResolution'; import { observeSessionActivityEvent, reconcileSessionActivitySnapshot, removeSessionOrdering, } from './session-ordering'; // Shared live busy/retry index for every directory. Global events update it // incrementally and authoritative directory snapshots reconcile it, so each // sidebar row can subscribe to one leaf instead of every child store. // // Only non-idle entries are kept; absence means idle. Entries carry their // directory so a polled per-directory snapshot can authoritatively replace // that directory's slice (the server omits idle sessions from snapshots). type ActiveStatusType = 'busy' | 'retry'; type GlobalSessionStatusEntry = { status: SessionStatus; directory: string }; type GlobalSessionStatusState = { statusById: Map; }; export const useGlobalSessionStatusStore = create(() => ({ statusById: new Map(), })); const normalizeStatusType = (type: unknown): ActiveStatusType | 'idle' => { if (type === 'busy') return 'busy'; if (type === 'retry') return 'retry'; return 'idle'; }; const statusesEqual = (left: SessionStatus, right: SessionStatus): boolean => ( left.type === right.type && JSON.stringify(left) === JSON.stringify(right) ); // Both write paths normalize the directory key, so a polled snapshot can // authoritatively replace entries written by events (and vice versa) even when // the two sources format the same path differently (trailing slash, …). const normalizeDirectory = (directory: string): string => normalizeProjectPath(directory) ?? directory; const setStatus = (sessionId: string, directory: string, status: SessionStatus | { type: 'idle' }): void => { useGlobalSessionStatusStore.setState((state) => { const current = state.statusById.get(sessionId); if (status.type === 'idle') { if (!current) return state; const next = new Map(state.statusById); next.delete(sessionId); return { statusById: next }; } if (current && current.directory === directory && statusesEqual(current.status, status)) return state; const next = new Map(state.statusById); next.set(sessionId, { status, directory }); return { statusById: next }; }); }; // Event-driven path: called by the sync dispatcher for status-bearing events // whose directory has no child store. Mirrors the child reducer's semantics // (`session.idle` / `session.error` both resolve to idle). export const applyGlobalSessionStatusEvent = (directory: string, payload: Event): void => { switch (payload.type) { case 'session.status': { const props = payload.properties as { sessionID?: string; status?: { type?: string } } | undefined; if (typeof props?.sessionID !== 'string' || !props.sessionID) return; const type = normalizeStatusType(props.status?.type); setStatus( props.sessionID, normalizeDirectory(directory), type === 'idle' ? { type: 'idle' } : { ...(props.status ?? {}), type } as SessionStatus, ); observeSessionActivityEvent(props.sessionID, type === 'idle' ? 'settled' : 'active'); return; } case 'session.idle': case 'session.error': { const props = payload.properties as { sessionID?: string } | undefined; if (typeof props?.sessionID === 'string' && props.sessionID) { setStatus(props.sessionID, normalizeDirectory(directory), { type: 'idle' }); observeSessionActivityEvent(props.sessionID, 'settled'); } return; } case 'session.deleted': { const props = payload.properties as { sessionID?: string; info?: { id?: string } } | undefined; const sessionId = props?.sessionID ?? props?.info?.id; if (sessionId) removeSessionOrdering(sessionId); return; } default: return; } }; // Polled path: an authoritative `/session/status?directory=X` snapshot. Entries // missing from the snapshot are idle now — cleared both by directory key and by // the caller's session-id list (the server may report a canonicalized directory // that differs from the key an event wrote, e.g. via symlinks). Seeds the // initial state (events only deliver changes) and reconciles missed events. export const applyGlobalSessionStatusSnapshot = ( rawDirectory: string, raw: Record, knownSessionIds?: Iterable, ): void => { const directory = normalizeDirectory(rawDirectory); const known = new Set(knownSessionIds ?? []); const activeSessionIds = Object.entries(raw) .filter(([, status]) => normalizeStatusType(status?.type) !== 'idle') .map(([sessionId]) => sessionId); reconcileSessionActivitySnapshot(activeSessionIds, known); useGlobalSessionStatusStore.setState((state) => { let changed = false; const next = new Map(state.statusById); for (const [sessionId, entry] of state.statusById) { if ((entry.directory === directory || known.has(sessionId)) && !(sessionId in raw)) { next.delete(sessionId); changed = true; } } for (const [sessionId, status] of Object.entries(raw)) { const type = normalizeStatusType(status?.type); const current = next.get(sessionId); if (type === 'idle') { if (current && (current.directory === directory || known.has(sessionId))) { next.delete(sessionId); changed = true; } continue; } const normalizedStatus = { ...status, type } as SessionStatus; if (!current || current.directory !== directory || !statusesEqual(current.status, normalizedStatus)) { next.set(sessionId, { status: normalizedStatus, directory }); changed = true; } } return changed ? { statusById: next } : state; }); };