diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts index c0a78da300e..bc5d8f2bb32 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts @@ -9,6 +9,28 @@ import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionW import type { ClientSessionState } from '../../types' +// Shallow per-message identity check. When a flush carries no transcript +// changes, `preserveLocalAssistantErrors` returns the same message objects in +// the same order, so reference equality per slot is enough to detect "nothing +// to publish" and avoid a needless `$messages` churn. +function sameMessageList(a: ChatMessage[], b: ChatMessage[]): boolean { + if (a === b) { + return true + } + + if (a.length !== b.length) { + return false + } + + for (let index = 0; index < a.length; index += 1) { + if (a[index] !== b[index]) { + return false + } + } + + return true +} + interface SessionStateCacheOptions { activeSessionId: string | null busyRef: MutableRefObject @@ -88,7 +110,20 @@ export function useSessionStateCache({ return } - setMessages(preserveLocalAssistantErrors(pending.state.messages, $messages.get())) + // `preserveLocalAssistantErrors` always returns a fresh array, so publishing + // it unconditionally puts a new `$messages` reference on the store every + // flush — including the periodic `session.info` heartbeats that don't touch + // the transcript. That churns ChatView → runtimeMessageRepository → the + // assistant-ui runtime → the virtualizer, which re-measures and visibly + // jerks the scroll position while the user is reading. Skip the publish when + // the merged result is content-identical to what's already on screen. + const currentMessages = $messages.get() + const nextMessages = preserveLocalAssistantErrors(pending.state.messages, currentMessages) + + if (!sameMessageList(nextMessages, currentMessages)) { + setMessages(nextMessages) + } + setBusy(pending.state.busy) setMutableRef(busyRef, pending.state.busy) setAwaitingResponse(pending.state.awaitingResponse) diff --git a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx index e0c6df42937..506319e89f5 100644 --- a/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread-virtualizer.tsx @@ -264,8 +264,27 @@ function useThreadScrollAnchor({ return } + // Already parked at the bottom: writing `scrollTop` is a no-op and the + // browser fires NO scroll event, so arming the programmatic gate here would + // leave it permanently set. Repeated pins (streaming heartbeats, the + // post-run lock loop) then accumulate the gate, and the next genuine user + // scroll-up is misread as one of our programmatic scrolls — re-arming + // sticky-bottom and yanking the viewport back down. Refresh trackers, bail. + const distFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight) + + if (distFromBottom <= AT_BOTTOM_THRESHOLD) { + lastTopRef.current = el.scrollTop + lastHeightRef.current = el.scrollHeight + lastClientHeightRef.current = el.clientHeight + + return + } + // Hold the disarm gate across the scroll event the next line will fire. - programmaticScrollPendingRef.current += 1 + // Set to 1 rather than incrementing: coalesced writes within a frame fire a + // single scroll event, so a counter > 1 can never drain and would swallow a + // later real user scroll. + programmaticScrollPendingRef.current = 1 scrollElementToBottom(el) lastTopRef.current = el.scrollTop lastHeightRef.current = el.scrollHeight