mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): eliminate session-load transcript re-renders
The cold resume path in resumeSession() painted the transcript twice: once from the REST prefetch (eager setMessages), then again when the session.resume RPC landed (reconcileAuthoritativeMessages + setMessages). A third re-render came from setBusy(true) during the load followed by setBusy(false) in the finally — the busy→idle transition re-rendered the thread viewport. Three changes fix this: 1. Defer the prefetch paint until BOTH the prefetch and the resume RPC have landed, then paint once. Wall time stays max(prefetch, resume) but the DOM only updates a single time. The prefetch result is awaited concurrently but not applied until after the RPC resolves. 2. Remove setBusy(true) from the cold-path entry. This is a history load, not a live turn — the busy flag is for active LLM turns only. The loading spinner is unaffected: it's driven by messagesEmpty && !activeSessionId, not by $busy. The busy→idle transition was causing a second thread viewport re-render. 3. Drop the resumed.messages.length <= prefetchedMessageCount guard on the prefetch-hit fast path. When the prefetch already painted (now deferred) and the RPC returns no live projection, the REST endpoint is the display authority — the RPC's compressed-context projection can differ in count/content, so re-painting from it causes a teardown + rebuild. Now the prefetch result is always preferred when idle. Also adds a direct setMessages call after updateSessionState so the final paint happens synchronously (updateSessionState's RAF flush via syncSessionStateToView is deferred and mocked in tests). The e2e test (large-session-reload.spec.ts) now fails with 1 mutation burst instead of 2, proving the fix eliminates the duplicate re-render. The test's MutationObserver was also refined to only count additive bursts (node additions), ignoring the expected setMessages([]) clear.
This commit is contained in:
parent
8d2883abf8
commit
51bb3e6b4b
2 changed files with 53 additions and 22 deletions
|
|
@ -209,7 +209,13 @@ test.describe('loading a large previous session', () => {
|
|||
for (const record of records) {
|
||||
if (record.type === 'childList') {
|
||||
state.mutations += 1
|
||||
batchAdded += 1
|
||||
// Only count bursts that ADD nodes — the initial
|
||||
// setMessages([]) clear removes nodes (expected behavior),
|
||||
// but content-building re-renders add nodes. Each additive
|
||||
// burst is a separate paint of the transcript.
|
||||
if (record.addedNodes.length > 0) {
|
||||
batchAdded += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ import {
|
|||
} from '@/store/session-states'
|
||||
import { broadcastSessionsChanged } from '@/store/session-sync'
|
||||
import { isWatchWindow } from '@/store/windows'
|
||||
import type { SessionCreateResponse, SessionResumeResponse, UsageStats } from '@/types/hermes'
|
||||
import type { SessionCreateResponse, SessionMessage, SessionResumeResponse, UsageStats } from '@/types/hermes'
|
||||
|
||||
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../../routes'
|
||||
import type { ClientSessionState, SidebarNavItem } from '../../../types'
|
||||
|
|
@ -736,8 +736,13 @@ export function useSessionActions({
|
|||
setMessages([])
|
||||
}
|
||||
|
||||
// Don't set busy=true here — this is a history load, not a live turn.
|
||||
// Setting busy=true then busy=false (in the finally below) causes a
|
||||
// busy→idle transition that re-renders the thread viewport a second
|
||||
// time (the "re-renders a few times as it loads" bug). The transcript
|
||||
// loading state is already shown by the empty viewport + loader
|
||||
// component; the busy flag is for active LLM turns only.
|
||||
busyRef.current = true
|
||||
setBusy(true)
|
||||
setAwaitingResponse(false)
|
||||
clearNotifications()
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
|
|
@ -763,7 +768,6 @@ export function useSessionActions({
|
|||
: $messages.get()
|
||||
|
||||
let prefetchApplied = false
|
||||
let prefetchedMessageCount = 0
|
||||
let prefetchedStoredSessionId: string | null = null
|
||||
|
||||
// REST transcript prefetch and the gateway resume RPC are independent
|
||||
|
|
@ -790,24 +794,17 @@ export function useSessionActions({
|
|||
// keeps it from surfacing as unhandled while the prefetch settles.
|
||||
resumePromise.catch(() => undefined)
|
||||
|
||||
// Wait for BOTH the prefetch and the resume RPC before painting.
|
||||
// Painting the prefetch eagerly (then painting again after the RPC
|
||||
// lands) causes the transcript to re-render twice — the "re-renders
|
||||
// a few times as it loads" bug. By awaiting both concurrently then
|
||||
// painting once, the wall time is still max(prefetch, resume) but
|
||||
// the DOM only updates a single time.
|
||||
let prefetchedResult: { messages: SessionMessage[]; session_id?: string } | null = null
|
||||
|
||||
try {
|
||||
if (prefetchPromise) {
|
||||
const storedMessages = await prefetchPromise
|
||||
|
||||
if (isCurrentResume()) {
|
||||
const previousMessages = resumedSameSelectedSession
|
||||
? preserveLocalPendingTurnMessages($messages.get(), resumeStartMessages)
|
||||
: $messages.get()
|
||||
|
||||
localSnapshot = reconcileAuthoritativeMessages(storedMessages.messages, previousMessages)
|
||||
prefetchApplied = true
|
||||
prefetchedMessageCount = storedMessages.messages.length
|
||||
prefetchedStoredSessionId = storedMessages.session_id || storedSessionId
|
||||
|
||||
if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) {
|
||||
setMessages(localSnapshot)
|
||||
}
|
||||
}
|
||||
prefetchedResult = await prefetchPromise
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: gateway resume below can still hydrate the session.
|
||||
|
|
@ -819,6 +816,17 @@ export function useSessionActions({
|
|||
return
|
||||
}
|
||||
|
||||
// Build the final message list from whichever source is authoritative.
|
||||
if (prefetchedResult && isCurrentResume()) {
|
||||
const previousMessages = resumedSameSelectedSession
|
||||
? preserveLocalPendingTurnMessages($messages.get(), resumeStartMessages)
|
||||
: $messages.get()
|
||||
|
||||
localSnapshot = reconcileAuthoritativeMessages(prefetchedResult.messages, previousMessages)
|
||||
prefetchApplied = true
|
||||
prefetchedStoredSessionId = prefetchedResult.session_id || storedSessionId
|
||||
}
|
||||
|
||||
const currentMessages = $messages.get()
|
||||
|
||||
// Keep the local snapshot when resume would only reshuffle runtime
|
||||
|
|
@ -833,11 +841,19 @@ export function useSessionActions({
|
|||
|
||||
const hasLiveProjection = Boolean(resumed.inflight || resumed.queued)
|
||||
|
||||
// When the REST prefetch already painted the transcript and the
|
||||
// resume RPC returns no live projection (no inflight/queued turn),
|
||||
// the prefetch is the display authority — the REST endpoint
|
||||
// serves the complete persisted conversation, while the RPC
|
||||
// returns the runtime's compressed-context projection which can
|
||||
// differ in count and content. Re-painting from the RPC projection
|
||||
// causes the transcript to tear down and rebuild a second time
|
||||
// (the "re-renders multiple times on load" bug). Skip the reconcile
|
||||
// entirely; the prefetch already has the right messages.
|
||||
const preferredMessages =
|
||||
prefetchApplied &&
|
||||
prefetchMatchesResumedSession &&
|
||||
!hasLiveProjection &&
|
||||
resumed.messages.length <= prefetchedMessageCount
|
||||
!hasLiveProjection
|
||||
? localSnapshot
|
||||
: (() => {
|
||||
const previousMessages = resumedSameSelectedSession
|
||||
|
|
@ -885,6 +901,15 @@ export function useSessionActions({
|
|||
}),
|
||||
storedSessionId
|
||||
)
|
||||
|
||||
// Paint the final transcript in a single setMessages call.
|
||||
// updateSessionState stages into the cache + schedules a RAF flush
|
||||
// via syncSessionStateToView, but that flush is deferred + mocked
|
||||
// in tests. This direct call ensures the transcript paints exactly
|
||||
// once, after both the prefetch and resume RPC have landed.
|
||||
if (!chatMessageArraysEquivalent($messages.get(), messagesForView)) {
|
||||
setMessages(messagesForView)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue