Compare commits

..

1 Commits

Author SHA1 Message Date
Vikhyath Mondreti
eafec4fad9 fix(perms): copilot checks undefined issue 2026-01-10 11:19:44 -08:00
79 changed files with 1270 additions and 5602 deletions

View File

@@ -2,6 +2,7 @@
title: Router
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
import { Image } from '@/components/ui/image'
@@ -101,18 +102,11 @@ Input (Lead) → Router
└── [Self-serve] → Workflow (Automated Onboarding)
```
## Error Handling
When the Router cannot determine an appropriate route for the given context, it will route to the **error path** instead of arbitrarily selecting a route. This happens when:
- The context doesn't clearly match any of the defined route descriptions
- The AI determines that none of the available routes are appropriate
## Best Practices
- **Write clear route descriptions**: Each route description should clearly explain when that route should be selected. Be specific about the criteria.
- **Make routes mutually exclusive**: When possible, ensure route descriptions don't overlap to prevent ambiguous routing decisions.
- **Connect an error path**: Handle cases where no route matches by connecting an error handler for graceful fallback behavior.
- **Include an error/fallback route**: Add a catch-all route for unexpected inputs that don't match other routes.
- **Use descriptive route titles**: Route titles appear in the workflow canvas, so make them meaningful for readability.
- **Test with diverse inputs**: Ensure the Router handles various input types, edge cases, and unexpected content.
- **Monitor routing performance**: Review routing decisions regularly and refine route descriptions based on actual usage patterns.

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

@@ -654,20 +654,17 @@ export function ConditionInput({
}
const removeBlock = (id: string) => {
if (isPreview || disabled) return
// Condition mode requires at least 2 blocks (if/else), router mode requires at least 1
const minBlocks = isRouterMode ? 1 : 2
if (conditionalBlocks.length <= minBlocks) return
if (isPreview || disabled || conditionalBlocks.length <= 2) return
// Remove any associated edges before removing the block
const handlePrefix = isRouterMode ? `router-${id}` : `condition-${id}`
const edgeIdsToRemove = edges
.filter((edge) => edge.sourceHandle?.startsWith(handlePrefix))
.filter((edge) => edge.sourceHandle?.startsWith(`condition-${id}`))
.map((edge) => edge.id)
if (edgeIdsToRemove.length > 0) {
batchRemoveEdges(edgeIdsToRemove)
}
if (conditionalBlocks.length === 1) return
shouldPersistRef.current = true
setConditionalBlocks((blocks) => updateBlockTitles(blocks.filter((block) => block.id !== id)))
@@ -819,9 +816,7 @@ export function ConditionInput({
<Button
variant='ghost'
onClick={() => removeBlock(block.id)}
disabled={
isPreview || disabled || conditionalBlocks.length <= (isRouterMode ? 1 : 2)
}
disabled={isPreview || disabled || conditionalBlocks.length === 1}
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
>
<Trash className='h-[14px] w-[14px]' />

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

@@ -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])
@@ -867,8 +863,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
return parsed.map((item: unknown, index: number) => {
const routeItem = item as { id?: string; value?: string }
return {
// Use stable ID format that matches ConditionInput's generateStableId
id: routeItem?.id ?? `${id}-route${index + 1}`,
id: routeItem?.id ?? `${id}-route-${index}`,
value: routeItem?.value ?? '',
}
})
@@ -878,8 +873,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
logger.warn('Failed to parse router routes value', { error, blockId: id })
}
// Fallback must match ConditionInput's default: generateStableId(blockId, 'route1') = `${blockId}-route1`
return [{ id: `${id}-route1`, value: '' }]
return [{ id: `${id}-route-route1`, value: '' }]
}, [type, subBlockState, id])
/**

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

@@ -987,14 +987,6 @@ const WorkflowContent = React.memo(() => {
const handleId = conditionHandles[0].getAttribute('data-handleid')
if (handleId) return handleId
}
} else if (block.type === 'router_v2') {
const routerHandles = document.querySelectorAll(
`[data-nodeid^="${block.id}"][data-handleid^="router-"]`
)
if (routerHandles.length > 0) {
const handleId = routerHandles[0].getAttribute('data-handleid')
if (handleId) return handleId
}
} else if (block.type === 'loop') {
return 'loop-end-source'
} else if (block.type === 'parallel') {
@@ -3350,6 +3342,8 @@ const WorkflowContent = React.memo(() => {
<LazyChat />
</Suspense>
<DiffControls />
{/* Context Menus */}
<BlockContextMenu
isOpen={isBlockMenuOpen}
@@ -3405,8 +3399,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

@@ -115,26 +115,25 @@ Description: ${route.value || 'No description provided'}
)
.join('\n')
return `You are a DETERMINISTIC routing agent. You MUST select exactly ONE option.
return `You are an intelligent routing agent. Your task is to analyze the provided context and select the most appropriate route from the available options.
Available Routes:
${routesInfo}
Context to route:
Context to analyze:
${context}
ROUTING RULES:
1. ALWAYS prefer selecting a route over NO_MATCH
2. Pick the route whose description BEST matches the context, even if it's not a perfect match
3. If the context is even partially related to a route's description, select that route
4. ONLY output NO_MATCH if the context is completely unrelated to ALL route descriptions
Instructions:
1. Carefully analyze the context against each route's description
2. Select the route that best matches the context's intent and requirements
3. Consider the semantic meaning, not just keyword matching
4. If multiple routes could match, choose the most specific one
OUTPUT FORMAT:
- Output EXACTLY one route ID (copied exactly as shown above) OR "NO_MATCH"
- No explanation, no punctuation, no additional text
- Just the route ID or NO_MATCH
Response Format:
Return ONLY the route ID as a single string, no punctuation, no explanation.
Example: "route-abc123"
Your response:`
Remember: Your response must be ONLY the route ID - no additional text, formatting, or explanation.`
}
/**

View File

@@ -278,24 +278,14 @@ export class RouterBlockHandler implements BlockHandler {
const result = await response.json()
const chosenRouteId = result.content.trim()
if (chosenRouteId === 'NO_MATCH' || chosenRouteId.toUpperCase() === 'NO_MATCH') {
logger.info('Router determined no route matches the context, routing to error path')
throw new Error('Router could not determine a matching route for the given context')
}
const chosenRoute = routes.find((r) => r.id === chosenRouteId)
// Throw error if LLM returns invalid route ID - this routes through error path
if (!chosenRoute) {
const availableRoutes = routes.map((r) => ({ id: r.id, title: r.title }))
logger.error(
`Invalid routing decision. Response content: "${result.content}". Available routes:`,
availableRoutes
)
throw new Error(
`Router could not determine a valid route. LLM response: "${result.content}". Available route IDs: ${routes.map((r) => r.id).join(', ')}`
`Invalid routing decision. Response content: "${result.content}", available routes:`,
routes.map((r) => ({ id: r.id, title: r.title }))
)
throw new Error(`Invalid routing decision: ${chosenRouteId}`)
}
// Find the target block connected to this route's handle

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,11 +300,11 @@ 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
@@ -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

@@ -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

@@ -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(),
})

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

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",