diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
index 971732b747c..abedf727bf4 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
@@ -17,7 +17,6 @@ import { extractTextContent } from '@/lib/core/utils/react-node-text'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
import {
type ContentSegment,
- PendingTagIndicator,
parseSpecialTags,
SpecialTags,
} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
@@ -396,8 +395,13 @@ interface ChatContentProps {
onQuestionDismiss?: () => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
onRevealStateChange?: (isRevealing: boolean) => void
- /** Reports whether this segment is actively painting text or its own pending-tag indicator. */
+ /** Reports whether this segment is actively painting text. */
onStreamActivityChange?: (active: boolean) => void
+ /**
+ * Reports whether a special tag is mid-stream — bytes arriving but rendering
+ * nothing (tags are suppressed until complete). A wait from the user's POV.
+ */
+ onPendingTagChange?: (pending: boolean) => void
}
function ChatContentInner({
@@ -409,6 +413,7 @@ function ChatContentInner({
onWorkspaceResourceSelect,
onRevealStateChange,
onStreamActivityChange,
+ onPendingTagChange,
}: ChatContentProps) {
const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect)
onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect
@@ -530,12 +535,17 @@ function ChatContentInner({
() => parseSpecialTags(streamedContent, isRevealing),
[streamedContent, isRevealing]
)
- const hasPendingIndicator = parsed.hasPendingTag && isRevealing
useEffect(() => {
- onStreamActivityChange?.(hasRevealBacklog || hasPendingIndicator)
+ onStreamActivityChange?.(hasRevealBacklog)
return () => onStreamActivityChange?.(false)
- }, [hasPendingIndicator, hasRevealBacklog, onStreamActivityChange])
+ }, [hasRevealBacklog, onStreamActivityChange])
+
+ const hasPendingTag = parsed.hasPendingTag && isRevealing
+ useEffect(() => {
+ onPendingTagChange?.(hasPendingTag)
+ return () => onPendingTagChange?.(false)
+ }, [hasPendingTag, onPendingTagChange])
type BlockSegment = Exclude<
ContentSegment,
@@ -624,7 +634,6 @@ function ChatContentInner({
/>
)
})}
- {hasPendingIndicator && }
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
index a566cd3e9c2..f44aab8407f 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
@@ -559,13 +559,18 @@ export function SpecialTags({
}
}
+interface PendingTagIndicatorProps {
+ /** Activity phrase next to the loader; crossfades on change. */
+ label: string
+}
+
/**
- * Renders a "Thinking" shimmer while a special tag is still streaming in.
+ * Renders the turn-level activity shimmer.
*/
-export function PendingTagIndicator() {
+export function PendingTagIndicator({ label }: PendingTagIndicatorProps) {
return (
-
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts
index 6f87cb1de0a..942ca3c5afe 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts
@@ -14,8 +14,8 @@ import { modelToContentBlocks } from '@/app/workspace/[workspaceId]/home/hooks/s
import type { ContentBlock } from '../../types'
import {
assistantMessageHasVisibleExecutingTool,
+ deriveThinkingLabel,
parseBlocks,
- shouldShowTrailingThinking,
shouldSmoothTextSegment,
} from './message-content'
@@ -628,62 +628,6 @@ describe('narration text seams', () => {
})
})
-describe('shouldShowTrailingThinking', () => {
- it('shows one turn-level indicator while an open subagent waits between completed steps', () => {
- expect(
- shouldShowTrailingThinking({
- isStreaming: true,
- isStreamIdle: true,
- isRenderingStream: false,
- hasExecutingTool: false,
- lastSegmentType: 'agent_group',
- })
- ).toBe(true)
- })
-
- it('stays hidden while a chunk is rendering or before the stream becomes idle', () => {
- expect(
- shouldShowTrailingThinking({
- isStreaming: true,
- isStreamIdle: true,
- isRenderingStream: true,
- hasExecutingTool: false,
- lastSegmentType: 'text',
- })
- ).toBe(false)
- expect(
- shouldShowTrailingThinking({
- isStreaming: true,
- isStreamIdle: false,
- isRenderingStream: false,
- hasExecutingTool: false,
- lastSegmentType: 'agent_group',
- })
- ).toBe(false)
- })
-
- it('does not duplicate an executing tool row or survive a stopped turn', () => {
- expect(
- shouldShowTrailingThinking({
- isStreaming: true,
- isStreamIdle: true,
- isRenderingStream: false,
- hasExecutingTool: true,
- lastSegmentType: 'agent_group',
- })
- ).toBe(false)
- expect(
- shouldShowTrailingThinking({
- isStreaming: true,
- isStreamIdle: true,
- isRenderingStream: false,
- hasExecutingTool: false,
- lastSegmentType: 'stopped',
- })
- ).toBe(false)
- })
-})
-
describe('parseBlocks legacy — thinking between top-level tools', () => {
it('keeps consecutive mothership tools in one group across intervening thinking', () => {
const blocks: ContentBlock[] = [
@@ -793,3 +737,27 @@ describe('assistantMessageHasVisibleExecutingTool', () => {
expect(assistantMessageHasVisibleExecutingTool(blocks)).toBe(false)
})
})
+
+describe('deriveThinkingLabel', () => {
+ it('maps the most recent block to an activity phrase', () => {
+ expect(deriveThinkingLabel([])).toBe('Thinking…')
+ expect(deriveThinkingLabel([{ type: 'thinking', content: 'hm', timestamp: 1 }])).toBe(
+ 'Thinking…'
+ )
+ // A stall after streamed text is the agent deciding what's next, not generating.
+ expect(deriveThinkingLabel([mainText('hi')])).toBe('Thinking…')
+ expect(deriveThinkingLabel([{ type: 'subagent_text', content: 'x', timestamp: 1 }])).toBe(
+ 'Thinking…'
+ )
+ expect(deriveThinkingLabel([{ type: 'subagent_end', spanId: 'S1', timestamp: 1 }])).toBe(
+ 'Returning…'
+ )
+ })
+
+ it('shows Dispatching for the dispatch call, then yields to the opened lane', () => {
+ expect(deriveThinkingLabel([mainToolCall('t1', 'workflow')])).toBe('Dispatching…')
+ expect(deriveThinkingLabel([mainToolCall('t1', 'workspace_file')])).toBe('Dispatching…')
+ expect(deriveThinkingLabel([mainToolCall('t1', 'grep')])).toBe('Thinking…')
+ expect(deriveThinkingLabel([subagentStart('workflow', 'S1', 'main')])).toBeNull()
+ })
+})
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 9d8f67067f8..2abcec9fad5 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
@@ -1,6 +1,16 @@
'use client'
-import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import {
+ memo,
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import { cn } from '@sim/emcn'
import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils'
@@ -18,6 +28,7 @@ import { AgentGroup, ChatContent, CircleStop, Options, PendingTagIndicator } fro
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
interface TextSegment {
@@ -738,47 +749,69 @@ export function shouldSmoothTextSegment({
return isStreaming && segmentIndex === segmentCount - 1
}
-export function shouldShowTrailingThinking({
- isStreaming,
- isStreamIdle,
- isRenderingStream,
- hasExecutingTool,
- lastSegmentType,
-}: {
- isStreaming: boolean
- isStreamIdle: boolean
- isRenderingStream: boolean
- hasExecutingTool: boolean
- lastSegmentType?: 'text' | 'agent_group' | 'options' | 'stopped'
-}): boolean {
- return (
- isStreaming &&
- isStreamIdle &&
- !isRenderingStream &&
- !hasExecutingTool &&
- lastSegmentType !== 'stopped'
- )
+const DISPATCH_TOOL_NAMES = new Set([...SUBAGENT_KEYS, ...Object.values(SUBAGENT_DISPATCH_TOOLS)])
+
+/**
+ * Activity phrase for the turn-level shimmer, derived from the most recent
+ * stream block. The shimmer only shows in quiet gaps (see showShimmer), so the
+ * phrase describes the wait, not the output: a stall after streamed text is
+ * the agent deciding what's next — Thinking — never "Generating" (while text
+ * actually generates the shimmer is hidden). Dispatching covers only the
+ * dispatch call itself (whose tool row the parser absorbs, so nothing else
+ * shows); once the lane is open its own delegating shimmer owns the state and
+ * the turn-level one stays hidden (`null`).
+ */
+export function deriveThinkingLabel(blocks: ContentBlock[]): string | null {
+ const last = blocks[blocks.length - 1]
+ switch (last?.type) {
+ case 'subagent':
+ return null
+ case 'subagent_end':
+ return 'Returning…'
+ case 'tool_call':
+ return last.toolCall && DISPATCH_TOOL_NAMES.has(last.toolCall.name)
+ ? 'Dispatching…'
+ : 'Thinking…'
+ default:
+ return 'Thinking…'
+ }
}
interface MessageContentProps {
blocks: ContentBlock[]
fallbackContent: string
isStreaming: boolean
+ /**
+ * True for the last message in the transcript. The last turn keeps a
+ * fixed-height thinking slot at its bottom (see JSX) so the shimmer fades in
+ * place without ever changing height.
+ */
+ isLast?: boolean
/** Transcript-derived answers for this message's question card (renders the recap). */
questionAnswers?: string[]
onOptionSelect?: (id: string) => void
onQuestionDismiss?: () => void
onPhaseChange?: (phase: MessagePhase) => void
+ /**
+ * The message's actions row (copy/thumbs). Rendered here, in the thinking
+ * slot's position, so at settle the shimmer and the actions trade places in
+ * one render — a single tiny reflow instead of a collapse the buttons ride
+ * or a late mount the chase visibly scrolls to. The caller gates it on
+ * content/question eligibility only; the settle timing is owned here.
+ */
+ actions?: ReactNode
}
function MessageContentInner({
blocks,
fallbackContent,
isStreaming = false,
+ isLast = false,
questionAnswers,
onOptionSelect,
onQuestionDismiss,
onPhaseChange,
+ actions,
}: MessageContentProps) {
const { onWorkspaceResourceSelect } = useChatSurface()
const parsed = useMemo(() => (blocks.length > 0 ? parseBlocks(blocks) : []), [blocks])
@@ -791,6 +824,10 @@ function MessageContentInner({
const handleTrailingStreamActivityChange = useCallback((active: boolean) => {
setTrailingStreamActivity(active)
}, [])
+ const [trailingPendingTag, setTrailingPendingTag] = useState(false)
+ const handleTrailingPendingTagChange = useCallback((pending: boolean) => {
+ setTrailingPendingTag(pending)
+ }, [])
const [isStreamIdle, setIsStreamIdle] = useState(false)
const segments: MessageSegment[] =
@@ -802,8 +839,8 @@ function MessageContentInner({
const visibleStreamActivityKey = getVisibleStreamActivityKey(segments)
// Every visible stream update restarts the quiet-period clock. A layout
- // effect clears an already-visible indicator before paint, so a chunk from
- // any parallel lane hides the one turn-level loader without a stale flash.
+ // effect clears an already-visible shimmer before paint, so a chunk from any
+ // parallel lane yields the slot to the arriving output without a stale flash.
useLayoutEffect(() => {
if (!isStreaming) {
setIsStreamIdle(false)
@@ -826,91 +863,117 @@ function MessageContentInner({
onPhaseChangeRef.current?.(phase)
}, [phase])
- if (segments.length === 0) {
- if (isStreaming) {
- return (
-
- )
- }
- return null
- }
+ // The slot is the last message's own element, so it grows on send with the
+ // row (no separate mount → no jump). Gated on phase, not isStreaming: the
+ // trailing text keeps visually revealing on a timer after the network stream
+ // closes, and collapsing under a still-growing reveal reads as the blob
+ // winking out early while everything shifts.
+ const thinkingExpanded = phase !== 'settled' && lastSegment?.type !== 'stopped'
- // Executing tools already render an active row. An open subagent lane does
- // not suppress the turn-level indicator: once its latest visible chunk has
- // settled, the loader can bridge the wait until that lane (or a parallel
- // sibling) emits again.
+ if (segments.length === 0 && !isLast) return null
+
+ // A visible executing tool row already spins — the turn-level shimmer would
+ // double it. (A null label means a just-opened lane's shimmer owns the state.)
+ // A mid-stream special tag renders nothing until complete, so its bytes are a
+ // wait, not output — the shimmer bridges it without the quiet-period delay.
+ const thinkingLabel = deriveThinkingLabel(blocks)
const hasExecutingTool = assistantMessageHasVisibleExecutingTool(blocks)
- const showTrailingThinking = shouldShowTrailingThinking({
- isStreaming: phase === 'streaming',
- isStreamIdle,
- isRenderingStream: trailingStreamActivity,
- hasExecutingTool,
- lastSegmentType: lastSegment.type,
- })
+ const showShimmer =
+ thinkingExpanded &&
+ thinkingLabel !== null &&
+ (segments.length === 0 ||
+ trailingPendingTag ||
+ (isStreamIdle && !trailingStreamActivity && !hasExecutingTool))
return (
-
- {segments.map((segment, i) => {
- switch (segment.type) {
- case 'text':
- return (
-
- )
- case 'agent_group': {
- return (
-
-
+
+ {segments.map((segment, i) => {
+ switch (segment.type) {
+ case 'text':
+ return (
+
-
- )
+ )
+ case 'agent_group': {
+ return (
+
+ )
+ }
+ case 'options':
+ return (
+
+
+
+ )
+ case 'stopped':
+ return (
+
+
+ Stopped by user
+
+ )
}
- case 'options':
- return (
-
-
-
- )
- case 'stopped':
- return (
-
-
- Stopped by user
-
- )
- }
- })}
- {showTrailingThinking && }
+ })}
+
+ {thinkingExpanded && isLast ? (
+ // 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.
+
+ ) : (
+ // 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}
+ )}
)
}
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 5d11700c8f5..54044968d99 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
@@ -21,10 +21,7 @@ import {
type MessagePhase,
} from '@/app/workspace/[workspaceId]/home/components/message-content'
import { parseQuestionAnswerMessage } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question'
-import {
- PendingTagIndicator,
- parseLastQuestionTag,
-} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
+import { parseLastQuestionTag } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages'
import {
UserInput,
@@ -101,7 +98,6 @@ const OVERSCAN = 6
* scrolled up.
*/
const PIN_THRESHOLD = 2
-
/**
* Initial-scroll sentinel. Distinct from every real `chatId` value — including
* `undefined` (a not-yet-persisted chat) — so the first scroll-to-bottom fires
@@ -113,7 +109,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-8 [scrollbar-gutter:stable_both-edges]',
+ 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-2 [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',
@@ -175,6 +171,7 @@ const UserMessageRow = memo(function UserMessageRow({
interface AssistantMessageRowProps {
message: ChatMessage
isStreaming: boolean
+ isLast: boolean
precedingUserContent?: string
/** Transcript-derived answers for this message's question card (renders the recap). */
questionAnswers?: string[]
@@ -186,6 +183,7 @@ interface AssistantMessageRowProps {
const AssistantMessageRow = memo(function AssistantMessageRow({
message,
isStreaming,
+ isLast,
precedingUserContent,
questionAnswers,
rowClassName,
@@ -205,10 +203,6 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
onAnimatingChangeRef.current?.(phase !== 'settled')
}, [phase])
- if (!hasAnyBlocks && !trimmedContent && isStreaming) {
- return
- }
-
const hasRenderableAssistant = assistantMessageHasRenderableContent(blocks, message.content ?? '')
if (!hasRenderableAssistant && !trimmedContent && !isStreaming) {
return null
@@ -225,8 +219,11 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
const handleQuestionDismiss = () => {
if (questionTag) setDismissedQuestionTag(questionTag)
}
- const showActions = shouldShowAssistantMessageActions({
- phase,
+ // Settle timing lives in MessageContent (the actions take the thinking
+ // slot's place in the same render), so eligibility here is phase-free:
+ // `phase: 'settled'` asks the helper "would a settled turn show them?".
+ const actionsEligible = shouldShowAssistantMessageActions({
+ phase: 'settled',
hasContent: Boolean(message.content) || hasAnyBlocks,
endsWithQuestion,
questionDismissed,
@@ -238,21 +235,22 @@ const AssistantMessageRow = memo(function AssistantMessageRow({
blocks={blocks}
fallbackContent={message.content}
isStreaming={isStreaming}
+ isLast={isLast}
questionAnswers={questionAnswers}
onOptionSelect={onOptionSelect}
onQuestionDismiss={handleQuestionDismiss}
onPhaseChange={setPhase}
+ actions={
+ actionsEligible ? (
+
+ ) : undefined
+ }
/>
- {showActions && (
-
-
-
- )}
)
})
@@ -295,6 +293,45 @@ export function MothershipChat({
const [lastRowAnimating, setLastRowAnimating] = useState(false)
const scrollElementRef = useRef(null)
const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating)
+ const sizerRef = useRef(null)
+ const scrollerPaddingRef = useRef<{ top: number; bottom: number } | null>(null)
+
+ /**
+ * Sizer floor while streaming: `scrollHeight` must never dip below the
+ * current viewport bottom. Streaming markdown re-parse emits transient
+ * row-height shrinks; when they pull scrollHeight under
+ * `scrollTop + clientHeight`, the browser clamps `scrollTop` and the pinned
+ * transcript visibly drops, then the chase glides it back. Flooring the
+ * sizer at exactly the scrolled-to extent prevents that clamp while never
+ * ADDING space — the floor cannot exceed what is already on screen. So an
+ * estimate correction (a fresh row measuring smaller than
+ * ROW_HEIGHT_ESTIMATE) releases immediately instead of holding phantom space
+ * the chase would scroll into and bounce back out of.
+ *
+ * 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.
+ */
+ const floorActive = isStreamActive || lastRowAnimating
+ useLayoutEffect(() => {
+ const sizer = sizerRef.current
+ const el = scrollElementRef.current
+ if (!sizer || !el) return
+ if (!floorActive) {
+ sizer.style.minHeight = ''
+ return
+ }
+ if (!scrollerPaddingRef.current) {
+ const style = getComputedStyle(el)
+ scrollerPaddingRef.current = {
+ top: Number.parseFloat(style.paddingTop),
+ bottom: Number.parseFloat(style.paddingBottom),
+ }
+ }
+ const padding = scrollerPaddingRef.current
+ const floor = Math.max(0, el.scrollTop + el.clientHeight - padding.top - padding.bottom)
+ sizer.style.minHeight = `${floor}px`
+ })
const setScrollElement = useCallback(
(el: HTMLDivElement | null) => {
scrollElementRef.current = el
@@ -509,7 +546,11 @@ export function MothershipChat({
{isLoading && !hasMessages ? (
) : (
-
+
{virtualItems.map((virtualItem) => {
const index = virtualItem.index
const msg = messages[index]
@@ -537,6 +578,7 @@ export function MothershipChat({
{
- if (!stickyRef.current) return
- const until = performance.now() + durationMs
- let lastTop = -1
- const follow = () => {
- if (performance.now() > until || !stickyRef.current) return
- if (lastTop >= 0 && el.scrollTop < lastTop - 1) return
- const gap = el.scrollHeight - el.clientHeight - el.scrollTop
- if (gap > CHASE_REST_GAP) {
- el.scrollTop = el.scrollTop + Math.max(1, gap * SMOOTH_CHASE_RATE)
- }
- lastTop = el.scrollTop
- requestAnimationFrame(follow)
- }
- requestAnimationFrame(follow)
- }
-
/**
* CSS-driven height animations (e.g. Radix Collapsible expanding mid-stream)
* grow scrollHeight without triggering MutationObserver, so auto-scroll stops
- * following. Follow for a short window so the container stays pinned while the
- * animation runs.
+ * 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 = () => followToBottom(ANIMATION_FOLLOW_WINDOW)
+ const onAnimationStart = () => chase.kickUntil(ANIMATION_FOLLOW_WINDOW)
el.addEventListener('wheel', onWheel, { passive: true })
el.addEventListener('touchstart', onTouchStart, { passive: true })
@@ -262,7 +234,10 @@ export function useAutoScroll(
chase.cancel()
pointerDownRef.current = false
lastUserGestureAtRef.current = Number.NEGATIVE_INFINITY
- followToBottom(POST_STREAM_SETTLE_WINDOW)
+ // 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)
}
}, [isStreaming, scrollToBottom])
diff --git a/apps/sim/lib/core/utils/smooth-bottom-chase.ts b/apps/sim/lib/core/utils/smooth-bottom-chase.ts
index 1f6dea0af16..90c3b9d8e84 100644
--- a/apps/sim/lib/core/utils/smooth-bottom-chase.ts
+++ b/apps/sim/lib/core/utils/smooth-bottom-chase.ts
@@ -2,12 +2,12 @@
* Fraction of the remaining gap to close per frame while chasing the bottom —
* an exponential glide (originating in the subagent viewport's stick-to-bottom,
* see BoundedViewport in agent-group.tsx) instead of snapping `scrollTop` to
- * `scrollHeight` on every content append. Closes ~90% of any gap within ~18
- * frames (~300ms) — deliberately lazier than the subagent viewport's 0.18 so a
- * large content burst reads as a calm upward drift of the transcript rather
+ * `scrollHeight` on every content append. Closes ~90% of any gap within ~13
+ * frames (~220ms) — slightly lazier than the subagent viewport's 0.18 so a
+ * large content burst reads as a brisk upward drift of the transcript rather
* than a lurch.
*/
-export const SMOOTH_CHASE_RATE = 0.12
+export const SMOOTH_CHASE_RATE = 0.16
/** Gap (px) below which the chase parks until new growth reopens it. */
export const CHASE_REST_GAP = 0.5
@@ -26,6 +26,14 @@ 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
}
@@ -47,11 +55,15 @@ 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 = () => {
@@ -71,7 +83,14 @@ export function createSmoothBottomChase(
}
const gap = target.getBottomTop() - top
if (gap <= CHASE_REST_GAP) {
- park()
+ // 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)
return
}
target.setTop(top + Math.max(1, gap * SMOOTH_CHASE_RATE))
@@ -82,10 +101,25 @@ export function createSmoothBottomChase(
raf = requestAnimationFrame(step)
}
+ /**
+ * 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).
+ */
+ const start = () => {
+ if (raf !== null) return
+ lastTop = target.getTop()
+ raf = requestAnimationFrame(step)
+ }
+
return {
isActive: () => raf !== null,
- kick: () => {
- if (raf === null) raf = requestAnimationFrame(step)
+ kick: start,
+ kickUntil: (durationMs: number) => {
+ deadline = Math.max(deadline, performance.now() + durationMs)
+ start()
},
cancel: park,
}