diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
index 2abcec9fad5..21fb1a5a20d 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
@@ -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({
)
+ // 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 (
-
-
- Stopped by user
-
- )
+ return null
}
})}
@@ -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.
-
+ // it carries no stray sibling margin.
+
+ ) : // 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' ? (
+ <>
+
+
+ Stopped by user
+
+ {actions && {actions}
}
+ >
) : (
- // 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 && {actions}
+ actions && {actions}
)}
)
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 db903e8398a..dcac9da0b56 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
@@ -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(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(
diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts
index d5edf8977ba..d20a68c10f1 100644
--- a/apps/sim/hooks/use-auto-scroll.ts
+++ b/apps/sim/hooks/use-auto-scroll.ts
@@ -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(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 }
}
diff --git a/apps/sim/lib/core/utils/smooth-bottom-chase.ts b/apps/sim/lib/core/utils/smooth-bottom-chase.ts
index 90c3b9d8e84..e2b928e8f24 100644
--- a/apps/sim/lib/core/utils/smooth-bottom-chase.ts
+++ b/apps/sim/lib/core/utils/smooth-bottom-chase.ts
@@ -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)
}