-
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
base: staging
Are you sure you want to change the base?
Changes from all commits
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,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]) | ||||||||||||
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]'> | ||||||||||||
| <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={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> | ||||||||||||
| ) | ||||||||||||
| } | ||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lastTextIdxscan should be memoizedThe backward linear scan runs on every render, which happens on every SSE flush during streaming. For an
AgentGroupwith many items this is fine today (small N), but the scan is executed inside the component body without any memoization guard. Sinceitemsis a prop that changes reference on every parent re-render, wrapping this inuseMemomakes the intent explicit and avoids the scan on renders whereitemshasn't changed.You'll also need to add
useMemoto the import at line 3: