Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
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
2 changes: 2 additions & 0 deletions apps/sim/app/api/copilot/chat/stop/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ const ContentBlockSchema = z.object({
lifecycle: z.enum(['start', 'end']).optional(),
status: z.enum(['complete', 'error', 'cancelled']).optional(),
toolCall: StoredToolCallSchema.optional(),
timestamp: z.number().optional(),
endedAt: z.number().optional(),
})

const StopSchema = z.object({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@ import { ChevronDown, Expandable, ExpandableContent, PillsRing } from '@/compone
import { cn } from '@/lib/core/utils/cn'
import type { ToolCallData } from '../../../../types'
import { getAgentIcon } from '../../utils'
import { ThinkingBlock } from '../thinking-block'
import { ToolCallItem } from './tool-call-item'

export type AgentGroupItem =
| { type: 'text'; content: string }
| { type: 'thinking'; content: string; startedAt?: number; endedAt?: number }
| { type: 'tool'; data: ToolCallData }

interface AgentGroupProps {
agentName: string
agentLabel: string
items: AgentGroupItem[]
isDelegating?: boolean
isStreaming?: boolean
autoCollapse?: boolean
defaultExpanded?: boolean
}
Expand All @@ -35,6 +38,7 @@ export function AgentGroup({
agentLabel,
items,
isDelegating = false,
isStreaming = false,
autoCollapse = false,
defaultExpanded = false,
}: AgentGroupProps) {
Expand Down Expand Up @@ -110,24 +114,49 @@ export function AgentGroup({
<Expandable expanded={expanded}>
<ExpandableContent>
<div className='flex flex-col gap-1.5 pt-0.5'>
{items.map((item, idx) =>
item.type === 'tool' ? (
<ToolCallItem
key={item.data.id}
toolName={item.data.toolName}
displayTitle={item.data.displayTitle}
status={item.data.status}
streamingArgs={item.data.streamingArgs}
/>
) : (
{items.map((item, idx) => {
if (item.type === 'tool') {
return (
<ToolCallItem
key={item.data.id}
toolName={item.data.toolName}
displayTitle={item.data.displayTitle}
status={item.data.status}
streamingArgs={item.data.streamingArgs}
/>
)
}
if (item.type === 'thinking') {
const elapsedMs =
item.startedAt !== undefined && item.endedAt !== undefined
? item.endedAt - item.startedAt
: undefined
if (elapsedMs !== undefined && elapsedMs <= 3000) return null
// "Active" thinking is defined by the block itself, not by
// the group's `isDelegating` flag — subagent_thinking blocks
// explicitly clear isDelegating in parseBlocks, so keying
// off it here would lock the label to "Thought" forever.
return (
<div key={`thinking-${idx}`} className='pl-6'>
<ThinkingBlock
content={item.content}
isActive={idx === items.length - 1 && item.endedAt === undefined}
Comment thread
TheodoreSpeaks marked this conversation as resolved.
Outdated
isStreaming={isStreaming}
startedAt={item.startedAt}
endedAt={item.endedAt}
/>
</div>
)
}
return (
<span
key={`text-${idx}`}
className='pl-6 font-base text-[var(--text-secondary)] text-small'
>
{item.content.trim()}
</span>
)
)}
})}
</div>
</ExpandableContent>
</Expandable>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export { AgentGroup, CircleStop } from './agent-group'
export { ChatContent } from './chat-content'
export { Options } from './options'
export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags'
export { ThinkingBlock } from './thinking-block'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ThinkingBlock } from './thinking-block'
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
'use client'

import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { ChevronDown, Expandable, ExpandableContent } from '@/components/emcn'
import { BrainIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'

interface ThinkingBlockProps {
content: string
isActive: boolean
isStreaming?: boolean
startedAt?: number
endedAt?: number
}

const MIN_VISIBLE_THINKING_MS = 3000
Comment thread
cursor[bot] marked this conversation as resolved.

export function ThinkingBlock({
content,
isActive,
isStreaming = false,
startedAt,
endedAt,
}: ThinkingBlockProps) {
// Start collapsed so the `Expandable` plays its height-open animation
// when `expanded` flips to true below — otherwise the panel mounts
// already-open and jumps up with its full content in one frame.
const [expanded, setExpanded] = useState(false)
const panelRef = useRef<HTMLDivElement>(null)
const wasActiveRef = useRef<boolean | null>(null)
// Suppress active thinking until it exceeds MIN_VISIBLE_THINKING_MS.
// Completed-<=threshold is filtered upstream in message-content, so if
// we're mounted with isActive=false we've already passed that gate.
const [thresholdReached, setThresholdReached] = useState(() => {
if (!isActive || startedAt === undefined) return true
return Date.now() - startedAt > MIN_VISIBLE_THINKING_MS
})

useEffect(() => {
if (thresholdReached) return
if (!isActive || startedAt === undefined) {
setThresholdReached(true)
return
}
const remainingMs = Math.max(0, MIN_VISIBLE_THINKING_MS - (Date.now() - startedAt))
const id = window.setTimeout(() => setThresholdReached(true), remainingMs + 50)
return () => window.clearTimeout(id)
}, [isActive, startedAt, thresholdReached])

useEffect(() => {
// Wait until the threshold has actually been reached — otherwise this
// effect fires during the 3-second hidden period (while the component
// returns null) and sets `expanded` to true before the panel is even
// rendered, so the Collapsible mounts already-open with no animation.
if (!thresholdReached) return
if (wasActiveRef.current === isActive) return
// On first run (wasActiveRef === null): open if the stream is live —
// even when thinking itself has already ended — so a mid-stream refresh
// shows the thinking panel open while the rest of the response is still
// being generated. Subsequent runs only react to the isActive transition
// (auto-collapse when thinking ends).
const isFirstRun = wasActiveRef.current === null
wasActiveRef.current = isActive
const target = isFirstRun ? isActive || isStreaming : isActive
// Defer to the next frame so Radix Collapsible paints the closed state
// first, then sees the transition to open. Without this, React can batch
// the mount + flip into a single commit and the animation never plays.
const id = window.requestAnimationFrame(() => setExpanded(target))
return () => window.cancelAnimationFrame(id)
}, [isActive, isStreaming, thresholdReached])

useLayoutEffect(() => {
if (!isActive || !expanded) return
const el = panelRef.current
if (!el) return
el.scrollTop = el.scrollHeight
}, [content, isActive, expanded])

if (!thresholdReached) return null

const elapsedMs =
startedAt !== undefined && endedAt !== undefined && endedAt >= startedAt
? endedAt - startedAt
: undefined
const elapsedSeconds =
elapsedMs !== undefined ? Math.max(1, Math.round(elapsedMs / 1000)) : undefined
const label = isActive
? 'Thinking'
: elapsedSeconds !== undefined
? `Thought for ${elapsedSeconds}s`
: 'Thought'

return (
<div className='flex flex-col gap-1.5'>
<button
type='button'
onClick={() => setExpanded((prev) => !prev)}
className='flex cursor-pointer items-center gap-2'
>
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
<BrainIcon className='h-[14px] w-[14px] text-[var(--text-icon)]' />
</div>
<span className='font-base text-[var(--text-body)] text-sm'>{label}</span>
<ChevronDown
className={cn(
'h-[7px] w-[9px] text-[var(--text-icon)] transition-transform duration-150',
!expanded && '-rotate-90'
)}
/>
</button>

<Expandable expanded={expanded}>
<ExpandableContent>
<div ref={panelRef} className='max-h-[110px] overflow-y-scroll pt-0.5 pr-2 pl-6'>
<div className='whitespace-pre-wrap break-words font-base text-[13px] text-[var(--text-secondary)] leading-[18px] opacity-60'>
{content}
Comment thread
TheodoreSpeaks marked this conversation as resolved.
</div>
</div>
</ExpandableContent>
</Expandable>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state'
import type { ContentBlock, MothershipResource, OptionItem, ToolCallData } from '../../types'
import { SUBAGENT_LABELS, TOOL_UI_METADATA } from '../../types'
import type { AgentGroupItem } from './components'
import { AgentGroup, ChatContent, CircleStop, Options, PendingTagIndicator } from './components'
import {
AgentGroup,
ChatContent,
CircleStop,
Options,
PendingTagIndicator,
ThinkingBlock,
} from './components'

const FILE_SUBAGENT_ID = 'file'

Expand All @@ -19,6 +26,14 @@ interface TextSegment {
content: string
}

interface ThinkingSegment {
type: 'thinking'
id: string
content: string
startedAt?: number
endedAt?: number
}

interface AgentGroupSegment {
type: 'agent_group'
id: string
Expand All @@ -38,7 +53,12 @@ interface StoppedSegment {
type: 'stopped'
}

type MessageSegment = TextSegment | AgentGroupSegment | OptionsSegment | StoppedSegment
type MessageSegment =
| TextSegment
| ThinkingSegment
| AgentGroupSegment
| OptionsSegment
| StoppedSegment

const SUBAGENT_KEYS = new Set(Object.keys(SUBAGENT_LABELS))

Expand Down Expand Up @@ -156,6 +176,46 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
continue
}

if (block.type === 'subagent_thinking') {
if (!block.content || !group) continue
Comment thread
TheodoreSpeaks marked this conversation as resolved.
group.isDelegating = false
const lastItem = group.items[group.items.length - 1]
if (lastItem?.type === 'thinking') {
lastItem.content += block.content
if (block.endedAt !== undefined) lastItem.endedAt = block.endedAt
Comment thread
TheodoreSpeaks marked this conversation as resolved.
} else {
group.items.push({
type: 'thinking',
content: block.content,
startedAt: block.timestamp,
endedAt: block.endedAt,
})
}
continue
}

if (block.type === 'thinking') {
if (!block.content?.trim()) continue
if (group) {
pushGroup(group)
group = null
}
const last = segments[segments.length - 1]
if (last?.type === 'thinking') {
last.content += block.content
if (block.endedAt !== undefined) last.endedAt = block.endedAt
Comment thread
TheodoreSpeaks marked this conversation as resolved.
} else {
segments.push({
type: 'thinking',
id: `thinking-${i}`,
content: block.content,
startedAt: block.timestamp,
endedAt: block.endedAt,
})
}
continue
}

if (block.type === 'text') {
if (!block.content) continue
if (block.subagent) {
Expand Down Expand Up @@ -383,7 +443,9 @@ export function MessageContent({

const hasSubagentEnded = blocks.some((b) => b.type === 'subagent_end')
const showTrailingThinking =
isStreaming && !hasTrailingContent && (hasSubagentEnded || allLastGroupToolsDone)
isStreaming &&
!hasTrailingContent &&
(lastSegment.type === 'thinking' || hasSubagentEnded || allLastGroupToolsDone)
const lastOpenSubagentGroupId = [...segments]
.reverse()
.find(
Expand All @@ -405,6 +467,30 @@ export function MessageContent({
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
/>
)
case 'thinking': {
const isActive =
isStreaming && i === segments.length - 1 && segment.endedAt === undefined
const elapsedMs =
segment.startedAt !== undefined && segment.endedAt !== undefined
? segment.endedAt - segment.startedAt
: undefined
// Hide completed thinking that took 3s or less — quick thinking
// isn't worth the visual noise. Still show while active (unknown
// duration yet) and still show when timing is missing (old
// persisted blocks) so we don't drop historical content.
if (elapsedMs !== undefined && elapsedMs <= 3000) return null
return (
<div key={segment.id} className={isStreaming ? 'animate-stream-fade-in' : undefined}>
<ThinkingBlock
content={segment.content}
isActive={isActive}
isStreaming={isStreaming}
startedAt={segment.startedAt}
endedAt={segment.endedAt}
/>
</div>
)
}
case 'agent_group': {
const toolItems = segment.items.filter((item) => item.type === 'tool')
const allToolsDone =
Expand All @@ -419,6 +505,7 @@ export function MessageContent({
agentLabel={segment.agentLabel}
items={segment.items}
isDelegating={segment.isDelegating}
isStreaming={isStreaming}
autoCollapse={allToolsDone && hasFollowingText}
defaultExpanded={segment.id === lastOpenSubagentGroupId}
/>
Expand Down
Loading
Loading