feat(ui): Show subagent logs in bounded vertical view (#4280)

* feat(ui): render subagent actions in bounded box.

* Add gradients and scroll bar

* fix lint
This commit is contained in:
Theodore Li
2026-04-23 15:41:53 -07:00
committed by GitHub
parent c22ac38ab0
commit dcbe7c69b0
3 changed files with 114 additions and 64 deletions

View File

@@ -1,16 +1,14 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { ChevronDown, Expandable, ExpandableContent, PillsRing } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { ToolCallData } from '../../../../types'
import { getAgentIcon } from '../../utils'
import { ThinkingBlock } from '../thinking-block'
import { ToolCallItem } from './tool-call-item'
export type AgentGroupItem =
| { type: 'text'; content: string }
| { type: 'thinking'; content: string; startedAt?: number; endedAt?: number }
| { type: 'tool'; data: ToolCallData }
interface AgentGroupProps {
@@ -113,52 +111,117 @@ export function AgentGroup({
{hasItems && (
<Expandable expanded={expanded}>
<ExpandableContent>
<div className='flex flex-col gap-1.5 pt-0.5'>
{items.map((item, idx) => {
if (item.type === 'tool') {
return (
<ToolCallItem
key={item.data.id}
toolName={item.data.toolName}
displayTitle={item.data.displayTitle}
status={item.data.status}
streamingArgs={item.data.streamingArgs}
/>
)
}
if (item.type === 'thinking') {
const elapsedMs =
item.startedAt !== undefined && item.endedAt !== undefined
? item.endedAt - item.startedAt
: undefined
if (elapsedMs !== undefined && elapsedMs <= 3000) return null
return (
<div key={`thinking-${idx}`} className='pl-6'>
<ThinkingBlock
content={item.content}
isActive={
isStreaming && idx === items.length - 1 && item.endedAt === undefined
}
isStreaming={isStreaming}
startedAt={item.startedAt}
endedAt={item.endedAt}
<BoundedViewport isStreaming={isStreaming}>
<div className='flex flex-col gap-1.5 py-0.5'>
{items.map((item, idx) => {
if (item.type === 'tool') {
return (
<ToolCallItem
key={item.data.id}
toolName={item.data.toolName}
displayTitle={item.data.displayTitle}
status={item.data.status}
streamingArgs={item.data.streamingArgs}
/>
</div>
)
}
return (
<span
key={`text-${idx}`}
className='pl-6 font-base text-[13px] text-[var(--text-secondary)] leading-[18px] opacity-60'
>
{item.content.trim()}
</span>
)
}
return (
<span
key={`text-${idx}`}
className='pl-6 font-base text-[var(--text-secondary)] text-small'
>
{item.content.trim()}
</span>
)
})}
</div>
})}
</div>
</BoundedViewport>
</ExpandableContent>
</Expandable>
)}
</div>
)
}
interface BoundedViewportProps {
children: React.ReactNode
isStreaming: boolean
}
const BOTTOM_STICK_THRESHOLD_PX = 8
function BoundedViewport({ children, isStreaming }: BoundedViewportProps) {
const ref = useRef<HTMLDivElement>(null)
const rafRef = useRef<number | null>(null)
const stickToBottomRef = useRef(true)
const [hasOverflow, setHasOverflow] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
// Any upward user input detaches auto-stick. A subsequent scroll-to-bottom
// (wheel back down or dragging scrollbar) re-attaches it.
const handleWheel = (e: WheelEvent) => {
if (e.deltaY < 0) stickToBottomRef.current = false
}
const handleScroll = () => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
if (distance < BOTTOM_STICK_THRESHOLD_PX) stickToBottomRef.current = true
}
el.addEventListener('wheel', handleWheel, { passive: true })
el.addEventListener('scroll', handleScroll, { passive: true })
return () => {
el.removeEventListener('wheel', handleWheel)
el.removeEventListener('scroll', handleScroll)
}
}, [])
useLayoutEffect(() => {
const el = ref.current
if (el) {
const next = el.scrollHeight > el.clientHeight
setHasOverflow((prev) => (prev === next ? prev : next))
}
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
if (!isStreaming) return
const tick = () => {
const node = ref.current
if (!node || !stickToBottomRef.current) {
rafRef.current = null
return
}
const target = node.scrollHeight - node.clientHeight
const gap = target - node.scrollTop
if (gap < 1) {
rafRef.current = null
return
}
node.scrollTop = node.scrollTop + Math.max(1, gap * 0.18)
rafRef.current = window.requestAnimationFrame(tick)
}
rafRef.current = window.requestAnimationFrame(tick)
return () => {
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
})
return (
<div className='relative'>
<div ref={ref} className={cn('max-h-[110px] overflow-y-auto pr-2', hasOverflow && 'py-1')}>
{children}
</div>
{hasOverflow && (
<>
<div className='pointer-events-none absolute top-0 right-2 left-0 h-3 bg-gradient-to-b from-[var(--bg)] to-transparent' />
<div className='pointer-events-none absolute right-2 bottom-0 left-0 h-3 bg-gradient-to-t from-[var(--bg)] to-transparent' />
</>
)}
</div>
)
}

View File

@@ -164,7 +164,7 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]
if (block.type === 'subagent_text') {
if (block.type === 'subagent_text' || block.type === 'subagent_thinking') {
if (!block.content || !group) continue
group.isDelegating = false
const lastItem = group.items[group.items.length - 1]
@@ -176,24 +176,6 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
continue
}
if (block.type === 'subagent_thinking') {
if (!block.content || !group) continue
group.isDelegating = false
const lastItem = group.items[group.items.length - 1]
if (lastItem?.type === 'thinking' && lastItem.endedAt === undefined) {
lastItem.content += block.content
if (block.endedAt !== undefined) lastItem.endedAt = block.endedAt
} else {
group.items.push({
type: 'thinking',
content: block.content,
startedAt: block.timestamp,
endedAt: block.endedAt,
})
}
continue
}
if (block.type === 'thinking') {
if (!block.content?.trim()) continue
if (group) {

View File

@@ -3001,7 +3001,12 @@ export function useChat(
...timing,
}
}
return { type: block.type, content: block.content, ...timing }
return {
type: block.type,
content: block.content,
...(block.subagent ? { lane: 'subagent' } : {}),
...timing,
}
})
if (storedBlocks.length > 0) {