diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index c478ce39..9a571347 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -61,6 +61,10 @@ Examples: - per-directory session/message bootstrap - session/message/part SSE updates +Directory bootstrap must publish a closed session hierarchy: when a child is +returned before the roots query catches up during cold startup, retain or +recover its referenced parent instead of exposing an orphan-only snapshot. + ### Global session list Use `useGlobalSessionsStore` when the UI needs a **shared global session cache**. diff --git a/packages/ui/src/sync/reconnect-recovery.test.ts b/packages/ui/src/sync/reconnect-recovery.test.ts index 1f0c44eb..6bbbbae1 100644 --- a/packages/ui/src/sync/reconnect-recovery.test.ts +++ b/packages/ui/src/sync/reconnect-recovery.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import type { Message, Part, SessionStatus } from "@opencode-ai/sdk/v2/client" import type { Session } from "@opencode-ai/sdk/v2" -import { getReconnectCandidateSessionIds } from "./reconnect-recovery" +import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./reconnect-recovery" function createSession(id: string, overrides: Partial = {}): Session { return { @@ -88,3 +88,26 @@ describe("getReconnectCandidateSessionIds", () => { }).sort()).not.toContain("active") }) }) + +describe("mergeBootstrapSessions", () => { + test("recovers a referenced parent when the roots response is temporarily empty", () => { + const parent = createSession("parent") + const child = createSession("child", { parentID: "parent" }) + + expect(mergeBootstrapSessions([], [child], [parent])).toEqual({ + sessions: [child, parent], + rootCount: 1, + }) + }) + + test("recovers referenced parents from the broader response without retaining stale roots", () => { + const parent = createSession("parent") + const stale = createSession("stale") + const child = createSession("child", { parentID: "parent" }) + + expect(mergeBootstrapSessions([], [parent, child], [stale])).toEqual({ + sessions: [child, parent], + rootCount: 1, + }) + }) +}) diff --git a/packages/ui/src/sync/reconnect-recovery.ts b/packages/ui/src/sync/reconnect-recovery.ts index cd5e5a5a..da1379c6 100644 --- a/packages/ui/src/sync/reconnect-recovery.ts +++ b/packages/ui/src/sync/reconnect-recovery.ts @@ -19,6 +19,46 @@ type ReconnectCandidateOptions = { viewedSession?: ViewedSessionMaterializationTarget | null } +export function mergeBootstrapSessions( + rootSessions: Session[], + allSessions: Session[], + existingSessions: Session[], +): { sessions: Session[]; rootCount: number } { + const rootIds = new Set(rootSessions.map((session) => session.id)) + const sessionsById = new Map(existingSessions.map((session) => [session.id, session])) + for (const session of allSessions) sessionsById.set(session.id, session) + for (const session of rootSessions) sessionsById.set(session.id, session) + + const includedIds = new Set(rootIds) + const pendingParentIds: string[] = [] + for (const session of allSessions) { + const parentId = (session as Session & { parentID?: string | null }).parentID + if (!parentId) continue + includedIds.add(session.id) + pendingParentIds.push(parentId) + } + + while (pendingParentIds.length > 0) { + const parentId = pendingParentIds.pop() + if (!parentId || includedIds.has(parentId)) continue + const parent = sessionsById.get(parentId) + if (!parent) continue + includedIds.add(parentId) + const ancestorId = (parent as Session & { parentID?: string | null }).parentID + if (ancestorId) pendingParentIds.push(ancestorId) + } + + const sessions = [...includedIds] + .map((id) => sessionsById.get(id)) + .filter((session): session is Session => Boolean(session)) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + const rootCount = sessions.reduce((count, session) => ( + (session as Session & { parentID?: string | null }).parentID ? count : count + 1 + ), 0) + + return { sessions, rootCount } +} + export function getReconnectCandidateSessionIds(state: ReconnectMaterializationState, options?: ReconnectCandidateOptions) { const ids = new Set() diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 2aeff26c..d5e93777 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -27,7 +27,7 @@ import { setSyncRefs, getAllSyncSessions } from "./sync-refs" import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize" import { applySessionEventToGlobalSessions } from "./session-event-router" import { syncDebug } from "./debug" -import { getReconnectCandidateSessionIds } from "./reconnect-recovery" +import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./reconnect-recovery" import { opencodeClient } from "@/lib/opencode/client" import { usePermissionStore } from "@/stores/permissionStore" import { processVSCodePermissionAutoAccept } from "./vscode-permission-auto-accept" @@ -1780,24 +1780,22 @@ export function SyncProvider(props: { // Child load is best-effort; fall back to roots only. } - // Merge: keep root sessions from the first query (for accurate - // sessionTotal), plus any child sessions from the broader query. - const rootIds = new Set(rootSessions.map((s: { id: string }) => s.id)) - const childSessions = allSessions.filter((s: { id: string; parentID?: string | null }) => s?.id && !rootIds.has(s.id) && s.parentID) - - const sessions = rootSessions.concat(childSessions) + // A cold OpenCode process can briefly return children before its + // roots query catches up. Recover referenced parents from the + // broader response or cache instead of publishing orphan rows. + const currentSessions = store.getState().session + const { sessions, rootCount } = mergeBootstrapSessions(rootSessions, allSessions, currentSessions) // Race guard: if the list came back empty but event pipeline // already populated the store, don't clobber. OpenCode can // answer HTTP with empty sessions while WS delivers session // events for the same data (disk warmup race on app launch). - const currentSessions = store.getState().session if (sessions.length === 0 && currentSessions.length > 0) { console.warn( `[bootstrap] experimental.session.list returned empty for ${dir}; preserving ${currentSessions.length} existing sessions`, ) return } - store.setState({ session: sessions, sessionTotal: rootSessions.length, limit: Math.max(sessions.length, 50) }) + store.setState({ session: sessions, sessionTotal: rootCount, limit: Math.max(sessions.length, 50) }) ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState()) }), })