perf(ui): isolate sidebar session selection
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user