mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
+15
-6
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-3
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+25
-57
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+164
-101
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+65
-23
@@ -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)}
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user