Skip to content

fix(chat): render subagent content as collapsible thinking blocks#3602

Open
waleedlatif1 wants to merge 5 commits intostagingfrom
fix/thinking
Open

fix(chat): render subagent content as collapsible thinking blocks#3602
waleedlatif1 wants to merge 5 commits intostagingfrom
fix/thinking

Conversation

@waleedlatif1
Copy link
Collaborator

Summary

  • Fix SSE content routing so subagent text goes to subagent_text blocks instead of leaking into main chat text
  • Add SubagentThinkingBlock component with streaming text carousel, shimmer animation, and collapsible "Thought for Xs" label
  • Thread duration and isStreaming through AgentGroup and MessageContent
  • Handle reasoning SSE events correctly (only render inside subagents, drop otherwise)

Type of Change

  • Bug fix
  • New feature

Testing

Tested manually

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@cursor
Copy link

cursor bot commented Mar 15, 2026

PR Summary

Medium Risk
Touches SSE streaming block construction and persistence, so regressions could mis-route assistant text or break replay of partial streams; UI changes are localized to subagent rendering.

Overview
Subagent output is now rendered as a dedicated collapsible “thinking” UI instead of plain text. AgentGroup replaces raw <p> rendering with a new SubagentThinkingBlock that supports shimmer “Thinking” state, character-by-character streaming, auto-scroll, and a collapsed “Thought for X” label once complete.

Streaming/persistence now track subagent text separately and record subagent durations. use-chat routes content/reasoning events into subagent_text blocks only when a subagent is active (preventing leakage into main chat text), stamps elapsed time between subagent_start/subagent_end onto the subagent marker, and threads duration through stored block types and MessageContent into AgentGroup to drive the completed label.

Written by Cursor Bugbot for commit 1805b36. Configure here.

@vercel
Copy link

vercel bot commented Mar 15, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Mar 15, 2026 11:54am

Request Review

@waleedlatif1
Copy link
Collaborator Author

Fixed the cursor bot's finding — when the subagent block arrives for an already-open group, we now copy block.duration onto the existing group instead of skipping it entirely.

@waleedlatif1
Copy link
Collaborator Author

@greptile

@waleedlatif1
Copy link
Collaborator Author

@cursor review

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 15, 2026

Greptile Summary

This PR fixes SSE content routing so subagent text and reasoning tokens are correctly directed to subagent_text blocks instead of leaking into the main chat text, and introduces a new SubagentThinkingBlock component that renders subagent output as a collapsible "Thought for Xs" thinking block with a streaming shimmer animation — mirroring the existing copilot ThinkingBlock UX pattern.

Key changes:

  • use-chat.ts: content/reasoning events inside an active subagent are routed to subagent_text blocks; subagentStartTime is used to calculate elapsed duration which is stamped onto the subagent marker block on subagent_end; streamingContentRef assignment correctly moved to the main-text branch only
  • subagent-thinking-block.tsx: New component with rAF-driven streaming text carousel and CSS-module shimmer animation; userCollapsedRef prevents the auto-expand from overriding an explicit user collapse mid-stream
  • agent-group.tsx: duration and isStreaming props threaded through; duration === undefined used as a proxy to identify the still-active subagent among a group
  • message-content.tsx: parseBlocks updated to propagate duration from subagent marker blocks into AgentGroupSegment; global isStreaming passed to all AgentGroup instances
  • types.ts / tasks.ts: duration?: number added to ContentBlock and TaskStoredContentBlock for persistence

Confidence Score: 4/5

  • Safe to merge with minor style and UX polish items to address.
  • The core SSE routing fix and duration-stamping logic are sound and well-constructed. Previous review threads have been addressed (CSS module shimmer, streamingContentRef placement). Remaining concerns are all non-blocking: the lastTextIdx scan is a minor style/perf note, the silent drop of orphaned subagent_text is a robustness gap rather than a production bug, and the button-enabled-before-content issue is an edge-case UX regression that only affects users who click the "Thinking" label before any content arrives.
  • subagent-thinking-block.tsx (button disabled condition) and message-content.tsx (subagent_text silent drop) warrant a second look before merge.

Important Files Changed

