Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ChevronDown } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { ToolCallData } from '../../../../types'
import { getAgentIcon } from '../../utils'
import { SubagentThinkingBlock } from './subagent-thinking-block'
import { ToolCallItem } from './tool-call-item'

export type AgentGroupItem =
Expand All @@ -16,6 +17,8 @@ interface AgentGroupProps {
agentLabel: string
items: AgentGroupItem[]
autoCollapse?: boolean
duration?: number
isStreaming?: boolean
}

const FADE_MS = 300
Expand All @@ -25,9 +28,18 @@ export function AgentGroup({
agentLabel,
items,
autoCollapse = false,
duration,
isStreaming: isStreamingProp = false,
}: AgentGroupProps) {
const AgentIcon = getAgentIcon(agentName)
const hasItems = items.length > 0
let lastTextIdx = -1
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].type === 'text') {
lastTextIdx = i
break
}
}
Comment on lines +36 to +42
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lastTextIdx scan should be memoized

The backward linear scan runs on every render, which happens on every SSE flush during streaming. For an AgentGroup with many items this is fine today (small N), but the scan is executed inside the component body without any memoization guard. Since items is a prop that changes reference on every parent re-render, wrapping this in useMemo makes the intent explicit and avoids the scan on renders where items hasn't changed.

Suggested change
let lastTextIdx = -1
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].type === 'text') {
lastTextIdx = i
break
}
}
const lastTextIdx = useMemo(() => {
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].type === 'text') return i
}
return -1
}, [items])

You'll also need to add useMemo to the import at line 3:

import { useEffect, useMemo, useRef, useState } from 'react'

const toolItems = items.filter(
(item): item is Extract<AgentGroupItem, { type: 'tool' }> => item.type === 'tool'
)
Expand Down Expand Up @@ -100,12 +112,12 @@ export function AgentGroup({
status={item.data.status}
/>
) : (
<p
<SubagentThinkingBlock
key={`text-${idx}`}
className='whitespace-pre-wrap pl-[24px] font-base text-[13px] text-[var(--text-secondary)]'
>
{item.content.trim()}
</p>
content={item.content}
isStreaming={!!isStreamingProp && duration === undefined && idx === lastTextIdx}
duration={duration}
/>
)
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
@keyframes subagent-shimmer {
0% {
background-position: 150% 0;
}
50% {
background-position: 0% 0;
}
100% {
background-position: -150% 0;
}
}

.shimmer {
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.85) 50%,
rgba(255, 255, 255, 0) 100%
);
background-size: 200% 100%;
background-repeat: no-repeat;
-webkit-background-clip: text;
background-clip: text;
animation: subagent-shimmer 1.4s ease-in-out infinite;
mix-blend-mode: screen;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
'use client'

import { memo, useEffect, useRef, useState } from 'react'
import { ChevronDown } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import styles from './subagent-thinking-block.module.css'

const CHARS_PER_FRAME = 3
const SCROLL_INTERVAL_MS = 50

interface SubagentThinkingBlockProps {
content: string
isStreaming: boolean
duration?: number
}

/**
* Streams text character-by-character via rAF during streaming,
* then snaps to full content when done.
*/
const StreamingText = memo(
({ content, isStreaming }: { content: string; isStreaming: boolean }) => {
const [displayed, setDisplayed] = useState(() => (isStreaming ? '' : content))
const contentRef = useRef(content)
const indexRef = useRef(isStreaming ? 0 : content.length)
const rafRef = useRef<number | null>(null)

useEffect(() => {
contentRef.current = content

if (!isStreaming) {
if (rafRef.current) cancelAnimationFrame(rafRef.current)
setDisplayed(content)
indexRef.current = content.length
return
}

if (indexRef.current >= content.length) return

const step = () => {
const cur = contentRef.current
const next = Math.min(indexRef.current + CHARS_PER_FRAME, cur.length)
indexRef.current = next
setDisplayed(cur.slice(0, next))
if (next < cur.length) {
rafRef.current = requestAnimationFrame(step)
}
}
rafRef.current = requestAnimationFrame(step)

return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current)
}
}, [content, isStreaming])

return (
<p className='whitespace-pre-wrap text-[12px] leading-[1.4] text-[var(--text-muted)]'>
{displayed}
</p>
)
},
(prev, next) => prev.content === next.content && prev.isStreaming === next.isStreaming
)
StreamingText.displayName = 'StreamingText'

