Skip to content

Commit 3c8ad63

Browse files
committed
fix(chat): stop the streaming transcript floor inventing scroll space
The sizer floor was the viewport's bottom edge (scrollTop + clientHeight), which exceeds the content height whenever the transcript is shorter than the viewport. That invents scrollable space no content occupies, and a mid-turn container shrink turns it into real scroll room the bottom-pin scrolls into. Clamp the floor to the space content has actually held this turn: the max of the virtualizer's total size and the still-applied floor. The applied-floor term keeps undrained debt across a turn boundary that interrupts the drain.
1 parent 507def6 commit 3c8ad63

3 files changed

Lines changed: 172 additions & 13 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { useAutoScroll } from '@/hooks/use-auto-scroll'
4949
import type { ChatContext } from '@/stores/panel'
5050
import { MothershipChatSkeleton } from './components/mothership-chat-skeleton'
5151
import { shouldShowAssistantMessageActions } from './message-actions-visibility'
52+
import { nextSizerFloor } from './sizer-floor'
5253

5354
interface MothershipChatProps {
5455
messages: ChatMessage[]
@@ -328,6 +329,7 @@ export function MothershipChat({
328329
const sizerRef = useRef<HTMLDivElement | null>(null)
329330
const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null)
330331
const sizerFloorAppliedRef = useRef(0)
332+
const heldHighWaterRef = useRef(0)
331333
const floorDrainRafRef = useRef(0)
332334
useEffect(() => () => cancelAnimationFrame(floorDrainRafRef.current), [])
333335

@@ -337,11 +339,11 @@ export function MothershipChat({
337339
* row-height shrinks; when they pull scrollHeight under
338340
* `scrollTop + clientHeight`, the browser clamps `scrollTop` and the pinned
339341
* transcript visibly drops, then the chase glides it back. Flooring the
340-
* sizer at exactly the scrolled-to extent prevents that clamp while never
341-
* ADDING space — the floor cannot exceed what is already on screen. So an
342-
* estimate correction (a fresh row measuring smaller than
343-
* ROW_HEIGHT_ESTIMATE) releases immediately instead of holding phantom space
344-
* the chase would scroll into and bounce back out of.
342+
* sizer prevents that clamp while never ADDING space, so an estimate
343+
* correction (a fresh row measuring smaller than ROW_HEIGHT_ESTIMATE)
344+
* releases immediately instead of holding phantom space the chase would
345+
* scroll into and bounce back out of. {@link nextSizerFloor} owns the value
346+
* and the invariant that keeps it honest.
345347
*
346348
* Active on the same signal as auto-scroll: the reveal keeps re-parsing
347349
* markdown (and shrinking) after the network stream closes, so the floor
@@ -362,6 +364,7 @@ export function MothershipChat({
362364
const el = scrollElementRef.current
363365
if (!sizer || !el) return
364366
if (!floorActive) {
367+
heldHighWaterRef.current = 0
365368
if (sizerFloorAppliedRef.current === 0) return
366369
// A drain already in flight keeps its own rAF cadence — settle-burst
367370
// commits re-enter this branch and must not add extra steps in layout,
@@ -405,14 +408,16 @@ export function MothershipChat({
405408
}
406409
}
407410
const padding = scrollerPaddingRef.current
408-
// Math.floor, not the raw float: a fractional min-height can round
409-
// scrollHeight 1px ABOVE the scrolled-to extent, and that phantom 1px gap
410-
// re-derives 1px higher after every chase step — a visible 1px/frame
411-
// upward creep whenever the floor is what's holding scrollHeight.
412-
const floor = Math.max(
413-
0,
414-
Math.floor(el.scrollTop + el.clientHeight - padding.top - padding.bottom)
415-
)
411+
const { floor, highWater } = nextSizerFloor({
412+
previousHighWater: heldHighWaterRef.current,
413+
appliedFloor: sizerFloorAppliedRef.current,
414+
contentHeight: virtualizer.getTotalSize(),
415+
scrollTop: el.scrollTop,
416+
clientHeight: el.clientHeight,
417+
paddingTop: padding.top,
418+
paddingBottom: padding.bottom,
419+
})
420+
heldHighWaterRef.current = highWater
416421
// Dead-band: the floor feeds back into its own inputs (a floored value can
417422
// land a fraction BELOW the extent, the browser clamps scrollTop, and the
418423
// next commit re-derives from the clamped position — a visible ~1px×N
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { nextSizerFloor } from './sizer-floor'
6+
7+
/** Matches the transcript scroller's `pt-4 pb-2`. */
8+
const PADDING = { paddingTop: 16, paddingBottom: 8 }
9+
/** A viewport scrolled to the bottom of 2000px of content: extent resolves to 2000. */
10+
const PINNED = { scrollTop: 1424, clientHeight: 600, ...PADDING }
11+
12+
describe('nextSizerFloor', () => {
13+
it('follows the scrolled-to extent while it stays under the high-water mark', () => {
14+
const { floor } = nextSizerFloor({
15+
...PINNED,
16+
previousHighWater: 5000,
17+
appliedFloor: 0,
18+
contentHeight: 5000,
19+
})
20+
expect(floor).toBe(2000)
21+
})
22+
23+
it('floors fractional scroll offsets down so the sizer cannot round above the extent', () => {
24+
const { floor } = nextSizerFloor({
25+
scrollTop: 100.7,
26+
clientHeight: 600,
27+
...PADDING,
28+
previousHighWater: 5000,
29+
appliedFloor: 0,
30+
contentHeight: 5000,
31+
})
32+
expect(floor).toBe(676)
33+
})
34+
35+
it('never returns a negative floor for a container smaller than its padding', () => {
36+
const { floor } = nextSizerFloor({
37+
scrollTop: 0,
38+
clientHeight: 8,
39+
...PADDING,
40+
previousHighWater: 500,
41+
appliedFloor: 0,
42+
contentHeight: 500,
43+
})
44+
expect(floor).toBe(0)
45+
})
46+
47+
it('never exceeds the content height when the transcript is shorter than the viewport', () => {
48+
const { floor } = nextSizerFloor({
49+
scrollTop: 0,
50+
clientHeight: 600,
51+
...PADDING,
52+
previousHighWater: 0,
53+
appliedFloor: 0,
54+
contentHeight: 180,
55+
})
56+
expect(floor).toBe(180)
57+
})
58+
59+
it('holds the high-water mark when content re-measures smaller mid-turn', () => {
60+
const { floor, highWater } = nextSizerFloor({
61+
...PINNED,
62+
previousHighWater: 2000,
63+
appliedFloor: 2000,
64+
contentHeight: 1940,
65+
})
66+
expect(highWater).toBe(2000)
67+
expect(floor).toBe(2000)
68+
})
69+
70+
it('carries undrained debt across a turn boundary that interrupts the drain', () => {
71+
const { floor, highWater } = nextSizerFloor({
72+
...PINNED,
73+
previousHighWater: 0,
74+
appliedFloor: 1985,
75+
contentHeight: 1940,
76+
})
77+
expect(highWater).toBe(1985)
78+
expect(floor).toBe(1985)
79+
})
80+
81+
it('raises the high-water mark as content grows', () => {
82+
const { highWater } = nextSizerFloor({
83+
...PINNED,
84+
previousHighWater: 1200,
85+
appliedFloor: 1200,
86+
contentHeight: 1600,
87+
})
88+
expect(highWater).toBe(1600)
89+
})
90+
})
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
interface SizerFloorInput {
2+
/** High-water mark carried from the previous commit of this turn. */
3+
previousHighWater: number
4+
/** Floor currently written to the sizer, including one a drain has not finished releasing. */
5+
appliedFloor: number
6+
/** Natural sizer height — `virtualizer.getTotalSize()`. */
7+
contentHeight: number
8+
scrollTop: number
9+
clientHeight: number
10+
paddingTop: number
11+
paddingBottom: number
12+
}
13+
14+
interface SizerFloorResult {
15+
/** Floor to write to the sizer's `min-height`. */
16+
floor: number
17+
/** High-water mark to carry into the next commit. */
18+
highWater: number
19+
}
20+
21+
/**
22+
* Floor height for the transcript sizer while a turn streams — the value that
23+
* keeps `scrollHeight` from dipping below the scrolled-to extent when a row
24+
* transiently re-measures smaller — together with the high-water mark that
25+
* bounds it.
26+
*
27+
* The extent (`scrollTop + clientHeight`) is the viewport's bottom edge, which
28+
* sits BELOW the content whenever the transcript is shorter than the viewport —
29+
* early in a turn, or in any short chat. Flooring at the raw extent invents
30+
* scrollable space no content occupies, which stays invisible only while the
31+
* container keeps its height. The moment it shrinks mid-turn — the composer
32+
* growing, the queued-message banner appearing, a window or panel resize — that
33+
* space becomes real scrollable room, the bottom-pin scrolls into it, and the
34+
* transcript is dragged upward until the floor drains at the end of the turn.
35+
*
36+
* The high-water mark is what the extent is clamped to, and it folds in the
37+
* APPLIED floor as well as the live content height. Both terms are load-bearing:
38+
*
39+
* - Live content alone would release the debt on the very commit that created
40+
* it, since holding space a shrink just took away is the floor's whole purpose.
41+
* - Ignoring the applied floor would dump undrained debt in a single frame when
42+
* a queued message re-engages the floor mid-drain — the end-of-turn jump the
43+
* eased drain exists to prevent.
44+
*
45+
* Together they say: never exceed the space content has actually held this turn.
46+
*
47+
* `Math.floor`, not the raw float: a fractional min-height can round
48+
* `scrollHeight` 1px ABOVE the scrolled-to extent, and that phantom 1px gap
49+
* re-derives 1px higher after every chase step — a visible 1px/frame upward
50+
* creep whenever the floor is what is holding `scrollHeight`.
51+
*/
52+
export function nextSizerFloor({
53+
previousHighWater,
54+
appliedFloor,
55+
contentHeight,
56+
scrollTop,
57+
clientHeight,
58+
paddingTop,
59+
paddingBottom,
60+
}: SizerFloorInput): SizerFloorResult {
61+
const highWater = Math.max(previousHighWater, contentHeight, appliedFloor)
62+
const extent = Math.max(0, Math.floor(scrollTop + clientHeight - paddingTop - paddingBottom))
63+
return { floor: Math.min(extent, highWater), highWater }
64+
}

0 commit comments

Comments
 (0)