perf(ui): improve VS Code chat session switching
Improve chat session switching and history pagination, with most of the aggressive limits scoped to the VS Code webview where the freezes were observed. Session history loading and pagination: - Reduce the VS Code message page size to 30 records so switching sessions does not immediately hydrate large histories into the webview. - Keep manual Load older messages in VS Code fixed at 30 records per request instead of growing the request size over time. - Add a bounded VS Code initial-tail expansion path from 30 to 50, 80, and 120 records only when the initial page has no user-message turn boundary, preventing large final turns from rendering as an empty chat. - Lower the normal web message page size from 200 to 150 for a mild shared optimization without adopting the aggressive VS Code limits. - Make session pagination metadata reactive per session so ChatContainer receives cursor updates from materialization and reconnect paths without requiring a switch away and back. - Write pagination metadata before publishing newly materialized messages so the first render sees the correct has-more state. - Store cursor information from direct materialization and reconnect message fetches in the shared session prefetch metadata cache. VS Code cache and memory pressure reductions: - Use a shared per-directory session recency map so cache eviction is based on app-level recency instead of whichever useSync instance happened to run. - Limit VS Code warm session cache retention to 4 sessions and evict heavy inactive message caches after switching away from a large session. - Disable sidebar session prefetch in VS Code because warming extra sessions was increasing webview memory and GC pressure during navigation. - Remove dropdown background message prefetch so opening the switcher does not start additional session materialization work. - Drop cached session-message-record snapshots when evicting session data so stale derived records do not remain after the raw session cache is cleared. - Add bounded LRU caching for session message record snapshots, with much smaller VS Code limits and a VS Code cap that avoids caching snapshots above 30 messages. - Bound the turn-window model cache in VS Code and avoid caching turn models for sessions above the VS Code message-page size. Chat render-path reductions: - Reuse ChatContainer's already-materialized message records in plan detection instead of adding a second active-session message subscription. - Add a no-op guard when marking session plan availability so repeated detections do not create new Map references and fan out renders. - Add no-op guards for session switcher and dropdown open state updates to avoid unnecessary store updates and renders. - Convert several session-specific hooks to useSyncExternalStore with empty-session no-subscribe behavior so empty IDs do not subscribe to broad store updates. - Remount the chat viewport when the current session changes, isolating per-session viewport and list state. - Change the virtualized message-list fallback to render only a tail window when the virtualizer has not produced rows yet, instead of rendering an entire large history. VS Code layout and header improvements: - Remove the broad useSessions subscription from the VS Code layout header path and subscribe only to the active session title and initial-session existence. - Unmount the compact VS Code session sidebar when the user is in chat view instead of keeping the hidden session list mounted and subscribed. - Compute the latest assistant model and latest context-token usage in a single reverse scan of current-session messages instead of scanning the same list twice. - Remove switcher git-status warmup work so the switcher reads already-loaded branch labels without starting extra background git status requests. Markdown and file-reference safeguards: - Skip expensive syntax highlighting for very large code blocks, with a 200-line cap in VS Code and a softer 1200-line cap in web. - Add an LRU cap to file-reference stat lookups so the cache cannot grow without bound across many rendered messages. - Limit the number of file references annotated per render to 40 in VS Code and 200 in web to prevent large assistant outputs from spawning too many stat checks. - Clear file-link annotations when file-reference mode is disabled so stale attributes and handlers do not remain on previously annotated nodes. Assistant-message action and preview reductions: - Skip preview URL scanning on VS Code, mobile, and mini-chat surfaces so assistant text and tool output are not scanned where the preview action is unavailable. - Skip Save-as-Plan project lookup on VS Code, mini-chat, and mobile surfaces. - Hide Save-as-Plan and Start MultiRun assistant-message actions on VS Code, mini-chat, and mobile surfaces. - Resolve the current session directory on demand for assistant actions instead of subscribing each assistant message to the full session list. Tool and task rendering optimizations: - Prefer finalized task metadata summaries without fetching child-session messages when the summary is already present. - Avoid polling or final-fetching task child sessions once a final metadata summary is available. - Use VS Code-specific task child fetch limits of 30 records for initial, active, and idle fetches. - Parse diff stats by scanning patch text line-by-line instead of splitting large patches into arrays. - Count write-tool lines by scanning content instead of allocating a split array for large files. - Avoid trimming large patch strings just to test whether they contain content. - Memoize diff and write statistics so unchanged tool parts do not recalculate them on every render. VS Code bridge improvements: - Return JSON and text proxy responses through the VS Code bridge as bodyText instead of base64 so the webview avoids synchronous base64 decoding for common API responses. - Keep binary responses on the base64 path while making bodyBase64 optional in the bridge contract. - Strip content-length, content-encoding, and transfer-encoding headers from proxied responses because the bridge reconstructs the Response body. Validation: - bun run type-check - bun run lint - bun run vscode:build
This commit is contained in:
@@ -21,9 +21,16 @@ const compositeKey = (directory: string, sessionID: string) =>
|
||||
const cache = new Map<string, Meta>()
|
||||
const inflight = new Map<string, Promise<Meta | undefined>>()
|
||||
const rev = new Map<string, number>()
|
||||
const listeners = new Map<string, Set<() => void>>()
|
||||
|
||||
const version = (id: string) => rev.get(id) ?? 0
|
||||
|
||||
const notify = (id: string) => {
|
||||
const callbacks = listeners.get(id)
|
||||
if (!callbacks) return
|
||||
callbacks.forEach((callback) => callback())
|
||||
}
|
||||
|
||||
/** Check if a prefetch/sync can be skipped (recently fetched). */
|
||||
export function shouldSkipSessionPrefetch(input: {
|
||||
hasMessages: boolean
|
||||
@@ -46,6 +53,21 @@ export function getSessionPrefetch(directory: string, sessionID: string): Meta |
|
||||
return cache.get(compositeKey(directory, sessionID))
|
||||
}
|
||||
|
||||
export function subscribeSessionPrefetch(directory: string, sessionID: string, callback: () => void) {
|
||||
if (!sessionID) return () => undefined
|
||||
const id = compositeKey(directory, sessionID)
|
||||
let callbacks = listeners.get(id)
|
||||
if (!callbacks) {
|
||||
callbacks = new Set()
|
||||
listeners.set(id, callbacks)
|
||||
}
|
||||
callbacks.add(callback)
|
||||
return () => {
|
||||
callbacks?.delete(callback)
|
||||
if (callbacks?.size === 0) listeners.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionPrefetchPromise(directory: string, sessionID: string) {
|
||||
return inflight.get(compositeKey(directory, sessionID))
|
||||
}
|
||||
@@ -82,12 +104,14 @@ export function setSessionPrefetch(input: {
|
||||
complete: boolean
|
||||
at?: number
|
||||
}) {
|
||||
cache.set(compositeKey(input.directory, input.sessionID), {
|
||||
const id = compositeKey(input.directory, input.sessionID)
|
||||
cache.set(id, {
|
||||
limit: input.limit,
|
||||
cursor: input.cursor,
|
||||
complete: input.complete,
|
||||
at: input.at ?? Date.now(),
|
||||
})
|
||||
notify(id)
|
||||
}
|
||||
|
||||
/** Invalidate cache for specific sessions (e.g. after eviction). */
|
||||
@@ -98,6 +122,7 @@ export function clearSessionPrefetch(directory: string, sessionIDs: Iterable<str
|
||||
rev.set(id, version(id) + 1)
|
||||
cache.delete(id)
|
||||
inflight.delete(id)
|
||||
notify(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,5 +135,6 @@ export function clearSessionPrefetchDirectory(directory: string) {
|
||||
rev.set(id, version(id) + 1)
|
||||
cache.delete(id)
|
||||
inflight.delete(id)
|
||||
notify(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1208,6 +1208,9 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
// ---------------------------------------------------------------------------
|
||||
markSessionPlanAvailable: (sessionId) => {
|
||||
set((state) => {
|
||||
if (state.sessionPlanAvailable.get(sessionId) === true) {
|
||||
return state
|
||||
}
|
||||
const next = new Map(state.sessionPlanAvailable)
|
||||
next.set(sessionId, true)
|
||||
return { sessionPlanAvailable: next }
|
||||
|
||||
@@ -38,6 +38,7 @@ import type { PermissionRequest } from "@/types/permission"
|
||||
import type { QuestionRequest } from "@/types/question"
|
||||
import * as sessionActions from "./session-actions"
|
||||
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
|
||||
import { setSessionPrefetch } from "./session-prefetch-cache"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
@@ -195,6 +196,14 @@ async function materializeSessionFromServer(
|
||||
)
|
||||
const records = (result.data ?? []).filter((record: { info?: { id?: string } }) => !!record?.info?.id)
|
||||
if (records.length === 0) return
|
||||
const cursor = result.response?.headers?.get?.("x-next-cursor") ?? undefined
|
||||
setSessionPrefetch({
|
||||
directory,
|
||||
sessionID,
|
||||
limit: records.length,
|
||||
cursor,
|
||||
complete: !cursor,
|
||||
})
|
||||
|
||||
store.setState((state: DirectoryStore) => {
|
||||
const materialized = materializeSessionSnapshots(
|
||||
@@ -937,6 +946,14 @@ async function resyncDirectoryAfterReconnect(
|
||||
const session = sessionResponse?.data
|
||||
const records = messageResponse?.data
|
||||
if (!session || !records) return
|
||||
const cursor = messageResponse.response?.headers?.get?.("x-next-cursor") ?? undefined
|
||||
setSessionPrefetch({
|
||||
directory,
|
||||
sessionID: sessionId,
|
||||
limit: records.length,
|
||||
cursor,
|
||||
complete: !cursor,
|
||||
})
|
||||
|
||||
const nextSession = stripSessionDiffSnapshots(session)
|
||||
const nextMessages = records
|
||||
@@ -1562,10 +1579,16 @@ export function useSessionRevertMessageID(sessionID: string, directory?: string)
|
||||
|
||||
/** Get session messages for a specific session */
|
||||
export function useSessionMessages(sessionID: string, directory?: string) {
|
||||
return useDirectorySync(
|
||||
useCallback((state: State) => state.message[sessionID] ?? EMPTY_MESSAGES, [sessionID]),
|
||||
directory,
|
||||
)
|
||||
const store = useDirectoryStore(directory)
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (!sessionID) return EMPTY_MESSAGES
|
||||
return store.getState().message[sessionID] ?? EMPTY_MESSAGES
|
||||
}, [sessionID, store])
|
||||
const subscribe = useCallback((notify: () => void) => {
|
||||
if (!sessionID) return () => undefined
|
||||
return store.subscribe(notify)
|
||||
}, [sessionID, store])
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1602,18 +1625,30 @@ export function useSessionParts(messageID: string, directory?: string) {
|
||||
|
||||
/** Get status for a specific session */
|
||||
export function useSessionStatus(sessionID: string, directory?: string) {
|
||||
return useDirectorySync(
|
||||
useCallback((state: State) => state.session_status?.[sessionID], [sessionID]),
|
||||
directory,
|
||||
)
|
||||
const store = useDirectoryStore(directory)
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (!sessionID) return undefined
|
||||
return store.getState().session_status?.[sessionID]
|
||||
}, [sessionID, store])
|
||||
const subscribe = useCallback((notify: () => void) => {
|
||||
if (!sessionID) return () => undefined
|
||||
return store.subscribe(notify)
|
||||
}, [sessionID, store])
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
}
|
||||
|
||||
/** Get permissions for a specific session */
|
||||
export function useSessionPermissions(sessionID: string, directory?: string) {
|
||||
return useDirectorySync(
|
||||
useCallback((state: State) => state.permission[sessionID] ?? EMPTY_PERMISSION_REQUESTS, [sessionID]),
|
||||
directory,
|
||||
)
|
||||
const store = useDirectoryStore(directory)
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (!sessionID) return EMPTY_PERMISSION_REQUESTS
|
||||
return store.getState().permission[sessionID] ?? EMPTY_PERMISSION_REQUESTS
|
||||
}, [sessionID, store])
|
||||
const subscribe = useCallback((notify: () => void) => {
|
||||
if (!sessionID) return () => undefined
|
||||
return store.subscribe(notify)
|
||||
}, [sessionID, store])
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
}
|
||||
|
||||
/** Get questions for a specific session */
|
||||
@@ -1812,16 +1847,121 @@ const getFirstTextFromParts = (parts: Part[]): string => {
|
||||
}
|
||||
|
||||
type SessionMessageRecord = { info: Message; parts: Part[] }
|
||||
const EMPTY_SESSION_MESSAGE_RECORDS: SessionMessageRecord[] = []
|
||||
|
||||
type SessionMessageRecordsSnapshot = {
|
||||
sessionID: string
|
||||
sourceMessages: Message[]
|
||||
visibleMessages: Message[]
|
||||
revertMessageID?: string
|
||||
suspendPartUpdates: boolean
|
||||
list: SessionMessageRecord[]
|
||||
byId: Map<string, SessionMessageRecord>
|
||||
}
|
||||
|
||||
const SESSION_MESSAGE_RECORDS_CACHE_MAX = 40
|
||||
const VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX = 4
|
||||
const VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES = 30
|
||||
const sessionMessageRecordsCache = new WeakMap<StoreApi<DirectoryStore>, Map<string, SessionMessageRecordsSnapshot>>()
|
||||
|
||||
const getSessionMessageRecordsCacheKey = (sessionID: string, suspendPartUpdates: boolean): string => (
|
||||
`${sessionID}\u0000${suspendPartUpdates ? 1 : 0}`
|
||||
)
|
||||
|
||||
const getSessionMessageRecordsCache = (store: StoreApi<DirectoryStore>): Map<string, SessionMessageRecordsSnapshot> => {
|
||||
let cache = sessionMessageRecordsCache.get(store)
|
||||
if (!cache) {
|
||||
cache = new Map()
|
||||
sessionMessageRecordsCache.set(store, cache)
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
const readCachedSessionMessageRecordsSnapshot = (
|
||||
store: StoreApi<DirectoryStore>,
|
||||
sessionID: string,
|
||||
suspendPartUpdates: boolean,
|
||||
): SessionMessageRecordsSnapshot | undefined => {
|
||||
const cache = sessionMessageRecordsCache.get(store)
|
||||
if (!cache) return undefined
|
||||
const key = getSessionMessageRecordsCacheKey(sessionID, suspendPartUpdates)
|
||||
const cached = cache.get(key)
|
||||
if (!cached) return undefined
|
||||
cache.delete(key)
|
||||
cache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
const rememberSessionMessageRecordsSnapshot = (
|
||||
store: StoreApi<DirectoryStore>,
|
||||
snapshot: SessionMessageRecordsSnapshot,
|
||||
): void => {
|
||||
if (!snapshot.sessionID) return
|
||||
const cache = getSessionMessageRecordsCache(store)
|
||||
const key = getSessionMessageRecordsCacheKey(snapshot.sessionID, snapshot.suspendPartUpdates)
|
||||
if (isVSCodeRuntime() && snapshot.list.length > VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES) {
|
||||
cache.delete(key)
|
||||
return
|
||||
}
|
||||
cache.delete(key)
|
||||
cache.set(key, snapshot)
|
||||
const max = isVSCodeRuntime() ? VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX : SESSION_MESSAGE_RECORDS_CACHE_MAX
|
||||
while (cache.size > max) {
|
||||
const oldest = cache.keys().next().value
|
||||
if (typeof oldest !== "string") break
|
||||
cache.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
export function dropCachedSessionMessageRecordsSnapshots(
|
||||
store: StoreApi<DirectoryStore>,
|
||||
sessionIDs: Iterable<string>,
|
||||
): void {
|
||||
const cache = sessionMessageRecordsCache.get(store)
|
||||
if (!cache) return
|
||||
for (const sessionID of sessionIDs) {
|
||||
if (!sessionID) continue
|
||||
cache.delete(getSessionMessageRecordsCacheKey(sessionID, false))
|
||||
cache.delete(getSessionMessageRecordsCacheKey(sessionID, true))
|
||||
}
|
||||
}
|
||||
|
||||
const snapshotPartsMatchState = (snapshot: SessionMessageRecordsSnapshot, state: State): boolean => {
|
||||
if (snapshot.suspendPartUpdates) {
|
||||
return true
|
||||
}
|
||||
|
||||
for (const record of snapshot.list) {
|
||||
if ((state.part[record.info.id] ?? EMPTY_PARTS) !== record.parts) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const getReusableSessionMessageRecordsSnapshot = (
|
||||
store: StoreApi<DirectoryStore>,
|
||||
state: State,
|
||||
sessionID: string,
|
||||
suspendPartUpdates: boolean,
|
||||
): SessionMessageRecordsSnapshot | undefined => {
|
||||
const cached = readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates)
|
||||
if (!cached) return undefined
|
||||
const sourceMessages = state.message[sessionID] ?? EMPTY_MESSAGES
|
||||
const session = state.session.find((candidate) => candidate.id === sessionID)
|
||||
const revertMessageID = (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID
|
||||
if (
|
||||
cached.sourceMessages === sourceMessages
|
||||
&& cached.revertMessageID === revertMessageID
|
||||
&& cached.suspendPartUpdates === suspendPartUpdates
|
||||
&& snapshotPartsMatchState(cached, state)
|
||||
) {
|
||||
return cached
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getVisibleMessagesForSession(state: State, sessionID: string, previous?: SessionMessageRecordsSnapshot): {
|
||||
sourceMessages: Message[]
|
||||
visibleMessages: Message[]
|
||||
@@ -1886,6 +2026,7 @@ export function buildSessionMessageRecordsSnapshot(
|
||||
sourceMessages,
|
||||
visibleMessages,
|
||||
revertMessageID,
|
||||
suspendPartUpdates,
|
||||
list: nextList,
|
||||
byId: nextById,
|
||||
}
|
||||
@@ -1949,22 +2090,45 @@ export function useSessionMessageRecords(
|
||||
sourceMessages: EMPTY_MESSAGES,
|
||||
visibleMessages: EMPTY_MESSAGES,
|
||||
revertMessageID: undefined,
|
||||
suspendPartUpdates: Boolean(options?.suspendPartUpdates),
|
||||
list: [],
|
||||
byId: new Map(),
|
||||
})
|
||||
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (!sessionID) {
|
||||
return EMPTY_SESSION_MESSAGE_RECORDS
|
||||
}
|
||||
|
||||
const state = store.getState()
|
||||
const suspendPartUpdates = Boolean(options?.suspendPartUpdates)
|
||||
const reusableSnapshot = getReusableSessionMessageRecordsSnapshot(store, state, sessionID, suspendPartUpdates)
|
||||
if (reusableSnapshot) {
|
||||
snapshotRef.current = reusableSnapshot
|
||||
return reusableSnapshot.list
|
||||
}
|
||||
|
||||
const previousSnapshot = snapshotRef.current.sessionID === sessionID
|
||||
? snapshotRef.current
|
||||
: readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates)
|
||||
|
||||
const nextSnapshot = buildSessionMessageRecordsSnapshot(
|
||||
store.getState(),
|
||||
state,
|
||||
sessionID,
|
||||
snapshotRef.current.sessionID === sessionID ? snapshotRef.current : undefined,
|
||||
Boolean(options?.suspendPartUpdates),
|
||||
previousSnapshot,
|
||||
suspendPartUpdates,
|
||||
)
|
||||
snapshotRef.current = nextSnapshot
|
||||
rememberSessionMessageRecordsSnapshot(store, nextSnapshot)
|
||||
return nextSnapshot.list
|
||||
}, [options?.suspendPartUpdates, sessionID, store])
|
||||
|
||||
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot)
|
||||
const subscribe = useCallback((notify: () => void) => {
|
||||
if (!sessionID) return () => undefined
|
||||
return store.subscribe(notify)
|
||||
}, [sessionID, store])
|
||||
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,15 +2,16 @@ import { useCallback, useRef, useMemo } from "react"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { Binary } from "./binary"
|
||||
import { retry } from "./retry"
|
||||
import { SESSION_CACHE_LIMIT } from "./types"
|
||||
import { SESSION_CACHE_LIMIT, type State } from "./types"
|
||||
import { pickSessionCacheEvictions } from "./session-cache"
|
||||
import {
|
||||
mergeOptimisticPage,
|
||||
type OptimisticItem,
|
||||
} from "./optimistic"
|
||||
import { useDirectoryStore, useSyncSDK, useSyncDirectory, useChildStoreManager } from "./sync-context"
|
||||
import { dropCachedSessionMessageRecordsSnapshots, useDirectoryStore, useSyncSDK, useSyncDirectory, useChildStoreManager } from "./sync-context"
|
||||
import { dropSessionCaches, getProtectedSessionCacheIds } from "./session-cache"
|
||||
import { stripMessageDiffSnapshots } from "./sanitize"
|
||||
import { isVSCodeRuntime } from "@/lib/desktop"
|
||||
import {
|
||||
shouldSkipSessionPrefetch,
|
||||
getSessionPrefetch,
|
||||
@@ -20,14 +21,60 @@ import {
|
||||
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const MESSAGE_PAGE_SIZE = 200
|
||||
const MESSAGE_PAGE_SIZE = 150
|
||||
const VSCODE_MESSAGE_PAGE_SIZE = 30
|
||||
const VSCODE_INITIAL_PAGE_EXPANSION_LIMITS = [50, 80, 120] as const
|
||||
const MAX_SEEN_DIRS = 30
|
||||
const VSCODE_SESSION_CACHE_LIMIT = 4
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
// Shared across useSync() instances so cache eviction is based on app-level
|
||||
// session recency, not whichever component happened to call sync first.
|
||||
const seenByDirectory = new Map<string, Set<string>>()
|
||||
|
||||
type SyncMeta = {
|
||||
limit: number
|
||||
cursor: string | undefined
|
||||
complete: boolean
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const getEffectiveSessionCacheLimit = () => isVSCodeRuntime() ? VSCODE_SESSION_CACHE_LIMIT : SESSION_CACHE_LIMIT
|
||||
const getEffectiveMessagePageSize = () => isVSCodeRuntime() ? VSCODE_MESSAGE_PAGE_SIZE : MESSAGE_PAGE_SIZE
|
||||
const getVSCodeInitialPageExpansionMax = () => VSCODE_INITIAL_PAGE_EXPANSION_LIMITS[VSCODE_INITIAL_PAGE_EXPANSION_LIMITS.length - 1]
|
||||
const getDefaultMeta = (): SyncMeta => ({ limit: getEffectiveMessagePageSize(), cursor: undefined, complete: false, loading: false })
|
||||
|
||||
function getPrefetchMeta(directory: string, sessionID: string): SyncMeta | undefined {
|
||||
const info = getSessionPrefetch(directory, sessionID)
|
||||
if (!info) return undefined
|
||||
return {
|
||||
limit: info.limit,
|
||||
cursor: info.cursor,
|
||||
complete: info.complete,
|
||||
loading: false,
|
||||
}
|
||||
}
|
||||
|
||||
function sortParts(parts: Part[]) {
|
||||
return parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
function isHeavyVSCodeSessionCache(state: Pick<State, "message" | "part">, sessionID: string): boolean {
|
||||
const messages = state.message[sessionID]
|
||||
if (!messages || messages.length === 0) return false
|
||||
return messages.length > VSCODE_MESSAGE_PAGE_SIZE
|
||||
}
|
||||
|
||||
function isUserMessage(message: Message): boolean {
|
||||
const info = message as Message & { clientRole?: unknown; role?: unknown }
|
||||
const role = typeof info.clientRole === "string" ? info.clientRole : info.role
|
||||
return role === "user"
|
||||
}
|
||||
|
||||
function hasUserMessage(messages: Message[] | undefined): boolean {
|
||||
return Boolean(messages?.some(isUserMessage))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useSync — message loading, pagination, optimistic updates
|
||||
// Message loading, pagination, optimistic updates
|
||||
@@ -42,13 +89,7 @@ export function useSync() {
|
||||
// Refs for mutable tracking (no re-renders)
|
||||
const inflight = useRef(new Map<string, Promise<void>>())
|
||||
const optimistic = useRef(new Map<string, Map<string, OptimisticItem>>())
|
||||
const seen = useRef(new Map<string, Set<string>>())
|
||||
const meta = useRef(new Map<string, {
|
||||
limit: number
|
||||
cursor: string | undefined
|
||||
complete: boolean
|
||||
loading: boolean
|
||||
}>())
|
||||
const meta = useRef(new Map<string, SyncMeta>())
|
||||
|
||||
const keyFor = useCallback(
|
||||
(sessionID: string) => `${directory}\n${sessionID}`,
|
||||
@@ -58,18 +99,18 @@ export function useSync() {
|
||||
const getMetaFor = useCallback(
|
||||
(sessionID: string) => {
|
||||
const key = keyFor(sessionID)
|
||||
return meta.current.get(key) ?? { limit: MESSAGE_PAGE_SIZE, cursor: undefined, complete: false, loading: false }
|
||||
return meta.current.get(key) ?? getPrefetchMeta(directory, sessionID) ?? getDefaultMeta()
|
||||
},
|
||||
[keyFor],
|
||||
[directory, keyFor],
|
||||
)
|
||||
|
||||
const setMetaFor = useCallback(
|
||||
(sessionID: string, patch: Partial<{ limit: number; cursor: string | undefined; complete: boolean; loading: boolean }>) => {
|
||||
const key = keyFor(sessionID)
|
||||
const current = meta.current.get(key) ?? { limit: MESSAGE_PAGE_SIZE, cursor: undefined, complete: false, loading: false }
|
||||
const current = meta.current.get(key) ?? getPrefetchMeta(directory, sessionID) ?? getDefaultMeta()
|
||||
meta.current.set(key, { ...current, ...patch })
|
||||
},
|
||||
[keyFor],
|
||||
[directory, keyFor],
|
||||
)
|
||||
|
||||
// Session cache eviction — two levels of LRU:
|
||||
@@ -93,6 +134,7 @@ export function useSync() {
|
||||
question: { ...current.question },
|
||||
}
|
||||
dropSessionCaches(draft, sessionIDs)
|
||||
dropCachedSessionMessageRecordsSnapshots(dirStore, sessionIDs)
|
||||
dirStore.setState(draft)
|
||||
|
||||
// Clear meta + optimistic + prefetch cache for evicted sessions
|
||||
@@ -109,22 +151,22 @@ export function useSync() {
|
||||
// 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(() => {
|
||||
const existing = seen.current.get(directory)
|
||||
const existing = seenByDirectory.get(directory)
|
||||
if (existing) {
|
||||
// LRU reorder: delete + re-insert moves to end (most recent)
|
||||
seen.current.delete(directory)
|
||||
seen.current.set(directory, existing)
|
||||
seenByDirectory.delete(directory)
|
||||
seenByDirectory.set(directory, existing)
|
||||
return existing
|
||||
}
|
||||
const created = new Set<string>()
|
||||
seen.current.set(directory, created)
|
||||
seenByDirectory.set(directory, created)
|
||||
|
||||
// Evict oldest directories if over limit
|
||||
while (seen.current.size > MAX_SEEN_DIRS) {
|
||||
const first = seen.current.keys().next().value
|
||||
while (seenByDirectory.size > MAX_SEEN_DIRS) {
|
||||
const first = seenByDirectory.keys().next().value
|
||||
if (!first) break
|
||||
const staleSessionIds = [...(seen.current.get(first) ?? [])]
|
||||
seen.current.delete(first)
|
||||
const staleSessionIds = [...(seenByDirectory.get(first) ?? [])]
|
||||
seenByDirectory.delete(first)
|
||||
evict(first, staleSessionIds)
|
||||
}
|
||||
|
||||
@@ -136,13 +178,34 @@ export function useSync() {
|
||||
(sessionID: string) => {
|
||||
const s = seenFor()
|
||||
const protectedIds = getProtectedSessionCacheIds(store.getState())
|
||||
const cacheLimit = getEffectiveSessionCacheLimit()
|
||||
const stale = pickSessionCacheEvictions({
|
||||
seen: s,
|
||||
keep: sessionID,
|
||||
limit: SESSION_CACHE_LIMIT,
|
||||
limit: cacheLimit,
|
||||
preserve: protectedIds,
|
||||
})
|
||||
evict(directory, stale)
|
||||
|
||||
if (isVSCodeRuntime()) {
|
||||
const state = store.getState()
|
||||
const keep = new Set([sessionID, ...s, ...protectedIds])
|
||||
const prefetched = Object.keys(state.message).filter((id) => !keep.has(id))
|
||||
evict(directory, 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 the VSCode webview.
|
||||
const afterPrefetchEviction = prefetched.length > 0 ? store.getState() : state
|
||||
const heavyInactive = Object.keys(afterPrefetchEviction.message).filter((id) => {
|
||||
if (id === sessionID || protectedIds.has(id)) return false
|
||||
return isHeavyVSCodeSessionCache(afterPrefetchEviction, id)
|
||||
})
|
||||
if (heavyInactive.length > 0) {
|
||||
for (const id of heavyInactive) s.delete(id)
|
||||
evict(directory, heavyInactive)
|
||||
}
|
||||
}
|
||||
},
|
||||
[directory, seenFor, evict, store],
|
||||
)
|
||||
@@ -213,8 +276,21 @@ export function useSync() {
|
||||
setMetaFor(sessionID, { loading: true })
|
||||
|
||||
try {
|
||||
const limit = m.limit
|
||||
const page = await fetchMessages(sessionID, limit, options?.before)
|
||||
const limit = options?.before ? getEffectiveMessagePageSize() : m.limit
|
||||
let page = await fetchMessages(sessionID, limit, options?.before)
|
||||
|
||||
// VSCode keeps the initial page small for switch performance. Some
|
||||
// sessions have a very large final turn, so the latest 30 records can
|
||||
// contain only assistant/tool records and no user boundary. That makes
|
||||
// turn projection render an empty chat until the user manually loads
|
||||
// older messages. Expand only this initial tail fetch, with a hard cap.
|
||||
if (!options?.before && isVSCodeRuntime() && !page.complete && !hasUserMessage(page.session)) {
|
||||
for (const nextLimit of VSCODE_INITIAL_PAGE_EXPANSION_LIMITS) {
|
||||
if (nextLimit <= limit) continue
|
||||
page = await fetchMessages(sessionID, nextLimit)
|
||||
if (page.complete || hasUserMessage(page.session)) break
|
||||
}
|
||||
}
|
||||
|
||||
// Merge optimistic items
|
||||
const items = getOptimistic(sessionID)
|
||||
@@ -234,13 +310,13 @@ export function useSync() {
|
||||
{ skipPartTypes: SKIP_PARTS, mode: options?.mode === "prepend" ? "prepend" : "merge" },
|
||||
)
|
||||
|
||||
store.setState({ message: materialized.message, part: materialized.part })
|
||||
setMetaFor(sessionID, {
|
||||
limit: materialized.messages.length,
|
||||
cursor: merged.cursor,
|
||||
complete: merged.complete,
|
||||
loading: false,
|
||||
})
|
||||
store.setState({ message: materialized.message, part: materialized.part })
|
||||
setSessionPrefetch({
|
||||
directory,
|
||||
sessionID,
|
||||
@@ -269,16 +345,32 @@ export function useSync() {
|
||||
const m = getMetaFor(sessionID)
|
||||
const materialization = getSessionMaterializationStatus(current, sessionID)
|
||||
const cached = materialization.hasMessages && materialization.renderable && m.limit > 0
|
||||
const prefetchInfo = !force ? getSessionPrefetch(directory, sessionID) : undefined
|
||||
const knownCachedLimit = Math.max(m.limit, prefetchInfo?.limit ?? 0)
|
||||
const needsVSCodeInitialTurnBoundary = isVSCodeRuntime()
|
||||
&& cached
|
||||
&& !hasUserMessage(current.message[sessionID])
|
||||
&& knownCachedLimit < getVSCodeInitialPageExpansionMax()
|
||||
&& !m.complete
|
||||
&& prefetchInfo?.complete !== true
|
||||
&& Boolean(m.cursor ?? prefetchInfo?.cursor)
|
||||
if (needsVSCodeInitialTurnBoundary && prefetchInfo && prefetchInfo.limit > m.limit) {
|
||||
setMetaFor(sessionID, {
|
||||
limit: prefetchInfo.limit,
|
||||
cursor: prefetchInfo.cursor,
|
||||
complete: prefetchInfo.complete,
|
||||
})
|
||||
}
|
||||
const cachedReady = cached && !needsVSCodeInitialTurnBoundary
|
||||
const hasSession = Binary.search(current.session, sessionID, (s) => s.id).found
|
||||
if (cached && hasSession && !force) return
|
||||
if (cachedReady && hasSession && !force) return
|
||||
|
||||
// Skip if recently fetched (TTL)
|
||||
if (!force) {
|
||||
const prefetchInfo = getSessionPrefetch(directory, sessionID)
|
||||
if (!force && !needsVSCodeInitialTurnBoundary) {
|
||||
if (shouldSkipSessionPrefetch({
|
||||
hasMessages: cached,
|
||||
hasMessages: cachedReady,
|
||||
info: prefetchInfo,
|
||||
pageSize: MESSAGE_PAGE_SIZE,
|
||||
pageSize: getEffectiveMessagePageSize(),
|
||||
})) return
|
||||
}
|
||||
|
||||
@@ -304,7 +396,7 @@ export function useSync() {
|
||||
}
|
||||
|
||||
// Load messages if needed
|
||||
if (!cached || force) {
|
||||
if (!cachedReady || force) {
|
||||
await loadMessages(sessionID)
|
||||
}
|
||||
})()
|
||||
@@ -313,7 +405,7 @@ export function useSync() {
|
||||
promise.finally(() => inflight.current.delete(key))
|
||||
return promise
|
||||
},
|
||||
[store, sdk, keyFor, touch, getMetaFor, loadMessages, directory],
|
||||
[store, sdk, keyFor, touch, getMetaFor, setMetaFor, loadMessages, directory],
|
||||
)
|
||||
|
||||
// Load more (pagination)
|
||||
|
||||
Reference in New Issue
Block a user