Files
openchamber/packages/ui/src/stores/useGlobalSessionsStore.ts
T

350 lines
10 KiB
TypeScript

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<string, Session[]>;
hasLoaded: boolean;
status: GlobalSessionsStatus;
loadSessions: (fallbackActive?: Session[]) => Promise<LoadResult>;
applySnapshot: (activeSessions: Session[], archivedSessions: Session[], status?: GlobalSessionsStatus) => void;
upsertSession: (session: Session) => void;
removeSessions: (ids: Iterable<string>) => void;
archiveSessions: (ids: Iterable<string>, archivedAt?: number) => void;
};
const PAGE_SIZE = 200;
let inflightLoad: Promise<LoadResult> | 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<string, Session[]> => {
const next = new Map<string, Session[]>();
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<string>();
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> | 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<GlobalSessionsState>((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<LoadResult> => {
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<LoadResult> => {
return useGlobalSessionsStore.getState().loadSessions(fallbackActive);
};