Files
openchamber/packages/ui/src/stores/useGlobalSessionsStore.ts
T
bashrusakhandLeonid Skorobogatyy a25fc4c25a fix(sync): reflect share status from global store after cancel (#1709)
* fix(sync): reflect share status from global store after cancel

Fix #1551: unshareSession() called updateLiveSession() which silently
fails when the child store doesn't exist. The sidebar rendered from the
child store first, showing stale share data. Now overlays the global
session's share field at merge points.

* fix(sync): extract shared mergeLiveSessionWithGlobalSession helper

Extracted the share-field overlay into a single shared helper in
useGlobalSessionsStore.ts. All 3 merge sites now use the helper
instead of duplicating the overlay logic.

* test(sync): add unit tests for mergeLiveSessionWithGlobalSession helper

---------

Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
2026-06-23 22:18:44 +03:00

592 lines
20 KiB
TypeScript

import { create } from 'zustand';
import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2';
import { opencodeClient } from '@/lib/opencode/client';
import { listGlobalSessionPages } from '@/stores/globalSessions';
import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow';
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
type GlobalSessionsStatus = 'idle' | 'loading' | 'ready' | 'error';
type LoadResult = {
activeSessions: Session[];
archivedSessions: Session[];
};
type GlobalSessionsState = {
activeSessions: Session[];
archivedSessions: Session[];
sessionsByDirectory: Map<string, Session[]>;
reviewTransferBySessionId: Map<string, ReviewTransferDirection>;
hasLoaded: boolean;
status: GlobalSessionsStatus;
loadSessions: (fallbackActive?: Session[]) => Promise<LoadResult>;
refreshSessionsForDirectories: (directories: Iterable<string>, 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 = 500;
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);
};
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 = {
...(existingRecord.project ?? {}),
...(incomingRecord.project ?? {}),
worktree: existingRecord.project?.worktree,
};
changed = true;
} else if (!incomingRecord.project && existingRecord.project) {
next.project = existingRecord.project;
changed = true;
}
return changed ? next : incoming;
};
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;
};
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?.url ?? '',
JSON.stringify((session as Session & { metadata?: unknown }).metadata ?? null),
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 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 };
};
const upsertSessionIntoList = (sessions: Session[], session: Session): Session[] => {
const index = sessions.findIndex((candidate) => candidate.id === session.id);
if (index === -1) {
return [session, ...sessions];
}
const mergedSession = mergeSessionDirectoryMetadata(session, sessions[index]);
if (getSessionSignature(sessions[index]) === getSessionSignature(mergedSession)) {
return sessions;
}
const next = [...sessions];
next[index] = mergedSession;
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, mergeSessionDirectoryMetadata(session, byId.get(session.id)));
});
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);
const nextReviewTransferMap = nextActiveSessions === state.activeSessions
? state.reviewTransferBySessionId
: buildReviewTransferMap(nextActiveSessions);
if (
nextActiveSessions === state.activeSessions
&& nextArchivedSessions === state.archivedSessions
&& nextSessionsByDirectory === state.sessionsByDirectory
&& nextReviewTransferMap === state.reviewTransferBySessionId
&& state.hasLoaded
&& state.status === status
) {
return state;
}
return {
activeSessions: nextActiveSessions,
archivedSessions: nextArchivedSessions,
sessionsByDirectory: nextSessionsByDirectory,
reviewTransferBySessionId: nextReviewTransferMap,
hasLoaded: true,
status,
};
};
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
}
export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) => ({
activeSessions: [],
archivedSessions: [],
sessionsByDirectory: new Map(),
reviewTransferBySessionId: 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;
},
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,
reviewTransferBySessionId: nextActiveSessions === state.activeSessions
? state.reviewTransferBySessionId
: buildReviewTransferMap(nextActiveSessions),
};
});
const state = get();
return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions };
},
upsertSession: (session) => {
set((state) => {
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);
const nextActiveSessions = isArchived
? state.activeSessions.filter((candidate) => candidate.id !== session.id)
: upsertSessionIntoList(state.activeSessions, sessionWithMetadata);
const nextArchivedSessions = isArchived
? upsertSessionIntoList(state.archivedSessions, sessionWithMetadata)
: 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),
reviewTransferBySessionId: nextActiveSessions === state.activeSessions
? state.reviewTransferBySessionId
: buildReviewTransferMap(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),
reviewTransferBySessionId: buildReviewTransferMap(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),
reviewTransferBySessionId: buildReviewTransferMap(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);
};
export const refreshGlobalSessionsForDirectories = async (
directories: Iterable<string>,
fallbackActive?: Session[],
): Promise<LoadResult> => {
return useGlobalSessionsStore.getState().refreshSessionsForDirectories(directories, fallbackActive);
};