Compare commits

..

6 Commits

Author SHA1 Message Date
Vikhyath Mondreti
f2283d37e5 improve router prompt 2026-01-10 11:10:02 -08:00
Vikhyath Mondreti
ac7e93827f Merge staging into fix/router 2026-01-10 11:02:37 -08:00
Vikhyath Mondreti
f1b8745625 router block error path decision 2026-01-10 10:59:35 -08:00
Siddharth Ganesan
d7718e6b84 Fix lint 2026-01-09 17:21:27 -08:00
Siddharth Ganesan
9d7ab37e3d Fix autoconnect edge for router 2026-01-09 17:21:19 -08:00
Siddharth Ganesan
b65a9d33cd Fix router block 2026-01-09 17:18:16 -08:00
85 changed files with 1261 additions and 5648 deletions

View File

@@ -17,30 +17,25 @@ const logger = createLogger('CopilotChatUpdateAPI')
const UpdateMessagesSchema = z.object({
chatId: z.string(),
messages: z.array(
z
.object({
id: z.string(),
role: z.enum(['user', 'assistant', 'system']),
content: z.string(),
timestamp: z.string(),
toolCalls: z.array(z.any()).optional(),
contentBlocks: z.array(z.any()).optional(),
fileAttachments: z
.array(
z.object({
id: z.string(),
key: z.string(),
filename: z.string(),
media_type: z.string(),
size: z.number(),
})
)
.optional(),
contexts: z.array(z.any()).optional(),
citations: z.array(z.any()).optional(),
errorType: z.string().optional(),
})
.passthrough() // Preserve any additional fields for future compatibility
z.object({
id: z.string(),
role: z.enum(['user', 'assistant']),
content: z.string(),
timestamp: z.string(),
toolCalls: z.array(z.any()).optional(),
contentBlocks: z.array(z.any()).optional(),
fileAttachments: z
.array(
z.object({
id: z.string(),
key: z.string(),
filename: z.string(),
media_type: z.string(),
size: z.number(),
})
)
.optional(),
})
),
planArtifact: z.string().nullable().optional(),
config: z
@@ -62,19 +57,6 @@ export async function POST(req: NextRequest) {
}
const body = await req.json()
// Debug: Log what we received
const lastMsg = body.messages?.[body.messages.length - 1]
if (lastMsg?.role === 'assistant') {
logger.info(`[${tracker.requestId}] Received messages to save`, {
messageCount: body.messages?.length,
lastMsgId: lastMsg.id,
lastMsgContentLength: lastMsg.content?.length || 0,
lastMsgContentBlockCount: lastMsg.contentBlocks?.length || 0,
lastMsgContentBlockTypes: lastMsg.contentBlocks?.map((b: any) => b?.type) || [],
})
}
const { chatId, messages, planArtifact, config } = UpdateMessagesSchema.parse(body)
// Verify that the chat belongs to the user

View File

@@ -0,0 +1,134 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { getCopilotModel } from '@/lib/copilot/config'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/copilot/constants'
import type { CopilotProviderConfig } from '@/lib/copilot/types'
import { env } from '@/lib/core/config/env'
const logger = createLogger('ContextUsageAPI')
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
const ContextUsageRequestSchema = z.object({
chatId: z.string(),
model: z.string(),
workflowId: z.string(),
provider: z.any().optional(),
})
/**
* POST /api/copilot/context-usage
* Fetch context usage from sim-agent API
*/
export async function POST(req: NextRequest) {
try {
logger.info('[Context Usage API] Request received')
const session = await getSession()
if (!session?.user?.id) {
logger.warn('[Context Usage API] No session/user ID')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
logger.info('[Context Usage API] Request body', body)
const parsed = ContextUsageRequestSchema.safeParse(body)
if (!parsed.success) {
logger.warn('[Context Usage API] Invalid request body', parsed.error.errors)
return NextResponse.json(
{ error: 'Invalid request body', details: parsed.error.errors },
{ status: 400 }
)
}
const { chatId, model, workflowId, provider } = parsed.data
const userId = session.user.id // Get userId from session, not from request
logger.info('[Context Usage API] Request validated', { chatId, model, userId, workflowId })
// Build provider config similar to chat route
let providerConfig: CopilotProviderConfig | undefined = provider
if (!providerConfig) {
const defaults = getCopilotModel('chat')
const modelToUse = env.COPILOT_MODEL || defaults.model
const providerEnv = env.COPILOT_PROVIDER as any
if (providerEnv) {
if (providerEnv === 'azure-openai') {
providerConfig = {
provider: 'azure-openai',
model: modelToUse,
apiKey: env.AZURE_OPENAI_API_KEY,
apiVersion: env.AZURE_OPENAI_API_VERSION,
endpoint: env.AZURE_OPENAI_ENDPOINT,
}
} else if (providerEnv === 'vertex') {
providerConfig = {
provider: 'vertex',
model: modelToUse,
apiKey: env.COPILOT_API_KEY,
vertexProject: env.VERTEX_PROJECT,
vertexLocation: env.VERTEX_LOCATION,
}
} else {
providerConfig = {
provider: providerEnv,
model: modelToUse,
apiKey: env.COPILOT_API_KEY,
}
}
}
}
// Call sim-agent API
const requestPayload = {
chatId,
model,
userId,
workflowId,
...(providerConfig ? { provider: providerConfig } : {}),
}
logger.info('[Context Usage API] Calling sim-agent', {
url: `${SIM_AGENT_API_URL}/api/get-context-usage`,
payload: requestPayload,
})
const simAgentResponse = await fetch(`${SIM_AGENT_API_URL}/api/get-context-usage`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify(requestPayload),
})
logger.info('[Context Usage API] Sim-agent response', {
status: simAgentResponse.status,
ok: simAgentResponse.ok,
})
if (!simAgentResponse.ok) {
const errorText = await simAgentResponse.text().catch(() => '')
logger.warn('[Context Usage API] Sim agent request failed', {
status: simAgentResponse.status,
error: errorText,
})
return NextResponse.json(
{ error: 'Failed to fetch context usage from sim-agent' },
{ status: simAgentResponse.status }
)
}
const data = await simAgentResponse.json()
logger.info('[Context Usage API] Sim-agent data received', data)
return NextResponse.json(data)
} catch (error) {
logger.error('Error fetching context usage:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -7,7 +7,6 @@ import type { GlobalCommand } from '@/app/workspace/[workspaceId]/providers/glob
* ad-hoc ids or shortcuts to ensure a single source of truth.
*/
export type CommandId =
| 'accept-diff-changes'
| 'add-agent'
| 'goto-templates'
| 'goto-logs'
@@ -44,11 +43,6 @@ export interface CommandDefinition {
* All global commands must be declared here to be usable.
*/
export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
'accept-diff-changes': {
id: 'accept-diff-changes',
shortcut: 'Mod+Shift+Enter',
allowInEditable: true,
},
'add-agent': {
id: 'add-agent',
shortcut: 'Mod+Shift+A',

View File

@@ -2,10 +2,10 @@
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { Layout, Search } from 'lucide-react'
import { Layout, LibraryBig, Search } from 'lucide-react'
import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation'
import { Button, Library } from '@/components/emcn'
import { Button } from '@/components/emcn'
import { AgentIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
@@ -41,7 +41,7 @@ const commands: CommandItem[] = [
},
{
label: 'Logs',
icon: Library,
icon: LibraryBig,
shortcut: 'L',
},
{

View File

@@ -1,10 +1,10 @@
import { memo, useCallback, useMemo } from 'react'
import { memo, useCallback } from 'react'
import { createLogger } from '@sim/logger'
import clsx from 'clsx'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import { Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/emcn'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCopilotStore, usePanelStore } from '@/stores/panel'
import { useCopilotStore } from '@/stores/panel'
import { useTerminalStore } from '@/stores/terminal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -15,20 +15,28 @@ 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 {
isShowingDiff,
isDiffReady,
hasActiveDiff,
toggleDiffView,
acceptChanges,
rejectChanges,
baselineWorkflow,
} = useWorkflowDiffStore(
useCallback(
(state) => ({
isShowingDiff: state.isShowingDiff,
isDiffReady: state.isDiffReady,
hasActiveDiff: state.hasActiveDiff,
toggleDiffView: state.toggleDiffView,
acceptChanges: state.acceptChanges,
rejectChanges: state.rejectChanges,
baselineWorkflow: state.baselineWorkflow,
}),
[]
)
)
const { updatePreviewToolCallState, currentChat, messages } = useCopilotStore(
useCallback(
@@ -45,6 +53,11 @@ export const DiffControls = memo(function DiffControls() {
useCallback((state) => ({ activeWorkflowId: state.activeWorkflowId }), [])
)
const handleToggleDiff = useCallback(() => {
logger.info('Toggling diff view', { currentState: isShowingDiff })
toggleDiffView()
}, [isShowingDiff, toggleDiffView])
const createCheckpoint = useCallback(async () => {
if (!activeWorkflowId || !currentChat?.id) {
logger.warn('Cannot create checkpoint: missing workflowId or chatId', {
@@ -193,47 +206,54 @@ export const DiffControls = memo(function DiffControls() {
}
}, [activeWorkflowId, currentChat, messages, baselineWorkflow])
const handleAccept = useCallback(() => {
const handleAccept = useCallback(async () => {
logger.info('Accepting proposed changes with backup protection')
// Resolve target toolCallId for build/edit and update to terminal success state in the copilot store
// This happens synchronously first for instant UI feedback
try {
const { toolCallsById, messages } = useCopilotStore.getState()
let id: string | undefined
outer: for (let mi = messages.length - 1; mi >= 0; mi--) {
const m = messages[mi]
if (m.role !== 'assistant' || !m.contentBlocks) continue
const blocks = m.contentBlocks as any[]
for (let bi = blocks.length - 1; bi >= 0; bi--) {
const b = blocks[bi]
if (b?.type === 'tool_call') {
const tn = b.toolCall?.name
if (tn === 'edit_workflow') {
id = b.toolCall?.id
break outer
// 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)
})
// Resolve target toolCallId for build/edit and update to terminal success state in the copilot store
try {
const { toolCallsById, messages } = useCopilotStore.getState()
let id: string | undefined
outer: for (let mi = messages.length - 1; mi >= 0; mi--) {
const m = messages[mi]
if (m.role !== 'assistant' || !m.contentBlocks) continue
const blocks = m.contentBlocks as any[]
for (let bi = blocks.length - 1; bi >= 0; bi--) {
const b = blocks[bi]
if (b?.type === 'tool_call') {
const tn = b.toolCall?.name
if (tn === 'edit_workflow') {
id = b.toolCall?.id
break outer
}
}
}
}
}
if (!id) {
const candidates = Object.values(toolCallsById).filter((t) => t.name === 'edit_workflow')
id = candidates.length ? candidates[candidates.length - 1].id : undefined
}
if (id) updatePreviewToolCallState('accepted', id)
} catch {}
if (!id) {
const candidates = Object.values(toolCallsById).filter((t) => t.name === 'edit_workflow')
id = candidates.length ? candidates[candidates.length - 1].id : undefined
}
if (id) updatePreviewToolCallState('accepted', id)
} catch {}
// Accept changes without blocking the UI; errors will be logged by the store handler
acceptChanges().catch((error) => {
logger.error('Failed to accept changes (background):', error)
})
// Accept changes without blocking the UI; errors will be logged by the store handler
acceptChanges().catch((error) => {
logger.error('Failed to accept changes (background):', error)
})
// 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')
} catch (error) {
logger.error('Failed to accept changes:', error)
logger.info('Accept triggered; UI will update optimistically')
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error('Workflow update failed:', errorMessage)
alert(`Failed to save workflow changes: ${errorMessage}`)
}
}, [createCheckpoint, updatePreviewToolCallState, acceptChanges])
const handleReject = useCallback(() => {
@@ -273,82 +293,54 @@ export const DiffControls = memo(function DiffControls() {
const preventZoomRef = usePreventZoom()
// Register global command to accept changes (Cmd/Ctrl + Shift + Enter)
const acceptCommand = useMemo(
() =>
createCommand({
id: 'accept-diff-changes',
handler: () => {
if (hasActiveDiff && isDiffReady) {
handleAccept()
}
},
}),
[hasActiveDiff, isDiffReady, handleAccept]
)
useRegisterGlobalCommands([acceptCommand])
// Don't show anything if no diff is available or diff is not ready
if (!hasActiveDiff || !isDiffReady) {
return null
}
const isResizing = isTerminalResizing || isPanelResizing
return (
<div
ref={preventZoomRef}
className={clsx(
'fixed z-30',
!isResizing && 'transition-[bottom,right] duration-100 ease-out'
'-translate-x-1/2 fixed left-1/2 z-30',
!isTerminalResizing && 'transition-[bottom] duration-100 ease-out'
)}
style={{
bottom: 'calc(var(--terminal-height) + 8px)',
right: 'calc(var(--panel-width) + 8px)',
}}
style={{ bottom: 'calc(var(--terminal-height) + 40px)' }}
>
<div
className='group relative flex h-[30px] overflow-hidden rounded-[4px]'
style={{ isolation: 'isolate' }}
>
{/* Reject side */}
<button
<div className='flex items-center gap-[6px] rounded-[10px] p-[6px]'>
{/* Toggle (left, icon-only) */}
<Button
variant='active'
onClick={handleToggleDiff}
className='h-[30px] w-[30px] rounded-[8px] p-0'
title={isShowingDiff ? 'View original' : 'Preview changes'}
>
{isShowingDiff ? (
<Eye className='h-[14px] w-[14px]' />
) : (
<EyeOff className='h-[14px] w-[14px]' />
)}
</Button>
{/* Reject */}
<Button
variant='active'
onClick={handleReject}
className='h-[30px] rounded-[8px] px-3'
title='Reject changes'
className='relative flex h-full items-center border border-[var(--border)] bg-[var(--surface-4)] pr-[20px] pl-[12px] font-medium text-[13px] text-[var(--text-secondary)] transition-colors hover:border-[var(--border-1)] hover:bg-[var(--surface-6)] hover:text-[var(--text-primary)] dark:hover:bg-[var(--surface-5)]'
style={{
clipPath: 'polygon(0 0, calc(100% + 10px) 0, 100% 100%, 0 100%)',
borderRadius: '4px 0 0 4px',
}}
>
Reject
</button>
{/* Slanted divider - split gray/green */}
<div
className='pointer-events-none absolute top-0 bottom-0 z-10'
style={{
left: '66px',
width: '2px',
transform: 'skewX(-18.4deg)',
background:
'linear-gradient(to right, var(--border) 50%, color-mix(in srgb, var(--brand-tertiary-2) 70%, black) 50%)',
}}
/>
{/* Accept side */}
<button
</Button>
{/* Accept */}
<Button
variant='tertiary'
onClick={handleAccept}
title='Accept changes (⇧⌘⏎)'
className='-ml-[10px] relative flex h-full items-center border border-[rgba(0,0,0,0.15)] bg-[var(--brand-tertiary-2)] pr-[12px] pl-[20px] font-medium text-[13px] text-[var(--text-inverse)] transition-[background-color,border-color,fill,stroke] hover:brightness-110 dark:border-[rgba(255,255,255,0.1)]'
style={{
clipPath: 'polygon(10px 0, 100% 0, 100% 100%, 0 100%)',
borderRadius: '0 4px 4px 0',
}}
className='h-[30px] rounded-[8px] px-3'
title='Accept changes'
>
Accept
<kbd className='ml-2 rounded border border-white/20 bg-white/10 px-1.5 py-0.5 font-medium font-sans text-[10px]'>
<span className='translate-y-[-1px]'></span>
</kbd>
</button>
</Button>
</div>
</div>
)

View File

@@ -11,7 +11,6 @@ import {
openCopilotWithMessage,
useNotificationStore,
} from '@/stores/notifications'
import { useSidebarStore } from '@/stores/sidebar/store'
import { useTerminalStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -20,7 +19,7 @@ const MAX_VISIBLE_NOTIFICATIONS = 4
/**
* Notifications display component
* Positioned in the bottom-left workspace area, reactive to sidebar width and terminal height
* Positioned in the bottom-right workspace area, aligned with terminal and panel spacing
* Shows both global notifications and workflow-specific notifications
*/
export const Notifications = memo(function Notifications() {
@@ -37,7 +36,6 @@ export const Notifications = memo(function Notifications() {
.slice(0, MAX_VISIBLE_NOTIFICATIONS)
}, [allNotifications, activeWorkflowId])
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
const isSidebarResizing = useSidebarStore((state) => state.isResizing)
/**
* Executes a notification action and handles side effects.
@@ -105,14 +103,12 @@ export const Notifications = memo(function Notifications() {
return null
}
const isResizing = isTerminalResizing || isSidebarResizing
return (
<div
ref={preventZoomRef}
className={clsx(
'fixed bottom-[calc(var(--terminal-height)+16px)] left-[calc(var(--sidebar-width)+16px)] z-30 flex flex-col items-start',
!isResizing && 'transition-[bottom,left] duration-100 ease-out'
'fixed right-[calc(var(--panel-width)+16px)] bottom-[calc(var(--terminal-height)+16px)] z-30 flex flex-col items-end',
!isTerminalResizing && 'transition-[bottom] duration-100 ease-out'
)}
>
{[...visibleNotifications].reverse().map((notification, index, stacked) => {

View File

@@ -3,23 +3,75 @@
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronUp } from 'lucide-react'
import CopilotMarkdownRenderer from './markdown-renderer'
/**
* Max height for thinking content before internal scrolling kicks in
*/
const THINKING_MAX_HEIGHT = 200
/**
* Interval for auto-scroll during streaming (ms)
*/
const SCROLL_INTERVAL = 100
/**
* Timer update interval in milliseconds
*/
const TIMER_UPDATE_INTERVAL = 100
/**
* Milliseconds threshold for displaying as seconds
*/
const SECONDS_THRESHOLD = 1000
/**
* Props for the ShimmerOverlayText component
*/
interface ShimmerOverlayTextProps {
/** Label text to display */
label: string
/** Value text to display */
value: string
/** Whether the shimmer animation is active */
active?: boolean
}
/**
* ShimmerOverlayText component for thinking block
* Applies shimmer effect to the "Thought for X.Xs" text during streaming
*
* @param props - Component props
* @returns Text with optional shimmer overlay effect
*/
function ShimmerOverlayText({ label, value, active = false }: ShimmerOverlayTextProps) {
return (
<span className='relative inline-block'>
<span className='text-[var(--text-tertiary)]'>{label}</span>
<span className='text-[var(--text-muted)]'>{value}</span>
{active ? (
<span
aria-hidden='true'
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
>
<span
className='block text-transparent'
style={{
backgroundImage:
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
backgroundSize: '200% 100%',
backgroundRepeat: 'no-repeat',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
animation: 'thinking-shimmer 1.4s ease-in-out infinite',
mixBlendMode: 'screen',
}}
>
{label}
{value}
</span>
</span>
) : null}
<style>{`
@keyframes thinking-shimmer {
0% { background-position: 150% 0; }
50% { background-position: 0% 0; }
100% { background-position: -150% 0; }
}
`}</style>
</span>
)
}
/**
* Props for the ThinkingBlock component
*/
@@ -28,19 +80,16 @@ interface ThinkingBlockProps {
content: string
/** Whether the block is currently streaming */
isStreaming?: boolean
/** Whether there are more content blocks after this one (e.g., tool calls) */
hasFollowingContent?: boolean
/** Custom label for the thinking block (e.g., "Thinking", "Exploring"). Defaults to "Thought" */
label?: string
/** Whether special tags (plan, options) are present - triggers collapse */
hasSpecialTags?: boolean
/** Persisted duration from content block */
duration?: number
/** Persisted start time from content block */
startTime?: number
}
/**
* ThinkingBlock component displays AI reasoning/thinking process
* Shows collapsible content with duration timer
* Auto-expands during streaming and collapses when complete
* Auto-collapses when a tool call or other content comes in after it
*
* @param props - Component props
* @returns Thinking block with expandable content and timer
@@ -48,248 +97,112 @@ interface ThinkingBlockProps {
export function ThinkingBlock({
content,
isStreaming = false,
hasFollowingContent = false,
label = 'Thought',
hasSpecialTags = false,
duration: persistedDuration,
startTime: persistedStartTime,
}: ThinkingBlockProps) {
const [isExpanded, setIsExpanded] = useState(false)
const [duration, setDuration] = useState(0)
const [userHasScrolledAway, setUserHasScrolledAway] = useState(false)
const [duration, setDuration] = useState(persistedDuration ?? 0)
const userCollapsedRef = useRef<boolean>(false)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const startTimeRef = useRef<number>(Date.now())
const lastScrollTopRef = useRef(0)
const programmaticScrollRef = useRef(false)
const startTimeRef = useRef<number>(persistedStartTime ?? Date.now())
/**
* Updates start time reference when persisted start time changes
*/
useEffect(() => {
if (typeof persistedStartTime === 'number') {
startTimeRef.current = persistedStartTime
}
}, [persistedStartTime])
/**
* Auto-expands block when streaming with content
* Auto-collapses when streaming ends OR when following content arrives
* Auto-collapses when streaming ends
*/
useEffect(() => {
// Collapse if streaming ended or if there's following content (like a tool call)
if (!isStreaming || hasFollowingContent) {
if (!isStreaming) {
setIsExpanded(false)
userCollapsedRef.current = false
setUserHasScrolledAway(false)
return
}
if (!userCollapsedRef.current && content && content.trim().length > 0) {
setIsExpanded(true)
}
}, [isStreaming, content, hasFollowingContent])
// Reset start time when streaming begins
useEffect(() => {
if (isStreaming && !hasFollowingContent) {
startTimeRef.current = Date.now()
setDuration(0)
setUserHasScrolledAway(false)
}
}, [isStreaming, hasFollowingContent])
// Update duration timer during streaming (stop when following content arrives)
useEffect(() => {
// Stop timer if not streaming or if there's following content (thinking is done)
if (!isStreaming || hasFollowingContent) return
const interval = setInterval(() => {
setDuration(Date.now() - startTimeRef.current)
}, TIMER_UPDATE_INTERVAL)
return () => clearInterval(interval)
}, [isStreaming, hasFollowingContent])
// Handle scroll events to detect user scrolling away
useEffect(() => {
const container = scrollContainerRef.current
if (!container || !isExpanded) return
const handleScroll = () => {
if (programmaticScrollRef.current) return
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const isNearBottom = distanceFromBottom <= 20
const delta = scrollTop - lastScrollTopRef.current
const movedUp = delta < -2
if (movedUp && !isNearBottom) {
setUserHasScrolledAway(true)
}
// Re-stick if user scrolls back to bottom
if (userHasScrolledAway && isNearBottom) {
setUserHasScrolledAway(false)
}
lastScrollTopRef.current = scrollTop
}
container.addEventListener('scroll', handleScroll, { passive: true })
lastScrollTopRef.current = container.scrollTop
return () => container.removeEventListener('scroll', handleScroll)
}, [isExpanded, userHasScrolledAway])
// Smart auto-scroll: only scroll if user hasn't scrolled away
useEffect(() => {
if (!isStreaming || !isExpanded || userHasScrolledAway) return
const intervalId = window.setInterval(() => {
const container = scrollContainerRef.current
if (!container) return
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const isNearBottom = distanceFromBottom <= 50
if (isNearBottom) {
programmaticScrollRef.current = true
container.scrollTo({
top: container.scrollHeight,
behavior: 'smooth',
})
window.setTimeout(() => {
programmaticScrollRef.current = false
}, 150)
}
}, SCROLL_INTERVAL)
return () => window.clearInterval(intervalId)
}, [isStreaming, isExpanded, userHasScrolledAway])
}, [isStreaming, content])
/**
* Formats duration in milliseconds to seconds
* Always shows seconds, rounded to nearest whole second, minimum 1s
* Updates duration timer during streaming
* Uses persisted duration when available
*/
useEffect(() => {
if (typeof persistedDuration === 'number') {
setDuration(persistedDuration)
return
}
if (isStreaming) {
const interval = setInterval(() => {
setDuration(Date.now() - startTimeRef.current)
}, TIMER_UPDATE_INTERVAL)
return () => clearInterval(interval)
}
setDuration(Date.now() - startTimeRef.current)
}, [isStreaming, persistedDuration])
/**
* Formats duration in milliseconds to human-readable format
* @param ms - Duration in milliseconds
* @returns Formatted string (e.g., "150ms" or "2.5s")
*/
const formatDuration = (ms: number) => {
const seconds = Math.max(1, Math.round(ms / 1000))
if (ms < SECONDS_THRESHOLD) {
return `${ms}ms`
}
const seconds = (ms / SECONDS_THRESHOLD).toFixed(1)
return `${seconds}s`
}
const hasContent = content && content.trim().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)}`
// Convert past tense label to present tense for streaming (e.g., "Thought" → "Thinking")
const getStreamingLabel = (lbl: string) => {
if (lbl === 'Thought') return 'Thinking'
if (lbl.endsWith('ed')) return `${lbl.slice(0, -2)}ing`
return lbl
}
const streamingLabel = getStreamingLabel(label)
// During streaming: show header with shimmer effect + expanded content
if (!isThinkingDone) {
return (
<div>
{/* Define shimmer keyframes */}
<style>{`
@keyframes thinking-shimmer {
0% { background-position: 150% 0; }
50% { background-position: 0% 0; }
100% { background-position: -150% 0; }
}
`}</style>
<button
onClick={() => {
setIsExpanded((v) => {
const next = !v
if (!next) userCollapsedRef.current = true
return next
})
}}
className='group inline-flex items-center gap-1 text-left font-[470] font-season text-[var(--text-secondary)] text-sm transition-colors hover:text-[var(--text-primary)]'
type='button'
>
<span className='relative inline-block'>
<span className='text-[var(--text-tertiary)]'>{streamingLabel}</span>
<span
aria-hidden='true'
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
>
<span
className='block text-transparent'
style={{
backgroundImage:
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
backgroundSize: '200% 100%',
backgroundRepeat: 'no-repeat',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
animation: 'thinking-shimmer 1.4s ease-in-out infinite',
mixBlendMode: 'screen',
}}
>
{streamingLabel}
</span>
</span>
</span>
{hasContent && (
<ChevronUp
className={clsx(
'h-3 w-3 transition-all group-hover:opacity-100',
isExpanded ? 'rotate-180 opacity-100' : 'rotate-90 opacity-0'
)}
aria-hidden='true'
/>
)}
</button>
<div
ref={scrollContainerRef}
className={clsx(
'overflow-y-auto transition-all duration-300 ease-in-out',
isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
)}
>
{/* Render markdown during streaming with thinking text styling */}
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-none [&_*]:!m-0 [&_*]:!p-0 [&_*]:!mb-0 [&_*]:!mt-0 [&_p]:!m-0 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_code]:!text-[11px] [&_ul]:!pl-4 [&_ul]:!my-0 [&_ol]:!pl-4 [&_ol]:!my-0 [&_li]:!my-0 [&_li]:!py-0 [&_br]:!leading-[0.5] whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-muted)] leading-none'>
<CopilotMarkdownRenderer content={content} />
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-[var(--text-muted)]' />
</div>
</div>
</div>
)
}
// After done: show collapsible header with duration
return (
<div>
<div className='mt-1 mb-0'>
<button
onClick={() => {
setIsExpanded((v) => !v)
setIsExpanded((v) => {
const next = !v
// If user collapses during streaming, remember to not auto-expand again
if (!next && isStreaming) userCollapsedRef.current = true
return next
})
}}
className='group inline-flex items-center gap-1 text-left font-[470] font-season text-[var(--text-secondary)] text-sm transition-colors hover:text-[var(--text-primary)]'
className='mb-1 inline-flex items-center gap-1 text-left font-[470] font-season text-[var(--text-secondary)] text-sm transition-colors hover:text-[var(--text-primary)]'
type='button'
disabled={!hasContent}
>
<span className='text-[var(--text-tertiary)]'>{durationText}</span>
<ShimmerOverlayText
label='Thought'
value={` for ${formatDuration(duration)}`}
active={isStreaming}
/>
{hasContent && (
<ChevronUp
className={clsx(
'h-3 w-3 transition-all group-hover:opacity-100',
isExpanded ? 'rotate-180 opacity-100' : 'rotate-90 opacity-0'
)}
className={clsx('h-3 w-3 transition-transform', isExpanded && 'rotate-180')}
aria-hidden='true'
/>
)}
</button>
<div
ref={scrollContainerRef}
className={clsx(
'overflow-y-auto transition-all duration-300 ease-in-out',
isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
)}
>
{/* Use markdown renderer for completed content */}
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-none [&_*]:!m-0 [&_*]:!p-0 [&_*]:!mb-0 [&_*]:!mt-0 [&_p]:!m-0 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_code]:!text-[11px] [&_ul]:!pl-4 [&_ul]:!my-0 [&_ol]:!pl-4 [&_ol]:!my-0 [&_li]:!my-0 [&_li]:!py-0 [&_br]:!leading-[0.5] whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-muted)] leading-none'>
<CopilotMarkdownRenderer content={content} />
{isExpanded && (
<div className='ml-1 border-[var(--border-1)] border-l-2 pl-2'>
<pre className='whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-tertiary)] leading-[1.15rem]'>
{content}
{isStreaming && (
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-[var(--text-tertiary)]' />
)}
</pre>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,13 +1,9 @@
'use client'
import { type FC, memo, useCallback, useMemo, useState } from 'react'
import { RotateCcw } from 'lucide-react'
import { type FC, memo, useMemo, useState } from 'react'
import { Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from 'lucide-react'
import { Button } from '@/components/emcn'
import {
OptionsSelector,
parseSpecialTags,
ToolCall,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
import { ToolCall } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
import {
FileAttachmentDisplay,
SmoothStreamingText,
@@ -19,6 +15,8 @@ import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId
import {
useCheckpointManagement,
useMessageEditing,
useMessageFeedback,
useSuccessTimers,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks'
import { UserInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
import type { CopilotMessage as CopilotMessageType } from '@/stores/panel'
@@ -42,8 +40,6 @@ interface CopilotMessageProps {
onEditModeChange?: (isEditing: boolean, cancelCallback?: () => void) => void
/** Callback when revert mode changes */
onRevertModeChange?: (isReverting: boolean) => void
/** Whether this is the last message in the conversation */
isLastMessage?: boolean
}
/**
@@ -63,7 +59,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
checkpointCount = 0,
onEditModeChange,
onRevertModeChange,
isLastMessage = false,
}) => {
const isUser = message.role === 'user'
const isAssistant = message.role === 'assistant'
@@ -93,6 +88,22 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
// UI state
const [isHoveringMessage, setIsHoveringMessage] = useState(false)
// Success timers hook
const {
showCopySuccess,
showUpvoteSuccess,
showDownvoteSuccess,
handleCopy,
setShowUpvoteSuccess,
setShowDownvoteSuccess,
} = useSuccessTimers()
// Message feedback hook
const { handleUpvote, handleDownvote } = useMessageFeedback(message, messages, {
setShowUpvoteSuccess,
setShowDownvoteSuccess,
})
// Checkpoint management hook
const {
showRestoreConfirmation,
@@ -142,6 +153,14 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
pendingEditRef,
})
/**
* Handles copying message content to clipboard
* Uses the success timer hook to show feedback
*/
const handleCopyContent = () => {
handleCopy(message.content)
}
// Get clean text content with double newline parsing
const cleanTextContent = useMemo(() => {
if (!message.content) return ''
@@ -150,42 +169,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
return message.content.replace(/\n{3,}/g, '\n\n')
}, [message.content])
// Parse special tags from message content (options, plan)
// Parse during streaming to show options/plan as they stream in
const parsedTags = useMemo(() => {
if (isUser) return null
// Try message.content first
if (message.content) {
const parsed = parseSpecialTags(message.content)
if (parsed.options || parsed.plan) return parsed
}
// During streaming, check content blocks for options/plan
if (isStreaming && message.contentBlocks && message.contentBlocks.length > 0) {
for (const block of message.contentBlocks) {
if (block.type === 'text' && block.content) {
const parsed = parseSpecialTags(block.content)
if (parsed.options || parsed.plan) return parsed
}
}
}
return message.content ? parseSpecialTags(message.content) : null
}, [message.content, message.contentBlocks, isUser, isStreaming])
// Get sendMessage from store for continuation actions
const sendMessage = useCopilotStore((s) => s.sendMessage)
// Handler for option selection
const handleOptionSelect = useCallback(
(_optionKey: string, optionText: string) => {
// Send the option text as a message
sendMessage(optionText)
},
[sendMessage]
)
// Memoize content blocks to avoid re-rendering unchanged blocks
const memoizedContentBlocks = useMemo(() => {
if (!message.contentBlocks || message.contentBlocks.length === 0) {
@@ -196,12 +179,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
if (block.type === 'text') {
const isLastTextBlock =
index === message.contentBlocks!.length - 1 && block.type === 'text'
// Always strip special tags from display (they're rendered separately as options/plan)
const parsed = parseSpecialTags(block.content)
const cleanBlockContent = parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
// Skip if no content after stripping tags
if (!cleanBlockContent.trim()) return null
// Clean content for this text block
const cleanBlockContent = block.content.replace(/\n{3,}/g, '\n\n')
// Use smooth streaming for the last text block if we're streaming
const shouldUseSmoothing = isStreaming && isLastTextBlock
@@ -222,14 +201,19 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
)
}
if (block.type === 'thinking') {
// Check if there are any blocks after this one (tool calls, text, etc.)
const hasFollowingContent = index < message.contentBlocks!.length - 1
const isLastBlock = index === message.contentBlocks!.length - 1
// Consider the thinking block streaming if the overall message is streaming
// and the block has not been finalized with a duration yet. This avoids
// freezing the timer when new blocks are appended after the thinking block.
const isStreamingThinking = isStreaming && (block as any).duration == null
return (
<div key={`thinking-${index}-${block.timestamp || index}`} className='w-full'>
<ThinkingBlock
content={block.content}
isStreaming={isStreaming}
hasFollowingContent={hasFollowingContent}
isStreaming={isStreamingThinking}
duration={block.duration}
startTime={block.startTime}
/>
</div>
)
@@ -483,11 +467,53 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
)}
{message.errorType === 'usage_limit' && (
<div className='flex gap-1.5'>
<div className='mt-3 flex gap-1.5'>
<UsageLimitActions />
</div>
)}
{/* Action buttons for completed messages */}
{!isStreaming && cleanTextContent && (
<div className='flex items-center gap-[8px] pt-[8px]'>
<Button
onClick={handleCopyContent}
variant='ghost'
title='Copy'
className='!h-[14px] !w-[14px] !p-0'
>
{showCopySuccess ? (
<Check className='h-[14px] w-[14px]' strokeWidth={2} />
) : (
<Copy className='h-[14px] w-[14px]' strokeWidth={2} />
)}
</Button>
<Button
onClick={handleUpvote}
variant='ghost'
title='Upvote'
className='!h-[14px] !w-[14px] !p-0'
>
{showUpvoteSuccess ? (
<Check className='h-[14px] w-[14px]' strokeWidth={2} />
) : (
<ThumbsUp className='h-[14px] w-[14px]' strokeWidth={2} />
)}
</Button>
<Button
onClick={handleDownvote}
variant='ghost'
title='Downvote'
className='!h-[14px] !w-[14px] !p-0'
>
{showDownvoteSuccess ? (
<Check className='h-[14px] w-[14px]' strokeWidth={2} />
) : (
<ThumbsDown className='h-[14px] w-[14px]' strokeWidth={2} />
)}
</Button>
</div>
)}
{/* Citations if available */}
{message.citations && message.citations.length > 0 && (
<div className='pt-1'>
@@ -507,20 +533,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
</div>
</div>
)}
{/* Options selector when agent presents choices - streams in but disabled until complete */}
{/* Disabled for previous messages (not isLastMessage) so only the latest options are interactive */}
{parsedTags?.options && Object.keys(parsedTags.options).length > 0 && (
<OptionsSelector
options={parsedTags.options}
onSelect={handleOptionSelect}
disabled={!isLastMessage || isSendingMessage || isStreaming}
enableKeyboardNav={
isLastMessage && !isStreaming && parsedTags.optionsComplete === true
}
streaming={isStreaming || !parsedTags.optionsComplete}
/>
)}
</div>
</div>
)
@@ -558,11 +570,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
return false
}
// If isLastMessage changed, re-render (for options visibility)
if (prevProps.isLastMessage !== nextProps.isLastMessage) {
return false
}
// For streaming messages, check if content actually changed
if (nextProps.isStreaming) {
const prevBlocks = prevMessage.contentBlocks || []

View File

@@ -1,6 +1,5 @@
export * from './copilot-message/copilot-message'
export * from './plan-mode-section/plan-mode-section'
export * from './queued-messages/queued-messages'
export * from './todo-list/todo-list'
export * from './tool-call/tool-call'
export * from './user-input/user-input'

View File

@@ -1,102 +0,0 @@
'use client'
import { useCallback, useState } from 'react'
import { ArrowUp, ChevronDown, ChevronRight, Trash2 } from 'lucide-react'
import { useCopilotStore } from '@/stores/panel/copilot/store'
/**
* Displays queued messages in a Cursor-style collapsible panel above the input box.
*/
export function QueuedMessages() {
const messageQueue = useCopilotStore((s) => s.messageQueue)
const removeFromQueue = useCopilotStore((s) => s.removeFromQueue)
const sendNow = useCopilotStore((s) => s.sendNow)
const [isExpanded, setIsExpanded] = useState(true)
const handleRemove = useCallback(
(id: string) => {
removeFromQueue(id)
},
[removeFromQueue]
)
const handleSendNow = useCallback(
async (id: string) => {
await sendNow(id)
},
[sendNow]
)
if (messageQueue.length === 0) return null
return (
<div className='mx-2 overflow-hidden rounded-t-lg border border-black/[0.08] border-b-0 bg-[var(--bg-secondary)] dark:border-white/[0.08]'>
{/* Header */}
<button
type='button'
onClick={() => setIsExpanded(!isExpanded)}
className='flex w-full items-center justify-between px-2.5 py-1.5 transition-colors hover:bg-[var(--bg-tertiary)]'
>
<div className='flex items-center gap-1.5'>
{isExpanded ? (
<ChevronDown className='h-3 w-3 text-[var(--text-tertiary)]' />
) : (
<ChevronRight className='h-3 w-3 text-[var(--text-tertiary)]' />
)}
<span className='font-medium text-[var(--text-secondary)] text-xs'>
{messageQueue.length} Queued
</span>
</div>
</button>
{/* Message list */}
{isExpanded && (
<div>
{messageQueue.map((msg) => (
<div
key={msg.id}
className='group flex items-center gap-2 border-black/[0.04] border-t px-2.5 py-1.5 hover:bg-[var(--bg-tertiary)] dark:border-white/[0.04]'
>
{/* Radio indicator */}
<div className='flex h-3 w-3 shrink-0 items-center justify-center'>
<div className='h-2.5 w-2.5 rounded-full border border-[var(--text-tertiary)]/50' />
</div>
{/* Message content */}
<div className='min-w-0 flex-1'>
<p className='truncate text-[var(--text-primary)] text-xs'>{msg.content}</p>
</div>
{/* Actions - always visible */}
<div className='flex shrink-0 items-center gap-0.5'>
<button
type='button'
onClick={(e) => {
e.stopPropagation()
handleSendNow(msg.id)
}}
className='rounded p-0.5 text-[var(--text-tertiary)] transition-colors hover:bg-[var(--bg-quaternary)] hover:text-[var(--text-primary)]'
title='Send now (aborts current stream)'
>
<ArrowUp className='h-3 w-3' />
</button>
<button
type='button'
onClick={(e) => {
e.stopPropagation()
handleRemove(msg.id)
}}
className='rounded p-0.5 text-[var(--text-tertiary)] transition-colors hover:bg-red-500/10 hover:text-red-400'
title='Remove from queue'
>
<Trash2 className='h-3 w-3' />
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,76 @@
'use client'
import { useMemo } from 'react'
import { Tooltip } from '@/components/emcn'
interface ContextUsageIndicatorProps {
/** Usage percentage (0-100) */
percentage: number
/** Size of the indicator in pixels */
size?: number
/** Stroke width in pixels */
strokeWidth?: number
}
/**
* Circular context usage indicator showing percentage of context window used.
* Displays a progress ring that changes color based on usage level.
*
* @param props - Component props
* @returns Rendered context usage indicator
*/
export function ContextUsageIndicator({
percentage,
size = 20,
strokeWidth = 2,
}: ContextUsageIndicatorProps) {
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (percentage / 100) * circumference
const color = useMemo(() => {
if (percentage >= 90) return 'var(--text-error)'
if (percentage >= 75) return 'var(--warning)'
return 'var(--text-muted)'
}, [percentage])
const displayPercentage = useMemo(() => {
return Math.round(percentage)
}, [percentage])
return (
<Tooltip.Root delayDuration={100}>
<Tooltip.Trigger asChild>
<div
className='flex cursor-pointer items-center justify-center transition-opacity hover:opacity-80'
style={{ width: size, height: size }}
>
<svg width={size} height={size} className='rotate-[-90deg]'>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke='currentColor'
strokeWidth={strokeWidth}
fill='none'
className='text-muted-foreground/20'
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
fill='none'
strokeDasharray={circumference}
strokeDashoffset={offset}
className='transition-all duration-300 ease-in-out'
strokeLinecap='round'
/>
</svg>
</div>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{displayPercentage}% context used</Tooltip.Content>
</Tooltip.Root>
)
}

View File

@@ -1,5 +1,6 @@
export { AttachedFilesDisplay } from './attached-files-display/attached-files-display'
export { ContextPills } from './context-pills/context-pills'
export { ContextUsageIndicator } from './context-usage-indicator/context-usage-indicator'
export { MentionMenu } from './mention-menu/mention-menu'
export { ModeSelector } from './mode-selector/mode-selector'
export { ModelSelector } from './model-selector/model-selector'

View File

@@ -178,12 +178,11 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
/**
* Opens file picker dialog
* Note: We allow file selection even when isLoading (streaming) so users can prepare images for the next message
*/
const handleFileSelect = useCallback(() => {
if (disabled) return
if (disabled || isLoading) return
fileInputRef.current?.click()
}, [disabled])
}, [disabled, isLoading])
/**
* Handles file input change event

View File

@@ -117,6 +117,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
const selectedModel =
selectedModelOverride !== undefined ? selectedModelOverride : copilotStore.selectedModel
const setSelectedModel = onModelChangeOverride || copilotStore.setSelectedModel
const contextUsage = copilotStore.contextUsage
// Internal state
const [internalMessage, setInternalMessage] = useState('')
@@ -299,8 +300,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => {
const targetMessage = overrideMessage ?? message
const trimmedMessage = targetMessage.trim()
// Allow submission even when isLoading - store will queue the message
if (!trimmedMessage || disabled) return
if (!trimmedMessage || disabled || isLoading) return
const failedUploads = fileAttachments.attachedFiles.filter((f) => !f.uploading && !f.key)
if (failedUploads.length > 0) {
@@ -746,7 +746,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
title='Attach file'
className={cn(
'cursor-pointer rounded-[6px] border-0 bg-transparent p-[0px] dark:bg-transparent',
disabled && 'cursor-not-allowed opacity-50'
(disabled || isLoading) && 'cursor-not-allowed opacity-50'
)}
>
<Image className='!h-3.5 !w-3.5 scale-x-110' />
@@ -802,7 +802,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
</div>
</div>
{/* Hidden File Input - enabled during streaming so users can prepare images for the next message */}
{/* Hidden File Input */}
<input
ref={fileAttachments.fileInputRef}
type='file'
@@ -810,7 +810,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
className='hidden'
accept='image/*'
multiple
disabled={disabled}
disabled={disabled || isLoading}
/>
</div>
</div>

View File

@@ -22,11 +22,9 @@ import {
PopoverTrigger,
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { cn } from '@/lib/core/utils/cn'
import {
CopilotMessage,
PlanModeSection,
QueuedMessages,
TodoList,
UserInput,
Welcome,
@@ -101,6 +99,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
loadChats,
messageCheckpoints,
currentChat,
fetchContextUsage,
selectChat,
deleteChat,
areChatsFresh,
@@ -119,6 +118,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
chatsLoadedForWorkflow,
setCopilotWorkflowId,
loadChats,
fetchContextUsage,
loadAutoAllowedTools,
currentChat,
isSendingMessage,
@@ -298,8 +298,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
*/
const handleSubmit = useCallback(
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => {
// Allow submission even when isSendingMessage - store will queue the message
if (!query || !activeWorkflowId) return
if (!query || isSendingMessage || !activeWorkflowId) return
if (showPlanTodos) {
const store = useCopilotStore.getState()
@@ -317,7 +316,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
logger.error('Failed to send message:', error)
}
},
[activeWorkflowId, sendMessage, showPlanTodos]
[isSendingMessage, activeWorkflowId, sendMessage, showPlanTodos]
)
/**
@@ -444,13 +443,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
<span className='min-w-0 flex-1 truncate'>
{chat.title || 'New Chat'}
</span>
<div
className={cn(
'flex flex-shrink-0 items-center gap-[4px]',
currentChat?.id !== chat.id &&
'opacity-0 transition-opacity group-hover:opacity-100'
)}
>
<div className='flex flex-shrink-0 items-center gap-[4px] opacity-0 transition-opacity group-hover:opacity-100'>
<Button
variant='ghost'
className='h-[16px] w-[16px] p-0'
@@ -570,7 +563,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
onRevertModeChange={(isReverting) =>
handleRevertModeChange(message.id, isReverting)
}
isLastMessage={index === messages.length - 1}
/>
)
})}
@@ -596,9 +588,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
)}
</div>
{/* Queued messages (shown when messages are waiting) */}
<QueuedMessages />
{/* Input area with integrated mode selector */}
<div className='flex-shrink-0 px-[8px] pb-[8px]'>
<UserInput

View File

@@ -11,6 +11,7 @@ interface UseCopilotInitializationProps {
chatsLoadedForWorkflow: string | null
setCopilotWorkflowId: (workflowId: string | null) => Promise<void>
loadChats: (forceRefresh?: boolean) => Promise<void>
fetchContextUsage: () => Promise<void>
loadAutoAllowedTools: () => Promise<void>
currentChat: any
isSendingMessage: boolean
@@ -29,6 +30,7 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
chatsLoadedForWorkflow,
setCopilotWorkflowId,
loadChats,
fetchContextUsage,
loadAutoAllowedTools,
currentChat,
isSendingMessage,
@@ -100,6 +102,18 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
isSendingMessage,
])
/**
* Fetch context usage when component is initialized and has a current chat
*/
useEffect(() => {
if (isInitialized && currentChat?.id && activeWorkflowId) {
logger.info('[Copilot] Component initialized, fetching context usage')
fetchContextUsage().catch((err) => {
logger.warn('[Copilot] Failed to fetch context usage on mount', err)
})
}
}, [isInitialized, currentChat?.id, activeWorkflowId, fetchContextUsage])
/**
* Load auto-allowed tools once on mount
*/

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { PANEL_WIDTH } from '@/stores/constants'
import { usePanelStore } from '@/stores/panel'
@@ -10,14 +10,15 @@ import { usePanelStore } from '@/stores/panel'
* @returns Resize state and handlers
*/
export function usePanelResize() {
const { setPanelWidth, isResizing, setIsResizing } = usePanelStore()
const { setPanelWidth } = usePanelStore()
const [isResizing, setIsResizing] = useState(false)
/**
* Handles mouse down on resize handle
*/
const handleMouseDown = useCallback(() => {
setIsResizing(true)
}, [setIsResizing])
}, [])
/**
* Setup resize event listeners and body styles when resizing
@@ -51,7 +52,7 @@ export function usePanelResize() {
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
}, [isResizing, setPanelWidth, setIsResizing])
}, [isResizing, setPanelWidth])
return {
isResizing,

View File

@@ -136,7 +136,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
const ringStyles = cn(
hasRing && 'ring-[1.75px]',
(isFocused || isPreviewSelected) && 'ring-[var(--brand-secondary)]',
diffStatus === 'new' && 'ring-[var(--brand-tertiary-2)]',
diffStatus === 'new' && 'ring-[#22C55F]',
diffStatus === 'edited' && 'ring-[var(--warning)]'
)

View File

@@ -306,7 +306,6 @@ export function Terminal() {
const terminalRef = useRef<HTMLElement>(null)
const prevEntriesLengthRef = useRef(0)
const prevWorkflowEntriesLengthRef = useRef(0)
const isTerminalFocusedRef = useRef(false)
const {
setTerminalHeight,
lastExpandedHeight,
@@ -541,11 +540,8 @@ export function Terminal() {
/**
* Handle row click - toggle if clicking same entry
* Disables auto-selection when user manually selects, re-enables when deselecting
* Also focuses the terminal to enable keyboard navigation
*/
const handleRowClick = useCallback((entry: ConsoleEntry) => {
// Focus the terminal to enable keyboard navigation
terminalRef.current?.focus()
setSelectedEntry((prev) => {
const isDeselecting = prev?.id === entry.id
setAutoSelectEnabled(isDeselecting)
@@ -566,26 +562,6 @@ export function Terminal() {
setIsToggling(false)
}, [])
/**
* Handle terminal focus - enables keyboard navigation
*/
const handleTerminalFocus = useCallback(() => {
isTerminalFocusedRef.current = true
}, [])
/**
* Handle terminal blur - disables keyboard navigation
*/
const handleTerminalBlur = useCallback((e: React.FocusEvent) => {
// Only blur if focus is moving outside the terminal
if (!terminalRef.current?.contains(e.relatedTarget as Node)) {
isTerminalFocusedRef.current = false
}
}, [])
/**
* Handle copy output to clipboard
*/
const handleCopy = useCallback(() => {
if (!selectedEntry) return
@@ -816,12 +792,9 @@ export function Terminal() {
/**
* Handle keyboard navigation through logs
* Disables auto-selection when user manually navigates
* Only active when the terminal is focused
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle navigation when terminal is focused
if (!isTerminalFocusedRef.current) return
if (isEventFromEditableElement(e)) return
const activeElement = document.activeElement as HTMLElement | null
const toolbarRoot = document.querySelector(
@@ -856,12 +829,9 @@ export function Terminal() {
/**
* Handle keyboard navigation for input/output toggle
* Left arrow shows output, right arrow shows input
* Only active when the terminal is focused
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle navigation when terminal is focused
if (!isTerminalFocusedRef.current) return
// Ignore when typing/navigating inside editable inputs/editors
if (isEventFromEditableElement(e)) return
@@ -966,9 +936,6 @@ export function Terminal() {
isToggling && 'transition-[height] duration-100 ease-out'
)}
onTransitionEnd={handleTransitionEnd}
onFocus={handleTerminalFocus}
onBlur={handleTerminalBlur}
tabIndex={-1}
aria-label='Terminal'
>
<div className='relative flex h-full border-[var(--border)] border-t'>

View File

@@ -87,8 +87,8 @@ export const ActionBar = memo(
const userPermissions = useUserPermissionsContext()
// Check for start_trigger (unified start block) - prevent duplication but allow deletion
const isStartBlock = blockType === 'starter' || blockType === 'start_trigger'
const isResponseBlock = blockType === 'response'
const isNoteBlock = blockType === 'note'
/**
@@ -140,7 +140,7 @@ export const ActionBar = memo(
</Tooltip.Root>
)}
{!isStartBlock && !isResponseBlock && (
{!isStartBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button

View File

@@ -199,9 +199,8 @@ const tryParseJson = (value: unknown): unknown => {
/**
* Formats a subblock value for display, intelligently handling nested objects and arrays.
* Used by both the canvas workflow blocks and copilot edit summaries.
*/
export const getDisplayValue = (value: unknown): string => {
const getDisplayValue = (value: unknown): string => {
if (value == null || value === '') return '-'
// Try parsing JSON strings first
@@ -631,13 +630,10 @@ export const WorkflowBlock = memo(function WorkflowBlock({
? ((credValue as { value?: unknown }).value as string | undefined)
: (credValue as string | undefined)
if (prevCredRef.current !== cred) {
const hadPreviousCredential = prevCredRef.current !== undefined
prevCredRef.current = cred
if (hadPreviousCredential) {
const keys = Object.keys(current)
const dependentKeys = keys.filter((k) => k !== 'credential')
dependentKeys.forEach((k) => collaborativeSetSubblockValue(id, k, ''))
}
const keys = Object.keys(current)
const dependentKeys = keys.filter((k) => k !== 'credential')
dependentKeys.forEach((k) => collaborativeSetSubblockValue(id, k, ''))
}
}, [id, collaborativeSetSubblockValue])

View File

@@ -93,7 +93,7 @@ const WorkflowEdgeComponent = ({
} else if (isErrorEdge) {
color = 'var(--text-error)'
} else if (edgeDiffStatus === 'new') {
color = 'var(--brand-tertiary-2)'
color = 'var(--brand-tertiary)'
} else if (edgeRunStatus === 'success') {
// Use green for preview mode, default for canvas execution
color = previewExecutionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)'

View File

@@ -23,8 +23,7 @@ interface TriggerValidationResult {
}
/**
* Validates that pasting/duplicating blocks won't violate constraints.
* Checks both trigger constraints and single-instance block constraints.
* Validates that pasting/duplicating trigger blocks won't violate constraints.
* Returns validation result with error message if invalid.
*/
export function validateTriggerPaste(
@@ -44,12 +43,6 @@ export function validateTriggerPaste(
return { isValid: false, message }
}
}
const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue(existingBlocks, block.type)
if (singleInstanceIssue) {
const message = `A workflow can only have one ${singleInstanceIssue.blockName} block. ${action === 'paste' ? 'Please remove the existing one before pasting.' : 'Cannot duplicate.'}`
return { isValid: false, message }
}
}
return { isValid: true }
}

View File

@@ -1129,18 +1129,17 @@ const WorkflowContent = React.memo(() => {
)
/**
* Checks if adding a block would violate constraints (triggers or single-instance blocks)
* and shows notification if so.
* Checks if adding a trigger block would violate constraints and shows notification if so.
* @returns true if validation failed (caller should return early), false if ok to proceed
*/
const checkTriggerConstraints = useCallback(
(blockType: string): boolean => {
const triggerIssue = TriggerUtils.getTriggerAdditionIssue(blocks, blockType)
if (triggerIssue) {
const issue = TriggerUtils.getTriggerAdditionIssue(blocks, blockType)
if (issue) {
const message =
triggerIssue.issue === 'legacy'
issue.issue === 'legacy'
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
: `A workflow can only have one ${triggerIssue.triggerName} trigger block. Please remove the existing one before adding a new one.`
: `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before adding a new one.`
addNotification({
level: 'error',
message,
@@ -1148,17 +1147,6 @@ const WorkflowContent = React.memo(() => {
})
return true
}
const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue(blocks, blockType)
if (singleInstanceIssue) {
addNotification({
level: 'error',
message: `A workflow can only have one ${singleInstanceIssue.blockName} block. Please remove the existing one before adding a new one.`,
workflowId: activeWorkflowId || undefined,
})
return true
}
return false
},
[blocks, addNotification, activeWorkflowId]
@@ -3362,6 +3350,8 @@ const WorkflowContent = React.memo(() => {
<LazyChat />
</Suspense>
<DiffControls />
{/* Context Menus */}
<BlockContextMenu
isOpen={isBlockMenuOpen}
@@ -3417,8 +3407,6 @@ const WorkflowContent = React.memo(() => {
<Panel />
</div>
<DiffControls />
<Terminal />
{oauthModal && (

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { SIDEBAR_WIDTH } from '@/stores/constants'
import { useSidebarStore } from '@/stores/sidebar/store'
@@ -10,7 +10,8 @@ import { useSidebarStore } from '@/stores/sidebar/store'
* @returns Resize state and handlers
*/
export function useSidebarResize() {
const { setSidebarWidth, isResizing, setIsResizing } = useSidebarStore()
const { setSidebarWidth } = useSidebarStore()
const [isResizing, setIsResizing] = useState(false)
/**
* Handles mouse down on resize handle

View File

@@ -17,7 +17,6 @@ export const ResponseBlock: BlockConfig<ResponseBlockOutput> = {
category: 'blocks',
bgColor: '#2F55FF',
icon: ResponseIcon,
singleInstance: true,
subBlocks: [
{
id: 'dataMode',

View File

@@ -320,7 +320,6 @@ export interface BlockConfig<T extends ToolResponse = ToolResponse> {
subBlocks: SubBlockConfig[]
triggerAllowed?: boolean
authMode?: AuthMode
singleInstance?: boolean
tools: {
access: string[]
config?: {

View File

@@ -369,7 +369,7 @@ async function processBlockMetadata(
if (userId) {
const permissionConfig = await getUserPermissionConfig(userId)
const allowedIntegrations = permissionConfig?.allowedIntegrations
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) {
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockId)) {
logger.debug('Block not allowed by permission group', { blockId, userId })
return null
}

View File

@@ -1,120 +0,0 @@
/**
* Base class for subagent tools.
*
* Subagent tools spawn a server-side subagent that does the actual work.
* The tool auto-executes and the subagent's output is streamed back
* as nested content under the tool call.
*
* Examples: edit, plan, debug, evaluate, research, etc.
*/
import type { LucideIcon } from 'lucide-react'
import { BaseClientTool, type BaseClientToolMetadata, ClientToolCallState } from './base-tool'
import type { SubagentConfig, ToolUIConfig } from './ui-config'
import { registerToolUIConfig } from './ui-config'
/**
* Configuration for creating a subagent tool
*/
export interface SubagentToolConfig {
/** Unique tool ID */
id: string
/** Display names per state */
displayNames: {
streaming: { text: string; icon: LucideIcon }
success: { text: string; icon: LucideIcon }
error: { text: string; icon: LucideIcon }
}
/** Subagent UI configuration */
subagent: SubagentConfig
/**
* Optional: Whether this is a "special" tool (gets gradient styling).
* Default: false
*/
isSpecial?: boolean
}
/**
* Create metadata for a subagent tool from config
*/
function createSubagentMetadata(config: SubagentToolConfig): BaseClientToolMetadata {
const { displayNames, subagent, isSpecial } = config
const { streaming, success, error } = displayNames
const uiConfig: ToolUIConfig = {
isSpecial: isSpecial ?? false,
subagent,
}
return {
displayNames: {
[ClientToolCallState.generating]: streaming,
[ClientToolCallState.pending]: streaming,
[ClientToolCallState.executing]: streaming,
[ClientToolCallState.success]: success,
[ClientToolCallState.error]: error,
[ClientToolCallState.rejected]: {
text: `${config.id.charAt(0).toUpperCase() + config.id.slice(1)} skipped`,
icon: error.icon,
},
[ClientToolCallState.aborted]: {
text: `${config.id.charAt(0).toUpperCase() + config.id.slice(1)} aborted`,
icon: error.icon,
},
},
uiConfig,
}
}
/**
* Base class for subagent tools.
* Extends BaseClientTool with subagent-specific behavior.
*/
export abstract class BaseSubagentTool extends BaseClientTool {
/**
* Subagent configuration.
* Override in subclasses to customize behavior.
*/
static readonly subagentConfig: SubagentToolConfig
constructor(toolCallId: string, config: SubagentToolConfig) {
super(toolCallId, config.id, createSubagentMetadata(config))
// Register UI config for this tool
registerToolUIConfig(config.id, this.metadata.uiConfig!)
}
/**
* Execute the subagent tool.
* Immediately transitions to executing state - the actual work
* is done server-side by the subagent.
*/
async execute(_args?: Record<string, any>): Promise<void> {
this.setState(ClientToolCallState.executing)
// The tool result will come from the server via tool_result event
// when the subagent completes its work
}
}
/**
* Factory function to create a subagent tool class.
* Use this for simple subagent tools that don't need custom behavior.
*/
export function createSubagentToolClass(config: SubagentToolConfig) {
// Register UI config at class creation time
const uiConfig: ToolUIConfig = {
isSpecial: config.isSpecial ?? false,
subagent: config.subagent,
}
registerToolUIConfig(config.id, uiConfig)
return class extends BaseClientTool {
static readonly id = config.id
constructor(toolCallId: string) {
super(toolCallId, config.id, createSubagentMetadata(config))
}
async execute(_args?: Record<string, any>): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
}

View File

@@ -1,7 +1,6 @@
// Lazy require in setState to avoid circular init issues
import { createLogger } from '@sim/logger'
import type { LucideIcon } from 'lucide-react'
import type { ToolUIConfig } from './ui-config'
const baseToolLogger = createLogger('BaseClientTool')
@@ -52,11 +51,6 @@ export interface BaseClientToolMetadata {
* If provided, this will override the default text in displayNames
*/
getDynamicText?: DynamicTextFormatter
/**
* UI configuration for how this tool renders in the tool-call component.
* This replaces hardcoded logic in tool-call.tsx with declarative config.
*/
uiConfig?: ToolUIConfig
}
export class BaseClientTool {
@@ -264,12 +258,4 @@ export class BaseClientTool {
hasInterrupt(): boolean {
return !!this.metadata.interrupt
}
/**
* Get UI configuration for this tool.
* Used by tool-call component to determine rendering behavior.
*/
getUIConfig(): ToolUIConfig | undefined {
return this.metadata.uiConfig
}
}

View File

@@ -14,7 +14,6 @@ import {
interface GetBlockConfigArgs {
blockType: string
operation?: string
trigger?: boolean
}
export class GetBlockConfigClientTool extends BaseClientTool {
@@ -29,7 +28,7 @@ export class GetBlockConfigClientTool extends BaseClientTool {
[ClientToolCallState.generating]: { text: 'Getting block config', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Getting block config', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Getting block config', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Retrieved block config', icon: FileCode },
[ClientToolCallState.success]: { text: 'Got block config', icon: FileCode },
[ClientToolCallState.error]: { text: 'Failed to get block config', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted getting block config', icon: XCircle },
[ClientToolCallState.rejected]: {
@@ -44,17 +43,17 @@ export class GetBlockConfigClientTool extends BaseClientTool {
switch (state) {
case ClientToolCallState.success:
return `Retrieved ${blockName}${opSuffix} config`
return `Got ${blockName}${opSuffix} config`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Retrieving ${blockName}${opSuffix} config`
return `Getting ${blockName}${opSuffix} config`
case ClientToolCallState.error:
return `Failed to retrieve ${blockName}${opSuffix} config`
return `Failed to get ${blockName}${opSuffix} config`
case ClientToolCallState.aborted:
return `Aborted retrieving ${blockName}${opSuffix} config`
return `Aborted getting ${blockName}${opSuffix} config`
case ClientToolCallState.rejected:
return `Skipped retrieving ${blockName}${opSuffix} config`
return `Skipped getting ${blockName}${opSuffix} config`
}
}
return undefined
@@ -66,15 +65,12 @@ export class GetBlockConfigClientTool extends BaseClientTool {
try {
this.setState(ClientToolCallState.executing)
const { blockType, operation, trigger } = GetBlockConfigInput.parse(args || {})
const { blockType, operation } = GetBlockConfigInput.parse(args || {})
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
toolName: 'get_block_config',
payload: { blockType, operation, trigger },
}),
body: JSON.stringify({ toolName: 'get_block_config', payload: { blockType, operation } }),
})
if (!res.ok) {
const errorText = await res.text().catch(() => '')

View File

@@ -27,7 +27,7 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
[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.success]: { text: 'Got block options', icon: ListFilter },
[ClientToolCallState.error]: { text: 'Failed to get block options', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted getting block options', icon: XCircle },
[ClientToolCallState.rejected]: {
@@ -41,17 +41,17 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
switch (state) {
case ClientToolCallState.success:
return `Retrieved ${blockName} options`
return `Got ${blockName} options`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Retrieving ${blockName} options`
return `Getting ${blockName} options`
case ClientToolCallState.error:
return `Failed to retrieve ${blockName} options`
return `Failed to get ${blockName} options`
case ClientToolCallState.aborted:
return `Aborted retrieving ${blockName} options`
return `Aborted getting ${blockName} options`
case ClientToolCallState.rejected:
return `Skipped retrieving ${blockName} options`
return `Skipped getting ${blockName} options`
}
}
return undefined
@@ -63,20 +63,7 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
try {
this.setState(ClientToolCallState.executing)
// Handle both camelCase and snake_case parameter names, plus blockType as an alias
const normalizedArgs = args
? {
blockId:
args.blockId ||
(args as any).block_id ||
(args as any).blockType ||
(args as any).block_type,
}
: {}
logger.info('execute called', { originalArgs: args, normalizedArgs })
const { blockId } = GetBlockOptionsInput.parse(normalizedArgs)
const { blockId } = GetBlockOptionsInput.parse(args || {})
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',

View File

@@ -1,48 +0,0 @@
/**
* Initialize all tool UI configurations.
*
* This module imports all client tools to trigger their UI config registration.
* Import this module early in the app to ensure all tool configs are available.
*/
// Other tools (subagents)
import './other/auth'
import './other/custom-tool'
import './other/debug'
import './other/deploy'
import './other/edit'
import './other/evaluate'
import './other/info'
import './other/knowledge'
import './other/make-api-request'
import './other/plan'
import './other/research'
import './other/sleep'
import './other/test'
import './other/tour'
import './other/workflow'
// Workflow tools
import './workflow/deploy-api'
import './workflow/deploy-chat'
import './workflow/deploy-mcp'
import './workflow/edit-workflow'
import './workflow/run-workflow'
import './workflow/set-global-workflow-variables'
// User tools
import './user/set-environment-variables'
// Re-export UI config utilities for convenience
export {
getSubagentLabels,
getToolUIConfig,
hasInterrupt,
type InterruptConfig,
isSpecialTool,
isSubagentTool,
type ParamsTableConfig,
type SecondaryActionConfig,
type SubagentConfig,
type ToolUIConfig,
} from './ui-config'

View File

@@ -1,56 +0,0 @@
import { KeyRound, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface AuthArgs {
instruction: string
}
/**
* Auth tool that spawns a subagent to handle authentication setup.
* This tool auto-executes and the actual work is done by the auth subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class AuthClientTool extends BaseClientTool {
static readonly id = 'auth'
constructor(toolCallId: string) {
super(toolCallId, AuthClientTool.id, AuthClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Authenticating', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Authenticating', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Authenticating', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Authenticated', icon: KeyRound },
[ClientToolCallState.error]: { text: 'Failed to authenticate', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped auth', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted auth', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Authenticating',
completedLabel: 'Authenticated',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the auth tool.
* This just marks the tool as executing - the actual auth work is done server-side
* by the auth subagent, and its output is streamed as subagent events.
*/
async execute(_args?: AuthArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(AuthClientTool.id, AuthClientTool.metadata.uiConfig!)

View File

@@ -22,7 +22,7 @@ export class CheckoffTodoClientTool extends BaseClientTool {
displayNames: {
[ClientToolCallState.generating]: { text: 'Marking todo', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Marking todo', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Marked todo complete', icon: Check },
[ClientToolCallState.success]: { text: 'Todo marked complete', icon: Check },
[ClientToolCallState.error]: { text: 'Failed to mark todo', icon: XCircle },
},
}

View File

@@ -1,56 +0,0 @@
import { Loader2, Wrench, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface CustomToolArgs {
instruction: string
}
/**
* Custom tool that spawns a subagent to manage custom tools.
* This tool auto-executes and the actual work is done by the custom_tool subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class CustomToolClientTool extends BaseClientTool {
static readonly id = 'custom_tool'
constructor(toolCallId: string) {
super(toolCallId, CustomToolClientTool.id, CustomToolClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Managing custom tool', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Managing custom tool', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Managing custom tool', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Managed custom tool', icon: Wrench },
[ClientToolCallState.error]: { text: 'Failed custom tool', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped custom tool', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted custom tool', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Managing custom tool',
completedLabel: 'Custom tool managed',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the custom_tool tool.
* This just marks the tool as executing - the actual custom tool work is done server-side
* by the custom_tool subagent, and its output is streamed as subagent events.
*/
async execute(_args?: CustomToolArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(CustomToolClientTool.id, CustomToolClientTool.metadata.uiConfig!)

View File

@@ -1,60 +0,0 @@
import { Bug, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface DebugArgs {
error_description: string
context?: string
}
/**
* Debug tool that spawns a subagent to diagnose workflow issues.
* This tool auto-executes and the actual work is done by the debug subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class DebugClientTool extends BaseClientTool {
static readonly id = 'debug'
constructor(toolCallId: string) {
super(toolCallId, DebugClientTool.id, DebugClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Debugging', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Debugging', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Debugging', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Debugged', icon: Bug },
[ClientToolCallState.error]: { text: 'Failed to debug', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped debug', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted debug', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Debugging',
completedLabel: 'Debugged',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the debug tool.
* This just marks the tool as executing - the actual debug work is done server-side
* by the debug subagent, and its output is streamed as subagent events.
*/
async execute(_args?: DebugArgs): Promise<void> {
// Immediately transition to executing state - no user confirmation needed
this.setState(ClientToolCallState.executing)
// The tool result will come from the server via tool_result event
// when the debug subagent completes its work
}
}
// Register UI config at module load
registerToolUIConfig(DebugClientTool.id, DebugClientTool.metadata.uiConfig!)

View File

@@ -1,56 +0,0 @@
import { Loader2, Rocket, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface DeployArgs {
instruction: string
}
/**
* Deploy tool that spawns a subagent to handle deployment.
* This tool auto-executes and the actual work is done by the deploy subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class DeployClientTool extends BaseClientTool {
static readonly id = 'deploy'
constructor(toolCallId: string) {
super(toolCallId, DeployClientTool.id, DeployClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Deploying', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Deploying', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Deploying', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Deployed', icon: Rocket },
[ClientToolCallState.error]: { text: 'Failed to deploy', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped deploy', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted deploy', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Deploying',
completedLabel: 'Deployed',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the deploy tool.
* This just marks the tool as executing - the actual deploy work is done server-side
* by the deploy subagent, and its output is streamed as subagent events.
*/
async execute(_args?: DeployArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(DeployClientTool.id, DeployClientTool.metadata.uiConfig!)

View File

@@ -1,61 +0,0 @@
import { Loader2, Pencil, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface EditArgs {
instruction: string
}
/**
* Edit tool that spawns a subagent to apply code/workflow edits.
* This tool auto-executes and the actual work is done by the edit subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class EditClientTool extends BaseClientTool {
static readonly id = 'edit'
constructor(toolCallId: string) {
super(toolCallId, EditClientTool.id, EditClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Editing', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Editing', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Editing', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Edited', icon: Pencil },
[ClientToolCallState.error]: { text: 'Failed to apply edit', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped edit', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted edit', icon: XCircle },
},
uiConfig: {
isSpecial: true,
subagent: {
streamingLabel: 'Editing',
completedLabel: 'Edited',
shouldCollapse: false, // Edit subagent stays expanded
outputArtifacts: ['edit_summary'],
hideThinkingText: true, // We show WorkflowEditSummary instead
},
},
}
/**
* Execute the edit tool.
* This just marks the tool as executing - the actual edit work is done server-side
* by the edit subagent, and its output is streamed as subagent events.
*/
async execute(_args?: EditArgs): Promise<void> {
// Immediately transition to executing state - no user confirmation needed
this.setState(ClientToolCallState.executing)
// The tool result will come from the server via tool_result event
// when the edit subagent completes its work
}
}
// Register UI config at module load
registerToolUIConfig(EditClientTool.id, EditClientTool.metadata.uiConfig!)

View File

@@ -1,56 +0,0 @@
import { ClipboardCheck, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface EvaluateArgs {
instruction: string
}
/**
* Evaluate tool that spawns a subagent to evaluate workflows or outputs.
* This tool auto-executes and the actual work is done by the evaluate subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class EvaluateClientTool extends BaseClientTool {
static readonly id = 'evaluate'
constructor(toolCallId: string) {
super(toolCallId, EvaluateClientTool.id, EvaluateClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Evaluating', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Evaluating', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Evaluating', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Evaluated', icon: ClipboardCheck },
[ClientToolCallState.error]: { text: 'Failed to evaluate', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped evaluation', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted evaluation', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Evaluating',
completedLabel: 'Evaluated',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the evaluate tool.
* This just marks the tool as executing - the actual evaluation work is done server-side
* by the evaluate subagent, and its output is streamed as subagent events.
*/
async execute(_args?: EvaluateArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(EvaluateClientTool.id, EvaluateClientTool.metadata.uiConfig!)

View File

@@ -1,56 +0,0 @@
import { Info, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface InfoArgs {
instruction: string
}
/**
* Info tool that spawns a subagent to retrieve information.
* This tool auto-executes and the actual work is done by the info subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class InfoClientTool extends BaseClientTool {
static readonly id = 'info'
constructor(toolCallId: string) {
super(toolCallId, InfoClientTool.id, InfoClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Getting info', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Getting info', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Getting info', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Retrieved info', icon: Info },
[ClientToolCallState.error]: { text: 'Failed to get info', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped info', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted info', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Getting info',
completedLabel: 'Info retrieved',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the info tool.
* This just marks the tool as executing - the actual info work is done server-side
* by the info subagent, and its output is streamed as subagent events.
*/
async execute(_args?: InfoArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(InfoClientTool.id, InfoClientTool.metadata.uiConfig!)

View File

@@ -1,56 +0,0 @@
import { BookOpen, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface KnowledgeArgs {
instruction: string
}
/**
* Knowledge tool that spawns a subagent to manage knowledge bases.
* This tool auto-executes and the actual work is done by the knowledge subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class KnowledgeClientTool extends BaseClientTool {
static readonly id = 'knowledge'
constructor(toolCallId: string) {
super(toolCallId, KnowledgeClientTool.id, KnowledgeClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Managing knowledge', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Managing knowledge', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Managing knowledge', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Managed knowledge', icon: BookOpen },
[ClientToolCallState.error]: { text: 'Failed to manage knowledge', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped knowledge', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted knowledge', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Managing knowledge',
completedLabel: 'Knowledge managed',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the knowledge tool.
* This just marks the tool as executing - the actual knowledge search work is done server-side
* by the knowledge subagent, and its output is streamed as subagent events.
*/
async execute(_args?: KnowledgeArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(KnowledgeClientTool.id, KnowledgeClientTool.metadata.uiConfig!)

View File

@@ -5,7 +5,6 @@ import {
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
interface MakeApiRequestArgs {
@@ -28,7 +27,7 @@ export class MakeApiRequestClientTool extends BaseClientTool {
[ClientToolCallState.generating]: { text: 'Preparing API request', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Review API request', icon: Globe2 },
[ClientToolCallState.executing]: { text: 'Executing API request', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Completed API request', icon: Globe2 },
[ClientToolCallState.success]: { text: 'API request complete', icon: Globe2 },
[ClientToolCallState.error]: { text: 'Failed to execute API request', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped API request', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted API request', icon: XCircle },
@@ -37,23 +36,6 @@ export class MakeApiRequestClientTool extends BaseClientTool {
accept: { text: 'Execute', icon: Globe2 },
reject: { text: 'Skip', icon: MinusCircle },
},
uiConfig: {
interrupt: {
accept: { text: 'Execute', icon: Globe2 },
reject: { text: 'Skip', icon: MinusCircle },
showAllowOnce: true,
showAllowAlways: true,
},
paramsTable: {
columns: [
{ key: 'method', label: 'Method', width: '26%', editable: true, mono: true },
{ key: 'url', label: 'Endpoint', width: '74%', editable: true, mono: true },
],
extractRows: (params) => {
return [['request', (params.method || 'GET').toUpperCase(), params.url || '']]
},
},
},
getDynamicText: (params, state) => {
if (params?.url && typeof params.url === 'string') {
const method = params.method || 'GET'
@@ -128,6 +110,3 @@ export class MakeApiRequestClientTool extends BaseClientTool {
await this.handleAccept(args)
}
}
// Register UI config at module load
registerToolUIConfig(MakeApiRequestClientTool.id, MakeApiRequestClientTool.metadata.uiConfig!)

View File

@@ -23,7 +23,7 @@ export class MarkTodoInProgressClientTool extends BaseClientTool {
[ClientToolCallState.generating]: { text: 'Marking todo in progress', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Marking todo in progress', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Marking todo in progress', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Marked todo in progress', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Todo marked in progress', icon: Loader2 },
[ClientToolCallState.error]: { text: 'Failed to mark in progress', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted marking in progress', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped marking in progress', icon: MinusCircle },

View File

@@ -71,9 +71,9 @@ export class OAuthRequestAccessClientTool extends BaseClientTool {
displayNames: {
[ClientToolCallState.generating]: { text: 'Requesting integration access', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Requesting integration access', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Requesting integration access', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Connecting integration', icon: Loader2 },
[ClientToolCallState.rejected]: { text: 'Skipped integration access', icon: MinusCircle },
[ClientToolCallState.success]: { text: 'Requested integration access', icon: CheckCircle },
[ClientToolCallState.success]: { text: 'Integration connected', icon: CheckCircle },
[ClientToolCallState.error]: { text: 'Failed to request integration access', icon: X },
[ClientToolCallState.aborted]: { text: 'Aborted integration access request', icon: XCircle },
},
@@ -87,16 +87,17 @@ export class OAuthRequestAccessClientTool extends BaseClientTool {
switch (state) {
case ClientToolCallState.generating:
case ClientToolCallState.pending:
case ClientToolCallState.executing:
return `Requesting ${name} access`
case ClientToolCallState.executing:
return `Connecting to ${name}`
case ClientToolCallState.rejected:
return `Skipped ${name} access`
case ClientToolCallState.success:
return `Requested ${name} access`
return `${name} connected`
case ClientToolCallState.error:
return `Failed to request ${name} access`
return `Failed to connect ${name}`
case ClientToolCallState.aborted:
return `Aborted ${name} access request`
return `Aborted ${name} connection`
}
}
return undefined
@@ -150,12 +151,9 @@ export class OAuthRequestAccessClientTool extends BaseClientTool {
})
)
// Mark as success - the user opened the prompt, but connection is not guaranteed
// Mark as success - the modal will handle the actual OAuth flow
this.setState(ClientToolCallState.success)
await this.markToolComplete(
200,
`The user opened the ${this.providerName} connection prompt and may have connected. Check the connected integrations to verify the connection status.`
)
await this.markToolComplete(200, `Opened ${this.providerName} connection dialog`)
} catch (e) {
logger.error('Failed to open OAuth connect modal', { error: e })
this.setState(ClientToolCallState.error)

View File

@@ -1,20 +1,16 @@
import { ListTodo, Loader2, XCircle } from 'lucide-react'
import { createLogger } from '@sim/logger'
import { ListTodo, Loader2, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface PlanArgs {
request: string
objective?: string
todoList?: Array<{ id?: string; content: string } | string>
}
/**
* Plan tool that spawns a subagent to plan an approach.
* This tool auto-executes and the actual work is done by the plan subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class PlanClientTool extends BaseClientTool {
static readonly id = 'plan'
@@ -26,34 +22,48 @@ export class PlanClientTool extends BaseClientTool {
displayNames: {
[ClientToolCallState.generating]: { text: 'Planning', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Planning', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Planning', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Planned', icon: ListTodo },
[ClientToolCallState.error]: { text: 'Failed to plan', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped plan', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted plan', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Planning',
completedLabel: 'Planned',
shouldCollapse: true,
outputArtifacts: ['plan'],
},
[ClientToolCallState.executing]: { text: 'Planning an approach', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Finished planning', icon: ListTodo },
[ClientToolCallState.error]: { text: 'Failed to plan', icon: X },
[ClientToolCallState.aborted]: { text: 'Aborted planning', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped planning approach', icon: XCircle },
},
}
/**
* Execute the plan tool.
* This just marks the tool as executing - the actual planning work is done server-side
* by the plan subagent, and its output is streamed as subagent events.
*/
async execute(_args?: PlanArgs): Promise<void> {
// Immediately transition to executing state - no user confirmation needed
this.setState(ClientToolCallState.executing)
// The tool result will come from the server via tool_result event
// when the plan subagent completes its work
async execute(args?: PlanArgs): Promise<void> {
const logger = createLogger('PlanClientTool')
try {
this.setState(ClientToolCallState.executing)
// Update store todos from args if present (client-side only)
try {
const todoList = args?.todoList
if (Array.isArray(todoList)) {
const todos = todoList.map((item: any, index: number) => ({
id: (item && (item.id || item.todoId)) || `todo-${index}`,
content: typeof item === 'string' ? item : item.content,
completed: false,
executing: false,
}))
const { useCopilotStore } = await import('@/stores/panel/copilot/store')
const store = useCopilotStore.getState()
if (store.setPlanTodos) {
store.setPlanTodos(todos)
useCopilotStore.setState({ showPlanTodos: true })
}
}
} catch (e) {
logger.warn('Failed to update plan todos in store', { message: (e as any)?.message })
}
this.setState(ClientToolCallState.success)
// Echo args back so store/tooling can parse todoList if needed
await this.markToolComplete(200, 'Plan ready', args || {})
this.setState(ClientToolCallState.success)
} catch (e: any) {
logger.error('execute failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to plan')
}
}
}
// Register UI config at module load
registerToolUIConfig(PlanClientTool.id, PlanClientTool.metadata.uiConfig!)

View File

@@ -1,56 +0,0 @@
import { Loader2, Search, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface ResearchArgs {
instruction: string
}
/**
* Research tool that spawns a subagent to research information.
* This tool auto-executes and the actual work is done by the research subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class ResearchClientTool extends BaseClientTool {
static readonly id = 'research'
constructor(toolCallId: string) {
super(toolCallId, ResearchClientTool.id, ResearchClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Researching', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Researching', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Researching', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Researched', icon: Search },
[ClientToolCallState.error]: { text: 'Failed to research', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped research', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted research', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Researching',
completedLabel: 'Researched',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the research tool.
* This just marks the tool as executing - the actual research work is done server-side
* by the research subagent, and its output is streamed as subagent events.
*/
async execute(_args?: ResearchArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(ResearchClientTool.id, ResearchClientTool.metadata.uiConfig!)

View File

@@ -25,7 +25,7 @@ export class SearchDocumentationClientTool extends BaseClientTool {
[ClientToolCallState.generating]: { text: 'Searching documentation', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Searching documentation', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Searching documentation', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Completed documentation search', icon: BookOpen },
[ClientToolCallState.success]: { text: 'Documentation search complete', icon: BookOpen },
[ClientToolCallState.error]: { text: 'Failed to search docs', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted documentation search', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped documentation search', icon: MinusCircle },

View File

@@ -27,7 +27,7 @@ export class SearchOnlineClientTool extends BaseClientTool {
[ClientToolCallState.generating]: { text: 'Searching online', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Searching online', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Searching online', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Completed online search', icon: Globe },
[ClientToolCallState.success]: { text: 'Online search complete', icon: Globe },
[ClientToolCallState.error]: { text: 'Failed to search online', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped online search', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted online search', icon: XCircle },

View File

@@ -5,7 +5,6 @@ import {
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
/** Maximum sleep duration in seconds (3 minutes) */
const MAX_SLEEP_SECONDS = 180
@@ -40,20 +39,11 @@ export class SleepClientTool extends BaseClientTool {
[ClientToolCallState.pending]: { text: 'Sleeping', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Sleeping', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Finished sleeping', icon: Moon },
[ClientToolCallState.error]: { text: 'Interrupted sleep', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped sleep', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted sleep', icon: MinusCircle },
[ClientToolCallState.error]: { text: 'Sleep interrupted', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Sleep skipped', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Sleep aborted', icon: MinusCircle },
[ClientToolCallState.background]: { text: 'Resumed', icon: Moon },
},
uiConfig: {
secondaryAction: {
text: 'Wake',
title: 'Wake',
variant: 'tertiary',
showInStates: [ClientToolCallState.executing],
targetState: ClientToolCallState.background,
},
},
// No interrupt - auto-execute immediately
getDynamicText: (params, state) => {
const seconds = params?.seconds
@@ -152,6 +142,3 @@ export class SleepClientTool extends BaseClientTool {
await this.handleAccept(args)
}
}
// Register UI config at module load
registerToolUIConfig(SleepClientTool.id, SleepClientTool.metadata.uiConfig!)

View File

@@ -1,56 +0,0 @@
import { FlaskConical, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface TestArgs {
instruction: string
}
/**
* Test tool that spawns a subagent to run tests.
* This tool auto-executes and the actual work is done by the test subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class TestClientTool extends BaseClientTool {
static readonly id = 'test'
constructor(toolCallId: string) {
super(toolCallId, TestClientTool.id, TestClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Testing', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Testing', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Testing', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Tested', icon: FlaskConical },
[ClientToolCallState.error]: { text: 'Failed to test', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped test', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted test', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Testing',
completedLabel: 'Tested',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the test tool.
* This just marks the tool as executing - the actual test work is done server-side
* by the test subagent, and its output is streamed as subagent events.
*/
async execute(_args?: TestArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(TestClientTool.id, TestClientTool.metadata.uiConfig!)

View File

@@ -1,56 +0,0 @@
import { Compass, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface TourArgs {
instruction: string
}
/**
* Tour tool that spawns a subagent to guide the user.
* This tool auto-executes and the actual work is done by the tour subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class TourClientTool extends BaseClientTool {
static readonly id = 'tour'
constructor(toolCallId: string) {
super(toolCallId, TourClientTool.id, TourClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Touring', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Touring', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Touring', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Completed tour', icon: Compass },
[ClientToolCallState.error]: { text: 'Failed tour', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped tour', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted tour', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Touring',
completedLabel: 'Tour complete',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the tour tool.
* This just marks the tool as executing - the actual tour work is done server-side
* by the tour subagent, and its output is streamed as subagent events.
*/
async execute(_args?: TourArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(TourClientTool.id, TourClientTool.metadata.uiConfig!)

View File

@@ -1,56 +0,0 @@
import { GitBranch, Loader2, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
interface WorkflowArgs {
instruction: string
}
/**
* Workflow tool that spawns a subagent to manage workflows.
* This tool auto-executes and the actual work is done by the workflow subagent.
* The subagent's output is streamed as nested content under this tool call.
*/
export class WorkflowClientTool extends BaseClientTool {
static readonly id = 'workflow'
constructor(toolCallId: string) {
super(toolCallId, WorkflowClientTool.id, WorkflowClientTool.metadata)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Managing workflow', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Managing workflow', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Managing workflow', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Managed workflow', icon: GitBranch },
[ClientToolCallState.error]: { text: 'Failed to manage workflow', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped workflow', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted workflow', icon: XCircle },
},
uiConfig: {
subagent: {
streamingLabel: 'Managing workflow',
completedLabel: 'Workflow managed',
shouldCollapse: true,
outputArtifacts: [],
},
},
}
/**
* Execute the workflow tool.
* This just marks the tool as executing - the actual workflow work is done server-side
* by the workflow subagent, and its output is streamed as subagent events.
*/
async execute(_args?: WorkflowArgs): Promise<void> {
this.setState(ClientToolCallState.executing)
}
}
// Register UI config at module load
registerToolUIConfig(WorkflowClientTool.id, WorkflowClientTool.metadata.uiConfig!)

View File

@@ -1,238 +0,0 @@
/**
* UI Configuration Types for Copilot Tools
*
* This module defines the configuration interfaces that control how tools
* are rendered in the tool-call component. All UI behavior should be defined
* here rather than hardcoded in the rendering component.
*/
import type { LucideIcon } from 'lucide-react'
import type { ClientToolCallState } from './base-tool'
/**
* Configuration for a params table column
*/
export interface ParamsTableColumn {
/** Key to extract from params */
key: string
/** Display label for the column header */
label: string
/** Width as percentage or CSS value */
width?: string
/** Whether values in this column are editable */
editable?: boolean
/** Whether to use monospace font */
mono?: boolean
/** Whether to mask the value (for passwords) */
masked?: boolean
}
/**
* Configuration for params table rendering
*/
export interface ParamsTableConfig {
/** Column definitions */
columns: ParamsTableColumn[]
/**
* Extract rows from tool params.
* Returns array of [key, ...cellValues] for each row.
*/
extractRows: (params: Record<string, any>) => Array<[string, ...any[]]>
/**
* Optional: Update params when a cell is edited.
* Returns the updated params object.
*/
updateCell?: (
params: Record<string, any>,
rowKey: string,
columnKey: string,
newValue: any
) => Record<string, any>
}
/**
* Configuration for secondary action button (like "Move to Background")
*/
export interface SecondaryActionConfig {
/** Button text */
text: string
/** Button title/tooltip */
title?: string
/** Button variant */
variant?: 'tertiary' | 'default' | 'outline'
/** States in which to show this button */
showInStates: ClientToolCallState[]
/**
* Message to send when the action is triggered.
* Used by markToolComplete.
*/
completionMessage?: string
/**
* Target state after action.
* If not provided, defaults to 'background'.
*/
targetState?: ClientToolCallState
}
/**
* Configuration for subagent tools (tools that spawn subagents)
*/
export interface SubagentConfig {
/** Label shown while streaming (e.g., "Planning", "Editing") */
streamingLabel: string
/** Label shown when complete (e.g., "Planned", "Edited") */
completedLabel: string
/**
* Whether the content should collapse when streaming ends.
* Default: true
*/
shouldCollapse?: boolean
/**
* Output artifacts that should NOT be collapsed.
* These are rendered outside the collapsible content.
* Examples: 'plan' for PlanSteps, 'options' for OptionsSelector
*/
outputArtifacts?: Array<'plan' | 'options' | 'edit_summary'>
/**
* Whether this subagent renders its own specialized content
* and the thinking text should be minimal or hidden.
* Used for tools like 'edit' where we show WorkflowEditSummary instead.
*/
hideThinkingText?: boolean
}
/**
* Interrupt button configuration
*/
export interface InterruptButtonConfig {
text: string
icon: LucideIcon
}
/**
* Configuration for interrupt behavior (Run/Skip buttons)
*/
export interface InterruptConfig {
/** Accept button config */
accept: InterruptButtonConfig
/** Reject button config */
reject: InterruptButtonConfig
/**
* Whether to show "Allow Once" button (default accept behavior).
* Default: true
*/
showAllowOnce?: boolean
/**
* Whether to show "Allow Always" button (auto-approve this tool in future).
* Default: true for most tools
*/
showAllowAlways?: boolean
}
/**
* Complete UI configuration for a tool
*/
export interface ToolUIConfig {
/**
* Whether this is a "special" tool that gets gradient styling.
* Used for workflow operation tools like edit_workflow, build_workflow, etc.
*/
isSpecial?: boolean
/**
* Interrupt configuration for tools that require user confirmation.
* If not provided, tool auto-executes.
*/
interrupt?: InterruptConfig
/**
* Secondary action button (like "Move to Background" for run_workflow)
*/
secondaryAction?: SecondaryActionConfig
/**
* Configuration for rendering params as a table.
* If provided, tool will show an expandable/inline table.
*/
paramsTable?: ParamsTableConfig
/**
* Subagent configuration for tools that spawn subagents.
* If provided, tool is treated as a subagent tool.
*/
subagent?: SubagentConfig
/**
* Whether this tool should always show params expanded (not collapsible).
* Used for tools like set_environment_variables that always show their table.
*/
alwaysExpanded?: boolean
/**
* Custom component type for special rendering.
* The tool-call component will use this to render specialized content.
*/
customRenderer?: 'code' | 'edit_summary' | 'none'
}
/**
* Registry of tool UI configurations.
* Tools can register their UI config here for the tool-call component to use.
*/
const toolUIConfigs: Record<string, ToolUIConfig> = {}
/**
* Register a tool's UI configuration
*/
export function registerToolUIConfig(toolName: string, config: ToolUIConfig): void {
toolUIConfigs[toolName] = config
}
/**
* Get a tool's UI configuration
*/
export function getToolUIConfig(toolName: string): ToolUIConfig | undefined {
return toolUIConfigs[toolName]
}
/**
* Check if a tool is a subagent tool
*/
export function isSubagentTool(toolName: string): boolean {
return !!toolUIConfigs[toolName]?.subagent
}
/**
* Check if a tool is a "special" tool (gets gradient styling)
*/
export function isSpecialTool(toolName: string): boolean {
return !!toolUIConfigs[toolName]?.isSpecial
}
/**
* Check if a tool has interrupt (requires user confirmation)
*/
export function hasInterrupt(toolName: string): boolean {
return !!toolUIConfigs[toolName]?.interrupt
}
/**
* Get subagent labels for a tool
*/
export function getSubagentLabels(
toolName: string,
isStreaming: boolean
): { streaming: string; completed: string } | undefined {
const config = toolUIConfigs[toolName]?.subagent
if (!config) return undefined
return {
streaming: config.streamingLabel,
completed: config.completedLabel,
}
}
/**
* Get all registered tool UI configs (for debugging)
*/
export function getAllToolUIConfigs(): Record<string, ToolUIConfig> {
return { ...toolUIConfigs }
}

View File

@@ -5,7 +5,6 @@ import {
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
import { useEnvironmentStore } from '@/stores/settings/environment'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -49,33 +48,6 @@ export class SetEnvironmentVariablesClientTool extends BaseClientTool {
accept: { text: 'Apply', icon: Settings2 },
reject: { text: 'Skip', icon: XCircle },
},
uiConfig: {
alwaysExpanded: true,
interrupt: {
accept: { text: 'Apply', icon: Settings2 },
reject: { text: 'Skip', icon: XCircle },
showAllowOnce: true,
showAllowAlways: true,
},
paramsTable: {
columns: [
{ key: 'name', label: 'Variable', width: '36%', editable: true },
{ key: 'value', label: 'Value', width: '64%', editable: true, mono: true },
],
extractRows: (params) => {
const variables = params.variables || {}
const entries = Array.isArray(variables)
? variables.map((v: any, i: number) => [String(i), v.name || `var_${i}`, v.value || ''])
: Object.entries(variables).map(([key, val]) => {
if (typeof val === 'object' && val !== null && 'value' in (val as any)) {
return [key, key, (val as any).value]
}
return [key, key, val]
})
return entries as Array<[string, ...any[]]>
},
},
},
getDynamicText: (params, state) => {
if (params?.variables && typeof params.variables === 'object') {
const count = Object.keys(params.variables).length
@@ -149,9 +121,3 @@ export class SetEnvironmentVariablesClientTool extends BaseClientTool {
await this.handleAccept(args)
}
}
// Register UI config at module load
registerToolUIConfig(
SetEnvironmentVariablesClientTool.id,
SetEnvironmentVariablesClientTool.metadata.uiConfig!
)

View File

@@ -11,29 +11,6 @@ interface CheckDeploymentStatusArgs {
workflowId?: string
}
interface ApiDeploymentDetails {
isDeployed: boolean
deployedAt: string | null
endpoint: string | null
}
interface ChatDeploymentDetails {
isDeployed: boolean
chatId: string | null
identifier: string | null
chatUrl: string | null
}
interface McpDeploymentDetails {
isDeployed: boolean
servers: Array<{
serverId: string
serverName: string
toolName: string
toolDescription: string | null
}>
}
export class CheckDeploymentStatusClientTool extends BaseClientTool {
static readonly id = 'check_deployment_status'
@@ -68,116 +45,52 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool {
try {
this.setState(ClientToolCallState.executing)
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
const { activeWorkflowId } = useWorkflowRegistry.getState()
const workflowId = args?.workflowId || activeWorkflowId
if (!workflowId) {
throw new Error('No workflow ID provided')
}
const workflow = workflows[workflowId]
const workspaceId = workflow?.workspaceId
// Fetch deployment status from all sources
const [apiDeployRes, chatDeployRes, mcpServersRes] = await Promise.all([
// Fetch deployment status from API
const [apiDeployRes, chatDeployRes] = await Promise.all([
fetch(`/api/workflows/${workflowId}/deploy`),
fetch(`/api/workflows/${workflowId}/chat/status`),
workspaceId ? fetch(`/api/mcp/workflow-servers?workspaceId=${workspaceId}`) : null,
])
const apiDeploy = apiDeployRes.ok ? await apiDeployRes.json() : null
const chatDeploy = chatDeployRes.ok ? await chatDeployRes.json() : null
const mcpServers = mcpServersRes?.ok ? await mcpServersRes.json() : null
// API deployment details
const isApiDeployed = apiDeploy?.isDeployed || false
const appUrl = typeof window !== 'undefined' ? window.location.origin : ''
const apiDetails: ApiDeploymentDetails = {
isDeployed: isApiDeployed,
deployedAt: apiDeploy?.deployedAt || null,
endpoint: isApiDeployed ? `${appUrl}/api/workflows/${workflowId}/execute` : null,
}
// Chat deployment details
const isChatDeployed = !!(chatDeploy?.isDeployed && chatDeploy?.deployment)
const chatDetails: ChatDeploymentDetails = {
isDeployed: isChatDeployed,
chatId: chatDeploy?.deployment?.id || null,
identifier: chatDeploy?.deployment?.identifier || null,
chatUrl: isChatDeployed ? `${appUrl}/chat/${chatDeploy?.deployment?.identifier}` : null,
}
// MCP deployment details - find servers that have this workflow as a tool
const mcpServerList = mcpServers?.data?.servers || []
const mcpToolDeployments: McpDeploymentDetails['servers'] = []
for (const server of mcpServerList) {
// Check if this workflow is deployed as a tool on this server
if (server.toolNames && Array.isArray(server.toolNames)) {
// We need to fetch the actual tools to check if this workflow is there
try {
const toolsRes = await fetch(
`/api/mcp/workflow-servers/${server.id}/tools?workspaceId=${workspaceId}`
)
if (toolsRes.ok) {
const toolsData = await toolsRes.json()
const tools = toolsData.data?.tools || []
for (const tool of tools) {
if (tool.workflowId === workflowId) {
mcpToolDeployments.push({
serverId: server.id,
serverName: server.name,
toolName: tool.toolName,
toolDescription: tool.toolDescription,
})
}
}
}
} catch {
// Skip this server if we can't fetch tools
}
}
}
const isMcpDeployed = mcpToolDeployments.length > 0
const mcpDetails: McpDeploymentDetails = {
isDeployed: isMcpDeployed,
servers: mcpToolDeployments,
}
// Build deployment types list
const deploymentTypes: string[] = []
if (isApiDeployed) deploymentTypes.push('api')
if (isChatDeployed) deploymentTypes.push('chat')
if (isMcpDeployed) deploymentTypes.push('mcp')
const isDeployed = isApiDeployed || isChatDeployed || isMcpDeployed
// Build summary message
let message = ''
if (!isDeployed) {
message = 'Workflow is not deployed'
} else {
const parts: string[] = []
if (isApiDeployed) parts.push('API')
if (isChatDeployed) parts.push(`Chat (${chatDetails.identifier})`)
if (isMcpDeployed) {
const serverNames = mcpToolDeployments.map((d) => d.serverName).join(', ')
parts.push(`MCP (${serverNames})`)
}
message = `Workflow is deployed as: ${parts.join(', ')}`
if (isApiDeployed) {
// Default to sync API, could be extended to detect streaming/async
deploymentTypes.push('api')
}
if (isChatDeployed) {
deploymentTypes.push('chat')
}
const isDeployed = isApiDeployed || isChatDeployed
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, message, {
isDeployed,
deploymentTypes,
api: apiDetails,
chat: chatDetails,
mcp: mcpDetails,
})
logger.info('Checked deployment status', { isDeployed, deploymentTypes })
await this.markToolComplete(
200,
isDeployed
? `Workflow is deployed as: ${deploymentTypes.join(', ')}`
: 'Workflow is not deployed',
{
isDeployed,
deploymentTypes,
apiDeployed: isApiDeployed,
chatDeployed: isChatDeployed,
deployedAt: apiDeploy?.deployedAt || null,
}
)
} catch (e: any) {
logger.error('Check deployment status failed', { message: e?.message })
this.setState(ClientToolCallState.error)

View File

@@ -1,155 +0,0 @@
import { createLogger } from '@sim/logger'
import { Loader2, Plus, Server, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export interface CreateWorkspaceMcpServerArgs {
/** Name of the MCP server */
name: string
/** Optional description */
description?: string
workspaceId?: string
}
/**
* Create workspace MCP server tool.
* Creates a new MCP server in the workspace that workflows can be deployed to as tools.
*/
export class CreateWorkspaceMcpServerClientTool extends BaseClientTool {
static readonly id = 'create_workspace_mcp_server'
constructor(toolCallId: string) {
super(
toolCallId,
CreateWorkspaceMcpServerClientTool.id,
CreateWorkspaceMcpServerClientTool.metadata
)
}
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
const toolCallsById = useCopilotStore.getState().toolCallsById
const toolCall = toolCallsById[this.toolCallId]
const params = toolCall?.params as CreateWorkspaceMcpServerArgs | undefined
const serverName = params?.name || 'MCP Server'
return {
accept: { text: `Create "${serverName}"`, icon: Plus },
reject: { text: 'Skip', icon: XCircle },
}
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Preparing to create MCP server',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Create MCP server?', icon: Server },
[ClientToolCallState.executing]: { text: 'Creating MCP server', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Created MCP server', icon: Server },
[ClientToolCallState.error]: { text: 'Failed to create MCP server', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted creating MCP server', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped creating MCP server', icon: XCircle },
},
interrupt: {
accept: { text: 'Create', icon: Plus },
reject: { text: 'Skip', icon: XCircle },
},
getDynamicText: (params, state) => {
const name = params?.name || 'MCP server'
switch (state) {
case ClientToolCallState.success:
return `Created MCP server "${name}"`
case ClientToolCallState.executing:
return `Creating MCP server "${name}"`
case ClientToolCallState.generating:
return `Preparing to create "${name}"`
case ClientToolCallState.pending:
return `Create MCP server "${name}"?`
case ClientToolCallState.error:
return `Failed to create "${name}"`
}
return undefined
},
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: CreateWorkspaceMcpServerArgs): Promise<void> {
const logger = createLogger('CreateWorkspaceMcpServerClientTool')
try {
if (!args?.name) {
throw new Error('Server name is required')
}
// Get workspace ID from active workflow if not provided
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
let workspaceId = args?.workspaceId
if (!workspaceId && activeWorkflowId) {
workspaceId = workflows[activeWorkflowId]?.workspaceId
}
if (!workspaceId) {
throw new Error('No workspace ID available')
}
this.setState(ClientToolCallState.executing)
const res = await fetch('/api/mcp/workflow-servers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspaceId,
name: args.name.trim(),
description: args.description?.trim() || null,
}),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || `Failed to create MCP server (${res.status})`)
}
const server = data.data?.server
if (!server) {
throw new Error('Server creation response missing server data')
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(
200,
`MCP server "${args.name}" created successfully. You can now deploy workflows to it using deploy_mcp.`,
{
success: true,
serverId: server.id,
serverName: server.name,
description: server.description,
}
)
logger.info(`Created MCP server: ${server.name} (${server.id})`)
} catch (e: any) {
logger.error('Failed to create MCP server', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to create MCP server', {
success: false,
error: e?.message,
})
}
}
async execute(args?: CreateWorkspaceMcpServerArgs): Promise<void> {
await this.handleAccept(args)
}
}

View File

@@ -1,365 +0,0 @@
import { createLogger } from '@sim/logger'
import { Loader2, MessageSquare, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export type ChatAuthType = 'public' | 'password' | 'email' | 'sso'
export interface OutputConfig {
blockId: string
path: string
}
export interface DeployChatArgs {
action: 'deploy' | 'undeploy'
workflowId?: string
/** URL slug for the chat (lowercase letters, numbers, hyphens only) */
identifier?: string
/** Display title for the chat interface */
title?: string
/** Optional description */
description?: string
/** Authentication type: public, password, email, or sso */
authType?: ChatAuthType
/** Password for password-protected chats */
password?: string
/** List of allowed emails/domains for email or SSO auth */
allowedEmails?: string[]
/** Welcome message shown to users */
welcomeMessage?: string
/** Output configurations specifying which block outputs to display in chat */
outputConfigs?: OutputConfig[]
}
/**
* Deploy Chat tool for deploying workflows as chat interfaces.
* This tool handles deploying workflows with chat-specific configuration
* including authentication, customization, and output selection.
*/
export class DeployChatClientTool extends BaseClientTool {
static readonly id = 'deploy_chat'
constructor(toolCallId: string) {
super(toolCallId, DeployChatClientTool.id, DeployChatClientTool.metadata)
}
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
const toolCallsById = useCopilotStore.getState().toolCallsById
const toolCall = toolCallsById[this.toolCallId]
const params = toolCall?.params as DeployChatArgs | undefined
const action = params?.action || 'deploy'
const buttonText = action === 'undeploy' ? 'Undeploy' : 'Deploy Chat'
return {
accept: { text: buttonText, icon: MessageSquare },
reject: { text: 'Skip', icon: XCircle },
}
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Preparing to deploy chat',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Deploy as chat?', icon: MessageSquare },
[ClientToolCallState.executing]: { text: 'Deploying chat', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Deployed chat', icon: MessageSquare },
[ClientToolCallState.error]: { text: 'Failed to deploy chat', icon: XCircle },
[ClientToolCallState.aborted]: {
text: 'Aborted deploying chat',
icon: XCircle,
},
[ClientToolCallState.rejected]: {
text: 'Skipped deploying chat',
icon: XCircle,
},
},
interrupt: {
accept: { text: 'Deploy Chat', icon: MessageSquare },
reject: { text: 'Skip', icon: XCircle },
},
uiConfig: {
isSpecial: true,
interrupt: {
accept: { text: 'Deploy Chat', icon: MessageSquare },
reject: { text: 'Skip', icon: XCircle },
showAllowOnce: true,
showAllowAlways: true,
},
},
getDynamicText: (params, state) => {
const action = params?.action === 'undeploy' ? 'undeploy' : 'deploy'
switch (state) {
case ClientToolCallState.success:
return action === 'undeploy' ? 'Chat undeployed' : 'Chat deployed'
case ClientToolCallState.executing:
return action === 'undeploy' ? 'Undeploying chat' : 'Deploying chat'
case ClientToolCallState.generating:
return `Preparing to ${action} chat`
case ClientToolCallState.pending:
return action === 'undeploy' ? 'Undeploy chat?' : 'Deploy as chat?'
case ClientToolCallState.error:
return `Failed to ${action} chat`
case ClientToolCallState.aborted:
return action === 'undeploy' ? 'Aborted undeploying chat' : 'Aborted deploying chat'
case ClientToolCallState.rejected:
return action === 'undeploy' ? 'Skipped undeploying chat' : 'Skipped deploying chat'
}
return undefined
},
}
/**
* Generates a default identifier from the workflow name
*/
private generateIdentifier(workflowName: string): string {
return workflowName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.substring(0, 50)
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: DeployChatArgs): Promise<void> {
const logger = createLogger('DeployChatClientTool')
try {
const action = args?.action || 'deploy'
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
const workflowId = args?.workflowId || activeWorkflowId
if (!workflowId) {
throw new Error('No workflow ID provided')
}
const workflow = workflows[workflowId]
// Handle undeploy action
if (action === 'undeploy') {
this.setState(ClientToolCallState.executing)
// First get the chat deployment ID
const statusRes = await fetch(`/api/workflows/${workflowId}/chat/status`)
if (!statusRes.ok) {
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, 'Failed to check chat deployment status', {
success: false,
action: 'undeploy',
isDeployed: false,
error: 'Failed to check chat deployment status',
errorCode: 'SERVER_ERROR',
})
return
}
const statusJson = await statusRes.json()
if (!statusJson.isDeployed || !statusJson.deployment?.id) {
this.setState(ClientToolCallState.error)
await this.markToolComplete(400, 'No active chat deployment found for this workflow', {
success: false,
action: 'undeploy',
isDeployed: false,
error: 'No active chat deployment found for this workflow',
errorCode: 'VALIDATION_ERROR',
})
return
}
const chatId = statusJson.deployment.id
// Delete the chat deployment
const res = await fetch(`/api/chat/manage/${chatId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
this.setState(ClientToolCallState.error)
await this.markToolComplete(res.status, txt || `Server error (${res.status})`, {
success: false,
action: 'undeploy',
isDeployed: true,
error: txt || 'Failed to undeploy chat',
errorCode: 'SERVER_ERROR',
})
return
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, 'Chat deployment removed successfully.', {
success: true,
action: 'undeploy',
isDeployed: false,
})
return
}
// Deploy action - validate required fields
if (!args?.identifier && !workflow?.name) {
throw new Error('Either identifier or workflow name is 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) {
throw new Error('Password is required when using password protection')
}
if (
(authType === 'email' || authType === 'sso') &&
(!args?.allowedEmails || args.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)',
welcomeMessage: welcomeMessage.trim(),
},
authType,
password: authType === 'password' ? args?.password : undefined,
allowedEmails: authType === 'email' || authType === 'sso' ? args?.allowedEmails : [],
outputConfigs,
}
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const json = await res.json()
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',
}
)
return
}
// Handle validation errors
if (json.code === 'VALIDATION_ERROR') {
this.setState(ClientToolCallState.error)
await this.markToolComplete(400, json.error || 'Validation error', {
success: false,
action: 'deploy',
isDeployed: false,
error: json.error,
errorCode: 'VALIDATION_ERROR',
})
return
}
this.setState(ClientToolCallState.error)
await this.markToolComplete(res.status, json.error || 'Failed to deploy chat', {
success: false,
action: 'deploy',
isDeployed: false,
error: json.error || 'Server error',
errorCode: 'SERVER_ERROR',
})
return
}
if (!json.chatUrl) {
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, 'Response missing chat URL', {
success: false,
action: 'deploy',
isDeployed: false,
error: 'Response missing chat URL',
errorCode: 'SERVER_ERROR',
})
return
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(
200,
`Chat deployed successfully! Available at: ${json.chatUrl}`,
{
success: true,
action: 'deploy',
isDeployed: true,
chatId: json.id,
chatUrl: json.chatUrl,
identifier,
title,
authType,
}
)
// Update the workflow registry to reflect deployment status
// Chat deployment also deploys the API, so we update the registry
try {
const setDeploymentStatus = useWorkflowRegistry.getState().setDeploymentStatus
setDeploymentStatus(workflowId, true, new Date(), '')
logger.info('Workflow deployment status updated in registry')
} catch (error) {
logger.warn('Failed to update workflow registry:', error)
}
logger.info('Chat deployed successfully:', json.chatUrl)
} catch (e: any) {
logger.error('Deploy chat failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to deploy chat', {
success: false,
action: 'deploy',
isDeployed: false,
error: e?.message || 'Failed to deploy chat',
errorCode: 'SERVER_ERROR',
})
}
}
async execute(args?: DeployChatArgs): Promise<void> {
await this.handleAccept(args)
}
}
// Register UI config at module load
registerToolUIConfig(DeployChatClientTool.id, DeployChatClientTool.metadata.uiConfig!)

View File

@@ -1,211 +0,0 @@
import { createLogger } from '@sim/logger'
import { Loader2, Server, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export interface ParameterDescription {
name: string
description: string
}
export interface DeployMcpArgs {
/** The MCP server ID to deploy to (get from list_workspace_mcp_servers) */
serverId: string
/** Optional workflow ID (defaults to active workflow) */
workflowId?: string
/** Custom tool name (defaults to workflow name) */
toolName?: string
/** Custom tool description */
toolDescription?: string
/** Parameter descriptions to include in the schema */
parameterDescriptions?: ParameterDescription[]
}
/**
* Deploy MCP tool.
* Deploys the workflow as an MCP tool to a workspace MCP server.
*/
export class DeployMcpClientTool extends BaseClientTool {
static readonly id = 'deploy_mcp'
constructor(toolCallId: string) {
super(toolCallId, DeployMcpClientTool.id, DeployMcpClientTool.metadata)
}
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
return {
accept: { text: 'Deploy to MCP', icon: Server },
reject: { text: 'Skip', icon: XCircle },
}
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Preparing to deploy to MCP',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Deploy to MCP server?', icon: Server },
[ClientToolCallState.executing]: { text: 'Deploying to MCP', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Deployed to MCP', icon: Server },
[ClientToolCallState.error]: { text: 'Failed to deploy to MCP', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted MCP deployment', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped MCP deployment', icon: XCircle },
},
interrupt: {
accept: { text: 'Deploy', icon: Server },
reject: { text: 'Skip', icon: XCircle },
},
uiConfig: {
isSpecial: true,
interrupt: {
accept: { text: 'Deploy', icon: Server },
reject: { text: 'Skip', icon: XCircle },
showAllowOnce: true,
showAllowAlways: true,
},
},
getDynamicText: (params, state) => {
const toolName = params?.toolName || 'workflow'
switch (state) {
case ClientToolCallState.success:
return `Deployed "${toolName}" to MCP`
case ClientToolCallState.executing:
return `Deploying "${toolName}" to MCP`
case ClientToolCallState.generating:
return `Preparing to deploy to MCP`
case ClientToolCallState.pending:
return `Deploy "${toolName}" to MCP?`
case ClientToolCallState.error:
return `Failed to deploy to MCP`
}
return undefined
},
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: DeployMcpArgs): Promise<void> {
const logger = createLogger('DeployMcpClientTool')
try {
if (!args?.serverId) {
throw new Error(
'Server ID is required. Use list_workspace_mcp_servers to get available servers.'
)
}
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
const workflowId = args?.workflowId || activeWorkflowId
if (!workflowId) {
throw new Error('No workflow ID available')
}
const workflow = workflows[workflowId]
const workspaceId = workflow?.workspaceId
if (!workspaceId) {
throw new Error('Workflow workspace not found')
}
// Check if workflow is deployed
const deploymentStatus = useWorkflowRegistry
.getState()
.getWorkflowDeploymentStatus(workflowId)
if (!deploymentStatus?.isDeployed) {
throw new Error(
'Workflow must be deployed before adding as an MCP tool. Use deploy_api first.'
)
}
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 }> = {}
for (const param of args.parameterDescriptions) {
properties[param.name] = { description: param.description }
}
parameterSchema = { properties }
}
const res = await fetch(
`/api/mcp/workflow-servers/${args.serverId}/tools?workspaceId=${workspaceId}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflowId,
toolName: args.toolName?.trim(),
toolDescription: args.toolDescription?.trim(),
parameterSchema,
}),
}
)
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')
}
if (data.error?.includes('not deployed')) {
throw new Error('Workflow must be deployed before adding as an MCP tool')
}
if (data.error?.includes('Start block')) {
throw new Error('Workflow must have a Start block to be used as an MCP tool')
}
if (data.error?.includes('Server not found')) {
throw new Error(
'MCP server not found. Use list_workspace_mcp_servers to see available servers.'
)
}
throw new Error(data.error || `Failed to deploy to MCP (${res.status})`)
}
const tool = data.data?.tool
if (!tool) {
throw new Error('Response missing tool data')
}
this.setState(ClientToolCallState.success)
await this.markToolComplete(
200,
`Workflow deployed as MCP tool "${tool.toolName}" to server.`,
{
success: true,
toolId: tool.id,
toolName: tool.toolName,
toolDescription: tool.toolDescription,
serverId: args.serverId,
}
)
logger.info(`Deployed workflow as MCP tool: ${tool.toolName}`)
} catch (e: any) {
logger.error('Failed to deploy to MCP', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to deploy to MCP', {
success: false,
error: e?.message,
})
}
}
async execute(args?: DeployMcpArgs): Promise<void> {
await this.handleAccept(args)
}
}
// Register UI config at module load
registerToolUIConfig(DeployMcpClientTool.id, DeployMcpClientTool.metadata.uiConfig!)

View File

@@ -1,40 +1,43 @@
import { createLogger } from '@sim/logger'
import { Loader2, Rocket, XCircle } from 'lucide-react'
import { Loader2, Rocket, X, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { getInputFormatExample } from '@/lib/workflows/operations/deployment-utils'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface DeployApiArgs {
interface DeployWorkflowArgs {
action: 'deploy' | 'undeploy'
deployType?: 'api' | 'chat'
workflowId?: string
}
/**
* Deploy API tool for deploying workflows as REST APIs.
* This tool handles both deploying and undeploying workflows via the API endpoint.
*/
export class DeployApiClientTool extends BaseClientTool {
static readonly id = 'deploy_api'
interface ApiKeysData {
workspaceKeys: Array<{ id: string; name: string }>
personalKeys: Array<{ id: string; name: string }>
}
export class DeployWorkflowClientTool extends BaseClientTool {
static readonly id = 'deploy_workflow'
constructor(toolCallId: string) {
super(toolCallId, DeployApiClientTool.id, DeployApiClientTool.metadata)
super(toolCallId, DeployWorkflowClientTool.id, DeployWorkflowClientTool.metadata)
}
/**
* Override to provide dynamic button text based on action
* Override to provide dynamic button text based on action and deployType
*/
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
// Get params from the copilot store
const toolCallsById = useCopilotStore.getState().toolCallsById
const toolCall = toolCallsById[this.toolCallId]
const params = toolCall?.params as DeployApiArgs | undefined
const params = toolCall?.params as DeployWorkflowArgs | undefined
const action = params?.action || 'deploy'
const deployType = params?.deployType || 'api'
// Check if workflow is already deployed
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
@@ -42,10 +45,13 @@ export class DeployApiClientTool extends BaseClientTool {
? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed
: false
let buttonText = action === 'undeploy' ? 'Undeploy' : 'Deploy'
let buttonText = action.charAt(0).toUpperCase() + action.slice(1)
// Change to "Redeploy" if already deployed
if (action === 'deploy' && isAlreadyDeployed) {
buttonText = 'Redeploy'
} else if (action === 'deploy' && deployType === 'chat') {
buttonText = 'Deploy as chat'
}
return {
@@ -57,19 +63,19 @@ export class DeployApiClientTool extends BaseClientTool {
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Preparing to deploy API',
text: 'Preparing to deploy workflow',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Deploy as API?', icon: Rocket },
[ClientToolCallState.executing]: { text: 'Deploying API', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Deployed API', icon: Rocket },
[ClientToolCallState.error]: { text: 'Failed to deploy API', icon: XCircle },
[ClientToolCallState.pending]: { text: 'Deploy workflow?', icon: Rocket },
[ClientToolCallState.executing]: { text: 'Deploying workflow', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Deployed workflow', icon: Rocket },
[ClientToolCallState.error]: { text: 'Failed to deploy workflow', icon: X },
[ClientToolCallState.aborted]: {
text: 'Aborted deploying API',
text: 'Aborted deploying workflow',
icon: XCircle,
},
[ClientToolCallState.rejected]: {
text: 'Skipped deploying API',
text: 'Skipped deploying workflow',
icon: XCircle,
},
},
@@ -77,17 +83,9 @@ export class DeployApiClientTool extends BaseClientTool {
accept: { text: 'Deploy', icon: Rocket },
reject: { text: 'Skip', icon: XCircle },
},
uiConfig: {
isSpecial: true,
interrupt: {
accept: { text: 'Deploy', icon: Rocket },
reject: { text: 'Skip', icon: XCircle },
showAllowOnce: true,
showAllowAlways: true,
},
},
getDynamicText: (params, state) => {
const action = params?.action === 'undeploy' ? 'undeploy' : 'deploy'
const deployType = params?.deployType || 'api'
// Check if workflow is already deployed
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
@@ -95,32 +93,48 @@ export class DeployApiClientTool extends BaseClientTool {
? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed
: false
// Determine action text based on deployment status
let actionText = action
let actionTextIng = action === 'undeploy' ? 'undeploying' : 'deploying'
const actionTextPast = action === 'undeploy' ? 'undeployed' : 'deployed'
let actionTextPast = action === 'undeploy' ? 'undeployed' : 'deployed'
// If already deployed and action is deploy, change to redeploy
if (action === 'deploy' && isAlreadyDeployed) {
actionText = 'redeploy'
actionTextIng = 'redeploying'
actionTextPast = 'redeployed'
}
const actionCapitalized = actionText.charAt(0).toUpperCase() + actionText.slice(1)
// Special text for chat deployment
const isChatDeploy = action === 'deploy' && deployType === 'chat'
const displayAction = isChatDeploy ? 'deploy as chat' : actionText
const displayActionCapitalized = isChatDeploy ? 'Deploy as chat' : actionCapitalized
switch (state) {
case ClientToolCallState.success:
return `API ${actionTextPast}`
return isChatDeploy
? 'Opened chat deployment settings'
: `${actionCapitalized}ed workflow`
case ClientToolCallState.executing:
return `${actionCapitalized}ing API`
return isChatDeploy
? 'Opening chat deployment settings'
: `${actionCapitalized}ing workflow`
case ClientToolCallState.generating:
return `Preparing to ${actionText} API`
return `Preparing to ${displayAction} workflow`
case ClientToolCallState.pending:
return `${actionCapitalized} API?`
return `${displayActionCapitalized} workflow?`
case ClientToolCallState.error:
return `Failed to ${actionText} API`
return `Failed to ${displayAction} workflow`
case ClientToolCallState.aborted:
return `Aborted ${actionTextIng} API`
return isChatDeploy
? 'Aborted opening chat deployment'
: `Aborted ${actionTextIng} workflow`
case ClientToolCallState.rejected:
return `Skipped ${actionTextIng} API`
return isChatDeploy
? 'Skipped opening chat deployment'
: `Skipped ${actionTextIng} workflow`
}
return undefined
},
@@ -148,7 +162,7 @@ export class DeployApiClientTool extends BaseClientTool {
return workspaceKeys.length > 0 || personalKeys.length > 0
} catch (error) {
const logger = createLogger('DeployApiClientTool')
const logger = createLogger('DeployWorkflowClientTool')
logger.warn('Failed to check API keys:', error)
return false
}
@@ -161,15 +175,23 @@ export class DeployApiClientTool extends BaseClientTool {
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'apikeys' } }))
}
/**
* Opens the deploy modal to the chat tab
*/
private openDeployModal(tab: 'api' | 'chat' = 'api'): void {
window.dispatchEvent(new CustomEvent('open-deploy-modal', { detail: { tab } }))
}
async handleReject(): Promise<void> {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
async handleAccept(args?: DeployApiArgs): Promise<void> {
const logger = createLogger('DeployApiClientTool')
async handleAccept(args?: DeployWorkflowArgs): Promise<void> {
const logger = createLogger('DeployWorkflowClientTool')
try {
const action = args?.action || 'deploy'
const deployType = args?.deployType || 'api'
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
const workflowId = args?.workflowId || activeWorkflowId
@@ -180,6 +202,22 @@ export class DeployApiClientTool extends BaseClientTool {
const workflow = workflows[workflowId]
const workspaceId = workflow?.workspaceId
// For chat deployment, just open the deploy modal
if (action === 'deploy' && deployType === 'chat') {
this.setState(ClientToolCallState.success)
this.openDeployModal('chat')
await this.markToolComplete(
200,
'Opened chat deployment settings. Configure and deploy your workflow as a chat interface.',
{
action,
deployType,
openedModal: true,
}
)
return
}
// For deploy action, check if user has API keys first
if (action === 'deploy') {
if (!workspaceId) {
@@ -189,7 +227,10 @@ export class DeployApiClientTool extends BaseClientTool {
const hasKeys = await this.hasApiKeys(workspaceId)
if (!hasKeys) {
// Mark as rejected since we can't deploy without an API key
this.setState(ClientToolCallState.rejected)
// Open the API keys modal to help user create one
this.openApiKeysModal()
await this.markToolComplete(
@@ -207,6 +248,7 @@ export class DeployApiClientTool extends BaseClientTool {
this.setState(ClientToolCallState.executing)
// Perform the deploy/undeploy action
const endpoint = `/api/workflows/${workflowId}/deploy`
const method = action === 'deploy' ? 'POST' : 'DELETE'
@@ -231,21 +273,25 @@ export class DeployApiClientTool extends BaseClientTool {
}
if (action === 'deploy') {
// Generate the curl command for the deployed workflow (matching deploy modal format)
const appUrl =
typeof window !== 'undefined'
? window.location.origin
: process.env.NEXT_PUBLIC_APP_URL || 'https://app.sim.ai'
const apiEndpoint = `${appUrl}/api/workflows/${workflowId}/execute`
const endpoint = `${appUrl}/api/workflows/${workflowId}/execute`
const apiKeyPlaceholder = '$SIM_API_KEY'
// Get input format example (returns empty string if no inputs, or -d flag with example data)
const inputExample = getInputFormatExample(false)
const curlCommand = `curl -X POST -H "X-API-Key: ${apiKeyPlaceholder}" -H "Content-Type: application/json"${inputExample} ${apiEndpoint}`
successMessage = 'Workflow deployed successfully as API. You can now call it via REST.'
// Match the exact format from deploy modal
const curlCommand = `curl -X POST -H "X-API-Key: ${apiKeyPlaceholder}" -H "Content-Type: application/json"${inputExample} ${endpoint}`
successMessage = 'Workflow deployed successfully. You can now call it via the API.'
resultData = {
...resultData,
endpoint: apiEndpoint,
endpoint,
curlCommand,
apiKeyPlaceholder,
}
@@ -270,21 +316,18 @@ export class DeployApiClientTool extends BaseClientTool {
setDeploymentStatus(workflowId, false, undefined, '')
}
const actionPast = action === 'undeploy' ? 'undeployed' : 'deployed'
logger.info(`Workflow ${actionPast} as API and registry updated`)
logger.info(`Workflow ${actionPast} and registry updated`)
} catch (error) {
logger.warn('Failed to update workflow registry:', error)
}
} catch (e: any) {
logger.error('Deploy API failed', { message: e?.message })
logger.error('Deploy/undeploy failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to deploy API')
await this.markToolComplete(500, e?.message || 'Failed to deploy/undeploy workflow')
}
}
async execute(args?: DeployApiArgs): Promise<void> {
async execute(args?: DeployWorkflowArgs): Promise<void> {
await this.handleAccept(args)
}
}
// Register UI config at module load
registerToolUIConfig(DeployApiClientTool.id, DeployApiClientTool.metadata.uiConfig!)

View File

@@ -5,7 +5,6 @@ import {
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
import { stripWorkflowDiffMarkers } from '@/lib/workflows/diff'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
@@ -125,10 +124,6 @@ export class EditWorkflowClientTool extends BaseClientTool {
[ClientToolCallState.aborted]: { text: 'Aborted editing your workflow', icon: MinusCircle },
[ClientToolCallState.pending]: { text: 'Editing your workflow', icon: Loader2 },
},
uiConfig: {
isSpecial: true,
customRenderer: 'edit_summary',
},
getDynamicText: (params, state) => {
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
if (workflowId) {
@@ -417,6 +412,3 @@ export class EditWorkflowClientTool extends BaseClientTool {
})
}
}
// Register UI config at module load
registerToolUIConfig(EditWorkflowClientTool.id, EditWorkflowClientTool.metadata.uiConfig!)

View File

@@ -16,6 +16,7 @@ import {
GetBlockOutputsResult,
type GetBlockOutputsResultType,
} from '@/lib/copilot/tools/shared/schemas'
import { normalizeName } from '@/executor/constants'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -89,6 +90,10 @@ export class GetBlockOutputsClientTool extends BaseClientTool {
if (!block?.type) continue
const blockName = block.name || block.type
const normalizedBlockName = normalizeName(blockName)
let insideSubflowOutputs: string[] | undefined
let outsideSubflowOutputs: string[] | undefined
const blockOutput: GetBlockOutputsResultType['blocks'][0] = {
blockId,
@@ -97,11 +102,6 @@ export class GetBlockOutputsClientTool extends BaseClientTool {
outputs: [],
}
// Include triggerMode if the block is in trigger mode
if (block.triggerMode) {
blockOutput.triggerMode = true
}
if (block.type === 'loop' || block.type === 'parallel') {
const insidePaths = getSubflowInsidePaths(block.type, blockId, loops, parallels)
blockOutput.insideSubflowOutputs = formatOutputsWithPrefix(insidePaths, blockName)

View File

@@ -193,11 +193,6 @@ export class GetBlockUpstreamReferencesClientTool extends BaseClientTool {
outputs: formattedOutputs,
}
// Include triggerMode if the block is in trigger mode
if (block.triggerMode) {
entry.triggerMode = true
}
if (accessContext) entry.accessContext = accessContext
accessibleBlocks.push(entry)
}

View File

@@ -29,7 +29,7 @@ export class GetWorkflowDataClientTool extends BaseClientTool {
[ClientToolCallState.pending]: { text: 'Fetching workflow data', icon: Database },
[ClientToolCallState.executing]: { text: 'Fetching workflow data', icon: Loader2 },
[ClientToolCallState.aborted]: { text: 'Aborted fetching data', icon: XCircle },
[ClientToolCallState.success]: { text: 'Retrieved workflow data', icon: Database },
[ClientToolCallState.success]: { text: 'Workflow data retrieved', icon: Database },
[ClientToolCallState.error]: { text: 'Failed to fetch data', icon: X },
[ClientToolCallState.rejected]: { text: 'Skipped fetching data', icon: XCircle },
},

View File

@@ -1,112 +0,0 @@
import { createLogger } from '@sim/logger'
import { Loader2, Server, XCircle } from 'lucide-react'
import {
BaseClientTool,
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface ListWorkspaceMcpServersArgs {
workspaceId?: string
}
export interface WorkspaceMcpServer {
id: string
name: string
description: string | null
toolCount: number
toolNames: string[]
}
/**
* List workspace MCP servers tool.
* Returns a list of MCP servers available in the workspace that workflows can be deployed to.
*/
export class ListWorkspaceMcpServersClientTool extends BaseClientTool {
static readonly id = 'list_workspace_mcp_servers'
constructor(toolCallId: string) {
super(
toolCallId,
ListWorkspaceMcpServersClientTool.id,
ListWorkspaceMcpServersClientTool.metadata
)
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Getting MCP servers',
icon: Loader2,
},
[ClientToolCallState.pending]: { text: 'Getting MCP servers', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Getting MCP servers', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Retrieved MCP servers', icon: Server },
[ClientToolCallState.error]: { text: 'Failed to get MCP servers', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted getting MCP servers', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped getting MCP servers', icon: XCircle },
},
interrupt: undefined,
}
async execute(args?: ListWorkspaceMcpServersArgs): Promise<void> {
const logger = createLogger('ListWorkspaceMcpServersClientTool')
try {
this.setState(ClientToolCallState.executing)
// Get workspace ID from active workflow if not provided
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
let workspaceId = args?.workspaceId
if (!workspaceId && activeWorkflowId) {
workspaceId = workflows[activeWorkflowId]?.workspaceId
}
if (!workspaceId) {
throw new Error('No workspace ID available')
}
const res = await fetch(`/api/mcp/workflow-servers?workspaceId=${workspaceId}`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || `Failed to fetch MCP servers (${res.status})`)
}
const data = await res.json()
const servers: WorkspaceMcpServer[] = (data.data?.servers || []).map((s: any) => ({
id: s.id,
name: s.name,
description: s.description,
toolCount: s.toolCount || 0,
toolNames: s.toolNames || [],
}))
this.setState(ClientToolCallState.success)
if (servers.length === 0) {
await this.markToolComplete(
200,
'No MCP servers found in this workspace. Use create_workspace_mcp_server to create one.',
{ servers: [], count: 0 }
)
} else {
await this.markToolComplete(
200,
`Found ${servers.length} MCP server(s) in the workspace.`,
{
servers,
count: servers.length,
}
)
}
logger.info(`Listed ${servers.length} MCP servers`)
} catch (e: any) {
logger.error('Failed to list MCP servers', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to list MCP servers')
}
}
}

View File

@@ -24,7 +24,7 @@ interface CustomToolSchema {
}
interface ManageCustomToolArgs {
operation: 'add' | 'edit' | 'delete' | 'list'
operation: 'add' | 'edit' | 'delete'
toolId?: string
schema?: CustomToolSchema
code?: string
@@ -81,7 +81,7 @@ export class ManageCustomToolClientTool extends BaseClientTool {
reject: { text: 'Skip', icon: XCircle },
},
getDynamicText: (params, state) => {
const operation = params?.operation as 'add' | 'edit' | 'delete' | 'list' | undefined
const operation = params?.operation as 'add' | 'edit' | 'delete' | undefined
// Return undefined if no operation yet - use static defaults
if (!operation) return undefined
@@ -105,30 +105,19 @@ export class ManageCustomToolClientTool extends BaseClientTool {
return verb === 'present' ? 'Edit' : verb === 'past' ? 'Edited' : 'Editing'
case 'delete':
return verb === 'present' ? 'Delete' : verb === 'past' ? 'Deleted' : 'Deleting'
case 'list':
return verb === 'present' ? 'List' : verb === 'past' ? 'Listed' : 'Listing'
default:
return verb === 'present' ? 'Manage' : verb === 'past' ? 'Managed' : 'Managing'
}
}
// For add: only show tool name in past tense (success)
// For edit/delete: always show tool name
// For list: never show individual tool name, use plural
const shouldShowToolName = (currentState: ClientToolCallState) => {
if (operation === 'list') return false
if (operation === 'add') {
return currentState === ClientToolCallState.success
}
return true // edit and delete always show tool name
}
const nameText =
operation === 'list'
? ' custom tools'
: shouldShowToolName(state) && toolName
? ` ${toolName}`
: ' custom tool'
const nameText = shouldShowToolName(state) && toolName ? ` ${toolName}` : ' custom tool'
switch (state) {
case ClientToolCallState.success:
@@ -199,16 +188,16 @@ export class ManageCustomToolClientTool extends BaseClientTool {
async execute(args?: ManageCustomToolArgs): Promise<void> {
this.currentArgs = args
// For add and list operations, execute directly without confirmation
// For add operation, execute directly without confirmation
// For edit/delete, the copilot store will check hasInterrupt() and wait for confirmation
if (args?.operation === 'add' || args?.operation === 'list') {
if (args?.operation === 'add') {
await this.handleAccept(args)
}
// edit/delete will wait for user confirmation via handleAccept
}
/**
* Executes the custom tool operation (add, edit, delete, or list)
* Executes the custom tool operation (add, edit, or delete)
*/
private async executeOperation(
args: ManageCustomToolArgs | undefined,
@@ -246,10 +235,6 @@ export class ManageCustomToolClientTool extends BaseClientTool {
case 'delete':
await this.deleteCustomTool({ toolId, workspaceId }, logger)
break
case 'list':
// List operation is read-only, just mark as complete
await this.markToolComplete(200, 'Listed custom tools')
break
default:
throw new Error(`Unknown operation: ${operation}`)
}

View File

@@ -7,7 +7,6 @@ import {
ClientToolCallState,
WORKFLOW_EXECUTION_TIMEOUT_MS,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useExecutionStore } from '@/stores/execution'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -30,9 +29,9 @@ export class RunWorkflowClientTool extends BaseClientTool {
[ClientToolCallState.generating]: { text: 'Preparing to run your workflow', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Run this workflow?', icon: Play },
[ClientToolCallState.executing]: { text: 'Running your workflow', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Executed workflow', icon: Play },
[ClientToolCallState.success]: { text: 'Workflow executed', icon: Play },
[ClientToolCallState.error]: { text: 'Errored running workflow', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped workflow execution', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Workflow execution skipped', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted workflow execution', icon: MinusCircle },
[ClientToolCallState.background]: { text: 'Running in background', icon: Play },
},
@@ -40,49 +39,6 @@ export class RunWorkflowClientTool extends BaseClientTool {
accept: { text: 'Run', icon: Play },
reject: { text: 'Skip', icon: MinusCircle },
},
uiConfig: {
isSpecial: true,
interrupt: {
accept: { text: 'Run', icon: Play },
reject: { text: 'Skip', icon: MinusCircle },
showAllowOnce: true,
showAllowAlways: true,
},
secondaryAction: {
text: 'Move to Background',
title: 'Move to Background',
variant: 'tertiary',
showInStates: [ClientToolCallState.executing],
completionMessage:
'The user has chosen to move the workflow execution to the background. Check back with them later to know when the workflow execution is complete',
targetState: ClientToolCallState.background,
},
paramsTable: {
columns: [
{ key: 'input', label: 'Input', width: '36%' },
{ key: 'value', label: 'Value', width: '64%', editable: true, mono: true },
],
extractRows: (params) => {
let inputs = params.input || params.inputs || params.workflow_input
if (typeof inputs === 'string') {
try {
inputs = JSON.parse(inputs)
} catch {
inputs = {}
}
}
if (params.workflow_input && typeof params.workflow_input === 'object') {
inputs = params.workflow_input
}
if (!inputs || typeof inputs !== 'object') {
const { workflowId, workflow_input, ...rest } = params
inputs = rest
}
const safeInputs = inputs && typeof inputs === 'object' ? inputs : {}
return Object.entries(safeInputs).map(([key, value]) => [key, key, String(value)])
},
},
},
getDynamicText: (params, state) => {
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
if (workflowId) {
@@ -226,6 +182,3 @@ export class RunWorkflowClientTool extends BaseClientTool {
await this.handleAccept(args)
}
}
// Register UI config at module load
registerToolUIConfig(RunWorkflowClientTool.id, RunWorkflowClientTool.metadata.uiConfig!)

View File

@@ -5,7 +5,6 @@ import {
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -40,7 +39,7 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
},
[ClientToolCallState.pending]: { text: 'Set workflow variables?', icon: Settings2 },
[ClientToolCallState.executing]: { text: 'Setting workflow variables', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Updated workflow variables', icon: Settings2 },
[ClientToolCallState.success]: { text: 'Workflow variables updated', icon: Settings2 },
[ClientToolCallState.error]: { text: 'Failed to set workflow variables', icon: X },
[ClientToolCallState.aborted]: { text: 'Aborted setting variables', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped setting variables', icon: XCircle },
@@ -49,28 +48,6 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
accept: { text: 'Apply', icon: Settings2 },
reject: { text: 'Skip', icon: XCircle },
},
uiConfig: {
interrupt: {
accept: { text: 'Apply', icon: Settings2 },
reject: { text: 'Skip', icon: XCircle },
showAllowOnce: true,
showAllowAlways: true,
},
paramsTable: {
columns: [
{ key: 'name', label: 'Name', width: '40%', editable: true, mono: true },
{ key: 'value', label: 'Value', width: '60%', editable: true, mono: true },
],
extractRows: (params) => {
const operations = params.operations || []
return operations.map((op: any, idx: number) => [
String(idx),
op.name || '',
String(op.value ?? ''),
])
},
},
},
getDynamicText: (params, state) => {
if (params?.operations && Array.isArray(params.operations)) {
const varNames = params.operations
@@ -266,9 +243,3 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
await this.handleAccept(args)
}
}
// Register UI config at module load
registerToolUIConfig(
SetGlobalWorkflowVariablesClientTool.id,
SetGlobalWorkflowVariablesClientTool.metadata.uiConfig!
)

View File

@@ -10,7 +10,6 @@ import type { SubBlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { tools as toolsRegistry } from '@/tools/registry'
import { getTrigger, isTriggerValid } from '@/triggers'
interface InputFieldSchema {
type: string
@@ -108,12 +107,11 @@ function resolveSubBlockOptions(sb: SubBlockConfig): string[] | undefined {
return undefined
}
// Return the actual option ID/value that edit_workflow expects, not the display label
return rawOptions
.map((opt: any) => {
if (!opt) return undefined
if (typeof opt === 'object') {
return opt.id || opt.label // Prefer id (actual value) over label (display name)
return opt.label || opt.id
}
return String(opt)
})
@@ -147,20 +145,13 @@ function matchesOperation(condition: any, operation: string): boolean {
*/
function extractInputsFromSubBlocks(
subBlocks: SubBlockConfig[],
operation?: string,
triggerMode?: boolean
operation?: string
): Record<string, InputFieldSchema> {
const inputs: Record<string, InputFieldSchema> = {}
for (const sb of subBlocks) {
// Handle trigger vs non-trigger mode filtering
if (triggerMode) {
// In trigger mode, only include subBlocks with mode: 'trigger'
if (sb.mode !== 'trigger') continue
} else {
// In non-trigger mode, skip trigger-mode subBlocks
if (sb.mode === 'trigger') continue
}
// Skip trigger-mode subBlocks
if (sb.mode === 'trigger') continue
// Skip hidden subBlocks
if (sb.hidden) continue
@@ -256,53 +247,12 @@ function mapSubBlockTypeToSchemaType(type: string): string {
return typeMap[type] || 'string'
}
/**
* Extracts trigger outputs from the first available trigger
*/
function extractTriggerOutputs(blockConfig: any): Record<string, OutputFieldSchema> {
const outputs: Record<string, OutputFieldSchema> = {}
if (!blockConfig.triggers?.enabled || !blockConfig.triggers?.available?.length) {
return outputs
}
// Get the first available trigger's outputs as a baseline
const triggerId = blockConfig.triggers.available[0]
if (triggerId && isTriggerValid(triggerId)) {
const trigger = getTrigger(triggerId)
if (trigger.outputs) {
for (const [key, def] of Object.entries(trigger.outputs)) {
if (typeof def === 'string') {
outputs[key] = { type: def }
} else if (typeof def === 'object' && def !== null) {
const typedDef = def as { type?: string; description?: string }
outputs[key] = {
type: typedDef.type || 'any',
description: typedDef.description,
}
}
}
}
}
return outputs
}
/**
* Extracts output schema from block config or tool
*/
function extractOutputs(
blockConfig: any,
operation?: string,
triggerMode?: boolean
): Record<string, OutputFieldSchema> {
function extractOutputs(blockConfig: any, operation?: string): Record<string, OutputFieldSchema> {
const outputs: Record<string, OutputFieldSchema> = {}
// In trigger mode, return trigger outputs
if (triggerMode && blockConfig.triggers?.enabled) {
return extractTriggerOutputs(blockConfig)
}
// If operation is specified, try to get outputs from the specific tool
if (operation) {
try {
@@ -350,16 +300,16 @@ export const getBlockConfigServerTool: BaseServerTool<
> = {
name: 'get_block_config',
async execute(
{ blockType, operation, trigger }: GetBlockConfigInputType,
{ blockType, operation }: GetBlockConfigInputType,
context?: { userId: string }
): Promise<GetBlockConfigResultType> {
const logger = createLogger('GetBlockConfigServerTool')
logger.debug('Executing get_block_config', { blockType, operation, trigger })
logger.debug('Executing get_block_config', { blockType, operation })
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
const allowedIntegrations = permissionConfig?.allowedIntegrations
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) {
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) {
throw new Error(`Block "${blockType}" is not available`)
}
@@ -368,13 +318,6 @@ export const getBlockConfigServerTool: BaseServerTool<
throw new Error(`Block not found: ${blockType}`)
}
// Validate trigger mode is supported for this block
if (trigger && !blockConfig.triggers?.enabled && !blockConfig.triggerAllowed) {
throw new Error(
`Block "${blockType}" does not support trigger mode. Only blocks with triggers.enabled or triggerAllowed can be used in trigger mode.`
)
}
// If operation is specified, validate it exists
if (operation) {
const operationSubBlock = blockConfig.subBlocks?.find((sb) => sb.id === 'operation')
@@ -391,14 +334,13 @@ export const getBlockConfigServerTool: BaseServerTool<
}
const subBlocks = Array.isArray(blockConfig.subBlocks) ? blockConfig.subBlocks : []
const inputs = extractInputsFromSubBlocks(subBlocks, operation, trigger)
const outputs = extractOutputs(blockConfig, operation, trigger)
const inputs = extractInputsFromSubBlocks(subBlocks, operation)
const outputs = extractOutputs(blockConfig, operation)
const result = {
blockType,
blockName: blockConfig.name,
operation,
trigger,
inputs,
outputs,
}

View File

@@ -24,7 +24,7 @@ export const getBlockOptionsServerTool: BaseServerTool<
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
const allowedIntegrations = permissionConfig?.allowedIntegrations
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) {
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockId)) {
throw new Error(`Block "${blockId}" is not available`)
}

View File

@@ -31,7 +31,7 @@ export const getBlocksAndToolsServerTool: BaseServerTool<
Object.entries(blockRegistry)
.filter(([blockType, blockConfig]: [string, BlockConfig]) => {
if (blockConfig.hideFromToolbar) return false
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) return false
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) return false
return true
})
.forEach(([blockType, blockConfig]: [string, BlockConfig]) => {

View File

@@ -118,7 +118,7 @@ export const getBlocksMetadataServerTool: BaseServerTool<
const result: Record<string, CopilotBlockMetadata> = {}
for (const blockId of blockIds || []) {
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) {
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockId)) {
logger.debug('Block not allowed by permission group', { blockId })
continue
}
@@ -408,8 +408,11 @@ function extractInputs(metadata: CopilotBlockMetadata): {
}
if (schema.options && schema.options.length > 0) {
// Always return the id (actual value to use), not the display label
input.options = schema.options.map((opt) => opt.id || opt.label)
if (schema.id === 'operation') {
input.options = schema.options.map((opt) => opt.id)
} else {
input.options = schema.options.map((opt) => opt.label || opt.id)
}
}
if (inputDef?.enum && Array.isArray(inputDef.enum)) {

View File

@@ -26,7 +26,7 @@ export const getTriggerBlocksServerTool: BaseServerTool<
Object.entries(blockRegistry).forEach(([blockType, blockConfig]: [string, BlockConfig]) => {
if (blockConfig.hideFromToolbar) return
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) return
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) return
if (blockConfig.category === 'triggers') {
triggerBlockIds.push(blockType)

View File

@@ -11,7 +11,6 @@ import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { isValidKey } from '@/lib/workflows/sanitization/key-validation'
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getAllBlocks, getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { EDGE, normalizeName } from '@/executor/constants'
@@ -63,8 +62,6 @@ type SkippedItemType =
| 'invalid_subflow_parent'
| 'nested_subflow_not_allowed'
| 'duplicate_block_name'
| 'duplicate_trigger'
| 'duplicate_single_instance_block'
/**
* Represents an item that was skipped during operation application
@@ -1778,34 +1775,6 @@ function applyOperationsToWorkflowState(
break
}
const triggerIssue = TriggerUtils.getTriggerAdditionIssue(modifiedState.blocks, params.type)
if (triggerIssue) {
logSkippedItem(skippedItems, {
type: 'duplicate_trigger',
operationType: 'add',
blockId: block_id,
reason: `Cannot add ${triggerIssue.triggerName} - a workflow can only have one`,
details: { requestedType: params.type, issue: triggerIssue.issue },
})
break
}
// Check single-instance block constraints (e.g., Response block)
const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue(
modifiedState.blocks,
params.type
)
if (singleInstanceIssue) {
logSkippedItem(skippedItems, {
type: 'duplicate_single_instance_block',
operationType: 'add',
blockId: block_id,
reason: `Cannot add ${singleInstanceIssue.blockName} - a workflow can only have one`,
details: { requestedType: params.type },
})
break
}
// Create new block with proper structure
const newBlock = createBlockFromParams(
block_id,

View File

@@ -57,13 +57,11 @@ export type GetBlockOptionsResultType = z.infer<typeof GetBlockOptionsResult>
export const GetBlockConfigInput = z.object({
blockType: z.string(),
operation: z.string().optional(),
trigger: z.boolean().optional(),
})
export const GetBlockConfigResult = z.object({
blockType: z.string(),
blockName: z.string(),
operation: z.string().optional(),
trigger: z.boolean().optional(),
inputs: z.record(z.any()),
outputs: z.record(z.any()),
})
@@ -116,7 +114,6 @@ export const GetBlockOutputsResult = z.object({
blockId: z.string(),
blockName: z.string(),
blockType: z.string(),
triggerMode: z.boolean().optional(),
outputs: z.array(z.string()),
insideSubflowOutputs: z.array(z.string()).optional(),
outsideSubflowOutputs: z.array(z.string()).optional(),
@@ -158,7 +155,6 @@ export const GetBlockUpstreamReferencesResult = z.object({
blockId: z.string(),
blockName: z.string(),
blockType: z.string(),
triggerMode: z.boolean().optional(),
outputs: z.array(z.string()),
accessContext: z.enum(['inside', 'outside']).optional(),
})

View File

@@ -592,34 +592,4 @@ export class TriggerUtils {
const parentWithType = parent as T & { type?: string }
return parentWithType.type === 'loop' || parentWithType.type === 'parallel'
}
static isSingleInstanceBlockType(blockType: string): boolean {
const blockConfig = getBlock(blockType)
return blockConfig?.singleInstance === true
}
static wouldViolateSingleInstanceBlock<T extends { type: string }>(
blocks: T[] | Record<string, T>,
blockType: string
): boolean {
if (!TriggerUtils.isSingleInstanceBlockType(blockType)) {
return false
}
const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks)
return blockArray.some((block) => block.type === blockType)
}
static getSingleInstanceBlockIssue<T extends { type: string }>(
blocks: T[] | Record<string, T>,
blockType: string
): { issue: 'duplicate'; blockName: string } | null {
if (!TriggerUtils.wouldViolateSingleInstanceBlock(blocks, blockType)) {
return null
}
const blockConfig = getBlock(blockType)
const blockName = blockConfig?.name || blockType
return { issue: 'duplicate', blockName }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,30 +2,12 @@ import type { ClientToolCallState, ClientToolDisplay } from '@/lib/copilot/tools
export type ToolState = ClientToolCallState
/**
* Subagent content block for nested thinking/reasoning inside a tool call
*/
export interface SubAgentContentBlock {
type: 'subagent_text' | 'subagent_tool_call'
content?: string
toolCall?: CopilotToolCall
timestamp: number
}
export interface CopilotToolCall {
id: string
name: string
state: ClientToolCallState
params?: Record<string, any>
display?: ClientToolDisplay
/** Content streamed from a subagent (e.g., debug agent) */
subAgentContent?: string
/** Tool calls made by the subagent */
subAgentToolCalls?: CopilotToolCall[]
/** Structured content blocks for subagent (thinking + tool calls in order) */
subAgentBlocks?: SubAgentContentBlock[]
/** Whether subagent is currently streaming */
subAgentStreaming?: boolean
}
export interface MessageFileAttachment {
@@ -60,18 +42,6 @@ export interface CopilotMessage {
errorType?: 'usage_limit' | 'unauthorized' | 'forbidden' | 'rate_limit' | 'upgrade_required'
}
/**
* A message queued for sending while another message is in progress.
* Like Cursor's queued message feature.
*/
export interface QueuedMessage {
id: string
content: string
fileAttachments?: MessageFileAttachment[]
contexts?: ChatContext[]
queuedAt: number
}
// Contexts attached to a user message
export type ChatContext =
| { kind: 'past_chat'; chatId: string; label: string }
@@ -161,11 +131,18 @@ export interface CopilotState {
// Per-message metadata captured at send-time for reliable stats
// Context usage tracking for percentage pill
contextUsage: {
usage: number
percentage: number
model: string
contextWindow: number
when: 'start' | 'end'
estimatedTokens?: number
} | null
// Auto-allowed integration tools (tools that can run without confirmation)
autoAllowedTools: string[]
// Message queue for messages sent while another is in progress
messageQueue: QueuedMessage[]
}
export interface CopilotActions {
@@ -173,6 +150,7 @@ export interface CopilotActions {
setSelectedModel: (model: CopilotStore['selectedModel']) => Promise<void>
setAgentPrefetch: (prefetch: boolean) => void
setEnabledModels: (models: string[] | null) => void
fetchContextUsage: () => Promise<void>
setWorkflowId: (workflowId: string | null) => Promise<void>
validateCurrentChat: () => boolean
@@ -242,19 +220,6 @@ export interface CopilotActions {
addAutoAllowedTool: (toolId: string) => Promise<void>
removeAutoAllowedTool: (toolId: string) => Promise<void>
isToolAutoAllowed: (toolId: string) => boolean
// Message queue actions
addToQueue: (
message: string,
options?: {
fileAttachments?: MessageFileAttachment[]
contexts?: ChatContext[]
}
) => void
removeFromQueue: (id: string) => void
moveUpInQueue: (id: string) => void
sendNow: (id: string) => Promise<void>
clearQueue: () => void
}
export type CopilotStore = CopilotState & CopilotActions

View File

@@ -29,10 +29,6 @@ export const usePanelStore = create<PanelState>()(
document.documentElement.removeAttribute('data-panel-active-tab')
}
},
isResizing: false,
setIsResizing: (isResizing) => {
set({ isResizing })
},
_hasHydrated: false,
setHasHydrated: (hasHydrated) => {
set({ _hasHydrated: hasHydrated })

View File

@@ -11,10 +11,6 @@ export interface PanelState {
setPanelWidth: (width: number) => void
activeTab: PanelTab
setActiveTab: (tab: PanelTab) => void
/** Whether the panel is currently being resized */
isResizing: boolean
/** Updates the panel resize state */
setIsResizing: (isResizing: boolean) => void
_hasHydrated: boolean
setHasHydrated: (hasHydrated: boolean) => void
}

View File

@@ -9,7 +9,6 @@ export const useSidebarStore = create<SidebarState>()(
workspaceDropdownOpen: false,
sidebarWidth: SIDEBAR_WIDTH.DEFAULT,
isCollapsed: false,
isResizing: false,
_hasHydrated: false,
setWorkspaceDropdownOpen: (isOpen) => set({ workspaceDropdownOpen: isOpen }),
setSidebarWidth: (width) => {
@@ -32,9 +31,6 @@ export const useSidebarStore = create<SidebarState>()(
document.documentElement.style.setProperty('--sidebar-width', `${currentWidth}px`)
}
},
setIsResizing: (isResizing) => {
set({ isResizing })
},
setHasHydrated: (hasHydrated) => set({ _hasHydrated: hasHydrated }),
}),
{

View File

@@ -5,13 +5,9 @@ export interface SidebarState {
workspaceDropdownOpen: boolean
sidebarWidth: number
isCollapsed: boolean
/** Whether the sidebar is currently being resized */
isResizing: boolean
_hasHydrated: boolean
setWorkspaceDropdownOpen: (isOpen: boolean) => void
setSidebarWidth: (width: number) => void
setIsCollapsed: (isCollapsed: boolean) => void
/** Updates the sidebar resize state */
setIsResizing: (isResizing: boolean) => void
setHasHydrated: (hasHydrated: boolean) => void
}

View File

@@ -273,7 +273,6 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
)
}
// Background operations (fire-and-forget) - don't block
if (triggerMessageId) {
fetch('/api/copilot/stats', {
method: 'POST',
@@ -286,15 +285,14 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
}).catch(() => {})
}
findLatestEditWorkflowToolCallId().then((toolCallId) => {
if (toolCallId) {
getClientTool(toolCallId)
?.handleAccept?.()
?.catch?.((error: Error) => {
logger.warn('Failed to notify tool accept state', { error })
})
const toolCallId = await findLatestEditWorkflowToolCallId()
if (toolCallId) {
try {
await getClientTool(toolCallId)?.handleAccept?.()
} catch (error) {
logger.warn('Failed to notify tool accept state', { error })
}
})
}
},
rejectChanges: async () => {
@@ -329,26 +327,27 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
})
const afterReject = cloneWorkflowState(baselineWorkflow)
// Clear diff state FIRST for instant UI feedback
set({
hasActiveDiff: false,
isShowingDiff: false,
isDiffReady: false,
baselineWorkflow: null,
baselineWorkflowId: null,
diffAnalysis: null,
diffMetadata: null,
diffError: null,
_triggerMessageId: null,
})
// Clear the diff engine
diffEngine.clearDiff()
// Apply baseline state locally
applyWorkflowStateToStores(baselineWorkflowId, baselineWorkflow)
// Emit event for undo/redo recording synchronously
// Broadcast to other users
logger.info('Broadcasting reject to other users', {
workflowId: activeWorkflowId,
blockCount: Object.keys(baselineWorkflow.blocks).length,
})
await enqueueReplaceWorkflowState({
workflowId: activeWorkflowId,
state: baselineWorkflow,
})
// Persist to database
const persisted = await persistWorkflowStateToServer(baselineWorkflowId, baselineWorkflow)
if (!persisted) {
throw new Error('Failed to restore baseline workflow state')
}
// Emit event for undo/redo recording
if (!(window as any).__skipDiffRecording) {
window.dispatchEvent(
new CustomEvent('record-diff-operation', {
@@ -363,25 +362,6 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
)
}
// Background operations (fire-and-forget) - don't block UI
// Broadcast to other users
logger.info('Broadcasting reject to other users', {
workflowId: activeWorkflowId,
blockCount: Object.keys(baselineWorkflow.blocks).length,
})
enqueueReplaceWorkflowState({
workflowId: activeWorkflowId,
state: baselineWorkflow,
}).catch((error) => {
logger.error('Failed to broadcast reject to other users:', error)
})
// Persist to database in background
persistWorkflowStateToServer(baselineWorkflowId, baselineWorkflow).catch((error) => {
logger.error('Failed to persist baseline workflow state:', error)
})
if (_triggerMessageId) {
fetch('/api/copilot/stats', {
method: 'POST',
@@ -394,15 +374,16 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
}).catch(() => {})
}
findLatestEditWorkflowToolCallId().then((toolCallId) => {
if (toolCallId) {
getClientTool(toolCallId)
?.handleReject?.()
?.catch?.((error: Error) => {
logger.warn('Failed to notify tool reject state', { error })
})
const toolCallId = await findLatestEditWorkflowToolCallId()
if (toolCallId) {
try {
await getClientTool(toolCallId)?.handleReject?.()
} catch (error) {
logger.warn('Failed to notify tool reject state', { error })
}
})
}
get().clearDiff({ restoreBaseline: false })
},
reapplyDiffMarkers: () => {

View File

@@ -41,10 +41,6 @@ export function getUniqueBlockName(baseName: string, existingBlocks: Record<stri
return 'Start'
}
if (normalizedBaseName === 'response') {
return 'Response'
}
const baseNameMatch = baseName.match(/^(.*?)(\s+\d+)?$/)
const namePrefix = baseNameMatch ? baseNameMatch[1].trim() : baseName