Skip to content

Commit bdfb56b

Browse files
authored
fix(copilot): streaming (#1023)
* Fix 1 * Fix * Bugfix * Make thinking streaming smoother * Better autoscroll, still not great * Updates * Updates * Updates * Restore checkpoitn logic * Fix aborts * Checkpoitn ui * Lint * Fix empty file
1 parent 4a7de31 commit bdfb56b

File tree

14 files changed

+362
-216
lines changed

14 files changed

+362
-216
lines changed

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

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ const ChatMessageSchema = z.object({
8181
chatId: z.string().optional(),
8282
workflowId: z.string().min(1, 'Workflow ID is required'),
8383
mode: z.enum(['ask', 'agent']).optional().default('agent'),
84-
depth: z.number().int().min(0).max(3).optional().default(0),
84+
depth: z.number().int().min(-2).max(3).optional().default(0),
85+
prefetch: z.boolean().optional(),
8586
createNewChat: z.boolean().optional().default(false),
8687
stream: z.boolean().optional().default(true),
8788
implicitFeedback: z.string().optional(),
@@ -198,6 +199,7 @@ export async function POST(req: NextRequest) {
198199
workflowId,
199200
mode,
200201
depth,
202+
prefetch,
201203
createNewChat,
202204
stream,
203205
implicitFeedback,
@@ -214,6 +216,19 @@ export async function POST(req: NextRequest) {
214216
return createInternalServerErrorResponse('Missing required configuration: BETTER_AUTH_URL')
215217
}
216218

219+
// Consolidation mapping: map negative depths to base depth with prefetch=true
220+
let effectiveDepth: number | undefined = typeof depth === 'number' ? depth : undefined
221+
let effectivePrefetch: boolean | undefined = prefetch
222+
if (typeof effectiveDepth === 'number') {
223+
if (effectiveDepth === -2) {
224+
effectiveDepth = 1
225+
effectivePrefetch = true
226+
} else if (effectiveDepth === -1) {
227+
effectiveDepth = 0
228+
effectivePrefetch = true
229+
}
230+
}
231+
217232
logger.info(`[${tracker.requestId}] Processing copilot chat request`, {
218233
userId: authenticatedUserId,
219234
workflowId,
@@ -226,6 +241,7 @@ export async function POST(req: NextRequest) {
226241
provider: provider || 'openai',
227242
hasConversationId: !!conversationId,
228243
depth,
244+
prefetch,
229245
origin: requestOrigin,
230246
})
231247

@@ -402,7 +418,8 @@ export async function POST(req: NextRequest) {
402418
mode: mode,
403419
provider: providerToUse,
404420
...(effectiveConversationId ? { conversationId: effectiveConversationId } : {}),
405-
...(typeof depth === 'number' ? { depth } : {}),
421+
...(typeof effectiveDepth === 'number' ? { depth: effectiveDepth } : {}),
422+
...(typeof effectivePrefetch === 'boolean' ? { prefetch: effectivePrefetch } : {}),
406423
...(session?.user?.name && { userName: session.user.name }),
407424
...(requestOrigin ? { origin: requestOrigin } : {}),
408425
}
@@ -416,7 +433,8 @@ export async function POST(req: NextRequest) {
416433
stream,
417434
workflowId,
418435
hasConversationId: !!effectiveConversationId,
419-
depth: typeof depth === 'number' ? depth : undefined,
436+
depth: typeof effectiveDepth === 'number' ? effectiveDepth : undefined,
437+
prefetch: typeof effectivePrefetch === 'boolean' ? effectivePrefetch : undefined,
420438
messagesCount: requestPayload.messages.length,
421439
...(requestOrigin ? { origin: requestOrigin } : {}),
422440
})
@@ -478,6 +496,12 @@ export async function POST(req: NextRequest) {
478496
let isFirstDone = true
479497
let responseIdFromStart: string | undefined
480498
let responseIdFromDone: string | undefined
499+
// Track tool call progress to identify a safe done event
500+
const announcedToolCallIds = new Set<string>()
501+
const startedToolExecutionIds = new Set<string>()
502+
const completedToolExecutionIds = new Set<string>()
503+
let lastDoneResponseId: string | undefined
504+
let lastSafeDoneResponseId: string | undefined
481505

482506
// Send chatId as first event
483507
if (actualChatId) {
@@ -595,6 +619,9 @@ export async function POST(req: NextRequest) {
595619
)
596620
if (!event.data?.partial) {
597621
toolCalls.push(event.data)
622+
if (event.data?.id) {
623+
announcedToolCallIds.add(event.data.id)
624+
}
598625
}
599626
break
600627

@@ -604,6 +631,14 @@ export async function POST(req: NextRequest) {
604631
toolName: event.toolName,
605632
status: event.status,
606633
})
634+
if (event.toolCallId) {
635+
if (event.status === 'completed') {
636+
startedToolExecutionIds.add(event.toolCallId)
637+
completedToolExecutionIds.add(event.toolCallId)
638+
} else {
639+
startedToolExecutionIds.add(event.toolCallId)
640+
}
641+
}
607642
break
608643

609644
case 'tool_result':
@@ -614,6 +649,9 @@ export async function POST(req: NextRequest) {
614649
result: `${JSON.stringify(event.result).substring(0, 200)}...`,
615650
resultSize: JSON.stringify(event.result).length,
616651
})
652+
if (event.toolCallId) {
653+
completedToolExecutionIds.add(event.toolCallId)
654+
}
617655
break
618656

619657
case 'tool_error':
@@ -623,6 +661,9 @@ export async function POST(req: NextRequest) {
623661
error: event.error,
624662
success: event.success,
625663
})
664+
if (event.toolCallId) {
665+
completedToolExecutionIds.add(event.toolCallId)
666+
}
626667
break
627668

628669
case 'start':
@@ -637,9 +678,25 @@ export async function POST(req: NextRequest) {
637678
case 'done':
638679
if (event.data?.responseId) {
639680
responseIdFromDone = event.data.responseId
681+
lastDoneResponseId = responseIdFromDone
640682
logger.info(
641683
`[${tracker.requestId}] Received done event with responseId: ${responseIdFromDone}`
642684
)
685+
// Mark this done as safe only if no tool call is currently in progress or pending
686+
const announced = announcedToolCallIds.size
687+
const completed = completedToolExecutionIds.size
688+
const started = startedToolExecutionIds.size
689+
const hasToolInProgress = announced > completed || started > completed
690+
if (!hasToolInProgress) {
691+
lastSafeDoneResponseId = responseIdFromDone
692+
logger.info(
693+
`[${tracker.requestId}] Marked done as SAFE (no tools in progress)`
694+
)
695+
} else {
696+
logger.info(
697+
`[${tracker.requestId}] Done received but tools are in progress (announced=${announced}, started=${started}, completed=${completed})`
698+
)
699+
}
643700
}
644701
if (isFirstDone) {
645702
logger.info(
@@ -734,7 +791,9 @@ export async function POST(req: NextRequest) {
734791
)
735792
}
736793

737-
const responseId = responseIdFromDone
794+
// Persist only a safe conversationId to avoid continuing from a state that expects tool outputs
795+
const previousConversationId = currentChat?.conversationId as string | undefined
796+
const responseId = lastSafeDoneResponseId || previousConversationId || undefined
738797

739798
// Update chat in database immediately (without title)
740799
await db

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,11 @@ export function DiffControls() {
191191
logger.info('Accepting proposed changes with backup protection')
192192

193193
try {
194+
// Create a checkpoint before applying changes so it appears under the triggering user message
195+
await createCheckpoint().catch((error) => {
196+
logger.warn('Failed to create checkpoint before accept:', error)
197+
})
198+
194199
// Clear preview YAML immediately
195200
await clearPreviewYaml().catch((error) => {
196201
logger.warn('Failed to clear preview YAML:', error)
@@ -219,10 +224,10 @@ export function DiffControls() {
219224
logger.warn('Failed to clear preview YAML:', error)
220225
})
221226

222-
// Reject is immediate (no server save needed)
223-
rejectChanges()
224-
225-
logger.info('Successfully rejected proposed changes')
227+
// Reject changes optimistically
228+
rejectChanges().catch((error) => {
229+
logger.error('Failed to reject changes (background):', error)
230+
})
226231
}
227232

228233
return (

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

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ export function ThinkingBlock({
2727
}
2828
}, [persistedStartTime])
2929

30+
useEffect(() => {
31+
// Auto-collapse when streaming ends
32+
if (!isStreaming) {
33+
setIsExpanded(false)
34+
return
35+
}
36+
// Expand once there is visible content while streaming
37+
if (content && content.trim().length > 0) {
38+
setIsExpanded(true)
39+
}
40+
}, [isStreaming, content])
41+
3042
useEffect(() => {
3143
// If we already have a persisted duration, just use it
3244
if (typeof persistedDuration === 'number') {
@@ -52,44 +64,36 @@ export function ThinkingBlock({
5264
return `${seconds}s`
5365
}
5466

55-
if (!isExpanded) {
56-
return (
67+
return (
68+
<div className='my-1'>
5769
<button
58-
onClick={() => setIsExpanded(true)}
70+
onClick={() => setIsExpanded((v) => !v)}
5971
className={cn(
60-
'inline-flex items-center gap-1 text-gray-400 text-xs transition-colors hover:text-gray-500',
72+
'mb-1 inline-flex items-center gap-1 text-gray-400 text-xs transition-colors hover:text-gray-500',
6173
'font-normal italic'
6274
)}
6375
type='button'
6476
>
6577
<Brain className='h-3 w-3' />
66-
<span>Thought for {formatDuration(duration)}</span>
78+
<span>
79+
Thought for {formatDuration(duration)}
80+
{isExpanded ? ' (click to collapse)' : ''}
81+
</span>
6782
{isStreaming && (
6883
<span className='inline-flex h-1 w-1 animate-pulse rounded-full bg-gray-400' />
6984
)}
7085
</button>
71-
)
72-
}
7386

74-
return (
75-
<div className='my-1'>
76-
<button
77-
onClick={() => setIsExpanded(false)}
78-
className={cn(
79-
'mb-1 inline-flex items-center gap-1 text-gray-400 text-xs transition-colors hover:text-gray-500',
80-
'font-normal italic'
81-
)}
82-
type='button'
83-
>
84-
<Brain className='h-3 w-3' />
85-
<span>Thought for {formatDuration(duration)} (click to collapse)</span>
86-
</button>
87-
<div className='ml-1 border-gray-200 border-l-2 pl-2 dark:border-gray-700'>
88-
<pre className='whitespace-pre-wrap font-mono text-gray-400 text-xs dark:text-gray-500'>
89-
{content}
90-
{isStreaming && <span className='ml-1 inline-block h-2 w-1 animate-pulse bg-gray-400' />}
91-
</pre>
92-
</div>
87+
{isExpanded && (
88+
<div className='ml-1 border-gray-200 border-l-2 pl-2 dark:border-gray-700'>
89+
<pre className='whitespace-pre-wrap font-mono text-gray-400 text-xs dark:text-gray-500'>
90+
{content}
91+
{isStreaming && (
92+
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-gray-400' />
93+
)}
94+
</pre>
95+
</div>
96+
)}
9397
</div>
9498
)
9599
}

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

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -643,41 +643,49 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
643643
{/* Checkpoints below message */}
644644
{hasCheckpoints && (
645645
<div className='mt-1 flex justify-end'>
646-
{showRestoreConfirmation ? (
647-
<div className='flex items-center gap-2'>
648-
<span className='text-muted-foreground text-xs'>Restore?</span>
649-
<button
650-
onClick={handleConfirmRevert}
651-
disabled={isRevertingCheckpoint}
652-
className='text-muted-foreground text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
653-
title='Confirm restore'
654-
>
655-
{isRevertingCheckpoint ? (
656-
<Loader2 className='h-3 w-3 animate-spin' />
657-
) : (
658-
<Check className='h-3 w-3' />
659-
)}
660-
</button>
661-
<button
662-
onClick={handleCancelRevert}
663-
disabled={isRevertingCheckpoint}
664-
className='text-muted-foreground text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
665-
title='Cancel restore'
666-
>
667-
<X className='h-3 w-3' />
668-
</button>
646+
<div className='inline-flex items-center gap-0.5 text-muted-foreground text-xs'>
647+
<span className='select-none'>
648+
Restore{showRestoreConfirmation && <span className='ml-0.5'>?</span>}
649+
</span>
650+
<div className='inline-flex w-8 items-center justify-center'>
651+
{showRestoreConfirmation ? (
652+
<div className='inline-flex items-center gap-1'>
653+
<button
654+
onClick={handleConfirmRevert}
655+
disabled={isRevertingCheckpoint}
656+
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
657+
title='Confirm restore'
658+
aria-label='Confirm restore'
659+
>
660+
{isRevertingCheckpoint ? (
661+
<Loader2 className='h-3 w-3 animate-spin' />
662+
) : (
663+
<Check className='h-3 w-3' />
664+
)}
665+
</button>
666+
<button
667+
onClick={handleCancelRevert}
668+
disabled={isRevertingCheckpoint}
669+
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
670+
title='Cancel restore'
671+
aria-label='Cancel restore'
672+
>
673+
<X className='h-3 w-3' />
674+
</button>
675+
</div>
676+
) : (
677+
<button
678+
onClick={handleRevertToCheckpoint}
679+
disabled={isRevertingCheckpoint}
680+
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
681+
title='Restore workflow to this checkpoint state'
682+
aria-label='Restore'
683+
>
684+
<RotateCcw className='h-3 w-3' />
685+
</button>
686+
)}
669687
</div>
670-
) : (
671-
<button
672-
onClick={handleRevertToCheckpoint}
673-
disabled={isRevertingCheckpoint}
674-
className='flex items-center gap-1.5 rounded-md px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50'
675-
title='Restore workflow to this checkpoint state'
676-
>
677-
<RotateCcw className='h-3 w-3' />
678-
Restore
679-
</button>
680-
)}
688+
</div>
681689
</div>
682690
)}
683691
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
'use client'
2+
3+
import * as React from 'react'
4+
import * as SliderPrimitive from '@radix-ui/react-slider'
5+
import { cn } from '@/lib/utils'
6+
7+
export const CopilotSlider = React.forwardRef<
8+
React.ElementRef<typeof SliderPrimitive.Root>,
9+
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
10+
>(({ className, ...props }, ref) => (
11+
<SliderPrimitive.Root
12+
ref={ref}
13+
className={cn(
14+
'relative flex w-full cursor-pointer touch-none select-none items-center',
15+
className
16+
)}
17+
{...props}
18+
>
19+
<SliderPrimitive.Track className='relative h-2 w-full grow cursor-pointer overflow-hidden rounded-full bg-input'>
20+
<SliderPrimitive.Range className='absolute h-full bg-primary' />
21+
</SliderPrimitive.Track>
22+
<SliderPrimitive.Thumb className='block h-5 w-5 cursor-pointer rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50' />
23+
</SliderPrimitive.Root>
24+
))
25+
CopilotSlider.displayName = 'CopilotSlider'

0 commit comments

Comments
 (0)