Skip to content

Commit e959b88

Browse files
committed
Add subagents
1 parent 0977ed2 commit e959b88

File tree

6 files changed

+820
-128
lines changed

6 files changed

+820
-128
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx

Lines changed: 94 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -5,72 +5,19 @@ import clsx from 'clsx'
55
import { ChevronUp } from 'lucide-react'
66

77
/**
8-
* Timer update interval in milliseconds
8+
* Max height for thinking content before internal scrolling kicks in
99
*/
10-
const TIMER_UPDATE_INTERVAL = 100
10+
const THINKING_MAX_HEIGHT = 125
1111

1212
/**
13-
* Milliseconds threshold for displaying as seconds
13+
* Interval for auto-scroll during streaming (ms)
1414
*/
15-
const SECONDS_THRESHOLD = 1000
15+
const SCROLL_INTERVAL = 100
1616

1717
/**
18-
* Props for the ShimmerOverlayText component
19-
*/
20-
interface ShimmerOverlayTextProps {
21-
/** Label text to display */
22-
label: string
23-
/** Value text to display */
24-
value: string
25-
/** Whether the shimmer animation is active */
26-
active?: boolean
27-
}
28-
29-
/**
30-
* ShimmerOverlayText component for thinking block
31-
* Applies shimmer effect to the "Thought for X.Xs" text during streaming
32-
*
33-
* @param props - Component props
34-
* @returns Text with optional shimmer overlay effect
18+
* Timer update interval in milliseconds
3519
*/
36-
function ShimmerOverlayText({ label, value, active = false }: ShimmerOverlayTextProps) {
37-
return (
38-
<span className='relative inline-block'>
39-
<span className='text-[var(--text-tertiary)]'>{label}</span>
40-
<span className='text-[var(--text-muted)]'>{value}</span>
41-
{active ? (
42-
<span
43-
aria-hidden='true'
44-
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
45-
>
46-
<span
47-
className='block text-transparent'
48-
style={{
49-
backgroundImage:
50-
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
51-
backgroundSize: '200% 100%',
52-
backgroundRepeat: 'no-repeat',
53-
WebkitBackgroundClip: 'text',
54-
backgroundClip: 'text',
55-
animation: 'thinking-shimmer 1.4s ease-in-out infinite',
56-
mixBlendMode: 'screen',
57-
}}
58-
>
59-
{label}
60-
{value}
61-
</span>
62-
</span>
63-
) : null}
64-
<style>{`
65-
@keyframes thinking-shimmer {
66-
0% { background-position: 150% 0; }
67-
50% { background-position: 0% 0; }
68-
100% { background-position: -150% 0; }
69-
}
70-
`}</style>
71-
</span>
72-
)
73-
}
20+
const TIMER_UPDATE_INTERVAL = 100
7421

7522
/**
7623
* Props for the ThinkingBlock component
@@ -80,46 +27,37 @@ interface ThinkingBlockProps {
8027
content: string
8128
/** Whether the block is currently streaming */
8229
isStreaming?: boolean
83-
/** Persisted duration from content block */
84-
duration?: number
85-
/** Persisted start time from content block */
86-
startTime?: number
30+
/** Whether there are more content blocks after this one (e.g., tool calls) */
31+
hasFollowingContent?: boolean
8732
}
8833

