From 91e1edd8667cd166d565f668ed8c2f2ecabcf244 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 15 Mar 2026 04:13:05 -0700 Subject: [PATCH 1/5] fix(home-chat): render subagent content as collapsible thinking blocks --- .../components/agent-group/agent-group.tsx | 15 +- .../agent-group/subagent-thinking-block.tsx | 176 ++++++++++++++++++ .../message-content/message-content.tsx | 4 + .../[workspaceId]/home/hooks/use-chat.ts | 66 +++++-- .../app/workspace/[workspaceId]/home/types.ts | 1 + apps/sim/hooks/queries/tasks.ts | 1 + 6 files changed, 246 insertions(+), 17 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/subagent-thinking-block.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx index 5cdb9c6a78..65560f7e40 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx @@ -5,6 +5,7 @@ import { ChevronDown } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import type { ToolCallData } from '../../../../types' import { getAgentIcon } from '../../utils' +import { SubagentThinkingBlock } from './subagent-thinking-block' import { ToolCallItem } from './tool-call-item' export type AgentGroupItem = @@ -16,6 +17,8 @@ interface AgentGroupProps { agentLabel: string items: AgentGroupItem[] autoCollapse?: boolean + duration?: number + isStreaming?: boolean } const FADE_MS = 300 @@ -25,6 +28,8 @@ export function AgentGroup({ agentLabel, items, autoCollapse = false, + duration, + isStreaming: isStreamingProp = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) const hasItems = items.length > 0 @@ -100,12 +105,12 @@ export function AgentGroup({ status={item.data.status} /> ) : ( -

- {item.content.trim()} -

+ content={item.content} + isStreaming={!!isStreamingProp && duration === undefined} + duration={duration} + /> ) )} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/subagent-thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/subagent-thinking-block.tsx new file mode 100644 index 0000000000..c68cd5e736 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/subagent-thinking-block.tsx @@ -0,0 +1,176 @@ +'use client' + +import { memo, useEffect, useRef, useState } from 'react' +import { ChevronDown } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import { formatDuration } from '@/lib/core/utils/formatting' + +const CHARS_PER_FRAME = 3 +const SCROLL_INTERVAL_MS = 50 + +interface SubagentThinkingBlockProps { + content: string + isStreaming: boolean + duration?: number +} + +/** + * Streams text character-by-character via rAF during streaming, + * then snaps to full content when done. + */ +const StreamingText = memo( + ({ content, isStreaming }: { content: string; isStreaming: boolean }) => { + const [displayed, setDisplayed] = useState(() => (isStreaming ? '' : content)) + const contentRef = useRef(content) + const indexRef = useRef(isStreaming ? 0 : content.length) + const rafRef = useRef(null) + + useEffect(() => { + contentRef.current = content + + if (!isStreaming) { + if (rafRef.current) cancelAnimationFrame(rafRef.current) + setDisplayed(content) + indexRef.current = content.length + return + } + + if (indexRef.current >= content.length) return + + const step = () => { + const cur = contentRef.current + const next = Math.min(indexRef.current + CHARS_PER_FRAME, cur.length) + indexRef.current = next + setDisplayed(cur.slice(0, next)) + if (next < cur.length) { + rafRef.current = requestAnimationFrame(step) + } + } + rafRef.current = requestAnimationFrame(step) + + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current) + } + }, [content, isStreaming]) + + return ( +

+ {displayed} +

+ ) + }, + (prev, next) => prev.content === next.content && prev.isStreaming === next.isStreaming +) +StreamingText.displayName = 'StreamingText' + +/** + * Collapsible thinking block for subagent content in the home chat. + * + * Streaming: "Thinking" shimmer label, auto-expands with scrolling carousel. + * Done: collapses to "Thought for Xs", click to re-expand. + */ +export function SubagentThinkingBlock({ + content, + isStreaming, + duration, +}: SubagentThinkingBlockProps) { + const trimmed = content.trim() + const hasContent = trimmed.length > 0 + + const [expanded, setExpanded] = useState(false) + const userCollapsedRef = useRef(false) + const scrollRef = useRef(null) + + useEffect(() => { + if (isStreaming && hasContent && !userCollapsedRef.current) { + setExpanded(true) + } + if (!isStreaming) { + setExpanded(false) + userCollapsedRef.current = false + } + }, [isStreaming, hasContent]) + + useEffect(() => { + if (!isStreaming || !expanded) return + const id = window.setInterval(() => { + const el = scrollRef.current + if (el) el.scrollTop = el.scrollHeight + }, SCROLL_INTERVAL_MS) + return () => window.clearInterval(id) + }, [isStreaming, expanded]) + + const toggle = () => { + setExpanded((v) => { + const next = !v + if (!next && isStreaming) userCollapsedRef.current = true + return next + }) + } + + const roundedMs = duration != null ? Math.max(1000, Math.round(duration / 1000) * 1000) : null + const label = isStreaming ? 'Thinking' : `Thought for ${formatDuration(roundedMs) ?? '…'}` + + return ( +
+ + + + +
+ +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index d4f7430478..504b4a35a3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -16,6 +16,7 @@ interface AgentGroupSegment { agentName: string agentLabel: string items: AgentGroupItem[] + duration?: number } interface OptionsSegment { @@ -105,6 +106,7 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] { agentName: key, agentLabel: resolveAgentLabel(key), items: [], + duration: block.duration, } continue } @@ -246,6 +248,8 @@ export function MessageContent({ agentLabel={segment.agentLabel} items={segment.items} autoCollapse={allToolsDone && hasFollowingText} + duration={segment.duration} + isStreaming={isStreaming} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 2aab59a7ad..aff66cbcd8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -78,6 +78,7 @@ function mapStoredBlock(block: TaskStoredContentBlock): ContentBlock { const mapped: ContentBlock = { type: block.type as ContentBlockType, content: block.content, + duration: block.duration, } if (block.type === 'tool_call' && block.toolCall) { @@ -486,6 +487,7 @@ export function useChat( const toolArgsMap = new Map>() const clientExecutionStarted = new Set() let activeSubagent: string | undefined + let subagentStartTime: number | undefined let runningText = '' let lastContentSource: 'main' | 'subagent' | null = null @@ -569,20 +571,44 @@ export function useChat( } break } + case 'reasoning': { + if (!activeSubagent) break + const reasoningChunk = + typeof parsed.data === 'string' ? parsed.data : (parsed.content ?? '') + if (reasoningChunk) { + const last = blocks[blocks.length - 1] + if (last?.type === 'subagent_text') { + last.content = (last.content ?? '') + reasoningChunk + } else { + blocks.push({ type: 'subagent_text', content: reasoningChunk }) + } + lastContentSource = 'subagent' + flush() + } + break + } case 'content': { const chunk = typeof parsed.data === 'string' ? parsed.data : (parsed.content ?? '') if (chunk) { - const contentSource: 'main' | 'subagent' = activeSubagent ? 'subagent' : 'main' - const needsBoundaryNewline = - lastContentSource !== null && - lastContentSource !== contentSource && - runningText.length > 0 && - !runningText.endsWith('\n') - const tb = ensureTextBlock() - const normalizedChunk = needsBoundaryNewline ? `\n${chunk}` : chunk - tb.content = (tb.content ?? '') + normalizedChunk - runningText += normalizedChunk - lastContentSource = contentSource + if (activeSubagent) { + const last = blocks[blocks.length - 1] + if (last?.type === 'subagent_text') { + last.content = (last.content ?? '') + chunk + } else { + blocks.push({ type: 'subagent_text', content: chunk }) + } + lastContentSource = 'subagent' + } else { + const needsBoundaryNewline = + lastContentSource === 'subagent' && + runningText.length > 0 && + !runningText.endsWith('\n') + const tb = ensureTextBlock() + const normalizedChunk = needsBoundaryNewline ? `\n${chunk}` : chunk + tb.content = (tb.content ?? '') + normalizedChunk + runningText += normalizedChunk + lastContentSource = 'main' + } streamingContentRef.current = runningText flush() } @@ -744,13 +770,25 @@ export function useChat( const name = parsed.subagent || getPayloadData(parsed)?.agent if (name) { activeSubagent = name + subagentStartTime = Date.now() blocks.push({ type: 'subagent', content: name }) flush() } break } case 'subagent_end': { + // Subagents are flat (not nested) — stamp duration on the most recent marker + if (subagentStartTime != null) { + const elapsed = Date.now() - subagentStartTime + for (let j = blocks.length - 1; j >= 0; j--) { + if (blocks[j].type === 'subagent') { + blocks[j].duration = elapsed + break + } + } + } activeSubagent = undefined + subagentStartTime = undefined flush() break } @@ -800,7 +838,11 @@ export function useChat( }, } } - return { type: block.type, content: block.content } + return { + type: block.type, + content: block.content, + ...(block.type === 'subagent' && block.duration != null && { duration: block.duration }), + } }) if (storedBlocks.length > 0) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 0f34f0daa2..e850aad1c8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -164,6 +164,7 @@ export interface ContentBlock { content?: string toolCall?: ToolCallInfo options?: OptionItem[] + duration?: number } export interface ChatMessageAttachment { diff --git a/apps/sim/hooks/queries/tasks.ts b/apps/sim/hooks/queries/tasks.ts index 198d59701c..ae1ab37a6f 100644 --- a/apps/sim/hooks/queries/tasks.ts +++ b/apps/sim/hooks/queries/tasks.ts @@ -63,6 +63,7 @@ export interface TaskStoredMessage { export interface TaskStoredContentBlock { type: string content?: string + duration?: number toolCall?: { id?: string name?: string From 756e86d24c746ebbe30a03fda662df4a99926663 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 15 Mar 2026 04:21:26 -0700 Subject: [PATCH 2/5] fix(home-chat): copy duration to existing agent group instead of skipping --- .../home/components/message-content/message-content.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 504b4a35a3..df981ed551 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -95,7 +95,10 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] { if (block.type === 'subagent') { if (!block.content) continue const key = block.content - if (group && group.agentName === key) continue + if (group && group.agentName === key) { + if (block.duration != null) group.duration = block.duration + continue + } if (group) { segments.push(group) group = null From 919265ee6f0b8254c88f88639125312f780b7ca6 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 15 Mar 2026 04:32:27 -0700 Subject: [PATCH 3/5] fix(home-chat): extract shimmer to CSS module, move streamingContentRef to main-only branch --- .../subagent-thinking-block.module.css | 26 ++++++++++++++++++ .../agent-group/subagent-thinking-block.tsx | 27 +++---------------- .../[workspaceId]/home/hooks/use-chat.ts | 2 +- 3 files changed, 30 insertions(+), 25 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/subagent-thinking-block.module.css diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/subagent-thinking-block.module.css b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/subagent-thinking-block.module.css new file mode 100644 index 0000000000..161420c0a7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/subagent-thinking-block.module.css @@ -0,0 +1,26 @@ +@keyframes subagent-shimmer { + 0% { + background-position: 150% 0; + } + 50% { + background-position: 0% 0; + } + 100% { + background-position: -150% 0; + } +} + +.shimmer { + background-image: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.85) 50%, + rgba(255, 255, 255, 0) 100% + ); + background-size: 200% 100%; + background-repeat: no-repeat; + -webkit-background-clip: text; + background-clip: text; + animation: subagent-shimmer 1.4s ease-in-out infinite; + mix-blend-mode: screen; +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/subagent-thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/subagent-thinking-block.tsx index c68cd5e736..3c14a1761e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/subagent-thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/subagent-thinking-block.tsx @@ -4,6 +4,7 @@ import { memo, useEffect, useRef, useState } from 'react' import { ChevronDown } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { formatDuration } from '@/lib/core/utils/formatting' +import styles from './subagent-thinking-block.module.css' const CHARS_PER_FRAME = 3 const SCROLL_INTERVAL_MS = 50 @@ -54,7 +55,7 @@ const StreamingText = memo( }, [content, isStreaming]) return ( -

+

{displayed}

) @@ -113,14 +114,6 @@ export function SubagentThinkingBlock({ return (
- -