Skip to content

Commit c9d7e7c

Browse files
improvement(mothership): net-zero settle — equal tail regions, drained floor, one growth signal
1 parent e7ea9db commit c9d7e7c

4 files changed

Lines changed: 74 additions & 93 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ import { deriveMessagePhase, isToolDone, type MessagePhase } from './utils'
3030
const FILE_SUBAGENT_ID = 'file'
3131
/** Quiet period before the shimmer takes the slot back from streamed output. */
3232
const STREAM_IDLE_DELAY_MS = 1_500
33+
/**
34+
* The one vertical extent (10px gap + 36px row) every tail-region occupant —
35+
* shimmer slot, actions row, stopped row — must share. The settle swap is only
36+
* jump-free because these are equal; changing one side without the others
37+
* reintroduces a scroll clamp at end of turn.
38+
*/
39+
const TAIL_REGION_CLASSES = 'mt-[10px] flex h-[36px] items-center'
3340

3441
interface TextSegment {
3542
type: 'text'
@@ -956,8 +963,8 @@ function MessageContentInner({
956963
// Fixed-height placeholder for the NEXT piece of output: the shimmer
957964
// and arriving output trade places via opacity only, so mid-turn swaps
958965
// can't move layout. A sibling of the space-y stack (not a child), so
959-
// it carries no stray sibling margin — pt-[10px] is its own gap.
960-
<div aria-hidden={!showShimmer} className='pt-[10px]'>
966+
// it carries no stray sibling margin.
967+
<div aria-hidden={!showShimmer} className={TAIL_REGION_CLASSES}>
961968
<div
962969
className={cn(
963970
'transition-opacity duration-200 ease-out',
@@ -968,17 +975,17 @@ function MessageContentInner({
968975
</div>
969976
</div>
970977
) : (
971-
// Stopped row and actions take the slot's place in the SAME render — a
972-
// single small reflow instead of a collapse the buttons would ride
973-
// upward or a late mount the chase would visibly scroll to.
978+
// Stopped row and actions take the slot's place in the SAME render and
979+
// at the SAME extent (TAIL_REGION_CLASSES), so the swap is height-
980+
// neutral by construction — no reflow for the pinned scroller to absorb.
974981
<>
975982
{lastSegment?.type === 'stopped' && (
976-
<div className='flex items-center gap-[8px] pt-[10px]'>
983+
<div className={cn(TAIL_REGION_CLASSES, 'gap-[8px]')}>
977984
<CircleStop className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
978985
<span className='text-[14px] text-[var(--text-body)]'>Stopped by user</span>
979986
</div>
980987
)}
981-
{actions && <div className='mt-[10px]'>{actions}</div>}
988+
{actions && <div className={TAIL_REGION_CLASSES}>{actions}</div>}
982989
</>
983990
)}
984991
</div>

apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from 'react'
1313
import { cn } from '@sim/emcn'
1414
import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual'
15+
import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase'
1516
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
1617
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
1718
import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
@@ -109,7 +110,7 @@ const UNSCROLLED = Symbol('unscrolled')
109110
const 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',
@@ -315,6 +317,7 @@ export function MothershipChat({
315317
const sizerRef = useRef<HTMLDivElement | null>(null)
316318
const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null)
317319
const sizerFloorAppliedRef = useRef(0)
320+
const floorDrainRafRef = useRef(0)
318321

319322
/**
320323
* Sizer floor while streaming: `scrollHeight` must never dip below the
@@ -331,17 +334,44 @@ export function MothershipChat({
331334
* Active on the same signal as auto-scroll: the reveal keeps re-parsing
332335
* markdown (and shrinking) after the network stream closes, so the floor
333336
* must hold through `lastRowAnimating` too.
337+
*
338+
* Release is DRAINED, not cliffed: while active the floor forbids
339+
* scrollHeight from dropping, so permanent shrinks over the turn accrue as
340+
* phantom space (debt). Clearing min-height in one commit released that
341+
* whole debt as a single clamp — the end-of-turn downward jump. Instead the
342+
* floor glides down to the natural size at the chase's rate; the browser's
343+
* clamp follows a few px per frame, which reads as the same eased settle as
344+
* the rest of the stream. Instant-clears when the debt is sub-pixel or the
345+
* user isn't pinned (shrinking below-viewport space is invisible then).
334346
*/
335347
const floorActive = isStreamActive || lastRowAnimating
336348
useLayoutEffect(() => {
337349
const sizer = sizerRef.current
338350
const el = scrollElementRef.current
339351
if (!sizer || !el) return
340352
if (!floorActive) {
341-
sizerFloorAppliedRef.current = 0
342-
sizer.style.minHeight = ''
353+
if (sizerFloorAppliedRef.current === 0) return
354+
scrollerPaddingRef.current = null
355+
const pinned = el.scrollHeight - el.scrollTop - el.clientHeight <= 2
356+
cancelAnimationFrame(floorDrainRafRef.current)
357+
const drain = () => {
358+
const target = virtualizer.getTotalSize()
359+
const current = sizerFloorAppliedRef.current
360+
if (current === 0) return
361+
const next = Math.floor(current - Math.max(1, (current - target) * SMOOTH_CHASE_RATE))
362+
if (!pinned || next - target <= 1) {
363+
sizerFloorAppliedRef.current = 0
364+
sizer.style.minHeight = ''
365+
return
366+
}
367+
sizerFloorAppliedRef.current = next
368+
sizer.style.minHeight = `${next}px`
369+
floorDrainRafRef.current = requestAnimationFrame(drain)
370+
}
371+
drain()
343372
return
344373
}
374+
cancelAnimationFrame(floorDrainRafRef.current)
345375
if (!scrollerPaddingRef.current) {
346376
const style = getComputedStyle(el)
347377
scrollerPaddingRef.current = {

apps/sim/hooks/use-auto-scroll.ts

Lines changed: 24 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,6 @@ const USER_GESTURE_WINDOW = 250
2525
* in the listener) is the other upward shortcut; plain `Space` pages down.
2626
*/
2727
const SCROLL_UP_KEYS = new Set(['ArrowUp', 'PageUp', 'Home'])
28-
/** How long to keep chasing the bottom while a CSS height animation plays. */
29-
const ANIMATION_FOLLOW_WINDOW = 500
30-
/**
31-
* How long to keep chasing the bottom after streaming stops. End-of-turn content
32-
* mounts just after `isStreaming` flips false — the suggested-follow-up options,
33-
* the actions row (swapped into the thinking slot's place), and the
34-
* virtualizer's re-measure of the grown row — so a single final scroll fires
35-
* before it lays out and leaves it clipped behind the input. Following for a
36-
* short window pulls it into view.
37-
*/
38-
const POST_STREAM_SETTLE_WINDOW = 300
39-
40-
interface UseAutoScrollOptions {
41-
scrollOnMount?: boolean
42-
}
43-
4428
/**
4529
* Manages sticky auto-scroll for a streaming chat container.
4630
*
@@ -50,23 +34,18 @@ interface UseAutoScrollOptions {
5034
* of the bottom to re-engage. Each streaming start re-seeds stickiness from the
5135
* current scroll position, so a user who scrolled up beforehand stays put.
5236
*
53-
* Returns `ref` (callback ref for the scroll container), `scrollToBottom` for
54-
* imperative use after layout-changing events like panel expansion, and
55-
* `detach` for programmatic freezes (a user stop) — it parks every chase path
56-
* exactly like a user scroll-away, until the user scrolls back to the bottom
57-
* or the next stream re-seeds stickiness.
37+
* Returns `ref` (callback ref for the scroll container) and `detach` for
38+
* programmatic freezes (a user stop) — it parks every chase path exactly like
39+
* a user scroll-away, until the user scrolls back to the bottom or the next
40+
* stream re-seeds stickiness.
5841
*/
59-
export function useAutoScroll(
60-
isStreaming: boolean,
61-
{ scrollOnMount = false }: UseAutoScrollOptions = {}
62-
) {
42+
export function useAutoScroll(isStreaming: boolean) {
6343
const containerRef = useRef<HTMLDivElement>(null)
6444
const stickyRef = useRef(true)
6545
const userDetachedRef = useRef(false)
6646
const prevScrollTopRef = useRef(0)
6747
const prevScrollHeightRef = useRef(0)
6848
const touchStartYRef = useRef(0)
69-
const scrollOnMountRef = useRef(scrollOnMount)
7049
/**
7150
* Whether the user is actively dragging the scrollbar — a pointer press on the
7251
* container itself rather than its content. Reset on teardown so a pointer held
@@ -80,20 +59,13 @@ export function useAutoScroll(
8059
*/
8160
const lastUserGestureAtRef = useRef(Number.NEGATIVE_INFINITY)
8261

83-
const scrollToBottom = useCallback(() => {
84-
const el = containerRef.current
85-
if (!el) return
86-
el.scrollTop = el.scrollHeight
87-
}, [])
88-
8962
const detach = useCallback(() => {
9063
stickyRef.current = false
9164
userDetachedRef.current = true
9265
}, [])
9366

9467
const callbackRef = useCallback((el: HTMLDivElement | null) => {
9568
containerRef.current = el
96-
if (el && scrollOnMountRef.current) el.scrollTop = el.scrollHeight
9769
}, [])
9870

9971
useEffect(() => {
@@ -198,42 +170,38 @@ export function useAutoScroll(
198170
prevScrollHeightRef.current = scrollHeight
199171
}
200172

201-
const onMutation = () => {
173+
/**
174+
* The single growth signal: the transcript sizer's height. Every source of
175+
* scrollHeight growth flows through it — virtualizer re-measures per
176+
* streamed token, CSS height animations (each frame re-measures the row),
177+
* the sizer min-height floor — so one ResizeObserver replaces a subtree
178+
* MutationObserver plus an `animationstart` deadline machine, and there is
179+
* exactly one reason the chase ever runs.
180+
*/
181+
const sizer = el.firstElementChild
182+
const onSizerResize = () => {
202183
prevScrollHeightRef.current = el.scrollHeight
203184
if (!stickyRef.current) return
204185
chase.kick()
205186
}
206187

207-
/**
208-
* CSS-driven height animations (e.g. Radix Collapsible expanding mid-stream)
209-
* grow scrollHeight without triggering MutationObserver, so auto-scroll stops
210-
* following. Keep the one chase loop alive for a short window so the
211-
* container stays pinned while the animation runs. `animationstart` fires
212-
* for every child animation in the transcript (segment fade-ins, loader
213-
* keyframes, label crossfades) — kickUntil coalesces them into a single
214-
* extended deadline on the single loop; anything more snaps the glide.
215-
*/
216-
const onAnimationStart = () => chase.kickUntil(ANIMATION_FOLLOW_WINDOW)
217-
218188
el.addEventListener('wheel', onWheel, { passive: true })
219189
el.addEventListener('touchstart', onTouchStart, { passive: true })
220190
el.addEventListener('touchmove', onTouchMove, { passive: true })
221191
el.addEventListener('scroll', onScroll, { passive: true })
222-
el.addEventListener('animationstart', onAnimationStart)
223192
el.addEventListener('pointerdown', onPointerDown, { passive: true })
224193
el.addEventListener('keydown', onKeyDown, { passive: true })
225194
window.addEventListener('pointerup', onPointerUp, { passive: true })
226195
window.addEventListener('pointercancel', onPointerUp, { passive: true })
227196

228-
const observer = new MutationObserver(onMutation)
229-
observer.observe(el, { childList: true, subtree: true, characterData: true })
197+
const observer = new ResizeObserver(onSizerResize)
198+
if (sizer) observer.observe(sizer)
230199

231200
return () => {
232201
el.removeEventListener('wheel', onWheel)
233202
el.removeEventListener('touchstart', onTouchStart)
234203
el.removeEventListener('touchmove', onTouchMove)
235204
el.removeEventListener('scroll', onScroll)
236-
el.removeEventListener('animationstart', onAnimationStart)
237205
el.removeEventListener('pointerdown', onPointerDown)
238206
el.removeEventListener('keydown', onKeyDown)
239207
window.removeEventListener('pointerup', onPointerUp)
@@ -242,13 +210,13 @@ export function useAutoScroll(
242210
chase.cancel()
243211
pointerDownRef.current = false
244212
lastUserGestureAtRef.current = Number.NEGATIVE_INFINITY
245-
// End-of-turn content mounts just after teardown; follow it briefly. The
246-
// chase's own upward-move interrupt still protects a real user scroll
247-
// even with the gesture listeners gone, and the per-frame sticky check
248-
// makes this a no-op after a detach().
249-
chase.kickUntil(POST_STREAM_SETTLE_WINDOW)
213+
// Teardown can land mid-glide (options mounted late in the reveal, gap
214+
// not yet closed) — canceling there strands the follow-ups behind the
215+
// input. One plain kick runs the loop to rest and parks; a stopped turn
216+
// stays frozen because the detached sticky check parks it on frame one.
217+
chase.kick()
250218
}
251-
}, [isStreaming, scrollToBottom])
219+
}, [isStreaming])
252220

253-
return { ref: callbackRef, scrollToBottom, detach }
221+
return { ref: callbackRef, detach }
254222
}

apps/sim/lib/core/utils/smooth-bottom-chase.ts

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,6 @@ export interface SmoothBottomChaseHandle {
2626
isActive: () => boolean
2727
/** Start the loop if parked. Call after content growth. */
2828
kick: () => void
29-
/**
30-
* Keep the loop alive for `durationMs` even while the gap is at rest,
31-
* re-checking every frame. Covers growth that arrives over several frames
32-
* with no observable trigger — a CSS height animation, or a virtualizer
33-
* re-measure settling after streaming stops. Repeat calls extend the
34-
* deadline; there is never more than one loop.
35-
*/
36-
kickUntil: (durationMs: number) => void
3729
cancel: () => void
3830
}
3931

@@ -55,15 +47,11 @@ export function createSmoothBottomChase(
5547
): SmoothBottomChaseHandle {
5648
let raf: number | null = null
5749
let lastTop: number | null = null
58-
let deadline = 0
5950

6051
const park = () => {
6152
if (raf !== null) cancelAnimationFrame(raf)
6253
raf = null
6354
lastTop = null
64-
// A stale deadline must not leak into a later plain kick() — kick alone
65-
// parks at rest, only a live kickUntil window idles through it.
66-
deadline = 0
6755
}
6856

6957
const step = () => {
@@ -83,14 +71,7 @@ export function createSmoothBottomChase(
8371
}
8472
const gap = target.getBottomTop() - top
8573
if (gap <= CHASE_REST_GAP) {
86-
// Within a kickUntil deadline the loop idles at rest instead of parking,
87-
// so growth in the deadline window is chased without a fresh trigger.
88-
if (performance.now() >= deadline) {
89-
park()
90-
return
91-
}
92-
lastTop = top
93-
raf = requestAnimationFrame(step)
74+
park()
9475
return
9576
}
9677
target.setTop(top + Math.max(1, gap * SMOOTH_CHASE_RATE))
@@ -105,8 +86,7 @@ export function createSmoothBottomChase(
10586
* Seed the upward-move interrupt baseline at (re)start so a user scroll-up
10687
* between the kick and the first frame parks the loop immediately — without
10788
* it the first step has no baseline and writes one downward frame against
108-
* the user (relevant on the teardown kickUntil, where the gesture listeners
109-
* are already gone).
89+
* the user.
11090
*/
11191
const start = () => {
11292
if (raf !== null) return
@@ -117,10 +97,6 @@ export function createSmoothBottomChase(
11797
return {
11898
isActive: () => raf !== null,
11999
kick: start,
120-
kickUntil: (durationMs: number) => {
121-
deadline = Math.max(deadline, performance.now() + durationMs)
122-
start()
123-
},
124100
cancel: park,
125101
}
126102
}

0 commit comments

Comments
 (0)