From 6bddf6f50bc5caf0dc8cb24b2db2f7d52c50af29 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 21 Jul 2026 17:07:40 -0700 Subject: [PATCH 1/7] improvement(mothership): stable thinking indicator and jump-free streaming scroll --- .../components/chat-content/chat-content.tsx | 9 +- .../components/special-tags/special-tags.tsx | 11 +- .../message-content/message-content.test.ts | 82 ++---- .../message-content/message-content.tsx | 248 +++++++++++------- .../mothership-chat/mothership-chat.tsx | 54 +++- apps/sim/hooks/use-auto-scroll.ts | 48 +--- .../sim/lib/core/utils/smooth-bottom-chase.ts | 22 +- 7 files changed, 261 insertions(+), 213 deletions(-) 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..97e19b74604 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,7 +395,7 @@ 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 or streaming a special tag. */ onStreamActivityChange?: (active: boolean) => void } @@ -530,12 +529,11 @@ 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]) type BlockSegment = Exclude< ContentSegment, @@ -624,7 +622,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..5a843431912 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,7 @@ 'use client' import { memo, 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 +19,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,32 +740,44 @@ 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 @@ -775,6 +789,7 @@ function MessageContentInner({ blocks, fallbackContent, isStreaming = false, + isLast = false, questionAnswers, onOptionSelect, onQuestionDismiss, @@ -802,8 +817,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 +841,126 @@ function MessageContentInner({ onPhaseChangeRef.current?.(phase) }, [phase]) - if (segments.length === 0) { - if (isStreaming) { - return ( -
- -
- ) - } - return null - } - - // 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. - const hasExecutingTool = assistantMessageHasVisibleExecutingTool(blocks) - const showTrailingThinking = shouldShowTrailingThinking({ - isStreaming: phase === 'streaming', - isStreamIdle, - isRenderingStream: trailingStreamActivity, - hasExecutingTool, - lastSegmentType: lastSegment.type, - }) + // Nothing to render yet and not the live tail: no slot, no blob. + if (segments.length === 0 && !isLast) return null + + // The thinking slot expands while the turn is in flight and collapses when it + // ends. It's the last message's own element, so it grows on send with the row + // (no separate mount → no jump) and slides out once, at the end. 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. The + // slot leaves only at true settle, after the reveal has finished painting. + const thinkingExpanded = phase !== 'settled' && lastSegment?.type !== 'stopped' + + // The shimmer stands in for the NEXT piece of output and yields the moment + // output actually arrives: hidden while the trailing text is visibly + // revealing or any lane streamed within the quiet period, shown otherwise. + // Immediately on an empty turn — nothing has arrived yet. A null label means + // a just-opened lane's delegating shimmer owns the state. + const thinkingLabel = deriveThinkingLabel(blocks) + const showShimmer = + thinkingExpanded && + thinkingLabel !== null && + (segments.length === 0 || (isStreamIdle && !trailingStreamActivity)) 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 ( + })} +
+ {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; the grid-rows collapse slides it out once, at + // settle. A sibling of the space-y stack (not a child), so collapsed + // it carries no leftover sibling margin — pt-[10px] is its own gap. +
+
+
- -
- ) - case 'stopped': - return ( -
- - Stopped by user + {thinkingExpanded && }
- ) - } - })} - {showTrailingThinking && } +
+
+
+ )}
) } 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..59559f4969c 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 @@ -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 @@ -238,6 +232,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ blocks={blocks} fallbackContent={message.content} isStreaming={isStreaming} + isLast={isLast} questionAnswers={questionAnswers} onOptionSelect={onOptionSelect} onQuestionDismiss={handleQuestionDismiss} @@ -295,6 +290,40 @@ 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. + */ + useLayoutEffect(() => { + const sizer = sizerRef.current + const el = scrollElementRef.current + if (!sizer || !el) return + if (!isStreamActive) { + 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 +538,11 @@ export function MothershipChat({ {isLoading && !hasMessages ? ( ) : ( -
+
{virtualItems.map((virtualItem) => { const index = virtualItem.index const msg = messages[index] @@ -537,6 +570,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 +233,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..3e90c926de2 100644 --- a/apps/sim/lib/core/utils/smooth-bottom-chase.ts +++ b/apps/sim/lib/core/utils/smooth-bottom-chase.ts @@ -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,6 +55,7 @@ export function createSmoothBottomChase( ): SmoothBottomChaseHandle { let raf: number | null = null let lastTop: number | null = null + let deadline = 0 const park = () => { if (raf !== null) cancelAnimationFrame(raf) @@ -71,7 +80,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)) @@ -87,6 +103,10 @@ export function createSmoothBottomChase( kick: () => { if (raf === null) raf = requestAnimationFrame(step) }, + kickUntil: (durationMs: number) => { + deadline = Math.max(deadline, performance.now() + durationMs) + if (raf === null) raf = requestAnimationFrame(step) + }, cancel: park, } } From 20ce8b578cf940a88d07f3a9dedac1bbce803ef0 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 21 Jul 2026 17:16:38 -0700 Subject: [PATCH 2/7] fix(mothership): suppress shimmer over executing tool rows and seed chase interrupt baseline --- .../message-content/message-content.tsx | 6 ++++-- apps/sim/lib/core/utils/smooth-bottom-chase.ts | 17 +++++++++++++---- 2 files changed, 17 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 5a843431912..f0208b90438 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 @@ -857,12 +857,14 @@ function MessageContentInner({ // output actually arrives: hidden while the trailing text is visibly // revealing or any lane streamed within the quiet period, shown otherwise. // Immediately on an empty turn — nothing has arrived yet. A null label means - // a just-opened lane's delegating shimmer owns the state. + // a just-opened lane's delegating shimmer owns the state, and a visible + // executing tool row already spins — the turn-level shimmer would double it. const thinkingLabel = deriveThinkingLabel(blocks) + const hasExecutingTool = assistantMessageHasVisibleExecutingTool(blocks) const showShimmer = thinkingExpanded && thinkingLabel !== null && - (segments.length === 0 || (isStreamIdle && !trailingStreamActivity)) + (segments.length === 0 || (isStreamIdle && !trailingStreamActivity && !hasExecutingTool)) return (
diff --git a/apps/sim/lib/core/utils/smooth-bottom-chase.ts b/apps/sim/lib/core/utils/smooth-bottom-chase.ts index 3e90c926de2..71e5933d5a9 100644 --- a/apps/sim/lib/core/utils/smooth-bottom-chase.ts +++ b/apps/sim/lib/core/utils/smooth-bottom-chase.ts @@ -98,14 +98,23 @@ 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) - if (raf === null) raf = requestAnimationFrame(step) + start() }, cancel: park, } From 23e38758b16a342c119723da9f50dd728159f594 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 21 Jul 2026 17:28:44 -0700 Subject: [PATCH 3/7] fix(mothership): keep shimmer mounted through the slot collapse so it animates out --- .../message-content/message-content.tsx | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) 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 f0208b90438..b01666cd095 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 @@ -807,6 +807,24 @@ function MessageContentInner({ setTrailingStreamActivity(active) }, []) const [isStreamIdle, setIsStreamIdle] = useState(false) + /** + * True once the slot's collapse transition has finished (seeded true so a + * settled mount never runs the loader). The loader must stay mounted through + * the collapse: with `grid-rows` transitioning 1fr→0fr, unmounting the + * content in the same flip zeroes the track instantly — the slot snaps + * instead of sliding out, clamping `scrollTop` at settle. + */ + const [slotExited, setSlotExited] = useState(true) + const handleSlotTransitionEnd = useCallback((event: React.TransitionEvent) => { + if ( + event.target === event.currentTarget && + event.propertyName === 'grid-template-rows' && + // Only the collapse's end exits the loader; the expand ends with a live track. + getComputedStyle(event.currentTarget).gridTemplateRows === '0px' + ) { + setSlotExited(true) + } + }, []) const segments: MessageSegment[] = parsed.length > 0 @@ -853,6 +871,9 @@ function MessageContentInner({ // slot leaves only at true settle, after the reveal has finished painting. const thinkingExpanded = phase !== 'settled' && lastSegment?.type !== 'stopped' + // Guarded render adjust (see sim-hooks): a live turn re-arms the exit latch. + if (thinkingExpanded && slotExited) setSlotExited(false) + // The shimmer stands in for the NEXT piece of output and yields the moment // output actually arrives: hidden while the trailing text is visibly // revealing or any lane streamed within the quiet period, shown otherwise. @@ -939,6 +960,7 @@ function MessageContentInner({ // it carries no leftover sibling margin — pt-[10px] is its own gap.
- {thinkingExpanded && } + {!slotExited && }
From 817c1c991e4032d23a964da4d6ec23a958d74d88 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 21 Jul 2026 18:05:03 -0700 Subject: [PATCH 4/7] improvement(mothership): tighten transcript bottom padding, timed slot-exit latch, cleanup pass --- .../message-content/message-content.tsx | 58 ++++++++----------- .../mothership-chat/mothership-chat.tsx | 2 +- .../sim/lib/core/utils/smooth-bottom-chase.ts | 12 ++-- 3 files changed, 33 insertions(+), 39 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 b01666cd095..2617a2d4052 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 @@ -21,6 +21,8 @@ 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 +/** Unmount the loader after the slot's 300ms collapse, with margin. */ +const SLOT_EXIT_DELAY_MS = 400 interface TextSegment { type: 'text' @@ -808,23 +810,15 @@ function MessageContentInner({ }, []) const [isStreamIdle, setIsStreamIdle] = useState(false) /** - * True once the slot's collapse transition has finished (seeded true so a - * settled mount never runs the loader). The loader must stay mounted through - * the collapse: with `grid-rows` transitioning 1fr→0fr, unmounting the - * content in the same flip zeroes the track instantly — the slot snaps - * instead of sliding out, clamping `scrollTop` at settle. + * True once the slot's collapse has finished (seeded true so a settled mount + * never runs the loader). The loader must stay mounted through the collapse: + * with `grid-rows` transitioning 1fr→0fr, unmounting the content in the same + * flip zeroes the track instantly — the slot snaps instead of sliding out, + * clamping `scrollTop` at settle. Timed rather than transitionend-latched: + * motion-reduce (`transition-none`) and browsers that can't animate + * `grid-template-rows` never fire the event. */ const [slotExited, setSlotExited] = useState(true) - const handleSlotTransitionEnd = useCallback((event: React.TransitionEvent) => { - if ( - event.target === event.currentTarget && - event.propertyName === 'grid-template-rows' && - // Only the collapse's end exits the loader; the expand ends with a live track. - getComputedStyle(event.currentTarget).gridTemplateRows === '0px' - ) { - setSlotExited(true) - } - }, []) const segments: MessageSegment[] = parsed.length > 0 @@ -859,27 +853,26 @@ function MessageContentInner({ onPhaseChangeRef.current?.(phase) }, [phase]) - // Nothing to render yet and not the live tail: no slot, no blob. - if (segments.length === 0 && !isLast) return null - - // The thinking slot expands while the turn is in flight and collapses when it - // ends. It's the last message's own element, so it grows on send with the row - // (no separate mount → no jump) and slides out once, at the end. 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. The - // slot leaves only at true settle, after the reveal has finished painting. + // 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' + useEffect(() => { + if (thinkingExpanded) return + const timeout = setTimeout(() => setSlotExited(true), SLOT_EXIT_DELAY_MS) + return () => clearTimeout(timeout) + }, [thinkingExpanded]) + // Guarded render adjust (see sim-hooks): a live turn re-arms the exit latch. if (thinkingExpanded && slotExited) setSlotExited(false) - // The shimmer stands in for the NEXT piece of output and yields the moment - // output actually arrives: hidden while the trailing text is visibly - // revealing or any lane streamed within the quiet period, shown otherwise. - // Immediately on an empty turn — nothing has arrived yet. A null label means - // a just-opened lane's delegating shimmer owns the state, and a visible - // executing tool row already spins — the turn-level shimmer would double it. + 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.) const thinkingLabel = deriveThinkingLabel(blocks) const hasExecutingTool = assistantMessageHasVisibleExecutingTool(blocks) const showShimmer = @@ -960,7 +953,6 @@ function MessageContentInner({ // it carries no leftover sibling margin — pt-[10px] is its own gap.
{ if (raf !== null) return lastTop = target.getTop() From 7c37401dd00d59c0451d23bab66673346d02fb62 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 21 Jul 2026 18:13:38 -0700 Subject: [PATCH 5/7] fix(mothership): bridge hidden special-tag streaming with the shimmer --- .../components/chat-content/chat-content.tsx | 14 +++++++++++++- .../components/message-content/message-content.tsx | 13 ++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) 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 97e19b74604..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 @@ -395,8 +395,13 @@ interface ChatContentProps { onQuestionDismiss?: () => void onWorkspaceResourceSelect?: (resource: MothershipResource) => void onRevealStateChange?: (isRevealing: boolean) => void - /** Reports whether this segment is actively painting text or streaming a special tag. */ + /** 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({ @@ -408,6 +413,7 @@ function ChatContentInner({ onWorkspaceResourceSelect, onRevealStateChange, onStreamActivityChange, + onPendingTagChange, }: ChatContentProps) { const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect) onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect @@ -535,6 +541,12 @@ function ChatContentInner({ return () => onStreamActivityChange?.(false) }, [hasRevealBacklog, onStreamActivityChange]) + const hasPendingTag = parsed.hasPendingTag && isRevealing + useEffect(() => { + onPendingTagChange?.(hasPendingTag) + return () => onPendingTagChange?.(false) + }, [hasPendingTag, onPendingTagChange]) + type BlockSegment = Exclude< ContentSegment, { type: 'text' } | { type: 'thinking' } | { type: 'workspace_resource' } 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 2617a2d4052..e60fd46b527 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 @@ -808,6 +808,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) /** * True once the slot's collapse has finished (seeded true so a settled mount @@ -873,12 +877,16 @@ function MessageContentInner({ // 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 showShimmer = thinkingExpanded && thinkingLabel !== null && - (segments.length === 0 || (isStreamIdle && !trailingStreamActivity && !hasExecutingTool)) + (segments.length === 0 || + trailingPendingTag || + (isStreamIdle && !trailingStreamActivity && !hasExecutingTool)) return (
@@ -905,6 +913,9 @@ function MessageContentInner({ onStreamActivityChange={ i === segments.length - 1 ? handleTrailingStreamActivityChange : undefined } + onPendingTagChange={ + i === segments.length - 1 ? handleTrailingPendingTagChange : undefined + } /> ) case 'agent_group': { From 8ac08dd2b3aa338c7600f5403a995e1c456ff7d6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 21 Jul 2026 18:20:54 -0700 Subject: [PATCH 6/7] fix(mothership): hold sizer floor through reveal and reset chase deadline on park --- .../home/components/mothership-chat/mothership-chat.tsx | 7 ++++++- apps/sim/lib/core/utils/smooth-bottom-chase.ts | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) 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 5d01bb3cf69..15fd5b924b6 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 @@ -304,12 +304,17 @@ export function MothershipChat({ * 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 (!isStreamActive) { + if (!floorActive) { sizer.style.minHeight = '' return } diff --git a/apps/sim/lib/core/utils/smooth-bottom-chase.ts b/apps/sim/lib/core/utils/smooth-bottom-chase.ts index 6ea13165b91..176e520e39b 100644 --- a/apps/sim/lib/core/utils/smooth-bottom-chase.ts +++ b/apps/sim/lib/core/utils/smooth-bottom-chase.ts @@ -61,6 +61,9 @@ export function createSmoothBottomChase( 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 = () => { From 65c9cf2e24ec75303e09d62a073def7310214b93 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 21 Jul 2026 18:32:21 -0700 Subject: [PATCH 7/7] improvement(mothership): swap actions into the thinking slot at settle, quicken the chase --- .../message-content/message-content.tsx | 84 ++++++++----------- .../mothership-chat/mothership-chat.tsx | 27 +++--- apps/sim/hooks/use-auto-scroll.ts | 7 +- .../sim/lib/core/utils/smooth-bottom-chase.ts | 8 +- 4 files changed, 58 insertions(+), 68 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 e60fd46b527..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,15 @@ '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' @@ -21,8 +30,6 @@ 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 -/** Unmount the loader after the slot's 300ms collapse, with margin. */ -const SLOT_EXIT_DELAY_MS = 400 interface TextSegment { type: 'text' @@ -785,6 +792,14 @@ interface MessageContentProps { 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({ @@ -796,6 +811,7 @@ function MessageContentInner({ onOptionSelect, onQuestionDismiss, onPhaseChange, + actions, }: MessageContentProps) { const { onWorkspaceResourceSelect } = useChatSurface() const parsed = useMemo(() => (blocks.length > 0 ? parseBlocks(blocks) : []), [blocks]) @@ -813,16 +829,6 @@ function MessageContentInner({ setTrailingPendingTag(pending) }, []) const [isStreamIdle, setIsStreamIdle] = useState(false) - /** - * True once the slot's collapse has finished (seeded true so a settled mount - * never runs the loader). The loader must stay mounted through the collapse: - * with `grid-rows` transitioning 1fr→0fr, unmounting the content in the same - * flip zeroes the track instantly — the slot snaps instead of sliding out, - * clamping `scrollTop` at settle. Timed rather than transitionend-latched: - * motion-reduce (`transition-none`) and browsers that can't animate - * `grid-template-rows` never fire the event. - */ - const [slotExited, setSlotExited] = useState(true) const segments: MessageSegment[] = parsed.length > 0 @@ -864,15 +870,6 @@ function MessageContentInner({ // winking out early while everything shifts. const thinkingExpanded = phase !== 'settled' && lastSegment?.type !== 'stopped' - useEffect(() => { - if (thinkingExpanded) return - const timeout = setTimeout(() => setSlotExited(true), SLOT_EXIT_DELAY_MS) - return () => clearTimeout(timeout) - }, [thinkingExpanded]) - - // Guarded render adjust (see sim-hooks): a live turn re-arms the exit latch. - if (thinkingExpanded && slotExited) setSlotExited(false) - if (segments.length === 0 && !isLast) return null // A visible executing tool row already spins — the turn-level shimmer would @@ -956,37 +953,26 @@ function MessageContentInner({ } })}
- {isLast && ( + {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; the grid-rows collapse slides it out once, at - // settle. A sibling of the space-y stack (not a child), so collapsed - // it carries no leftover sibling margin — pt-[10px] is its own gap. -
-
-
-
- {!slotExited && } -
-
+ // 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 15fd5b924b6..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 @@ -219,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, @@ -237,17 +240,17 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ onOptionSelect={onOptionSelect} onQuestionDismiss={handleQuestionDismiss} onPhaseChange={setPhase} + actions={ + actionsEligible ? ( + + ) : undefined + } /> - {showActions && ( -
- -
- )}
) }) diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index b5deae824fc..d5edf8977ba 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -30,9 +30,10 @@ 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 (gated on `!isStreaming`), 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. + * 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 diff --git a/apps/sim/lib/core/utils/smooth-bottom-chase.ts b/apps/sim/lib/core/utils/smooth-bottom-chase.ts index 176e520e39b..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