diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 8a1d82acd7e..6a847e0d8b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -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(null) const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null) const sizerFloorAppliedRef = useRef(0) + const heldHighWaterRef = useRef(0) + const floorChatRef = useRef(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 diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/sizer-floor.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/sizer-floor.test.ts new file mode 100644 index 00000000000..fc277aaf64a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/sizer-floor.test.ts @@ -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) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/sizer-floor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/sizer-floor.ts new file mode 100644 index 00000000000..a856dd7a231 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/sizer-floor.ts @@ -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 } +}