@@ -12,6 +12,7 @@ import {
1212} from 'react'
1313import { cn } from '@sim/emcn'
1414import { defaultRangeExtractor , type Range , useVirtualizer } from '@tanstack/react-virtual'
15+ import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase'
1516import { MessageActions } from '@/app/workspace/[workspaceId]/components'
1617import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
1718import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
@@ -109,7 +110,7 @@ const UNSCROLLED = Symbol('unscrolled')
109110const LAYOUT_STYLES = {
110111 'mothership-view' : {
111112 scrollContainer :
112- 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-2 [scrollbar-gutter:stable_both-edges]' ,
113+ 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-2 [overflow-anchor:none] [ scrollbar-gutter:stable_both-edges]' ,
113114 sizer : 'relative mx-auto w-full max-w-[48rem]' ,
114115 rowGap : 'pb-6' ,
115116 userRow : 'flex flex-col items-end gap-[6px] pt-3' ,
@@ -120,7 +121,8 @@ const LAYOUT_STYLES = {
120121 footerInner : 'mx-auto max-w-[48rem]' ,
121122 } ,
122123 'copilot-view' : {
123- scrollContainer : 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4' ,
124+ scrollContainer :
125+ 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4 [overflow-anchor:none]' ,
124126 sizer : 'relative w-full' ,
125127 rowGap : 'pb-4' ,
126128 userRow : 'flex flex-col items-end gap-[6px] pt-2' ,
@@ -302,6 +304,9 @@ export function MothershipChat({
302304 const { ref : autoScrollRef } = useAutoScroll ( isStreamActive || lastRowAnimating )
303305 const sizerRef = useRef < HTMLDivElement | null > ( null )
304306 const scrollerPaddingRef = useRef < { top : number ; bottom : number } | null > ( null )
307+ const sizerFloorAppliedRef = useRef ( 0 )
308+ const floorDrainRafRef = useRef ( 0 )
309+ useEffect ( ( ) => ( ) => cancelAnimationFrame ( floorDrainRafRef . current ) , [ ] )
305310
306311 /**
307312 * Sizer floor while streaming: `scrollHeight` must never dip below the
@@ -318,16 +323,57 @@ export function MothershipChat({
318323 * Active on the same signal as auto-scroll: the reveal keeps re-parsing
319324 * markdown (and shrinking) after the network stream closes, so the floor
320325 * must hold through `lastRowAnimating` too.
326+ *
327+ * Release is DRAINED, not cliffed: while active the floor forbids
328+ * scrollHeight from dropping, so permanent shrinks over the turn accrue as
329+ * phantom space (debt). Clearing min-height in one commit released that
330+ * whole debt as a single clamp — the end-of-turn downward jump. Instead the
331+ * floor glides down to the natural size at the chase's rate; the browser's
332+ * clamp follows a few px per frame, which reads as the same eased settle as
333+ * the rest of the stream. Instant-clears when the debt is sub-pixel or the
334+ * user isn't pinned (shrinking below-viewport space is invisible then).
321335 */
322336 const floorActive = isStreamActive || lastRowAnimating
323337 useLayoutEffect ( ( ) => {
324338 const sizer = sizerRef . current
325339 const el = scrollElementRef . current
326340 if ( ! sizer || ! el ) return
327341 if ( ! floorActive ) {
328- sizer . style . minHeight = ''
342+ if ( sizerFloorAppliedRef . current === 0 ) return
343+ // A drain already in flight keeps its own rAF cadence — settle-burst
344+ // commits re-enter this branch and must not add extra steps in layout,
345+ // which would accelerate the release past the eased rate.
346+ if ( floorDrainRafRef . current !== 0 ) return
347+ scrollerPaddingRef . current = null
348+ const drain = ( ) => {
349+ const target = virtualizer . getTotalSize ( )
350+ const current = sizerFloorAppliedRef . current
351+ if ( current === 0 ) {
352+ floorDrainRafRef . current = 0
353+ return
354+ }
355+ // Instant-clear only when the whole remaining debt sits BELOW the
356+ // viewport (debt ≤ distance-from-bottom) — then the shrink is
357+ // invisible. A merely-unpinned viewport with debt larger than its
358+ // slack would still clamp, so it keeps the eased drain instead.
359+ const distance = el . scrollHeight - el . scrollTop - el . clientHeight
360+ const debt = current - target
361+ if ( debt <= 1 || debt <= distance ) {
362+ sizerFloorAppliedRef . current = 0
363+ floorDrainRafRef . current = 0
364+ sizer . style . minHeight = ''
365+ return
366+ }
367+ const next = Math . floor ( current - Math . max ( 1 , debt * SMOOTH_CHASE_RATE ) )
368+ sizerFloorAppliedRef . current = next
369+ sizer . style . minHeight = `${ next } px`
370+ floorDrainRafRef . current = requestAnimationFrame ( drain )
371+ }
372+ floorDrainRafRef . current = requestAnimationFrame ( drain )
329373 return
330374 }
375+ cancelAnimationFrame ( floorDrainRafRef . current )
376+ floorDrainRafRef . current = 0
331377 if ( ! scrollerPaddingRef . current ) {
332378 const style = getComputedStyle ( el )
333379 scrollerPaddingRef . current = {
@@ -336,7 +382,21 @@ export function MothershipChat({
336382 }
337383 }
338384 const padding = scrollerPaddingRef . current
339- const floor = Math . max ( 0 , el . scrollTop + el . clientHeight - padding . top - padding . bottom )
385+ // Math.floor, not the raw float: a fractional min-height can round
386+ // scrollHeight 1px ABOVE the scrolled-to extent, and that phantom 1px gap
387+ // re-derives 1px higher after every chase step — a visible 1px/frame
388+ // upward creep whenever the floor is what's holding scrollHeight.
389+ const floor = Math . max (
390+ 0 ,
391+ Math . floor ( el . scrollTop + el . clientHeight - padding . top - padding . bottom )
392+ )
393+ // Dead-band: the floor feeds back into its own inputs (a floored value can
394+ // land a fraction BELOW the extent, the browser clamps scrollTop, and the
395+ // next commit re-derives from the clamped position — a visible ~1px×N
396+ // downward cascade on fractional-scrollTop displays). Sub-pixel deltas are
397+ // rounding noise from that loop, never real growth; only apply real moves.
398+ if ( Math . abs ( floor - sizerFloorAppliedRef . current ) <= 1 ) return
399+ sizerFloorAppliedRef . current = floor
340400 sizer . style . minHeight = `${ floor } px`
341401 } )
342402 const setScrollElement = useCallback (
0 commit comments