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