Compare commits

..

11 Commits

Author SHA1 Message Date
Emir Karabeg
077a902325 feat(copilot): editable input component 2026-01-10 17:59:49 -08:00
Emir Karabeg
fd2c4b6a7c improvement: diff controls and notifications positioning 2026-01-10 17:35:17 -08:00
Siddharth Ganesan
47209aee32 Scroll stickiness 2026-01-10 15:49:15 -08:00
Siddharth Ganesan
0350321d1b Scroll stickiness 2026-01-10 15:36:24 -08:00
Siddharth Ganesan
e3b849ad74 Fix Lint 2026-01-10 15:29:52 -08:00
Siddharth Ganesan
07433ccbb1 Fix loading 2026-01-10 15:17:05 -08:00
Waleed
ead2413b95 fix(context-menu): make divider on context menu aware of available options (#2766) 2026-01-10 14:06:51 -08:00
Vikhyath Mondreti
9a16e7c20f improvement(response): only allow singleton (#2764)
* improvement(response): only allow singleton

* respect singleton triggers and blocks in copilot

* don't show dup button for response

* fix error message
2026-01-10 12:16:32 -08:00
Siddharth Ganesan
283a521614 feat(copilot): subagents (#2731)
* fix(helm): add custom egress rules to realtime network policy (#2481)

The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.

* Add subagents

* Edit, plan, debug subagents

* Tweaks

* Message queue

* Many subagents

* Fix bugs

* Trigger request

* Overlays

* Diff in chat

* Remove context usage code

* Diff view in chat

* Options

* Lint

* Fix rendering of edit subblocks

* Add deploy mcp tools

* Add evaluator subagent

* Editor component

* Options select

* Fixes to options

* Fix spacing between options

* Subagent rendering

* Fix previews

* Plan

* Streaming

* Fix thinking scroll

* Renaming

* Fix thinking text

* Persist and load chats properly

* Diff view

* Fix lint

* Previous options should not be selectable

* Enable images

* improvement(copilot): ui/ux

* improvement(copilot): diff controls

* Fix ops bug

* Fix ops

* Stuff

* Fix config

---------

Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Martin Yankov <23098926+Lutherwaves@users.noreply.github.com>
Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
2026-01-10 11:44:04 -08:00
Vikhyath Mondreti
92fabe785d fix(perms): copilot checks undefined issue (#2763) 2026-01-10 11:23:35 -08:00
Siddharth Ganesan
3ed177520a fix(router): fix router ports (#2757)
* Fix router block

* Fix autoconnect edge for router

* Fix lint

* router block error path decision

* improve router prompt

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-01-10 11:22:11 -08:00
95 changed files with 5926 additions and 1350 deletions

View File

@@ -2,7 +2,6 @@
title: Router
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
import { Image } from '@/components/ui/image'
@@ -102,11 +101,18 @@ 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.
- **Include an error/fallback route**: Add a catch-all route for unexpected inputs that don't match other routes.
- **Connect an error path**: Handle cases where no route matches by connecting an error handler for graceful fallback behavior.
- **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

@@ -802,49 +802,29 @@ export async function POST(req: NextRequest) {
toolNames: toolCalls.map((tc) => tc?.name).filter(Boolean),
})
// Save messages to database after streaming completes (including aborted messages)
// NOTE: Messages are saved by the client via update-messages endpoint with full contentBlocks.
// Server only updates conversationId here to avoid overwriting client's richer save.
if (currentChat) {
const updatedMessages = [...conversationHistory, userMessage]
// Save assistant message if there's any content or tool calls (even partial from abort)
if (assistantContent.trim() || toolCalls.length > 0) {
const assistantMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: assistantContent,
timestamp: new Date().toISOString(),
...(toolCalls.length > 0 && { toolCalls }),
}
updatedMessages.push(assistantMessage)
logger.info(
`[${tracker.requestId}] Saving assistant message with content (${assistantContent.length} chars) and ${toolCalls.length} tool calls`
)
} else {
logger.info(
`[${tracker.requestId}] No assistant content or tool calls to save (aborted before response)`
)
}
// Persist only a safe conversationId to avoid continuing from a state that expects tool outputs
const previousConversationId = currentChat?.conversationId as string | undefined
const responseId = lastSafeDoneResponseId || previousConversationId || undefined
// Update chat in database immediately (without title)
await db
.update(copilotChats)
.set({
messages: updatedMessages,
updatedAt: new Date(),
...(responseId ? { conversationId: responseId } : {}),
})
.where(eq(copilotChats.id, actualChatId!))
if (responseId) {
await db
.update(copilotChats)
.set({
updatedAt: new Date(),
conversationId: responseId,
})
.where(eq(copilotChats.id, actualChatId!))
logger.info(`[${tracker.requestId}] Updated chat ${actualChatId} with new messages`, {
messageCount: updatedMessages.length,
savedUserMessage: true,
savedAssistantMessage: assistantContent.trim().length > 0,
updatedConversationId: responseId || null,
})
logger.info(
`[${tracker.requestId}] Updated conversationId for chat ${actualChatId}`,
{
updatedConversationId: responseId,
}
)
}
}
} catch (error) {
logger.error(`[${tracker.requestId}] Error processing stream:`, error)

View File

@@ -17,25 +17,30 @@ const logger = createLogger('CopilotChatUpdateAPI')
const UpdateMessagesSchema = z.object({
chatId: z.string(),
messages: z.array(
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(),
})
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
),
planArtifact: z.string().nullable().optional(),
config: z
@@ -57,8 +62,33 @@ 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)
// Debug: Log what we're about to save
const lastMsgParsed = messages[messages.length - 1]
if (lastMsgParsed?.role === 'assistant') {
logger.info(`[${tracker.requestId}] Parsed messages to save`, {
messageCount: messages.length,
lastMsgId: lastMsgParsed.id,
lastMsgContentLength: lastMsgParsed.content?.length || 0,
lastMsgContentBlockCount: lastMsgParsed.contentBlocks?.length || 0,
lastMsgContentBlockTypes: lastMsgParsed.contentBlocks?.map((b: any) => b?.type) || [],
})
}
// Verify that the chat belongs to the user
const [chat] = await db
.select()

View File

@@ -1,134 +0,0 @@
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

@@ -462,9 +462,6 @@ export default function PlaygroundPage() {
<Avatar size='lg'>
<AvatarFallback>LG</AvatarFallback>
</Avatar>
<Avatar size='xl'>
<AvatarFallback>XL</AvatarFallback>
</Avatar>
</VariantRow>
<VariantRow label='with image'>
<Avatar size='md'>
@@ -505,9 +502,6 @@ export default function PlaygroundPage() {
<Avatar size='lg' status='online'>
<AvatarFallback>LG</AvatarFallback>
</Avatar>
<Avatar size='xl' status='online'>
<AvatarFallback>XL</AvatarFallback>
</Avatar>
</VariantRow>
</Section>

View File

@@ -7,6 +7,7 @@ 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'
@@ -43,6 +44,11 @@ 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, LibraryBig, Search } from 'lucide-react'
import { Layout, Search } from 'lucide-react'
import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/emcn'
import { Button, Library } 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: LibraryBig,
icon: Library,
shortcut: 'L',
},
{

View File

@@ -1,10 +1,10 @@
import { memo, useCallback } from 'react'
import { memo, useCallback, useMemo } from 'react'
import { createLogger } from '@sim/logger'
import clsx from 'clsx'
import { Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/emcn'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCopilotStore } from '@/stores/panel'
import { useCopilotStore, usePanelStore } from '@/stores/panel'
import { useTerminalStore } from '@/stores/terminal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -15,28 +15,20 @@ const logger = createLogger('DiffControls')
export const DiffControls = memo(function DiffControls() {
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
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 isPanelResizing = usePanelStore((state) => state.isResizing)
const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges, baselineWorkflow } =
useWorkflowDiffStore(
useCallback(
(state) => ({
isDiffReady: state.isDiffReady,
hasActiveDiff: state.hasActiveDiff,
acceptChanges: state.acceptChanges,
rejectChanges: state.rejectChanges,
baselineWorkflow: state.baselineWorkflow,
}),
[]
)
)
)
const { updatePreviewToolCallState, currentChat, messages } = useCopilotStore(
useCallback(
@@ -53,11 +45,6 @@ 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', {
@@ -206,54 +193,47 @@ export const DiffControls = memo(function DiffControls() {
}
}, [activeWorkflowId, currentChat, messages, baselineWorkflow])
const handleAccept = useCallback(async () => {
const handleAccept = useCallback(() => {
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 {
// 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
}
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)
})
logger.info('Accept triggered; UI will update optimistically')
} catch (error) {
logger.error('Failed to accept changes:', 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)
})
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
logger.error('Workflow update failed:', errorMessage)
alert(`Failed to save workflow changes: ${errorMessage}`)
}
logger.info('Accept triggered; UI will update optimistically')
}, [createCheckpoint, updatePreviewToolCallState, acceptChanges])
const handleReject = useCallback(() => {
@@ -293,54 +273,82 @@ 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(
'-translate-x-1/2 fixed left-1/2 z-30',
!isTerminalResizing && 'transition-[bottom] duration-100 ease-out'
'fixed z-30',
!isResizing && 'transition-[bottom,right] duration-100 ease-out'
)}
style={{ bottom: 'calc(var(--terminal-height) + 40px)' }}
style={{
bottom: 'calc(var(--terminal-height) + 16px)',
right: 'calc(var(--panel-width) + 16px)',
}}
>
<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'
<div
className='group relative flex h-[30px] overflow-hidden rounded-[4px]'
style={{ isolation: 'isolate' }}
>
{/* Reject side */}
<button
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>
{/* Accept */}
<Button
variant='tertiary'
</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
onClick={handleAccept}
className='h-[30px] rounded-[8px] px-3'
title='Accept changes'
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',
}}
>
Accept
</Button>
<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>
</div>
</div>
)

