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
+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 {