Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -16,6 +17,8 @@ interface AgentGroupProps {
agentLabel: string
items: AgentGroupItem[]
autoCollapse?: boolean
duration?: number
isStreaming?: boolean
}

const FADE_MS = 300
Expand All @@ -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
Expand Down Expand Up @@ -100,12 +105,12 @@ export function AgentGroup({
status={item.data.status}
/>
) : (
<p
<SubagentThinkingBlock
key={`text-${idx}`}
className='whitespace-pre-wrap pl-[24px] font-base text-[13px] text-[var(--text-secondary)]'
>
{item.content.trim()}
</p>
content={item.content}
isStreaming={!!isStreamingProp && duration === undefined}
duration={duration}
/>
)
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<number | null>(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 (
<p className='whitespace-pre-wrap text-[12px] text-[var(--text-muted)] leading-[1.4]'>
{displayed}
</p>
)
},
(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<HTMLDivElement>(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 (
<div className='pl-[24px]'>
<style>{`
@keyframes subagent-shimmer {
0% { background-position: 150% 0; }
50% { background-position: 0% 0; }
100% { background-position: -150% 0; }
}
`}</style>

<button
type='button'
onClick={toggle}
disabled={!hasContent && !isStreaming}
className='group inline-flex items-center gap-1 text-left text-[13px] text-[var(--text-secondary)] transition-colors hover:text-[var(--text-primary)]'
Comment on lines +119 to +122
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Button clickable but visually inert when streaming with empty content

disabled={!hasContent && !isStreaming} leaves the button enabled whenever isStreaming is true, even before any content has arrived. At that point there is no ChevronDown affordance and no visible content to expand — yet a click calls toggle(), sets expanded=true, and if the user clicks again it sets userCollapsedRef.current = true, permanently suppressing the auto-expand that fires when the first chunk arrives.

A user who clicks the "Thinking" label impatiently (before content) will then see the block never auto-expand, since userCollapsedRef has been set:

Suggested change
type='button'
onClick={toggle}
disabled={!hasContent && !isStreaming}
className='group inline-flex items-center gap-1 text-left text-[13px] text-[var(--text-secondary)] transition-colors hover:text-[var(--text-primary)]'
disabled={!hasContent}

This disables the button until content exists, regardless of streaming state. The auto-expand effect already handles opening the block as soon as hasContent becomes true during streaming, so there's no need for early interaction.

>
<span className='relative inline-block'>
<span className='text-[var(--text-tertiary)]'>{label}</span>
{isStreaming && (
<span
aria-hidden='true'
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
>
<span
className='block text-transparent'
style={{
backgroundImage:
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
backgroundSize: '200% 100%',
backgroundRepeat: 'no-repeat',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
animation: 'subagent-shimmer 1.4s ease-in-out infinite',
mixBlendMode: 'screen',
}}
>
{label}
</span>
</span>
)}
</span>
{hasContent && (
<ChevronDown
className={cn(
'h-[7px] w-[9px] transition-all group-hover:opacity-100',
expanded ? 'opacity-100' : '-rotate-90 opacity-0'
)}
/>
)}
</button>

<div
ref={scrollRef}
className={cn(
'overflow-y-auto transition-all duration-150 ease-out',
expanded ? 'mt-1 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
)}
>
<StreamingText content={trimmed} isStreaming={isStreaming} />
</div>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface AgentGroupSegment {
agentName: string
agentLabel: string
items: AgentGroupItem[]
duration?: number
}

interface OptionsSegment {
Expand Down Expand Up @@ -105,6 +106,7 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
agentName: key,
agentLabel: resolveAgentLabel(key),
items: [],
duration: block.duration,
}
continue
}
Expand Down Expand Up @@ -246,6 +248,8 @@ export function MessageContent({
agentLabel={segment.agentLabel}
items={segment.items}
autoCollapse={allToolsDone && hasFollowingText}
duration={segment.duration}
isStreaming={isStreaming}
/>
</div>
)
Expand Down
66 changes: 54 additions & 12 deletions apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -486,6 +487,7 @@ export function useChat(
const toolArgsMap = new Map<string, Record<string, unknown>>()
const clientExecutionStarted = new Set<string>()
let activeSubagent: string | undefined
let subagentStartTime: number | undefined
let runningText = ''
let lastContentSource: 'main' | 'subagent' | null = null

Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replayed SSE events produce near-zero subagent durations

Low Severity

During SSE stream reconnection, all cached batch events are enqueued as a single chunk and processed synchronously. subagentStartTime is set to Date.now() when subagent_start is processed, and subagent_end computes Date.now() - subagentStartTime moments later, yielding ~0ms. The 1s floor causes every replayed subagent to display "Thought for 1s" regardless of actual duration.

Additional Locations (1)
Fix in Cursor Fix in Web

}
activeSubagent = undefined
subagentStartTime = undefined
flush()
break
}
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/workspace/[workspaceId]/home/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ export interface ContentBlock {
content?: string
toolCall?: ToolCallInfo
options?: OptionItem[]
duration?: number
}

export interface ChatMessageAttachment {
Expand Down
1 change: 1 addition & 0 deletions apps/sim/hooks/queries/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface TaskStoredMessage {
export interface TaskStoredContentBlock {
type: string
content?: string
duration?: number
toolCall?: {
id?: string
name?: string
Expand Down
Loading