perf(ui): isolate sidebar session selection

This commit is contained in:
c_w_xiaohei
2026-08-26 00:42:36 +08:00
parent 77da97953f
commit ed37d8e249
8 changed files with 351 additions and 159 deletions
@@ -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<SessionProjectCollectionProps> = ({ 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<SessionProjectCollectionProps> = ({ topol
<SessionPrefetchEffect
sortedSessions={collection.orderedSessions}
recentSessions={recentSessions}
prefetchSession={sync.prefetchSession}
prefetchSession={prefetchSession}
/>
<SessionProjectScroller model={scrollerModel} view={scrollerView} actions={scrollerActionSet} />
<SessionBulkActions
@@ -28,7 +28,7 @@ describe('session prefetch demand', () => {
currentSessionId: current.id,
sortedSessions: [current, nearby],
recentSessions: [current, nearby],
prefetchSession: async (sessionId) => { calls.push(sessionId); },
prefetchSession: async ({ sessionID }) => { calls.push(sessionID); },
});
return null;
};
@@ -14,7 +14,7 @@ type Args = {
currentSessionId: string | null;
sortedSessions: Session[];
recentSessions?: Session[];
prefetchSession: (sessionId: string, directory: string) => Promise<void>;
prefetchSession: (target: { directory: string; sessionID: string }) => Promise<void>;
};
type PrefetchRequest = {
@@ -23,6 +23,10 @@ type PrefetchRequest = {
generation: number;
};
const getPrefetchRequestKey = (request: Pick<PrefetchRequest, 'directory' | 'sessionId'>): 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<PrefetchRequest, 'directory' | 'sessionId'>) => (
`${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();
@@ -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> = {}): 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<Session>, Partial<Session>]> = [
["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)
})
}
})
+91
View File
@@ -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<T>(
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
}
@@ -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<typeof usePrefetchSessionMessages> | 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(
<SyncProvider sdk={sdk} directory="/workspace/a">
<RuntimeConsumer />
<DirectoryConsumer />
</SyncProvider>,
))
const initialCallback = callback
await act(async () => root.render(
<SyncProvider sdk={sdk} directory="/workspace/b">
<RuntimeConsumer />
<DirectoryConsumer />
</SyncProvider>,
))
expect(runtimeRenders).toBe(1)
expect(callback).toBe(initialCallback)
expect(directoryRenders).toBe(2)
} finally {
await act(async () => root.unmount())
dom.restore()
}
})
})
+32 -28
View File
@@ -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<SyncSystem | null>
[SYNC_RUNTIME_CONTEXT_GLOBAL_KEY]?: React.Context<SyncRuntime | null>
}
const syncGlobal = globalThis as SyncGlobal
const SyncContext = syncGlobal[SYNC_CONTEXT_GLOBAL_KEY] ?? createContext<SyncSystem | null>(null)
syncGlobal[SYNC_CONTEXT_GLOBAL_KEY] = SyncContext
const SyncRuntimeContext = syncGlobal[SYNC_RUNTIME_CONTEXT_GLOBAL_KEY] ?? createContext<SyncRuntime | null>(null)
syncGlobal[SYNC_RUNTIME_CONTEXT_GLOBAL_KEY] = SyncRuntimeContext
type SdkResult<T> = {
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 <SyncProvider>")
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<SyncRuntime>(
() => ({ childStores, messageLoader, runtimeKey, sdk: props.sdk }),
[childStores, messageLoader, props.sdk, runtimeKey],
)
const system = useMemo<SyncSystem>(
() => ({
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 <SyncContext.Provider value={system}>{props.children}</SyncContext.Provider>
// Directory navigation must not republish stable runtime dependencies.
return (
<SyncContext.Provider value={system}>
<SyncRuntimeContext.Provider value={runtime}>
{props.children}
</SyncRuntimeContext.Provider>
</SyncContext.Provider>
)
}
// ---------------------------------------------------------------------------
+92 -116
View File
@@ -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<string, Promise<void>>()
// 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<string, number>()
type SdkResult<T> = {
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<boolean> => {
@@ -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])
}