improvement(mothership): stable thinking indicator and jump-free streaming scroll (#5828)

* improvement(mothership): stable thinking indicator and jump-free streaming scroll

* fix(mothership): suppress shimmer over executing tool rows and seed chase interrupt baseline

* fix(mothership): keep shimmer mounted through the slot collapse so it animates out

* improvement(mothership): tighten transcript bottom padding, timed slot-exit latch, cleanup pass

* fix(mothership): bridge hidden special-tag streaming with the shimmer

* fix(mothership): hold sizer floor through reveal and reset chase deadline on park

* improvement(mothership): swap actions into the thinking slot at settle, quicken the chase
This commit is contained in:
Theodore Li
2026-07-21 22:11:40 -04:00
committed by GitHub
parent 1b48eeb991
commit 2cbecb5baa
7 changed files with 333 additions and 237 deletions
@@ -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 && <PendingTagIndicator />}
</div>
)
}
@@ -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 (
<div className='animate-stream-fade-in py-2'>
<ThinkingLoader size={20} startVariant='corners' label='Thinking…' labelRatio={0.7} />
<ThinkingLoader size={20} startVariant='corners' label={label} labelRatio={0.7} />
</div>
)
}
@@ -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()
})
})
@@ -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 (
<div className='space-y-[10px]'>
<PendingTagIndicator />
</div>
)
}
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 (
<div className='space-y-[10px]'>
{segments.map((segment, i) => {
switch (segment.type) {
case 'text':
return (
<ChatContent
key={segment.id}
content={segment.content}
isStreaming={shouldSmoothTextSegment({
isStreaming,
segmentIndex: i,
segmentCount: segments.length,
})}
questionAnswers={questionAnswers}
onOptionSelect={onOptionSelect}
onQuestionDismiss={onQuestionDismiss}
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
onRevealStateChange={
i === segments.length - 1 ? handleTrailingRevealChange : undefined
}
onStreamActivityChange={
i === segments.length - 1 ? handleTrailingStreamActivityChange : undefined
}
/>
)
case 'agent_group': {
return (
<div key={segment.id} className={isStreaming ? 'animate-stream-fade-in' : undefined}>
<AgentGroup
<div>
<div className='space-y-[10px]'>
{segments.map((segment, i) => {
switch (segment.type) {
case 'text':
return (
<ChatContent
key={segment.id}
agentName={segment.agentName}
agentLabel={segment.agentLabel}
items={segment.items}
isDelegating={segment.isDelegating}
isStreaming={isStreaming}
isCurrentSection={i === segments.length - 1}
isLaneOpen={segment.isOpen}
content={segment.content}
isStreaming={shouldSmoothTextSegment({
isStreaming,
segmentIndex: i,
segmentCount: segments.length,
})}
questionAnswers={questionAnswers}
onOptionSelect={onOptionSelect}
onQuestionDismiss={onQuestionDismiss}
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
onRevealStateChange={
i === segments.length - 1 ? handleTrailingRevealChange : undefined
}
onStreamActivityChange={
i === segments.length - 1 ? handleTrailingStreamActivityChange : undefined
}
onPendingTagChange={
i === segments.length - 1 ? handleTrailingPendingTagChange : undefined
}
/>
</div>
)
)
case 'agent_group': {
return (
<div
key={segment.id}
className={isStreaming ? 'animate-stream-fade-in' : undefined}
>
<AgentGroup
key={segment.id}
agentName={segment.agentName}
agentLabel={segment.agentLabel}
items={segment.items}
isDelegating={segment.isDelegating}
isStreaming={isStreaming}
isCurrentSection={i === segments.length - 1}
isLaneOpen={segment.isOpen}
/>
</div>
)
}
case 'options':
return (
<div
key={`options-${i}`}
className={isStreaming ? 'animate-stream-fade-in' : undefined}
>
<Options items={segment.items} onSelect={onOptionSelect} />
</div>
)
case 'stopped':
return (
<div key={`stopped-${i}`} className='flex items-center gap-[8px]'>
<CircleStop className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='text-[14px] text-[var(--text-body)]'>Stopped by user</span>
</div>
)
}
case 'options':
return (
<div
key={`options-${i}`}
className={isStreaming ? 'animate-stream-fade-in' : undefined}
>
<Options items={segment.items} onSelect={onOptionSelect} />
</div>
)
case 'stopped':
return (
<div key={`stopped-${i}`} className='flex items-center gap-[8px]'>
<CircleStop className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='text-[14px] text-[var(--text-body)]'>Stopped by user</span>
</div>
)
}
})}
{showTrailingThinking && <PendingTagIndicator />}
})}
</div>
{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.
<div aria-hidden={!showShimmer} className='pt-[10px]'>
<div
className={cn(
'transition-opacity duration-200 ease-out',
showShimmer ? 'opacity-100' : 'opacity-0'
)}
>
<PendingTagIndicator label={thinkingLabel ?? 'Thinking…'} />
</div>
</div>
) : (
// 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 && <div className='mt-2.5'>{actions}</div>
)}
</div>
)
}
@@ -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 <PendingTagIndicator />
}
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,
@@ -245,21 +242,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 ? (
<MessageActions
content={message.content}
userQuery={precedingUserContent}
requestId={message.requestId}
messageId={message.id}
/>
) : undefined
}
/>
{showActions && (
<div className='mt-2.5'>
<MessageActions
content={message.content}
userQuery={precedingUserContent}
requestId={message.requestId}
messageId={message.id}
/>
</div>
)}
</div>
)
})
@@ -302,6 +300,45 @@ export function MothershipChat({
const [lastRowAnimating, setLastRowAnimating] = useState(false)
const scrollElementRef = useRef<HTMLDivElement | null>(null)
const { ref: autoScrollRef } = useAutoScroll(isStreamActive || lastRowAnimating)
const sizerRef = useRef<HTMLDivElement | null>(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
@@ -516,7 +553,11 @@ export function MothershipChat({
{isLoading && !hasMessages ? (
<MothershipChatSkeleton layout={layout} />
) : (
<div className={styles.sizer} style={{ height: virtualizer.getTotalSize() }}>
<div
ref={sizerRef}
className={styles.sizer}
style={{ height: virtualizer.getTotalSize() }}
>
{virtualItems.map((virtualItem) => {
const index = virtualItem.index
const msg = messages[index]
@@ -544,6 +585,7 @@ export function MothershipChat({
<AssistantMessageRow
message={msg}
isStreaming={isStreamActive && isLast}
isLast={isLast}
precedingUserContent={precedingUserContentByIndex[index]}
questionAnswers={questionPairing.answersByIndex[index]}
rowClassName={cn(styles.assistantRow, styles.rowGap)}
+15 -40
View File
@@ -1,9 +1,5 @@
import { useCallback, useEffect, useRef } from 'react'
import {
CHASE_REST_GAP,
createSmoothBottomChase,
SMOOTH_CHASE_RATE,
} from '@/lib/core/utils/smooth-bottom-chase'
import { createSmoothBottomChase } from '@/lib/core/utils/smooth-bottom-chase'
/** Tolerance for keeping stickiness during programmatic auto-scroll. */
const STICK_THRESHOLD = 30
@@ -34,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
@@ -199,41 +196,16 @@ export function useAutoScroll(
chase.kick()
}
/**
* Chase the bottom every frame for `durationMs` with the same eased step.
* Catches height growth that arrives over several frames with no observed
* DOM mutation — a CSS height animation, or end-of-turn content and the
* virtualizer's re-measure settling after streaming stops.
*
* Self-interrupting: our eased writes leave `scrollTop` exactly where we
* last put it, whereas a user scroll moves it up from there — so the moment
* `scrollTop` drops below our last write, we stop and never fight a real
* scroll, even with the gesture listeners already torn down.
*/
const followToBottom = (durationMs: number) => {
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])
+41 -7
View File
@@ -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,
}