Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Comment thread
waleedlatif1 marked this conversation as resolved.
const floorChatRef = useRef<string | undefined>(undefined)
const floorDrainRafRef = useRef(0)
useEffect(() => () => cancelAnimationFrame(floorDrainRafRef.current), [])

Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading