Skip to content

Commit 2e9b5ce

Browse files
feat(ui): Add thinking ui
1 parent d927d8b commit 2e9b5ce

15 files changed

Lines changed: 528 additions & 39 deletions

File tree

apps/sim/app/api/copilot/chat/stop/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ const ContentBlockSchema = z.object({
5252
lifecycle: z.enum(['start', 'end']).optional(),
5353
status: z.enum(['complete', 'error', 'cancelled']).optional(),
5454
toolCall: StoredToolCallSchema.optional(),
55+
timestamp: z.number().optional(),
56+
endedAt: z.number().optional(),
5557
})
5658

5759
const StopSchema = z.object({

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,20 @@ import { ChevronDown, Expandable, ExpandableContent, PillsRing } from '@/compone
55
import { cn } from '@/lib/core/utils/cn'
66
import type { ToolCallData } from '../../../../types'
77
import { getAgentIcon } from '../../utils'
8+
import { ThinkingBlock } from '../thinking-block'
89
import { ToolCallItem } from './tool-call-item'
910

1011
export type AgentGroupItem =
1112
| { type: 'text'; content: string }
13+
| { type: 'thinking'; content: string; startedAt?: number; endedAt?: number }
1214
| { type: 'tool'; data: ToolCallData }
1315

1416
interface AgentGroupProps {
1517
agentName: string
1618
agentLabel: string
1719
items: AgentGroupItem[]
1820
isDelegating?: boolean
21+
isStreaming?: boolean
1922
autoCollapse?: boolean
2023
defaultExpanded?: boolean
2124
}
@@ -35,6 +38,7 @@ export function AgentGroup({
3538
agentLabel,
3639
items,
3740
isDelegating = false,
41+
isStreaming = false,
3842
autoCollapse = false,
3943
defaultExpanded = false,
4044
}: AgentGroupProps) {
@@ -110,24 +114,49 @@ export function AgentGroup({
110114
<Expandable expanded={expanded}>
111115
<ExpandableContent>
112116
<div className='flex flex-col gap-1.5 pt-0.5'>
113-
{items.map((item, idx) =>
114-
item.type === 'tool' ? (
115-
<ToolCallItem
116-
key={item.data.id}
117-
toolName={item.data.toolName}
118-
displayTitle={item.data.displayTitle}
119-
status={item.data.status}
120-
streamingArgs={item.data.streamingArgs}
121-
/>
122-
) : (
117+
{items.map((item, idx) => {
118+
if (item.type === 'tool') {
119+
return (
120+
<ToolCallItem
121+
key={item.data.id}
122+
toolName={item.data.toolName}
123+
displayTitle={item.data.displayTitle}
124+
status={item.data.status}
125+
streamingArgs={item.data.streamingArgs}
126+
/>
127+
)
128+
}
129+
if (item.type === 'thinking') {
130+
const elapsedMs =
131+
item.startedAt !== undefined && item.endedAt !== undefined
132+
? item.endedAt - item.startedAt
133+
: undefined
134+
if (elapsedMs !== undefined && elapsedMs <= 3000) return null
135+
// "Active" thinking is defined by the block itself, not by
136+
// the group's `isDelegating` flag — subagent_thinking blocks
137+
// explicitly clear isDelegating in parseBlocks, so keying
138+
// off it here would lock the label to "Thought" forever.
139+
return (
140+
<div key={`thinking-${idx}`} className='pl-6'>
141+
<ThinkingBlock
142+
content={item.content}
143+
isActive={idx === items.length - 1 && item.endedAt === undefined}
144+
isStreaming={isStreaming}
145+
startedAt={item.startedAt}
146+
endedAt={item.endedAt}
147+
/>
148+
</div>
149+
)
150+
}
151+
return (
123152
<span
124153
key={`text-${idx}`}
125154
className='pl-6 font-base text-[var(--text-secondary)] text-small'
126155
>
127156
{item.content.trim()}
128157
</span>
129158
)
130-
)}
159+
})}
131160
</div>
132161
</ExpandableContent>
133162
</Expandable>

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export { AgentGroup, CircleStop } from './agent-group'
33
export { ChatContent } from './chat-content'
44
export { Options } from './options'
55
export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags'
6+
export { ThinkingBlock } from './thinking-block'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ThinkingBlock } from './thinking-block'
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
'use client'
2+
3+
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
4+
import { ChevronDown, Expandable, ExpandableContent } from '@/components/emcn'
5+
import { BrainIcon } from '@/components/icons'
6+
import { cn } from '@/lib/core/utils/cn'
7+
8+
interface ThinkingBlockProps {
9+
content: string
10+
isActive: boolean
11+
isStreaming?: boolean
12+
startedAt?: number
13+
endedAt?: number
14+
}
15+
16+
const MIN_VISIBLE_THINKING_MS = 3000
17+
18+
export function ThinkingBlock({
19+
content,
20+
isActive,
21+
isStreaming = false,
22+
startedAt,
23+
endedAt,
24+
}: ThinkingBlockProps) {
25+
// Start collapsed so the `Expandable` plays its height-open animation
26+
// when `expanded` flips to true below — otherwise the panel mounts
27+
// already-open and jumps up with its full content in one frame.
28+
const [expanded, setExpanded] = useState(false)
29+
const panelRef = useRef<HTMLDivElement>(null)
30+
const wasActiveRef = useRef<boolean | null>(null)
31+
// Suppress active thinking until it exceeds MIN_VISIBLE_THINKING_MS.
32+
// Completed-<=threshold is filtered upstream in message-content, so if
33+
// we're mounted with isActive=false we've already passed that gate.
34+
const [thresholdReached, setThresholdReached] = useState(() => {
35+
if (!isActive || startedAt === undefined) return true
36+
return Date.now() - startedAt > MIN_VISIBLE_THINKING_MS
37+
})
38+
39+
useEffect(() => {
40+
if (thresholdReached) return
41+
if (!isActive || startedAt === undefined) {
42+
setThresholdReached(true)
43+
return
44+
}
45+
const remainingMs = Math.max(0, MIN_VISIBLE_THINKING_MS - (Date.now() - startedAt))
46+
const id = window.setTimeout(() => setThresholdReached(true), remainingMs + 50)
47+
return () => window.clearTimeout(id)
48+
}, [isActive, startedAt, thresholdReached])
49+
50+
useEffect(() => {
51+
// Wait until the threshold has actually been reached — otherwise this
52+
// effect fires during the 3-second hidden period (while the component
53+
// returns null) and sets `expanded` to true before the panel is even
54+
// rendered, so the Collapsible mounts already-open with no animation.
55+
if (!thresholdReached) return
56+
if (wasActiveRef.current === isActive) return
57+
// On first run (wasActiveRef === null): open if the stream is live —
58+
// even when thinking itself has already ended — so a mid-stream refresh
59+
// shows the thinking panel open while the rest of the response is still
60+
// being generated. Subsequent runs only react to the isActive transition
61+
// (auto-collapse when thinking ends).
62+
const isFirstRun = wasActiveRef.current === null
63+
wasActiveRef.current = isActive
64+
const target = isFirstRun ? isActive || isStreaming : isActive
65+
// Defer to the next frame so Radix Collapsible paints the closed state
66+
// first, then sees the transition to open. Without this, React can batch
67+
// the mount + flip into a single commit and the animation never plays.
68+
const id = window.requestAnimationFrame(() => setExpanded(target))
69+
return () => window.cancelAnimationFrame(id)
70+
}, [isActive, isStreaming, thresholdReached])
71+
72+
useLayoutEffect(() => {
73+
if (!isActive || !expanded) return
74+
const el = panelRef.current
75+
if (!el) return
76+
el.scrollTop = el.scrollHeight
77+
}, [content, isActive, expanded])
78+
79+
if (!thresholdReached) return null
80+
81+
const elapsedMs =
82+
startedAt !== undefined && endedAt !== undefined && endedAt >= startedAt
83+
? endedAt - startedAt
84+
: undefined
85+
const elapsedSeconds =
86+
elapsedMs !== undefined ? Math.max(1, Math.round(elapsedMs / 1000)) : undefined
87+
const label = isActive
88+
? 'Thinking'
89+
: elapsedSeconds !== undefined
90+
? `Thought for ${elapsedSeconds}s`
91+
: 'Thought'
92+
93+
return (
94+
<div className='flex flex-col gap-1.5'>
95+
<button
96+
type='button'
97+
onClick={() => setExpanded((prev) => !prev)}
98+
className='flex cursor-pointer items-center gap-2'
99+
>
100+
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
101+
<BrainIcon className='h-[14px] w-[14px] text-[var(--text-icon)]' />
102+
</div>
103+
<span className='font-base text-[var(--text-body)] text-sm'>{label}</span>
104+
<ChevronDown
105+
className={cn(
106+
'h-[7px] w-[9px] text-[var(--text-icon)] transition-transform duration-150',
107+
!expanded && '-rotate-90'
108+
)}
109+
/>
110+
</button>
111+
112+
<Expandable expanded={expanded}>
113+
<ExpandableContent>
114+
<div ref={panelRef} className='max-h-[110px] overflow-y-scroll pt-0.5 pr-2 pl-6'>
115+
<div className='whitespace-pre-wrap break-words font-base text-[13px] text-[var(--text-secondary)] leading-[18px] opacity-60'>
116+
{content}
117+
</div>
118+
</div>
119+
</ExpandableContent>
120+
</Expandable>
121+
</div>
122+
)
123+
}

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@ import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state'
1010
import type { ContentBlock, MothershipResource, OptionItem, ToolCallData } from '../../types'
1111
import { SUBAGENT_LABELS, TOOL_UI_METADATA } from '../../types'
1212
import type { AgentGroupItem } from './components'
13-
import { AgentGroup, ChatContent, CircleStop, Options, PendingTagIndicator } from './components'
13+
import {
14+
AgentGroup,
15+
ChatContent,
16+
CircleStop,
17+
Options,
18+
PendingTagIndicator,
19+
ThinkingBlock,
20+
} from './components'
1421

1522
const FILE_SUBAGENT_ID = 'file'
1623

@@ -19,6 +26,14 @@ interface TextSegment {
1926
content: string
2027
}
2128

29+
interface ThinkingSegment {
30+
type: 'thinking'
31+
id: string
32+
content: string
33+
startedAt?: number
34+
endedAt?: number
35+
}
36+
2237
interface AgentGroupSegment {
2338
type: 'agent_group'
2439
id: string
@@ -38,7 +53,12 @@ interface StoppedSegment {
3853
type: 'stopped'
3954
}
4055

41-
type MessageSegment = TextSegment | AgentGroupSegment | OptionsSegment | StoppedSegment
56+
type MessageSegment =
57+
| TextSegment
58+
| ThinkingSegment
59+
| AgentGroupSegment
60+
| OptionsSegment
61+
| StoppedSegment
4262

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

@@ -156,6 +176,46 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
156176
continue
157177
}
158178

179+
if (block.type === 'subagent_thinking') {
180+
if (!block.content || !group) continue
181+
group.isDelegating = false
182+
const lastItem = group.items[group.items.length - 1]
183+
if (lastItem?.type === 'thinking') {
184+
lastItem.content += block.content
185+
if (block.endedAt !== undefined) lastItem.endedAt = block.endedAt
186+
} else {
187+
group.items.push({
188+
type: 'thinking',
189+
content: block.content,
190+
startedAt: block.timestamp,
191+
endedAt: block.endedAt,
192+
})
193+
}
194+
continue
195+
}
196+
197+
if (block.type === 'thinking') {
198+
if (!block.content?.trim()) continue
199+
if (group) {
200+
pushGroup(group)
201+
group = null
202+
}
203+
const last = segments[segments.length - 1]
204+
if (last?.type === 'thinking') {
205+
last.content += block.content
206+
if (block.endedAt !== undefined) last.endedAt = block.endedAt
207+
} else {
208+
segments.push({
209+
type: 'thinking',
210+
id: `thinking-${i}`,
211+
content: block.content,
212+
startedAt: block.timestamp,
213+
endedAt: block.endedAt,
214+
})
215+
}
216+
continue
217+
}
218+
159219
if (block.type === 'text') {
160220
if (!block.content) continue
161221
if (block.subagent) {
@@ -383,7 +443,9 @@ export function MessageContent({
383443

384444
const hasSubagentEnded = blocks.some((b) => b.type === 'subagent_end')
385445
const showTrailingThinking =
386-
isStreaming && !hasTrailingContent && (hasSubagentEnded || allLastGroupToolsDone)
446+
isStreaming &&
447+
!hasTrailingContent &&
448+
(lastSegment.type === 'thinking' || hasSubagentEnded || allLastGroupToolsDone)
387449
const lastOpenSubagentGroupId = [...segments]
388450
.reverse()
389451
.find(
@@ -405,6 +467,30 @@ export function MessageContent({
405467
onWorkspaceResourceSelect={onWorkspaceResourceSelect}
406468
/>
407469
)
470+
case 'thinking': {
471+
const isActive =
472+
isStreaming && i === segments.length - 1 && segment.endedAt === undefined
473+
const elapsedMs =
474+
segment.startedAt !== undefined && segment.endedAt !== undefined
475+
? segment.endedAt - segment.startedAt
476+
: undefined
477+
// Hide completed thinking that took 3s or less — quick thinking
478+
// isn't worth the visual noise. Still show while active (unknown
479+
// duration yet) and still show when timing is missing (old
480+
// persisted blocks) so we don't drop historical content.
481+
if (elapsedMs !== undefined && elapsedMs <= 3000) return null
482+
return (
483+
<div key={segment.id} className={isStreaming ? 'animate-stream-fade-in' : undefined}>
484+
<ThinkingBlock
485+
content={segment.content}
486+
isActive={isActive}
487+
isStreaming={isStreaming}
488+
startedAt={segment.startedAt}
489+
endedAt={segment.endedAt}
490+
/>
491+
</div>
492+
)
493+
}
408494
case 'agent_group': {
409495
const toolItems = segment.items.filter((item) => item.type === 'tool')
410496
const allToolsDone =
@@ -419,6 +505,7 @@ export function MessageContent({
419505
agentLabel={segment.agentLabel}
420506
items={segment.items}
421507
isDelegating={segment.isDelegating}
508+
isStreaming={isStreaming}
422509
autoCollapse={allToolsDone && hasFollowingText}
423510
defaultExpanded={segment.id === lastOpenSubagentGroupId}
424511
/>

0 commit comments

Comments
 (0)