View File

@@ -11,6 +11,7 @@ import {
openCopilotWithMessage,
useNotificationStore,
} from '@/stores/notifications'
import { useSidebarStore } from '@/stores/sidebar/store'
import { useTerminalStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -19,7 +20,7 @@ const MAX_VISIBLE_NOTIFICATIONS = 4
/**
* Notifications display component
* Positioned in the bottom-right workspace area, aligned with terminal and panel spacing
* Positioned in the bottom-left workspace area, reactive to sidebar width and terminal height
* Shows both global notifications and workflow-specific notifications
*/
export const Notifications = memo(function Notifications() {
@@ -36,6 +37,7 @@ 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.
@@ -103,12 +105,14 @@ export const Notifications = memo(function Notifications() {
return null
}
const isResizing = isTerminalResizing || isSidebarResizing
return (
<div
ref={preventZoomRef}
className={clsx(
'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'
'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'
)}
>
{[...visibleNotifications].reverse().map((notification, index, stacked) => {

View File

@@ -3,75 +3,23 @@
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
*/
@@ -80,16 +28,19 @@ interface ThinkingBlockProps {
content: string
/** Whether the block is currently streaming */
isStreaming?: boolean
/** Persisted duration from content block */
duration?: number
/** Persisted start time from content block */
startTime?: number
/** 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
}
/**
* 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
@@ -97,112 +48,248 @@ interface ThinkingBlockProps {
export function ThinkingBlock({
content,
isStreaming = false,
duration: persistedDuration,
startTime: persistedStartTime,
hasFollowingContent = false,
label = 'Thought',
hasSpecialTags = false,
}: ThinkingBlockProps) {
const [isExpanded, setIsExpanded] = useState(false)
const [duration, setDuration] = useState(persistedDuration ?? 0)
const [duration, setDuration] = useState(0)
const [userHasScrolledAway, setUserHasScrolledAway] = useState(false)
const userCollapsedRef = useRef<boolean>(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])
const scrollContainerRef = useRef<HTMLDivElement>(null)
const startTimeRef = useRef<number>(Date.now())
const lastScrollTopRef = useRef(0)
const programmaticScrollRef = useRef(false)
/**
* Auto-expands block when streaming with content
* Auto-collapses when streaming ends
* Auto-collapses when streaming ends OR when following content arrives
*/
useEffect(() => {
if (!isStreaming) {
// Collapse if streaming ended or if there's following content (like a tool call)
if (!isStreaming || hasFollowingContent) {
setIsExpanded(false)
userCollapsedRef.current = false
setUserHasScrolledAway(false)
return
}
if (!userCollapsedRef.current && content && content.trim().length > 0) {
setIsExpanded(true)
}
}, [isStreaming, content])
}, [isStreaming, content, hasFollowingContent])
/**
* Updates duration timer during streaming
* Uses persisted duration when available
*/
// Reset start time when streaming begins
useEffect(() => {
if (typeof persistedDuration === 'number') {
setDuration(persistedDuration)
return
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
}
if (isStreaming) {
const interval = setInterval(() => {
setDuration(Date.now() - startTimeRef.current)
}, TIMER_UPDATE_INTERVAL)
return () => clearInterval(interval)
}
container.addEventListener('scroll', handleScroll, { passive: true })
lastScrollTopRef.current = container.scrollTop
setDuration(Date.now() - startTimeRef.current)
}, [isStreaming, persistedDuration])
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])
/**
* Formats duration in milliseconds to human-readable format
* @param ms - Duration in milliseconds
* @returns Formatted string (e.g., "150ms" or "2.5s")
* Formats duration in milliseconds to seconds
* Always shows seconds, rounded to nearest whole second, minimum 1s
*/
const formatDuration = (ms: number) => {
if (ms < SECONDS_THRESHOLD) {
return `${ms}ms`
}
const seconds = (ms / SECONDS_THRESHOLD).toFixed(1)
const seconds = Math.max(1, Math.round(ms / 1000))
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 className='mt-1 mb-0'>
<div>
<button
onClick={() => {
setIsExpanded((v) => {
const next = !v
// If user collapses during streaming, remember to not auto-expand again
if (!next && isStreaming) userCollapsedRef.current = true
return next
})
setIsExpanded((v) => !v)
}}
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)]'
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'
disabled={!hasContent}
>
<ShimmerOverlayText
label='Thought'
value={` for ${formatDuration(duration)}`}
active={isStreaming}
/>
<span className='text-[var(--text-tertiary)]'>{durationText}</span>
{hasContent && (
<ChevronUp
className={clsx('h-3 w-3 transition-transform', isExpanded && 'rotate-180')}
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>
{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
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} />
</div>
)}
</div>
</div>
)
}

View File

@@ -1,9 +1,13 @@
'use client'
import { type FC, memo, useMemo, useState } from 'react'
import { Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from 'lucide-react'
import { type FC, memo, useCallback, useMemo, useState } from 'react'
import { RotateCcw } from 'lucide-react'
import { Button } from '@/components/emcn'
import { ToolCall } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
import {
OptionsSelector,
parseSpecialTags,
ToolCall,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
import {
FileAttachmentDisplay,
SmoothStreamingText,
@@ -15,8 +19,6 @@ 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'
@@ -40,6 +42,8 @@ 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
}
/**
@@ -59,6 +63,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
checkpointCount = 0,
onEditModeChange,
onRevertModeChange,
isLastMessage = false,
}) => {
const isUser = message.role === 'user'
const isAssistant = message.role === 'assistant'
@@ -88,22 +93,6 @@ 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,
@@ -153,14 +142,6 @@ 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 ''
@@ -169,6 +150,42 @@ 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) {
@@ -179,8 +196,12 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
if (block.type === 'text') {
const isLastTextBlock =
index === message.contentBlocks!.length - 1 && block.type === 'text'
// Clean content for this text block
const cleanBlockContent = block.content.replace(/\n{3,}/g, '\n\n')
// 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
// Use smooth streaming for the last text block if we're streaming
const shouldUseSmoothing = isStreaming && isLastTextBlock
@@ -201,19 +222,14 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
)
}
if (block.type === 'thinking') {
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
// Check if there are any blocks after this one (tool calls, text, etc.)
const hasFollowingContent = index < message.contentBlocks!.length - 1
return (
<div key={`thinking-${index}-${block.timestamp || index}`} className='w-full'>
<ThinkingBlock
content={block.content}
isStreaming={isStreamingThinking}
duration={block.duration}
startTime={block.startTime}
isStreaming={isStreaming}
hasFollowingContent={hasFollowingContent}
/>
</div>
)
@@ -454,66 +470,15 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
{/* Content blocks in chronological order */}
{memoizedContentBlocks}
{/* Show streaming indicator if streaming but no text content yet after tool calls */}
{isStreaming &&
!message.content &&
message.contentBlocks?.every((block) => block.type === 'tool_call') && (
<StreamingIndicator />
)}
{/* Streaming indicator when no content yet */}
{!cleanTextContent && !message.contentBlocks?.length && isStreaming && (
<StreamingIndicator />
)}
{/* Always show streaming indicator at the end while streaming */}
{isStreaming && <StreamingIndicator />}
{message.errorType === 'usage_limit' && (
<div className='mt-3 flex gap-1.5'>
<div className='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'>
@@ -533,6 +498,20 @@ 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>
)
@@ -570,6 +549,11 @@ 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,5 +1,6 @@
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

@@ -0,0 +1,102 @@
'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

@@ -1,76 +0,0 @@
'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,6 +1,5 @@
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,11 +178,12 @@ 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 || isLoading) return
if (disabled) return
fileInputRef.current?.click()
}, [disabled, isLoading])
}, [disabled])
/**
* Handles file input change event

View File

@@ -117,7 +117,6 @@ 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('')
@@ -300,7 +299,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => {
const targetMessage = overrideMessage ?? message
const trimmedMessage = targetMessage.trim()
if (!trimmedMessage || disabled || isLoading) return
// Allow submission even when isLoading - store will queue the message
if (!trimmedMessage || disabled) 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 || isLoading) && 'cursor-not-allowed opacity-50'
disabled && '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 */}
{/* Hidden File Input - enabled during streaming so users can prepare images for the next message */}
<input
ref={fileAttachments.fileInputRef}
type='file'
@@ -810,7 +810,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
className='hidden'
accept='image/*'
multiple
disabled={disabled || isLoading}
disabled={disabled}
/>
</div>
</div>

