From fde912e93b961cb4c708f273eb703af9d7efa0f2 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 21 Jul 2026 19:42:32 -0700 Subject: [PATCH 01/12] fix(mothership): freeze the transcript on user stop instead of settle-scrolling --- .../mothership-chat/mothership-chat.tsx | 19 +++++++++++++++++-- apps/sim/hooks/use-auto-scroll.ts | 14 ++++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) 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..dcf171cb463 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 @@ -299,7 +299,22 @@ export function MothershipChat({ const messages = useDeferredValue(messagesProp) const [lastRowAnimating, setLastRowAnimating] = useState(false) const scrollElementRef = useRef(null) - const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating) + /** + * True from the stop button until the next stream: a stopped turn must not + * settle-scroll — the user asked everything to freeze, and following the + * stopped-row/actions mount nudges the transcript up right after they did. + */ + const stopRequestedRef = useRef(false) + const handleStopGeneration = useCallback(() => { + stopRequestedRef.current = true + onStopGeneration() + }, [onStopGeneration]) + useEffect(() => { + if (isStreamActive) stopRequestedRef.current = false + }, [isStreamActive]) + const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating, { + shouldFollowSettle: () => !stopRequestedRef.current, + }) const sizerRef = useRef(null) const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null) @@ -618,7 +633,7 @@ export function MothershipChat({ ref={userInputRef} onSubmit={onSubmit} isSending={isStreamActive} - onStopGeneration={onStopGeneration} + onStopGeneration={handleStopGeneration} isInitialView={false} onSendQueuedHead={handleSendQueuedHead} onEditQueuedTail={handleEditQueuedTail} diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index d5edf8977ba..a7f4fce4c47 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -39,6 +39,12 @@ const POST_STREAM_SETTLE_WINDOW = 300 interface UseAutoScrollOptions { scrollOnMount?: boolean + /** + * Consulted at stream teardown; return false to skip the post-stream settle + * follow. A user-initiated stop means "freeze" — chasing the stopped-row and + * actions mount would visibly nudge the transcript the user just halted. + */ + shouldFollowSettle?: () => boolean } /** @@ -55,10 +61,12 @@ interface UseAutoScrollOptions { */ export function useAutoScroll( isStreaming: boolean, - { scrollOnMount = false }: UseAutoScrollOptions = {} + { scrollOnMount = false, shouldFollowSettle }: UseAutoScrollOptions = {} ) { const containerRef = useRef(null) const stickyRef = useRef(true) + const shouldFollowSettleRef = useRef(shouldFollowSettle) + shouldFollowSettleRef.current = shouldFollowSettle const userDetachedRef = useRef(false) const prevScrollTopRef = useRef(0) const prevScrollHeightRef = useRef(0) @@ -237,7 +245,9 @@ export function useAutoScroll( // 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) + if (shouldFollowSettleRef.current?.() !== false) { + chase.kickUntil(POST_STREAM_SETTLE_WINDOW) + } } }, [isStreaming, scrollToBottom]) From fff6119766f26c5a5943ee4d7a0cfad1b8e88783 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 21 Jul 2026 21:05:58 -0700 Subject: [PATCH 02/12] fix(mothership): swap stopped row into the shimmer slot and floor the sizer min-height --- .../message-content/message-content.tsx | 27 ++++++++++++------- .../mothership-chat/mothership-chat.tsx | 9 ++++++- 2 files changed, 25 insertions(+), 11 deletions(-) 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..13ec42f4557 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 @@ -943,13 +943,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 } })} @@ -969,10 +968,18 @@ function MessageContentInner({ ) : ( - // 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}
+ // Stopped row and actions take the slot's place in the SAME render — a + // single small reflow instead of a collapse the buttons would ride + // upward or a late mount the chase would visibly scroll to. + <> + {lastSegment?.type === 'stopped' && ( +
+ + Stopped by user +
+ )} + {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 dcf171cb463..a92563dc4fd 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 @@ -351,7 +351,14 @@ 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) + ) sizer.style.minHeight = `${floor}px` }) const setScrollElement = useCallback( From e7ea9db9f75c9b192e917abd05f035a4038b27ce Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 22 Jul 2026 10:28:42 -0700 Subject: [PATCH 03/12] fix(mothership): detach auto-scroll on stop and dead-band the sizer floor --- .../mothership-chat/mothership-chat.tsx | 30 +++++++++++------- apps/sim/hooks/use-auto-scroll.ts | 31 +++++++++---------- 2 files changed, 33 insertions(+), 28 deletions(-) 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 a92563dc4fd..827867d72f8 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 @@ -299,24 +299,22 @@ export function MothershipChat({ const messages = useDeferredValue(messagesProp) const [lastRowAnimating, setLastRowAnimating] = useState(false) const scrollElementRef = useRef(null) + const { ref: autoScrollRef, detach: detachAutoScroll } = useAutoScroll( + isStreamActive || lastRowAnimating + ) /** - * True from the stop button until the next stream: a stopped turn must not - * settle-scroll — the user asked everything to freeze, and following the - * stopped-row/actions mount nudges the transcript up right after they did. + * Stop means freeze: detach auto-scroll exactly like a user scroll-away, so + * every chase path — mutation kicks while the reveal drains, animation + * follows, the settle window — parks instead of nudging the transcript the + * user just halted. The next stream re-seeds stickiness from position. */ - const stopRequestedRef = useRef(false) const handleStopGeneration = useCallback(() => { - stopRequestedRef.current = true + detachAutoScroll() onStopGeneration() - }, [onStopGeneration]) - useEffect(() => { - if (isStreamActive) stopRequestedRef.current = false - }, [isStreamActive]) - const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating, { - shouldFollowSettle: () => !stopRequestedRef.current, - }) + }, [detachAutoScroll, onStopGeneration]) const sizerRef = useRef(null) const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null) + const sizerFloorAppliedRef = useRef(0) /** * Sizer floor while streaming: `scrollHeight` must never dip below the @@ -340,6 +338,7 @@ export function MothershipChat({ const el = scrollElementRef.current if (!sizer || !el) return if (!floorActive) { + sizerFloorAppliedRef.current = 0 sizer.style.minHeight = '' return } @@ -359,6 +358,13 @@ export function MothershipChat({ 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 a7f4fce4c47..254f25ca0ce 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -39,12 +39,6 @@ const POST_STREAM_SETTLE_WINDOW = 300 interface UseAutoScrollOptions { scrollOnMount?: boolean - /** - * Consulted at stream teardown; return false to skip the post-stream settle - * follow. A user-initiated stop means "freeze" — chasing the stopped-row and - * actions mount would visibly nudge the transcript the user just halted. - */ - shouldFollowSettle?: () => boolean } /** @@ -56,17 +50,18 @@ 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` (callback ref for the scroll container), `scrollToBottom` for + * imperative use after layout-changing events like panel expansion, and + * `detach` for programmatic freezes (a user stop) — it parks every chase path + * exactly like a user scroll-away, until the user scrolls back to the bottom + * or the next stream re-seeds stickiness. */ export function useAutoScroll( isStreaming: boolean, - { scrollOnMount = false, shouldFollowSettle }: UseAutoScrollOptions = {} + { scrollOnMount = false }: UseAutoScrollOptions = {} ) { const containerRef = useRef(null) const stickyRef = useRef(true) - const shouldFollowSettleRef = useRef(shouldFollowSettle) - shouldFollowSettleRef.current = shouldFollowSettle const userDetachedRef = useRef(false) const prevScrollTopRef = useRef(0) const prevScrollHeightRef = useRef(0) @@ -91,6 +86,11 @@ export function useAutoScroll( el.scrollTop = el.scrollHeight }, []) + const detach = useCallback(() => { + stickyRef.current = false + userDetachedRef.current = true + }, []) + const callbackRef = useCallback((el: HTMLDivElement | null) => { containerRef.current = el if (el && scrollOnMountRef.current) el.scrollTop = el.scrollHeight @@ -244,12 +244,11 @@ export function useAutoScroll( 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. - if (shouldFollowSettleRef.current?.() !== false) { - chase.kickUntil(POST_STREAM_SETTLE_WINDOW) - } + // even with the gesture listeners gone, and the per-frame sticky check + // makes this a no-op after a detach(). + chase.kickUntil(POST_STREAM_SETTLE_WINDOW) } }, [isStreaming, scrollToBottom]) - return { ref: callbackRef, scrollToBottom } + return { ref: callbackRef, scrollToBottom, detach } } From c9d7e7c8a0bb5444a8d1a54a591fa4e1cc5a8e93 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 22 Jul 2026 10:57:04 -0700 Subject: [PATCH 04/12] =?UTF-8?q?improvement(mothership):=20net-zero=20set?= =?UTF-8?q?tle=20=E2=80=94=20equal=20tail=20regions,=20drained=20floor,=20?= =?UTF-8?q?one=20growth=20signal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../message-content/message-content.tsx | 21 +++-- .../mothership-chat/mothership-chat.tsx | 38 ++++++++- apps/sim/hooks/use-auto-scroll.ts | 80 ++++++------------- .../sim/lib/core/utils/smooth-bottom-chase.ts | 28 +------ 4 files changed, 74 insertions(+), 93 deletions(-) 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 13ec42f4557..173e6528e16 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,13 @@ 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 one vertical extent (10px gap + 36px row) every tail-region occupant — + * shimmer slot, actions row, stopped row — must share. The settle swap is only + * jump-free because these are equal; changing one side without the others + * reintroduces a scroll clamp at end of turn. + */ +const TAIL_REGION_CLASSES = 'mt-[10px] flex h-[36px] items-center' interface TextSegment { type: 'text' @@ -956,8 +963,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. +
) : ( - // Stopped row and actions take the slot's place in the SAME render — a - // single small reflow instead of a collapse the buttons would ride - // upward or a late mount the chase would visibly scroll to. + // Stopped row and actions take 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. <> {lastSegment?.type === 'stopped' && ( -
+
Stopped by user
)} - {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 827867d72f8..b65df433261 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', @@ -315,6 +317,7 @@ export function MothershipChat({ const sizerRef = useRef(null) const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null) const sizerFloorAppliedRef = useRef(0) + const floorDrainRafRef = useRef(0) /** * Sizer floor while streaming: `scrollHeight` must never dip below the @@ -331,6 +334,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(() => { @@ -338,10 +350,28 @@ export function MothershipChat({ const el = scrollElementRef.current if (!sizer || !el) return if (!floorActive) { - sizerFloorAppliedRef.current = 0 - sizer.style.minHeight = '' + if (sizerFloorAppliedRef.current === 0) return + scrollerPaddingRef.current = null + const pinned = el.scrollHeight - el.scrollTop - el.clientHeight <= 2 + cancelAnimationFrame(floorDrainRafRef.current) + const drain = () => { + const target = virtualizer.getTotalSize() + const current = sizerFloorAppliedRef.current + if (current === 0) return + const next = Math.floor(current - Math.max(1, (current - target) * SMOOTH_CHASE_RATE)) + if (!pinned || next - target <= 1) { + sizerFloorAppliedRef.current = 0 + sizer.style.minHeight = '' + return + } + sizerFloorAppliedRef.current = next + sizer.style.minHeight = `${next}px` + floorDrainRafRef.current = requestAnimationFrame(drain) + } + drain() return } + cancelAnimationFrame(floorDrainRafRef.current) if (!scrollerPaddingRef.current) { const style = getComputedStyle(el) scrollerPaddingRef.current = { diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index 254f25ca0ce..2df1efda434 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -25,22 +25,6 @@ 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. - */ -const POST_STREAM_SETTLE_WINDOW = 300 - -interface UseAutoScrollOptions { - scrollOnMount?: boolean -} - /** * Manages sticky auto-scroll for a streaming chat container. * @@ -50,23 +34,18 @@ 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), `scrollToBottom` for - * imperative use after layout-changing events like panel expansion, and - * `detach` for programmatic freezes (a user stop) — it parks every chase path - * exactly like a user scroll-away, until the user scrolls back to the bottom - * or the next stream re-seeds stickiness. + * Returns `ref` (callback ref for the scroll container) and `detach` for + * programmatic freezes (a user stop) — it parks every chase path exactly like + * a user scroll-away, until the user scrolls back to the bottom or the next + * stream re-seeds stickiness. */ -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 @@ -80,12 +59,6 @@ export function useAutoScroll( */ const lastUserGestureAtRef = useRef(Number.NEGATIVE_INFINITY) - const scrollToBottom = useCallback(() => { - const el = containerRef.current - if (!el) return - el.scrollTop = el.scrollHeight - }, []) - const detach = useCallback(() => { stickyRef.current = false userDetachedRef.current = true @@ -93,7 +66,6 @@ export function useAutoScroll( const callbackRef = useCallback((el: HTMLDivElement | null) => { containerRef.current = el - if (el && scrollOnMountRef.current) el.scrollTop = el.scrollHeight }, []) useEffect(() => { @@ -198,42 +170,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) @@ -242,13 +210,13 @@ 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, and the per-frame sticky check - // makes this a no-op after a detach(). - chase.kickUntil(POST_STREAM_SETTLE_WINDOW) + // Teardown can land mid-glide (options mounted late in the reveal, gap + // not yet closed) — canceling there strands the follow-ups behind the + // input. One plain kick runs the loop to rest and parks; a stopped turn + // stays frozen because the detached sticky check parks it on frame one. + chase.kick() } - }, [isStreaming, scrollToBottom]) + }, [isStreaming]) - return { ref: callbackRef, scrollToBottom, detach } + return { ref: callbackRef, detach } } diff --git a/apps/sim/lib/core/utils/smooth-bottom-chase.ts b/apps/sim/lib/core/utils/smooth-bottom-chase.ts index 90c3b9d8e84..ad31140bd96 100644 --- a/apps/sim/lib/core/utils/smooth-bottom-chase.ts +++ b/apps/sim/lib/core/utils/smooth-bottom-chase.ts @@ -26,14 +26,6 @@ export interface SmoothBottomChaseHandle { isActive: () => boolean /** Start the loop if parked. Call after content growth. */ 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. - */ - kickUntil: (durationMs: number) => void cancel: () => void } @@ -55,15 +47,11 @@ export function createSmoothBottomChase( ): SmoothBottomChaseHandle { let raf: number | null = null let lastTop: 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. - deadline = 0 } const step = () => { @@ -83,14 +71,7 @@ export function createSmoothBottomChase( } const gap = target.getBottomTop() - 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. - if (performance.now() >= deadline) { - park() - return - } - lastTop = top - raf = requestAnimationFrame(step) + park() return } target.setTop(top + Math.max(1, gap * SMOOTH_CHASE_RATE)) @@ -105,8 +86,7 @@ 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 @@ -117,10 +97,6 @@ export function createSmoothBottomChase( return { isActive: () => raf !== null, kick: start, - kickUntil: (durationMs: number) => { - deadline = Math.max(deadline, performance.now() + durationMs) - start() - }, cancel: park, } } From cb63d01b2f1ef71a0f00a56eea30978deccafc49 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 22 Jul 2026 11:10:58 -0700 Subject: [PATCH 05/12] fix(mothership): follow the post-stop drain to the end instead of freezing --- .../message-content/message-content.tsx | 18 ++++++++---- .../mothership-chat/mothership-chat.tsx | 16 ++-------- apps/sim/hooks/use-auto-scroll.ts | 29 +++++++++---------- .../sim/lib/core/utils/smooth-bottom-chase.ts | 22 +++++++++++++- 4 files changed, 50 insertions(+), 35 deletions(-) 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 173e6528e16..846bfea31e5 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 @@ -860,8 +860,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) @@ -912,13 +920,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 } /> ) 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 b65df433261..1273a58e701 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 @@ -301,19 +301,7 @@ export function MothershipChat({ const messages = useDeferredValue(messagesProp) const [lastRowAnimating, setLastRowAnimating] = useState(false) const scrollElementRef = useRef(null) - const { ref: autoScrollRef, detach: detachAutoScroll } = useAutoScroll( - isStreamActive || lastRowAnimating - ) - /** - * Stop means freeze: detach auto-scroll exactly like a user scroll-away, so - * every chase path — mutation kicks while the reveal drains, animation - * follows, the settle window — parks instead of nudging the transcript the - * user just halted. The next stream re-seeds stickiness from position. - */ - const handleStopGeneration = useCallback(() => { - detachAutoScroll() - onStopGeneration() - }, [detachAutoScroll, onStopGeneration]) + const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating) const sizerRef = useRef(null) const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null) const sizerFloorAppliedRef = useRef(0) @@ -676,7 +664,7 @@ export function MothershipChat({ ref={userInputRef} onSubmit={onSubmit} isSending={isStreamActive} - onStopGeneration={handleStopGeneration} + onStopGeneration={onStopGeneration} isInitialView={false} onSendQueuedHead={handleSendQueuedHead} onEditQueuedTail={handleEditQueuedTail} diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index 2df1efda434..c5ae57f9a28 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -25,6 +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 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_STOP_SETTLE_WINDOW = 800 /** * Manages sticky auto-scroll for a streaming chat container. * @@ -34,10 +40,7 @@ const SCROLL_UP_KEYS = new Set(['ArrowUp', 'PageUp', 'Home']) * 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 `detach` for - * programmatic freezes (a user stop) — it parks every chase path exactly like - * a user scroll-away, until the user scrolls back to the bottom or the next - * stream re-seeds stickiness. + * Returns `ref`, the callback ref for the scroll container. */ export function useAutoScroll(isStreaming: boolean) { const containerRef = useRef(null) @@ -59,11 +62,6 @@ export function useAutoScroll(isStreaming: boolean) { */ const lastUserGestureAtRef = useRef(Number.NEGATIVE_INFINITY) - const detach = useCallback(() => { - stickyRef.current = false - userDetachedRef.current = true - }, []) - const callbackRef = useCallback((el: HTMLDivElement | null) => { containerRef.current = el }, []) @@ -210,13 +208,14 @@ export function useAutoScroll(isStreaming: boolean) { chase.cancel() pointerDownRef.current = false lastUserGestureAtRef.current = Number.NEGATIVE_INFINITY - // Teardown can land mid-glide (options mounted late in the reveal, gap - // not yet closed) — canceling there strands the follow-ups behind the - // input. One plain kick runs the loop to rest and parks; a stopped turn - // stays frozen because the detached sticky check parks it on frame one. - chase.kick() + // 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) } }, [isStreaming]) - return { ref: callbackRef, detach } + 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 ad31140bd96..0b71687fc32 100644 --- a/apps/sim/lib/core/utils/smooth-bottom-chase.ts +++ b/apps/sim/lib/core/utils/smooth-bottom-chase.ts @@ -26,6 +26,13 @@ export interface SmoothBottomChaseHandle { isActive: () => boolean /** Start the loop if parked. Call after content growth. */ kick: () => void + /** + * Keep the loop alive for `durationMs` even while the gap is at rest, + * 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 } @@ -47,11 +54,13 @@ export function createSmoothBottomChase( ): SmoothBottomChaseHandle { let raf: number | null = null let lastTop: number | null = null + let deadline = 0 const park = () => { if (raf !== null) cancelAnimationFrame(raf) raf = null lastTop = null + deadline = 0 } const step = () => { @@ -71,7 +80,14 @@ export function createSmoothBottomChase( } const gap = target.getBottomTop() - top if (gap <= CHASE_REST_GAP) { - park() + // 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 + } + lastTop = top + raf = requestAnimationFrame(step) return } target.setTop(top + Math.max(1, gap * SMOOTH_CHASE_RATE)) @@ -97,6 +113,10 @@ export function createSmoothBottomChase( return { isActive: () => raf !== null, kick: start, + kickUntil: (durationMs: number) => { + deadline = Math.max(deadline, performance.now() + durationMs) + start() + }, cancel: park, } } From 4bb8fbd56c58b896e69185f33dd223e756b2745e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 22 Jul 2026 11:18:17 -0700 Subject: [PATCH 06/12] fix(mothership): single stopped tail region and drain cleanup --- .../components/message-content/message-content.tsx | 14 +++++++++----- .../components/mothership-chat/mothership-chat.tsx | 5 ++++- 2 files changed, 13 insertions(+), 6 deletions(-) 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 846bfea31e5..4be3768d760 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 @@ -983,17 +983,21 @@ function MessageContentInner({
) : ( - // Stopped row and actions take 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. + // 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 shares the ONE region (status left, actions right); stacking two + // regions would double the tail and break neutrality. <> - {lastSegment?.type === 'stopped' && ( + {lastSegment?.type === 'stopped' ? (
Stopped by user + {actions &&
{actions}
}
+ ) : ( + 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 1273a58e701..6c8bae6e805 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 @@ -306,6 +306,7 @@ export function MothershipChat({ 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 @@ -340,12 +341,14 @@ export function MothershipChat({ if (!floorActive) { if (sizerFloorAppliedRef.current === 0) return scrollerPaddingRef.current = null - const pinned = el.scrollHeight - el.scrollTop - el.clientHeight <= 2 cancelAnimationFrame(floorDrainRafRef.current) const drain = () => { const target = virtualizer.getTotalSize() const current = sizerFloorAppliedRef.current if (current === 0) return + // Re-checked per frame: a user scrolling away mid-drain makes the + // remaining shrink invisible, so finish instantly instead of clamping. + const pinned = el.scrollHeight - el.scrollTop - el.clientHeight <= 2 const next = Math.floor(current - Math.max(1, (current - target) * SMOOTH_CHASE_RATE)) if (!pinned || next - target <= 1) { sizerFloorAppliedRef.current = 0 From f7a94daac3153dcb5b8741bb4b01ee3b53436b2d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 22 Jul 2026 11:25:10 -0700 Subject: [PATCH 07/12] fix(mothership): clamp-aware chase interrupt so the floor drain can't park the settle follow --- .../sim/lib/core/utils/smooth-bottom-chase.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/core/utils/smooth-bottom-chase.ts b/apps/sim/lib/core/utils/smooth-bottom-chase.ts index 0b71687fc32..ffe8011c298 100644 --- a/apps/sim/lib/core/utils/smooth-bottom-chase.ts +++ b/apps/sim/lib/core/utils/smooth-bottom-chase.ts @@ -44,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, @@ -54,12 +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 + lastBottomTop = null deadline = 0 } @@ -74,11 +79,17 @@ export function createSmoothBottomChase( return } const top = target.getTop() - if (lastTop !== null && top < lastTop - 1) { + const bottomTop = target.getBottomTop() + if ( + lastTop !== null && + lastBottomTop !== null && + lastTop - top > lastBottomTop - bottomTop + 1 + ) { park() return } - const gap = target.getBottomTop() - top + lastBottomTop = bottomTop + const gap = bottomTop - top if (gap <= CHASE_REST_GAP) { // Within a kickUntil deadline, idle at rest instead of parking so // trigger-less growth inside the window is still chased. @@ -107,6 +118,7 @@ export function createSmoothBottomChase( const start = () => { if (raf !== null) return lastTop = target.getTop() + lastBottomTop = target.getBottomTop() raf = requestAnimationFrame(step) } From fb10473b4f9f3c28812edd8bf28f620b63778a53 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 22 Jul 2026 11:31:46 -0700 Subject: [PATCH 08/12] fix(mothership): park the chase only on real upward top moves, not growth --- apps/sim/lib/core/utils/smooth-bottom-chase.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/core/utils/smooth-bottom-chase.ts b/apps/sim/lib/core/utils/smooth-bottom-chase.ts index ffe8011c298..e2b928e8f24 100644 --- a/apps/sim/lib/core/utils/smooth-bottom-chase.ts +++ b/apps/sim/lib/core/utils/smooth-bottom-chase.ts @@ -80,11 +80,12 @@ export function createSmoothBottomChase( } const top = target.getTop() const bottomTop = target.getBottomTop() - if ( - lastTop !== null && - lastBottomTop !== null && - lastTop - top > lastBottomTop - bottomTop + 1 - ) { + // 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 } From 7b6c86f2ff8f5efce73b5a69d062afe6a959531d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 22 Jul 2026 11:48:09 -0700 Subject: [PATCH 09/12] improvement(mothership): stack the stopped status above the actions row --- .../components/message-content/message-content.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 4be3768d760..75d45c79db1 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 @@ -986,18 +986,17 @@ function MessageContentInner({ // 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 shares the ONE region (status left, actions right); stacking two - // regions would double the tail and break neutrality. + // turn stacks a second region (status row above the actions); that +46px + // is deliberate — the teardown's eased follow glides it into view, and + // the stacked layout beats a combined status/actions row visually. <> - {lastSegment?.type === 'stopped' ? ( + {lastSegment?.type === 'stopped' && (
Stopped by user - {actions &&
{actions}
}
- ) : ( - actions &&
{actions}
)} + {actions &&
{actions}
} )} From ecacda731f50e4b07f23da0e3b812e2bb12c4ee0 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 22 Jul 2026 11:51:08 -0700 Subject: [PATCH 10/12] improvement(mothership): compact stopped-turn tail with the original 10px rhythm --- .../message-content/message-content.tsx | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) 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 75d45c79db1..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 @@ -31,10 +31,11 @@ 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 one vertical extent (10px gap + 36px row) every tail-region occupant — - * shimmer slot, actions row, stopped row — must share. The settle swap is only - * jump-free because these are equal; changing one side without the others - * reintroduces a scroll clamp at end of turn. + * 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' @@ -982,22 +983,22 @@ function MessageContentInner({ - ) : ( - // 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 stacks a second region (status row above the actions); that +46px - // is deliberate — the teardown's eased follow glides it into view, and - // the stacked layout beats a combined status/actions row visually. + ) : // 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' ? ( <> - {lastSegment?.type === 'stopped' && ( -
- - Stopped by user -
- )} - {actions &&
{actions}
} +
+ + Stopped by user +
+ {actions &&
{actions}
} + ) : ( + actions &&
{actions}
)} ) From 4757422ea80f054b038c7d764c825901d2f43481 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 22 Jul 2026 12:12:32 -0700 Subject: [PATCH 11/12] fix(mothership): single drain cadence and a settle-window gesture kill switch --- .../components/mothership-chat/mothership-chat.tsx | 14 +++++++++++--- apps/sim/hooks/use-auto-scroll.ts | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) 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 6c8bae6e805..c0041ccacf2 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 @@ -340,18 +340,25 @@ export function MothershipChat({ if (!sizer || !el) return if (!floorActive) { 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 - cancelAnimationFrame(floorDrainRafRef.current) const drain = () => { const target = virtualizer.getTotalSize() const current = sizerFloorAppliedRef.current - if (current === 0) return + if (current === 0) { + floorDrainRafRef.current = 0 + return + } // Re-checked per frame: a user scrolling away mid-drain makes the // remaining shrink invisible, so finish instantly instead of clamping. const pinned = el.scrollHeight - el.scrollTop - el.clientHeight <= 2 const next = Math.floor(current - Math.max(1, (current - target) * SMOOTH_CHASE_RATE)) if (!pinned || next - target <= 1) { sizerFloorAppliedRef.current = 0 + floorDrainRafRef.current = 0 sizer.style.minHeight = '' return } @@ -359,10 +366,11 @@ export function MothershipChat({ sizer.style.minHeight = `${next}px` floorDrainRafRef.current = requestAnimationFrame(drain) } - drain() + floorDrainRafRef.current = requestAnimationFrame(drain) return } cancelAnimationFrame(floorDrainRafRef.current) + floorDrainRafRef.current = 0 if (!scrollerPaddingRef.current) { const style = getComputedStyle(el) scrollerPaddingRef.current = { diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index c5ae57f9a28..a14aa532eda 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -214,6 +214,20 @@ export function useAutoScroll(isStreaming: boolean) { // 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 }) + setTimeout(() => { + el.removeEventListener('wheel', cancelOnGesture) + el.removeEventListener('touchmove', cancelOnGesture) + }, POST_STOP_SETTLE_WINDOW + 100) } }, [isStreaming]) From 243cd3708884dabe5b1aa362c87ddfe5154bab4c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 22 Jul 2026 12:24:36 -0700 Subject: [PATCH 12/12] fix(mothership): debt-aware drain fast-path and settle-window handle retention --- .../mothership-chat/mothership-chat.tsx | 13 ++++++----- apps/sim/hooks/use-auto-scroll.ts | 22 ++++++++++++++++++- 2 files changed, 29 insertions(+), 6 deletions(-) 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 c0041ccacf2..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 @@ -352,16 +352,19 @@ export function MothershipChat({ floorDrainRafRef.current = 0 return } - // Re-checked per frame: a user scrolling away mid-drain makes the - // remaining shrink invisible, so finish instantly instead of clamping. - const pinned = el.scrollHeight - el.scrollTop - el.clientHeight <= 2 - const next = Math.floor(current - Math.max(1, (current - target) * SMOOTH_CHASE_RATE)) - if (!pinned || next - target <= 1) { + // 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) diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index a14aa532eda..d20a68c10f1 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -66,10 +66,21 @@ export function useAutoScroll(isStreaming: boolean) { containerRef.current = el }, []) + /** + * 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 — @@ -224,10 +235,19 @@ export function useAutoScroll(isStreaming: boolean) { } el.addEventListener('wheel', cancelOnGesture, { passive: true }) el.addEventListener('touchmove', cancelOnGesture, { passive: true }) - setTimeout(() => { + 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])