fix(mothership): stop-button transitions freeze in place (#5838)

* fix(mothership): freeze the transcript on user stop instead of settle-scrolling

* fix(mothership): swap stopped row into the shimmer slot and floor the sizer min-height

* fix(mothership): detach auto-scroll on stop and dead-band the sizer floor

* improvement(mothership): net-zero settle — equal tail regions, drained floor, one growth signal

* fix(mothership): follow the post-stop drain to the end instead of freezing

* fix(mothership): single stopped tail region and drain cleanup

* fix(mothership): clamp-aware chase interrupt so the floor drain can't park the settle follow

* fix(mothership): park the chase only on real upward top moves, not growth

* improvement(mothership): stack the stopped status above the actions row

* improvement(mothership): compact stopped-turn tail with the original 10px rhythm

* fix(mothership): single drain cadence and a settle-window gesture kill switch

* fix(mothership): debt-aware drain fast-path and settle-window handle retention
This commit is contained in:
Theodore Li
2026-07-22 16:53:35 -04:00
committed by GitHub
parent b49fe16dae
commit d7d42faff8
4 changed files with 191 additions and 86 deletions
@@ -30,6 +30,14 @@ import { deriveMessagePhase, isToolDone, type MessagePhase } from './utils'
const FILE_SUBAGENT_ID = 'file'
/** Quiet period before the shimmer takes the slot back from streamed output. */
const STREAM_IDLE_DELAY_MS = 1_500
/**
* The vertical extent (10px gap + 36px row) shared by the shimmer slot and the
* actions row that replaces it at settle. The swap is only jump-free because
* these are equal; changing one side without the other reintroduces a scroll
* clamp at end of turn. (A stopped turn's stacked rows are exempt — their
* extra height is glided-in growth, not a swap.)
*/
const TAIL_REGION_CLASSES = 'mt-[10px] flex h-[36px] items-center'
interface TextSegment {
type: 'text'
@@ -853,8 +861,16 @@ function MessageContentInner({
}, [visibleStreamActivityKey, isStreaming])
const lastSegment = segments[segments.length - 1]
const hasTrailingTextSegment = lastSegment?.type === 'text'
const isRevealing = hasTrailingTextSegment && trailingRevealing
// The reveal tail is the last TEXT segment — a stopped block appends AFTER
// the text that is still visibly draining, and treating the turn as settled
// the moment it lands tears down the scroll machinery mid-reveal.
const revealTailIndex =
lastSegment?.type === 'stopped' && segments[segments.length - 2]?.type === 'text'
? segments.length - 2
: lastSegment?.type === 'text'
? segments.length - 1
: -1
const isRevealing = revealTailIndex >= 0 && trailingRevealing
const phase = deriveMessagePhase({ isStreaming, isRevealing })
const onPhaseChangeRef = useRef(onPhaseChange)
@@ -905,13 +921,13 @@ function MessageContentInner({
onQuestionDismiss={onQuestionDismiss}
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
onRevealStateChange={
i === segments.length - 1 ? handleTrailingRevealChange : undefined
i === revealTailIndex ? handleTrailingRevealChange : undefined
}
onStreamActivityChange={
i === segments.length - 1 ? handleTrailingStreamActivityChange : undefined
i === revealTailIndex ? handleTrailingStreamActivityChange : undefined
}
onPendingTagChange={
i === segments.length - 1 ? handleTrailingPendingTagChange : undefined
i === revealTailIndex ? handleTrailingPendingTagChange : undefined
}
/>
)
@@ -943,13 +959,12 @@ function MessageContentInner({
<Options items={segment.items} onSelect={onOptionSelect} />
</div>
)
// The stopped row renders in the tail region below, in the
// shimmer's place — a stop while the shimmer is visible must read
// as an in-place replacement, not the shimmer vanishing from the
// tail while a row mounts up here.
case 'stopped':
return (
<div key={`stopped-${i}`} className='flex items-center gap-[8px]'>
<CircleStop className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='text-[14px] text-[var(--text-body)]'>Stopped by user</span>
</div>
)
return null
}
})}
</div>
@@ -957,8 +972,8 @@ function MessageContentInner({
// Fixed-height placeholder for the NEXT piece of output: the shimmer
// and arriving output trade places via opacity only, so mid-turn swaps
// can't move layout. A sibling of the space-y stack (not a child), so
// it carries no stray sibling margin — pt-[10px] is its own gap.
<div aria-hidden={!showShimmer} className='pt-[10px]'>
// it carries no stray sibling margin.
<div aria-hidden={!showShimmer} className={TAIL_REGION_CLASSES}>
<div
className={cn(
'transition-opacity duration-200 ease-out',
@@ -968,11 +983,22 @@ function MessageContentInner({
<PendingTagIndicator label={thinkingLabel ?? 'Thinking…'} />
</div>
</div>
) : // The settled tail takes the slot's place in the SAME render and at the
// SAME extent (TAIL_REGION_CLASSES), so the swap is height-neutral by
// construction — no reflow for the pinned scroller to absorb. A stopped
// turn instead stacks compact natural rows (10px gaps, no 36px boxes):
// its extra height is glided-in growth either way, so only the
// shimmer-swap occupant needs the fixed extent.
lastSegment?.type === 'stopped' ? (
<>
<div className='mt-[10px] flex items-center gap-[8px]'>
<CircleStop className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='text-[14px] text-[var(--text-body)]'>Stopped by user</span>
</div>
{actions && <div className='mt-[10px]'>{actions}</div>}
</>
) : (
// The actions row takes the slot's place in the SAME render — a single
// ~10px reflow instead of a collapse the buttons would ride upward or a
// late mount the chase would visibly scroll to.
actions && <div className='mt-2.5'>{actions}</div>
actions && <div className={TAIL_REGION_CLASSES}>{actions}</div>
)}
</div>
)
@@ -12,6 +12,7 @@ import {
} from 'react'
import { cn } from '@sim/emcn'
import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual'
import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase'
import { MessageActions } from '@/app/workspace/[workspaceId]/components'
import { ChatMessageAttachments } from '@/app/workspace/[workspaceId]/home/components/chat-message-attachments'
import { ChatSurfaceProvider } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
@@ -109,7 +110,7 @@ const UNSCROLLED = Symbol('unscrolled')
const LAYOUT_STYLES = {
'mothership-view': {
scrollContainer:
'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-2 [scrollbar-gutter:stable_both-edges]',
'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]',
sizer: 'relative mx-auto w-full max-w-[48rem]',
rowGap: 'pb-6',
userRow: 'flex flex-col items-end gap-[6px] pt-3',
@@ -120,7 +121,8 @@ const LAYOUT_STYLES = {
footerInner: 'mx-auto max-w-[48rem]',
},
'copilot-view': {
scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4',
scrollContainer:
'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4 [overflow-anchor:none]',
sizer: 'relative w-full',
rowGap: 'pb-4',
userRow: 'flex flex-col items-end gap-[6px] pt-2',
@@ -302,6 +304,9 @@ export function MothershipChat({
const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating)
const sizerRef = useRef<HTMLDivElement | null>(null)
const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null)
const sizerFloorAppliedRef = useRef(0)
const floorDrainRafRef = useRef(0)
useEffect(() => () => cancelAnimationFrame(floorDrainRafRef.current), [])
/**
* Sizer floor while streaming: `scrollHeight` must never dip below the
@@ -318,6 +323,15 @@ export function MothershipChat({
* Active on the same signal as auto-scroll: the reveal keeps re-parsing
* markdown (and shrinking) after the network stream closes, so the floor
* must hold through `lastRowAnimating` too.
*
* Release is DRAINED, not cliffed: while active the floor forbids
* scrollHeight from dropping, so permanent shrinks over the turn accrue as
* phantom space (debt). Clearing min-height in one commit released that
* whole debt as a single clamp — the end-of-turn downward jump. Instead the
* floor glides down to the natural size at the chase's rate; the browser's
* clamp follows a few px per frame, which reads as the same eased settle as
* the rest of the stream. Instant-clears when the debt is sub-pixel or the
* user isn't pinned (shrinking below-viewport space is invisible then).
*/
const floorActive = isStreamActive || lastRowAnimating
useLayoutEffect(() => {
@@ -325,9 +339,41 @@ export function MothershipChat({
const el = scrollElementRef.current
if (!sizer || !el) return
if (!floorActive) {
sizer.style.minHeight = ''
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,
// which would accelerate the release past the eased rate.
if (floorDrainRafRef.current !== 0) return
scrollerPaddingRef.current = null
const drain = () => {
const target = virtualizer.getTotalSize()
const current = sizerFloorAppliedRef.current
if (current === 0) {
floorDrainRafRef.current = 0
return
}
// Instant-clear only when the whole remaining debt sits BELOW the
// viewport (debt ≤ distance-from-bottom) — then the shrink is
// invisible. A merely-unpinned viewport with debt larger than its
// slack would still clamp, so it keeps the eased drain instead.
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
const debt = current - target
if (debt <= 1 || debt <= distance) {
sizerFloorAppliedRef.current = 0
floorDrainRafRef.current = 0
sizer.style.minHeight = ''
return
}
const next = Math.floor(current - Math.max(1, debt * SMOOTH_CHASE_RATE))
sizerFloorAppliedRef.current = next
sizer.style.minHeight = `${next}px`
floorDrainRafRef.current = requestAnimationFrame(drain)
}
floorDrainRafRef.current = requestAnimationFrame(drain)
return
}
cancelAnimationFrame(floorDrainRafRef.current)
floorDrainRafRef.current = 0
if (!scrollerPaddingRef.current) {
const style = getComputedStyle(el)
scrollerPaddingRef.current = {
@@ -336,7 +382,21 @@ export function MothershipChat({
}
}
const padding = scrollerPaddingRef.current
const floor = Math.max(0, el.scrollTop + el.clientHeight - padding.top - padding.bottom)
// 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)
)
// 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
// downward cascade on fractional-scrollTop displays). Sub-pixel deltas are
// rounding noise from that loop, never real growth; only apply real moves.
if (Math.abs(floor - sizerFloorAppliedRef.current) <= 1) return
sizerFloorAppliedRef.current = floor
sizer.style.minHeight = `${floor}px`
})
const setScrollElement = useCallback(
+60 -50
View File
@@ -25,22 +25,12 @@ const USER_GESTURE_WINDOW = 250
* in the listener) is the other upward shortcut; plain `Space` pages down.
*/
const SCROLL_UP_KEYS = new Set(['ArrowUp', 'PageUp', 'Home'])
/** How long to keep chasing the bottom while a CSS height animation plays. */
const ANIMATION_FOLLOW_WINDOW = 500
/**
* How long to keep chasing the bottom after streaming stops. End-of-turn content
* mounts just after `isStreaming` flips false — the suggested-follow-up options,
* the actions row (swapped into the thinking slot's place), and the
* virtualizer's re-measure of the grown row — so a single final scroll fires
* before it lays out and leaves it clipped behind the input. Following for a
* short window pulls it into view.
* How long the chase idle-follows after stream teardown. Covers content that
* mounts with the observers already gone — a stop's stopped-row and actions
* append only after the abort round-trips.
*/
const POST_STREAM_SETTLE_WINDOW = 300
interface UseAutoScrollOptions {
scrollOnMount?: boolean
}
const POST_STOP_SETTLE_WINDOW = 800
/**
* Manages sticky auto-scroll for a streaming chat container.
*
@@ -50,20 +40,15 @@ interface UseAutoScrollOptions {
* of the bottom to re-engage. Each streaming start re-seeds stickiness from the
* current scroll position, so a user who scrolled up beforehand stays put.
*
* Returns `ref` (callback ref for the scroll container) and `scrollToBottom`
* for imperative use after layout-changing events like panel expansion.
* Returns `ref`, the callback ref for the scroll container.
*/
export function useAutoScroll(
isStreaming: boolean,
{ scrollOnMount = false }: UseAutoScrollOptions = {}
) {
export function useAutoScroll(isStreaming: boolean) {
const containerRef = useRef<HTMLDivElement>(null)
const stickyRef = useRef(true)
const userDetachedRef = useRef(false)
const prevScrollTopRef = useRef(0)
const prevScrollHeightRef = useRef(0)
const touchStartYRef = useRef(0)
const scrollOnMountRef = useRef(scrollOnMount)
/**
* Whether the user is actively dragging the scrollbar — a pointer press on the
* container itself rather than its content. Reset on teardown so a pointer held
@@ -77,21 +62,25 @@ export function useAutoScroll(
*/
const lastUserGestureAtRef = useRef(Number.NEGATIVE_INFINITY)
const scrollToBottom = useCallback(() => {
const el = containerRef.current
if (!el) return
el.scrollTop = el.scrollHeight
}, [])
const callbackRef = useCallback((el: HTMLDivElement | null) => {
containerRef.current = el
if (el && scrollOnMountRef.current) el.scrollTop = el.scrollHeight
}, [])
/**
* Cancels the previous teardown's settle window (chase, temp listeners,
* removal timer). Invoked when a new stream starts — a stop-then-resend must
* not leave the old settle chase writing beside the new stream's chase — and
* on unmount.
*/
const settleCleanupRef = useRef<(() => void) | null>(null)
useEffect(() => () => settleCleanupRef.current?.(), [])
useEffect(() => {
if (!isStreaming) return
const el = containerRef.current
if (!el) return
settleCleanupRef.current?.()
settleCleanupRef.current = null
/**
* Eased bottom-chase shared by the mutation observer and the seed below —
@@ -190,42 +179,38 @@ export function useAutoScroll(
prevScrollHeightRef.current = scrollHeight
}
const onMutation = () => {
/**
* The single growth signal: the transcript sizer's height. Every source of
* scrollHeight growth flows through it — virtualizer re-measures per
* streamed token, CSS height animations (each frame re-measures the row),
* the sizer min-height floor — so one ResizeObserver replaces a subtree
* MutationObserver plus an `animationstart` deadline machine, and there is
* exactly one reason the chase ever runs.
*/
const sizer = el.firstElementChild
const onSizerResize = () => {
prevScrollHeightRef.current = el.scrollHeight
if (!stickyRef.current) return
chase.kick()
}
/**
* CSS-driven height animations (e.g. Radix Collapsible expanding mid-stream)
* grow scrollHeight without triggering MutationObserver, so auto-scroll stops
* following. Keep the one chase loop alive for a short window so the
* container stays pinned while the animation runs. `animationstart` fires
* for every child animation in the transcript (segment fade-ins, loader
* keyframes, label crossfades) — kickUntil coalesces them into a single
* extended deadline on the single loop; anything more snaps the glide.
*/
const onAnimationStart = () => chase.kickUntil(ANIMATION_FOLLOW_WINDOW)
el.addEventListener('wheel', onWheel, { passive: true })
el.addEventListener('touchstart', onTouchStart, { passive: true })
el.addEventListener('touchmove', onTouchMove, { passive: true })
el.addEventListener('scroll', onScroll, { passive: true })
el.addEventListener('animationstart', onAnimationStart)
el.addEventListener('pointerdown', onPointerDown, { passive: true })
el.addEventListener('keydown', onKeyDown, { passive: true })
window.addEventListener('pointerup', onPointerUp, { passive: true })
window.addEventListener('pointercancel', onPointerUp, { passive: true })
const observer = new MutationObserver(onMutation)
observer.observe(el, { childList: true, subtree: true, characterData: true })
const observer = new ResizeObserver(onSizerResize)
if (sizer) observer.observe(sizer)
return () => {
el.removeEventListener('wheel', onWheel)
el.removeEventListener('touchstart', onTouchStart)
el.removeEventListener('touchmove', onTouchMove)
el.removeEventListener('scroll', onScroll)
el.removeEventListener('animationstart', onAnimationStart)
el.removeEventListener('pointerdown', onPointerDown)
el.removeEventListener('keydown', onKeyDown)
window.removeEventListener('pointerup', onPointerUp)
@@ -234,12 +219,37 @@ export function useAutoScroll(
chase.cancel()
pointerDownRef.current = false
lastUserGestureAtRef.current = Number.NEGATIVE_INFINITY
// End-of-turn content mounts just after teardown; follow it briefly. The
// chase's own upward-move interrupt still protects a real user scroll
// even with the gesture listeners gone.
chase.kickUntil(POST_STREAM_SETTLE_WINDOW)
// Growth can land after teardown with no observer alive: options mounted
// late in the reveal, and a stop's stopped-row/actions appending once the
// abort completes. Idle-follow briefly so that content isn't stranded
// behind the input; a user who scrolled away stays put via the sticky
// check.
chase.kickUntil(POST_STOP_SETTLE_WINDOW)
// The main gesture listeners are gone, so give the settle window its own
// kill switch: the chase's clamp-aware interrupt catches wheel-scale
// moves, but a slow trackpad glide during a concurrent shrink could slip
// under it for up to the window's duration.
const cancelOnGesture = (event: Event) => {
if (event.type === 'wheel' && (event as WheelEvent).deltaY >= 0) return
chase.cancel()
}
el.addEventListener('wheel', cancelOnGesture, { passive: true })
el.addEventListener('touchmove', cancelOnGesture, { passive: true })
const removeGestureGuard = () => {
el.removeEventListener('wheel', cancelOnGesture)
el.removeEventListener('touchmove', cancelOnGesture)
}
const guardTimeout = setTimeout(() => {
removeGestureGuard()
settleCleanupRef.current = null
}, POST_STOP_SETTLE_WINDOW + 100)
settleCleanupRef.current = () => {
chase.cancel()
clearTimeout(guardTimeout)
removeGestureGuard()
}
}
}, [isStreaming, scrollToBottom])
}, [isStreaming])
return { ref: callbackRef, scrollToBottom }
return { ref: callbackRef }
}
+24 -15
View File
@@ -28,10 +28,9 @@ export interface SmoothBottomChaseHandle {
kick: () => void
/**
* Keep the loop alive for `durationMs` even while the gap is at rest,
* re-checking every frame. Covers growth that arrives over several frames
* with no observable trigger — a CSS height animation, or a virtualizer
* re-measure settling after streaming stops. Repeat calls extend the
* deadline; there is never more than one loop.
* re-checking every frame. For growth that lands with no observable trigger
* — content mounting just after a stream's observers tear down (a stop's
* stopped-row/actions). Repeat calls extend the deadline; one loop only.
*/
kickUntil: (durationMs: number) => void
cancel: () => void
@@ -45,9 +44,12 @@ export interface SmoothBottomChaseHandle {
*
* Self-interrupting: chase writes only ever move the offset down, and content
* growth leaves it where the last write put it — so an offset that moved UP
* since the last write can only be a user scrolling away, and the loop parks
* instead of fighting them. `shouldContinue` layers any caller-owned stickiness
* on top (checked every frame).
* since the last write, MORE than the bottom itself moved up, can only be a
* user scrolling away, and the loop parks instead of fighting them. (A
* content-shrink clamp moves the offset and the bottom together — e.g. the
* transcript's floor drain — and must not read as a user scroll.)
* `shouldContinue` layers any caller-owned stickiness on top (checked every
* frame).
*/
export function createSmoothBottomChase(
target: SmoothBottomChaseTarget,
@@ -55,14 +57,14 @@ export function createSmoothBottomChase(
): SmoothBottomChaseHandle {
let raf: number | null = null
let lastTop: number | null = null
let lastBottomTop: number | null = null
let deadline = 0
const park = () => {
if (raf !== null) cancelAnimationFrame(raf)
raf = null
lastTop = null
// A stale deadline must not leak into a later plain kick() — kick alone
// parks at rest, only a live kickUntil window idles through it.
lastBottomTop = null
deadline = 0
}
@@ -77,14 +79,21 @@ export function createSmoothBottomChase(
return
}
const top = target.getTop()
if (lastTop !== null && top < lastTop - 1) {
const bottomTop = target.getBottomTop()
// A user scroll is an ACTUAL upward top move (first clause — growth alone
// must not trip this) that exceeds any upward move of the bottom itself
// (second clause — a shrink clamp moves both together).
const topDrop = lastTop === null ? 0 : lastTop - top
const bottomDrop = lastBottomTop === null ? 0 : lastBottomTop - bottomTop
if (topDrop > 1 && topDrop > bottomDrop + 1) {
park()
return
}
const gap = target.getBottomTop() - top
lastBottomTop = bottomTop
const gap = bottomTop - top
if (gap <= CHASE_REST_GAP) {
// Within a kickUntil deadline the loop idles at rest instead of parking,
// so growth in the deadline window is chased without a fresh trigger.
// Within a kickUntil deadline, idle at rest instead of parking so
// trigger-less growth inside the window is still chased.
if (performance.now() >= deadline) {
park()
return
@@ -105,12 +114,12 @@ export function createSmoothBottomChase(
* Seed the upward-move interrupt baseline at (re)start so a user scroll-up
* between the kick and the first frame parks the loop immediately — without
* it the first step has no baseline and writes one downward frame against
* the user (relevant on the teardown kickUntil, where the gesture listeners
* are already gone).
* the user.
*/
const start = () => {
if (raf !== null) return
lastTop = target.getTop()
lastBottomTop = target.getBottomTop()
raf = requestAnimationFrame(step)
}