Filename Overview
apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts SSE routing refactored to send content/reasoning events inside active subagents to subagent_text blocks instead of the main text block; subagentStartTime tracks elapsed time and stamps duration onto the subagent marker on subagent_end; streamingContentRef correctly moved to the else branch. Logic is sound for sequential subagents.
apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx Replaces plain <p> with SubagentThinkingBlock; uses duration === undefined as a proxy for "subagent still active" to derive per-item isStreaming. The lastTextIdx backward scan runs on every render without memoization — a minor style issue.
apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/subagent-thinking-block.tsx New collapsible thinking block component with rAF-driven streaming text and shimmer animation. The toggle button is enabled during streaming even with no content yet, which can suppress the intended auto-expand via userCollapsedRef. CSS module shimmer (addressing previous thread) is correctly used.
apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx parseBlocks updated to propagate duration from the subagent marker block to the segment, and isStreaming + duration are threaded down to AgentGroup. subagent_text blocks with no active group are silently discarded without a warning — minor robustness gap.
apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/subagent-thinking-block.module.css New CSS module for shimmer animation; correctly extracted from inline <style> injection (per previous review). Keyframe and .shimmer utility look correct.
apps/sim/app/workspace/[workspaceId]/home/types.ts Adds optional duration?: number to ContentBlock; straightforward and backward-compatible.
apps/sim/hooks/queries/tasks.ts Adds optional duration?: number to TaskStoredContentBlock API interface; backward-compatible with existing stored messages that lack the field.

Sequence Diagram

sequenceDiagram
    participant BE as Backend SSE
    participant UC as useChat (processSSEStream)
    participant MC as MessageContent (parseBlocks)
    participant AG as AgentGroup
    participant STB as SubagentThinkingBlock

    BE->>UC: subagent_start { subagent: "agent-A" }
    UC->>UC: activeSubagent = "agent-A"<br/>subagentStartTime = Date.now()<br/>blocks.push({ type: "subagent", content: "agent-A" })
    UC->>MC: flush() → re-render

    BE->>UC: content / reasoning chunk
    UC->>UC: last block is subagent_text?<br/>→ append chunk<br/>else push new subagent_text block
    UC->>MC: flush() → re-render
    MC->>AG: segment { duration: undefined, isStreaming: true }
    AG->>STB: isStreaming={true}, content, duration={undefined}
    STB-->>STB: auto-expand, shimmer "Thinking" label

    BE->>UC: subagent_end
    UC->>UC: elapsed = Date.now() - subagentStartTime<br/>blocks[j].duration = elapsed (backward scan)<br/>activeSubagent = undefined
    UC->>MC: flush() → re-render
    MC->>AG: segment { duration: 2000, isStreaming: true }
    AG->>STB: isStreaming={false}, duration=2000
    STB-->>STB: collapse, label → "Thought for 2s"

    BE->>UC: stream end (done event)
    UC->>MC: isStreaming = false
    MC->>AG: isStreaming={false}
    AG->>STB: isStreaming={false} (no change)
Loading

Comments Outside Diff (1)

  1. apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx, line 69-78 (link)

    subagent_text silently dropped when no active group

    When !group is true (e.g. a subagent_text block arrives before its corresponding subagent marker due to an out-of-order flush, or after the group has already been pushed to segments), the block's content is silently discarded with continue. This creates a silent data-loss path that is hard to debug in production.

    A defensive console.warn or console.error would surface this quickly during development/staging without affecting production behavior:

Last reviewed commit: 5eb93dd

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

@waleedlatif1
Copy link
Collaborator Author

@cursor review

@waleedlatif1
Copy link
Collaborator Author

@greptile

@waleedlatif1 waleedlatif1 changed the title fix(home-chat): render subagent content as collapsible thinking blocks fix(chat): render subagent content as collapsible thinking blocks Mar 15, 2026
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

@waleedlatif1
Copy link
Collaborator Author

@cursor review

@waleedlatif1
Copy link
Collaborator Author

@greptile

Comment on lines +36 to +42
let lastTextIdx = -1
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].type === 'text') {
lastTextIdx = i
break
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

lastTextIdx scan should be memoized

The backward linear scan runs on every render, which happens on every SSE flush during streaming. For an AgentGroup with many items this is fine today (small N), but the scan is executed inside the component body without any memoization guard. Since items is a prop that changes reference on every parent re-render, wrapping this in useMemo makes the intent explicit and avoids the scan on renders where items hasn't changed.

Suggested change
let lastTextIdx = -1
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].type === 'text') {
lastTextIdx = i
break
}
}
const lastTextIdx = useMemo(() => {
for (let i = items.length - 1; i >= 0; i--) {
if (items[i].type === 'text') return i
}
return -1
}, [items])

You'll also need to add useMemo to the import at line 3:

import { useEffect, useMemo, useRef, useState } from 'react'

Comment on lines +119 to +122
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)]'
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.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant