-
Notifications
You must be signed in to change notification settings - Fork 3.4k
fix(chat): render subagent content as collapsible thinking blocks #3602
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
91e1edd
756e86d
919265e
1805b36
5eb93dd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,176 @@ | ||||||||||||
| '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' | ||||||||||||
|
|
||||||||||||
| 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] text-[var(--text-muted)] leading-[1.4]'> | ||||||||||||
| {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) | ||||||||||||
| userCollapsedRef.current = false | ||||||||||||
| } | ||||||||||||
| }, [isStreaming, hasContent]) | ||||||||||||
waleedlatif1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||
|
|
||||||||||||
| 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) ?? '…'}` | ||||||||||||
waleedlatif1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||
|
|
||||||||||||
| return ( | ||||||||||||
| <div className='pl-[24px]'> | ||||||||||||
| <style>{` | ||||||||||||
| @keyframes subagent-shimmer { | ||||||||||||
| 0% { background-position: 150% 0; } | ||||||||||||
| 50% { background-position: 0% 0; } | ||||||||||||
| 100% { background-position: -150% 0; } | ||||||||||||
| } | ||||||||||||
| `}</style> | ||||||||||||
waleedlatif1 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||
|
|
||||||||||||
| <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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Button clickable but visually inert when streaming with empty content
A user who clicks the "Thinking" label impatiently (before content) will then see the block never auto-expand, since
Suggested change
This disables the button until content exists, regardless of streaming state. The auto-expand effect already handles opening the block as soon as |
||||||||||||
| > | ||||||||||||
| <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='block text-transparent' | ||||||||||||
| style={{ | ||||||||||||
| backgroundImage: | ||||||||||||
| 'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)', | ||||||||||||
| backgroundSize: '200% 100%', | ||||||||||||
| backgroundRepeat: 'no-repeat', | ||||||||||||
| WebkitBackgroundClip: 'text', | ||||||||||||
| backgroundClip: 'text', | ||||||||||||
| animation: 'subagent-shimmer 1.4s ease-in-out infinite', | ||||||||||||
| mixBlendMode: 'screen', | ||||||||||||
| }} | ||||||||||||
| > | ||||||||||||
| {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 |
|---|---|---|
|
|
@@ -78,6 +78,7 @@ function mapStoredBlock(block: TaskStoredContentBlock): ContentBlock { | |
| const mapped: ContentBlock = { | ||
| type: block.type as ContentBlockType, | ||
| content: block.content, | ||
| duration: block.duration, | ||
| } | ||
|
|
||
| if (block.type === 'tool_call' && block.toolCall) { | ||
|
|
@@ -486,6 +487,7 @@ export function useChat( | |
| const toolArgsMap = new Map<string, Record<string, unknown>>() | ||
| const clientExecutionStarted = new Set<string>() | ||
| let activeSubagent: string | undefined | ||
| let subagentStartTime: number | undefined | ||
| let runningText = '' | ||
| let lastContentSource: 'main' | 'subagent' | null = null | ||
|
|
||
|
|
@@ -569,20 +571,44 @@ export function useChat( | |
| } | ||
| break | ||
| } | ||
| case 'reasoning': { | ||
| if (!activeSubagent) break | ||
| const reasoningChunk = | ||
| typeof parsed.data === 'string' ? parsed.data : (parsed.content ?? '') | ||
| if (reasoningChunk) { | ||
| const last = blocks[blocks.length - 1] | ||
| if (last?.type === 'subagent_text') { | ||
| last.content = (last.content ?? '') + reasoningChunk | ||
| } else { | ||
| blocks.push({ type: 'subagent_text', content: reasoningChunk }) | ||
| } | ||
| lastContentSource = 'subagent' | ||
| flush() | ||
| } | ||
| break | ||
waleedlatif1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| case 'content': { | ||
| const chunk = typeof parsed.data === 'string' ? parsed.data : (parsed.content ?? '') | ||
| if (chunk) { | ||
| const contentSource: 'main' | 'subagent' = activeSubagent ? 'subagent' : 'main' | ||
| const needsBoundaryNewline = | ||
| lastContentSource !== null && | ||
| lastContentSource !== contentSource && | ||
| runningText.length > 0 && | ||
| !runningText.endsWith('\n') | ||
| const tb = ensureTextBlock() | ||
| const normalizedChunk = needsBoundaryNewline ? `\n${chunk}` : chunk | ||
| tb.content = (tb.content ?? '') + normalizedChunk | ||
| runningText += normalizedChunk | ||
| lastContentSource = contentSource | ||
| if (activeSubagent) { | ||
| const last = blocks[blocks.length - 1] | ||
| if (last?.type === 'subagent_text') { | ||
| last.content = (last.content ?? '') + chunk | ||
| } else { | ||
| blocks.push({ type: 'subagent_text', content: chunk }) | ||
| } | ||
| lastContentSource = 'subagent' | ||
| } else { | ||
| const needsBoundaryNewline = | ||
| lastContentSource === 'subagent' && | ||
| runningText.length > 0 && | ||
| !runningText.endsWith('\n') | ||
| const tb = ensureTextBlock() | ||
| const normalizedChunk = needsBoundaryNewline ? `\n${chunk}` : chunk | ||
| tb.content = (tb.content ?? '') + normalizedChunk | ||
| runningText += normalizedChunk | ||
| lastContentSource = 'main' | ||
| } | ||
| streamingContentRef.current = runningText | ||
| flush() | ||
waleedlatif1 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
@@ -744,13 +770,25 @@ export function useChat( | |
| const name = parsed.subagent || getPayloadData(parsed)?.agent | ||
| if (name) { | ||
| activeSubagent = name | ||
| subagentStartTime = Date.now() | ||
| blocks.push({ type: 'subagent', content: name }) | ||
| flush() | ||
| } | ||
| break | ||
| } | ||
| case 'subagent_end': { | ||
| // Subagents are flat (not nested) — stamp duration on the most recent marker | ||
| if (subagentStartTime != null) { | ||
| const elapsed = Date.now() - subagentStartTime | ||
| for (let j = blocks.length - 1; j >= 0; j--) { | ||
| if (blocks[j].type === 'subagent') { | ||
| blocks[j].duration = elapsed | ||
| break | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replayed SSE events produce near-zero subagent durationsLow Severity During SSE stream reconnection, all cached batch events are enqueued as a single chunk and processed synchronously. Additional Locations (1) |
||
| } | ||
| activeSubagent = undefined | ||
| subagentStartTime = undefined | ||
| flush() | ||
| break | ||
| } | ||
|
|
@@ -800,7 +838,11 @@ export function useChat( | |
| }, | ||
| } | ||
| } | ||
| return { type: block.type, content: block.content } | ||
| return { | ||
| type: block.type, | ||
| content: block.content, | ||
| ...(block.type === 'subagent' && block.duration != null && { duration: block.duration }), | ||
| } | ||
| }) | ||
|
|
||
| if (storedBlocks.length > 0) { | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.