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) }