View File

@@ -22,9 +22,11 @@ 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,
@@ -99,7 +101,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
loadChats,
messageCheckpoints,
currentChat,
fetchContextUsage,
selectChat,
deleteChat,
areChatsFresh,
@@ -118,14 +119,15 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
chatsLoadedForWorkflow,
setCopilotWorkflowId,
loadChats,
fetchContextUsage,
loadAutoAllowedTools,
currentChat,
isSendingMessage,
})
// Handle scroll management
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage)
// Handle scroll management (80px stickiness for copilot)
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage, {
stickinessThreshold: 80,
})
// Handle chat history grouping
const { groupedChats, handleHistoryDropdownOpen: handleHistoryDropdownOpenHook } = useChatHistory(
@@ -298,7 +300,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
*/
const handleSubmit = useCallback(
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => {
if (!query || isSendingMessage || !activeWorkflowId) return
// Allow submission even when isSendingMessage - store will queue the message
if (!query || !activeWorkflowId) return
if (showPlanTodos) {
const store = useCopilotStore.getState()
@@ -316,7 +319,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
logger.error('Failed to send message:', error)
}
},
[isSendingMessage, activeWorkflowId, sendMessage, showPlanTodos]
[activeWorkflowId, sendMessage, showPlanTodos]
)
/**
@@ -443,7 +446,13 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
<span className='min-w-0 flex-1 truncate'>
{chat.title || 'New Chat'}
</span>
<div className='flex flex-shrink-0 items-center gap-[4px] opacity-0 transition-opacity group-hover:opacity-100'>
<div
className={cn(
'flex flex-shrink-0 items-center gap-[4px]',
currentChat?.id !== chat.id &&
'opacity-0 transition-opacity group-hover:opacity-100'
)}
>
<Button
variant='ghost'
className='h-[16px] w-[16px] p-0'
@@ -563,6 +572,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
onRevertModeChange={(isReverting) =>
handleRevertModeChange(message.id, isReverting)
}
isLastMessage={index === messages.length - 1}
/>
)
})}
@@ -588,6 +598,9 @@ 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,7 +11,6 @@ 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
@@ -30,7 +29,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
chatsLoadedForWorkflow,
setCopilotWorkflowId,
loadChats,
fetchContextUsage,
loadAutoAllowedTools,
currentChat,
isSendingMessage,
@@ -102,18 +100,6 @@ 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,17 +654,20 @@ export function ConditionInput({
}
const removeBlock = (id: string) => {
if (isPreview || disabled || conditionalBlocks.length <= 2) return
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
// Remove any associated edges before removing the block
const handlePrefix = isRouterMode ? `router-${id}` : `condition-${id}`
const edgeIdsToRemove = edges
.filter((edge) => edge.sourceHandle?.startsWith(`condition-${id}`))
.filter((edge) => edge.sourceHandle?.startsWith(handlePrefix))
.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)))
@@ -816,7 +819,9 @@ export function ConditionInput({
<Button
variant='ghost'
onClick={() => removeBlock(block.id)}
disabled={isPreview || disabled || conditionalBlocks.length === 1}
disabled={
isPreview || disabled || conditionalBlocks.length <= (isRouterMode ? 1 : 2)
}
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, useState } from 'react'
import { useCallback, useEffect } from 'react'
import { PANEL_WIDTH } from '@/stores/constants'
import { usePanelStore } from '@/stores/panel'
@@ -10,15 +10,14 @@ import { usePanelStore } from '@/stores/panel'
* @returns Resize state and handlers
*/
export function usePanelResize() {
const { setPanelWidth } = usePanelStore()
const [isResizing, setIsResizing] = useState(false)
const { setPanelWidth, isResizing, setIsResizing } = usePanelStore()
/**
* Handles mouse down on resize handle
*/
const handleMouseDown = useCallback(() => {
setIsResizing(true)
}, [])
}, [setIsResizing])
/**
* Setup resize event listeners and body styles when resizing
@@ -52,7 +51,7 @@ export function usePanelResize() {
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
}, [isResizing, setPanelWidth])
}, [isResizing, setPanelWidth, setIsResizing])
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-[#22C55F]',
diffStatus === 'new' && 'ring-[var(--brand-tertiary-2)]',
diffStatus === 'edited' && 'ring-[var(--warning)]'
)

View File

@@ -306,6 +306,7 @@ export function Terminal() {
const terminalRef = useRef<HTMLElement>(null)
const prevEntriesLengthRef = useRef(0)
const prevWorkflowEntriesLengthRef = useRef(0)
const isTerminalFocusedRef = useRef(false)
const {
setTerminalHeight,
lastExpandedHeight,
@@ -540,8 +541,11 @@ 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)
@@ -562,6 +566,26 @@ 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
@@ -792,9 +816,12 @@ 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(
@@ -829,9 +856,12 @@ 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
@@ -936,6 +966,9 @@ export function Terminal() {
isToggling && 'transition-[height] duration-100 ease-out'
)}
onTransitionEnd={handleTransitionEnd}
onFocus={handleTerminalFocus}
onBlur={handleTerminalBlur}
tabIndex={-1}
aria-label='Terminal'
>
<div className='relative flex h-full border-[var(--border)] border-t'>

View File

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

View File

@@ -199,8 +199,9 @@ 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.
*/
const getDisplayValue = (value: unknown): string => {
export const getDisplayValue = (value: unknown): string => {
if (value == null || value === '') return '-'
// Try parsing JSON strings first
@@ -630,10 +631,13 @@ 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
const keys = Object.keys(current)
const dependentKeys = keys.filter((k) => k !== 'credential')
dependentKeys.forEach((k) => collaborativeSetSubblockValue(id, k, ''))
if (hadPreviousCredential) {
const keys = Object.keys(current)
const dependentKeys = keys.filter((k) => k !== 'credential')
dependentKeys.forEach((k) => collaborativeSetSubblockValue(id, k, ''))
}
}
}, [id, collaborativeSetSubblockValue])
@@ -863,7 +867,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
return parsed.map((item: unknown, index: number) => {
const routeItem = item as { id?: string; value?: string }
return {
id: routeItem?.id ?? `${id}-route-${index}`,
// Use stable ID format that matches ConditionInput's generateStableId
id: routeItem?.id ?? `${id}-route${index + 1}`,
value: routeItem?.value ?? '',
}
})
@@ -873,7 +878,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
logger.warn('Failed to parse router routes value', { error, blockId: id })
}
return [{ id: `${id}-route-route1`, value: '' }]
// Fallback must match ConditionInput's default: generateStableId(blockId, 'route1') = `${blockId}-route1`
return [{ id: `${id}-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)'
color = 'var(--brand-tertiary-2)'
} else if (edgeRunStatus === 'success') {
// Use green for preview mode, default for canvas execution
color = previewExecutionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)'

View File

@@ -12,6 +12,12 @@ interface UseScrollManagementOptions {
* - `auto`: immediate scroll to bottom (used by floating chat to avoid jitter).
*/
behavior?: 'auto' | 'smooth'
/**
* Distance from bottom (in pixels) within which auto-scroll stays active.
* Lower values = less sticky (user can scroll away easier).
* Default is 100px.
*/
stickinessThreshold?: number
}
/**
@@ -34,6 +40,7 @@ export function useScrollManagement(
const programmaticScrollInProgressRef = useRef(false)
const lastScrollTopRef = useRef(0)
const scrollBehavior: 'auto' | 'smooth' = options?.behavior ?? 'smooth'
const stickinessThreshold = options?.stickinessThreshold ?? 100
/**
* Scrolls the container to the bottom with smooth animation
@@ -74,7 +81,7 @@ export function useScrollManagement(
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const nearBottom = distanceFromBottom <= 100
const nearBottom = distanceFromBottom <= stickinessThreshold
setIsNearBottom(nearBottom)
if (isSendingMessage) {
@@ -95,7 +102,7 @@ export function useScrollManagement(
// Track last scrollTop for direction detection
lastScrollTopRef.current = scrollTop
}, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream])
}, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream, stickinessThreshold])
// Attach scroll listener
useEffect(() => {
@@ -174,14 +181,20 @@ export function useScrollManagement(
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const nearBottom = distanceFromBottom <= 120
const nearBottom = distanceFromBottom <= stickinessThreshold
if (nearBottom) {
scrollToBottom()
}
}, 100)
return () => window.clearInterval(intervalId)
}, [isSendingMessage, userHasScrolledDuringStream, getScrollContainer, scrollToBottom])
}, [
isSendingMessage,
userHasScrolledDuringStream,
getScrollContainer,
scrollToBottom,
stickinessThreshold,
])
return {
scrollAreaRef,

View File

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

View File

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

View File

@@ -147,6 +147,12 @@ export function ContextMenu({
disableCreate = false,
disableCreateFolder = false,
}: ContextMenuProps) {
// Section visibility for divider logic
const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
const hasEditSection =
(showRename && onRename) || (showCreate && onCreate) || (showCreateFolder && onCreateFolder)
const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport)
return (
<Popover
open={isOpen}
@@ -176,7 +182,7 @@ export function ContextMenu({
Open in new tab
</PopoverItem>
)}
{showOpenInNewTab && onOpenInNewTab && <PopoverDivider />}
{hasNavigationSection && (hasEditSection || hasCopySection) && <PopoverDivider />}
{/* Edit and create actions */}
{showRename && onRename && (
@@ -214,7 +220,7 @@ export function ContextMenu({
)}
{/* Copy and export actions */}
{(showDuplicate || showExport) && <PopoverDivider />}
{hasEditSection && hasCopySection && <PopoverDivider />}
{showDuplicate && onDuplicate && (
<PopoverItem
disabled={disableDuplicate}
@@ -239,7 +245,7 @@ export function ContextMenu({
)}
{/* Destructive action */}
<PopoverDivider />
{(hasNavigationSection || hasEditSection || hasCopySection) && <PopoverDivider />}
<PopoverItem
disabled={disableDelete}
onClick={() => {

View File

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

View File

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

View File

@@ -115,25 +115,26 @@ Description: ${route.value || 'No description provided'}
)
.join('\n')
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.
return `You are a DETERMINISTIC routing agent. You MUST select exactly ONE option.
Available Routes:
${routesInfo}
Context to analyze:
Context to route:
${context}
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
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
Response Format:
Return ONLY the route ID as a single string, no punctuation, no explanation.
Example: "route-abc123"
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
Remember: Your response must be ONLY the route ID - no additional text, formatting, or explanation.`
Your response:`
}
/**

View File

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

View File

@@ -16,7 +16,6 @@ const avatarVariants = cva('relative flex shrink-0 overflow-hidden rounded-full'
sm: 'h-8 w-8',
md: 'h-10 w-10',
lg: 'h-12 w-12',
xl: 'h-16 w-16',
},
},
defaultVariants: {
@@ -42,7 +41,6 @@ const avatarStatusVariants = cva(
sm: 'h-2.5 w-2.5',
md: 'h-3 w-3',
lg: 'h-3.5 w-3.5',
xl: 'h-4 w-4',
},
},
defaultVariants: {

View File

@@ -278,14 +278,24 @@ 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:`,
routes.map((r) => ({ id: r.id, title: r.title }))
`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(', ')}`
)
throw new Error(`Invalid routing decision: ${chosenRouteId}`)
}
// Find the target block connected to this route's handle

View File

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

View File

@@ -0,0 +1,120 @@
/**
* 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,6 +1,7 @@
// 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')
@@ -51,6 +52,11 @@ 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 {
@@ -258,4 +264,12 @@ 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,6 +14,7 @@ import {
interface GetBlockConfigArgs {
blockType: string
operation?: string
trigger?: boolean
}
export class GetBlockConfigClientTool extends BaseClientTool {
@@ -28,7 +29,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: 'Got block config', icon: FileCode },
[ClientToolCallState.success]: { text: 'Retrieved 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]: {
@@ -43,17 +44,17 @@ export class GetBlockConfigClientTool extends BaseClientTool {
switch (state) {
case ClientToolCallState.success:
return `Got ${blockName}${opSuffix} config`
return `Retrieved ${blockName}${opSuffix} config`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Getting ${blockName}${opSuffix} config`
return `Retrieving ${blockName}${opSuffix} config`
case ClientToolCallState.error:
return `Failed to get ${blockName}${opSuffix} config`
return `Failed to retrieve ${blockName}${opSuffix} config`
case ClientToolCallState.aborted:
return `Aborted getting ${blockName}${opSuffix} config`
return `Aborted retrieving ${blockName}${opSuffix} config`
case ClientToolCallState.rejected:
return `Skipped getting ${blockName}${opSuffix} config`
return `Skipped retrieving ${blockName}${opSuffix} config`
}
}
return undefined
@@ -65,12 +66,15 @@ export class GetBlockConfigClientTool extends BaseClientTool {
try {
this.setState(ClientToolCallState.executing)
const { blockType, operation } = GetBlockConfigInput.parse(args || {})
const { blockType, operation, trigger } = 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 } }),
body: JSON.stringify({
toolName: 'get_block_config',
payload: { blockType, operation, trigger },
}),
})
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: 'Got block options', icon: ListFilter },
[ClientToolCallState.success]: { text: 'Retrieved block options', icon: ListFilter },
[ClientToolCallState.error]: { text: 'Failed to get block options', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted getting block options', icon: XCircle },
[ClientToolCallState.rejected]: {
@@ -41,17 +41,17 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
switch (state) {
case ClientToolCallState.success:
return `Got ${blockName} options`
return `Retrieved ${blockName} options`
case ClientToolCallState.executing:
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Getting ${blockName} options`
return `Retrieving ${blockName} options`
case ClientToolCallState.error:
return `Failed to get ${blockName} options`
return `Failed to retrieve ${blockName} options`
case ClientToolCallState.aborted:
return `Aborted getting ${blockName} options`
return `Aborted retrieving ${blockName} options`
case ClientToolCallState.rejected:
return `Skipped getting ${blockName} options`
return `Skipped retrieving ${blockName} options`
}
}
return undefined
@@ -63,7 +63,20 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
try {
this.setState(ClientToolCallState.executing)
const { blockId } = GetBlockOptionsInput.parse(args || {})
// 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 res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',

View File

@@ -0,0 +1,48 @@
/**
* 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

@@ -0,0 +1,56 @@
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: 'Todo marked complete', icon: Check },
[ClientToolCallState.success]: { text: 'Marked todo complete', icon: Check },
[ClientToolCallState.error]: { text: 'Failed to mark todo', icon: XCircle },
},
}

View File

@@ -0,0 +1,56 @@
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

@@ -0,0 +1,60 @@
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

@@ -0,0 +1,56 @@
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

@@ -0,0 +1,61 @@
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

@@ -0,0 +1,56 @@
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

@@ -0,0 +1,56 @@
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

@@ -0,0 +1,56 @@
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,6 +5,7 @@ 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 {
@@ -27,7 +28,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: 'API request complete', icon: Globe2 },
[ClientToolCallState.success]: { text: 'Completed API request', 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 },
@@ -36,6 +37,23 @@ 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'
@@ -110,3 +128,6 @@ 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: 'Todo marked in progress', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Marked todo 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: 'Connecting integration', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Requesting integration access', icon: Loader2 },
[ClientToolCallState.rejected]: { text: 'Skipped integration access', icon: MinusCircle },
[ClientToolCallState.success]: { text: 'Integration connected', icon: CheckCircle },
[ClientToolCallState.success]: { text: 'Requested integration access', icon: CheckCircle },
[ClientToolCallState.error]: { text: 'Failed to request integration access', icon: X },
[ClientToolCallState.aborted]: { text: 'Aborted integration access request', icon: XCircle },
},
@@ -87,17 +87,16 @@ export class OAuthRequestAccessClientTool extends BaseClientTool {
switch (state) {
case ClientToolCallState.generating:
case ClientToolCallState.pending:
return `Requesting ${name} access`
case ClientToolCallState.executing:
return `Connecting to ${name}`
return `Requesting ${name} access`
case ClientToolCallState.rejected:
return `Skipped ${name} access`
case ClientToolCallState.success:
return `${name} connected`
return `Requested ${name} access`
case ClientToolCallState.error:
return `Failed to connect ${name}`
return `Failed to request ${name} access`
case ClientToolCallState.aborted:
return `Aborted ${name} connection`
return `Aborted ${name} access request`
}
}
return undefined
@@ -151,9 +150,12 @@ export class OAuthRequestAccessClientTool extends BaseClientTool {
})
)
// Mark as success - the modal will handle the actual OAuth flow
// Mark as success - the user opened the prompt, but connection is not guaranteed
this.setState(ClientToolCallState.success)
await this.markToolComplete(200, `Opened ${this.providerName} connection dialog`)
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.`
)
} catch (e) {
logger.error('Failed to open OAuth connect modal', { error: e })
this.setState(ClientToolCallState.error)

View File

@@ -1,16 +1,20 @@
import { createLogger } from '@sim/logger'
import { ListTodo, Loader2, X, XCircle } from 'lucide-react'
import { ListTodo, 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 PlanArgs {
objective?: string
todoList?: Array<{ id?: string; content: string } | string>
request: 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'
@@ -22,48 +26,34 @@ export class PlanClientTool extends BaseClientTool {
displayNames: {
[ClientToolCallState.generating]: { text: 'Planning', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Planning', icon: Loader2 },
[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 },
[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'],
},
},
}
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')
}
/**
* 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
}
}
// Register UI config at module load
registerToolUIConfig(PlanClientTool.id, PlanClientTool.metadata.uiConfig!)

View File

@@ -0,0 +1,56 @@
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: 'Documentation search complete', icon: BookOpen },
[ClientToolCallState.success]: { text: 'Completed documentation search', 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: 'Online search complete', icon: Globe },
[ClientToolCallState.success]: { text: 'Completed online search', 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,6 +5,7 @@ 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
@@ -39,11 +40,20 @@ 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: 'Sleep interrupted', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Sleep skipped', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Sleep aborted', icon: MinusCircle },
[ClientToolCallState.error]: { text: 'Interrupted sleep', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped sleep', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted sleep', 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
@@ -142,3 +152,6 @@ export class SleepClientTool extends BaseClientTool {
await this.handleAccept(args)
}
}
// Register UI config at module load
registerToolUIConfig(SleepClientTool.id, SleepClientTool.metadata.uiConfig!)

View File

@@ -0,0 +1,56 @@
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

@@ -0,0 +1,56 @@
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

@@ -0,0 +1,56 @@
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

@@ -0,0 +1,238 @@
/**
* 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,6 +5,7 @@ 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'
@@ -48,6 +49,33 @@ 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
@@ -121,3 +149,9 @@ export class SetEnvironmentVariablesClientTool extends BaseClientTool {
await this.handleAccept(args)
}
}
// Register UI config at module load
registerToolUIConfig(
SetEnvironmentVariablesClientTool.id,
SetEnvironmentVariablesClientTool.metadata.uiConfig!
)

View File

@@ -11,6 +11,29 @@ 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'
@@ -45,52 +68,116 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool {
try {
this.setState(ClientToolCallState.executing)
const { activeWorkflowId } = useWorkflowRegistry.getState()
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
const workflowId = args?.workflowId || activeWorkflowId
if (!workflowId) {
throw new Error('No workflow ID provided')
}
// Fetch deployment status from API
const [apiDeployRes, chatDeployRes] = await Promise.all([
const workflow = workflows[workflowId]
const workspaceId = workflow?.workspaceId
// Fetch deployment status from all sources
const [apiDeployRes, chatDeployRes, mcpServersRes] = 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')
if (isApiDeployed) {
// Default to sync API, could be extended to detect streaming/async
deploymentTypes.push('api')
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 (isChatDeployed) {
deploymentTypes.push('chat')
}
const isDeployed = isApiDeployed || isChatDeployed
this.setState(ClientToolCallState.success)
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,
}
)
await this.markToolComplete(200, message, {
isDeployed,
deploymentTypes,
api: apiDetails,
chat: chatDetails,
mcp: mcpDetails,
})
logger.info('Checked deployment status', { isDeployed, deploymentTypes })
} catch (e: any) {
logger.error('Check deployment status failed', { message: e?.message })
this.setState(ClientToolCallState.error)

View File

@@ -0,0 +1,155 @@
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,43 +1,40 @@
import { createLogger } from '@sim/logger'
import { Loader2, Rocket, X, XCircle } from 'lucide-react'
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'
import { getInputFormatExample } from '@/lib/workflows/operations/deployment-utils'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface DeployWorkflowArgs {
interface DeployApiArgs {
action: 'deploy' | 'undeploy'
deployType?: 'api' | 'chat'
workflowId?: string
}
interface ApiKeysData {
workspaceKeys: Array<{ id: string; name: string }>
personalKeys: Array<{ id: string; name: string }>
}
export class DeployWorkflowClientTool extends BaseClientTool {
static readonly id = 'deploy_workflow'
/**
* 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'
constructor(toolCallId: string) {
super(toolCallId, DeployWorkflowClientTool.id, DeployWorkflowClientTool.metadata)
super(toolCallId, DeployApiClientTool.id, DeployApiClientTool.metadata)
}
/**
* Override to provide dynamic button text based on action and deployType
* Override to provide dynamic button text based on action
*/
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 DeployWorkflowArgs | undefined
const params = toolCall?.params as DeployApiArgs | undefined
const action = params?.action || 'deploy'
const deployType = params?.deployType || 'api'
// Check if workflow is already deployed
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
@@ -45,13 +42,10 @@ export class DeployWorkflowClientTool extends BaseClientTool {
? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed
: false
let buttonText = action.charAt(0).toUpperCase() + action.slice(1)
let buttonText = action === 'undeploy' ? 'Undeploy' : 'Deploy'
// Change to "Redeploy" if already deployed
if (action === 'deploy' && isAlreadyDeployed) {
buttonText = 'Redeploy'
} else if (action === 'deploy' && deployType === 'chat') {
buttonText = 'Deploy as chat'
}
return {
@@ -63,19 +57,19 @@ export class DeployWorkflowClientTool extends BaseClientTool {
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: {
text: 'Preparing to deploy workflow',
text: 'Preparing to deploy API',
icon: Loader2,
},
[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.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.aborted]: {
text: 'Aborted deploying workflow',
text: 'Aborted deploying API',
icon: XCircle,
},
[ClientToolCallState.rejected]: {
text: 'Skipped deploying workflow',
text: 'Skipped deploying API',
icon: XCircle,
},
},
@@ -83,9 +77,17 @@ export class DeployWorkflowClientTool 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
@@ -93,48 +95,32 @@ export class DeployWorkflowClientTool extends BaseClientTool {
? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed
: false
// Determine action text based on deployment status
let actionText = action
let actionTextIng = action === 'undeploy' ? 'undeploying' : 'deploying'
let actionTextPast = action === 'undeploy' ? 'undeployed' : 'deployed'
const 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 isChatDeploy
? 'Opened chat deployment settings'
: `${actionCapitalized}ed workflow`
return `API ${actionTextPast}`
case ClientToolCallState.executing:
return isChatDeploy
? 'Opening chat deployment settings'
: `${actionCapitalized}ing workflow`
return `${actionCapitalized}ing API`
case ClientToolCallState.generating:
return `Preparing to ${displayAction} workflow`
return `Preparing to ${actionText} API`
case ClientToolCallState.pending:
return `${displayActionCapitalized} workflow?`
return `${actionCapitalized} API?`
case ClientToolCallState.error:
return `Failed to ${displayAction} workflow`
return `Failed to ${actionText} API`
case ClientToolCallState.aborted:
return isChatDeploy
? 'Aborted opening chat deployment'
: `Aborted ${actionTextIng} workflow`
return `Aborted ${actionTextIng} API`
case ClientToolCallState.rejected:
return isChatDeploy
? 'Skipped opening chat deployment'
: `Skipped ${actionTextIng} workflow`
return `Skipped ${actionTextIng} API`
}
return undefined
},
@@ -162,7 +148,7 @@ export class DeployWorkflowClientTool extends BaseClientTool {
return workspaceKeys.length > 0 || personalKeys.length > 0
} catch (error) {
const logger = createLogger('DeployWorkflowClientTool')
const logger = createLogger('DeployApiClientTool')
logger.warn('Failed to check API keys:', error)
return false
}
@@ -175,23 +161,15 @@ export class DeployWorkflowClientTool 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?: DeployWorkflowArgs): Promise<void> {
const logger = createLogger('DeployWorkflowClientTool')
async handleAccept(args?: DeployApiArgs): Promise<void> {
const logger = createLogger('DeployApiClientTool')
try {
const action = args?.action || 'deploy'
const deployType = args?.deployType || 'api'
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
const workflowId = args?.workflowId || activeWorkflowId
@@ -202,22 +180,6 @@ export class DeployWorkflowClientTool 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) {
@@ -227,10 +189,7 @@ export class DeployWorkflowClientTool 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(
@@ -248,7 +207,6 @@ export class DeployWorkflowClientTool extends BaseClientTool {
this.setState(ClientToolCallState.executing)
// Perform the deploy/undeploy action
const endpoint = `/api/workflows/${workflowId}/deploy`
const method = action === 'deploy' ? 'POST' : 'DELETE'
@@ -273,25 +231,21 @@ export class DeployWorkflowClientTool 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 endpoint = `${appUrl}/api/workflows/${workflowId}/execute`
const apiEndpoint = `${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}`
// 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.'
successMessage = 'Workflow deployed successfully as API. You can now call it via REST.'
resultData = {
...resultData,
endpoint,
endpoint: apiEndpoint,
curlCommand,
apiKeyPlaceholder,
}
@@ -316,18 +270,21 @@ export class DeployWorkflowClientTool extends BaseClientTool {
setDeploymentStatus(workflowId, false, undefined, '')
}
const actionPast = action === 'undeploy' ? 'undeployed' : 'deployed'
logger.info(`Workflow ${actionPast} and registry updated`)
logger.info(`Workflow ${actionPast} as API and registry updated`)
} catch (error) {
logger.warn('Failed to update workflow registry:', error)
}
} catch (e: any) {
logger.error('Deploy/undeploy failed', { message: e?.message })
logger.error('Deploy API failed', { message: e?.message })
this.setState(ClientToolCallState.error)
await this.markToolComplete(500, e?.message || 'Failed to deploy/undeploy workflow')
await this.markToolComplete(500, e?.message || 'Failed to deploy API')
}
}
async execute(args?: DeployWorkflowArgs): Promise<void> {
async execute(args?: DeployApiArgs): Promise<void> {
await this.handleAccept(args)
}
}
// Register UI config at module load
registerToolUIConfig(DeployApiClientTool.id, DeployApiClientTool.metadata.uiConfig!)

View File

@@ -0,0 +1,365 @@
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

@@ -0,0 +1,211 @@
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

@@ -5,6 +5,7 @@ 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'
@@ -124,6 +125,10 @@ 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) {
@@ -412,3 +417,6 @@ export class EditWorkflowClientTool extends BaseClientTool {
})
}
}
// Register UI config at module load
registerToolUIConfig(EditWorkflowClientTool.id, EditWorkflowClientTool.metadata.uiConfig!)

