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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user