fix(chat): stop the streaming transcript floor inventing scroll space (#6527)

* 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.

* fix(chat): release the transcript floor when the chat changes

The high-water mark and applied floor are per-turn refs on a component that
survives a chat switch, so a tall chat's mark could size a newly opened short
one for as long as the outgoing turn kept the floor engaged. Release both
outright on a chat change — the switch re-lands the viewport, so there is no
eased settle to preserve — while treating a pending chat adopting its id as
the same conversation.

Also switch the sizer-floor import to the absolute path convention.
This commit is contained in:
Waleed
2026-08-10 22:00:07 -07:00
committed by GitHub
parent ff6438964e
commit 1a39e29cfe
3 changed files with 191 additions and 13 deletions
@@ -29,6 +29,7 @@ import {
parseLastCredentialTag,
parseLastQuestionTag,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { nextSizerFloor } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/sizer-floor'
import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages'
import {
UserInput,
@@ -328,6 +329,8 @@ export function MothershipChat({
const sizerRef = useRef<HTMLDivElement | null>(null)
const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null)
const sizerFloorAppliedRef = useRef(0)
const heldHighWaterRef = useRef(0)
const floorChatRef = useRef<string | undefined>(undefined)
const floorDrainRafRef = useRef(0)
useEffect(() => () => cancelAnimationFrame(floorDrainRafRef.current), [])
@@ -337,11 +340,11 @@ export function MothershipChat({
* row-height shrinks; when they pull scrollHeight under
* `scrollTop + clientHeight`, the browser clamps `scrollTop` and the pinned
* transcript visibly drops, then the chase glides it back. Flooring the
* sizer at exactly the scrolled-to extent prevents that clamp while never
* ADDING space — the floor cannot exceed what is already on screen. So an
* estimate correction (a fresh row measuring smaller than
* ROW_HEIGHT_ESTIMATE) releases immediately instead of holding phantom space
* the chase would scroll into and bounce back out of.
* sizer prevents that clamp while never ADDING space, so an estimate
* correction (a fresh row measuring smaller than ROW_HEIGHT_ESTIMATE)
* releases immediately instead of holding phantom space the chase would
* scroll into and bounce back out of. {@link nextSizerFloor} owns the value
* and the invariant that keeps it honest.
*
* Active on the same signal as auto-scroll: the reveal keeps re-parsing
* markdown (and shrinking) after the network stream closes, so the floor
@@ -361,7 +364,26 @@ export function MothershipChat({
const sizer = sizerRef.current
const el = scrollElementRef.current
if (!sizer || !el) return
// A chat switch replaces the entire transcript, so a floor held for the
// previous one is meaningless — and its high-water mark would otherwise
// hand a short chat the tall chat's space for as long as the outgoing
// turn's `lastRowAnimating` keeps the floor engaged. Released outright
// rather than drained: the switch re-lands the viewport anyway, so there
// is no eased settle to preserve. A pending chat adopting its id is the
// SAME conversation, so it must not release mid-turn.
if (floorChatRef.current !== chatId) {
const isPendingPersist = floorChatRef.current === undefined && chatId !== undefined
floorChatRef.current = chatId
if (!isPendingPersist) {
cancelAnimationFrame(floorDrainRafRef.current)
floorDrainRafRef.current = 0
sizerFloorAppliedRef.current = 0
heldHighWaterRef.current = 0
sizer.style.minHeight = ''
}
}
if (!floorActive) {
heldHighWaterRef.current = 0
if (sizerFloorAppliedRef.current === 0) return
// A drain already in flight keeps its own rAF cadence — settle-burst
// commits re-enter this branch and must not add extra steps in layout,
@@ -405,14 +427,16 @@ export function MothershipChat({
}
}
const padding = scrollerPaddingRef.current
// Math.floor, not the raw float: a fractional min-height can round
// scrollHeight 1px ABOVE the scrolled-to extent, and that phantom 1px gap
// re-derives 1px higher after every chase step — a visible 1px/frame
// upward creep whenever the floor is what's holding scrollHeight.
const floor = Math.max(
0,
Math.floor(el.scrollTop + el.clientHeight - padding.top - padding.bottom)
)
const { floor, highWater } = nextSizerFloor({
previousHighWater: heldHighWaterRef.current,
appliedFloor: sizerFloorAppliedRef.current,
contentHeight: virtualizer.getTotalSize(),
scrollTop: el.scrollTop,
clientHeight: el.clientHeight,
paddingTop: padding.top,
paddingBottom: padding.bottom,
})
heldHighWaterRef.current = highWater
// Dead-band: the floor feeds back into its own inputs (a floored value can
// land a fraction BELOW the extent, the browser clamps scrollTop, and the
// next commit re-derives from the clamped position — a visible ~1px×N
@@ -0,0 +1,90 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { nextSizerFloor } from '@/app/workspace/[workspaceId]/home/components/mothership-chat/sizer-floor'
/** Matches the transcript scroller's `pt-4 pb-2`. */
const PADDING = { paddingTop: 16, paddingBottom: 8 }
/** A viewport scrolled to the bottom of 2000px of content: extent resolves to 2000. */
const PINNED = { scrollTop: 1424, clientHeight: 600, ...PADDING }
describe('nextSizerFloor', () => {
it('follows the scrolled-to extent while it stays under the high-water mark', () => {
const { floor } = nextSizerFloor({
...PINNED,
previousHighWater: 5000,
appliedFloor: 0,
contentHeight: 5000,
})
expect(floor).toBe(2000)
})
it('floors fractional scroll offsets down so the sizer cannot round above the extent', () => {
const { floor } = nextSizerFloor({
scrollTop: 100.7,
clientHeight: 600,
...PADDING,
previousHighWater: 5000,
appliedFloor: 0,
contentHeight: 5000,
})
expect(floor).toBe(676)
})
it('never returns a negative floor for a container smaller than its padding', () => {
const { floor } = nextSizerFloor({
scrollTop: 0,
clientHeight: 8,
...PADDING,
previousHighWater: 500,
appliedFloor: 0,
contentHeight: 500,
})
expect(floor).toBe(0)
})
it('never exceeds the content height when the transcript is shorter than the viewport', () => {
const { floor } = nextSizerFloor({
scrollTop: 0,
clientHeight: 600,
...PADDING,
previousHighWater: 0,
appliedFloor: 0,
contentHeight: 180,
})
expect(floor).toBe(180)
})
it('holds the high-water mark when content re-measures smaller mid-turn', () => {
const { floor, highWater } = nextSizerFloor({
...PINNED,
previousHighWater: 2000,
appliedFloor: 2000,
contentHeight: 1940,
})
expect(highWater).toBe(2000)
expect(floor).toBe(2000)
})
it('carries undrained debt across a turn boundary that interrupts the drain', () => {
const { floor, highWater } = nextSizerFloor({
...PINNED,
previousHighWater: 0,
appliedFloor: 1985,
contentHeight: 1940,
})
expect(highWater).toBe(1985)
expect(floor).toBe(1985)
})
it('raises the high-water mark as content grows', () => {
const { highWater } = nextSizerFloor({
...PINNED,
previousHighWater: 1200,
appliedFloor: 1200,
contentHeight: 1600,
})
expect(highWater).toBe(1600)
})
})
@@ -0,0 +1,64 @@
interface SizerFloorInput {
/** High-water mark carried from the previous commit of this turn. */
previousHighWater: number
/** Floor currently written to the sizer, including one a drain has not finished releasing. */
appliedFloor: number
/** Natural sizer height — `virtualizer.getTotalSize()`. */
contentHeight: number
scrollTop: number
clientHeight: number
paddingTop: number
paddingBottom: number
}
interface SizerFloorResult {
/** Floor to write to the sizer's `min-height`. */
floor: number
/** High-water mark to carry into the next commit. */
highWater: number
}
/**
* Floor height for the transcript sizer while a turn streams — the value that
* keeps `scrollHeight` from dipping below the scrolled-to extent when a row
* transiently re-measures smaller — together with the high-water mark that
* bounds it.
*
* The extent (`scrollTop + clientHeight`) is the viewport's bottom edge, which
* sits BELOW the content whenever the transcript is shorter than the viewport —
* early in a turn, or in any short chat. Flooring at the raw extent invents
* scrollable space no content occupies, which stays invisible only while the
* container keeps its height. The moment it shrinks mid-turn — the composer
* growing, the queued-message banner appearing, a window or panel resize — that
* space becomes real scrollable room, the bottom-pin scrolls into it, and the
* transcript is dragged upward until the floor drains at the end of the turn.
*
* The high-water mark is what the extent is clamped to, and it folds in the
* APPLIED floor as well as the live content height. Both terms are load-bearing:
*
* - Live content alone would release the debt on the very commit that created
* it, since holding space a shrink just took away is the floor's whole purpose.
* - Ignoring the applied floor would dump undrained debt in a single frame when
* a queued message re-engages the floor mid-drain — the end-of-turn jump the
* eased drain exists to prevent.
*
* Together they say: never exceed the space content has actually held this turn.
*
* `Math.floor`, not the raw float: a fractional min-height can round
* `scrollHeight` 1px ABOVE the scrolled-to extent, and that phantom 1px gap
* re-derives 1px higher after every chase step — a visible 1px/frame upward
* creep whenever the floor is what is holding `scrollHeight`.
*/
export function nextSizerFloor({
previousHighWater,
appliedFloor,
contentHeight,
scrollTop,
clientHeight,
paddingTop,
paddingBottom,
}: SizerFloorInput): SizerFloorResult {
const highWater = Math.max(previousHighWater, contentHeight, appliedFloor)
const extent = Math.max(0, Math.floor(scrollTop + clientHeight - paddingTop - paddingBottom))
return { floor: Math.min(extent, highWater), highWater }
}