mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
fix(desktop): stop long-session transcript from drifting to old turns (#69019)
content-visibility:auto on turn groups (perf: off-screen turns skip style/layout/paint) pairs with contain-intrinsic-size:auto, which only remembers a turn's size after it renders. A turn that finished streaming near the bottom had its smaller mid-stream size remembered; once it scrolled off the top edge and got skipped, it collapsed to that stale height. With overflow-anchor:none the viewport can't self-correct, so the stick-to-bottom lock drifts and the view creeps up over older turns — the 'long session eventually shows old responses' visual glitch. Exempt the newest turns (live tail) from virtualization so a turn is only ever skipped after its layout has settled at its final size (remembered == real -> skipping changes no height). Off-screen older turns still skip, so the dialog/popover whole-document recalc win on long transcripts is kept (it scales with the hundreds of old turns, not the small tail).
This commit is contained in:
parent
967e078ae4
commit
3de91c6974
2 changed files with 63 additions and 3 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildGroups, firstVisibleGroupIndex, type MessageGroup } from './list'
|
||||
import { buildGroups, firstVisibleGroupIndex, isVirtualizedGroup, LIVE_TAIL_GROUPS, type MessageGroup } from './list'
|
||||
|
||||
// Signature rows are `${index}:${id}:${role}:${weight}` (see the useAuiState
|
||||
// selector in list.tsx).
|
||||
|
|
@ -80,3 +80,33 @@ describe('firstVisibleGroupIndex', () => {
|
|||
expect(firstVisibleGroupIndex([], 60)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isVirtualizedGroup', () => {
|
||||
it('never virtualizes the newest turns (the live tail)', () => {
|
||||
const count = 20
|
||||
|
||||
for (let i = count - LIVE_TAIL_GROUPS; i < count; i++) {
|
||||
expect(isVirtualizedGroup(i, count)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('virtualizes older turns that sit before the live tail', () => {
|
||||
const count = 20
|
||||
|
||||
expect(isVirtualizedGroup(0, count)).toBe(true)
|
||||
expect(isVirtualizedGroup(count - LIVE_TAIL_GROUPS - 1, count)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps every turn rendered when the whole transcript fits in the tail', () => {
|
||||
const count = LIVE_TAIL_GROUPS
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
expect(isVirtualizedGroup(i, count)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('honors a custom tail size', () => {
|
||||
expect(isVirtualizedGroup(5, 10, 3)).toBe(true)
|
||||
expect(isVirtualizedGroup(7, 10, 3)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -113,6 +113,27 @@ export function firstVisibleGroupIndex(groups: readonly MessageGroup[], budget:
|
|||
return firstVisible
|
||||
}
|
||||
|
||||
// content-visibility:auto skips off-screen turns for perf, but with
|
||||
// contain-intrinsic-size:auto the browser only remembers a turn's size AFTER
|
||||
// it has rendered. A turn that finishes streaming near the bottom may have had
|
||||
// its (smaller) mid-stream size remembered; when it scrolls just off the top
|
||||
// edge and gets skipped, it snaps back to that stale height, shifting content
|
||||
// down. With overflow-anchor:none (the viewport can't self-correct) the
|
||||
// stick-to-bottom lock drifts and the view creeps up over older turns — the
|
||||
// "long session eventually shows old responses" glitch.
|
||||
//
|
||||
// Keep the newest N turns always-rendered so a turn is only ever virtualized
|
||||
// once its layout has settled at its final size (remembered == real → skipping
|
||||
// it changes no height). Off-screen OLDER turns still skip, so the dialog/popover
|
||||
// recalc win on long transcripts is preserved (that scales with the hundreds of
|
||||
// old turns, not this small live tail).
|
||||
export const LIVE_TAIL_GROUPS = 6
|
||||
|
||||
/** True when a visible group is old enough to virtualize (outside the live tail). */
|
||||
export function isVirtualizedGroup(indexInVisible: number, visibleCount: number, liveTail = LIVE_TAIL_GROUPS): boolean {
|
||||
return indexInVisible < visibleCount - liveTail
|
||||
}
|
||||
|
||||
const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
clampToComposer,
|
||||
components,
|
||||
|
|
@ -359,7 +380,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
{t.assistant.thread.showEarlier}
|
||||
</button>
|
||||
)}
|
||||
{visibleGroups.map(group => (
|
||||
{visibleGroups.map((group, indexInVisible) => (
|
||||
// content-visibility:auto — off-screen turns skip style recalc,
|
||||
// layout, and paint. On a long transcript this is what keeps
|
||||
// UNRELATED UI fast: any dialog/popover mount (Radix Presence
|
||||
|
|
@ -370,8 +391,17 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||
// real size once rendered), so scrollbar/anchoring stay stable.
|
||||
// Sticky human bubbles are unaffected — their turn is rendered
|
||||
// whenever any part of it intersects the viewport.
|
||||
//
|
||||
// The live tail (newest turns) is exempt: virtualizing a turn
|
||||
// whose final size hasn't been remembered yet snaps it to a stale
|
||||
// height when it scrolls off, drifting stick-to-bottom up over old
|
||||
// turns. See isVirtualizedGroup.
|
||||
<div
|
||||
className="flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap) [contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]"
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col gap-(--conversation-turn-gap) pb-(--conversation-turn-gap)',
|
||||
isVirtualizedGroup(indexInVisible, visibleGroups.length) &&
|
||||
'[contain-intrinsic-size:auto_37.5rem] [content-visibility:auto]'
|
||||
)}
|
||||
key={group.id}
|
||||
>
|
||||
<MessageRenderBoundary resetKey={messageSignature}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue