mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user