mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
+37
-13
@@ -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
|
||||
|
||||
+90
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user