2026-03-31 18:47:00 +03:00
|
|
|
import { create } from 'zustand';
|
2026-06-03 18:43:13 +03:00
|
|
|
import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2';
|
2026-03-31 18:47:00 +03:00
|
|
|
import { opencodeClient } from '@/lib/opencode/client';
|
|
|
|
|
import { listGlobalSessionPages } from '@/stores/globalSessions';
|
2026-06-18 08:43:16 +11:00
|
|
|
import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow';
|
|
|
|
|
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
type GlobalSessionsStatus = 'idle' | 'loading' | 'ready' | 'error';
|
|
|
|
|
|
|
|
|
|
type LoadResult = {
|
|
|
|
|
activeSessions: Session[];
|
|
|
|
|
archivedSessions: Session[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type GlobalSessionsState = {
|
|
|
|
|
activeSessions: Session[];
|
|
|
|
|
archivedSessions: Session[];
|
|
|
|
|
sessionsByDirectory: Map<string, Session[]>;
|
2026-06-18 08:43:16 +11:00
|
|
|
reviewTransferBySessionId: Map<string, ReviewTransferDirection>;
|
2026-03-31 18:47:00 +03:00
|
|
|
hasLoaded: boolean;
|
|
|
|
|
status: GlobalSessionsStatus;
|
|
|
|
|
loadSessions: (fallbackActive?: Session[]) => Promise<LoadResult>;
|
2026-06-03 18:43:13 +03:00
|
|
|
refreshSessionsForDirectories: (directories: Iterable<string>, fallbackActive?: Session[]) => Promise<LoadResult>;
|
2026-03-31 18:47:00 +03:00
|
|
|
applySnapshot: (activeSessions: Session[], archivedSessions: Session[], status?: GlobalSessionsStatus) => void;
|
|
|
|
|
upsertSession: (session: Session) => void;
|
|
|
|
|
removeSessions: (ids: Iterable<string>) => void;
|
|
|
|
|
archiveSessions: (ids: Iterable<string>, archivedAt?: number) => void;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-25 11:58:59 +03:00
|
|
|
const PAGE_SIZE = 500;
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-03 13:02:18 +03:00
|
|
|
export const mergeSessionDirectoryMetadata = (incoming: Session, existing?: Session | null): Session => {
|
|
|
|
|
if (!existing) {
|
|
|
|
|
return incoming;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const incomingRecord = incoming as Session & {
|
|
|
|
|
directory?: string | null;
|
|
|
|
|
project?: ({ worktree?: string | null } & Record<string, unknown>) | null;
|
|
|
|
|
};
|
|
|
|
|
const existingRecord = existing as Session & {
|
|
|
|
|
directory?: string | null;
|
|
|
|
|
project?: ({ worktree?: string | null } & Record<string, unknown>) | null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const incomingDirectory = normalizePath(incomingRecord.directory ?? null);
|
|
|
|
|
const incomingWorktree = normalizePath(incomingRecord.project?.worktree ?? null);
|
|
|
|
|
const existingDirectory = normalizePath(existingRecord.directory ?? null);
|
|
|
|
|
const existingWorktree = normalizePath(existingRecord.project?.worktree ?? null);
|
|
|
|
|
|
|
|
|
|
let changed = false;
|
|
|
|
|
const next: typeof incomingRecord = { ...incomingRecord };
|
|
|
|
|
|
|
|
|
|
// Some live session updates omit stable raw directory metadata; keep the
|
|
|
|
|
// cached value so project grouping does not temporarily lose the session.
|
|
|
|
|
if (!incomingDirectory && existingDirectory) {
|
|
|
|
|
next.directory = existingRecord.directory;
|
|
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!incomingWorktree && existingWorktree) {
|
|
|
|
|
next.project = {
|
2026-06-03 18:43:13 +03:00
|
|
|
...(existingRecord.project ?? {}),
|
2026-06-03 13:02:18 +03:00
|
|
|
...(incomingRecord.project ?? {}),
|
|
|
|
|
worktree: existingRecord.project?.worktree,
|
|
|
|
|
};
|
|
|
|
|
changed = true;
|
2026-06-03 18:43:13 +03:00
|
|
|
} else if (!incomingRecord.project && existingRecord.project) {
|
|
|
|
|
next.project = existingRecord.project;
|
|
|
|
|
changed = true;
|
2026-06-03 13:02:18 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return changed ? next : incoming;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-24 06:18:44 +11:00
|
|
|
export const mergeLiveSessionWithGlobalSession = (
|
|
|
|
|
liveSession: Session,
|
|
|
|
|
globalSession: Session,
|
|
|
|
|
): Session => {
|
|
|
|
|
const merged = mergeSessionDirectoryMetadata(liveSession, globalSession);
|
|
|
|
|
if (merged.share !== globalSession.share) {
|
|
|
|
|
return { ...merged, share: globalSession.share };
|
|
|
|
|
}
|
|
|
|
|
return merged;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
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,
|
2026-05-12 04:05:54 -04:00
|
|
|
session.share?.url ?? '',
|
2026-06-07 01:22:40 +03:00
|
|
|
JSON.stringify((session as Session & { metadata?: unknown }).metadata ?? null),
|
2026-03-31 18:47:00 +03:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-03 18:43:13 +03:00
|
|
|
const getSessionUpdatedAt = (session: Session): number => {
|
|
|
|
|
const updatedAt = session.time?.updated;
|
|
|
|
|
if (typeof updatedAt === 'number' && Number.isFinite(updatedAt)) {
|
|
|
|
|
return updatedAt;
|
|
|
|
|
}
|
|
|
|
|
const createdAt = session.time?.created;
|
|
|
|
|
return typeof createdAt === 'number' && Number.isFinite(createdAt) ? createdAt : 0;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sortSessionsByUpdated = (sessions: Session[]): Session[] => {
|
|
|
|
|
return [...sessions].sort((left, right) => {
|
|
|
|
|
const timeDelta = getSessionUpdatedAt(right) - getSessionUpdatedAt(left);
|
|
|
|
|
if (timeDelta !== 0) return timeDelta;
|
|
|
|
|
return right.id.localeCompare(left.id);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const normalizeDirectorySet = (directories: Iterable<string>): Set<string> => {
|
|
|
|
|
const next = new Set<string>();
|
|
|
|
|
for (const directory of directories) {
|
|
|
|
|
const normalized = normalizePath(directory);
|
|
|
|
|
if (normalized) next.add(normalized);
|
|
|
|
|
}
|
|
|
|
|
return next;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const replaceSessionsForDirectories = (
|
|
|
|
|
existing: Session[],
|
|
|
|
|
incoming: Session[],
|
|
|
|
|
directories: Set<string>,
|
|
|
|
|
): Session[] => {
|
|
|
|
|
if (directories.size === 0) {
|
|
|
|
|
return existing;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const existingById = new Map(existing.map((session) => [session.id, session]));
|
|
|
|
|
const incomingById = new Map<string, Session>();
|
|
|
|
|
|
|
|
|
|
for (const session of incoming) {
|
|
|
|
|
if (!session?.id) continue;
|
|
|
|
|
incomingById.set(session.id, mergeSessionDirectoryMetadata(session, existingById.get(session.id)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const kept = existing.filter((session) => {
|
|
|
|
|
if (incomingById.has(session.id)) return false;
|
|
|
|
|
const directory = resolveGlobalSessionDirectory(session);
|
|
|
|
|
return !directory || !directories.has(directory);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return sortSessionsByUpdated([...incomingById.values(), ...kept]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type DirectoryPageResult = {
|
|
|
|
|
directories: Set<string>;
|
|
|
|
|
sessions: Session[];
|
|
|
|
|
errors: unknown[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fetchDirectoryPages = async (
|
|
|
|
|
sdk: OpencodeClient,
|
|
|
|
|
directories: Set<string>,
|
|
|
|
|
archived: boolean,
|
|
|
|
|
): Promise<DirectoryPageResult> => {
|
|
|
|
|
const results = await Promise.allSettled(
|
|
|
|
|
[...directories].map(async (directory) => ({
|
|
|
|
|
directory,
|
|
|
|
|
sessions: await listGlobalSessionPages(sdk, { directory, archived, pageSize: PAGE_SIZE }),
|
|
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const fulfilledDirectories = new Set<string>();
|
|
|
|
|
const sessions: Session[] = [];
|
|
|
|
|
const errors: unknown[] = [];
|
|
|
|
|
|
|
|
|
|
for (const result of results) {
|
|
|
|
|
if (result.status === 'fulfilled') {
|
|
|
|
|
fulfilledDirectories.add(result.value.directory);
|
|
|
|
|
sessions.push(...result.value.sessions);
|
|
|
|
|
} else {
|
|
|
|
|
errors.push(result.reason);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { directories: fulfilledDirectories, sessions, errors };
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
const upsertSessionIntoList = (sessions: Session[], session: Session): Session[] => {
|
|
|
|
|
const index = sessions.findIndex((candidate) => candidate.id === session.id);
|
|
|
|
|
if (index === -1) {
|
|
|
|
|
return [session, ...sessions];
|
|
|
|
|
}
|
2026-06-03 13:02:18 +03:00
|
|
|
const mergedSession = mergeSessionDirectoryMetadata(session, sessions[index]);
|
|
|
|
|
if (getSessionSignature(sessions[index]) === getSessionSignature(mergedSession)) {
|
2026-03-31 18:47:00 +03:00
|
|
|
return sessions;
|
|
|
|
|
}
|
|
|
|
|
const next = [...sessions];
|
2026-06-03 13:02:18 +03:00
|
|
|
next[index] = mergedSession;
|
2026-03-31 18:47:00 +03:00
|
|
|
return next;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-16 17:02:23 +03:00
|
|
|
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) => {
|
2026-06-03 13:02:18 +03:00
|
|
|
byId.set(session.id, mergeSessionDirectoryMetadata(session, byId.get(session.id)));
|
2026-04-16 17:02:23 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
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);
|
2026-06-18 08:43:16 +11:00
|
|
|
const nextReviewTransferMap = nextActiveSessions === state.activeSessions
|
|
|
|
|
? state.reviewTransferBySessionId
|
|
|
|
|
: buildReviewTransferMap(nextActiveSessions);
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
nextActiveSessions === state.activeSessions
|
|
|
|
|
&& nextArchivedSessions === state.archivedSessions
|
|
|
|
|
&& nextSessionsByDirectory === state.sessionsByDirectory
|
2026-06-18 08:43:16 +11:00
|
|
|
&& nextReviewTransferMap === state.reviewTransferBySessionId
|
2026-03-31 18:47:00 +03:00
|
|
|
&& state.hasLoaded
|
|
|
|
|
&& state.status === status
|
|
|
|
|
) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
activeSessions: nextActiveSessions,
|
|
|
|
|
archivedSessions: nextArchivedSessions,
|
|
|
|
|
sessionsByDirectory: nextSessionsByDirectory,
|
2026-06-18 08:43:16 +11:00
|
|
|
reviewTransferBySessionId: nextReviewTransferMap,
|
2026-03-31 18:47:00 +03:00
|
|
|
hasLoaded: true,
|
|
|
|
|
status,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-18 08:43:16 +11:00
|
|
|
const buildReviewTransferMap = (sessions: Session[]): Map<string, ReviewTransferDirection> => {
|
|
|
|
|
const next = new Map<string, ReviewTransferDirection>()
|
|
|
|
|
const activeIds = new Set(sessions.map((s) => s.id))
|
|
|
|
|
for (const session of sessions) {
|
|
|
|
|
const direction = getReviewTransferDirection(session)
|
|
|
|
|
if (!direction) continue
|
|
|
|
|
const targetSessionId = direction === 'review-to-original'
|
|
|
|
|
? getOriginalSessionID(session)
|
|
|
|
|
: getReviewSessionID(session)
|
|
|
|
|
if (!targetSessionId || !activeIds.has(targetSessionId)) continue
|
|
|
|
|
next.set(session.id, direction)
|
|
|
|
|
}
|
|
|
|
|
return next
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) => ({
|
|
|
|
|
activeSessions: [],
|
|
|
|
|
archivedSessions: [],
|
|
|
|
|
sessionsByDirectory: new Map(),
|
2026-06-18 08:43:16 +11:00
|
|
|
reviewTransferBySessionId: new Map(),
|
2026-03-31 18:47:00 +03:00
|
|
|
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 }),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-04-16 17:02:23 +03:00
|
|
|
const fallbackSnapshot = mergeSessionLists(current.activeSessions, fallbackActive);
|
2026-03-31 18:47:00 +03:00
|
|
|
const nextActiveSessions = activeResult.status === 'fulfilled'
|
|
|
|
|
? activeResult.value
|
2026-04-16 17:02:23 +03:00
|
|
|
: fallbackSnapshot;
|
2026-03-31 18:47:00 +03:00
|
|
|
const nextArchivedSessions = archivedResult.status === 'fulfilled'
|
|
|
|
|
? archivedResult.value
|
|
|
|
|
: current.archivedSessions;
|
|
|
|
|
|
|
|
|
|
if (activeResult.status === 'rejected') {
|
2026-04-16 17:02:23 +03:00
|
|
|
console.warn('[GlobalSessions] Failed to load active sessions, preserving existing snapshot with fallback merge:', activeResult.reason);
|
2026-03-31 18:47:00 +03:00
|
|
|
}
|
|
|
|
|
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) {
|
2026-04-16 17:02:23 +03:00
|
|
|
const nextActiveSessions = mergeSessionLists(current.activeSessions, fallbackActive);
|
2026-03-31 18:47:00 +03:00
|
|
|
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;
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-03 18:43:13 +03:00
|
|
|
refreshSessionsForDirectories: async (directories, fallbackActive) => {
|
|
|
|
|
const directorySet = normalizeDirectorySet(directories);
|
|
|
|
|
if (directorySet.size === 0) {
|
|
|
|
|
const state = get();
|
|
|
|
|
return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sdk = opencodeClient.getSdkClient();
|
|
|
|
|
const [active, archived] = await Promise.all([
|
|
|
|
|
fetchDirectoryPages(sdk, directorySet, false),
|
|
|
|
|
fetchDirectoryPages(sdk, directorySet, true),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if (active.errors.length > 0) {
|
|
|
|
|
console.warn('[GlobalSessions] Failed to refresh active sessions for some directories:', active.errors[0]);
|
|
|
|
|
}
|
|
|
|
|
if (archived.errors.length > 0) {
|
|
|
|
|
console.warn('[GlobalSessions] Failed to refresh archived sessions for some directories:', archived.errors[0]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set((state) => {
|
|
|
|
|
let nextActiveSessions = replaceSessionsForDirectories(state.activeSessions, active.sessions, active.directories);
|
|
|
|
|
nextActiveSessions = mergeSessionLists(nextActiveSessions, fallbackActive);
|
|
|
|
|
if (sameSessionList(state.activeSessions, nextActiveSessions)) {
|
|
|
|
|
nextActiveSessions = state.activeSessions;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let nextArchivedSessions = replaceSessionsForDirectories(state.archivedSessions, archived.sessions, archived.directories);
|
|
|
|
|
if (sameSessionList(state.archivedSessions, nextArchivedSessions)) {
|
|
|
|
|
nextArchivedSessions = state.archivedSessions;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextSessionsByDirectory = nextActiveSessions === state.activeSessions
|
|
|
|
|
? state.sessionsByDirectory
|
|
|
|
|
: buildSessionsByDirectory(nextActiveSessions);
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
nextActiveSessions === state.activeSessions
|
|
|
|
|
&& nextArchivedSessions === state.archivedSessions
|
|
|
|
|
&& nextSessionsByDirectory === state.sessionsByDirectory
|
|
|
|
|
) {
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
activeSessions: nextActiveSessions,
|
|
|
|
|
archivedSessions: nextArchivedSessions,
|
|
|
|
|
sessionsByDirectory: nextSessionsByDirectory,
|
2026-06-18 08:43:16 +11:00
|
|
|
reviewTransferBySessionId: nextActiveSessions === state.activeSessions
|
|
|
|
|
? state.reviewTransferBySessionId
|
|
|
|
|
: buildReviewTransferMap(nextActiveSessions),
|
2026-06-03 18:43:13 +03:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const state = get();
|
|
|
|
|
return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions };
|
|
|
|
|
},
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
upsertSession: (session) => {
|
|
|
|
|
set((state) => {
|
2026-06-03 13:02:18 +03:00
|
|
|
const existingSession = state.activeSessions.find((candidate) => candidate.id === session.id)
|
|
|
|
|
?? state.archivedSessions.find((candidate) => candidate.id === session.id)
|
|
|
|
|
?? null;
|
|
|
|
|
const sessionWithMetadata = mergeSessionDirectoryMetadata(session, existingSession);
|
|
|
|
|
const isArchived = Boolean(sessionWithMetadata.time?.archived);
|
2026-03-31 18:47:00 +03:00
|
|
|
const nextActiveSessions = isArchived
|
|
|
|
|
? state.activeSessions.filter((candidate) => candidate.id !== session.id)
|
2026-06-03 13:02:18 +03:00
|
|
|
: upsertSessionIntoList(state.activeSessions, sessionWithMetadata);
|
2026-03-31 18:47:00 +03:00
|
|
|
const nextArchivedSessions = isArchived
|
2026-06-03 13:02:18 +03:00
|
|
|
? upsertSessionIntoList(state.archivedSessions, sessionWithMetadata)
|
2026-03-31 18:47:00 +03:00
|
|
|
: 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),
|
2026-06-18 08:43:16 +11:00
|
|
|
reviewTransferBySessionId: nextActiveSessions === state.activeSessions
|
|
|
|
|
? state.reviewTransferBySessionId
|
|
|
|
|
: buildReviewTransferMap(nextActiveSessions),
|
2026-03-31 18:47:00 +03:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
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),
|
2026-06-18 08:43:16 +11:00
|
|
|
reviewTransferBySessionId: buildReviewTransferMap(nextActiveSessions),
|
2026-03-31 18:47:00 +03:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
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),
|
2026-06-18 08:43:16 +11:00
|
|
|
reviewTransferBySessionId: buildReviewTransferMap(nextActiveSessions),
|
2026-03-31 18:47:00 +03:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
};
|
2026-06-03 18:43:13 +03:00
|
|
|
|
|
|
|
|
export const refreshGlobalSessionsForDirectories = async (
|
|
|
|
|
directories: Iterable<string>,
|
|
|
|
|
fallbackActive?: Session[],
|
|
|
|
|
): Promise<LoadResult> => {
|
|
|
|
|
return useGlobalSessionsStore.getState().refreshSessionsForDirectories(directories, fallbackActive);
|
|
|
|
|
};
|