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
67 changes: 63 additions & 4 deletions apps/sim/app/api/copilot/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ const ChatMessageSchema = z.object({
chatId: z.string().optional(),
workflowId: z.string().min(1, 'Workflow ID is required'),
mode: z.enum(['ask', 'agent']).optional().default('agent'),
depth: z.number().int().min(0).max(3).optional().default(0),
depth: z.number().int().min(-2).max(3).optional().default(0),
prefetch: z.boolean().optional(),
createNewChat: z.boolean().optional().default(false),
stream: z.boolean().optional().default(true),
implicitFeedback: z.string().optional(),
Expand Down Expand Up @@ -198,6 +199,7 @@ export async function POST(req: NextRequest) {
workflowId,
mode,
depth,
prefetch,
createNewChat,
stream,
implicitFeedback,
Expand All @@ -214,6 +216,19 @@ export async function POST(req: NextRequest) {
return createInternalServerErrorResponse('Missing required configuration: BETTER_AUTH_URL')
}

// Consolidation mapping: map negative depths to base depth with prefetch=true
let effectiveDepth: number | undefined = typeof depth === 'number' ? depth : undefined
let effectivePrefetch: boolean | undefined = prefetch
if (typeof effectiveDepth === 'number') {
if (effectiveDepth === -2) {
effectiveDepth = 1
effectivePrefetch = true
} else if (effectiveDepth === -1) {
effectiveDepth = 0
effectivePrefetch = true
}
}

logger.info(`[${tracker.requestId}] Processing copilot chat request`, {
userId: authenticatedUserId,
workflowId,
Expand All @@ -226,6 +241,7 @@ export async function POST(req: NextRequest) {
provider: provider || 'openai',
hasConversationId: !!conversationId,
depth,
prefetch,
origin: requestOrigin,
})

Expand Down Expand Up @@ -402,7 +418,8 @@ export async function POST(req: NextRequest) {
mode: mode,
provider: providerToUse,
...(effectiveConversationId ? { conversationId: effectiveConversationId } : {}),
...(typeof depth === 'number' ? { depth } : {}),
...(typeof effectiveDepth === 'number' ? { depth: effectiveDepth } : {}),
...(typeof effectivePrefetch === 'boolean' ? { prefetch: effectivePrefetch } : {}),
...(session?.user?.name && { userName: session.user.name }),
...(requestOrigin ? { origin: requestOrigin } : {}),
}
Expand All @@ -416,7 +433,8 @@ export async function POST(req: NextRequest) {
stream,
workflowId,
hasConversationId: !!effectiveConversationId,
depth: typeof depth === 'number' ? depth : undefined,
depth: typeof effectiveDepth === 'number' ? effectiveDepth : undefined,
prefetch: typeof effectivePrefetch === 'boolean' ? effectivePrefetch : undefined,
messagesCount: requestPayload.messages.length,
...(requestOrigin ? { origin: requestOrigin } : {}),
})
Expand Down Expand Up @@ -478,6 +496,12 @@ export async function POST(req: NextRequest) {
let isFirstDone = true
let responseIdFromStart: string | undefined
let responseIdFromDone: string | undefined
// Track tool call progress to identify a safe done event
const announcedToolCallIds = new Set<string>()
const startedToolExecutionIds = new Set<string>()
const completedToolExecutionIds = new Set<string>()
let lastDoneResponseId: string | undefined
let lastSafeDoneResponseId: string | undefined

// Send chatId as first event
if (actualChatId) {
Expand Down Expand Up @@ -595,6 +619,9 @@ export async function POST(req: NextRequest) {
)
if (!event.data?.partial) {
toolCalls.push(event.data)
if (event.data?.id) {
announcedToolCallIds.add(event.data.id)
}
}
break

Expand All @@ -604,6 +631,14 @@ export async function POST(req: NextRequest) {
toolName: event.toolName,
status: event.status,
})
if (event.toolCallId) {
if (event.status === 'completed') {
startedToolExecutionIds.add(event.toolCallId)
completedToolExecutionIds.add(event.toolCallId)
} else {
startedToolExecutionIds.add(event.toolCallId)
}
}
break

case 'tool_result':
Expand All @@ -614,6 +649,9 @@ export async function POST(req: NextRequest) {
result: `${JSON.stringify(event.result).substring(0, 200)}...`,
resultSize: JSON.stringify(event.result).length,
})
if (event.toolCallId) {
completedToolExecutionIds.add(event.toolCallId)
}
break

case 'tool_error':
Expand All @@ -623,6 +661,9 @@ export async function POST(req: NextRequest) {
error: event.error,
success: event.success,
})
if (event.toolCallId) {
completedToolExecutionIds.add(event.toolCallId)
}
break

case 'start':
Expand All @@ -637,9 +678,25 @@ export async function POST(req: NextRequest) {
case 'done':
if (event.data?.responseId) {
responseIdFromDone = event.data.responseId
lastDoneResponseId = responseIdFromDone
logger.info(
`[${tracker.requestId}] Received done event with responseId: ${responseIdFromDone}`
)
// Mark this done as safe only if no tool call is currently in progress or pending
const announced = announcedToolCallIds.size
const completed = completedToolExecutionIds.size
const started = startedToolExecutionIds.size
const hasToolInProgress = announced > completed || started > completed
if (!hasToolInProgress) {
lastSafeDoneResponseId = responseIdFromDone
logger.info(
`[${tracker.requestId}] Marked done as SAFE (no tools in progress)`
)
} else {
logger.info(
`[${tracker.requestId}] Done received but tools are in progress (announced=${announced}, started=${started}, completed=${completed})`
)
}
}
if (isFirstDone) {
logger.info(
Expand Down Expand Up @@ -734,7 +791,9 @@ export async function POST(req: NextRequest) {
)
}

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

// Update chat in database immediately (without title)
await db
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,11 @@ export function DiffControls() {
logger.info('Accepting proposed changes with backup protection')

try {
// Create a checkpoint before applying changes so it appears under the triggering user message
await createCheckpoint().catch((error) => {
logger.warn('Failed to create checkpoint before accept:', error)
})

// Clear preview YAML immediately
await clearPreviewYaml().catch((error) => {
logger.warn('Failed to clear preview YAML:', error)
Expand Down Expand Up @@ -219,10 +224,10 @@ export function DiffControls() {
logger.warn('Failed to clear preview YAML:', error)
})

// Reject is immediate (no server save needed)
rejectChanges()

logger.info('Successfully rejected proposed changes')
// Reject changes optimistically
rejectChanges().catch((error) => {
logger.error('Failed to reject changes (background):', error)
})
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ export function ThinkingBlock({
}
}, [persistedStartTime])

useEffect(() => {
// Auto-collapse when streaming ends
if (!isStreaming) {
setIsExpanded(false)
return
}
// Expand once there is visible content while streaming
if (content && content.trim().length > 0) {
setIsExpanded(true)
}
}, [isStreaming, content])

useEffect(() => {
// If we already have a persisted duration, just use it
if (typeof persistedDuration === 'number') {
Expand All @@ -52,44 +64,36 @@ export function ThinkingBlock({
return `${seconds}s`
}

if (!isExpanded) {
return (
return (
<div className='my-1'>
<button
onClick={() => setIsExpanded(true)}
onClick={() => setIsExpanded((v) => !v)}
className={cn(
'inline-flex items-center gap-1 text-gray-400 text-xs transition-colors hover:text-gray-500',
'mb-1 inline-flex items-center gap-1 text-gray-400 text-xs transition-colors hover:text-gray-500',
'font-normal italic'
)}
type='button'
>
<Brain className='h-3 w-3' />
<span>Thought for {formatDuration(duration)}</span>
<span>
Thought for {formatDuration(duration)}
{isExpanded ? ' (click to collapse)' : ''}
</span>
{isStreaming && (
<span className='inline-flex h-1 w-1 animate-pulse rounded-full bg-gray-400' />
)}
</button>
)
}

return (
<div className='my-1'>
<button
onClick={() => setIsExpanded(false)}
className={cn(
'mb-1 inline-flex items-center gap-1 text-gray-400 text-xs transition-colors hover:text-gray-500',
'font-normal italic'
)}
type='button'
>
<Brain className='h-3 w-3' />
<span>Thought for {formatDuration(duration)} (click to collapse)</span>
</button>
<div className='ml-1 border-gray-200 border-l-2 pl-2 dark:border-gray-700'>
<pre className='whitespace-pre-wrap font-mono text-gray-400 text-xs dark:text-gray-500'>
{content}
{isStreaming && <span className='ml-1 inline-block h-2 w-1 animate-pulse bg-gray-400' />}
</pre>
</div>
{isExpanded && (
<div className='ml-1 border-gray-200 border-l-2 pl-2 dark:border-gray-700'>
<pre className='whitespace-pre-wrap font-mono text-gray-400 text-xs dark:text-gray-500'>
{content}
{isStreaming && (
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-gray-400' />
)}
</pre>
</div>
)}
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -643,41 +643,49 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
{/* Checkpoints below message */}
{hasCheckpoints && (
<div className='mt-1 flex justify-end'>
{showRestoreConfirmation ? (
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-xs'>Restore?</span>
<button
onClick={handleConfirmRevert}
disabled={isRevertingCheckpoint}
className='text-muted-foreground text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
title='Confirm restore'
>
{isRevertingCheckpoint ? (
<Loader2 className='h-3 w-3 animate-spin' />
) : (
<Check className='h-3 w-3' />
)}
</button>
<button
onClick={handleCancelRevert}
disabled={isRevertingCheckpoint}
className='text-muted-foreground text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
title='Cancel restore'
>
<X className='h-3 w-3' />
</button>
<div className='inline-flex items-center gap-0.5 text-muted-foreground text-xs'>
<span className='select-none'>
Restore{showRestoreConfirmation && <span className='ml-0.5'>?</span>}
</span>
<div className='inline-flex w-8 items-center justify-center'>
{showRestoreConfirmation ? (
<div className='inline-flex items-center gap-1'>
<button
onClick={handleConfirmRevert}
disabled={isRevertingCheckpoint}
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
title='Confirm restore'
aria-label='Confirm restore'
>
{isRevertingCheckpoint ? (
<Loader2 className='h-3 w-3 animate-spin' />
) : (
<Check className='h-3 w-3' />
)}
</button>
<button
onClick={handleCancelRevert}
disabled={isRevertingCheckpoint}
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
title='Cancel restore'
aria-label='Cancel restore'
>
<X className='h-3 w-3' />
</button>
</div>
) : (
<button
onClick={handleRevertToCheckpoint}
disabled={isRevertingCheckpoint}
className='text-muted-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50'
title='Restore workflow to this checkpoint state'
aria-label='Restore'
>
<RotateCcw className='h-3 w-3' />
</button>
)}
</div>
) : (
<button
onClick={handleRevertToCheckpoint}
disabled={isRevertingCheckpoint}
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'
title='Restore workflow to this checkpoint state'
>
<RotateCcw className='h-3 w-3' />
Restore
</button>
)}
</div>
</div>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
'use client'

import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from '@/lib/utils'

export const CopilotSlider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full cursor-pointer touch-none select-none items-center',
className
)}
{...props}
>
<SliderPrimitive.Track className='relative h-2 w-full grow cursor-pointer overflow-hidden rounded-full bg-input'>
<SliderPrimitive.Range className='absolute h-full bg-primary' />
</SliderPrimitive.Track>
<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' />
</SliderPrimitive.Root>
))
CopilotSlider.displayName = 'CopilotSlider'
Loading