View File

@@ -16,7 +16,6 @@ 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'
@@ -90,10 +89,6 @@ 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,
@@ -102,6 +97,11 @@ 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,6 +193,11 @@ 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: 'Workflow data retrieved', icon: Database },
[ClientToolCallState.success]: { text: 'Retrieved workflow data', icon: Database },
[ClientToolCallState.error]: { text: 'Failed to fetch data', icon: X },
[ClientToolCallState.rejected]: { text: 'Skipped fetching data', icon: XCircle },
},

View File

@@ -0,0 +1,112 @@
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'
operation: 'add' | 'edit' | 'delete' | 'list'
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' | undefined
const operation = params?.operation as 'add' | 'edit' | 'delete' | 'list' | undefined
// Return undefined if no operation yet - use static defaults
if (!operation) return undefined
@@ -105,19 +105,30 @@ 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 = shouldShowToolName(state) && toolName ? ` ${toolName}` : ' custom tool'
const nameText =
operation === 'list'
? ' custom tools'
: shouldShowToolName(state) && toolName
? ` ${toolName}`
: ' custom tool'
switch (state) {
case ClientToolCallState.success:
@@ -188,16 +199,16 @@ export class ManageCustomToolClientTool extends BaseClientTool {
async execute(args?: ManageCustomToolArgs): Promise<void> {
this.currentArgs = args
// For add operation, execute directly without confirmation
// For add and list operations, execute directly without confirmation
// For edit/delete, the copilot store will check hasInterrupt() and wait for confirmation
if (args?.operation === 'add') {
if (args?.operation === 'add' || args?.operation === 'list') {
await this.handleAccept(args)
}
// edit/delete will wait for user confirmation via handleAccept
}
/**
* Executes the custom tool operation (add, edit, or delete)
* Executes the custom tool operation (add, edit, delete, or list)
*/
private async executeOperation(
args: ManageCustomToolArgs | undefined,
@@ -235,6 +246,10 @@ 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,6 +7,7 @@ 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'
@@ -29,9 +30,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: 'Workflow executed', icon: Play },
[ClientToolCallState.success]: { text: 'Executed workflow', icon: Play },
[ClientToolCallState.error]: { text: 'Errored running workflow', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Workflow execution skipped', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped workflow execution', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted workflow execution', icon: MinusCircle },
[ClientToolCallState.background]: { text: 'Running in background', icon: Play },
},
@@ -39,6 +40,49 @@ 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) {
@@ -182,3 +226,6 @@ export class RunWorkflowClientTool extends BaseClientTool {
await this.handleAccept(args)
}
}
// Register UI config at module load
registerToolUIConfig(RunWorkflowClientTool.id, RunWorkflowClientTool.metadata.uiConfig!)

View File

@@ -5,6 +5,7 @@ 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'
@@ -39,7 +40,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: 'Workflow variables updated', icon: Settings2 },
[ClientToolCallState.success]: { text: 'Updated workflow variables', 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 },
@@ -48,6 +49,28 @@ 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
@@ -243,3 +266,9 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
await this.handleAccept(args)
}
}
// Register UI config at module load
registerToolUIConfig(
SetGlobalWorkflowVariablesClientTool.id,
SetGlobalWorkflowVariablesClientTool.metadata.uiConfig!
)

