From 7ab9c51767f7afd9be9ee56ea49f70183d6d9e3b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 20:34:09 -0700 Subject: [PATCH 1/2] 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. --- .../mothership-chat/mothership-chat.tsx | 31 ++++--- .../mothership-chat/sizer-floor.test.ts | 90 +++++++++++++++++++ .../components/mothership-chat/sizer-floor.ts | 64 +++++++++++++ 3 files changed, 172 insertions(+), 13 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/sizer-floor.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/sizer-floor.ts 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..bcd027db159 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 @@ -49,6 +49,7 @@ import { useAutoScroll } from '@/hooks/use-auto-scroll' import type { ChatContext } from '@/stores/panel' import { MothershipChatSkeleton } from './components/mothership-chat-skeleton' import { shouldShowAssistantMessageActions } from './message-actions-visibility' +import { nextSizerFloor } from './sizer-floor' interface MothershipChatProps { messages: ChatMessage[] @@ -328,6 +329,7 @@ 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 floorDrainRafRef = useRef(0) useEffect(() => () => cancelAnimationFrame(floorDrainRafRef.current), []) @@ -337,11 +339,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 @@ -362,6 +364,7 @@ export function MothershipChat({ const el = scrollElementRef.current if (!sizer || !el) return 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 +408,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..ad4745982b3 --- /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 './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 } +} From 0369620e1081d28d140365c80928944dfb944e31 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 20:40:34 -0700 Subject: [PATCH 2/2] fix(chat): release the transcript floor when the chat changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../mothership-chat/mothership-chat.tsx | 21 ++++++++++++++++++- .../mothership-chat/sizer-floor.test.ts | 2 +- 2 files changed, 21 insertions(+), 2 deletions(-) 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 bcd027db159..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, @@ -49,7 +50,6 @@ import { useAutoScroll } from '@/hooks/use-auto-scroll' import type { ChatContext } from '@/stores/panel' import { MothershipChatSkeleton } from './components/mothership-chat-skeleton' import { shouldShowAssistantMessageActions } from './message-actions-visibility' -import { nextSizerFloor } from './sizer-floor' interface MothershipChatProps { messages: ChatMessage[] @@ -330,6 +330,7 @@ export function MothershipChat({ 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), []) @@ -363,6 +364,24 @@ 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 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 index ad4745982b3..fc277aaf64a 100644 --- 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 @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { nextSizerFloor } from './sizer-floor' +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 }