From ed37d8e24985fd26f65e02af504fe34a448f62ba Mon Sep 17 00:00:00 2001 From: c_w_xiaohei <1641233466@qq.com> Date: Sun, 23 Aug 2026 02:47:31 +0800 Subject: [PATCH] perf(ui): isolate sidebar session selection --- .../sidebar/list/SessionProjectCollection.tsx | 6 +- .../sidebar/list/useSessionPrefetch.test.tsx | 2 +- .../sidebar/list/useSessionPrefetch.ts | 22 +- packages/ui/src/sync/session-records.test.ts | 54 +++++ packages/ui/src/sync/session-records.ts | 91 ++++++++ .../sync-context-selection-boundary.test.tsx | 67 ++++++ packages/ui/src/sync/sync-context.tsx | 60 ++--- packages/ui/src/sync/use-sync.ts | 208 ++++++++---------- 8 files changed, 351 insertions(+), 159 deletions(-) create mode 100644 packages/ui/src/sync/session-records.test.ts create mode 100644 packages/ui/src/sync/session-records.ts create mode 100644 packages/ui/src/sync/sync-context-selection-boundary.test.tsx diff --git a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx index efc736a9..5f0c6cef 100644 --- a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx +++ b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useSync } from '@/sync/use-sync'; +import { usePrefetchSessionMessages } from '@/sync/use-sync'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; @@ -148,7 +148,7 @@ const VisibleSessionProjects: React.FC = ({ topol if (sessionId === useSessionUIStore.getState().currentSessionId) return; setCurrentSession(sessionId, sessionDirectory); }, [setCurrentSession]); - const sync = useSync(); + const prefetchSession = usePrefetchSessionMessages(); const { buildGroupedSessions, filterSessionNodesForSearch, buildGroupSearchText } = useSessionGrouping({ homeDirectory: view.homeDirectory, worktreeMetadata: topology.worktreeMetadata, @@ -487,7 +487,7 @@ const VisibleSessionProjects: React.FC = ({ topol { currentSessionId: current.id, sortedSessions: [current, nearby], recentSessions: [current, nearby], - prefetchSession: async (sessionId) => { calls.push(sessionId); }, + prefetchSession: async ({ sessionID }) => { calls.push(sessionID); }, }); return null; }; diff --git a/packages/ui/src/components/session/sidebar/list/useSessionPrefetch.ts b/packages/ui/src/components/session/sidebar/list/useSessionPrefetch.ts index c8dc60c5..13a1eeae 100644 --- a/packages/ui/src/components/session/sidebar/list/useSessionPrefetch.ts +++ b/packages/ui/src/components/session/sidebar/list/useSessionPrefetch.ts @@ -14,7 +14,7 @@ type Args = { currentSessionId: string | null; sortedSessions: Session[]; recentSessions?: Session[]; - prefetchSession: (sessionId: string, directory: string) => Promise; + prefetchSession: (target: { directory: string; sessionID: string }) => Promise; }; type PrefetchRequest = { @@ -23,6 +23,10 @@ type PrefetchRequest = { generation: number; }; +const getPrefetchRequestKey = (request: Pick): string => ( + `${request.directory}\n${request.sessionId}` +); + const sessionDirectory = (session: Session | null | undefined): string | null => { const directory = session?.directory?.trim(); return directory || null; @@ -35,10 +39,6 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes const generationRef = React.useRef(0); const prefetchDisabled = React.useMemo(() => isVSCodeRuntime(), []); - const requestKey = React.useCallback((request: Pick) => ( - `${request.directory}\n${request.sessionId}` - ), []); - const clearPendingPrefetches = React.useCallback(() => { generationRef.current += 1; sessionPrefetchQueueRef.current = []; @@ -68,16 +68,16 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes continue; } - const key = requestKey(request); + const key = getPrefetchRequestKey(request); sessionPrefetchInFlightRef.current.add(key); - void prefetchSession(request.sessionId, request.directory) + void prefetchSession({ directory: request.directory, sessionID: request.sessionId }) .catch(() => undefined) .finally(() => { sessionPrefetchInFlightRef.current.delete(key); pumpSessionPrefetchQueue(); }); } - }, [enabled, prefetchDisabled, prefetchSession, requestKey]); + }, [enabled, prefetchDisabled, prefetchSession]); const scheduleSessionPrefetch = React.useCallback((session: Session | null | undefined) => { const sessionId = session?.id; @@ -86,7 +86,7 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes return; } const request = { sessionId, directory, generation: generationRef.current }; - const key = requestKey(request); + const key = getPrefetchRequestKey(request); // Already renderable in sync if (getSyncSessionMaterializationStatus(sessionId, directory).renderable) { @@ -97,7 +97,7 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes return; } - if (sessionPrefetchQueueRef.current.some((candidate) => requestKey(candidate) === key)) { + if (sessionPrefetchQueueRef.current.some((candidate) => getPrefetchRequestKey(candidate) === key)) { return; } @@ -117,7 +117,7 @@ export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSes pumpSessionPrefetchQueue(); }, SESSION_PREFETCH_HOVER_DELAY_MS); sessionPrefetchTimersRef.current.set(key, timer); - }, [currentSessionId, enabled, prefetchDisabled, pumpSessionPrefetchQueue, requestKey]); + }, [currentSessionId, enabled, prefetchDisabled, pumpSessionPrefetchQueue]); React.useEffect(() => { clearPendingPrefetches(); diff --git a/packages/ui/src/sync/session-records.test.ts b/packages/ui/src/sync/session-records.test.ts new file mode 100644 index 00000000..8a41c3d6 --- /dev/null +++ b/packages/ui/src/sync/session-records.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" +import type { Session } from "@opencode-ai/sdk/v2" +import { upsertSessionRecord } from "./session-records" + +const session = (id: string, overrides: Partial = {}): Session => ({ + id, slug: id, projectID: "project", directory: "/workspace", title: id, version: "1", + time: { created: 1, updated: 1 }, ...overrides, +}) + +describe("upsertSessionRecord", () => { + test("inserts missing IDs in binary order", () => { + expect(upsertSessionRecord([session("a"), session("c")], session("b")).map((item) => item.id)).toEqual(["a", "b", "c"]) + }) + + test("preserves references for separately allocated equivalent metadata", () => { + const current = [session("a", { + metadata: { nested: ["value", { count: 1 }] }, + summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 1, deletions: 0 }] }, + }), session("b")] + const incoming = session("a", { + metadata: { nested: ["value", { count: 1 }] }, + summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 1, deletions: 0 }] }, + }) + const result = upsertSessionRecord(current, incoming) + expect(result).toBe(current) + expect(result[0]).toBe(current[0]) + expect(result[1]).toBe(current[1]) + }) + + const changes: Array<[string, Partial, Partial]> = [ + ["scalars", { workspaceID: "one", path: "a", parentID: "p", cost: 1, agent: "a" }, { workspaceID: "two", path: "b", parentID: "q", cost: 2, agent: "b" }], + ["tokens", { tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } } }, { tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 6 } } }], + ["share and model", { share: { url: "a" }, model: { id: "m", providerID: "p", variant: "a" } }, { share: { url: "b" }, model: { id: "m", providerID: "p", variant: "b" } }], + ["metadata", { metadata: { key: "a" } }, { metadata: { key: "b" } }], + ["permission", { permission: [{ permission: "bash", pattern: "*", action: "ask" }] }, { permission: [{ permission: "bash", pattern: "*", action: "allow" }] }], + ["revert", { revert: { messageID: "m", partID: "a", snapshot: "s", diff: "d" } }, { revert: { messageID: "m", partID: "b", snapshot: "s", diff: "d" } }], + ["summary diffs", { summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 1, deletions: 0 }] } }, { summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 2, deletions: 0 }] } }], + ["time", { time: { created: 1, updated: 1, compacting: 2, archived: 3 } }, { time: { created: 1, updated: 2, compacting: 2, archived: 3 } }], + ] + + for (const [field, current, incoming] of changes) { + test(`replaces only target when ${field} changes`, () => { + const first = session("a") + const target = session("b", current) + const last = session("c") + const list = [first, target, last] + const result = upsertSessionRecord(list, session("b", incoming)) + expect(result).not.toBe(list) + expect(result[0]).toBe(first) + expect(result[1]).not.toBe(target) + expect(result[2]).toBe(last) + }) + } +}) diff --git a/packages/ui/src/sync/session-records.ts b/packages/ui/src/sync/session-records.ts new file mode 100644 index 00000000..1e6a3ce3 --- /dev/null +++ b/packages/ui/src/sync/session-records.ts @@ -0,0 +1,91 @@ +import type { PermissionRuleset, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import { Binary } from "./binary" + +function areMetadataEqual(left: Session["metadata"], right: Session["metadata"]): boolean { + return JSON.stringify(left ?? null) === JSON.stringify(right ?? null) +} + +function optionalEqual( + left: T | undefined, + right: T | undefined, + equal: (left: T, right: T) => boolean, +): boolean { + return left === right || (left !== undefined && right !== undefined && equal(left, right)) +} + +const diffsEqual = (left: SnapshotFileDiff[], right: SnapshotFileDiff[]) => ( + left.length === right.length + && left.every((item, index) => { + const candidate = right[index] + return item.file === candidate.file + && item.patch === candidate.patch + && item.additions === candidate.additions + && item.deletions === candidate.deletions + && item.status === candidate.status + }) +) + +const permissionsEqual = (left: PermissionRuleset, right: PermissionRuleset) => ( + left.length === right.length + && left.every((item, index) => { + const candidate = right[index] + return item.permission === candidate.permission + && item.pattern === candidate.pattern + && item.action === candidate.action + }) +) + +function areSessionsEqual(left: Session, right: Session): boolean { + return left.id === right.id + && left.slug === right.slug + && left.projectID === right.projectID + && left.workspaceID === right.workspaceID + && left.directory === right.directory + && left.path === right.path + && left.parentID === right.parentID + && left.cost === right.cost + && left.title === right.title + && left.agent === right.agent + && left.version === right.version + && areMetadataEqual(left.metadata, right.metadata) + && optionalEqual(left.summary, right.summary, (a, b) => ( + a.additions === b.additions + && a.deletions === b.deletions + && a.files === b.files + && optionalEqual(a.diffs, b.diffs, diffsEqual) + )) + && optionalEqual(left.tokens, right.tokens, (a, b) => ( + a.input === b.input + && a.output === b.output + && a.reasoning === b.reasoning + && a.cache.read === b.cache.read + && a.cache.write === b.cache.write + )) + && optionalEqual(left.share, right.share, (a, b) => a.url === b.url) + && optionalEqual(left.model, right.model, (a, b) => ( + a.id === b.id + && a.providerID === b.providerID + && a.variant === b.variant + )) + && left.time.created === right.time.created + && left.time.updated === right.time.updated + && left.time.compacting === right.time.compacting + && left.time.archived === right.time.archived + && optionalEqual(left.permission, right.permission, permissionsEqual) + && optionalEqual(left.revert, right.revert, (a, b) => ( + a.messageID === b.messageID + && a.partID === b.partID + && a.snapshot === b.snapshot + && a.diff === b.diff + )) +} + +export function upsertSessionRecord(current: Session[], incoming: Session): Session[] { + const result = Binary.search(current, incoming.id, (session) => session.id) + if (!result.found) return [...current.slice(0, result.index), incoming, ...current.slice(result.index)] + // Equivalent authoritative detail must retain sidebar session-list references. + if (areSessionsEqual(current[result.index], incoming)) return current + const next = [...current] + next[result.index] = incoming + return next +} diff --git a/packages/ui/src/sync/sync-context-selection-boundary.test.tsx b/packages/ui/src/sync/sync-context-selection-boundary.test.tsx new file mode 100644 index 00000000..5bf96a48 --- /dev/null +++ b/packages/ui/src/sync/sync-context-selection-boundary.test.tsx @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test' +import React, { act } from 'react' +import { createRoot } from 'react-dom/client' +import { createOpencodeClient } from '@opencode-ai/sdk/v2' +import { SyncProvider, useSyncDirectory } from './sync-context' +import { usePrefetchSessionMessages } from './use-sync' +import { installHookTestDom } from '../components/session/sidebar/test-utils/testDom' + +const createSdk = () => createOpencodeClient({ + baseUrl: 'https://sync.test', + fetch: async (request) => { + const path = new URL(request instanceof Request ? request.url : request.toString()).pathname + if (path.endsWith('/global/event')) { + return new Response(new ReadableStream(), { headers: { 'content-type': 'text/event-stream' } }) + } + const body = path.endsWith('/path') + ? { state: '', config: '', worktree: '/workspace', directory: '/workspace', home: '/home' } + : path.endsWith('/project') ? [] + : path.endsWith('/project/current') ? { id: 'project' } + : path.endsWith('/session/status') ? {} + : [] + return new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } }) + }, +}) + +describe('SyncProvider selection boundary', () => { + test('does not rerender a stable prefetch consumer when only current directory changes', async () => { + const dom = installHookTestDom() + const root = createRoot(dom.container) + let runtimeRenders = 0 + let directoryRenders = 0 + let callback: ReturnType | undefined + const RuntimeConsumer = React.memo(() => { + callback = usePrefetchSessionMessages() + runtimeRenders += 1 + return null + }) + const DirectoryConsumer = () => { + useSyncDirectory() + directoryRenders += 1 + return null + } + const sdk = createSdk() + + try { + await act(async () => root.render( + + + + , + )) + const initialCallback = callback + await act(async () => root.render( + + + + , + )) + expect(runtimeRenders).toBe(1) + expect(callback).toBe(initialCallback) + expect(directoryRenders).toBe(2) + } finally { + await act(async () => root.unmount()) + dom.restore() + } + }) +}) diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 772138a1..b4a30f3f 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -37,6 +37,7 @@ import { setActionRefs } from "./session-actions" import { setSyncRefs, getAllSyncSessions } from "./sync-refs" import { useSessionUIStore } from "./session-ui-store" import { stripSessionDiffSnapshots } from "./sanitize" +import { upsertSessionRecord } from "./session-records" import { applySessionEventToGlobalSessions } from "./session-event-router" import { syncDebug } from "./debug" import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./reconnect-recovery" @@ -86,22 +87,29 @@ import { // Context // --------------------------------------------------------------------------- -type SyncSystem = { +type SyncRuntime = { childStores: ChildStoreManager messageLoader: SessionMessageLoader runtimeKey: string sdk: OpencodeClient +} + +type SyncSystem = SyncRuntime & { directory: string } const SYNC_CONTEXT_GLOBAL_KEY = "__openchamber_sync_context__" +const SYNC_RUNTIME_CONTEXT_GLOBAL_KEY = "__openchamber_sync_runtime_context__" type SyncGlobal = typeof globalThis & { [SYNC_CONTEXT_GLOBAL_KEY]?: React.Context + [SYNC_RUNTIME_CONTEXT_GLOBAL_KEY]?: React.Context } const syncGlobal = globalThis as SyncGlobal const SyncContext = syncGlobal[SYNC_CONTEXT_GLOBAL_KEY] ?? createContext(null) syncGlobal[SYNC_CONTEXT_GLOBAL_KEY] = SyncContext +const SyncRuntimeContext = syncGlobal[SYNC_RUNTIME_CONTEXT_GLOBAL_KEY] ?? createContext(null) +syncGlobal[SYNC_RUNTIME_CONTEXT_GLOBAL_KEY] = SyncRuntimeContext type SdkResult = { data?: T @@ -137,6 +145,12 @@ function useSyncSystem() { return ctx } +export function useSyncRuntime() { + const ctx = useContext(SyncRuntimeContext) + if (!ctx) throw new Error("useSyncRuntime must be used within ") + return ctx +} + function getLiveStates(childStores: ChildStoreManager): State[] { return Array.from(childStores.children.values(), (store) => store.getState()) } @@ -1408,28 +1422,13 @@ async function resyncDirectoryAfterReconnect( const nextSession = stripSessionDiffSnapshots(session) store.setState((state: DirectoryStore) => { - const sessionIndex = state.session.findIndex((item) => item.id === nextSession.id) - let sessions = state.session - let sessionChanged = false + const sessions = upsertSessionRecord(state.session, nextSession) let sessionTotal = state.sessionTotal - if (sessionIndex >= 0) { - if (!haveEquivalentSyncSnapshots(sessions[sessionIndex], nextSession)) { - sessions = [...state.session] - sessions[sessionIndex] = nextSession - sessionChanged = true - } - } else { - sessions = [...state.session] - sessions.push(nextSession) - sessions.sort((a, b) => cmp(a.id, b.id)) - if (!nextSession.parentID) sessionTotal += 1 - sessionChanged = true - } - - if (!sessionChanged) { + if (sessions === state.session) { return state } + if (!state.session.some((item) => item.id === nextSession.id) && !nextSession.parentID) sessionTotal += 1 return { session: sessions, @@ -2024,15 +2023,13 @@ export function SyncProvider(props: { const pipelineHasConnectedRef = useRef(false) const pipelineDisconnectedBeforeFirstConnectRef = useRef(false) + const runtime = useMemo( + () => ({ childStores, messageLoader, runtimeKey, sdk: props.sdk }), + [childStores, messageLoader, props.sdk, runtimeKey], + ) const system = useMemo( - () => ({ - childStores, - messageLoader, - runtimeKey, - sdk: props.sdk, - directory: props.directory, - }), - [childStores, messageLoader, props.sdk, props.directory, runtimeKey], + () => ({ ...runtime, directory: props.directory }), + [props.directory, runtime], ) const triggerDirectoryResync = useCallback((directory: string, reason: SessionMaterializationReason) => { @@ -2547,7 +2544,14 @@ export function SyncProvider(props: { return unsubscribe }, [props.directory, childStores]) - return {props.children} + // Directory navigation must not republish stable runtime dependencies. + return ( + + + {props.children} + + + ) } // --------------------------------------------------------------------------- diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index 629821f7..e5ea35e6 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -1,9 +1,10 @@ import { useCallback, useMemo } from "react" import type { Message, Part } from "@opencode-ai/sdk/v2/client" import { Binary } from "./binary" +import { upsertSessionRecord } from "./session-records" import { retry } from "./retry" import { SESSION_CACHE_LIMIT, type State } from "./types" -import { pickSessionCacheEvictions } from "./session-cache" +import { dropSessionCaches, getProtectedSessionCacheIds, pickSessionCacheEvictions } from "./session-cache" import { dropCachedSessionMessageRecordsSnapshots, useChildStoreManager, @@ -11,9 +12,9 @@ import { useSessionMessageLoader, useSyncDirectory, useSyncSDK, + useSyncRuntime, resyncBlockingRequestsForDirectory, } from "./sync-context" -import { dropSessionCaches, getProtectedSessionCacheIds } from "./session-cache" import { stripSessionDiffSnapshots } from "./sanitize" import { isVSCodeRuntime } from "@/lib/desktop" import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface" @@ -46,7 +47,6 @@ const syncSessionInflightByKey = new Map>() // to the store. This prevents rapid session switches (e.g. 1→2→3 in the // sidebar) from having each completed fetch fight for focus. const syncSessionGenerationByKey = new Map() - type SdkResult = { data?: T error?: unknown @@ -111,10 +111,80 @@ export function shouldFetchSessionForRenderableSync(input: { return Boolean(input.force) || !input.hasSession || input.shouldLoadMessages } -// --------------------------------------------------------------------------- -// useSync — message loading, pagination, optimistic updates -// Message loading, pagination, optimistic updates -// --------------------------------------------------------------------------- +function useSessionCacheTouch() { + const { childStores, messageLoader, runtimeKey } = useSyncRuntime() + + const evict = useCallback( + (directory: string, sessionIDs: string[]) => { + if (sessionIDs.length === 0 || getRuntimeKey() !== runtimeKey) return + const store = childStores.getChild(directory) + if (!store) return + + const current = store.getState() + const draft = { + message: { ...current.message }, + part: { ...current.part }, + session_status: { ...current.session_status }, + session_diff: { ...current.session_diff }, + todo: { ...current.todo }, + permission: { ...current.permission }, + question: { ...current.question }, + } + dropSessionCaches(draft, sessionIDs) + dropCachedSessionMessageRecordsSnapshots(store, sessionIDs) + store.setState(draft) + for (const sessionID of sessionIDs) messageLoader.invalidateSession({ directory, sessionID }) + clearSessionPrefetch(directory, sessionIDs) + }, + [childStores, messageLoader, runtimeKey], + ) + + const seenFor = useCallback((directory: string) => { + const cacheKey = `${runtimeKey}\n${directory}` + const existing = seenByDirectory.get(cacheKey) + if (existing) { + seenByDirectory.delete(cacheKey) + seenByDirectory.set(cacheKey, existing) + return existing.sessions + } + const created: SeenDirectoryEntry = { runtimeKey, directory, sessions: new Set() } + seenByDirectory.set(cacheKey, created) + while (seenByDirectory.size > MAX_SEEN_DIRS) { + const oldestKey = seenByDirectory.keys().next().value + if (!oldestKey) break + const oldest = seenByDirectory.get(oldestKey) + seenByDirectory.delete(oldestKey) + if (oldest?.runtimeKey === runtimeKey) evict(oldest.directory, [...oldest.sessions]) + } + return created.sessions + }, [evict, runtimeKey]) + + return useCallback((sessionID: string, directory: string) => { + if (getRuntimeKey() !== runtimeKey) return + const seen = seenFor(directory) + const store = childStores.ensureChild(directory, { bootstrap: false }) + const protectedIds = getProtectedSessionCacheIds(store.getState()) + const stale = pickSessionCacheEvictions({ + seen, + keep: sessionID, + limit: getEffectiveSessionCacheLimit(), + preserve: protectedIds, + }) + evict(directory, stale) + + if (!isConstrainedSessionRuntime()) return + const state = store.getState() + const keep = new Set([sessionID, ...seen, ...protectedIds]) + const prefetched = Object.keys(state.message).filter((id) => !keep.has(id)) + evict(directory, prefetched) + const afterPrefetchEviction = prefetched.length > 0 ? store.getState() : state + const heavyInactive = Object.keys(afterPrefetchEviction.message).filter((id) => ( + id !== sessionID && !protectedIds.has(id) && isHeavyConstrainedSessionCache(afterPrefetchEviction, id) + )) + for (const id of heavyInactive) seen.delete(id) + evict(directory, heavyInactive) + }, [childStores, evict, runtimeKey, seenFor]) +} export function useSync() { const sdk = useSyncSDK() @@ -123,6 +193,7 @@ export function useSync() { const childStores = useChildStoreManager() const messageLoader = useSessionMessageLoader() const runtimeKey = getRuntimeKey() + const touch = useSessionCacheTouch() const recoverPendingQuestions = useCallback( async (sessionID: string, directoryOverride?: string): Promise => { @@ -146,107 +217,6 @@ export function useSync() { [directory, runtimeKey], ) - // Session cache eviction — two levels of LRU: - // (1) across directories (max 30), (2) within a directory (SESSION_CACHE_LIMIT). - - // Evict all cached session data for given IDs from a directory's store - const evict = useCallback( - (dir: string, sessionIDs: string[]) => { - if (sessionIDs.length === 0 || getRuntimeKey() !== runtimeKey) return - const dirStore = childStores.getChild(dir) - if (!dirStore) return - - const current = dirStore.getState() - const draft = { - message: { ...current.message }, - part: { ...current.part }, - session_status: { ...current.session_status }, - session_diff: { ...current.session_diff }, - todo: { ...current.todo }, - permission: { ...current.permission }, - question: { ...current.question }, - } - dropSessionCaches(draft, sessionIDs) - dropCachedSessionMessageRecordsSnapshots(dirStore, sessionIDs) - dirStore.setState(draft) - - // Clear meta + optimistic + prefetch cache for evicted sessions - for (const id of sessionIDs) { - messageLoader.invalidateSession({ directory: dir, sessionID: id }) - } - clearSessionPrefetch(dir, sessionIDs) - }, - [childStores, messageLoader, runtimeKey], - ) - - // Get or create the seen-set for a directory. LRU reorder on access. - // When seen directories exceed MAX_SEEN_DIRS, evict the oldest directory's caches. - // LRU reorder on access. Evicts oldest directory when exceeding MAX_SEEN_DIRS. - const seenFor = useCallback((targetDirectory: string) => { - const cacheKey = `${runtimeKey}\n${targetDirectory}` - const existing = seenByDirectory.get(cacheKey) - if (existing) { - // LRU reorder: delete + re-insert moves to end (most recent) - seenByDirectory.delete(cacheKey) - seenByDirectory.set(cacheKey, existing) - return existing.sessions - } - const created: SeenDirectoryEntry = { runtimeKey, directory: targetDirectory, sessions: new Set() } - seenByDirectory.set(cacheKey, created) - - // Evict oldest directories if over limit - while (seenByDirectory.size > MAX_SEEN_DIRS) { - const first = seenByDirectory.keys().next().value - if (!first) break - const stale = seenByDirectory.get(first) - seenByDirectory.delete(first) - if (stale?.runtimeKey === runtimeKey) evict(stale.directory, [...stale.sessions]) - } - - return created.sessions - }, [evict, runtimeKey]) - - // Touch a session — triggers both directory-level and session-level eviction - const touch = useCallback( - (sessionID: string, targetDirectory = directory) => { - if (getRuntimeKey() !== runtimeKey) return - const s = seenFor(targetDirectory) - const targetStore = targetDirectory === directory - ? store - : childStores.ensureChild(targetDirectory, { bootstrap: false }) - const protectedIds = getProtectedSessionCacheIds(targetStore.getState()) - const cacheLimit = getEffectiveSessionCacheLimit() - const stale = pickSessionCacheEvictions({ - seen: s, - keep: sessionID, - limit: cacheLimit, - preserve: protectedIds, - }) - evict(targetDirectory, stale) - - if (isConstrainedSessionRuntime()) { - const state = targetStore.getState() - const keep = new Set([sessionID, ...s, ...protectedIds]) - const prefetched = Object.keys(state.message).filter((id) => !keep.has(id)) - evict(targetDirectory, prefetched) - - // One very large inactive session can create memory/GC pressure that - // makes later small-session switches feel slow. Keep it while active, - // but do not retain it as a warm cache in constrained shells. - const afterPrefetchEviction = prefetched.length > 0 ? targetStore.getState() : state - const heavyInactive = Object.keys(afterPrefetchEviction.message).filter((id) => { - if (id === sessionID || protectedIds.has(id)) return false - return isHeavyConstrainedSessionCache(afterPrefetchEviction, id) - }) - if (heavyInactive.length > 0) { - for (const id of heavyInactive) s.delete(id) - evict(targetDirectory, heavyInactive) - } - } - }, - [childStores, directory, seenFor, evict, runtimeKey, store], - ) - // Sync a session (load if not cached) const syncSession = useCallback( async (sessionID: string, force?: boolean, directoryOverride?: string) => { @@ -289,14 +259,8 @@ export function useSync() { if (result.data && !isStale()) { const nextSession = stripSessionDiffSnapshots(result.data) const s = targetStore.getState() - const sessions = [...s.session] - const idx = Binary.search(sessions, sessionID, (s) => s.id) - if (idx.found) { - sessions[idx.index] = nextSession - } else { - sessions.splice(idx.index, 0, nextSession) - } - if (!isStale()) { + const sessions = upsertSessionRecord(s.session, nextSession) + if (sessions !== s.session && !isStale()) { targetStore.setState({ session: sessions }) } } @@ -436,3 +400,15 @@ export function useSync() { [syncSession, prefetchSession, loadMore, loadCompleteHistory, hasMore, isLoading, isComplete, recoverPendingQuestions, optimisticAdd, optimisticRemove, optimisticConfirm], ) } + +export function usePrefetchSessionMessages() { + const { messageLoader, runtimeKey } = useSyncRuntime() + const touch = useSessionCacheTouch() + + return useCallback(async ({ directory, sessionID }: { directory: string; sessionID: string }) => { + if (getRuntimeKey() !== runtimeKey) return + await messageLoader.prefetch({ directory, sessionID }) + if (messageLoader.getSnapshot({ directory, sessionID }).status !== "ready") return + touch(sessionID, directory) + }, [messageLoader, runtimeKey, touch]) +}