Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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,47 @@ 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
return (
<div key={`thinking-${idx}`} className='pl-6'>
<ThinkingBlock
content={item.content}
isActive={
isStreaming && idx === items.length - 1 && item.endedAt === undefined
}
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.endedAt === undefined) {
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.endedAt === undefined) {
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