Skip to content

Commit dcbe7c6

Browse files
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
1 parent c22ac38 commit dcbe7c6

3 files changed

Lines changed: 114 additions & 64 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx

Lines changed: 107 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
'use client'
22

3-
import { useEffect, useRef, useState } from 'react'
3+
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
44
import { ChevronDown, Expandable, ExpandableContent, PillsRing } from '@/components/emcn'
55
import { cn } from '@/lib/core/utils/cn'
66
import type { ToolCallData } from '../../../../types'
77
import { getAgentIcon } from '../../utils'
8-
import { ThinkingBlock } from '../thinking-block'
98
import { ToolCallItem } from './tool-call-item'
109

1110
export type AgentGroupItem =
1211
| { type: 'text'; content: string }
13-
| { type: 'thinking'; content: string; startedAt?: number; endedAt?: number }
1412
| { type: 'tool'; data: ToolCallData }
1513

1614
interface AgentGroupProps {
@@ -113,52 +111,117 @@ export function AgentGroup({
113111
{hasItems && (
114112
<Expandable expanded={expanded}>
115113
<ExpandableContent>
116-
<div className='flex flex-col gap-1.5 pt-0.5'>
117-
{items.map((item, idx) => {
118-
if (item.type === 'tool') {
119-
return (
120-
<ToolCallItem
121-
key={item.data.id}
122-
toolName={item.data.toolName}
123-
displayTitle={item.data.displayTitle}
124-
status={item.data.status}
125-
streamingArgs={item.data.streamingArgs}
126-
/>
127-
)
128-
}
129-
if (item.type === 'thinking') {
130-
const elapsedMs =
131-
item.startedAt !== undefined && item.endedAt !== undefined
132-
? item.endedAt - item.startedAt
133-
: undefined
134-
if (elapsedMs !== undefined && elapsedMs <= 3000) return null
135-
return (
136-
<div key={`thinking-${idx}`} className='pl-6'>
137-
<ThinkingBlock
138-
content={item.content}
139-
isActive={
140-
isStreaming && idx === items.length - 1 && item.endedAt === undefined
141-
}
142-
isStreaming={isStreaming}
143-
startedAt={item.startedAt}
144-
endedAt={item.endedAt}
114+
<BoundedViewport isStreaming={isStreaming}>
115+
<div className='flex flex-col gap-1.5 py-0.5'>
116+
{items.map((item, idx) => {
117+
if (item.type === 'tool') {
118+
return (
119+
<ToolCallItem
120+
key={item.data.id}
121+
toolName={item.data.toolName}
122+
displayTitle={item.data.displayTitle}
123+
status={item.data.status}
124+
streamingArgs={item.data.streamingArgs}
145125
/>
146-
</div>
126+
)
127+
}
128+
return (
129+
<span
130+
key={`text-${idx}`}
131+
className='pl-6 font-base text-[13px] text-[var(--text-secondary)] leading-[18px] opacity-60'
132+
>
133+
{item.content.trim()}
134+
</span>
147135
)
148-
}
149-
return (
150-
<span
151-
key={`text-${idx}`}
152-
className='pl-6 font-base text-[var(--text-secondary)] text-small'
153-
>
154-
{item.content.trim()}
155-
</span>
156-
)
157-
})}
158-
</div>
136+
})}
137+
</div>
138+
</BoundedViewport>
159139
</ExpandableContent>
160140
</Expandable>
161141
)}
162142
</div>
163143
)
164144
}
145+
146+
interface BoundedViewportProps {
147+
children: React.ReactNode
148+
isStreaming: boolean
149+
}
150+
151+
const BOTTOM_STICK_THRESHOLD_PX = 8
152+
153+
function BoundedViewport({ children, isStreaming }: BoundedViewportProps) {
154+
const ref = useRef<HTMLDivElement>(null)
155+
const rafRef = useRef<number | null>(null)
156+
const stickToBottomRef = useRef(true)
157+
const [hasOverflow, setHasOverflow] = useState(false)
158+
159+
useEffect(() => {
160+
const el = ref.current
161+
if (!el) return
162+
// Any upward user input detaches auto-stick. A subsequent scroll-to-bottom
163+
// (wheel back down or dragging scrollbar) re-attaches it.
164+
const handleWheel = (e: WheelEvent) => {
165+
if (e.deltaY < 0) stickToBottomRef.current = false
166+
}
167+
const handleScroll = () => {
168+
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
169+
if (distance < BOTTOM_STICK_THRESHOLD_PX) stickToBottomRef.current = true
170+
}
171+
el.addEventListener('wheel', handleWheel, { passive: true })
172+
el.addEventListener('scroll', handleScroll, { passive: true })
173+
return () => {
174+
el.removeEventListener('wheel', handleWheel)
175+
el.removeEventListener('scroll', handleScroll)
176+
}
177+
}, [])
178+
179+
useLayoutEffect(() => {
180+
const el = ref.current
181+
if (el) {
182+
const next = el.scrollHeight > el.clientHeight
183+
setHasOverflow((prev) => (prev === next ? prev : next))
184+
}
185+
if (rafRef.current !== null) {
186+
window.cancelAnimationFrame(rafRef.current)
187+
rafRef.current = null
188+
}
189+
if (!isStreaming) return
190+
const tick = () => {
191+
const node = ref.current
192+
if (!node || !stickToBottomRef.current) {
193+
rafRef.current = null
194+
return
195+
}
196+
const target = node.scrollHeight - node.clientHeight
197+
const gap = target - node.scrollTop
198+
if (gap < 1) {
199+
rafRef.current = null
200+
return
201+
}
202+
node.scrollTop = node.scrollTop + Math.max(1, gap * 0.18)
203+
rafRef.current = window.requestAnimationFrame(tick)
204+
}
205+
rafRef.current = window.requestAnimationFrame(tick)
206+
return () => {
207+
if (rafRef.current !== null) {
208+
window.cancelAnimationFrame(rafRef.current)
209+
rafRef.current = null
210+
}
211+
}
212+
})
213+
214+
return (
215+
<div className='relative'>
216+
<div ref={ref} className={cn('max-h-[110px] overflow-y-auto pr-2', hasOverflow && 'py-1')}>
217+
{children}
218+
</div>
219+
{hasOverflow && (
220+
<>
221+
<div className='pointer-events-none absolute top-0 right-2 left-0 h-3 bg-gradient-to-b from-[var(--bg)] to-transparent' />
222+
<div className='pointer-events-none absolute right-2 bottom-0 left-0 h-3 bg-gradient-to-t from-[var(--bg)] to-transparent' />
223+
</>
224+
)}
225+
</div>
226+
)
227+
}

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
164164
for (let i = 0; i < blocks.length; i++) {
165165
const block = blocks[i]
166166

167-
if (block.type === 'subagent_text') {
167+
if (block.type === 'subagent_text' || block.type === 'subagent_thinking') {
168168
if (!block.content || !group) continue
169169
group.isDelegating = false
170170
const lastItem = group.items[group.items.length - 1]
@@ -176,24 +176,6 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
176176
continue
177177
}
178178

179-
if (block.type === 'subagent_thinking') {
180-
if (!block.content || !group) continue
181-
group.isDelegating = false
182-
const lastItem = group.items[group.items.length - 1]
183-
if (lastItem?.type === 'thinking' && lastItem.endedAt === undefined) {
184-
lastItem.content += block.content
185-
if (block.endedAt !== undefined) lastItem.endedAt = block.endedAt
186-
} else {
187-
group.items.push({
188-
type: 'thinking',
189-
content: block.content,
190-
startedAt: block.timestamp,
191-
endedAt: block.endedAt,
192-
})
193-
}
194-
continue
195-
}
196-
197179
if (block.type === 'thinking') {
198180
if (!block.content?.trim()) continue
199181
if (group) {

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3001,7 +3001,12 @@ export function useChat(
30013001
...timing,
30023002
}
30033003
}
3004-
return { type: block.type, content: block.content, ...timing }
3004+
return {
3005+
type: block.type,
3006+
content: block.content,
3007+
...(block.subagent ? { lane: 'subagent' } : {}),
3008+
...timing,
3009+
}
30053010
})
30063011

30073012
if (storedBlocks.length > 0) {

0 commit comments

Comments
 (0)