Skip to content

Commit 8adbdb3

Browse files
feat(ui): render subagent actions in bounded box.
1 parent 65972f2 commit 8adbdb3

3 files changed

Lines changed: 103 additions & 64 deletions

File tree

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

Lines changed: 96 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,106 @@ 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+
158+
useEffect(() => {
159+
const el = ref.current
160+
if (!el) return
161+
// Any upward user input detaches auto-stick. A subsequent scroll-to-bottom
162+
// (wheel back down or dragging scrollbar) re-attaches it.
163+
const handleWheel = (e: WheelEvent) => {
164+
if (e.deltaY < 0) stickToBottomRef.current = false
165+
}
166+
const handleScroll = () => {
167+
const distance = el.scrollHeight - el.scrollTop - el.clientHeight
168+
if (distance < BOTTOM_STICK_THRESHOLD_PX) stickToBottomRef.current = true
169+
}
170+
el.addEventListener('wheel', handleWheel, { passive: true })
171+
el.addEventListener('scroll', handleScroll, { passive: true })
172+
return () => {
173+
el.removeEventListener('wheel', handleWheel)
174+
el.removeEventListener('scroll', handleScroll)
175+
}
176+
}, [])
177+
178+
useLayoutEffect(() => {
179+
if (rafRef.current !== null) {
180+
window.cancelAnimationFrame(rafRef.current)
181+
rafRef.current = null
182+
}
183+
if (!isStreaming) return
184+
const tick = () => {
185+
const node = ref.current
186+
if (!node || !stickToBottomRef.current) {
187+
rafRef.current = null
188+
return
189+
}
190+
const target = node.scrollHeight - node.clientHeight
191+
const gap = target - node.scrollTop
192+
if (gap < 1) {
193+
rafRef.current = null
194+
return
195+
}
196+
node.scrollTop = node.scrollTop + Math.max(1, gap * 0.18)
197+
rafRef.current = window.requestAnimationFrame(tick)
198+
}
199+
rafRef.current = window.requestAnimationFrame(tick)
200+
return () => {
201+
if (rafRef.current !== null) {
202+
window.cancelAnimationFrame(rafRef.current)
203+
rafRef.current = null
204+
}
205+
}
206+
})
207+
208+
return (
209+
<div
210+
ref={ref}
211+
className='max-h-[110px] overflow-y-auto pr-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
212+
>
213+
{children}
214+
</div>
215+
)
216+
}

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)