Files
openchamber/packages/ui/src/stores/useGlobalSessionsStore.ts
T
Bohdan Triapitsyn 1f9769a932 feat: add session review handoff flow
Introduce a desktop/web-only /handoff-review flow that generates a handoff from the active implementation session, creates or reuses a separate review session in the same directory, and links the pair through hidden OpenChamber session metadata.

Add review flow orchestration, metadata helpers, magic prompts, localized command/action labels, session metadata create/update support, and assistant message transfer actions for sending reviewer feedback back to the implementer or implementation responses back to the reviewer.

Review sessions are ordinary sessions, not child sessions. The flow avoids exposing session IDs or routing metadata to agents, hides review controls on mobile and VS Code, hides unrelated assistant actions inside review sessions, cleans up stale metadata where possible, and uses the optimistic send path so cross-session sends scroll like normal composer messages.
2026-06-07 01:22:40 +03:00

549 lines
18 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';
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>;
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;
};
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);
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;
},
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,
};
});
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),
};
});
},
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);
};
export const refreshGlobalSessionsForDirectories = async (
directories: Iterable<string>,
fallbackActive?: Session[],
): Promise<LoadResult> => {
return useGlobalSessionsStore.getState().refreshSessionsForDirectories(directories, fallbackActive);
};