perf: isolate chat streaming renders and reduce sidebar render cost (#1672)

Reworks the chat and session-sidebar render paths to cut render cascades, memory
  churn, and UI jank on large sessions and big session trees. Behavior is preserved;
  the changes are about *when* and *how much* the UI re-renders.

  ## Chat streaming
  - Freeze the streaming message's parts in the bulk turn projection during streaming,
    and re-inject live parts only in an isolated tail leaf, so a ~60/sec delta stream
    no longer re-runs the whole-session projection or re-renders unrelated rows.
    session with referential reuse of unchanged turns.
  - Memoize message rows with field-aware comparators instead of reference equality.
  - Replace the manual child-session polling in the task tool with the live SSE
    stream + a one-shot load, removing a fetch/settle state machine.

  ## History loading & scroll
  - Load an initial page fast, then prepend one older page in the background so the
    scroll container has headroom and "load older on scroll-up" fires before the user
    hits the absolute top.
  - Compensate scroll synchronously (in a layout effect, before paint) for prepends —
    including background prepends that don't originate from a user scroll — so the
    viewport stays stable instead of judder-correcting on the next frame.

  ## Markdown rendering
  - Render markdown synchronously *styled* on first paint (paragraphs, lists, code
    cards, tables, inline code) instead of raw escaped text; the async pass then only
    upgrades syntax-highlight colors. Eliminates the flash of full-width raw text.
  - Load KaTeX CSS eagerly with the main bundle instead of inside the lazy markdown
    chunk, avoiding a late stylesheet injection on first render.

  ## Sidebar
  - Hoist per-row recursive tree walks out of row comparators into per-group
    precomputed sets/keys; batch live-session lookups into a single map; add a
    group-level memo boundary.
  - Isolate rename drafts so per-keystroke typing doesn't repaint the row tree.

  ## Sync layer
  - Add a staleness guard so a slow message fetch can't repopulate a session the user
    navigated away from.
  - Throw on fetch failure for authoritative loaders so a transient blip can't read as
    an empty server response.

  ## Cleanup
  - Remove dead code (unused hooks, params, duplicated inline types) surfaced while
    reworking the above.

  ## Known issue
  - A rare, purely cosmetic first-paint width flash can still appear on large sessions;
    it has no behavioral or data impact and is tracked for a follow-up runtime trace.
This commit is contained in:
bashrusakh
2026-06-18 00:43:16 +03:00
committed by GitHub
parent 077a766f94
commit 59ecd86b4b
47 changed files with 3168 additions and 1829 deletions
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test"
import type { Session } from "@opencode-ai/sdk/v2"
import {
areRequestArraysReferentiallyEqual,
collectScopedBlockingRequests,
} from "./scoped-blocking-requests"
const session = (id: string, parentID?: string): Session => ({ id, parentID }) as Session
describe("scoped blocking requests", () => {
test("collects requests for the current session subtree", () => {
const rootRequest = { id: "perm_root" }
const childRequest = { id: "perm_child" }
const grandchildRequest = { id: "perm_grandchild" }
const siblingRequest = { id: "perm_sibling" }
const empty: Array<typeof rootRequest> = []
const result = collectScopedBlockingRequests(
[
session("ses_root"),
session("ses_child", "ses_root"),
session("ses_grandchild", "ses_child"),
session("ses_sibling"),
],
{
ses_root: [rootRequest],
ses_child: [childRequest],
ses_grandchild: [grandchildRequest],
ses_sibling: [siblingRequest],
},
"ses_root",
empty,
)
expect(result).toEqual([rootRequest, childRequest, grandchildRequest])
})
test("returns the provided empty array when no scoped requests exist", () => {
const empty: Array<{ id: string }> = []
expect(collectScopedBlockingRequests([session("ses_root")], {}, "ses_root", empty)).toBe(empty)
expect(collectScopedBlockingRequests([session("ses_root")], {}, null, empty)).toBe(empty)
})
test("compares request arrays by item identity", () => {
const first = { id: "perm_1" }
const second = { id: "perm_2" }
expect(areRequestArraysReferentiallyEqual([first, second], [first, second])).toBe(true)
expect(areRequestArraysReferentiallyEqual([first, second], [second, first])).toBe(false)
expect(areRequestArraysReferentiallyEqual([first], [{ id: "perm_1" }])).toBe(false)
})
})
@@ -0,0 +1,61 @@
import type { Session } from "@opencode-ai/sdk/v2"
type BlockingRequest = { id: string }
export const computeSubtreeIds = (sessions: Session[], rootId: string): Set<string> => {
const childrenByParent = new Map<string, string[]>()
for (const session of sessions) {
if (!session.parentID) continue
const list = childrenByParent.get(session.parentID) ?? []
list.push(session.id)
childrenByParent.set(session.parentID, list)
}
const ids = new Set<string>([rootId])
const queue = [rootId]
for (const id of queue) {
const children = childrenByParent.get(id)
if (!children) continue
for (const childId of children) {
if (ids.has(childId)) continue
ids.add(childId)
queue.push(childId)
}
}
return ids
}
export const areRequestArraysReferentiallyEqual = <T extends BlockingRequest>(left: T[], right: T[]): boolean => {
if (left === right) return true
if (left.length !== right.length) return false
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false
}
return true
}
export const collectScopedBlockingRequests = <T extends BlockingRequest>(
sessions: Session[],
requestsBySession: Record<string, T[] | undefined>,
sessionID: string | null,
empty: T[],
): T[] => {
if (!sessionID) return empty
const scopedIds = computeSubtreeIds(sessions, sessionID)
if (scopedIds.size === 0) return empty
const seen = new Set<string>()
const result: T[] = []
for (const id of scopedIds) {
const entries = requestsBySession[id]
if (!entries) continue
for (const entry of entries) {
if (seen.has(entry.id)) continue
seen.add(entry.id)
result.push(entry)
}
}
return result.length === 0 ? empty : result
}
+73 -2
View File
@@ -13,7 +13,10 @@ import { mergeSessionDirectoryMetadata, useGlobalSessionsStore } from "@/stores/
import { useConfigStore } from "@/stores/useConfigStore"
import { registerSessionDirectory } from "./sync-refs"
import { isSyntheticPart } from "@/lib/messages/synthetic"
import { materializeSessionSnapshots } from "./materialization"
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
import { retry } from "./retry"
import { isVSCodeRuntime } from "@/lib/desktop"
import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface"
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
import { sessionEvents } from "@/lib/sessionEvents"
import {
@@ -24,7 +27,7 @@ import {
type SessionMetadataRecord,
} from "@/lib/sessionReviewMetadata"
const MESSAGE_REFETCH_LIMIT = 200
const MESSAGE_REFETCH_LIMIT = 100
const MESSAGE_REFETCH_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const UNREVERT_REFETCH_ATTEMPTS = 3
const UNREVERT_REFETCH_RETRY_MS = 150
@@ -1037,3 +1040,71 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro
// Clear existing attachments and restore file parts from the forked message.
restoreFilePartsToInput(fileParts)
}
// ---------------------------------------------------------------------------
// Imperative fetch path — starts message loading on the same tick as
// setCurrentSession, before the React commit cycle fires useEffect.
// ---------------------------------------------------------------------------
const FETCH_MESSAGES_LOADING = new Set<string>()
const DESKTOP_INITIAL_PAGE_SIZE = 50
const CONSTRAINED_INITIAL_PAGE_SIZE = 30
const getFetchPageSize = () => {
if (isVSCodeRuntime() || isMobileSurfaceRuntime()) return CONSTRAINED_INITIAL_PAGE_SIZE
return DESKTOP_INITIAL_PAGE_SIZE
}
export async function fetchMessagesForSession(sessionID: string, directory?: string | null): Promise<void> {
const resolvedDir = directory ?? dir()
if (!resolvedDir) return
const s = sdk()
const store = directory
? dirStoreForDirectory(directory)
: dirStore()
if (getSessionMaterializationStatus(store.getState(), sessionID).renderable) return
const loadingKey = `${resolvedDir}:${sessionID}`
if (FETCH_MESSAGES_LOADING.has(loadingKey)) return
FETCH_MESSAGES_LOADING.add(loadingKey)
try {
const result = await retry(async () => {
const response = await s.session.messages({
sessionID,
directory: resolvedDir,
limit: getFetchPageSize(),
})
return response
})
const records = (assertSdkSuccess(result, "session.messages") ?? [])
.filter((record: { info?: { id?: string } }) => !!record?.info?.id)
if (records.length === 0) return
// Staleness guard: a rapid session switch may have moved the user off this
// session while the fetch was in flight. Skip the write so a slow fetch
// can't repopulate (and un-evict) a session already navigated away from.
if (useSessionUIStore.getState().currentSessionId !== sessionID) return
store.setState((state) => {
const materialized = materializeSessionSnapshots(
state,
sessionID,
records.map((record: { info: Message; parts?: Part[] }) => ({
info: stripMessageDiffSnapshots(record.info),
parts: record.parts ?? [],
})),
{ skipPartTypes: MESSAGE_REFETCH_SKIP_PARTS },
)
return { message: materialized.message, part: materialized.part }
})
} catch {
// Transient failure — the reactive path in ChatContainer will retry
} finally {
FETCH_MESSAGES_LOADING.delete(loadingKey)
}
}
@@ -0,0 +1,68 @@
import { describe, expect, test } from 'bun:test';
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import { buildSessionMessageRecordsSnapshot } from './sync-context';
import { INITIAL_STATE, type State } from './types';
const message = (id: string, role: 'user' | 'assistant', parentID?: string): Message => ({
id,
role,
sessionID: 'ses_1',
...(parentID ? { parentID } : {}),
time: { created: 1 },
} as Message);
const textPart = (id: string, text: string): Part => ({
id,
type: 'text',
text,
} as Part);
const state = (partial: Partial<State>): State => ({
...INITIAL_STATE,
...partial,
});
describe('buildSessionMessageRecordsSnapshot', () => {
test('only suspends part updates for the active streaming message', () => {
const user = message('user_1', 'user');
const assistant1 = message('assistant_1', 'assistant', 'user_1');
const assistant2 = message('assistant_2', 'assistant', 'user_1');
const messages = [user, assistant1, assistant2];
const assistant1InitialParts = [textPart('assistant_1_initial', 'initial')];
const assistant2InitialParts = [textPart('assistant_2_initial', 'initial')];
const previous = buildSessionMessageRecordsSnapshot(
state({
message: { ses_1: messages },
part: {
assistant_1: assistant1InitialParts,
assistant_2: assistant2InitialParts,
},
}),
'ses_1',
undefined,
true,
'assistant_1',
);
const assistant1FinalParts = [textPart('assistant_1_final', 'final')];
const assistant2LiveParts = [textPart('assistant_2_live', 'live')];
const next = buildSessionMessageRecordsSnapshot(
state({
message: { ses_1: messages },
part: {
assistant_1: assistant1FinalParts,
assistant_2: assistant2LiveParts,
},
}),
'ses_1',
previous,
true,
'assistant_2',
);
expect(next.byId.get('assistant_1')?.parts).toBe(assistant1FinalParts);
expect(next.byId.get('assistant_2')?.parts).toBe(assistant2InitialParts);
});
});
+8
View File
@@ -51,6 +51,7 @@ import {
revertToMessage as revertToMessageAction,
unrevertSession as unrevertSessionAction,
forkFromMessage as forkFromMessageAction,
fetchMessagesForSession,
} from "./session-actions"
import { useInputStore, type SyntheticContextPart } from "./input-store"
import { useSelectionStore } from "./selection-store"
@@ -497,6 +498,13 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
set({ currentSessionId: id, currentSessionDirectory: id ? resolvedDir ?? null : null })
writeRuntimeSessionMemory(key, { sessionId: id, directory: resolvedDir ?? null })
// Kick off the message fetch on the same tick, before React commits the
// state change and fires ChatContainer.useEffect. The fetch is
// fire-and-forget — any transient failure gets retried by the reactive path.
if (id) {
void fetchMessagesForSession(id, resolvedDir)
}
try {
if (resolvedDir && directoryState.currentDirectory !== resolvedDir) {
directoryState.setDirectory(resolvedDir, { showOverlay: false })
+165 -58
View File
@@ -23,7 +23,7 @@ import { bootstrapGlobal, bootstrapDirectory } from "./bootstrap"
import { retry } from "./retry"
import { updateStreamingState } from "./streaming"
import { setActionRefs } from "./session-actions"
import { setSyncRefs } from "./sync-refs"
import { setSyncRefs, getAllSyncSessions } from "./sync-refs"
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
import { syncDebug } from "./debug"
import { getReconnectCandidateSessionIds } from "./reconnect-recovery"
@@ -47,6 +47,8 @@ import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
import { setSessionPrefetch } from "./session-prefetch-cache"
import { listGlobalSessionPages } from "@/stores/globalSessions"
import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
// ---------------------------------------------------------------------------
// Context
@@ -260,6 +262,7 @@ async function materializeSessionFromServer(
directory: string,
sessionID: string,
store: StoreApi<DirectoryStore>,
options?: { isStale?: () => boolean },
) {
const scopedClient = opencodeClient.getScopedSdkClient(directory)
const result = await retry(async () => {
@@ -278,6 +281,8 @@ async function materializeSessionFromServer(
complete: !cursor,
})
if (options?.isStale?.()) return
store.setState((state: DirectoryStore) => {
const materialized = materializeSessionSnapshots(
state,
@@ -326,12 +331,12 @@ type UiNotificationPayload = {
body?: unknown
tag?: unknown
kind?: unknown
sessionId?: unknown
directory?: unknown
requireHidden?: unknown
desktopNotificationDelivered?: unknown
desktopStdoutActive?: unknown
}
sessionId?: unknown
directory?: unknown
requireHidden?: unknown
desktopNotificationDelivered?: unknown
desktopStdoutActive?: unknown
}
const asOptionalString = (value: unknown): string | undefined => {
if (typeof value !== "string") return undefined
@@ -350,9 +355,9 @@ const handleUiNotificationEvent = (payload: Event, fallbackDirectory: string): b
}
const notification = properties as UiNotificationPayload
if ((notification.desktopNotificationDelivered === true || notification.desktopStdoutActive === true) && getRuntimeKey() === "local") {
return true
}
if ((notification.desktopNotificationDelivered === true || notification.desktopStdoutActive === true) && getRuntimeKey() === "local") {
return true
}
const notifications = getRegisteredRuntimeAPIs()?.notifications
if (!notifications?.notifyAgentCompletion) {
@@ -1657,10 +1662,10 @@ export function SyncProvider(props: {
ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState())
}
},
global: {
config: globalState.config,
projects: globalState.projects,
},
global: {
config: globalState.config,
projects: globalState.projects,
},
loadSessions: (dir) => retry(async () => {
const rootSessions = (await listGlobalSessionPages(props.sdk, {
directory: dir,
@@ -1751,11 +1756,11 @@ export function SyncProvider(props: {
const generation = ++globalBootstrapGeneration
bootingRoot = true
const globalActions = useGlobalSyncStore.getState().actions
bootstrapGlobal(props.sdk, (patch) => {
if (globalBootstrapGeneration === generation) {
globalActions.set(patch)
}
})
bootstrapGlobal(props.sdk, (patch) => {
if (globalBootstrapGeneration === generation) {
globalActions.set(patch)
}
})
.then(() => {
if (globalBootstrapGeneration === generation) {
bootedAt = Date.now()
@@ -1771,7 +1776,7 @@ export function SyncProvider(props: {
bootingRoot = false
}
}
}, [props.sdk])
}, [props.sdk])
// Event pipeline — created once per mount. No class, no start/stop.
// Abort controller owned by the pipeline closure. Cleanup aborts + flushes.
@@ -2177,6 +2182,77 @@ export function useSessions(directory?: string) {
)
}
const selectPermissionRequestsBySession = (state: State) => state.permission
const selectQuestionRequestsBySession = (state: State) => state.question
type ScopedBlockingRequestCache<T extends { id: string }> = {
sessionID: string | null
sessions: Session[] | null
requestsBySession: Record<string, T[] | undefined> | null
result: T[]
}
function useScopedBlockingRequests<T extends { id: string }>(
sessionID: string | null,
directory: string | undefined,
selectRequestsBySession: (state: State) => Record<string, T[] | undefined>,
empty: T[],
): T[] {
const cacheRef = useRef<ScopedBlockingRequestCache<T>>({
sessionID: null,
sessions: null,
requestsBySession: null,
result: empty,
})
return useDirectorySync(
useCallback((state: State) => {
const requestsBySession = selectRequestsBySession(state)
const cache = cacheRef.current
if (
cache.sessionID === sessionID
&& cache.sessions === state.session
&& cache.requestsBySession === requestsBySession
) {
return cache.result
}
const next = collectScopedBlockingRequests(state.session, requestsBySession, sessionID, empty)
const result = areRequestArraysReferentiallyEqual(cache.result, next) ? cache.result : next
cacheRef.current = {
sessionID,
sessions: state.session,
requestsBySession,
result,
}
return result
}, [empty, selectRequestsBySession, sessionID]),
directory,
)
}
export function useScopedBlockingPermissions(sessionID: string | null, directory?: string): PermissionRequest[] {
return useScopedBlockingRequests(sessionID, directory, selectPermissionRequestsBySession, EMPTY_PERMISSION_REQUESTS)
}
export function useScopedBlockingQuestions(sessionID: string | null, directory?: string): QuestionRequest[] {
return useScopedBlockingRequests(sessionID, directory, selectQuestionRequestsBySession, EMPTY_QUESTION_REQUESTS)
}
export function useParentSession(sessionID: string | null, directory?: string): Session | null {
return useDirectorySync(
useCallback((state: State) => {
if (!sessionID) return null
const current = state.session.find((s) => s.id === sessionID)
if (!current?.parentID) return null
return state.session.find((s) => s.id === current.parentID)
?? getAllSyncSessions().find((s) => s.id === current.parentID)
?? null
}, [sessionID]),
directory,
)
}
const getSidebarSessionSignature = (session: Session, stableUpdatedAt: number): string => {
const directory = (session as Session & { directory?: string | null }).directory ?? ''
const parentID = (session as Session & { parentID?: string | null }).parentID ?? ''
@@ -2348,14 +2424,6 @@ const getConcatenatedTextFromParts = (parts: Part[]): string => {
return text
}
const getFirstTextFromParts = (parts: Part[]): string => {
for (const part of parts) {
const text = getPartText(part)
if (text.length > 0) return text
}
return ""
}
type SessionMessageRecord = { info: Message; parts: Part[] }
const EMPTY_SESSION_MESSAGE_RECORDS: SessionMessageRecord[] = []
@@ -2365,6 +2433,7 @@ type SessionMessageRecordsSnapshot = {
visibleMessages: Message[]
revertMessageID?: string
suspendPartUpdates: boolean
suspendedPartUpdatesMessageID?: string
list: SessionMessageRecord[]
byId: Map<string, SessionMessageRecord>
}
@@ -2376,8 +2445,12 @@ const MOBILE_SESSION_MESSAGE_RECORDS_CACHE_MAX = 4
const MOBILE_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 getSessionMessageRecordsCacheKey = (
sessionID: string,
suspendPartUpdates: boolean,
suspendedPartUpdatesMessageID?: string,
): string => (
`${sessionID}\u0000${suspendPartUpdates ? 1 : 0}\u0000${suspendedPartUpdatesMessageID ?? ""}`
)
const getSessionMessageRecordsCache = (store: StoreApi<DirectoryStore>): Map<string, SessionMessageRecordsSnapshot> => {
@@ -2393,10 +2466,11 @@ const readCachedSessionMessageRecordsSnapshot = (
store: StoreApi<DirectoryStore>,
sessionID: string,
suspendPartUpdates: boolean,
suspendedPartUpdatesMessageID?: string,
): SessionMessageRecordsSnapshot | undefined => {
const cache = sessionMessageRecordsCache.get(store)
if (!cache) return undefined
const key = getSessionMessageRecordsCacheKey(sessionID, suspendPartUpdates)
const key = getSessionMessageRecordsCacheKey(sessionID, suspendPartUpdates, suspendedPartUpdatesMessageID)
const cached = cache.get(key)
if (!cached) return undefined
cache.delete(key)
@@ -2410,7 +2484,11 @@ const rememberSessionMessageRecordsSnapshot = (
): void => {
if (!snapshot.sessionID) return
const cache = getSessionMessageRecordsCache(store)
const key = getSessionMessageRecordsCacheKey(snapshot.sessionID, snapshot.suspendPartUpdates)
const key = getSessionMessageRecordsCacheKey(
snapshot.sessionID,
snapshot.suspendPartUpdates,
snapshot.suspendedPartUpdatesMessageID,
)
const constrainedMaxMessages = isVSCodeRuntime()
? VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES
: isMobileSurfaceRuntime()
@@ -2442,17 +2520,23 @@ export function dropCachedSessionMessageRecordsSnapshots(
if (!cache) return
for (const sessionID of sessionIDs) {
if (!sessionID) continue
cache.delete(getSessionMessageRecordsCacheKey(sessionID, false))
cache.delete(getSessionMessageRecordsCacheKey(sessionID, true))
const prefix = `${sessionID}\u0000`
for (const key of [...cache.keys()]) {
if (key.startsWith(prefix)) {
cache.delete(key)
}
}
}
}
const snapshotPartsMatchState = (snapshot: SessionMessageRecordsSnapshot, state: State): boolean => {
if (snapshot.suspendPartUpdates) {
return true
}
for (const record of snapshot.list) {
if (snapshot.suspendPartUpdates) {
const suspendedID = snapshot.suspendedPartUpdatesMessageID
if (!suspendedID || record.info.id === suspendedID) {
continue
}
}
if ((state.part[record.info.id] ?? EMPTY_PARTS) !== record.parts) {
return false
}
@@ -2466,8 +2550,9 @@ const getReusableSessionMessageRecordsSnapshot = (
state: State,
sessionID: string,
suspendPartUpdates: boolean,
suspendedPartUpdatesMessageID?: string,
): SessionMessageRecordsSnapshot | undefined => {
const cached = readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates)
const cached = readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates, suspendedPartUpdatesMessageID)
if (!cached) return undefined
const sourceMessages = state.message[sessionID] ?? EMPTY_MESSAGES
const session = state.session.find((candidate) => candidate.id === sessionID)
@@ -2476,6 +2561,7 @@ const getReusableSessionMessageRecordsSnapshot = (
cached.sourceMessages === sourceMessages
&& cached.revertMessageID === revertMessageID
&& cached.suspendPartUpdates === suspendPartUpdates
&& cached.suspendedPartUpdatesMessageID === suspendedPartUpdatesMessageID
&& snapshotPartsMatchState(cached, state)
) {
return cached
@@ -2516,12 +2602,16 @@ export function buildSessionMessageRecordsSnapshot(
sessionID: string,
previous?: SessionMessageRecordsSnapshot,
suspendPartUpdates = false,
suspendedPartUpdatesMessageID?: string,
): SessionMessageRecordsSnapshot {
const { sourceMessages, visibleMessages, revertMessageID } = getVisibleMessagesForSession(state, sessionID, previous)
const nextById = new Map<string, SessionMessageRecord>()
const nextList = visibleMessages.map((message) => {
const previousRecord = previous?.byId.get(message.id)
const parts = suspendPartUpdates && previousRecord
const shouldSuspendParts = suspendPartUpdates
&& previousRecord
&& (!suspendedPartUpdatesMessageID || message.id === suspendedPartUpdatesMessageID)
const parts = shouldSuspendParts
? previousRecord.parts
: (state.part[message.id] ?? EMPTY_PARTS)
@@ -2535,6 +2625,8 @@ export function buildSessionMessageRecordsSnapshot(
const unchanged = Boolean(previous)
&& previous?.visibleMessages === visibleMessages
&& previous.suspendPartUpdates === suspendPartUpdates
&& previous.suspendedPartUpdatesMessageID === suspendedPartUpdatesMessageID
&& previous.list.length === nextList.length
&& previous.list.every((record, index) => record === nextList[index])
@@ -2548,6 +2640,7 @@ export function buildSessionMessageRecordsSnapshot(
visibleMessages,
revertMessageID,
suspendPartUpdates,
suspendedPartUpdatesMessageID,
list: nextList,
byId: nextById,
}
@@ -2577,20 +2670,21 @@ export function useSessionTextMessages(sessionID: string, directory?: string): S
}
export function useUserMessageHistory(sessionID: string, directory?: string): string[] {
const records = useSessionMessageRecords(sessionID, directory)
const userMessages = useMemo(() => records.filter((record) => record.info.role === 'user'), [records])
const store = useDirectoryStore(directory)
const snapshotRef = useRef<UserMessageHistorySnapshot>(EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT)
return useMemo(() => {
const history: string[] = []
for (let index = userMessages.length - 1; index >= 0; index -= 1) {
const message = userMessages[index]
const text = getFirstTextFromParts(message.parts)
if (text.length > 0) {
history.push(text)
}
}
return history
}, [userMessages])
const getSnapshot = useCallback(() => {
const next = buildUserMessageHistorySnapshot(store.getState(), sessionID, snapshotRef.current)
snapshotRef.current = next
return next.history
}, [sessionID, store])
const subscribe = useCallback((notify: () => void) => {
if (!sessionID) return () => undefined
return store.subscribe(notify)
}, [sessionID, store])
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
/**
@@ -2603,7 +2697,7 @@ export function useUserMessageHistory(sessionID: string, directory?: string): st
export function useSessionMessageRecords(
sessionID: string,
directory?: string,
options?: { suspendPartUpdates?: boolean },
options?: { suspendPartUpdates?: boolean; suspendPartUpdatesForMessageId?: string | null },
) {
const store = useDirectoryStore(directory)
const snapshotRef = useRef<SessionMessageRecordsSnapshot>({
@@ -2612,6 +2706,7 @@ export function useSessionMessageRecords(
visibleMessages: EMPTY_MESSAGES,
revertMessageID: undefined,
suspendPartUpdates: Boolean(options?.suspendPartUpdates),
suspendedPartUpdatesMessageID: options?.suspendPartUpdatesForMessageId ?? undefined,
list: [],
byId: new Map(),
})
@@ -2623,7 +2718,14 @@ export function useSessionMessageRecords(
const state = store.getState()
const suspendPartUpdates = Boolean(options?.suspendPartUpdates)
const reusableSnapshot = getReusableSessionMessageRecordsSnapshot(store, state, sessionID, suspendPartUpdates)
const suspendedPartUpdatesMessageID = options?.suspendPartUpdatesForMessageId ?? undefined
const reusableSnapshot = getReusableSessionMessageRecordsSnapshot(
store,
state,
sessionID,
suspendPartUpdates,
suspendedPartUpdatesMessageID,
)
if (reusableSnapshot) {
snapshotRef.current = reusableSnapshot
return reusableSnapshot.list
@@ -2631,18 +2733,19 @@ export function useSessionMessageRecords(
const previousSnapshot = snapshotRef.current.sessionID === sessionID
? snapshotRef.current
: readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates)
: readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates, suspendedPartUpdatesMessageID)
const nextSnapshot = buildSessionMessageRecordsSnapshot(
state,
sessionID,
previousSnapshot,
suspendPartUpdates,
suspendedPartUpdatesMessageID,
)
snapshotRef.current = nextSnapshot
rememberSessionMessageRecordsSnapshot(store, nextSnapshot)
return nextSnapshot.list
}, [options?.suspendPartUpdates, sessionID, store])
}, [options?.suspendPartUpdates, options?.suspendPartUpdatesForMessageId, sessionID, store])
const subscribe = useCallback((notify: () => void) => {
if (!sessionID) return () => undefined
@@ -2671,6 +2774,7 @@ export function useEnsureSessionMessages(sessionID: string, directory?: string)
const syncDirectory = useSyncDirectory()
const resolvedDirectory = directory ?? syncDirectory
const store = useDirectoryStore(resolvedDirectory)
const requestGenerationRef = React.useRef(0)
React.useEffect(() => {
if (!sessionID) return
@@ -2685,11 +2789,14 @@ export function useEnsureSessionMessages(sessionID: string, directory?: string)
// Already loading this session for this directory
if (_ensureMessagesLoading.has(loadingKey)) return
const generation = ++requestGenerationRef.current
const isStale = () => generation !== requestGenerationRef.current
_ensureMessagesLoading.add(loadingKey)
void (async () => {
try {
await materializeSessionFromServer(resolvedDirectory, sessionID, store)
await materializeSessionFromServer(resolvedDirectory, sessionID, store, { isStale })
} catch {
// Transient failure — next navigation or reconnect will retry
} finally {
+52 -10
View File
@@ -22,10 +22,11 @@ import {
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const INITIAL_MESSAGE_PAGE_SIZE = 150
const INITIAL_MESSAGE_PAGE_SIZE = 50
const VSCODE_INITIAL_MESSAGE_PAGE_SIZE = 30
const MOBILE_INITIAL_MESSAGE_PAGE_SIZE = 30
const HISTORY_MESSAGE_PAGE_SIZE = 200
const HISTORY_MESSAGE_PAGE_SIZE = 100
const INITIAL_PAGE_EXPANSION_LIMITS = [100, 150] as const
const VSCODE_INITIAL_PAGE_EXPANSION_LIMITS = [50, 80, 120] as const
const MAX_SEEN_DIRS = 30
const VSCODE_SESSION_CACHE_LIMIT = 4
@@ -40,6 +41,12 @@ const seenByDirectory = new Map<string, Set<string>>()
// all request the same session during startup; coalesce them into one HTTP load.
const syncSessionInflightByKey = new Map<string, Promise<void>>()
// Per-session generation counter. When a newer syncSession request starts for
// the same session, older in-flight requests become stale and must not write
// 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 SyncMeta = {
limit: number
cursor: string | undefined
@@ -88,6 +95,9 @@ const getInitialMessagePageSize = () => {
if (isMobileSurfaceRuntime()) return MOBILE_INITIAL_MESSAGE_PAGE_SIZE
return INITIAL_MESSAGE_PAGE_SIZE
}
const getInitialPageExpansionLimits = () => isConstrainedSessionRuntime()
? VSCODE_INITIAL_PAGE_EXPANSION_LIMITS
: INITIAL_PAGE_EXPANSION_LIMITS
const getDefaultMeta = (): SyncMeta => ({ limit: getInitialMessagePageSize(), cursor: undefined, complete: false, loading: false })
function getPrefetchMeta(directory: string, sessionID: string): SyncMeta | undefined {
@@ -333,7 +343,7 @@ export function useSync() {
// Load messages for a session
const loadMessages = useCallback(
async (sessionID: string, options?: { before?: string; mode?: "replace" | "prepend" }) => {
async (sessionID: string, options?: { before?: string; mode?: "replace" | "prepend"; isStale?: () => boolean }) => {
const m = getMetaFor(sessionID)
if (m.loading) return
setMetaFor(sessionID, { loading: true })
@@ -342,13 +352,13 @@ export function useSync() {
const limit = options?.before ? HISTORY_MESSAGE_PAGE_SIZE : m.limit
let page = await fetchMessages(sessionID, limit, options?.before)
// Constrained shells keep the initial page small for switch performance. Some
// sessions have a very large final turn, so the latest 30 records can
// Keep the initial page small for switch performance. Some sessions
// have a very large final turn, so the latest 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 && isConstrainedSessionRuntime() && !page.complete && !hasUserMessage(page.session)) {
for (const nextLimit of VSCODE_INITIAL_PAGE_EXPANSION_LIMITS) {
if (!options?.before && !page.complete && !hasUserMessage(page.session)) {
for (const nextLimit of getInitialPageExpansionLimits()) {
if (nextLimit <= limit) continue
page = await fetchMessages(sessionID, nextLimit)
if (page.complete || hasUserMessage(page.session)) break
@@ -362,6 +372,11 @@ export function useSync() {
clearOptimistic(sessionID, messageID)
}
if (options?.isStale?.()) {
setMetaFor(sessionID, { loading: false })
return
}
const current = store.getState()
const materialized = materializeSessionSnapshots(
current,
@@ -373,6 +388,11 @@ export function useSync() {
{ skipPartTypes: SKIP_PARTS, mode: options?.mode === "prepend" ? "prepend" : "merge" },
)
if (options?.isStale?.()) {
setMetaFor(sessionID, { loading: false })
return
}
setMetaFor(sessionID, {
limit: materialized.messages.length,
cursor: merged.cursor,
@@ -404,6 +424,13 @@ export function useSync() {
const existing = syncSessionInflightByKey.get(key)
if (existing) return existing
// This is a new request. Bump generation so any older request that
// might still be finishing (e.g. from a previous component lifecycle)
// knows it is stale and should not write to the store.
const generation = (syncSessionGenerationByKey.get(key) ?? 0) + 1
syncSessionGenerationByKey.set(key, generation)
const isStale = () => syncSessionGenerationByKey.get(key) !== generation
const current = store.getState()
const m = getMetaFor(sessionID)
const materialization = getSessionMaterializationStatus(current, sessionID)
@@ -449,7 +476,7 @@ export function useSync() {
assertSdkSuccess(response, "session.get")
return response
})
if (result.data) {
if (result.data && !isStale()) {
const nextSession = stripSessionDiffSnapshots(result.data)
const s = store.getState()
const sessions = [...s.session]
@@ -459,15 +486,30 @@ export function useSync() {
} else {
sessions.splice(idx.index, 0, nextSession)
}
store.setState({ session: sessions })
if (!isStale()) {
store.setState({ session: sessions })
}
}
} catch (e) {
console.error("[sync] failed to fetch session", sessionID, e)
}
})()
: Promise.resolve(),
shouldLoadMessages ? loadMessages(sessionID) : Promise.resolve(),
shouldLoadMessages ? loadMessages(sessionID, { isStale }) : Promise.resolve(),
])
// Progressive mount: after the initial page resolves, if the session
// isn't stale and the server indicated more messages, dispatch a
// second fetch to prepend older history. The user sees the first page
// immediately; the rest arrive shortly after. This gives the scroll
// container headroom above the viewport so the "load older on
// scroll-up" trigger fires before the user hits the absolute top.
if (!isStale()) {
const currentMeta = getMetaFor(sessionID)
if (currentMeta.cursor && !currentMeta.complete) {
loadMessages(sessionID, { before: currentMeta.cursor, mode: "prepend", isStale })
}
}
})()
syncSessionInflightByKey.set(key, promise)
@@ -0,0 +1,98 @@
import { describe, expect, test } from 'bun:test';
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import type { State } from './types';
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot } from './user-message-history';
const message = (id: string, role: 'user' | 'assistant'): Message => ({
id,
role,
sessionID: 'ses_1',
time: { created: 1 },
} as Message);
const textPart = (id: string, text: string): Part => ({
id,
type: 'text',
text,
} as Part);
const state = (partial: Partial<State>): Pick<State, 'session' | 'message' | 'part'> => ({
session: [],
message: {},
part: {},
...partial,
});
describe('buildUserMessageHistorySnapshot', () => {
test('returns a shared empty snapshot without a session id', () => {
expect(buildUserMessageHistorySnapshot(state({}), '')).toBe(EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT);
});
test('keeps history stable when assistant parts change', () => {
const user = message('user_1', 'user');
const assistant = message('assistant_1', 'assistant');
const userParts = [textPart('part_user', 'hello')];
const first = buildUserMessageHistorySnapshot(
state({
message: { ses_1: [user, assistant] },
part: { user_1: userParts, assistant_1: [textPart('part_a', 'stream')] },
}),
'ses_1',
);
const second = buildUserMessageHistorySnapshot(
state({
message: { ses_1: [user, assistant] },
part: { user_1: userParts, assistant_1: [textPart('part_a2', 'streaming')] },
}),
'ses_1',
first,
);
expect(second).toBe(first);
expect(second.history).toEqual(['hello']);
});
test('updates history when a user part changes', () => {
const user = message('user_1', 'user');
const first = buildUserMessageHistorySnapshot(
state({
message: { ses_1: [user] },
part: { user_1: [textPart('part_user', 'hello')] },
}),
'ses_1',
);
const second = buildUserMessageHistorySnapshot(
state({
message: { ses_1: [user] },
part: { user_1: [textPart('part_user_updated', 'updated')] },
}),
'ses_1',
first,
);
expect(second).not.toBe(first);
expect(second.history).toEqual(['updated']);
});
test('excludes user messages hidden by session revert state', () => {
const beforeRevert = message('user_1', 'user');
const reverted = message('user_2', 'user');
const snapshot = buildUserMessageHistorySnapshot(
state({
session: [{ id: 'ses_1', revert: { messageID: 'user_2' } } as State['session'][number]],
message: { ses_1: [beforeRevert, reverted] },
part: {
user_1: [textPart('part_user_1', 'kept')],
user_2: [textPart('part_user_2', 'reverted')],
},
}),
'ses_1',
);
expect(snapshot.history).toEqual(['kept']);
});
});
@@ -0,0 +1,98 @@
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import type { State } from './types';
type UserMessageHistoryRecord = {
message: Message;
parts: Part[];
};
export type UserMessageHistorySnapshot = {
sessionID: string;
revertMessageID?: string;
records: UserMessageHistoryRecord[];
history: string[];
};
const EMPTY_PARTS: Part[] = [];
const EMPTY_RECORDS: UserMessageHistoryRecord[] = [];
const EMPTY_HISTORY: string[] = [];
export const EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT: UserMessageHistorySnapshot = {
sessionID: '',
revertMessageID: undefined,
records: EMPTY_RECORDS,
history: EMPTY_HISTORY,
};
const getPartText = (part: Part): string => {
if (part?.type !== 'text') return '';
const text = (part as { text?: unknown }).text;
return typeof text === 'string' ? text : '';
};
const getFirstTextFromParts = (parts: Part[]): string => {
for (const part of parts) {
const text = getPartText(part);
if (text.length > 0) return text;
}
return '';
};
const areRecordsEqual = (left: UserMessageHistoryRecord[], right: UserMessageHistoryRecord[]): boolean => {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index]?.message !== right[index]?.message || left[index]?.parts !== right[index]?.parts) {
return false;
}
}
return true;
};
export const buildUserMessageHistorySnapshot = (
state: Pick<State, 'session' | 'message' | 'part'>,
sessionID: string,
previous: UserMessageHistorySnapshot = EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT,
): UserMessageHistorySnapshot => {
if (!sessionID) {
return EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT;
}
const messages = state.message[sessionID] ?? [];
const session = state.session.find((candidate) => candidate.id === sessionID);
const revertMessageID = (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID;
const records: UserMessageHistoryRecord[] = [];
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.role !== 'user') {
continue;
}
if (revertMessageID && message.id >= revertMessageID) {
continue;
}
records.push({
message,
parts: state.part[message.id] ?? EMPTY_PARTS,
});
}
if (records.length === 0) {
return previous.sessionID === sessionID && previous.revertMessageID === revertMessageID && previous.records.length === 0
? previous
: { sessionID, revertMessageID, records: EMPTY_RECORDS, history: EMPTY_HISTORY };
}
if (previous.sessionID === sessionID && previous.revertMessageID === revertMessageID && areRecordsEqual(previous.records, records)) {
return previous;
}
const history: string[] = [];
for (const record of records) {
const text = getFirstTextFromParts(record.parts);
if (text.length > 0) {
history.push(text);
}
}
return { sessionID, revertMessageID, records, history };
};