Skip to content

Commit 91e1edd

Browse files
committed
fix(home-chat): render subagent content as collapsible thinking blocks
1 parent 8906439 commit 91e1edd

File tree

6 files changed

+246
-17
lines changed

6 files changed

+246
-17
lines changed

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ChevronDown } from '@/components/emcn'
55
import { cn } from '@/lib/core/utils/cn'
66
import type { ToolCallData } from '../../../../types'
77
import { getAgentIcon } from '../../utils'
8+
import { SubagentThinkingBlock } from './subagent-thinking-block'
89
import { ToolCallItem } from './tool-call-item'
910

1011
export type AgentGroupItem =
@@ -16,6 +17,8 @@ interface AgentGroupProps {
1617
agentLabel: string
1718
items: AgentGroupItem[]
1819
autoCollapse?: boolean
20+
duration?: number
21+
isStreaming?: boolean
1922
}
2023

2124
const FADE_MS = 300
@@ -25,6 +28,8 @@ export function AgentGroup({
2528
agentLabel,
2629
items,
2730
autoCollapse = false,
31+
duration,
32+
isStreaming: isStreamingProp = false,
2833
}: AgentGroupProps) {
2934
const AgentIcon = getAgentIcon(agentName)
3035
const hasItems = items.length > 0
@@ -100,12 +105,12 @@ export function AgentGroup({
100105
status={item.data.status}
101106
/>
102107
) : (
103-
<p
108+
<SubagentThinkingBlock
104109
key={`text-${idx}`}
105-
className='whitespace-pre-wrap pl-[24px] font-base text-[13px] text-[var(--text-secondary)]'
106-
>
107-
{item.content.trim()}
108-
</p>
110+
content={item.content}
111+
isStreaming={!!isStreamingProp && duration === undefined}
112+
duration={duration}
113+
/>
109114
)
110115
)}
111116
</div>
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
'use client'
2+
3+
import { memo, useEffect, useRef, useState } from 'react'
4+
import { ChevronDown } from '@/components/emcn'
5+
import { cn } from '@/lib/core/utils/cn'
6+
import { formatDuration } from '@/lib/core/utils/formatting'
7+
8+
const CHARS_PER_FRAME = 3
9+
const SCROLL_INTERVAL_MS = 50
10+
11+
interface SubagentThinkingBlockProps {
12+
content: string
13+
isStreaming: boolean
14+
duration?: number
15+
}
16+
17+
/**
18+
* Streams text character-by-character via rAF during streaming,
19+
* then snaps to full content when done.
20+
*/
21+
const StreamingText = memo(
22+
({ content, isStreaming }: { content: string; isStreaming: boolean }) => {
23+
const [displayed, setDisplayed] = useState(() => (isStreaming ? '' : content))
24+
const contentRef = useRef(content)
25+
const indexRef = useRef(isStreaming ? 0 : content.length)
26+
const rafRef = useRef<number | null>(null)
27+
28+
useEffect(() => {
29+
contentRef.current = content
30+
31+
if (!isStreaming) {
32+
if (rafRef.current) cancelAnimationFrame(rafRef.current)
33+
setDisplayed(content)
34+
indexRef.current = content.length
35+
return
36+
}
37+
38+
if (indexRef.current >= content.length) return
39+
40+
const step = () => {
41+
const cur = contentRef.current
42+
const next = Math.min(indexRef.current + CHARS_PER_FRAME, cur.length)
43+
indexRef.current = next
44+
setDisplayed(cur.slice(0, next))
45+
if (next < cur.length) {
46+
rafRef.current = requestAnimationFrame(step)
47+
}
48+
}
49+
rafRef.current = requestAnimationFrame(step)
50+
51+
return () => {
52+
if (rafRef.current) cancelAnimationFrame(rafRef.current)
53+
}
54+
}, [content, isStreaming])
55+
56+
return (
57+
<p className='whitespace-pre-wrap text-[12px] text-[var(--text-muted)] leading-[1.4]'>
58+
{displayed}
59+
</p>
60+
)
61+
},
62+
(prev, next) => prev.content === next.content && prev.isStreaming === next.isStreaming
63+
)
64+
StreamingText.displayName = 'StreamingText'
65+
66+
/**
67+
* Collapsible thinking block for subagent content in the home chat.
68+
*
69+
* Streaming: "Thinking" shimmer label, auto-expands with scrolling carousel.
70+
* Done: collapses to "Thought for Xs", click to re-expand.
71+
*/
72+
export function SubagentThinkingBlock({
73+
content,
74+
isStreaming,
75+
duration,
76+
}: SubagentThinkingBlockProps) {
77+
const trimmed = content.trim()
78+
const hasContent = trimmed.length > 0
79+
80+
const [expanded, setExpanded] = useState(false)
81+
const userCollapsedRef = useRef(false)
82+
const scrollRef = useRef<HTMLDivElement>(null)
83+
84+
useEffect(() => {
85+
if (isStreaming && hasContent && !userCollapsedRef.current) {
86+
setExpanded(true)
87+
}
88+
if (!isStreaming) {
89+
setExpanded(false)
90+
userCollapsedRef.current = false
91+
}
92+
}, [isStreaming, hasContent])
93+
94+
useEffect(() => {
95+
if (!isStreaming || !expanded) return
96+
const id = window.setInterval(() => {
97+
const el = scrollRef.current
98+
if (el) el.scrollTop = el.scrollHeight
99+
}, SCROLL_INTERVAL_MS)
100+
return () => window.clearInterval(id)
101+
}, [isStreaming, expanded])
102+
103+
const toggle = () => {
104+
setExpanded((v) => {
105+
const next = !v
106+
if (!next && isStreaming) userCollapsedRef.current = true
107+
return next
108+
})
109+
}
110+
111+
const roundedMs = duration != null ? Math.max(1000, Math.round(duration / 1000) * 1000) : null
112+
const label = isStreaming ? 'Thinking' : `Thought for ${formatDuration(roundedMs) ?? '…'}`
113+
114+
return (
115+
<div className='pl-[24px]'>
116+
<style>{`
117+
@keyframes subagent-shimmer {
118+
0% { background-position: 150% 0; }
119+
50% { background-position: 0% 0; }
120+
100% { background-position: -150% 0; }
121+
}
122+
`}</style>
123+
124+
<button
125+
type='button'
126+
onClick={toggle}
127+
disabled={!hasContent && !isStreaming}
128+
className='group inline-flex items-center gap-1 text-left text-[13px] text-[var(--text-secondary)] transition-colors hover:text-[var(--text-primary)]'
129+
>
130+
<span className='relative inline-block'>
131+
<span className='text-[var(--text-tertiary)]'>{label}</span>
132+
{isStreaming && (
133+
<span
134+
aria-hidden='true'
135+
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
136+
>
137+
<span
138+
className='block text-transparent'
139+
style={{
140+
backgroundImage:
141+
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
142+
backgroundSize: '200% 100%',
143+
backgroundRepeat: 'no-repeat',
144+
WebkitBackgroundClip: 'text',
145+
backgroundClip: 'text',
146+
animation: 'subagent-shimmer 1.4s ease-in-out infinite',
147+
mixBlendMode: 'screen',
148+
}}
149+
>
150+
{label}
151+
</span>
152+
</span>
153+
)}
154+
</span>
155+
{hasContent && (
156+
<ChevronDown
157+
className={cn(
158+
'h-[7px] w-[9px] transition-all group-hover:opacity-100',
159+
expanded ? 'opacity-100' : '-rotate-90 opacity-0'
160+
)}
161+
/>
162+
)}
163+
</button>
164+
165+
<div
166+
ref={scrollRef}
167+
className={cn(
168+
'overflow-y-auto transition-all duration-150 ease-out',
169+
expanded ? 'mt-1 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
170+
)}
171+
>
172+
<StreamingText content={trimmed} isStreaming={isStreaming} />
173+
</div>
174+
</div>
175+
)
176+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ interface AgentGroupSegment {
1616
agentName: string
1717
agentLabel: string
1818
items: AgentGroupItem[]
19+
duration?: number
1920
}
2021

2122
interface OptionsSegment {
@@ -105,6 +106,7 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
105106
agentName: key,
106107
agentLabel: resolveAgentLabel(key),
107108
items: [],
109+
duration: block.duration,
108110
}
109111
continue
110112
}
@@ -246,6 +248,8 @@ export function MessageContent({
246248
agentLabel={segment.agentLabel}
247249
items={segment.items}
248250
autoCollapse={allToolsDone && hasFollowingText}
251+
duration={segment.duration}
252+
isStreaming={isStreaming}
249253
/>
250254
</div>
251255
)

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

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ function mapStoredBlock(block: TaskStoredContentBlock): ContentBlock {
7878
const mapped: ContentBlock = {
7979
type: block.type as ContentBlockType,
8080
content: block.content,
81+
duration: block.duration,
8182
}
8283

8384
if (block.type === 'tool_call' && block.toolCall) {
@@ -486,6 +487,7 @@ export function useChat(
486487
const toolArgsMap = new Map<string, Record<string, unknown>>()
487488
const clientExecutionStarted = new Set<string>()
488489
let activeSubagent: string | undefined
490+
let subagentStartTime: number | undefined
489491
let runningText = ''
490492
let lastContentSource: 'main' | 'subagent' | null = null
491493

@@ -569,20 +571,44 @@ export function useChat(
569571
}
570572
break
571573
}
574+
case 'reasoning': {
575+
if (!activeSubagent) break
576+
const reasoningChunk =
577+
typeof parsed.data === 'string' ? parsed.data : (parsed.content ?? '')
578+
if (reasoningChunk) {
579+
const last = blocks[blocks.length - 1]
580+
if (last?.type === 'subagent_text') {
581+
last.content = (last.content ?? '') + reasoningChunk
582+
} else {
583+
blocks.push({ type: 'subagent_text', content: reasoningChunk })
584+
}
585+
lastContentSource = 'subagent'
586+
flush()
587+
}
588+
break
589+
}
572590
case 'content': {
573591
const chunk = typeof parsed.data === 'string' ? parsed.data : (parsed.content ?? '')
574592
if (chunk) {
575-
const contentSource: 'main' | 'subagent' = activeSubagent ? 'subagent' : 'main'
576-
const needsBoundaryNewline =
577-
lastContentSource !== null &&
578-
lastContentSource !== contentSource &&
579-
runningText.length > 0 &&
580-
!runningText.endsWith('\n')
581-
const tb = ensureTextBlock()
582-
const normalizedChunk = needsBoundaryNewline ? `\n${chunk}` : chunk
583-
tb.content = (tb.content ?? '') + normalizedChunk
584-
runningText += normalizedChunk
585-
lastContentSource = contentSource
593+
if (activeSubagent) {
594+
const last = blocks[blocks.length - 1]
595+
if (last?.type === 'subagent_text') {
596+
last.content = (last.content ?? '') + chunk
597+
} else {
598+
blocks.push({ type: 'subagent_text', content: chunk })
599+
}
600+
lastContentSource = 'subagent'
601+
} else {
602+
const needsBoundaryNewline =
603+
lastContentSource === 'subagent' &&
604+
runningText.length > 0 &&
605+
!runningText.endsWith('\n')
606+
const tb = ensureTextBlock()
607+
const normalizedChunk = needsBoundaryNewline ? `\n${chunk}` : chunk
608+
tb.content = (tb.content ?? '') + normalizedChunk
609+
runningText += normalizedChunk
610+
lastContentSource = 'main'
611+
}
586612
streamingContentRef.current = runningText
587613
flush()
588614
}
@@ -744,13 +770,25 @@ export function useChat(
744770
const name = parsed.subagent || getPayloadData(parsed)?.agent
745771
if (name) {
746772
activeSubagent = name
773+
subagentStartTime = Date.now()
747774
blocks.push({ type: 'subagent', content: name })
748775
flush()
749776
}
750777
break
751778
}
752779
case 'subagent_end': {
780+
// Subagents are flat (not nested) — stamp duration on the most recent marker
781+
if (subagentStartTime != null) {
782+
const elapsed = Date.now() - subagentStartTime
783+
for (let j = blocks.length - 1; j >= 0; j--) {
784+
if (blocks[j].type === 'subagent') {
785+
blocks[j].duration = elapsed
786+
break
787+
}
788+
}
789+
}
753790
activeSubagent = undefined
791+
subagentStartTime = undefined
754792
flush()
755793
break
756794
}
@@ -800,7 +838,11 @@ export function useChat(
800838
},
801839
}
802840
}
803-
return { type: block.type, content: block.content }
841+
return {
842+
type: block.type,
843+
content: block.content,
844+
...(block.type === 'subagent' && block.duration != null && { duration: block.duration }),
845+
}
804846
})
805847

806848
if (storedBlocks.length > 0) {

apps/sim/app/workspace/[workspaceId]/home/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ export interface ContentBlock {
164164
content?: string
165165
toolCall?: ToolCallInfo
166166
options?: OptionItem[]
167+
duration?: number
167168
}
168169

169170
export interface ChatMessageAttachment {

apps/sim/hooks/queries/tasks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export interface TaskStoredMessage {
6363
export interface TaskStoredContentBlock {
6464
type: string
6565
content?: string
66+
duration?: number
6667
toolCall?: {
6768
id?: string
6869
name?: string

0 commit comments

Comments
 (0)