From 837a47b6a396cd27163a1790b717f91196d53631 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 3 Jun 2026 18:43:13 +0300 Subject: [PATCH] fix: keep session titles and lists in sync Update sidebar titles from session events Load all session pages instead of partial lists Refresh sessions only for newly added project directories --- .../src/components/session/SessionSidebar.tsx | 38 ++++- packages/ui/src/stores/globalSessions.ts | 41 ++++- .../ui/src/stores/useGlobalSessionsStore.ts | 155 +++++++++++++++++- packages/ui/src/sync/event-pipeline.ts | 4 + packages/ui/src/sync/sync-context.tsx | 69 +++++--- 5 files changed, 284 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 2479df12..db64a333 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -69,7 +69,13 @@ import { formatProjectLabel, normalizePath, } from './sidebar/utils'; -import { mergeSessionDirectoryMetadata, refreshGlobalSessions, resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; +import { + mergeSessionDirectoryMetadata, + refreshGlobalSessions, + refreshGlobalSessionsForDirectories, + resolveGlobalSessionDirectory, + useGlobalSessionsStore, +} from '@/stores/useGlobalSessionsStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents'; @@ -794,6 +800,36 @@ export const SessionSidebar: React.FC = ({ [normalizedProjects], ); + const projectSessionDirectories = React.useMemo(() => { + const directories = new Set(); + normalizedProjects.forEach((project) => { + if (project.normalizedPath) directories.add(project.normalizedPath); + const worktrees = availableWorktreesByProject.get(project.normalizedPath) ?? []; + worktrees.forEach((worktree) => { + const directory = normalizePath(worktree.path); + if (directory) directories.add(directory); + }); + }); + return [...directories].sort(); + }, [availableWorktreesByProject, normalizedProjects]); + + const knownProjectSessionDirectoriesRef = React.useRef | null>(null); + React.useEffect(() => { + const nextDirectories = new Set(projectSessionDirectories); + const previousDirectories = knownProjectSessionDirectoriesRef.current; + knownProjectSessionDirectoriesRef.current = nextDirectories; + if (!previousDirectories) { + return; + } + + const addedDirectories = projectSessionDirectories.filter((directory) => !previousDirectories.has(directory)); + if (addedDirectories.length === 0) { + return; + } + + void refreshGlobalSessionsForDirectories(addedDirectories, syncSessionsSnapshotRef.current); + }, [projectSessionDirectories]); + const { github } = useRuntimeAPIs(); const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); diff --git a/packages/ui/src/stores/globalSessions.ts b/packages/ui/src/stores/globalSessions.ts index 36eb6294..7a01905b 100644 --- a/packages/ui/src/stores/globalSessions.ts +++ b/packages/ui/src/stores/globalSessions.ts @@ -37,6 +37,41 @@ const readResponseHeader = (response: unknown, header: string): string | null => return typeof direct === "string" ? direct : null; }; +const formatSdkError = (error: unknown): string => { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + if (error && typeof error === "object" && "message" in error && typeof (error as { message?: unknown }).message === "string") { + return (error as { message: string }).message; + } + try { + return JSON.stringify(error); + } catch { + return String(error); + } +}; + +const unwrapSessionList = ( + result: { data?: Session[]; error?: unknown; response?: { status?: number } }, + operation: string, +): GlobalSessionRecord[] => { + if (result.error) { + const status = result.response?.status; + const error = new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`); + if (status !== undefined) { + (error as Error & { status?: number }).status = status; + } + throw error; + } + + if (!Array.isArray(result.data)) { + const error = new Error(`${operation} returned no data`); + (error as Error & { status?: number }).status = 503; + throw error; + } + + return result.data as GlobalSessionRecord[]; +}; + export const readNextCursor = (response: unknown): number | null => { return toNumber(readResponseHeader(response, "x-next-cursor")); }; @@ -63,7 +98,9 @@ export const isMissingGlobalSessionsEndpointError = (error: unknown): boolean => export async function listGlobalSessionPages( apiClient: OpencodeClient, options: { + directory?: string; archived: boolean; + roots?: boolean; pageSize: number; onPage?: (sessions: GlobalSessionRecord[]) => void; }, @@ -75,14 +112,16 @@ export async function listGlobalSessionPages( while (true) { const response = await retry( () => apiClient.experimental.session.list({ + ...(options.directory ? { directory: options.directory } : {}), archived: options.archived, + ...(options.roots !== undefined ? { roots: options.roots } : {}), limit: options.pageSize, ...(cursor !== undefined ? { cursor } : {}), }), { attempts: 3, delay: 500, retryIf: () => true }, ); - const payload = Array.isArray(response.data) ? (response.data as GlobalSessionRecord[]) : []; + const payload = unwrapSessionList(response, "experimental.session.list"); if (payload.length === 0) break; let appended = 0; diff --git a/packages/ui/src/stores/useGlobalSessionsStore.ts b/packages/ui/src/stores/useGlobalSessionsStore.ts index e6d7a33c..c6dec01a 100644 --- a/packages/ui/src/stores/useGlobalSessionsStore.ts +++ b/packages/ui/src/stores/useGlobalSessionsStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import type { Session } from '@opencode-ai/sdk/v2'; +import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2'; import { opencodeClient } from '@/lib/opencode/client'; import { listGlobalSessionPages } from '@/stores/globalSessions'; @@ -17,6 +17,7 @@ type GlobalSessionsState = { hasLoaded: boolean; status: GlobalSessionsStatus; loadSessions: (fallbackActive?: Session[]) => Promise; + refreshSessionsForDirectories: (directories: Iterable, fallbackActive?: Session[]) => Promise; applySnapshot: (activeSessions: Session[], archivedSessions: Session[], status?: GlobalSessionsStatus) => void; upsertSession: (session: Session) => void; removeSessions: (ids: Iterable) => void; @@ -83,10 +84,14 @@ export const mergeSessionDirectoryMetadata = (incoming: Session, existing?: Sess 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; @@ -136,6 +141,92 @@ const sameSessionList = (prev: Session[], next: Session[]): boolean => { 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): Set => { + const next = new Set(); + for (const directory of directories) { + const normalized = normalizePath(directory); + if (normalized) next.add(normalized); + } + return next; +}; + +const replaceSessionsForDirectories = ( + existing: Session[], + incoming: Session[], + directories: Set, +): Session[] => { + if (directories.size === 0) { + return existing; + } + + const existingById = new Map(existing.map((session) => [session.id, session])); + const incomingById = new Map(); + + 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; + sessions: Session[]; + errors: unknown[]; +}; + +const fetchDirectoryPages = async ( + sdk: OpencodeClient, + directories: Set, + archived: boolean, +): Promise => { + const results = await Promise.allSettled( + [...directories].map(async (directory) => ({ + directory, + sessions: await listGlobalSessionPages(sdk, { directory, archived, pageSize: PAGE_SIZE }), + })), + ); + + const fulfilledDirectories = new Set(); + 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) { @@ -284,6 +375,61 @@ export const useGlobalSessionsStore = create((set, get) => 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) @@ -392,3 +538,10 @@ export const ensureGlobalSessionsLoaded = async (fallbackActive?: Session[]): Pr export const refreshGlobalSessions = async (fallbackActive?: Session[]): Promise => { return useGlobalSessionsStore.getState().loadSessions(fallbackActive); }; + +export const refreshGlobalSessionsForDirectories = async ( + directories: Iterable, + fallbackActive?: Session[], +): Promise => { + return useGlobalSessionsStore.getState().refreshSessionsForDirectories(directories, fallbackActive); +}; diff --git a/packages/ui/src/sync/event-pipeline.ts b/packages/ui/src/sync/event-pipeline.ts index ebb6736d..e7b057f4 100644 --- a/packages/ui/src/sync/event-pipeline.ts +++ b/packages/ui/src/sync/event-pipeline.ts @@ -257,6 +257,10 @@ export function createEventPipeline(input: EventPipelineInput): EventPipeline { const props = payload.properties as { sessionID: string } return `session.status:${props.sessionID}` } + if (payload.type === "session.updated") { + const props = payload.properties as { info?: { id?: string } } + return props.info?.id ? `session.updated:${props.info.id}` : undefined + } if (payload.type === "lsp.updated") { return "lsp.updated" } diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 7ff32fc1..4a0f832a 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -44,6 +44,8 @@ import { getRuntimeLiveStatusSeed, LIVE_STATUS_TTL_MS } from "./runtime-live-mem import { getRuntimeKey } from "@/lib/runtime-switch" import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry" import { setSessionPrefetch } from "./session-prefetch-cache" +import { listGlobalSessionPages } from "@/stores/globalSessions" +import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" // --------------------------------------------------------------------------- // Context @@ -589,6 +591,46 @@ const getSessionIdFromPayload = (event: Event): string | null => { return null } +const getSessionInfoFromPayload = (event: Event): Session | null => { + if (event.type !== "session.created" && event.type !== "session.updated" && event.type !== "session.deleted") { + return null + } + + const properties = (event as { properties?: unknown }).properties + if (!properties || typeof properties !== "object") { + return null + } + + const info = (properties as { info?: unknown }).info + if (!info || typeof info !== "object") { + return null + } + + const session = info as Partial + if (typeof session.id !== "string" || !session.time) { + return null + } + + return stripSessionDiffSnapshots(session as Session) +} + +const applySessionEventToGlobalSessions = (payload: Event) => { + if (payload.type === "session.created" || payload.type === "session.updated") { + const session = getSessionInfoFromPayload(payload) + if (session) { + useGlobalSessionsStore.getState().upsertSession(session) + } + return + } + + if (payload.type === "session.deleted") { + const sessionID = getSessionIdFromPayload(payload) ?? getSessionInfoFromPayload(payload)?.id + if (sessionID) { + useGlobalSessionsStore.getState().removeSessions([sessionID]) + } + } +} + const getMessageIdFromPayload = (event: Event): string | null => { const properties = (event as { properties?: unknown }).properties if (!properties || typeof properties !== "object") { @@ -1205,6 +1247,8 @@ function handleEvent( return } + applySessionEventToGlobalSessions(payload) + // Global events if (directory === "global" || !directory) { const recent = isRecentBoot() @@ -1561,27 +1605,12 @@ export function SyncProvider(props: { providers: globalState.providers, }, loadSessions: (dir) => retry(async () => { - const result = await props.sdk.session.list({ + const sessions = (await listGlobalSessionPages(props.sdk, { directory: dir, + archived: false, roots: true, - limit: 50, - }) - // SDK returns { error } instead of { data } on non-ok responses (503). - // Preserve HTTP status so retry()'s transient detection works. - const rawError = (result as { error?: unknown }).error - if (rawError) { - const response = (result as { response?: { status?: number } }).response - const status = response?.status - const message = typeof rawError === "object" && rawError !== null && "message" in rawError - ? String((rawError as { message?: unknown }).message) - : String(rawError) - const wrapped = new Error(`session.list failed${status ? ` (${status})` : ""}: ${message}`) - if (status !== undefined) { - ;(wrapped as Error & { status?: number }).status = status - } - throw wrapped - } - const sessions = (result.data ?? []) + pageSize: 500, + })) .filter((s) => !!s?.id) .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) // Race guard: if the list came back empty but event pipeline @@ -1591,7 +1620,7 @@ export function SyncProvider(props: { const currentSessions = store.getState().session if (sessions.length === 0 && currentSessions.length > 0) { console.warn( - `[bootstrap] session.list returned empty for ${dir}; preserving ${currentSessions.length} existing sessions`, + `[bootstrap] experimental.session.list returned empty for ${dir}; preserving ${currentSessions.length} existing sessions`, ) return }