8934
/**
9035
* ThinkingBlock component displays AI reasoning/thinking process
9136
* Shows collapsible content with duration timer
9237
* Auto-expands during streaming and collapses when complete
38+
* Auto-collapses when a tool call or other content comes in after it
9339
*
9440
* @param props - Component props
9541
* @returns Thinking block with expandable content and timer
9642
*/
9743
export function ThinkingBlock({
9844
content,
9945
isStreaming = false,
100-
duration: persistedDuration,
101-
startTime: persistedStartTime,
46+
hasFollowingContent = false,
10247
}: ThinkingBlockProps) {
10348
const [isExpanded, setIsExpanded] = useState(false)
104-
const [duration, setDuration] = useState(persistedDuration ?? 0)
49+
const [duration, setDuration] = useState(0)
10550
const userCollapsedRef = useRef<boolean>(false)
106-
const startTimeRef = useRef<number>(persistedStartTime ?? Date.now())
107-
108-
/**
109-
* Updates start time reference when persisted start time changes
110-
*/
111-
useEffect(() => {
112-
if (typeof persistedStartTime === 'number') {
113-
startTimeRef.current = persistedStartTime
114-
}
115-
}, [persistedStartTime])
51+
const scrollContainerRef = useRef<HTMLDivElement>(null)
52+
const startTimeRef = useRef<number>(Date.now())
11653

11754
/**
11855
* Auto-expands block when streaming with content
119-
* Auto-collapses when streaming ends
56+
* Auto-collapses when streaming ends OR when following content arrives
12057
*/
12158
useEffect(() => {
122-
if (!isStreaming) {
59+
// Collapse if streaming ended or if there's following content (like a tool call)
60+
if (!isStreaming || hasFollowingContent) {
12361
setIsExpanded(false)
12462
userCollapsedRef.current = false
12563
return
@@ -128,42 +66,57 @@ export function ThinkingBlock({
12866
if (!userCollapsedRef.current && content && content.trim().length > 0) {
12967
setIsExpanded(true)
13068
}
131-
}, [isStreaming, content])
69+
}, [isStreaming, content, hasFollowingContent])
13270

133-
/**
134-
* Updates duration timer during streaming
135-
* Uses persisted duration when available
136-
*/
71+
// Reset start time when streaming begins
13772
useEffect(() => {
138-
if (typeof persistedDuration === 'number') {
139-
setDuration(persistedDuration)
140-
return
73+
if (isStreaming && !hasFollowingContent) {
74+
startTimeRef.current = Date.now()
75+
setDuration(0)
14176
}
77+
}, [isStreaming, hasFollowingContent])
14278

143-
if (isStreaming) {
144-
const interval = setInterval(() => {
145-
setDuration(Date.now() - startTimeRef.current)
146-
}, TIMER_UPDATE_INTERVAL)
147-
return () => clearInterval(interval)
148-
}
79+
// Update duration timer during streaming (stop when following content arrives)
80+
useEffect(() => {
81+
// Stop timer if not streaming or if there's following content (thinking is done)
82+
if (!isStreaming || hasFollowingContent) return
83+
84+
const interval = setInterval(() => {
85+
setDuration(Date.now() - startTimeRef.current)
86+
}, TIMER_UPDATE_INTERVAL)
14987

150-
setDuration(Date.now() - startTimeRef.current)
151-
}, [isStreaming, persistedDuration])
88+
return () => clearInterval(interval)
89+
}, [isStreaming, hasFollowingContent])
90+
91+
// Auto-scroll to bottom during streaming using interval (same as copilot chat)
92+
useEffect(() => {
93+
if (!isStreaming || !isExpanded) return
94+
95+
const intervalId = window.setInterval(() => {
96+
const container = scrollContainerRef.current
97+
if (!container) return
98+
99+
container.scrollTo({
100+
top: container.scrollHeight,
101+
behavior: 'smooth',
102+
})
103+
}, SCROLL_INTERVAL)
104+
105+
return () => window.clearInterval(intervalId)
106+
}, [isStreaming, isExpanded])
152107

153108
/**
154-
* Formats duration in milliseconds to human-readable format
155-
* @param ms - Duration in milliseconds
156-
* @returns Formatted string (e.g., "150ms" or "2.5s")
109+
* Formats duration in milliseconds to seconds
110+
* Always shows seconds, rounded to nearest whole second, minimum 1s
157111
*/
158112
const formatDuration = (ms: number) => {
159-
if (ms < SECONDS_THRESHOLD) {
160-
return `${ms}ms`
161-
}
162-
const seconds = (ms / SECONDS_THRESHOLD).toFixed(1)
113+
const seconds = Math.max(1, Math.round(ms / 1000))
163114
return `${seconds}s`
164115
}
165116

166117
const hasContent = content && content.trim().length > 0
118+
const label = isStreaming ? 'Thinking' : 'Thought'
119+
const durationText = ` for ${formatDuration(duration)}`
167120

168121
return (
169122
<div className='mt-1 mb-0'>
@@ -180,21 +133,54 @@ export function ThinkingBlock({
180133
type='button'
181134
disabled={!hasContent}
182135
>
183-
<ShimmerOverlayText
184-
label='Thought'
185-
value={` for ${formatDuration(duration)}`}
186-
active={isStreaming}
187-
/>
136+
<span className='relative inline-block'>
137+
<span className='text-[var(--text-tertiary)]'>{label}</span>
138+
<span className='text-[var(--text-muted)]'>{durationText}</span>
139+
{isStreaming && (
140+
<span
141+
aria-hidden='true'
142+
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
143+
>
144+
<span
145+
className='block text-transparent'
146+
style={{
147+
backgroundImage:
148+
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
149+
backgroundSize: '200% 100%',
150+
backgroundRepeat: 'no-repeat',
151+
WebkitBackgroundClip: 'text',
152+
backgroundClip: 'text',
153+
animation: 'thinking-shimmer 1.4s ease-in-out infinite',
154+
mixBlendMode: 'screen',
155+
}}
156+
>
157+
{label}
158+
{durationText}
159+
</span>
160+
</span>
161+
)}
162+
<style>{`
163+
@keyframes thinking-shimmer {
164+
0% { background-position: 150% 0; }
165+
50% { background-position: 0% 0; }
166+
100% { background-position: -150% 0; }
167+
}
168+
`}</style>
169+
</span>
188170
{hasContent && (
189171
<ChevronUp
190-
className={clsx('h-3 w-3 transition-transform', isExpanded && 'rotate-180')}
172+
className={clsx('h-3 w-3 transition-transform', isExpanded ? 'rotate-180' : 'rotate-90')}
191173
aria-hidden='true'
192174
/>
193175
)}
194176
</button>
195177

196178
{isExpanded && (
197-
<div className='ml-1 border-[var(--border-1)] border-l-2 pl-2'>
179+
<div
180+
ref={scrollContainerRef}
181+
className='ml-1 overflow-y-auto border-[var(--border-1)] border-l-2 pl-2'
182+
style={{ maxHeight: THINKING_MAX_HEIGHT }}
183+
>
198184
<pre className='whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-tertiary)] leading-[1.15rem]'>
199185
{content}
200186
{isStreaming && (

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -201,19 +201,14 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
201201
)
202202
}
203203
if (block.type === 'thinking') {
204-
const isLastBlock = index === message.contentBlocks!.length - 1
205-
// Consider the thinking block streaming if the overall message is streaming
206-
// and the block has not been finalized with a duration yet. This avoids
207-
// freezing the timer when new blocks are appended after the thinking block.
208-
const isStreamingThinking = isStreaming && (block as any).duration == null
209-
204+
// Check if there are any blocks after this one (tool calls, text, etc.)
205+
const hasFollowingContent = index < message.contentBlocks!.length - 1
210206
return (
211207
<div key={`thinking-${index}-${block.timestamp || index}`} className='w-full'>
212208
<ThinkingBlock
213209
content={block.content}
214-
isStreaming={isStreamingThinking}
215-
duration={block.duration}
216-
startTime={block.startTime}
210+
isStreaming={isStreaming}
211+
hasFollowingContent={hasFollowingContent}
217212
/>
218213
</div>
219214
)

0 commit comments

Comments
 (0)