View File

@@ -10,6 +10,7 @@ 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
@@ -107,11 +108,12 @@ 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.label || opt.id
return opt.id || opt.label // Prefer id (actual value) over label (display name)
}
return String(opt)
})
@@ -145,13 +147,20 @@ function matchesOperation(condition: any, operation: string): boolean {
*/
function extractInputsFromSubBlocks(
subBlocks: SubBlockConfig[],
operation?: string
operation?: string,
triggerMode?: boolean
): Record<string, InputFieldSchema> {
const inputs: Record<string, InputFieldSchema> = {}
for (const sb of subBlocks) {
// Skip trigger-mode subBlocks
if (sb.mode === 'trigger') continue
// 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 hidden subBlocks
if (sb.hidden) continue
@@ -247,12 +256,53 @@ 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): Record<string, OutputFieldSchema> {
function extractOutputs(
blockConfig: any,
operation?: string,
triggerMode?: boolean
): 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 {
@@ -300,16 +350,16 @@ export const getBlockConfigServerTool: BaseServerTool<
> = {
name: 'get_block_config',
async execute(
{ blockType, operation }: GetBlockConfigInputType,
{ blockType, operation, trigger }: GetBlockConfigInputType,
context?: { userId: string }
): Promise<GetBlockConfigResultType> {
const logger = createLogger('GetBlockConfigServerTool')
logger.debug('Executing get_block_config', { blockType, operation })
logger.debug('Executing get_block_config', { blockType, operation, trigger })
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
const allowedIntegrations = permissionConfig?.allowedIntegrations
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) {
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) {
throw new Error(`Block "${blockType}" is not available`)
}
@@ -318,6 +368,13 @@ 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')
@@ -334,13 +391,14 @@ export const getBlockConfigServerTool: BaseServerTool<
}
const subBlocks = Array.isArray(blockConfig.subBlocks) ? blockConfig.subBlocks : []
const inputs = extractInputsFromSubBlocks(subBlocks, operation)
const outputs = extractOutputs(blockConfig, operation)
const inputs = extractInputsFromSubBlocks(subBlocks, operation, trigger)
const outputs = extractOutputs(blockConfig, operation, trigger)
const result = {
blockType,
blockName: blockConfig.name,
operation,
trigger,
inputs,
outputs,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,11 +57,13 @@ 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()),
})
@@ -114,6 +116,7 @@ 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(),
@@ -155,6 +158,7 @@ export const GetBlockUpstreamReferencesResult = z.object({
blockId: z.string(),
blockName: z.string(),
blockType: z.string(),
triggerMode: z.boolean().optional(),
outputs: z.array(z.string()),
accessContext: z.enum(['inside', 'outside']).optional(),
})

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -2,12 +2,30 @@ 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 {
@@ -42,6 +60,18 @@ 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 }
@@ -131,18 +161,11 @@ 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 {
@@ -150,7 +173,6 @@ 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
@@ -220,6 +242,19 @@ 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,6 +29,10 @@ 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,6 +11,10 @@ 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,6 +9,7 @@ export const useSidebarStore = create<SidebarState>()(
workspaceDropdownOpen: false,
sidebarWidth: SIDEBAR_WIDTH.DEFAULT,
isCollapsed: false,
isResizing: false,
_hasHydrated: false,
setWorkspaceDropdownOpen: (isOpen) => set({ workspaceDropdownOpen: isOpen }),
setSidebarWidth: (width) => {
@@ -31,6 +32,9 @@ 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,9 +5,13 @@ 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,6 +273,7 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
)
}
// Background operations (fire-and-forget) - don't block
if (triggerMessageId) {
fetch('/api/copilot/stats', {
method: 'POST',
@@ -285,14 +286,15 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
}).catch(() => {})
}
const toolCallId = await findLatestEditWorkflowToolCallId()
if (toolCallId) {
try {
await getClientTool(toolCallId)?.handleAccept?.()
} catch (error) {
logger.warn('Failed to notify tool accept state', { error })
findLatestEditWorkflowToolCallId().then((toolCallId) => {
if (toolCallId) {
getClientTool(toolCallId)
?.handleAccept?.()
?.catch?.((error: Error) => {
logger.warn('Failed to notify tool accept state', { error })
})
}
}
})
},
rejectChanges: async () => {
@@ -327,27 +329,26 @@ 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)
// 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
// Emit event for undo/redo recording synchronously
if (!(window as any).__skipDiffRecording) {
window.dispatchEvent(
new CustomEvent('record-diff-operation', {
@@ -362,6 +363,25 @@ 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',
@@ -374,16 +394,15 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
}).catch(() => {})
}
const toolCallId = await findLatestEditWorkflowToolCallId()
if (toolCallId) {
try {
await getClientTool(toolCallId)?.handleReject?.()
} catch (error) {
logger.warn('Failed to notify tool reject state', { error })
findLatestEditWorkflowToolCallId().then((toolCallId) => {
if (toolCallId) {
getClientTool(toolCallId)
?.handleReject?.()
?.catch?.((error: Error) => {
logger.warn('Failed to notify tool reject state', { error })
})
}
}
get().clearDiff({ restoreBaseline: false })
})
},
reapplyDiffMarkers: () => {

View File

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

View File

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