import { create } from 'zustand'; import type { Session } from '@opencode-ai/sdk/v2'; import { opencodeClient } from '@/lib/opencode/client'; import { listGlobalSessionPages } from '@/stores/globalSessions'; type GlobalSessionsStatus = 'idle' | 'loading' | 'ready' | 'error'; type LoadResult = { activeSessions: Session[]; archivedSessions: Session[]; }; type GlobalSessionsState = { activeSessions: Session[]; archivedSessions: Session[]; sessionsByDirectory: Map; hasLoaded: boolean; status: GlobalSessionsStatus; loadSessions: (fallbackActive?: Session[]) => Promise; applySnapshot: (activeSessions: Session[], archivedSessions: Session[], status?: GlobalSessionsStatus) => void; upsertSession: (session: Session) => void; removeSessions: (ids: Iterable) => void; archiveSessions: (ids: Iterable, archivedAt?: number) => void; }; const PAGE_SIZE = 200; let inflightLoad: Promise | null = null; const normalizePath = (value?: string | null): string | null => { if (typeof value !== 'string') { return null; } const trimmed = value.trim(); if (!trimmed) { return null; } const replaced = trimmed.replace(/\\/g, '/'); if (replaced === '/') { return '/'; } return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced; }; export const resolveGlobalSessionDirectory = (session: Session): string | null => { const record = session as Session & { directory?: string | null; project?: { worktree?: string | null } | null; }; return normalizePath(record.directory ?? null) ?? normalizePath(record.project?.worktree ?? null); }; const buildSessionsByDirectory = (sessions: Session[]): Map => { const next = new Map(); for (const session of sessions) { const directory = resolveGlobalSessionDirectory(session); if (!directory) { continue; } const existing = next.get(directory); if (existing) { existing.push(session); continue; } next.set(directory, [session]); } return next; }; const getSessionSignature = (session: Session): string => { return [ session.id, session.title ?? '', session.time?.created ?? 0, session.time?.updated ?? 0, session.time?.archived ?? 0, session.share ? 1 : 0, resolveGlobalSessionDirectory(session) ?? '', ].join(':'); }; const sameSessionList = (prev: Session[], next: Session[]): boolean => { if (prev === next) { return true; } if (prev.length !== next.length) { return false; } for (let index = 0; index < prev.length; index += 1) { if (getSessionSignature(prev[index]) !== getSessionSignature(next[index])) { return false; } } return true; }; const upsertSessionIntoList = (sessions: Session[], session: Session): Session[] => { const index = sessions.findIndex((candidate) => candidate.id === session.id); if (index === -1) { return [session, ...sessions]; } if (getSessionSignature(sessions[index]) === getSessionSignature(session)) { return sessions; } const next = [...sessions]; next[index] = session; return next; }; const mergeSessionLists = (existing: Session[], incoming?: Session[]): Session[] => { if (!incoming || incoming.length === 0) { return existing; } if (existing.length === 0) { return incoming; } const byId = new Map(existing.map((session) => [session.id, session])); incoming.forEach((session) => { byId.set(session.id, session); }); const ordered: Session[] = []; const seen = new Set(); existing.forEach((session) => { const next = byId.get(session.id); if (!next) { return; } ordered.push(next); seen.add(session.id); }); incoming.forEach((session) => { if (seen.has(session.id)) { return; } const next = byId.get(session.id); if (next) { ordered.push(next); seen.add(session.id); } }); return ordered; }; const applySnapshot = ( state: GlobalSessionsState, activeSessions: Session[], archivedSessions: Session[], status: GlobalSessionsStatus, ): Partial | GlobalSessionsState => { const nextActiveSessions = sameSessionList(state.activeSessions, activeSessions) ? state.activeSessions : activeSessions; const nextArchivedSessions = sameSessionList(state.archivedSessions, archivedSessions) ? state.archivedSessions : archivedSessions; const nextSessionsByDirectory = nextActiveSessions === state.activeSessions ? state.sessionsByDirectory : buildSessionsByDirectory(nextActiveSessions); if ( nextActiveSessions === state.activeSessions && nextArchivedSessions === state.archivedSessions && nextSessionsByDirectory === state.sessionsByDirectory && state.hasLoaded && state.status === status ) { return state; } return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions, sessionsByDirectory: nextSessionsByDirectory, hasLoaded: true, status, }; }; export const useGlobalSessionsStore = create((set, get) => ({ activeSessions: [], archivedSessions: [], sessionsByDirectory: new Map(), hasLoaded: false, status: 'idle', applySnapshot: (activeSessions, archivedSessions, status = 'ready') => { set((state) => applySnapshot(state, activeSessions, archivedSessions, status)); }, loadSessions: async (fallbackActive) => { if (inflightLoad) { return inflightLoad; } set((state) => (state.status === 'loading' ? state : { status: 'loading' })); inflightLoad = (async () => { const current = get(); try { const sdk = opencodeClient.getSdkClient(); const [activeResult, archivedResult] = await Promise.allSettled([ listGlobalSessionPages(sdk, { archived: false, pageSize: PAGE_SIZE }), listGlobalSessionPages(sdk, { archived: true, pageSize: PAGE_SIZE }), ]); const fallbackSnapshot = mergeSessionLists(current.activeSessions, fallbackActive); const nextActiveSessions = activeResult.status === 'fulfilled' ? activeResult.value : fallbackSnapshot; const nextArchivedSessions = archivedResult.status === 'fulfilled' ? archivedResult.value : current.archivedSessions; if (activeResult.status === 'rejected') { console.warn('[GlobalSessions] Failed to load active sessions, preserving existing snapshot with fallback merge:', activeResult.reason); } if (archivedResult.status === 'rejected') { console.warn('[GlobalSessions] Failed to load archived sessions, preserving current snapshot:', archivedResult.reason); } set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, 'ready')); return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions }; } catch (error) { const nextActiveSessions = mergeSessionLists(current.activeSessions, fallbackActive); const nextArchivedSessions = current.archivedSessions; console.warn('[GlobalSessions] Failed to load sessions, using fallback snapshot:', error); set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, 'error')); return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions }; } finally { inflightLoad = null; } })(); return inflightLoad; }, upsertSession: (session) => { set((state) => { const isArchived = Boolean(session.time?.archived); const nextActiveSessions = isArchived ? state.activeSessions.filter((candidate) => candidate.id !== session.id) : upsertSessionIntoList(state.activeSessions, session); const nextArchivedSessions = isArchived ? upsertSessionIntoList(state.archivedSessions, session) : state.archivedSessions.filter((candidate) => candidate.id !== session.id); if ( nextActiveSessions === state.activeSessions && nextArchivedSessions === state.archivedSessions ) { return state; } return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions, sessionsByDirectory: nextActiveSessions === state.activeSessions ? state.sessionsByDirectory : buildSessionsByDirectory(nextActiveSessions), }; }); }, removeSessions: (ids) => { const idSet = ids instanceof Set ? ids : new Set(ids); if (idSet.size === 0) { return; } set((state) => { const nextActiveSessions = state.activeSessions.filter((session) => !idSet.has(session.id)); const nextArchivedSessions = state.archivedSessions.filter((session) => !idSet.has(session.id)); if ( nextActiveSessions.length === state.activeSessions.length && nextArchivedSessions.length === state.archivedSessions.length ) { return state; } return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions, sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions), }; }); }, archiveSessions: (ids, archivedAt = Date.now()) => { const idSet = ids instanceof Set ? ids : new Set(ids); if (idSet.size === 0) { return; } set((state) => { const movedSessions: Session[] = []; const nextActiveSessions = state.activeSessions.filter((session) => { if (!idSet.has(session.id)) { return true; } movedSessions.push({ ...session, time: { ...session.time, archived: archivedAt, }, }); return false; }); if (movedSessions.length === 0) { return state; } const remainingArchivedSessions = state.archivedSessions.filter((session) => !idSet.has(session.id)); return { activeSessions: nextActiveSessions, archivedSessions: [...movedSessions, ...remainingArchivedSessions], sessionsByDirectory: buildSessionsByDirectory(nextActiveSessions), }; }); }, })); export const ensureGlobalSessionsLoaded = async (fallbackActive?: Session[]): Promise => { const state = useGlobalSessionsStore.getState(); if (state.hasLoaded && state.status !== 'error') { return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions, }; } return state.loadSessions(fallbackActive); }; export const refreshGlobalSessions = async (fallbackActive?: Session[]): Promise => { return useGlobalSessionsStore.getState().loadSessions(fallbackActive); };