Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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({
Expand All @@ -409,6 +413,7 @@ function ChatContentInner({
onWorkspaceResourceSelect,
onRevealStateChange,
onStreamActivityChange,
onPendingTagChange,
}: ChatContentProps) {
const onWorkspaceResourceSelectRef = useRef(onWorkspaceResourceSelect)
onWorkspaceResourceSelectRef.current = onWorkspaceResourceSelect
Expand Down Expand Up @@ -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])
Comment thread
TheodoreSpeaks marked this conversation as resolved.

const hasPendingTag = parsed.hasPendingTag && isRevealing
useEffect(() => {
onPendingTagChange?.(hasPendingTag)
return () => onPendingTagChange?.(false)
}, [hasPendingTag, onPendingTagChange])

type BlockSegment = Exclude<
ContentSegment,
Expand Down Expand Up @@ -624,7 +634,6 @@ function ChatContentInner({
/>
)
})}
{hasPendingIndicator && <PendingTagIndicator />}
</div>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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()
})
})
Loading
Loading