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:
Bohdan Triapitsyn
2026-05-21 15:45:44 +03:00
parent 80cf598d9f
commit 51c8d52ab5
19 changed files with 728 additions and 270 deletions
+27 -1
View File
@@ -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)
}
}
+3
View File
@@ -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 }
+180 -16
View File
@@ -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)
}
/**
+125 -33
View File
@@ -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)