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..3d02e30723 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,9 +28,18 @@ export function AgentGroup({ agentLabel, items, autoCollapse = false, + duration, + isStreaming: isStreamingProp = false, }: AgentGroupProps) { const AgentIcon = getAgentIcon(agentName) const hasItems = items.length > 0 + let lastTextIdx = -1 + for (let i = items.length - 1; i >= 0; i--) { + if (items[i].type === 'text') { + lastTextIdx = i + break + } + } const toolItems = items.filter( (item): item is Extract => item.type === 'tool' ) @@ -100,12 +112,12 @@ export function AgentGroup({ status={item.data.status} /> ) : ( -

- {item.content.trim()} -

+ content={item.content} + isStreaming={!!isStreamingProp && duration === undefined && idx === lastTextIdx} + duration={duration} + /> ) )} 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 new file mode 100644 index 0000000000..aa7f674ffd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/subagent-thinking-block.tsx @@ -0,0 +1,156 @@ +'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' +import styles from './subagent-thinking-block.module.css' + +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) + // Reset so the next streaming session auto-expands by default + 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..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 @@ -16,6 +16,7 @@ interface AgentGroupSegment { agentName: string agentLabel: string items: AgentGroupItem[] + duration?: number } interface OptionsSegment { @@ -94,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 @@ -105,6 +109,7 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] { agentName: key, agentLabel: resolveAgentLabel(key), items: [], + duration: block.duration, } continue } @@ -246,6 +251,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..b7b7a78efb 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) { @@ -284,6 +285,7 @@ export function useChat( const streamGenRef = useRef(0) const streamingContentRef = useRef('') const streamingBlocksRef = useRef([]) + const subagentStartTimeRef = useRef(undefined) const executionStream = useExecutionStream() const isHomePage = pathname.endsWith('/home') @@ -486,6 +488,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 @@ -501,8 +504,9 @@ export function useChat( } const flush = () => { - streamingBlocksRef.current = [...blocks] - const snapshot = { content: runningText, contentBlocks: [...blocks] } + const blocksCopy = [...blocks] + streamingBlocksRef.current = blocksCopy + const snapshot = { content: runningText, contentBlocks: blocksCopy } setMessages((prev) => { const idx = prev.findIndex((m) => m.id === assistantId) if (idx >= 0) { @@ -569,21 +573,45 @@ 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 - streamingContentRef.current = runningText + 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() } break @@ -744,13 +772,27 @@ export function useChat( const name = parsed.subagent || getPayloadData(parsed)?.agent if (name) { activeSubagent = name + subagentStartTime = Date.now() + subagentStartTimeRef.current = subagentStartTime 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 + subagentStartTimeRef.current = undefined flush() break } @@ -781,6 +823,10 @@ export function useChat( const content = streamingContentRef.current + // If a subagent was in progress when stopped, compute its elapsed duration + const inProgressDuration = + subagentStartTimeRef.current != null ? Date.now() - subagentStartTimeRef.current : undefined + const storedBlocks: TaskStoredContentBlock[] = streamingBlocksRef.current.map((block) => { if (block.type === 'tool_call' && block.toolCall) { const isCancelled = @@ -800,7 +846,14 @@ export function useChat( }, } } - return { type: block.type, content: block.content } + return { + type: block.type, + content: block.content, + ...(block.type === 'subagent' && + (block.duration ?? inProgressDuration) != null && { + duration: block.duration ?? inProgressDuration, + }), + } }) 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