/**
* Collapsible thinking block for subagent content in the home chat.
*
* Streaming: "Thinking" shimmer label, auto-expands with scrolling carousel.
* Done: collapses to "Thought for Xs", click to re-expand.
*/
export function SubagentThinkingBlock({
content,
isStreaming,
duration,
}: SubagentThinkingBlockProps) {
const trimmed = content.trim()
const hasContent = trimmed.length > 0

const [expanded, setExpanded] = useState(false)
const userCollapsedRef = useRef(false)
const scrollRef = useRef<HTMLDivElement>(null)

useEffect(() => {
if (isStreaming && hasContent && !userCollapsedRef.current) {
setExpanded(true)
}
if (!isStreaming) {
setExpanded(false)
// Reset so the next streaming session auto-expands by default
userCollapsedRef.current = false
}
}, [isStreaming, hasContent])

useEffect(() => {
if (!isStreaming || !expanded) return
const id = window.setInterval(() => {
const el = scrollRef.current
if (el) el.scrollTop = el.scrollHeight
}, SCROLL_INTERVAL_MS)
return () => window.clearInterval(id)
}, [isStreaming, expanded])

const toggle = () => {
setExpanded((v) => {
const next = !v
if (!next && isStreaming) userCollapsedRef.current = true
return next
})
}

const roundedMs = duration != null ? Math.max(1000, Math.round(duration / 1000) * 1000) : null
const label = isStreaming ? 'Thinking' : `Thought for ${formatDuration(roundedMs) ?? '…'}`

return (
<div className='pl-[24px]'>
<button
type='button'
onClick={toggle}
disabled={!hasContent && !isStreaming}
className='group inline-flex items-center gap-1 text-left text-[13px] text-[var(--text-secondary)] transition-colors hover:text-[var(--text-primary)]'
Comment on lines +119 to +122
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Button clickable but visually inert when streaming with empty content

disabled={!hasContent && !isStreaming} leaves the button enabled whenever isStreaming is true, even before any content has arrived. At that point there is no ChevronDown affordance and no visible content to expand — yet a click calls toggle(), sets expanded=true, and if the user clicks again it sets userCollapsedRef.current = true, permanently suppressing the auto-expand that fires when the first chunk arrives.

A user who clicks the "Thinking" label impatiently (before content) will then see the block never auto-expand, since userCollapsedRef has been set:

Suggested change
type='button'
onClick={toggle}
disabled={!hasContent && !isStreaming}
className='group inline-flex items-center gap-1 text-left text-[13px] text-[var(--text-secondary)] transition-colors hover:text-[var(--text-primary)]'
disabled={!hasContent}

This disables the button until content exists, regardless of streaming state. The auto-expand effect already handles opening the block as soon as hasContent becomes true during streaming, so there's no need for early interaction.

>
<span className='relative inline-block'>
<span className='text-[var(--text-tertiary)]'>{label}</span>
{isStreaming && (
<span
aria-hidden='true'
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
>
<span className={cn('block text-transparent', styles.shimmer)}>{label}</span>
</span>
)}
</span>
{hasContent && (
<ChevronDown
className={cn(
'h-[7px] w-[9px] transition-all group-hover:opacity-100',
expanded ? 'opacity-100' : '-rotate-90 opacity-0'
)}
/>
)}
</button>

<div
ref={scrollRef}
className={cn(
'overflow-y-auto transition-all duration-150 ease-out',
expanded ? 'mt-1 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
)}
>
<StreamingText content={trimmed} isStreaming={isStreaming} />
</div>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface AgentGroupSegment {
agentName: string
agentLabel: string
items: AgentGroupItem[]
duration?: number
}

interface OptionsSegment {
Expand Down Expand Up @@ -94,7 +95,10 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
if (block.type === 'subagent') {
if (!block.content) continue
const key = block.content
if (group && group.agentName === key) continue
if (group && group.agentName === key) {
if (block.duration != null) group.duration = block.duration
continue
}
if (group) {
segments.push(group)
group = null
Expand All @@ -105,6 +109,7 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
agentName: key,
agentLabel: resolveAgentLabel(key),
items: [],
duration: block.duration,
}
continue
}
Expand Down Expand Up @@ -246,6 +251,8 @@ export function MessageContent({
agentLabel={segment.agentLabel}
items={segment.items}
autoCollapse={allToolsDone && hasFollowingText}
duration={segment.duration}
isStreaming={isStreaming}
/>
</div>
)
Expand Down
Loading
Loading