Compare commits

...

9 Commits

Author SHA1 Message Date
Siddharth Ganesan
23fdbbfea9 Fix redeploy 2026-01-15 15:53:26 -08:00
Siddharth Ganesan
59df90ab0c Fix bugs 2026-01-15 15:43:26 -08:00
Siddharth Ganesan
a10f32dfa5 Fix get block options text 2026-01-15 15:12:08 -08:00
Siddharth Ganesan
72384f190d Fix thinking text 2026-01-15 15:10:42 -08:00
Siddharth Ganesan
e63fd8c482 Fix thinking tags 2026-01-15 15:06:01 -08:00
Siddharth Ganesan
080ab94165 Cleanup 2026-01-15 15:02:14 -08:00
Siddharth Ganesan
69309ecf5f Clean up autosend and continue options and enable mention menu 2026-01-15 11:58:50 -08:00
Siddharth Ganesan
2bc181d3a6 Fix block id edit, slash commands at end, thinking tag resolution, add continue button 2026-01-15 11:48:29 -08:00
Siddharth Ganesan
5db5c1c7d6 Fix edit workflow returning bad state 2026-01-15 10:55:40 -08:00
24 changed files with 868 additions and 606 deletions

View File

@@ -7,6 +7,7 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { generateChatTitle } from '@/lib/copilot/chat-title'
import { getCopilotModel } from '@/lib/copilot/config'
import { COPILOT_MODEL_IDS, COPILOT_REQUEST_MODES } from '@/lib/copilot/models'
import { SIM_AGENT_API_URL_DEFAULT, SIM_AGENT_VERSION } from '@/lib/copilot/constants'
import {
authenticateCopilotRequestSessionOnly,
@@ -40,34 +41,8 @@ const ChatMessageSchema = z.object({
userMessageId: z.string().optional(), // ID from frontend for the user message
chatId: z.string().optional(),
workflowId: z.string().min(1, 'Workflow ID is required'),
model: z
.enum([
'gpt-5-fast',
'gpt-5',
'gpt-5-medium',
'gpt-5-high',
'gpt-5.1-fast',
'gpt-5.1',
'gpt-5.1-medium',
'gpt-5.1-high',
'gpt-5-codex',
'gpt-5.1-codex',
'gpt-5.2',
'gpt-5.2-codex',
'gpt-5.2-pro',
'gpt-4o',
'gpt-4.1',
'o3',
'claude-4-sonnet',
'claude-4.5-haiku',
'claude-4.5-sonnet',
'claude-4.5-opus',
'claude-4.1-opus',
'gemini-3-pro',
])
.optional()
.default('claude-4.5-opus'),
mode: z.enum(['ask', 'agent', 'plan']).optional().default('agent'),
model: z.enum(COPILOT_MODEL_IDS).optional().default('claude-4.5-opus'),
mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'),
prefetch: z.boolean().optional(),
createNewChat: z.boolean().optional().default(false),
stream: z.boolean().optional().default(true),
@@ -295,7 +270,8 @@ export async function POST(req: NextRequest) {
}
const defaults = getCopilotModel('chat')
const modelToUse = env.COPILOT_MODEL || defaults.model
const selectedModel = model || defaults.model
const envModel = env.COPILOT_MODEL || defaults.model
let providerConfig: CopilotProviderConfig | undefined
const providerEnv = env.COPILOT_PROVIDER as any
@@ -304,7 +280,7 @@ export async function POST(req: NextRequest) {
if (providerEnv === 'azure-openai') {
providerConfig = {
provider: 'azure-openai',
model: modelToUse,
model: envModel,
apiKey: env.AZURE_OPENAI_API_KEY,
apiVersion: 'preview',
endpoint: env.AZURE_OPENAI_ENDPOINT,
@@ -312,7 +288,7 @@ export async function POST(req: NextRequest) {
} else if (providerEnv === 'vertex') {
providerConfig = {
provider: 'vertex',
model: modelToUse,
model: envModel,
apiKey: env.COPILOT_API_KEY,
vertexProject: env.VERTEX_PROJECT,
vertexLocation: env.VERTEX_LOCATION,
@@ -320,12 +296,15 @@ export async function POST(req: NextRequest) {
} else {
providerConfig = {
provider: providerEnv,
model: modelToUse,
model: selectedModel,
apiKey: env.COPILOT_API_KEY,
}
}
}
const effectiveMode = mode === 'agent' ? 'build' : mode
const transportMode = effectiveMode === 'build' ? 'agent' : effectiveMode
// Determine conversationId to use for this request
const effectiveConversationId =
(currentChat?.conversationId as string | undefined) || conversationId
@@ -345,7 +324,7 @@ export async function POST(req: NextRequest) {
}
} | null = null
if (mode === 'agent') {
if (effectiveMode === 'build') {
// Build base tools (executed locally, not deferred)
// Include function_execute for code execution capability
baseTools = [
@@ -452,8 +431,8 @@ export async function POST(req: NextRequest) {
userId: authenticatedUserId,
stream: stream,
streamToolCalls: true,
model: model,
mode: mode,
model: selectedModel,
mode: transportMode,
messageId: userMessageIdToUse,
version: SIM_AGENT_VERSION,
...(providerConfig ? { provider: providerConfig } : {}),
@@ -477,7 +456,7 @@ export async function POST(req: NextRequest) {
hasConversationId: !!effectiveConversationId,
hasFileAttachments: processedFileContents.length > 0,
messageLength: message.length,
mode,
mode: effectiveMode,
hasTools: integrationTools.length > 0,
toolCount: integrationTools.length,
hasBaseTools: baseTools.length > 0,

View File

@@ -11,6 +11,7 @@ import {
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { COPILOT_MODES } from '@/lib/copilot/models'
const logger = createLogger('CopilotChatUpdateAPI')
@@ -45,7 +46,7 @@ const UpdateMessagesSchema = z.object({
planArtifact: z.string().nullable().optional(),
config: z
.object({
mode: z.enum(['ask', 'build', 'plan']).optional(),
mode: z.enum(COPILOT_MODES).optional(),
model: z.string().optional(),
})
.nullable()

View File

@@ -2,12 +2,13 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import type { CopilotModelId } from '@/lib/copilot/models'
import { db } from '@/../../packages/db'
import { settings } from '@/../../packages/db/schema'
const logger = createLogger('CopilotUserModelsAPI')
const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
const DEFAULT_ENABLED_MODELS: Record<CopilotModelId, boolean> = {
'gpt-4o': false,
'gpt-4.1': false,
'gpt-5-fast': false,
@@ -28,7 +29,7 @@ const DEFAULT_ENABLED_MODELS: Record<string, boolean> = {
'claude-4.5-haiku': true,
'claude-4.5-sonnet': true,
'claude-4.5-opus': true,
// 'claude-4.1-opus': true,
'claude-4.1-opus': false,
'gemini-3-pro': true,
}
@@ -54,7 +55,9 @@ export async function GET(request: NextRequest) {
const mergedModels = { ...DEFAULT_ENABLED_MODELS }
for (const [modelId, enabled] of Object.entries(userModelsMap)) {
mergedModels[modelId] = enabled
if (modelId in mergedModels) {
mergedModels[modelId as CopilotModelId] = enabled
}
}
const hasNewModels = Object.keys(DEFAULT_ENABLED_MODELS).some(

View File

@@ -22,6 +22,13 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
.select({
id: chat.id,
identifier: chat.identifier,
title: chat.title,
description: chat.description,
customizations: chat.customizations,
authType: chat.authType,
allowedEmails: chat.allowedEmails,
outputConfigs: chat.outputConfigs,
password: chat.password,
isActive: chat.isActive,
})
.from(chat)
@@ -34,6 +41,13 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
? {
id: deploymentResults[0].id,
identifier: deploymentResults[0].identifier,
title: deploymentResults[0].title,
description: deploymentResults[0].description,
customizations: deploymentResults[0].customizations,
authType: deploymentResults[0].authType,
allowedEmails: deploymentResults[0].allowedEmails,
outputConfigs: deploymentResults[0].outputConfigs,
hasPassword: Boolean(deploymentResults[0].password),
}
: null

View File

@@ -7,192 +7,33 @@ import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hoo
import { useCopilotStore, usePanelStore } from '@/stores/panel'
import { useTerminalStore } from '@/stores/terminal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('DiffControls')
export const DiffControls = memo(function DiffControls() {
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
const isPanelResizing = usePanelStore((state) => state.isResizing)
const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges, baselineWorkflow } =
useWorkflowDiffStore(
useCallback(
(state) => ({
isDiffReady: state.isDiffReady,
hasActiveDiff: state.hasActiveDiff,
acceptChanges: state.acceptChanges,
rejectChanges: state.rejectChanges,
baselineWorkflow: state.baselineWorkflow,
}),
[]
)
)
const { updatePreviewToolCallState, currentChat, messages } = useCopilotStore(
const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges } = useWorkflowDiffStore(
useCallback(
(state) => ({
updatePreviewToolCallState: state.updatePreviewToolCallState,
currentChat: state.currentChat,
messages: state.messages,
isDiffReady: state.isDiffReady,
hasActiveDiff: state.hasActiveDiff,
acceptChanges: state.acceptChanges,
rejectChanges: state.rejectChanges,
}),
[]
)
)
const { activeWorkflowId } = useWorkflowRegistry(
useCallback((state) => ({ activeWorkflowId: state.activeWorkflowId }), [])
const { updatePreviewToolCallState } = useCopilotStore(
useCallback(
(state) => ({
updatePreviewToolCallState: state.updatePreviewToolCallState,
}),
[]
)
)
const createCheckpoint = useCallback(async () => {
if (!activeWorkflowId || !currentChat?.id) {
logger.warn('Cannot create checkpoint: missing workflowId or chatId', {
workflowId: activeWorkflowId,
chatId: currentChat?.id,
})
return false
}
try {
logger.info('Creating checkpoint before accepting changes')
// Use the baseline workflow (state before diff) instead of current state
// This ensures reverting to the checkpoint restores the pre-diff state
const rawState = baselineWorkflow || useWorkflowStore.getState().getWorkflowState()
// The baseline already has merged subblock values, but we'll merge again to be safe
// This ensures all user inputs and subblock data are captured
const blocksWithSubblockValues = mergeSubblockState(rawState.blocks, activeWorkflowId)
// Filter and complete blocks to ensure all required fields are present
// This matches the validation logic from /api/workflows/[id]/state
const filteredBlocks = Object.entries(blocksWithSubblockValues).reduce(
(acc, [blockId, block]) => {
if (block.type && block.name) {
// Ensure all required fields are present
acc[blockId] = {
...block,
id: block.id || blockId, // Ensure id field is set
enabled: block.enabled !== undefined ? block.enabled : true,
horizontalHandles:
block.horizontalHandles !== undefined ? block.horizontalHandles : true,
height: block.height !== undefined ? block.height : 90,
subBlocks: block.subBlocks || {},
outputs: block.outputs || {},
data: block.data || {},
position: block.position || { x: 0, y: 0 }, // Ensure position exists
}
}
return acc
},
{} as typeof rawState.blocks
)
// Clean the workflow state - only include valid fields, exclude null/undefined values
const workflowState = {
blocks: filteredBlocks,
edges: rawState.edges || [],
loops: rawState.loops || {},
parallels: rawState.parallels || {},
lastSaved: rawState.lastSaved || Date.now(),
deploymentStatuses: rawState.deploymentStatuses || {},
}
logger.info('Prepared complete workflow state for checkpoint', {
blocksCount: Object.keys(workflowState.blocks).length,
edgesCount: workflowState.edges.length,
loopsCount: Object.keys(workflowState.loops).length,
parallelsCount: Object.keys(workflowState.parallels).length,
hasRequiredFields: Object.values(workflowState.blocks).every(
(block) => block.id && block.type && block.name && block.position
),
hasSubblockValues: Object.values(workflowState.blocks).some((block) =>
Object.values(block.subBlocks || {}).some(
(subblock) => subblock.value !== null && subblock.value !== undefined
)
),
sampleBlock: Object.values(workflowState.blocks)[0],
})
// Find the most recent user message ID from the current chat
const userMessages = messages.filter((msg) => msg.role === 'user')
const lastUserMessage = userMessages[userMessages.length - 1]
const messageId = lastUserMessage?.id
logger.info('Creating checkpoint with message association', {
totalMessages: messages.length,
userMessageCount: userMessages.length,
lastUserMessageId: messageId,
chatId: currentChat.id,
entireMessageArray: messages,
allMessageIds: messages.map((m) => ({
id: m.id,
role: m.role,
content: m.content.substring(0, 50),
})),
selectedUserMessages: userMessages.map((m) => ({
id: m.id,
content: m.content.substring(0, 100),
})),
allRawMessageIds: messages.map((m) => m.id),
userMessageIds: userMessages.map((m) => m.id),
checkpointData: {
workflowId: activeWorkflowId,
chatId: currentChat.id,
messageId: messageId,
messageFound: !!lastUserMessage,
},
})
const response = await fetch('/api/copilot/checkpoints', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflowId: activeWorkflowId,
chatId: currentChat.id,
messageId,
workflowState: JSON.stringify(workflowState),
}),
})
if (!response.ok) {
throw new Error(`Failed to create checkpoint: ${response.statusText}`)
}
const result = await response.json()
const newCheckpoint = result.checkpoint
logger.info('Checkpoint created successfully', {
messageId,
chatId: currentChat.id,
checkpointId: newCheckpoint?.id,
})
// Update the copilot store immediately to show the checkpoint icon
if (newCheckpoint && messageId) {
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
const existingCheckpoints = currentCheckpoints[messageId] || []
const updatedCheckpoints = {
...currentCheckpoints,
[messageId]: [newCheckpoint, ...existingCheckpoints],
}
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
logger.info('Updated copilot store with new checkpoint', {
messageId,
checkpointId: newCheckpoint.id,
})
}
return true
} catch (error) {
logger.error('Failed to create checkpoint:', error)
return false
}
}, [activeWorkflowId, currentChat, messages, baselineWorkflow])
const handleAccept = useCallback(() => {
logger.info('Accepting proposed changes with backup protection')
@@ -229,12 +70,8 @@ export const DiffControls = memo(function DiffControls() {
})
// Create checkpoint in the background (fire-and-forget) so it doesn't block UI
createCheckpoint().catch((error) => {
logger.warn('Failed to create checkpoint after accept:', error)
})
logger.info('Accept triggered; UI will update optimistically')
}, [createCheckpoint, updatePreviewToolCallState, acceptChanges])
}, [updatePreviewToolCallState, acceptChanges])
const handleReject = useCallback(() => {
logger.info('Rejecting proposed changes (optimistic)')

View File

@@ -1,10 +1,20 @@
'use client'
import { memo, useEffect, useRef, useState } from 'react'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronUp } from 'lucide-react'
import CopilotMarkdownRenderer from './markdown-renderer'
/**
* Removes thinking tags (raw or escaped) from streamed content.
*/
function stripThinkingTags(text: string): string {
return text
.replace(/<\/?thinking[^>]*>/gi, '')
.replace(/&lt;\/?thinking[^&]*&gt;/gi, '')
.trim()
}
/**
* Max height for thinking content before internal scrolling kicks in
*/
@@ -187,6 +197,9 @@ export function ThinkingBlock({
label = 'Thought',
hasSpecialTags = false,
}: ThinkingBlockProps) {
// Strip thinking tags from content on render to handle persisted messages
const cleanContent = useMemo(() => stripThinkingTags(content || ''), [content])
const [isExpanded, setIsExpanded] = useState(false)
const [duration, setDuration] = useState(0)
const [userHasScrolledAway, setUserHasScrolledAway] = useState(false)
@@ -209,10 +222,10 @@ export function ThinkingBlock({
return
}
if (!userCollapsedRef.current && content && content.trim().length > 0) {
if (!userCollapsedRef.current && cleanContent && cleanContent.length > 0) {
setIsExpanded(true)
}
}, [isStreaming, content, hasFollowingContent, hasSpecialTags])
}, [isStreaming, cleanContent, hasFollowingContent, hasSpecialTags])
// Reset start time when streaming begins
useEffect(() => {
@@ -298,7 +311,7 @@ export function ThinkingBlock({
return `${seconds}s`
}
const hasContent = content && content.trim().length > 0
const hasContent = cleanContent.length > 0
// Thinking is "done" when streaming ends OR when there's following content (like a tool call) OR when special tags appear
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
const durationText = `${label} for ${formatDuration(duration)}`
@@ -374,7 +387,7 @@ export function ThinkingBlock({
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
)}
>
<SmoothThinkingText content={content} isStreaming={isStreaming && !hasFollowingContent} />
<SmoothThinkingText content={cleanContent} isStreaming={isStreaming && !hasFollowingContent} />
</div>
</div>
)
@@ -412,7 +425,7 @@ export function ThinkingBlock({
>
{/* Completed thinking text - dimmed with markdown */}
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'>
<CopilotMarkdownRenderer content={content} />
<CopilotMarkdownRenderer content={cleanContent} />
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
'use client'
import { type FC, memo, useCallback, useMemo, useState } from 'react'
import { type FC, memo, useCallback, useMemo, useRef, useState } from 'react'
import { RotateCcw } from 'lucide-react'
import { Button } from '@/components/emcn'
import {
@@ -93,6 +93,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
// UI state
const [isHoveringMessage, setIsHoveringMessage] = useState(false)
const cancelEditRef = useRef<(() => void) | null>(null)
// Checkpoint management hook
const {
showRestoreConfirmation,
@@ -112,7 +114,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
messages,
messageCheckpoints,
onRevertModeChange,
onEditModeChange
onEditModeChange,
() => cancelEditRef.current?.()
)
// Message editing hook
@@ -142,6 +145,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
pendingEditRef,
})
cancelEditRef.current = handleCancelEdit
// Get clean text content with double newline parsing
const cleanTextContent = useMemo(() => {
if (!message.content) return ''

View File

@@ -22,7 +22,8 @@ export function useCheckpointManagement(
messages: CopilotMessage[],
messageCheckpoints: any[],
onRevertModeChange?: (isReverting: boolean) => void,
onEditModeChange?: (isEditing: boolean) => void
onEditModeChange?: (isEditing: boolean) => void,
onCancelEdit?: () => void
) {
const [showRestoreConfirmation, setShowRestoreConfirmation] = useState(false)
const [showCheckpointDiscardModal, setShowCheckpointDiscardModal] = useState(false)
@@ -57,7 +58,7 @@ export function useCheckpointManagement(
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
const updatedCheckpoints = {
...currentCheckpoints,
[message.id]: messageCheckpoints.slice(1),
[message.id]: [],
}
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
@@ -140,7 +141,7 @@ export function useCheckpointManagement(
const { messageCheckpoints: currentCheckpoints } = useCopilotStore.getState()
const updatedCheckpoints = {
...currentCheckpoints,
[message.id]: messageCheckpoints.slice(1),
[message.id]: [],
}
useCopilotStore.setState({ messageCheckpoints: updatedCheckpoints })
@@ -154,6 +155,8 @@ export function useCheckpointManagement(
}
setShowCheckpointDiscardModal(false)
onEditModeChange?.(false)
onCancelEdit?.()
const { sendMessage } = useCopilotStore.getState()
if (pendingEditRef.current) {
@@ -180,13 +183,22 @@ export function useCheckpointManagement(
} finally {
setIsProcessingDiscard(false)
}
}, [messageCheckpoints, revertToCheckpoint, message, messages])
}, [
messageCheckpoints,
revertToCheckpoint,
message,
messages,
onEditModeChange,
onCancelEdit,
])
/**
* Cancels checkpoint discard and clears pending edit
*/
const handleCancelCheckpointDiscard = useCallback(() => {
setShowCheckpointDiscardModal(false)
onEditModeChange?.(false)
onCancelEdit?.()
pendingEditRef.current = null
}, [])
@@ -218,7 +230,7 @@ export function useCheckpointManagement(
}
pendingEditRef.current = null
}
}, [message, messages])
}, [message, messages, onEditModeChange, onCancelEdit])
/**
* Handles keyboard events for restore confirmation (Escape/Enter)

View File

@@ -1446,8 +1446,10 @@ function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) {
blockType = blockType || op.block_type || ''
}
// Fallback name to type or ID
if (!blockName) blockName = blockType || blockId
if (!blockName) blockName = blockType || ''
if (!blockName && !blockType) {
continue
}
const change: BlockChange = { blockId, blockName, blockType }

View File

@@ -22,6 +22,9 @@ interface UseContextManagementProps {
export function useContextManagement({ message, initialContexts }: UseContextManagementProps) {
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(initialContexts ?? [])
const initializedRef = useRef(false)
const escapeRegex = useCallback((value: string) => {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}, [])
// Initialize with initial contexts when they're first provided (for edit mode)
useEffect(() => {
@@ -78,10 +81,8 @@ export function useContextManagement({ message, initialContexts }: UseContextMan
// Check for slash command tokens or mention tokens based on kind
const isSlashCommand = c.kind === 'slash_command'
const prefix = isSlashCommand ? '/' : '@'
const tokenWithSpaces = ` ${prefix}${c.label} `
const tokenAtStart = `${prefix}${c.label} `
// Token can appear with leading space OR at the start of the message
return message.includes(tokenWithSpaces) || message.startsWith(tokenAtStart)
const tokenPattern = new RegExp(`(^|\\s)${escapeRegex(prefix)}${escapeRegex(c.label)}(\\s|$)`)
return tokenPattern.test(message)
})
return filtered.length === prev.length ? prev : filtered
})

View File

@@ -76,6 +76,15 @@ export function useMentionTokens({
ranges.push({ start: idx, end: idx + token.length, label })
fromIndex = idx + token.length
}
// Token at end of message without trailing space: "@label" or " /label"
const tokenAtEnd = `${prefix}${label}`
if (message.endsWith(tokenAtEnd)) {
const idx = message.lastIndexOf(tokenAtEnd)
const hasLeadingSpace = idx > 0 && message[idx - 1] === ' '
const start = hasLeadingSpace ? idx - 1 : idx
ranges.push({ start, end: message.length, label })
}
}
ranges.sort((a, b) => a.start - b.start)

View File

@@ -613,7 +613,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const insertTriggerAndOpenMenu = useCallback(
(trigger: '@' | '/') => {
if (disabled || isLoading) return
if (disabled) return
const textarea = mentionMenu.textareaRef.current
if (!textarea) return
@@ -642,7 +642,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
}
mentionMenu.setSubmenuActiveIndex(0)
},
[disabled, isLoading, mentionMenu, message, setMessage]
[disabled, mentionMenu, message, setMessage]
)
const handleOpenMentionMenuWithAt = useCallback(
@@ -735,10 +735,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
variant='outline'
onClick={handleOpenMentionMenuWithAt}
title='Insert @'
className={cn(
'cursor-pointer rounded-[6px] p-[4.5px]',
(disabled || isLoading) && 'cursor-not-allowed'
)}
className={cn('cursor-pointer rounded-[6px] p-[4.5px]', disabled && 'cursor-not-allowed')}
>
<AtSign className='h-3 w-3' strokeWidth={1.75} />
</Badge>
@@ -747,10 +744,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
variant='outline'
onClick={handleOpenSlashMenu}
title='Insert /'
className={cn(
'cursor-pointer rounded-[6px] p-[4.5px]',
(disabled || isLoading) && 'cursor-not-allowed'
)}
className={cn('cursor-pointer rounded-[6px] p-[4.5px]', disabled && 'cursor-not-allowed')}
>
<span className='flex h-3 w-3 items-center justify-center font-medium text-[11px] leading-none'>
/

View File

@@ -1,4 +1,9 @@
import { createLogger } from '@sim/logger'
import type {
CopilotMode,
CopilotModelId,
CopilotTransportMode,
} from '@/lib/copilot/models'
const logger = createLogger('CopilotAPI')
@@ -27,8 +32,8 @@ export interface CopilotMessage {
* Chat config stored in database
*/
export interface CopilotChatConfig {
mode?: 'ask' | 'build' | 'plan'
model?: string
mode?: CopilotMode
model?: CopilotModelId
}
/**
@@ -65,30 +70,8 @@ export interface SendMessageRequest {
userMessageId?: string // ID from frontend for the user message
chatId?: string
workflowId?: string
mode?: 'ask' | 'agent' | 'plan'
model?:
| 'gpt-5-fast'
| 'gpt-5'
| 'gpt-5-medium'
| 'gpt-5-high'
| 'gpt-5.1-fast'
| 'gpt-5.1'
| 'gpt-5.1-medium'
| 'gpt-5.1-high'
| 'gpt-5-codex'
| 'gpt-5.1-codex'
| 'gpt-5.2'
| 'gpt-5.2-codex'
| 'gpt-5.2-pro'
| 'gpt-4o'
| 'gpt-4.1'
| 'o3'
| 'claude-4-sonnet'
| 'claude-4.5-haiku'
| 'claude-4.5-sonnet'
| 'claude-4.5-opus'
| 'claude-4.1-opus'
| 'gemini-3-pro'
mode?: CopilotMode | CopilotTransportMode
model?: CopilotModelId
prefetch?: boolean
createNewChat?: boolean
stream?: boolean

View File

@@ -0,0 +1,36 @@
export const COPILOT_MODEL_IDS = [
'gpt-5-fast',
'gpt-5',
'gpt-5-medium',
'gpt-5-high',
'gpt-5.1-fast',
'gpt-5.1',
'gpt-5.1-medium',
'gpt-5.1-high',
'gpt-5-codex',
'gpt-5.1-codex',
'gpt-5.2',
'gpt-5.2-codex',
'gpt-5.2-pro',
'gpt-4o',
'gpt-4.1',
'o3',
'claude-4-sonnet',
'claude-4.5-haiku',
'claude-4.5-sonnet',
'claude-4.5-opus',
'claude-4.1-opus',
'gemini-3-pro',
] as const
export type CopilotModelId = (typeof COPILOT_MODEL_IDS)[number]
export const COPILOT_MODES = ['ask', 'build', 'plan'] as const
export type CopilotMode = (typeof COPILOT_MODES)[number]
export const COPILOT_TRANSPORT_MODES = ['ask', 'agent', 'plan'] as const
export type CopilotTransportMode = (typeof COPILOT_TRANSPORT_MODES)[number]
export const COPILOT_REQUEST_MODES = ['ask', 'build', 'plan', 'agent'] as const
export type CopilotRequestMode = (typeof COPILOT_REQUEST_MODES)[number]

View File

@@ -25,36 +25,41 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Getting block options', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Getting block options', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Getting block options', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Retrieved block options', icon: ListFilter },
[ClientToolCallState.error]: { text: 'Failed to get block options', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted getting block options', icon: XCircle },
[ClientToolCallState.generating]: { text: 'Getting block operations', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Getting block operations', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Getting block operations', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Retrieved block operations', icon: ListFilter },
[ClientToolCallState.error]: { text: 'Failed to get block operations', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted getting block operations', icon: XCircle },
[ClientToolCallState.rejected]: {
text: 'Skipped getting block options',
text: 'Skipped getting block operations',
icon: MinusCircle,
},
},
getDynamicText: (params, state) => {
if (params?.blockId && typeof params.blockId === 'string') {
const blockId =
(params as any)?.blockId ||
(params as any)?.blockType ||
(params as any)?.block_id ||
(params as any)?.block_type
if (typeof blockId === 'string') {
// Look up the block config to get the human-readable name
const blockConfig = getBlock(params.blockId)
const blockName = (blockConfig?.name ?? params.blockId.replace(/_/g, ' ')).toLowerCase()
const blockConfig = getBlock(blockId)
const blockName = (blockConfig?.name ?? blockId.replace(/_/g, ' ')).toLowerCase()
switch (state) {
case ClientToolCallState.success:
return `Retrieved ${blockName} options`
return `Retrieved ${blockName} operations`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Retrieving ${blockName} options`
return `Retrieving ${blockName} operations`
case ClientToolCallState.error:
return `Failed to retrieve ${blockName} options`
return `Failed to retrieve ${blockName} operations`
case ClientToolCallState.aborted:
return `Aborted retrieving ${blockName} options`
return `Aborted retrieving ${blockName} operations`
case ClientToolCallState.rejected:
return `Skipped retrieving ${blockName} options`
return `Skipped retrieving ${blockName} operations`
}
}
return undefined

View File

@@ -28,6 +28,7 @@ import './workflow/deploy-api'
import './workflow/deploy-chat'
import './workflow/deploy-mcp'
import './workflow/edit-workflow'
import './workflow/redeploy'
import './workflow/run-workflow'
import './workflow/set-global-workflow-variables'

View File

@@ -15,6 +15,8 @@ interface ApiDeploymentDetails {
isDeployed: boolean
deployedAt: string | null
endpoint: string | null
apiKey: string | null
needsRedeployment: boolean
}
interface ChatDeploymentDetails {
@@ -22,6 +24,14 @@ interface ChatDeploymentDetails {
chatId: string | null
identifier: string | null
chatUrl: string | null
title: string | null
description: string | null
authType: string | null
allowedEmails: string[] | null
outputConfigs: Array<{ blockId: string; path: string }> | null
welcomeMessage: string | null
primaryColor: string | null
hasPassword: boolean
}
interface McpDeploymentDetails {
@@ -31,6 +41,8 @@ interface McpDeploymentDetails {
serverName: string
toolName: string
toolDescription: string | null
parameterSchema?: Record<string, unknown> | null
toolId?: string | null
}>
}
@@ -96,6 +108,8 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool {
isDeployed: isApiDeployed,
deployedAt: apiDeploy?.deployedAt || null,
endpoint: isApiDeployed ? `${appUrl}/api/workflows/${workflowId}/execute` : null,
apiKey: apiDeploy?.apiKey || null,
needsRedeployment: apiDeploy?.needsRedeployment === true,
}
// Chat deployment details
@@ -105,6 +119,18 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool {
chatId: chatDeploy?.deployment?.id || null,
identifier: chatDeploy?.deployment?.identifier || null,
chatUrl: isChatDeployed ? `${appUrl}/chat/${chatDeploy?.deployment?.identifier}` : null,
title: chatDeploy?.deployment?.title || null,
description: chatDeploy?.deployment?.description || null,
authType: chatDeploy?.deployment?.authType || null,
allowedEmails: Array.isArray(chatDeploy?.deployment?.allowedEmails)
? chatDeploy?.deployment?.allowedEmails
: null,
outputConfigs: Array.isArray(chatDeploy?.deployment?.outputConfigs)
? chatDeploy?.deployment?.outputConfigs
: null,
welcomeMessage: chatDeploy?.deployment?.customizations?.welcomeMessage || null,
primaryColor: chatDeploy?.deployment?.customizations?.primaryColor || null,
hasPassword: chatDeploy?.deployment?.hasPassword === true,
}
// MCP deployment details - find servers that have this workflow as a tool
@@ -129,6 +155,8 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool {
serverName: server.name,
toolName: tool.toolName,
toolDescription: tool.toolDescription,
parameterSchema: tool.parameterSchema ?? null,
toolId: tool.id ?? null,
})
}
}

View File

@@ -208,54 +208,70 @@ export class DeployChatClientTool extends BaseClientTool {
return
}
// Deploy action - validate required fields
if (!args?.identifier && !workflow?.name) {
throw new Error('Either identifier or workflow name is required')
this.setState(ClientToolCallState.executing)
const statusRes = await fetch(`/api/workflows/${workflowId}/chat/status`)
const statusJson = statusRes.ok ? await statusRes.json() : null
const existingDeployment = statusJson?.deployment || null
const baseIdentifier =
existingDeployment?.identifier || this.generateIdentifier(workflow?.name || 'chat')
const baseTitle = existingDeployment?.title || workflow?.name || 'Chat'
const baseDescription = existingDeployment?.description || ''
const baseAuthType = existingDeployment?.authType || 'public'
const baseWelcomeMessage =
existingDeployment?.customizations?.welcomeMessage || 'Hi there! How can I help you today?'
const basePrimaryColor =
existingDeployment?.customizations?.primaryColor || 'var(--brand-primary-hover-hex)'
const baseAllowedEmails = Array.isArray(existingDeployment?.allowedEmails)
? existingDeployment.allowedEmails
: []
const baseOutputConfigs = Array.isArray(existingDeployment?.outputConfigs)
? existingDeployment.outputConfigs
: []
const identifier = args?.identifier || baseIdentifier
const title = args?.title || baseTitle
const description = args?.description ?? baseDescription
const authType = args?.authType || baseAuthType
const welcomeMessage = args?.welcomeMessage || baseWelcomeMessage
const outputConfigs = args?.outputConfigs || baseOutputConfigs
const allowedEmails = args?.allowedEmails || baseAllowedEmails
const primaryColor = basePrimaryColor
if (!identifier || !title) {
throw new Error('Chat identifier and title are required')
}
if (!args?.title && !workflow?.name) {
throw new Error('Chat title is required')
}
const identifier = args?.identifier || this.generateIdentifier(workflow?.name || 'chat')
const title = args?.title || workflow?.name || 'Chat'
const description = args?.description || ''
const authType = args?.authType || 'public'
const welcomeMessage = args?.welcomeMessage || 'Hi there! How can I help you today?'
// Validate auth-specific requirements
if (authType === 'password' && !args?.password) {
if (authType === 'password' && !args?.password && !existingDeployment?.hasPassword) {
throw new Error('Password is required when using password protection')
}
if (
(authType === 'email' || authType === 'sso') &&
(!args?.allowedEmails || args.allowedEmails.length === 0)
) {
if ((authType === 'email' || authType === 'sso') && allowedEmails.length === 0) {
throw new Error(`At least one email or domain is required when using ${authType} access`)
}
this.setState(ClientToolCallState.executing)
const outputConfigs = args?.outputConfigs || []
const payload = {
workflowId,
identifier: identifier.trim(),
title: title.trim(),
description: description.trim(),
customizations: {
primaryColor: 'var(--brand-primary-hover-hex)',
primaryColor,
welcomeMessage: welcomeMessage.trim(),
},
authType,
password: authType === 'password' ? args?.password : undefined,
allowedEmails: authType === 'email' || authType === 'sso' ? args?.allowedEmails : [],
allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [],
outputConfigs,
}
const res = await fetch('/api/chat', {
method: 'POST',
const isUpdating = Boolean(existingDeployment?.id)
const endpoint = isUpdating ? `/api/chat/manage/${existingDeployment.id}` : '/api/chat'
const method = isUpdating ? 'PATCH' : 'POST'
const res = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
@@ -265,18 +281,18 @@ export class DeployChatClientTool extends BaseClientTool {
if (!res.ok) {
if (json.error === 'Identifier already in use') {
this.setState(ClientToolCallState.error)
await this.markToolComplete(
400,
`The identifier "${identifier}" is already in use. Please choose a different one.`,
{
success: false,
action: 'deploy',
isDeployed: false,
identifier,
error: `Identifier "${identifier}" is already taken`,
errorCode: 'IDENTIFIER_TAKEN',
}
)
await this.markToolComplete(
400,
`The identifier "${identifier}" is already in use. Please choose a different one.`,
{
success: false,
action: 'deploy',
isDeployed: false,
identifier,
error: `Identifier "${identifier}" is already taken`,
errorCode: 'IDENTIFIER_TAKEN',
}
)
return
}

View File

@@ -128,7 +128,6 @@ export class DeployMcpClientTool extends BaseClientTool {
this.setState(ClientToolCallState.executing)
// Build parameter schema with descriptions if provided
let parameterSchema: Record<string, unknown> | undefined
if (args?.parameterDescriptions && args.parameterDescriptions.length > 0) {
const properties: Record<string, { description: string }> = {}
@@ -155,9 +154,49 @@ export class DeployMcpClientTool extends BaseClientTool {
const data = await res.json()
if (!res.ok) {
// Handle specific error cases
if (data.error?.includes('already added')) {
throw new Error('This workflow is already deployed to this MCP server')
const toolsRes = await fetch(
`/api/mcp/workflow-servers/${args.serverId}/tools?workspaceId=${workspaceId}`
)
const toolsJson = toolsRes.ok ? await toolsRes.json() : null
const tools = toolsJson?.data?.tools || []
const existingTool = tools.find((tool: any) => tool.workflowId === workflowId)
if (!existingTool?.id) {
throw new Error('This workflow is already deployed to this MCP server')
}
const patchRes = await fetch(
`/api/mcp/workflow-servers/${args.serverId}/tools/${existingTool.id}?workspaceId=${workspaceId}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
toolName: args.toolName?.trim(),
toolDescription: args.toolDescription?.trim(),
parameterSchema,
}),
}
)
const patchJson = patchRes.ok ? await patchRes.json() : null
if (!patchRes.ok) {
const patchError = patchJson?.error || `Failed to update MCP tool (${patchRes.status})`
throw new Error(patchError)
}
const updatedTool = patchJson?.data?.tool
this.setState(ClientToolCallState.success)
await this.markToolComplete(
200,
`Workflow MCP tool updated to "${updatedTool?.toolName || existingTool.toolName}".`,
{
success: true,
toolId: updatedTool?.id || existingTool.id,
toolName: updatedTool?.toolName || existingTool.toolName,
toolDescription: updatedTool?.toolDescription || existingTool.toolDescription,
serverId: args.serverId,
updated: true,
}
)
logger.info('Updated workflow MCP tool', { toolId: existingTool.id })
return
}
if (data.error?.includes('not deployed')) {
throw new Error('Workflow must be deployed before adding as an MCP tool')

View File

@@ -38,6 +38,18 @@ export class EditWorkflowClientTool extends BaseClientTool {
super(toolCallId, EditWorkflowClientTool.id, EditWorkflowClientTool.metadata)
}
async markToolComplete(status: number, message?: any, data?: any): Promise<boolean> {
const logger = createLogger('EditWorkflowClientTool')
logger.info('markToolComplete payload', {
toolCallId: this.toolCallId,
toolName: this.name,
status,
message,
data,
})
return super.markToolComplete(status, message, data)
}
/**
* Get sanitized workflow JSON from a workflow state, merge subblocks, and sanitize for copilot
* This matches what get_user_workflow returns
@@ -173,21 +185,13 @@ export class EditWorkflowClientTool extends BaseClientTool {
async execute(args?: EditWorkflowArgs): Promise<void> {
const logger = createLogger('EditWorkflowClientTool')
if (this.hasExecuted) {
logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId })
return
}
// Use timeout protection to ensure tool always completes
await this.executeWithTimeout(async () => {
if (this.hasExecuted) {
logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId })
// Even if skipped, ensure we mark complete with current workflow state
if (!this.hasBeenMarkedComplete()) {
const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger)
await this.markToolComplete(
200,
'Tool already executed',
currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined
)
}
return
}
this.hasExecuted = true
logger.info('execute called', { toolCallId: this.toolCallId, argsProvided: !!args })
this.setState(ClientToolCallState.executing)

View File

@@ -0,0 +1,72 @@
import { createLogger } from '@sim/logger'
import { Loader2, Rocket, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export class RedeployClientTool extends BaseClientTool {
static readonly id = 'redeploy'
private hasExecuted = false
constructor(toolCallId: string) {
super(toolCallId, RedeployClientTool.id, RedeployClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Redeploying workflow', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Redeploy workflow', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Redeploying workflow', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Redeployed workflow', icon: Rocket },
[ClientToolCallState.error]: { text: 'Failed to redeploy workflow', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted redeploy', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped redeploy', icon: XCircle },
},
interrupt: undefined,
}
async execute(): Promise<void> {
const logger = createLogger('RedeployClientTool')
try {
if (this.hasExecuted) {
logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId })
return
}
this.hasExecuted = true
this.setState(ClientToolCallState.executing)
const { activeWorkflowId } = useWorkflowRegistry.getState()
if (!activeWorkflowId) {
throw new Error('No workflow ID provided')
}
const res = await fetch(`/api/workflows/${activeWorkflowId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deployChatEnabled: false }),
})
const json = await res.json().catch(() => ({}))
if (!res.ok) {
const errorText = json?.error || `Server error (${res.status})`
throw new Error(errorText)
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Workflow redeployed', {
workflowId: activeWorkflowId,
deployedAt: json?.deployedAt || null,
schedule: json?.schedule,
})
} catch (error: any) {
logger.error('Redeploy failed', { message: error?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, error?.message || 'Failed to redeploy workflow')
}
}
}

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { type CopilotChat, sendStreamingMessage } from '@/lib/copilot/api'
import type { CopilotTransportMode } from '@/lib/copilot/models'
import type {
BaseClientToolMetadata,
ClientToolDisplay,
@@ -71,6 +72,7 @@ import { ListUserWorkflowsClientTool } from '@/lib/copilot/tools/client/workflow
import { ListWorkspaceMcpServersClientTool } from '@/lib/copilot/tools/client/workflow/list-workspace-mcp-servers'
import { ManageCustomToolClientTool } from '@/lib/copilot/tools/client/workflow/manage-custom-tool'
import { ManageMcpToolClientTool } from '@/lib/copilot/tools/client/workflow/manage-mcp-tool'
import { RedeployClientTool } from '@/lib/copilot/tools/client/workflow/redeploy'
import { RunWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/run-workflow'
import { SetGlobalWorkflowVariablesClientTool } from '@/lib/copilot/tools/client/workflow/set-global-workflow-variables'
import { getQueryClient } from '@/app/_shell/providers/query-provider'
@@ -84,7 +86,9 @@ import type {
} from '@/stores/panel/copilot/types'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('CopilotStore')
@@ -147,6 +151,7 @@ const CLIENT_TOOL_INSTANTIATORS: Record<string, (id: string) => any> = {
deploy_api: (id) => new DeployApiClientTool(id),
deploy_chat: (id) => new DeployChatClientTool(id),
deploy_mcp: (id) => new DeployMcpClientTool(id),
redeploy: (id) => new RedeployClientTool(id),
list_workspace_mcp_servers: (id) => new ListWorkspaceMcpServersClientTool(id),
create_workspace_mcp_server: (id) => new CreateWorkspaceMcpServerClientTool(id),
check_deployment_status: (id) => new CheckDeploymentStatusClientTool(id),
@@ -209,6 +214,7 @@ export const CLASS_TOOL_METADATA: Record<string, BaseClientToolMetadata | undefi
deploy_api: (DeployApiClientTool as any)?.metadata,
deploy_chat: (DeployChatClientTool as any)?.metadata,
deploy_mcp: (DeployMcpClientTool as any)?.metadata,
redeploy: (RedeployClientTool as any)?.metadata,
list_workspace_mcp_servers: (ListWorkspaceMcpServersClientTool as any)?.metadata,
create_workspace_mcp_server: (CreateWorkspaceMcpServerClientTool as any)?.metadata,
check_deployment_status: (CheckDeploymentStatusClientTool as any)?.metadata,
@@ -237,6 +243,7 @@ const TEXT_BLOCK_TYPE = 'text'
const THINKING_BLOCK_TYPE = 'thinking'
const DATA_PREFIX = 'data: '
const DATA_PREFIX_LENGTH = 6
const CONTINUE_OPTIONS_TAG = '<options>{"1":"Continue"}</options>'
// Resolve display text/icon for a tool based on its state
function resolveToolDisplay(
@@ -360,6 +367,7 @@ function abortAllInProgressTools(set: any, get: () => CopilotStore) {
const { toolCallsById, messages } = get()
const updatedMap = { ...toolCallsById }
const abortedIds = new Set<string>()
let hasUpdates = false
for (const [id, tc] of Object.entries(toolCallsById)) {
const st = tc.state as any
// Abort anything not already terminal success/error/rejected/aborted
@@ -373,11 +381,19 @@ function abortAllInProgressTools(set: any, get: () => CopilotStore) {
updatedMap[id] = {
...tc,
state: ClientToolCallState.aborted,
subAgentStreaming: false,
display: resolveToolDisplay(tc.name, ClientToolCallState.aborted, id, (tc as any).params),
}
hasUpdates = true
} else if (tc.subAgentStreaming) {
updatedMap[id] = {
...tc,
subAgentStreaming: false,
}
hasUpdates = true
}
}
if (abortedIds.size > 0) {
if (abortedIds.size > 0 || hasUpdates) {
set({ toolCallsById: updatedMap })
// Update inline blocks in-place for the latest assistant message only (most relevant)
set((s: CopilotStore) => {
@@ -620,6 +636,97 @@ function createErrorMessage(
}
}
/**
* Builds a workflow snapshot suitable for checkpoint persistence.
*/
function buildCheckpointWorkflowState(workflowId: string): WorkflowState | null {
const rawState = useWorkflowStore.getState().getWorkflowState()
if (!rawState) return null
const blocksWithSubblockValues = mergeSubblockState(rawState.blocks, workflowId)
const filteredBlocks = Object.entries(blocksWithSubblockValues).reduce(
(acc, [blockId, block]) => {
if (block?.type && block?.name) {
acc[blockId] = {
...block,
id: block.id || blockId,
enabled: block.enabled !== undefined ? block.enabled : true,
horizontalHandles: block.horizontalHandles !== undefined ? block.horizontalHandles : true,
height: block.height !== undefined ? block.height : 90,
subBlocks: block.subBlocks || {},
outputs: block.outputs || {},
data: block.data || {},
position: block.position || { x: 0, y: 0 },
}
}
return acc
},
{} as WorkflowState['blocks']
)
return {
blocks: filteredBlocks,
edges: rawState.edges || [],
loops: rawState.loops || {},
parallels: rawState.parallels || {},
lastSaved: rawState.lastSaved || Date.now(),
deploymentStatuses: rawState.deploymentStatuses || {},
}
}
/**
* Persists a previously captured snapshot as a workflow checkpoint.
*/
async function saveMessageCheckpoint(
messageId: string,
get: () => CopilotStore,
set: (partial: Partial<CopilotStore> | ((state: CopilotStore) => Partial<CopilotStore>)) => void
): Promise<boolean> {
const { workflowId, currentChat, messageSnapshots, messageCheckpoints } = get()
if (!workflowId || !currentChat?.id) return false
const snapshot = messageSnapshots[messageId]
if (!snapshot) return false
const nextSnapshots = { ...messageSnapshots }
delete nextSnapshots[messageId]
set({ messageSnapshots: nextSnapshots })
try {
const response = await fetch('/api/copilot/checkpoints', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflowId,
chatId: currentChat.id,
messageId,
workflowState: JSON.stringify(snapshot),
}),
})
if (!response.ok) {
throw new Error(`Failed to create checkpoint: ${response.statusText}`)
}
const result = await response.json()
const newCheckpoint = result.checkpoint
if (newCheckpoint) {
const existingCheckpoints = messageCheckpoints[messageId] || []
const updatedCheckpoints = {
...messageCheckpoints,
[messageId]: [newCheckpoint, ...existingCheckpoints],
}
set({ messageCheckpoints: updatedCheckpoints })
}
return true
} catch (error) {
logger.error('Failed to create checkpoint from snapshot:', error)
return false
}
}
function stripTodoTags(text: string): string {
if (!text) return text
return text
@@ -826,6 +933,8 @@ interface StreamingContext {
newChatId?: string
doneEventCount: number
streamComplete?: boolean
wasAborted?: boolean
suppressContinueOption?: boolean
/** Track active subagent sessions by parent tool call ID */
subAgentParentToolCallId?: string
/** Track subagent content per parent tool call */
@@ -843,6 +952,131 @@ type SSEHandler = (
set: any
) => Promise<void> | void
function appendTextBlock(context: StreamingContext, text: string) {
if (!text) return
context.accumulatedContent.append(text)
if (context.currentTextBlock && context.contentBlocks.length > 0) {
const lastBlock = context.contentBlocks[context.contentBlocks.length - 1]
if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) {
lastBlock.content += text
return
}
}
context.currentTextBlock = contentBlockPool.get()
context.currentTextBlock.type = TEXT_BLOCK_TYPE
context.currentTextBlock.content = text
context.currentTextBlock.timestamp = Date.now()
context.contentBlocks.push(context.currentTextBlock)
}
function appendContinueOption(content: string): string {
if (/<options>/i.test(content)) return content
const suffix = content.trim().length > 0 ? '\n\n' : ''
return `${content}${suffix}${CONTINUE_OPTIONS_TAG}`
}
function appendContinueOptionBlock(blocks: any[]): any[] {
if (!Array.isArray(blocks)) return blocks
const hasOptions = blocks.some(
(block) => block?.type === TEXT_BLOCK_TYPE && typeof block.content === 'string' && /<options>/i.test(block.content)
)
if (hasOptions) return blocks
return [
...blocks,
{
type: TEXT_BLOCK_TYPE,
content: CONTINUE_OPTIONS_TAG,
timestamp: Date.now(),
},
]
}
function beginThinkingBlock(context: StreamingContext) {
if (!context.currentThinkingBlock) {
context.currentThinkingBlock = contentBlockPool.get()
context.currentThinkingBlock.type = THINKING_BLOCK_TYPE
context.currentThinkingBlock.content = ''
context.currentThinkingBlock.timestamp = Date.now()
;(context.currentThinkingBlock as any).startTime = Date.now()
context.contentBlocks.push(context.currentThinkingBlock)
}
context.isInThinkingBlock = true
context.currentTextBlock = null
}
/**
* Removes thinking tags (raw or escaped) from streamed content.
*/
function stripThinkingTags(text: string): string {
return text
.replace(/<\/?thinking[^>]*>/gi, '')
.replace(/&lt;\/?thinking[^&]*&gt;/gi, '')
}
function appendThinkingContent(context: StreamingContext, text: string) {
if (!text) return
const cleanedText = stripThinkingTags(text)
if (!cleanedText) return
if (context.currentThinkingBlock) {
context.currentThinkingBlock.content += cleanedText
} else {
context.currentThinkingBlock = contentBlockPool.get()
context.currentThinkingBlock.type = THINKING_BLOCK_TYPE
context.currentThinkingBlock.content = cleanedText
context.currentThinkingBlock.timestamp = Date.now()
context.currentThinkingBlock.startTime = Date.now()
context.contentBlocks.push(context.currentThinkingBlock)
}
context.isInThinkingBlock = true
context.currentTextBlock = null
}
function finalizeThinkingBlock(context: StreamingContext) {
if (context.currentThinkingBlock) {
context.currentThinkingBlock.duration =
Date.now() - (context.currentThinkingBlock.startTime || Date.now())
}
context.isInThinkingBlock = false
context.currentThinkingBlock = null
context.currentTextBlock = null
}
function upsertToolCallBlock(context: StreamingContext, toolCall: CopilotToolCall) {
let found = false
for (let i = 0; i < context.contentBlocks.length; i++) {
const b = context.contentBlocks[i] as any
if (b.type === 'tool_call' && b.toolCall?.id === toolCall.id) {
context.contentBlocks[i] = { ...b, toolCall }
found = true
break
}
}
if (!found) {
context.contentBlocks.push({ type: 'tool_call', toolCall, timestamp: Date.now() })
}
}
function appendSubAgentText(context: StreamingContext, parentToolCallId: string, text: string) {
if (!context.subAgentContent[parentToolCallId]) {
context.subAgentContent[parentToolCallId] = ''
}
if (!context.subAgentBlocks[parentToolCallId]) {
context.subAgentBlocks[parentToolCallId] = []
}
context.subAgentContent[parentToolCallId] += text
const blocks = context.subAgentBlocks[parentToolCallId]
const lastBlock = blocks[blocks.length - 1]
if (lastBlock && lastBlock.type === 'subagent_text') {
lastBlock.content = (lastBlock.content || '') + text
} else {
blocks.push({
type: 'subagent_text',
content: text,
timestamp: Date.now(),
})
}
}
const sseHandlers: Record<string, SSEHandler> = {
chat_id: async (data, context, get) => {
context.newChatId = data.chatId
@@ -1033,17 +1267,7 @@ const sseHandlers: Record<string, SSEHandler> = {
logger.info('[toolCallsById] map updated', updated)
// Add/refresh inline content block
let found = false
for (let i = 0; i < context.contentBlocks.length; i++) {
const b = context.contentBlocks[i] as any
if (b.type === 'tool_call' && b.toolCall?.id === toolCallId) {
context.contentBlocks[i] = { ...b, toolCall: tc }
found = true
break
}
}
if (!found)
context.contentBlocks.push({ type: 'tool_call', toolCall: tc, timestamp: Date.now() })
upsertToolCallBlock(context, tc)
updateStreamingMessage(set, context)
}
},
@@ -1079,20 +1303,15 @@ const sseHandlers: Record<string, SSEHandler> = {
logger.info('[toolCallsById] → pending', { id, name, params: args })
// Ensure an inline content block exists/updated for this tool call
let found = false
for (let i = 0; i < context.contentBlocks.length; i++) {
const b = context.contentBlocks[i] as any
if (b.type === 'tool_call' && b.toolCall?.id === id) {
context.contentBlocks[i] = { ...b, toolCall: next }
found = true
break
}
}
if (!found) {
context.contentBlocks.push({ type: 'tool_call', toolCall: next, timestamp: Date.now() })
}
upsertToolCallBlock(context, next)
updateStreamingMessage(set, context)
// Do not execute on partial tool_call frames
if (isPartial) {
return
}
// Prefer interface-based registry to determine interrupt and execute
try {
const def = name ? getTool(name) : undefined
@@ -1275,44 +1494,18 @@ const sseHandlers: Record<string, SSEHandler> = {
reasoning: (data, context, _get, set) => {
const phase = (data && (data.phase || data?.data?.phase)) as string | undefined
if (phase === 'start') {
if (!context.currentThinkingBlock) {
context.currentThinkingBlock = contentBlockPool.get()
context.currentThinkingBlock.type = THINKING_BLOCK_TYPE
context.currentThinkingBlock.content = ''
context.currentThinkingBlock.timestamp = Date.now()
;(context.currentThinkingBlock as any).startTime = Date.now()
context.contentBlocks.push(context.currentThinkingBlock)
}
context.isInThinkingBlock = true
context.currentTextBlock = null
beginThinkingBlock(context)
updateStreamingMessage(set, context)
return
}
if (phase === 'end') {
if (context.currentThinkingBlock) {
;(context.currentThinkingBlock as any).duration =
Date.now() - ((context.currentThinkingBlock as any).startTime || Date.now())
}
context.isInThinkingBlock = false
context.currentThinkingBlock = null
context.currentTextBlock = null
finalizeThinkingBlock(context)
updateStreamingMessage(set, context)
return
}
const chunk: string = typeof data?.data === 'string' ? data.data : data?.content || ''
if (!chunk) return
if (context.currentThinkingBlock) {
context.currentThinkingBlock.content += chunk
} else {
context.currentThinkingBlock = contentBlockPool.get()
context.currentThinkingBlock.type = THINKING_BLOCK_TYPE
context.currentThinkingBlock.content = chunk
context.currentThinkingBlock.timestamp = Date.now()
;(context.currentThinkingBlock as any).startTime = Date.now()
context.contentBlocks.push(context.currentThinkingBlock)
}
context.isInThinkingBlock = true
context.currentTextBlock = null
appendThinkingContent(context, chunk)
updateStreamingMessage(set, context)
},
content: (data, context, get, set) => {
@@ -1327,21 +1520,23 @@ const sseHandlers: Record<string, SSEHandler> = {
const designWorkflowStartRegex = /<design_workflow>/
const designWorkflowEndRegex = /<\/design_workflow>/
const appendTextToContent = (text: string) => {
if (!text) return
context.accumulatedContent.append(text)
if (context.currentTextBlock && context.contentBlocks.length > 0) {
const lastBlock = context.contentBlocks[context.contentBlocks.length - 1]
if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) {
lastBlock.content += text
return
}
const splitTrailingPartialTag = (
text: string,
tags: string[]
): { text: string; remaining: string } => {
const partialIndex = text.lastIndexOf('<')
if (partialIndex < 0) {
return { text, remaining: '' }
}
const possibleTag = text.substring(partialIndex)
const matchesTagStart = tags.some((tag) => tag.startsWith(possibleTag))
if (!matchesTagStart) {
return { text, remaining: '' }
}
return {
text: text.substring(0, partialIndex),
remaining: possibleTag,
}
context.currentTextBlock = contentBlockPool.get()
context.currentTextBlock.type = TEXT_BLOCK_TYPE
context.currentTextBlock.content = text
context.currentTextBlock.timestamp = Date.now()
context.contentBlocks.push(context.currentTextBlock)
}
while (contentToProcess.length > 0) {
@@ -1363,13 +1558,17 @@ const sseHandlers: Record<string, SSEHandler> = {
hasProcessedContent = true
} else {
// Still in design_workflow block, accumulate content
context.designWorkflowContent += contentToProcess
const { text, remaining } = splitTrailingPartialTag(contentToProcess, ['</design_workflow>'])
context.designWorkflowContent += text
// Update store with partial content for streaming effect (available in all modes)
set({ streamingPlanContent: context.designWorkflowContent })
contentToProcess = ''
contentToProcess = remaining
hasProcessedContent = true
if (remaining) {
break
}
}
continue
}
@@ -1380,7 +1579,7 @@ const sseHandlers: Record<string, SSEHandler> = {
if (designStartMatch) {
const textBeforeDesign = contentToProcess.substring(0, designStartMatch.index)
if (textBeforeDesign) {
appendTextToContent(textBeforeDesign)
appendTextBlock(context, textBeforeDesign)
hasProcessedContent = true
}
context.isInDesignWorkflowBlock = true
@@ -1471,63 +1670,27 @@ const sseHandlers: Record<string, SSEHandler> = {
const endMatch = thinkingEndRegex.exec(contentToProcess)
if (endMatch) {
const thinkingContent = contentToProcess.substring(0, endMatch.index)
if (context.currentThinkingBlock) {
context.currentThinkingBlock.content += thinkingContent
} else {
context.currentThinkingBlock = contentBlockPool.get()
context.currentThinkingBlock.type = THINKING_BLOCK_TYPE
context.currentThinkingBlock.content = thinkingContent
context.currentThinkingBlock.timestamp = Date.now()
context.currentThinkingBlock.startTime = Date.now()
context.contentBlocks.push(context.currentThinkingBlock)
}
context.isInThinkingBlock = false
if (context.currentThinkingBlock) {
context.currentThinkingBlock.duration =
Date.now() - (context.currentThinkingBlock.startTime || Date.now())
}
context.currentThinkingBlock = null
context.currentTextBlock = null
appendThinkingContent(context, thinkingContent)
finalizeThinkingBlock(context)
contentToProcess = contentToProcess.substring(endMatch.index + endMatch[0].length)
hasProcessedContent = true
} else {
if (context.currentThinkingBlock) {
context.currentThinkingBlock.content += contentToProcess
} else {
context.currentThinkingBlock = contentBlockPool.get()
context.currentThinkingBlock.type = THINKING_BLOCK_TYPE
context.currentThinkingBlock.content = contentToProcess
context.currentThinkingBlock.timestamp = Date.now()
context.currentThinkingBlock.startTime = Date.now()
context.contentBlocks.push(context.currentThinkingBlock)
const { text, remaining } = splitTrailingPartialTag(contentToProcess, ['</thinking>'])
if (text) {
appendThinkingContent(context, text)
hasProcessedContent = true
}
contentToProcess = remaining
if (remaining) {
break
}
contentToProcess = ''
hasProcessedContent = true
}
} else {
const startMatch = thinkingStartRegex.exec(contentToProcess)
if (startMatch) {
const textBeforeThinking = contentToProcess.substring(0, startMatch.index)
if (textBeforeThinking) {
context.accumulatedContent.append(textBeforeThinking)
if (context.currentTextBlock && context.contentBlocks.length > 0) {
const lastBlock = context.contentBlocks[context.contentBlocks.length - 1]
if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) {
lastBlock.content += textBeforeThinking
} else {
context.currentTextBlock = contentBlockPool.get()
context.currentTextBlock.type = TEXT_BLOCK_TYPE
context.currentTextBlock.content = textBeforeThinking
context.currentTextBlock.timestamp = Date.now()
context.contentBlocks.push(context.currentTextBlock)
}
} else {
context.currentTextBlock = contentBlockPool.get()
context.currentTextBlock.type = TEXT_BLOCK_TYPE
context.currentTextBlock.content = textBeforeThinking
context.currentTextBlock.timestamp = Date.now()
context.contentBlocks.push(context.currentTextBlock)
}
appendTextBlock(context, textBeforeThinking)
hasProcessedContent = true
}
context.isInThinkingBlock = true
@@ -1556,25 +1719,7 @@ const sseHandlers: Record<string, SSEHandler> = {
remaining = contentToProcess.substring(partialTagIndex)
}
if (textToAdd) {
context.accumulatedContent.append(textToAdd)
if (context.currentTextBlock && context.contentBlocks.length > 0) {
const lastBlock = context.contentBlocks[context.contentBlocks.length - 1]
if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) {
lastBlock.content += textToAdd
} else {
context.currentTextBlock = contentBlockPool.get()
context.currentTextBlock.type = TEXT_BLOCK_TYPE
context.currentTextBlock.content = textToAdd
context.currentTextBlock.timestamp = Date.now()
context.contentBlocks.push(context.currentTextBlock)
}
} else {
context.currentTextBlock = contentBlockPool.get()
context.currentTextBlock.type = TEXT_BLOCK_TYPE
context.currentTextBlock.content = textToAdd
context.currentTextBlock.timestamp = Date.now()
context.contentBlocks.push(context.currentTextBlock)
}
appendTextBlock(context, textToAdd)
hasProcessedContent = true
}
contentToProcess = remaining
@@ -1612,37 +1757,13 @@ const sseHandlers: Record<string, SSEHandler> = {
stream_end: (_data, context, _get, set) => {
if (context.pendingContent) {
if (context.isInThinkingBlock && context.currentThinkingBlock) {
context.currentThinkingBlock.content += context.pendingContent
appendThinkingContent(context, context.pendingContent)
} else if (context.pendingContent.trim()) {
context.accumulatedContent.append(context.pendingContent)
if (context.currentTextBlock && context.contentBlocks.length > 0) {
const lastBlock = context.contentBlocks[context.contentBlocks.length - 1]
if (lastBlock.type === TEXT_BLOCK_TYPE && lastBlock === context.currentTextBlock) {
lastBlock.content += context.pendingContent
} else {
context.currentTextBlock = contentBlockPool.get()
context.currentTextBlock.type = TEXT_BLOCK_TYPE
context.currentTextBlock.content = context.pendingContent
context.currentTextBlock.timestamp = Date.now()
context.contentBlocks.push(context.currentTextBlock)
}
} else {
context.currentTextBlock = contentBlockPool.get()
context.currentTextBlock.type = TEXT_BLOCK_TYPE
context.currentTextBlock.content = context.pendingContent
context.currentTextBlock.timestamp = Date.now()
context.contentBlocks.push(context.currentTextBlock)
}
appendTextBlock(context, context.pendingContent)
}
context.pendingContent = ''
}
if (context.currentThinkingBlock) {
context.currentThinkingBlock.duration =
Date.now() - (context.currentThinkingBlock.startTime || Date.now())
}
context.isInThinkingBlock = false
context.currentThinkingBlock = null
context.currentTextBlock = null
finalizeThinkingBlock(context)
updateStreamingMessage(set, context)
},
default: () => {},
@@ -1740,29 +1861,7 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
return
}
// Initialize if needed
if (!context.subAgentContent[parentToolCallId]) {
context.subAgentContent[parentToolCallId] = ''
}
if (!context.subAgentBlocks[parentToolCallId]) {
context.subAgentBlocks[parentToolCallId] = []
}
// Append content
context.subAgentContent[parentToolCallId] += data.data
// Update or create the last text block in subAgentBlocks
const blocks = context.subAgentBlocks[parentToolCallId]
const lastBlock = blocks[blocks.length - 1]
if (lastBlock && lastBlock.type === 'subagent_text') {
lastBlock.content = (lastBlock.content || '') + data.data
} else {
blocks.push({
type: 'subagent_text',
content: data.data,
timestamp: Date.now(),
})
}
appendSubAgentText(context, parentToolCallId, data.data)
updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
},
@@ -1773,34 +1872,13 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
const phase = data?.phase || data?.data?.phase
if (!parentToolCallId) return
// Initialize if needed
if (!context.subAgentContent[parentToolCallId]) {
context.subAgentContent[parentToolCallId] = ''
}
if (!context.subAgentBlocks[parentToolCallId]) {
context.subAgentBlocks[parentToolCallId] = []
}
// For reasoning, we just append the content (treating start/end as markers)
if (phase === 'start' || phase === 'end') return
const chunk = typeof data?.data === 'string' ? data.data : data?.content || ''
if (!chunk) return
context.subAgentContent[parentToolCallId] += chunk
// Update or create the last text block in subAgentBlocks
const blocks = context.subAgentBlocks[parentToolCallId]
const lastBlock = blocks[blocks.length - 1]
if (lastBlock && lastBlock.type === 'subagent_text') {
lastBlock.content = (lastBlock.content || '') + chunk
} else {
blocks.push({
type: 'subagent_text',
content: chunk,
timestamp: Date.now(),
})
}
appendSubAgentText(context, parentToolCallId, chunk)
updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
},
@@ -1819,6 +1897,7 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
const id: string | undefined = toolData.id || data?.toolCallId
const name: string | undefined = toolData.name || data?.toolName
if (!id || !name) return
const isPartial = toolData.partial === true
// Arguments can come in different locations depending on SSE format
// Check multiple possible locations
@@ -1885,6 +1964,10 @@ const subAgentSSEHandlers: Record<string, SSEHandler> = {
updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
if (isPartial) {
return
}
// Execute client tools in parallel (non-blocking) - same pattern as main tool_call handler
try {
const def = getTool(name)
@@ -2002,6 +2085,14 @@ const MIN_BATCH_INTERVAL = 16
const MAX_BATCH_INTERVAL = 50
const MAX_QUEUE_SIZE = 5
function stopStreamingUpdates() {
if (streamingUpdateRAF !== null) {
cancelAnimationFrame(streamingUpdateRAF)
streamingUpdateRAF = null
}
streamingUpdateQueue.clear()
}
function createOptimizedContentBlocks(contentBlocks: any[]): any[] {
const result: any[] = new Array(contentBlocks.length)
for (let i = 0; i < contentBlocks.length; i++) {
@@ -2109,6 +2200,7 @@ const initialState = {
messages: [] as CopilotMessage[],
checkpoints: [] as any[],
messageCheckpoints: {} as Record<string, any[]>,
messageSnapshots: {} as Record<string, WorkflowState>,
isLoading: false,
isLoadingChats: false,
isLoadingCheckpoints: false,
@@ -2132,6 +2224,7 @@ const initialState = {
suppressAutoSelect: false,
autoAllowedTools: [] as string[],
messageQueue: [] as import('./types').QueuedMessage[],
suppressAbortContinueOption: false,
}
export const useCopilotStore = create<CopilotStore>()(
@@ -2154,7 +2247,7 @@ export const useCopilotStore = create<CopilotStore>()(
// Abort all in-progress tools and clear any diff preview
abortAllInProgressTools(set, get)
try {
useWorkflowDiffStore.getState().clearDiff()
useWorkflowDiffStore.getState().clearDiff({ restoreBaseline: false })
} catch {}
set({
@@ -2188,7 +2281,7 @@ export const useCopilotStore = create<CopilotStore>()(
// Abort in-progress tools and clear diff when changing chats
abortAllInProgressTools(set, get)
try {
useWorkflowDiffStore.getState().clearDiff()
useWorkflowDiffStore.getState().clearDiff({ restoreBaseline: false })
} catch {}
// Restore plan content and config (mode/model) from selected chat
@@ -2281,7 +2374,7 @@ export const useCopilotStore = create<CopilotStore>()(
// Abort in-progress tools and clear diff on new chat
abortAllInProgressTools(set, get)
try {
useWorkflowDiffStore.getState().clearDiff()
useWorkflowDiffStore.getState().clearDiff({ restoreBaseline: false })
} catch {}
// Background-save the current chat before clearing (optimistic)
@@ -2454,7 +2547,14 @@ export const useCopilotStore = create<CopilotStore>()(
// Send a message (streaming only)
sendMessage: async (message: string, options = {}) => {
const { workflowId, currentChat, mode, revertState, isSendingMessage } = get()
const {
workflowId,
currentChat,
mode,
revertState,
isSendingMessage,
abortController: activeAbortController,
} = get()
const {
stream = true,
fileAttachments,
@@ -2470,7 +2570,17 @@ export const useCopilotStore = create<CopilotStore>()(
if (!workflowId) return
// If already sending a message, queue this one instead
if (isSendingMessage) {
if (isSendingMessage && !activeAbortController) {
logger.warn('[Copilot] sendMessage: stale sending state detected, clearing', {
originalMessageId: messageId,
})
set({ isSendingMessage: false })
} else if (isSendingMessage && activeAbortController?.signal.aborted) {
logger.warn('[Copilot] sendMessage: aborted controller detected, clearing', {
originalMessageId: messageId,
})
set({ isSendingMessage: false, abortController: null })
} else if (isSendingMessage) {
get().addToQueue(message, { fileAttachments, contexts, messageId })
logger.info('[Copilot] Message queued (already sending)', {
queueLength: get().messageQueue.length + 1,
@@ -2479,11 +2589,17 @@ export const useCopilotStore = create<CopilotStore>()(
return
}
const abortController = new AbortController()
set({ isSendingMessage: true, error: null, abortController })
const nextAbortController = new AbortController()
set({ isSendingMessage: true, error: null, abortController: nextAbortController })
const userMessage = createUserMessage(message, fileAttachments, contexts, messageId)
const streamingMessage = createStreamingMessage()
const snapshot = workflowId ? buildCheckpointWorkflowState(workflowId) : null
if (snapshot) {
set((state) => ({
messageSnapshots: { ...state.messageSnapshots, [userMessage.id]: snapshot },
}))
}
let newMessages: CopilotMessage[]
if (revertState) {
@@ -2548,7 +2664,7 @@ export const useCopilotStore = create<CopilotStore>()(
}
// Call copilot API
const apiMode: 'ask' | 'agent' | 'plan' =
const apiMode: CopilotTransportMode =
mode === 'ask' ? 'ask' : mode === 'plan' ? 'plan' : 'agent'
// Extract slash commands from contexts (lowercase) and filter them out from contexts
@@ -2570,7 +2686,7 @@ export const useCopilotStore = create<CopilotStore>()(
fileAttachments,
contexts: filteredContexts,
commands: commands?.length ? commands : undefined,
abortSignal: abortController.signal,
abortSignal: nextAbortController.signal,
})
if (result.success && result.stream) {
@@ -2640,12 +2756,14 @@ export const useCopilotStore = create<CopilotStore>()(
},
// Abort streaming
abortMessage: () => {
abortMessage: (options?: { suppressContinueOption?: boolean }) => {
const { abortController, isSendingMessage, messages } = get()
if (!isSendingMessage || !abortController) return
set({ isAborting: true })
const suppressContinueOption = options?.suppressContinueOption === true
set({ isAborting: true, suppressAbortContinueOption: suppressContinueOption })
try {
abortController.abort()
stopStreamingUpdates()
const lastMessage = messages[messages.length - 1]
if (lastMessage && lastMessage.role === 'assistant') {
const textContent =
@@ -2653,10 +2771,19 @@ export const useCopilotStore = create<CopilotStore>()(
?.filter((b) => b.type === 'text')
.map((b: any) => b.content)
.join('') || ''
const nextContentBlocks = suppressContinueOption
? lastMessage.contentBlocks ?? []
: appendContinueOptionBlock(lastMessage.contentBlocks ? [...lastMessage.contentBlocks] : [])
set((state) => ({
messages: state.messages.map((msg) =>
msg.id === lastMessage.id
? { ...msg, content: textContent.trim() || 'Message was aborted' }
? {
...msg,
content: suppressContinueOption
? textContent.trim() || 'Message was aborted'
: appendContinueOption(textContent.trim() || 'Message was aborted'),
contentBlocks: nextContentBlocks,
}
: msg
),
isSendingMessage: false,
@@ -2955,6 +3082,10 @@ export const useCopilotStore = create<CopilotStore>()(
if (!workflowId) return
set({ isRevertingCheckpoint: true, checkpointError: null })
try {
const { messageCheckpoints } = get()
const checkpointMessageId = Object.entries(messageCheckpoints).find(([, cps]) =>
(cps || []).some((cp: any) => cp?.id === checkpointId)
)?.[0]
const response = await fetch('/api/copilot/checkpoints/revert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -3000,6 +3131,11 @@ export const useCopilotStore = create<CopilotStore>()(
},
})
}
if (checkpointMessageId) {
const { messageCheckpoints: currentCheckpoints } = get()
const updatedCheckpoints = { ...currentCheckpoints, [checkpointMessageId]: [] }
set({ messageCheckpoints: updatedCheckpoints })
}
set({ isRevertingCheckpoint: false })
} catch (error) {
set({
@@ -3013,6 +3149,10 @@ export const useCopilotStore = create<CopilotStore>()(
const { messageCheckpoints } = get()
return messageCheckpoints[messageId] || []
},
saveMessageCheckpoint: async (messageId: string) => {
if (!messageId) return false
return saveMessageCheckpoint(messageId, get, set)
},
// Handle streaming response
handleStreamingResponse: async (
@@ -3060,7 +3200,19 @@ export const useCopilotStore = create<CopilotStore>()(
try {
for await (const data of parseSSEStream(reader, decoder)) {
const { abortController } = get()
if (abortController?.signal.aborted) break
if (abortController?.signal.aborted) {
context.wasAborted = true
const { suppressAbortContinueOption } = get()
context.suppressContinueOption = suppressAbortContinueOption === true
if (suppressAbortContinueOption) {
set({ suppressAbortContinueOption: false })
}
context.pendingContent = ''
finalizeThinkingBlock(context)
stopStreamingUpdates()
reader.cancel()
break
}
// Log SSE events for debugging
logger.info('[SSE] Received event', {
@@ -3160,7 +3312,9 @@ export const useCopilotStore = create<CopilotStore>()(
if (context.streamComplete) break
}
if (sseHandlers.stream_end) sseHandlers.stream_end({}, context, get, set)
if (!context.wasAborted && sseHandlers.stream_end) {
sseHandlers.stream_end({}, context, get, set)
}
if (streamingUpdateRAF !== null) {
cancelAnimationFrame(streamingUpdateRAF)
@@ -3177,6 +3331,9 @@ export const useCopilotStore = create<CopilotStore>()(
: block
)
}
if (context.wasAborted && !context.suppressContinueOption) {
sanitizedContentBlocks = appendContinueOptionBlock(sanitizedContentBlocks)
}
if (context.contentBlocks) {
context.contentBlocks.forEach((block) => {
@@ -3187,21 +3344,36 @@ export const useCopilotStore = create<CopilotStore>()(
}
const finalContent = stripTodoTags(context.accumulatedContent.toString())
set((state) => ({
messages: state.messages.map((msg) =>
msg.id === assistantMessageId
? {
...msg,
content: finalContent,
contentBlocks: sanitizedContentBlocks,
}
: msg
),
isSendingMessage: false,
isAborting: false,
abortController: null,
currentUserMessageId: null,
}))
const finalContentWithOptions = context.wasAborted && !context.suppressContinueOption
? appendContinueOption(finalContent)
: finalContent
set((state) => {
const snapshotId = state.currentUserMessageId
const nextSnapshots =
snapshotId && state.messageSnapshots[snapshotId]
? (() => {
const updated = { ...state.messageSnapshots }
delete updated[snapshotId]
return updated
})()
: state.messageSnapshots
return {
messages: state.messages.map((msg) =>
msg.id === assistantMessageId
? {
...msg,
content: finalContentWithOptions,
contentBlocks: sanitizedContentBlocks,
}
: msg
),
isSendingMessage: false,
isAborting: false,
abortController: null,
currentUserMessageId: null,
messageSnapshots: nextSnapshots,
}
})
if (context.newChatId && !get().currentChat) {
await get().handleNewChatCreation(context.newChatId)
@@ -3709,7 +3881,7 @@ export const useCopilotStore = create<CopilotStore>()(
// If currently sending, abort and send this one
const { isSendingMessage } = get()
if (isSendingMessage) {
get().abortMessage()
get().abortMessage({ suppressContinueOption: true })
// Wait a tick for abort to complete
await new Promise((resolve) => setTimeout(resolve, 50))
}

View File

@@ -1,4 +1,7 @@
import type { CopilotMode, CopilotModelId } from '@/lib/copilot/models'
export type { CopilotMode, CopilotModelId } from '@/lib/copilot/models'
import type { ClientToolCallState, ClientToolDisplay } from '@/lib/copilot/tools/client/base-tool'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
export type ToolState = ClientToolCallState
@@ -91,33 +94,9 @@ import type { CopilotChat as ApiCopilotChat } from '@/lib/copilot/api'
export type CopilotChat = ApiCopilotChat
export type CopilotMode = 'ask' | 'build' | 'plan'
export interface CopilotState {
mode: CopilotMode
selectedModel:
| 'gpt-5-fast'
| 'gpt-5'
| 'gpt-5-medium'
| 'gpt-5-high'
| 'gpt-5.1-fast'
| 'gpt-5.1'
| 'gpt-5.1-medium'
| 'gpt-5.1-high'
| 'gpt-5-codex'
| 'gpt-5.1-codex'
| 'gpt-5.2'
| 'gpt-5.2-codex'
| 'gpt-5.2-pro'
| 'gpt-4o'
| 'gpt-4.1'
| 'o3'
| 'claude-4-sonnet'
| 'claude-4.5-haiku'
| 'claude-4.5-sonnet'
| 'claude-4.5-opus'
| 'claude-4.1-opus'
| 'gemini-3-pro'
selectedModel: CopilotModelId
agentPrefetch: boolean
enabledModels: string[] | null // Null means not loaded yet, array of model IDs when loaded
isCollapsed: boolean
@@ -129,6 +108,7 @@ export interface CopilotState {
checkpoints: any[]
messageCheckpoints: Record<string, any[]>
messageSnapshots: Record<string, WorkflowState>
isLoading: boolean
isLoadingChats: boolean
@@ -137,6 +117,8 @@ export interface CopilotState {
isSaving: boolean
isRevertingCheckpoint: boolean
isAborting: boolean
/** Skip adding Continue option on abort for queued send-now */
suppressAbortContinueOption?: boolean
error: string | null
saveError: string | null
@@ -197,7 +179,7 @@ export interface CopilotActions {
messageId?: string
}
) => Promise<void>
abortMessage: () => void
abortMessage: (options?: { suppressContinueOption?: boolean }) => void
sendImplicitFeedback: (
implicitFeedback: string,
toolCallState?: 'accepted' | 'rejected' | 'error'
@@ -215,6 +197,7 @@ export interface CopilotActions {
loadMessageCheckpoints: (chatId: string) => Promise<void>
revertToCheckpoint: (checkpointId: string) => Promise<void>
getCheckpointsForMessage: (messageId: string) => any[]
saveMessageCheckpoint: (messageId: string) => Promise<boolean>
clearMessages: () => void
clearError: () => void

View File

@@ -23,6 +23,31 @@ import {
const logger = createLogger('WorkflowDiffStore')
const diffEngine = new WorkflowDiffEngine()
/**
* Detects when a diff contains no meaningful changes.
*/
function isEmptyDiffAnalysis(diffAnalysis?: {
new_blocks?: string[]
edited_blocks?: string[]
deleted_blocks?: string[]
field_diffs?: Record<string, { changed_fields: string[] }>
edge_diff?: { new_edges?: string[]; deleted_edges?: string[] }
} | null): boolean {
if (!diffAnalysis) return false
const hasBlockChanges =
(diffAnalysis.new_blocks?.length || 0) > 0 ||
(diffAnalysis.edited_blocks?.length || 0) > 0 ||
(diffAnalysis.deleted_blocks?.length || 0) > 0
const hasEdgeChanges =
(diffAnalysis.edge_diff?.new_edges?.length || 0) > 0 ||
(diffAnalysis.edge_diff?.deleted_edges?.length || 0) > 0
const hasFieldChanges =
Object.values(diffAnalysis.field_diffs || {}).some(
(diff) => (diff?.changed_fields?.length || 0) > 0
)
return !hasBlockChanges && !hasEdgeChanges && !hasFieldChanges
}
export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActions>()(
devtools(
(set, get) => {
@@ -75,6 +100,24 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
throw new Error(errorMessage)
}
const diffAnalysisResult = diffResult.diff.diffAnalysis || null
if (isEmptyDiffAnalysis(diffAnalysisResult)) {
logger.info('No workflow diff detected; skipping diff view')
diffEngine.clearDiff()
batchedUpdate({
hasActiveDiff: false,
isShowingDiff: false,
isDiffReady: false,
baselineWorkflow: null,
baselineWorkflowId: null,
diffAnalysis: null,
diffMetadata: null,
diffError: null,
_triggerMessageId: null,
})
return
}
const candidateState = diffResult.diff.proposedState
// Validate proposed workflow using serializer round-trip
@@ -103,12 +146,22 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
isDiffReady: true,
baselineWorkflow: baselineWorkflow,
baselineWorkflowId,
diffAnalysis: diffResult.diff.diffAnalysis || null,
diffAnalysis: diffAnalysisResult,
diffMetadata: diffResult.diff.metadata,
diffError: null,
_triggerMessageId: triggerMessageId ?? null,
})
if (triggerMessageId) {
import('@/stores/panel/copilot/store')
.then(({ useCopilotStore }) =>
useCopilotStore.getState().saveMessageCheckpoint(triggerMessageId)
)
.catch((error) => {
logger.warn('Failed to save checkpoint for diff', { error })
})
}
logger.info('Workflow diff applied optimistically', {
workflowId: activeWorkflowId,
blocks: Object.keys(candidateState.blocks || {}).length,