Compare commits

...

42 Commits

Author SHA1 Message Date
Siddharth Ganesan
75e6e4f230 Fix config 2026-01-10 11:32:34 -08:00
Siddharth Ganesan
963f1d561a Merge branch 'staging' into feat/copilot-subagents 2026-01-10 11:29:06 -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
Siddharth Ganesan
5e9d8e1e9a Stuff 2026-01-10 11:16:33 -08:00
Siddharth Ganesan
152e942074 Merge origin/staging into feat/copilot-subagents 2026-01-10 10:46:36 -08:00
Siddharth Ganesan
3359d7b0d8 Fix ops 2026-01-10 10:13:39 -08:00
Siddharth Ganesan
b7d0f2053b Fix ops bug 2026-01-10 10:12:54 -08:00
Emir Karabeg
1f6f58cf7f improvement(copilot): diff controls 2026-01-09 20:17:49 -08:00
Emir Karabeg
41d767e170 improvement(copilot): ui/ux 2026-01-09 18:56:57 -08:00
Siddharth Ganesan
c5dc78ff08 Enable images 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
ae6e29512a Previous options should not be selectable 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
6639871c92 Fix lint 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
0ba5ec65f7 Diff view 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
8597786962 Persist and load chats properly 2026-01-09 18:56:33 -08:00
Siddharth Ganesan
1aada6ba57 Fix thinking text 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
a9edbd71f1 Renaming 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
bd35dda8fa Fix thinking scroll 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
b2b06c3dd1 Streaming 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
29eefd8416 Plan 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
51b2297e35 Fix previews 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
aa8da99ce2 Subagent rendering 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
2b2ed6df1a Fix spacing between options 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
7305ecf4fa Fixes to options 2026-01-09 18:56:32 -08:00
Siddharth Ganesan
d2ef972bbb Options select 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
90c875b895 Editor component 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
4280461cb8 Add evaluator subagent 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
3be426af8e Add deploy mcp tools 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
8fcb0349d2 Fix rendering of edit subblocks 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
96dc2b7afd Lint 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
e95b6135ac Options 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
2fe0afaef4 Diff view in chat 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
d86e43945f Remove context usage code 2026-01-09 18:56:31 -08:00
Siddharth Ganesan
4fd5656c01 Diff in chat 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
54f6047dd3 Overlays 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
86f8e77293 Trigger request 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
05034edc83 Fix bugs 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
7a925ad45c Many subagents 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
442d8a1f45 Message queue 2026-01-09 18:56:30 -08:00
Siddharth Ganesan
56366088d8 Tweaks 2026-01-09 18:56:29 -08:00
Siddharth Ganesan
f6cd0cbc55 Edit, plan, debug subagents 2026-01-09 18:56:29 -08:00
Siddharth Ganesan
df80309c3b Add subagents 2026-01-09 18:56:29 -08:00
83 changed files with 5603 additions and 1271 deletions

View File

@@ -2,7 +2,6 @@
title: Router title: Router
--- ---
import { Callout } from 'fumadocs-ui/components/callout'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs' import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
import { Image } from '@/components/ui/image' import { Image } from '@/components/ui/image'
@@ -102,11 +101,18 @@ Input (Lead) → Router
└── [Self-serve] → Workflow (Automated Onboarding) └── [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 ## Best Practices
- **Write clear route descriptions**: Each route description should clearly explain when that route should be selected. Be specific about the criteria. - **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. - **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. - **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. - **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. - **Monitor routing performance**: Review routing decisions regularly and refine route descriptions based on actual usage patterns.

View File

@@ -17,25 +17,30 @@ const logger = createLogger('CopilotChatUpdateAPI')
const UpdateMessagesSchema = z.object({ const UpdateMessagesSchema = z.object({
chatId: z.string(), chatId: z.string(),
messages: z.array( messages: z.array(
z.object({ z
id: z.string(), .object({
role: z.enum(['user', 'assistant']), id: z.string(),
content: z.string(), role: z.enum(['user', 'assistant', 'system']),
timestamp: z.string(), content: z.string(),
toolCalls: z.array(z.any()).optional(), timestamp: z.string(),
contentBlocks: z.array(z.any()).optional(), toolCalls: z.array(z.any()).optional(),
fileAttachments: z contentBlocks: z.array(z.any()).optional(),
.array( fileAttachments: z
z.object({ .array(
id: z.string(), z.object({
key: z.string(), id: z.string(),
filename: z.string(), key: z.string(),
media_type: z.string(), filename: z.string(),
size: z.number(), media_type: z.string(),
}) size: z.number(),
) })
.optional(), )
}) .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(), planArtifact: z.string().nullable().optional(),
config: z config: z
@@ -57,6 +62,19 @@ export async function POST(req: NextRequest) {
} }
const body = await req.json() 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) const { chatId, messages, planArtifact, config } = UpdateMessagesSchema.parse(body)
// Verify that the chat belongs to the user // Verify that the chat belongs to the user

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

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

View File

@@ -2,10 +2,10 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { Layout, LibraryBig, Search } from 'lucide-react' import { Layout, Search } from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/emcn' import { Button, Library } from '@/components/emcn'
import { AgentIcon } from '@/components/icons' import { AgentIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn' import { cn } from '@/lib/core/utils/cn'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
@@ -41,7 +41,7 @@ const commands: CommandItem[] = [
}, },
{ {
label: 'Logs', label: 'Logs',
icon: LibraryBig, icon: Library,
shortcut: 'L', 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 { createLogger } from '@sim/logger'
import clsx from 'clsx' import clsx from 'clsx'
import { Eye, EyeOff } from 'lucide-react' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { Button } from '@/components/emcn' import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' 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 { useTerminalStore } from '@/stores/terminal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff' import { useWorkflowDiffStore } from '@/stores/workflow-diff'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -15,28 +15,20 @@ const logger = createLogger('DiffControls')
export const DiffControls = memo(function DiffControls() { export const DiffControls = memo(function DiffControls() {
const isTerminalResizing = useTerminalStore((state) => state.isResizing) const isTerminalResizing = useTerminalStore((state) => state.isResizing)
const { const isPanelResizing = usePanelStore((state) => state.isResizing)
isShowingDiff, const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges, baselineWorkflow } =
isDiffReady, useWorkflowDiffStore(
hasActiveDiff, useCallback(
toggleDiffView, (state) => ({
acceptChanges, isDiffReady: state.isDiffReady,
rejectChanges, hasActiveDiff: state.hasActiveDiff,
baselineWorkflow, acceptChanges: state.acceptChanges,
} = useWorkflowDiffStore( rejectChanges: state.rejectChanges,
useCallback( baselineWorkflow: state.baselineWorkflow,
(state) => ({ }),
isShowingDiff: state.isShowingDiff, []
isDiffReady: state.isDiffReady, )
hasActiveDiff: state.hasActiveDiff,
toggleDiffView: state.toggleDiffView,
acceptChanges: state.acceptChanges,
rejectChanges: state.rejectChanges,
baselineWorkflow: state.baselineWorkflow,
}),
[]
) )
)
const { updatePreviewToolCallState, currentChat, messages } = useCopilotStore( const { updatePreviewToolCallState, currentChat, messages } = useCopilotStore(
useCallback( useCallback(
@@ -53,11 +45,6 @@ export const DiffControls = memo(function DiffControls() {
useCallback((state) => ({ activeWorkflowId: state.activeWorkflowId }), []) useCallback((state) => ({ activeWorkflowId: state.activeWorkflowId }), [])
) )
const handleToggleDiff = useCallback(() => {
logger.info('Toggling diff view', { currentState: isShowingDiff })
toggleDiffView()
}, [isShowingDiff, toggleDiffView])
const createCheckpoint = useCallback(async () => { const createCheckpoint = useCallback(async () => {
if (!activeWorkflowId || !currentChat?.id) { if (!activeWorkflowId || !currentChat?.id) {
logger.warn('Cannot create checkpoint: missing workflowId or chatId', { logger.warn('Cannot create checkpoint: missing workflowId or chatId', {
@@ -206,54 +193,47 @@ export const DiffControls = memo(function DiffControls() {
} }
}, [activeWorkflowId, currentChat, messages, baselineWorkflow]) }, [activeWorkflowId, currentChat, messages, baselineWorkflow])
const handleAccept = useCallback(async () => { const handleAccept = useCallback(() => {
logger.info('Accepting proposed changes with backup protection') 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 { try {
// Create a checkpoint before applying changes so it appears under the triggering user message const { toolCallsById, messages } = useCopilotStore.getState()
await createCheckpoint().catch((error) => { let id: string | undefined
logger.warn('Failed to create checkpoint before accept:', error) outer: for (let mi = messages.length - 1; mi >= 0; mi--) {
}) const m = messages[mi]
if (m.role !== 'assistant' || !m.contentBlocks) continue
// Resolve target toolCallId for build/edit and update to terminal success state in the copilot store const blocks = m.contentBlocks as any[]
try { for (let bi = blocks.length - 1; bi >= 0; bi--) {
const { toolCallsById, messages } = useCopilotStore.getState() const b = blocks[bi]
let id: string | undefined if (b?.type === 'tool_call') {
outer: for (let mi = messages.length - 1; mi >= 0; mi--) { const tn = b.toolCall?.name
const m = messages[mi] if (tn === 'edit_workflow') {
if (m.role !== 'assistant' || !m.contentBlocks) continue id = b.toolCall?.id
const blocks = m.contentBlocks as any[] break outer
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') if (!id) {
id = candidates.length ? candidates[candidates.length - 1].id : undefined 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) updatePreviewToolCallState('accepted', id)
} catch {}
// Accept changes without blocking the UI; errors will be logged by the store handler // Accept changes without blocking the UI; errors will be logged by the store handler
acceptChanges().catch((error) => { acceptChanges().catch((error) => {
logger.error('Failed to accept changes (background):', error) logger.error('Failed to accept changes (background):', error)
}) })
logger.info('Accept triggered; UI will update optimistically') // Create checkpoint in the background (fire-and-forget) so it doesn't block UI
} catch (error) { createCheckpoint().catch((error) => {
logger.error('Failed to accept changes:', error) logger.warn('Failed to create checkpoint after accept:', error)
})
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' logger.info('Accept triggered; UI will update optimistically')
logger.error('Workflow update failed:', errorMessage)
alert(`Failed to save workflow changes: ${errorMessage}`)
}
}, [createCheckpoint, updatePreviewToolCallState, acceptChanges]) }, [createCheckpoint, updatePreviewToolCallState, acceptChanges])
const handleReject = useCallback(() => { const handleReject = useCallback(() => {
@@ -293,54 +273,82 @@ export const DiffControls = memo(function DiffControls() {
const preventZoomRef = usePreventZoom() 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 // Don't show anything if no diff is available or diff is not ready
if (!hasActiveDiff || !isDiffReady) { if (!hasActiveDiff || !isDiffReady) {
return null return null
} }
const isResizing = isTerminalResizing || isPanelResizing
return ( return (
<div <div
ref={preventZoomRef} ref={preventZoomRef}
className={clsx( className={clsx(
'-translate-x-1/2 fixed left-1/2 z-30', 'fixed z-30',
!isTerminalResizing && 'transition-[bottom] duration-100 ease-out' !isResizing && 'transition-[bottom,right] duration-100 ease-out'
)} )}
style={{ bottom: 'calc(var(--terminal-height) + 40px)' }} style={{
bottom: 'calc(var(--terminal-height) + 8px)',
right: 'calc(var(--panel-width) + 8px)',
}}
> >
<div className='flex items-center gap-[6px] rounded-[10px] p-[6px]'> <div
{/* Toggle (left, icon-only) */} className='group relative flex h-[30px] overflow-hidden rounded-[4px]'
<Button style={{ isolation: 'isolate' }}
variant='active' >
onClick={handleToggleDiff} {/* Reject side */}
className='h-[30px] w-[30px] rounded-[8px] p-0' <button
title={isShowingDiff ? 'View original' : 'Preview changes'}
>
{isShowingDiff ? (
<Eye className='h-[14px] w-[14px]' />
) : (
<EyeOff className='h-[14px] w-[14px]' />
)}
</Button>
{/* Reject */}
<Button
variant='active'
onClick={handleReject} onClick={handleReject}
className='h-[30px] rounded-[8px] px-3'
title='Reject changes' 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 Reject
</Button> </button>
{/* Slanted divider - split gray/green */}
{/* Accept */} <div
<Button className='pointer-events-none absolute top-0 bottom-0 z-10'
variant='tertiary' 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} 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 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>
</div> </div>
) )

View File

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

View File

@@ -3,75 +3,23 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { ChevronUp } from 'lucide-react' 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 * Timer update interval in milliseconds
*/ */
const TIMER_UPDATE_INTERVAL = 100 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 * Props for the ThinkingBlock component
*/ */
@@ -80,16 +28,19 @@ interface ThinkingBlockProps {
content: string content: string
/** Whether the block is currently streaming */ /** Whether the block is currently streaming */
isStreaming?: boolean isStreaming?: boolean
/** Persisted duration from content block */ /** Whether there are more content blocks after this one (e.g., tool calls) */
duration?: number hasFollowingContent?: boolean
/** Persisted start time from content block */ /** Custom label for the thinking block (e.g., "Thinking", "Exploring"). Defaults to "Thought" */
startTime?: number label?: string
/** Whether special tags (plan, options) are present - triggers collapse */
hasSpecialTags?: boolean
} }
/** /**
* ThinkingBlock component displays AI reasoning/thinking process * ThinkingBlock component displays AI reasoning/thinking process
* Shows collapsible content with duration timer * Shows collapsible content with duration timer
* Auto-expands during streaming and collapses when complete * 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 * @param props - Component props
* @returns Thinking block with expandable content and timer * @returns Thinking block with expandable content and timer
@@ -97,112 +48,248 @@ interface ThinkingBlockProps {
export function ThinkingBlock({ export function ThinkingBlock({
content, content,
isStreaming = false, isStreaming = false,
duration: persistedDuration, hasFollowingContent = false,
startTime: persistedStartTime, label = 'Thought',
hasSpecialTags = false,
}: ThinkingBlockProps) { }: ThinkingBlockProps) {
const [isExpanded, setIsExpanded] = useState(false) 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 userCollapsedRef = useRef<boolean>(false)
const startTimeRef = useRef<number>(persistedStartTime ?? Date.now()) const scrollContainerRef = useRef<HTMLDivElement>(null)
const startTimeRef = useRef<number>(Date.now())
/** const lastScrollTopRef = useRef(0)
* Updates start time reference when persisted start time changes const programmaticScrollRef = useRef(false)
*/
useEffect(() => {
if (typeof persistedStartTime === 'number') {
startTimeRef.current = persistedStartTime
}
}, [persistedStartTime])
/** /**
* Auto-expands block when streaming with content * Auto-expands block when streaming with content
* Auto-collapses when streaming ends * Auto-collapses when streaming ends OR when following content arrives
*/ */
useEffect(() => { useEffect(() => {
if (!isStreaming) { // Collapse if streaming ended or if there's following content (like a tool call)
if (!isStreaming || hasFollowingContent) {
setIsExpanded(false) setIsExpanded(false)
userCollapsedRef.current = false userCollapsedRef.current = false
setUserHasScrolledAway(false)
return return
} }
if (!userCollapsedRef.current && content && content.trim().length > 0) { if (!userCollapsedRef.current && content && content.trim().length > 0) {
setIsExpanded(true) setIsExpanded(true)
} }
}, [isStreaming, content]) }, [isStreaming, content, hasFollowingContent])
/** // Reset start time when streaming begins
* Updates duration timer during streaming
* Uses persisted duration when available
*/
useEffect(() => { useEffect(() => {
if (typeof persistedDuration === 'number') { if (isStreaming && !hasFollowingContent) {
setDuration(persistedDuration) startTimeRef.current = Date.now()
return 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) { container.addEventListener('scroll', handleScroll, { passive: true })
const interval = setInterval(() => { lastScrollTopRef.current = container.scrollTop
setDuration(Date.now() - startTimeRef.current)
}, TIMER_UPDATE_INTERVAL)
return () => clearInterval(interval)
}
setDuration(Date.now() - startTimeRef.current) return () => container.removeEventListener('scroll', handleScroll)
}, [isStreaming, persistedDuration]) }, [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 * Formats duration in milliseconds to seconds
* @param ms - Duration in milliseconds * Always shows seconds, rounded to nearest whole second, minimum 1s
* @returns Formatted string (e.g., "150ms" or "2.5s")
*/ */
const formatDuration = (ms: number) => { const formatDuration = (ms: number) => {
if (ms < SECONDS_THRESHOLD) { const seconds = Math.max(1, Math.round(ms / 1000))
return `${ms}ms`
}
const seconds = (ms / SECONDS_THRESHOLD).toFixed(1)
return `${seconds}s` return `${seconds}s`
} }
const hasContent = content && content.trim().length > 0 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 ( return (
<div className='mt-1 mb-0'> <div>
<button <button
onClick={() => { onClick={() => {
setIsExpanded((v) => { setIsExpanded((v) => !v)
const next = !v
// If user collapses during streaming, remember to not auto-expand again
if (!next && isStreaming) userCollapsedRef.current = true
return next
})
}} }}
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' type='button'
disabled={!hasContent} disabled={!hasContent}
> >
<ShimmerOverlayText <span className='text-[var(--text-tertiary)]'>{durationText}</span>
label='Thought'
value={` for ${formatDuration(duration)}`}
active={isStreaming}
/>
{hasContent && ( {hasContent && (
<ChevronUp <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' aria-hidden='true'
/> />
)} )}
</button> </button>
{isExpanded && ( <div
<div className='ml-1 border-[var(--border-1)] border-l-2 pl-2'> ref={scrollContainerRef}
<pre className='whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-tertiary)] leading-[1.15rem]'> className={clsx(
{content} 'overflow-y-auto transition-all duration-300 ease-in-out',
{isStreaming && ( isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-[var(--text-tertiary)]' /> )}
)} >
</pre> {/* 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>
</div> </div>
) )
} }

View File

@@ -1,9 +1,13 @@
'use client' 'use client'
import { type FC, memo, useMemo, useState } from 'react' import { type FC, memo, useCallback, useMemo, useState } from 'react'
import { Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from 'lucide-react' import { RotateCcw } from 'lucide-react'
import { Button } from '@/components/emcn' 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 { import {
FileAttachmentDisplay, FileAttachmentDisplay,
SmoothStreamingText, SmoothStreamingText,
@@ -15,8 +19,6 @@ import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId
import { import {
useCheckpointManagement, useCheckpointManagement,
useMessageEditing, useMessageEditing,
useMessageFeedback,
useSuccessTimers,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks' } 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 { UserInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
import type { CopilotMessage as CopilotMessageType } from '@/stores/panel' import type { CopilotMessage as CopilotMessageType } from '@/stores/panel'
@@ -40,6 +42,8 @@ interface CopilotMessageProps {
onEditModeChange?: (isEditing: boolean, cancelCallback?: () => void) => void onEditModeChange?: (isEditing: boolean, cancelCallback?: () => void) => void
/** Callback when revert mode changes */ /** Callback when revert mode changes */
onRevertModeChange?: (isReverting: boolean) => void 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, checkpointCount = 0,
onEditModeChange, onEditModeChange,
onRevertModeChange, onRevertModeChange,
isLastMessage = false,
}) => { }) => {
const isUser = message.role === 'user' const isUser = message.role === 'user'
const isAssistant = message.role === 'assistant' const isAssistant = message.role === 'assistant'
@@ -88,22 +93,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
// UI state // UI state
const [isHoveringMessage, setIsHoveringMessage] = useState(false) 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 // Checkpoint management hook
const { const {
showRestoreConfirmation, showRestoreConfirmation,
@@ -153,14 +142,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
pendingEditRef, 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 // Get clean text content with double newline parsing
const cleanTextContent = useMemo(() => { const cleanTextContent = useMemo(() => {
if (!message.content) return '' if (!message.content) return ''
@@ -169,6 +150,42 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
return message.content.replace(/\n{3,}/g, '\n\n') return message.content.replace(/\n{3,}/g, '\n\n')
}, [message.content]) }, [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 // Memoize content blocks to avoid re-rendering unchanged blocks
const memoizedContentBlocks = useMemo(() => { const memoizedContentBlocks = useMemo(() => {
if (!message.contentBlocks || message.contentBlocks.length === 0) { if (!message.contentBlocks || message.contentBlocks.length === 0) {
@@ -179,8 +196,12 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
if (block.type === 'text') { if (block.type === 'text') {
const isLastTextBlock = const isLastTextBlock =
index === message.contentBlocks!.length - 1 && block.type === 'text' index === message.contentBlocks!.length - 1 && block.type === 'text'
// Clean content for this text block // Always strip special tags from display (they're rendered separately as options/plan)
const cleanBlockContent = block.content.replace(/\n{3,}/g, '\n\n') 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 // Use smooth streaming for the last text block if we're streaming
const shouldUseSmoothing = isStreaming && isLastTextBlock const shouldUseSmoothing = isStreaming && isLastTextBlock
@@ -201,19 +222,14 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
) )
} }
if (block.type === 'thinking') { if (block.type === 'thinking') {
const isLastBlock = index === message.contentBlocks!.length - 1 // Check if there are any blocks after this one (tool calls, text, etc.)
// Consider the thinking block streaming if the overall message is streaming const hasFollowingContent = index < message.contentBlocks!.length - 1
// and the block has not been finalized with a duration yet. This avoids
// freezing the timer when new blocks are appended after the thinking block.
const isStreamingThinking = isStreaming && (block as any).duration == null
return ( return (
<div key={`thinking-${index}-${block.timestamp || index}`} className='w-full'> <div key={`thinking-${index}-${block.timestamp || index}`} className='w-full'>
<ThinkingBlock <ThinkingBlock
content={block.content} content={block.content}
isStreaming={isStreamingThinking} isStreaming={isStreaming}
duration={block.duration} hasFollowingContent={hasFollowingContent}
startTime={block.startTime}
/> />
</div> </div>
) )
@@ -467,53 +483,11 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
)} )}
{message.errorType === 'usage_limit' && ( {message.errorType === 'usage_limit' && (
<div className='mt-3 flex gap-1.5'> <div className='flex gap-1.5'>
<UsageLimitActions /> <UsageLimitActions />
</div> </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 */} {/* Citations if available */}
{message.citations && message.citations.length > 0 && ( {message.citations && message.citations.length > 0 && (
<div className='pt-1'> <div className='pt-1'>
@@ -533,6 +507,20 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
</div> </div>
</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>
</div> </div>
) )
@@ -570,6 +558,11 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
return false 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 // For streaming messages, check if content actually changed
if (nextProps.isStreaming) { if (nextProps.isStreaming) {
const prevBlocks = prevMessage.contentBlocks || [] const prevBlocks = prevMessage.contentBlocks || []

View File

@@ -1,5 +1,6 @@
export * from './copilot-message/copilot-message' export * from './copilot-message/copilot-message'
export * from './plan-mode-section/plan-mode-section' export * from './plan-mode-section/plan-mode-section'
export * from './queued-messages/queued-messages'
export * from './todo-list/todo-list' export * from './todo-list/todo-list'
export * from './tool-call/tool-call' export * from './tool-call/tool-call'
export * from './user-input/user-input' 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 { AttachedFilesDisplay } from './attached-files-display/attached-files-display'
export { ContextPills } from './context-pills/context-pills' export { ContextPills } from './context-pills/context-pills'
export { ContextUsageIndicator } from './context-usage-indicator/context-usage-indicator'
export { MentionMenu } from './mention-menu/mention-menu' export { MentionMenu } from './mention-menu/mention-menu'
export { ModeSelector } from './mode-selector/mode-selector' export { ModeSelector } from './mode-selector/mode-selector'
export { ModelSelector } from './model-selector/model-selector' export { ModelSelector } from './model-selector/model-selector'

View File

@@ -178,11 +178,12 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
/** /**
* Opens file picker dialog * 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(() => { const handleFileSelect = useCallback(() => {
if (disabled || isLoading) return if (disabled) return
fileInputRef.current?.click() fileInputRef.current?.click()
}, [disabled, isLoading]) }, [disabled])
/** /**
* Handles file input change event * Handles file input change event

View File

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

View File

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

View File

@@ -11,7 +11,6 @@ interface UseCopilotInitializationProps {
chatsLoadedForWorkflow: string | null chatsLoadedForWorkflow: string | null
setCopilotWorkflowId: (workflowId: string | null) => Promise<void> setCopilotWorkflowId: (workflowId: string | null) => Promise<void>
loadChats: (forceRefresh?: boolean) => Promise<void> loadChats: (forceRefresh?: boolean) => Promise<void>
fetchContextUsage: () => Promise<void>
loadAutoAllowedTools: () => Promise<void> loadAutoAllowedTools: () => Promise<void>
currentChat: any currentChat: any
isSendingMessage: boolean isSendingMessage: boolean
@@ -30,7 +29,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
chatsLoadedForWorkflow, chatsLoadedForWorkflow,
setCopilotWorkflowId, setCopilotWorkflowId,
loadChats, loadChats,
fetchContextUsage,
loadAutoAllowedTools, loadAutoAllowedTools,
currentChat, currentChat,
isSendingMessage, isSendingMessage,
@@ -102,18 +100,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
isSendingMessage, 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 * Load auto-allowed tools once on mount
*/ */

View File

@@ -654,17 +654,20 @@ export function ConditionInput({
} }
const removeBlock = (id: string) => { 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 // Remove any associated edges before removing the block
const handlePrefix = isRouterMode ? `router-${id}` : `condition-${id}`
const edgeIdsToRemove = edges const edgeIdsToRemove = edges
.filter((edge) => edge.sourceHandle?.startsWith(`condition-${id}`)) .filter((edge) => edge.sourceHandle?.startsWith(handlePrefix))
.map((edge) => edge.id) .map((edge) => edge.id)
if (edgeIdsToRemove.length > 0) { if (edgeIdsToRemove.length > 0) {
batchRemoveEdges(edgeIdsToRemove) batchRemoveEdges(edgeIdsToRemove)
} }
if (conditionalBlocks.length === 1) return
shouldPersistRef.current = true shouldPersistRef.current = true
setConditionalBlocks((blocks) => updateBlockTitles(blocks.filter((block) => block.id !== id))) setConditionalBlocks((blocks) => updateBlockTitles(blocks.filter((block) => block.id !== id)))
@@ -816,7 +819,9 @@ export function ConditionInput({
<Button <Button
variant='ghost' variant='ghost'
onClick={() => removeBlock(block.id)} 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)]' className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
> >
<Trash className='h-[14px] w-[14px]' /> <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 { PANEL_WIDTH } from '@/stores/constants'
import { usePanelStore } from '@/stores/panel' import { usePanelStore } from '@/stores/panel'
@@ -10,15 +10,14 @@ import { usePanelStore } from '@/stores/panel'
* @returns Resize state and handlers * @returns Resize state and handlers
*/ */
export function usePanelResize() { export function usePanelResize() {
const { setPanelWidth } = usePanelStore() const { setPanelWidth, isResizing, setIsResizing } = usePanelStore()
const [isResizing, setIsResizing] = useState(false)
/** /**
* Handles mouse down on resize handle * Handles mouse down on resize handle
*/ */
const handleMouseDown = useCallback(() => { const handleMouseDown = useCallback(() => {
setIsResizing(true) setIsResizing(true)
}, []) }, [setIsResizing])
/** /**
* Setup resize event listeners and body styles when resizing * Setup resize event listeners and body styles when resizing
@@ -52,7 +51,7 @@ export function usePanelResize() {
document.body.style.cursor = '' document.body.style.cursor = ''
document.body.style.userSelect = '' document.body.style.userSelect = ''
} }
}, [isResizing, setPanelWidth]) }, [isResizing, setPanelWidth, setIsResizing])
return { return {
isResizing, isResizing,

View File

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

View File

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

View File

@@ -199,8 +199,9 @@ const tryParseJson = (value: unknown): unknown => {
/** /**
* Formats a subblock value for display, intelligently handling nested objects and arrays. * 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 '-' if (value == null || value === '') return '-'
// Try parsing JSON strings first // 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 { value?: unknown }).value as string | undefined)
: (credValue as string | undefined) : (credValue as string | undefined)
if (prevCredRef.current !== cred) { if (prevCredRef.current !== cred) {
const hadPreviousCredential = prevCredRef.current !== undefined
prevCredRef.current = cred prevCredRef.current = cred
const keys = Object.keys(current) if (hadPreviousCredential) {
const dependentKeys = keys.filter((k) => k !== 'credential') const keys = Object.keys(current)
dependentKeys.forEach((k) => collaborativeSetSubblockValue(id, k, '')) const dependentKeys = keys.filter((k) => k !== 'credential')
dependentKeys.forEach((k) => collaborativeSetSubblockValue(id, k, ''))
}
} }
}, [id, collaborativeSetSubblockValue]) }, [id, collaborativeSetSubblockValue])
@@ -863,7 +867,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
return parsed.map((item: unknown, index: number) => { return parsed.map((item: unknown, index: number) => {
const routeItem = item as { id?: string; value?: string } const routeItem = item as { id?: string; value?: string }
return { 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 ?? '', value: routeItem?.value ?? '',
} }
}) })
@@ -873,7 +878,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
logger.warn('Failed to parse router routes value', { error, blockId: id }) 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]) }, [type, subBlockState, id])
/** /**

View File

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

View File

@@ -987,6 +987,14 @@ const WorkflowContent = React.memo(() => {
const handleId = conditionHandles[0].getAttribute('data-handleid') const handleId = conditionHandles[0].getAttribute('data-handleid')
if (handleId) return 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') { } else if (block.type === 'loop') {
return 'loop-end-source' return 'loop-end-source'
} else if (block.type === 'parallel') { } else if (block.type === 'parallel') {
@@ -3342,8 +3350,6 @@ const WorkflowContent = React.memo(() => {
<LazyChat /> <LazyChat />
</Suspense> </Suspense>
<DiffControls />
{/* Context Menus */} {/* Context Menus */}
<BlockContextMenu <BlockContextMenu
isOpen={isBlockMenuOpen} isOpen={isBlockMenuOpen}
@@ -3399,6 +3405,8 @@ const WorkflowContent = React.memo(() => {
<Panel /> <Panel />
</div> </div>
<DiffControls />
<Terminal /> <Terminal />
{oauthModal && ( {oauthModal && (

View File

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

View File

@@ -115,25 +115,26 @@ Description: ${route.value || 'No description provided'}
) )
.join('\n') .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: Available Routes:
${routesInfo} ${routesInfo}
Context to analyze: Context to route:
${context} ${context}
Instructions: ROUTING RULES:
1. Carefully analyze the context against each route's description 1. ALWAYS prefer selecting a route over NO_MATCH
2. Select the route that best matches the context's intent and requirements 2. Pick the route whose description BEST matches the context, even if it's not a perfect match
3. Consider the semantic meaning, not just keyword matching 3. If the context is even partially related to a route's description, select that route
4. If multiple routes could match, choose the most specific one 4. ONLY output NO_MATCH if the context is completely unrelated to ALL route descriptions
Response Format: OUTPUT FORMAT:
Return ONLY the route ID as a single string, no punctuation, no explanation. - Output EXACTLY one route ID (copied exactly as shown above) OR "NO_MATCH"
Example: "route-abc123" - 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

@@ -278,14 +278,24 @@ export class RouterBlockHandler implements BlockHandler {
const result = await response.json() const result = await response.json()
const chosenRouteId = result.content.trim() 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) const chosenRoute = routes.find((r) => r.id === chosenRouteId)
// Throw error if LLM returns invalid route ID - this routes through error path
if (!chosenRoute) { if (!chosenRoute) {
const availableRoutes = routes.map((r) => ({ id: r.id, title: r.title }))
logger.error( logger.error(
`Invalid routing decision. Response content: "${result.content}", available routes:`, `Invalid routing decision. Response content: "${result.content}". Available routes:`,
routes.map((r) => ({ id: r.id, title: r.title })) 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 // Find the target block connected to this route's handle

View File

@@ -369,7 +369,7 @@ async function processBlockMetadata(
if (userId) { if (userId) {
const permissionConfig = await getUserPermissionConfig(userId) const permissionConfig = await getUserPermissionConfig(userId)
const allowedIntegrations = permissionConfig?.allowedIntegrations 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 }) logger.debug('Block not allowed by permission group', { blockId, userId })
return null 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 // Lazy require in setState to avoid circular init issues
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import type { ToolUIConfig } from './ui-config'
const baseToolLogger = createLogger('BaseClientTool') const baseToolLogger = createLogger('BaseClientTool')
@@ -51,6 +52,11 @@ export interface BaseClientToolMetadata {
* If provided, this will override the default text in displayNames * If provided, this will override the default text in displayNames
*/ */
getDynamicText?: DynamicTextFormatter 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 { export class BaseClientTool {
@@ -258,4 +264,12 @@ export class BaseClientTool {
hasInterrupt(): boolean { hasInterrupt(): boolean {
return !!this.metadata.interrupt 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 { interface GetBlockConfigArgs {
blockType: string blockType: string
operation?: string operation?: string
trigger?: boolean
} }
export class GetBlockConfigClientTool extends BaseClientTool { export class GetBlockConfigClientTool extends BaseClientTool {
@@ -28,7 +29,7 @@ export class GetBlockConfigClientTool extends BaseClientTool {
[ClientToolCallState.generating]: { text: 'Getting block config', icon: Loader2 }, [ClientToolCallState.generating]: { text: 'Getting block config', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Getting block config', icon: Loader2 }, [ClientToolCallState.pending]: { text: 'Getting block config', icon: Loader2 },
[ClientToolCallState.executing]: { 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.error]: { text: 'Failed to get block config', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted getting block config', icon: XCircle }, [ClientToolCallState.aborted]: { text: 'Aborted getting block config', icon: XCircle },
[ClientToolCallState.rejected]: { [ClientToolCallState.rejected]: {
@@ -43,17 +44,17 @@ export class GetBlockConfigClientTool extends BaseClientTool {
switch (state) { switch (state) {
case ClientToolCallState.success: case ClientToolCallState.success:
return `Got ${blockName}${opSuffix} config` return `Retrieved ${blockName}${opSuffix} config`
case ClientToolCallState.executing: case ClientToolCallState.executing:
case ClientToolCallState.generating: case ClientToolCallState.generating:
case ClientToolCallState.pending: case ClientToolCallState.pending:
return `Getting ${blockName}${opSuffix} config` return `Retrieving ${blockName}${opSuffix} config`
case ClientToolCallState.error: case ClientToolCallState.error:
return `Failed to get ${blockName}${opSuffix} config` return `Failed to retrieve ${blockName}${opSuffix} config`
case ClientToolCallState.aborted: case ClientToolCallState.aborted:
return `Aborted getting ${blockName}${opSuffix} config` return `Aborted retrieving ${blockName}${opSuffix} config`
case ClientToolCallState.rejected: case ClientToolCallState.rejected:
return `Skipped getting ${blockName}${opSuffix} config` return `Skipped retrieving ${blockName}${opSuffix} config`
} }
} }
return undefined return undefined
@@ -65,12 +66,15 @@ export class GetBlockConfigClientTool extends BaseClientTool {
try { try {
this.setState(ClientToolCallState.executing) 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', { const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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) { if (!res.ok) {
const errorText = await res.text().catch(() => '') 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.generating]: { text: 'Getting block options', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Getting block options', icon: Loader2 }, [ClientToolCallState.pending]: { text: 'Getting block options', icon: Loader2 },
[ClientToolCallState.executing]: { 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.error]: { text: 'Failed to get block options', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted getting block options', icon: XCircle }, [ClientToolCallState.aborted]: { text: 'Aborted getting block options', icon: XCircle },
[ClientToolCallState.rejected]: { [ClientToolCallState.rejected]: {
@@ -41,17 +41,17 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
switch (state) { switch (state) {
case ClientToolCallState.success: case ClientToolCallState.success:
return `Got ${blockName} options` return `Retrieved ${blockName} options`
case ClientToolCallState.executing: case ClientToolCallState.executing:
case ClientToolCallState.generating: case ClientToolCallState.generating:
case ClientToolCallState.pending: case ClientToolCallState.pending:
return `Getting ${blockName} options` return `Retrieving ${blockName} options`
case ClientToolCallState.error: case ClientToolCallState.error:
return `Failed to get ${blockName} options` return `Failed to retrieve ${blockName} options`
case ClientToolCallState.aborted: case ClientToolCallState.aborted:
return `Aborted getting ${blockName} options` return `Aborted retrieving ${blockName} options`
case ClientToolCallState.rejected: case ClientToolCallState.rejected:
return `Skipped getting ${blockName} options` return `Skipped retrieving ${blockName} options`
} }
} }
return undefined return undefined
@@ -63,7 +63,20 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
try { try {
this.setState(ClientToolCallState.executing) 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', { const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST', 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: { displayNames: {
[ClientToolCallState.generating]: { text: 'Marking todo', icon: Loader2 }, [ClientToolCallState.generating]: { text: 'Marking todo', icon: Loader2 },
[ClientToolCallState.executing]: { 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 }, [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, type BaseClientToolMetadata,
ClientToolCallState, ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool' } 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 { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
interface MakeApiRequestArgs { interface MakeApiRequestArgs {
@@ -27,7 +28,7 @@ export class MakeApiRequestClientTool extends BaseClientTool {
[ClientToolCallState.generating]: { text: 'Preparing API request', icon: Loader2 }, [ClientToolCallState.generating]: { text: 'Preparing API request', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Review API request', icon: Globe2 }, [ClientToolCallState.pending]: { text: 'Review API request', icon: Globe2 },
[ClientToolCallState.executing]: { text: 'Executing API request', icon: Loader2 }, [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.error]: { text: 'Failed to execute API request', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped API request', icon: MinusCircle }, [ClientToolCallState.rejected]: { text: 'Skipped API request', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted API request', icon: XCircle }, [ClientToolCallState.aborted]: { text: 'Aborted API request', icon: XCircle },
@@ -36,6 +37,23 @@ export class MakeApiRequestClientTool extends BaseClientTool {
accept: { text: 'Execute', icon: Globe2 }, accept: { text: 'Execute', icon: Globe2 },
reject: { text: 'Skip', icon: MinusCircle }, 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) => { getDynamicText: (params, state) => {
if (params?.url && typeof params.url === 'string') { if (params?.url && typeof params.url === 'string') {
const method = params.method || 'GET' const method = params.method || 'GET'
@@ -110,3 +128,6 @@ export class MakeApiRequestClientTool extends BaseClientTool {
await this.handleAccept(args) 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.generating]: { text: 'Marking todo in progress', icon: Loader2 },
[ClientToolCallState.pending]: { 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.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.error]: { text: 'Failed to mark in progress', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted marking in progress', icon: MinusCircle }, [ClientToolCallState.aborted]: { text: 'Aborted marking in progress', icon: MinusCircle },
[ClientToolCallState.rejected]: { text: 'Skipped 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: { displayNames: {
[ClientToolCallState.generating]: { text: 'Requesting integration access', icon: Loader2 }, [ClientToolCallState.generating]: { text: 'Requesting integration access', icon: Loader2 },
[ClientToolCallState.pending]: { 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.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.error]: { text: 'Failed to request integration access', icon: X },
[ClientToolCallState.aborted]: { text: 'Aborted integration access request', icon: XCircle }, [ClientToolCallState.aborted]: { text: 'Aborted integration access request', icon: XCircle },
}, },
@@ -87,17 +87,16 @@ export class OAuthRequestAccessClientTool extends BaseClientTool {
switch (state) { switch (state) {
case ClientToolCallState.generating: case ClientToolCallState.generating:
case ClientToolCallState.pending: case ClientToolCallState.pending:
return `Requesting ${name} access`
case ClientToolCallState.executing: case ClientToolCallState.executing:
return `Connecting to ${name}` return `Requesting ${name} access`
case ClientToolCallState.rejected: case ClientToolCallState.rejected:
return `Skipped ${name} access` return `Skipped ${name} access`
case ClientToolCallState.success: case ClientToolCallState.success:
return `${name} connected` return `Requested ${name} access`
case ClientToolCallState.error: case ClientToolCallState.error:
return `Failed to connect ${name}` return `Failed to request ${name} access`
case ClientToolCallState.aborted: case ClientToolCallState.aborted:
return `Aborted ${name} connection` return `Aborted ${name} access request`
} }
} }
return undefined 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) 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) { } catch (e) {
logger.error('Failed to open OAuth connect modal', { error: e }) logger.error('Failed to open OAuth connect modal', { error: e })
this.setState(ClientToolCallState.error) this.setState(ClientToolCallState.error)

View File

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

View File

@@ -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.generating]: { text: 'Searching documentation', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Searching documentation', icon: Loader2 }, [ClientToolCallState.pending]: { text: 'Searching documentation', icon: Loader2 },
[ClientToolCallState.executing]: { 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.error]: { text: 'Failed to search docs', icon: XCircle },
[ClientToolCallState.aborted]: { text: 'Aborted documentation search', icon: XCircle }, [ClientToolCallState.aborted]: { text: 'Aborted documentation search', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped documentation search', icon: MinusCircle }, [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.generating]: { text: 'Searching online', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Searching online', icon: Loader2 }, [ClientToolCallState.pending]: { text: 'Searching online', icon: Loader2 },
[ClientToolCallState.executing]: { 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.error]: { text: 'Failed to search online', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped online search', icon: MinusCircle }, [ClientToolCallState.rejected]: { text: 'Skipped online search', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted online search', icon: XCircle }, [ClientToolCallState.aborted]: { text: 'Aborted online search', icon: XCircle },

View File

@@ -5,6 +5,7 @@ import {
type BaseClientToolMetadata, type BaseClientToolMetadata,
ClientToolCallState, ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool' } from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
/** Maximum sleep duration in seconds (3 minutes) */ /** Maximum sleep duration in seconds (3 minutes) */
const MAX_SLEEP_SECONDS = 180 const MAX_SLEEP_SECONDS = 180
@@ -39,11 +40,20 @@ export class SleepClientTool extends BaseClientTool {
[ClientToolCallState.pending]: { text: 'Sleeping', icon: Loader2 }, [ClientToolCallState.pending]: { text: 'Sleeping', icon: Loader2 },
[ClientToolCallState.executing]: { text: 'Sleeping', icon: Loader2 }, [ClientToolCallState.executing]: { text: 'Sleeping', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Finished sleeping', icon: Moon }, [ClientToolCallState.success]: { text: 'Finished sleeping', icon: Moon },
[ClientToolCallState.error]: { text: 'Sleep interrupted', icon: XCircle }, [ClientToolCallState.error]: { text: 'Interrupted sleep', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Sleep skipped', icon: MinusCircle }, [ClientToolCallState.rejected]: { text: 'Skipped sleep', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Sleep aborted', icon: MinusCircle }, [ClientToolCallState.aborted]: { text: 'Aborted sleep', icon: MinusCircle },
[ClientToolCallState.background]: { text: 'Resumed', icon: Moon }, [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 // No interrupt - auto-execute immediately
getDynamicText: (params, state) => { getDynamicText: (params, state) => {
const seconds = params?.seconds const seconds = params?.seconds
@@ -142,3 +152,6 @@ export class SleepClientTool extends BaseClientTool {
await this.handleAccept(args) 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, type BaseClientToolMetadata,
ClientToolCallState, ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool' } 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 { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
import { useEnvironmentStore } from '@/stores/settings/environment' import { useEnvironmentStore } from '@/stores/settings/environment'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -48,6 +49,33 @@ export class SetEnvironmentVariablesClientTool extends BaseClientTool {
accept: { text: 'Apply', icon: Settings2 }, accept: { text: 'Apply', icon: Settings2 },
reject: { text: 'Skip', icon: XCircle }, 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) => { getDynamicText: (params, state) => {
if (params?.variables && typeof params.variables === 'object') { if (params?.variables && typeof params.variables === 'object') {
const count = Object.keys(params.variables).length const count = Object.keys(params.variables).length
@@ -121,3 +149,9 @@ export class SetEnvironmentVariablesClientTool extends BaseClientTool {
await this.handleAccept(args) 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 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 { export class CheckDeploymentStatusClientTool extends BaseClientTool {
static readonly id = 'check_deployment_status' static readonly id = 'check_deployment_status'
@@ -45,52 +68,116 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool {
try { try {
this.setState(ClientToolCallState.executing) this.setState(ClientToolCallState.executing)
const { activeWorkflowId } = useWorkflowRegistry.getState() const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
const workflowId = args?.workflowId || activeWorkflowId const workflowId = args?.workflowId || activeWorkflowId
if (!workflowId) { if (!workflowId) {
throw new Error('No workflow ID provided') throw new Error('No workflow ID provided')
} }
// Fetch deployment status from API const workflow = workflows[workflowId]
const [apiDeployRes, chatDeployRes] = await Promise.all([ 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}/deploy`),
fetch(`/api/workflows/${workflowId}/chat/status`), fetch(`/api/workflows/${workflowId}/chat/status`),
workspaceId ? fetch(`/api/mcp/workflow-servers?workspaceId=${workspaceId}`) : null,
]) ])
const apiDeploy = apiDeployRes.ok ? await apiDeployRes.json() : null const apiDeploy = apiDeployRes.ok ? await apiDeployRes.json() : null
const chatDeploy = chatDeployRes.ok ? await chatDeployRes.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 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 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[] = [] const deploymentTypes: string[] = []
if (isApiDeployed) deploymentTypes.push('api')
if (isChatDeployed) deploymentTypes.push('chat')
if (isMcpDeployed) deploymentTypes.push('mcp')
if (isApiDeployed) { const isDeployed = isApiDeployed || isChatDeployed || isMcpDeployed
// Default to sync API, could be extended to detect streaming/async
deploymentTypes.push('api') // 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) this.setState(ClientToolCallState.success)
await this.markToolComplete( await this.markToolComplete(200, message, {
200, isDeployed,
isDeployed deploymentTypes,
? `Workflow is deployed as: ${deploymentTypes.join(', ')}` api: apiDetails,
: 'Workflow is not deployed', chat: chatDetails,
{ mcp: mcpDetails,
isDeployed, })
deploymentTypes,
apiDeployed: isApiDeployed, logger.info('Checked deployment status', { isDeployed, deploymentTypes })
chatDeployed: isChatDeployed,
deployedAt: apiDeploy?.deployedAt || null,
}
)
} catch (e: any) { } catch (e: any) {
logger.error('Check deployment status failed', { message: e?.message }) logger.error('Check deployment status failed', { message: e?.message })
this.setState(ClientToolCallState.error) 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 { createLogger } from '@sim/logger'
import { Loader2, Rocket, X, XCircle } from 'lucide-react' import { Loader2, Rocket, XCircle } from 'lucide-react'
import { import {
BaseClientTool, BaseClientTool,
type BaseClientToolMetadata, type BaseClientToolMetadata,
ClientToolCallState, ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool' } 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 { getInputFormatExample } from '@/lib/workflows/operations/deployment-utils'
import { useCopilotStore } from '@/stores/panel/copilot/store' import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface DeployWorkflowArgs { interface DeployApiArgs {
action: 'deploy' | 'undeploy' action: 'deploy' | 'undeploy'
deployType?: 'api' | 'chat'
workflowId?: string workflowId?: string
} }
interface ApiKeysData { /**
workspaceKeys: Array<{ id: string; name: string }> * Deploy API tool for deploying workflows as REST APIs.
personalKeys: Array<{ id: string; name: string }> * This tool handles both deploying and undeploying workflows via the API endpoint.
} */
export class DeployApiClientTool extends BaseClientTool {
export class DeployWorkflowClientTool extends BaseClientTool { static readonly id = 'deploy_api'
static readonly id = 'deploy_workflow'
constructor(toolCallId: string) { 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 { getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
// Get params from the copilot store
const toolCallsById = useCopilotStore.getState().toolCallsById const toolCallsById = useCopilotStore.getState().toolCallsById
const toolCall = toolCallsById[this.toolCallId] 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 action = params?.action || 'deploy'
const deployType = params?.deployType || 'api'
// Check if workflow is already deployed // Check if workflow is already deployed
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
@@ -45,13 +42,10 @@ export class DeployWorkflowClientTool extends BaseClientTool {
? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed ? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed
: false : 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) { if (action === 'deploy' && isAlreadyDeployed) {
buttonText = 'Redeploy' buttonText = 'Redeploy'
} else if (action === 'deploy' && deployType === 'chat') {
buttonText = 'Deploy as chat'
} }
return { return {
@@ -63,19 +57,19 @@ export class DeployWorkflowClientTool extends BaseClientTool {
static readonly metadata: BaseClientToolMetadata = { static readonly metadata: BaseClientToolMetadata = {
displayNames: { displayNames: {
[ClientToolCallState.generating]: { [ClientToolCallState.generating]: {
text: 'Preparing to deploy workflow', text: 'Preparing to deploy API',
icon: Loader2, icon: Loader2,
}, },
[ClientToolCallState.pending]: { text: 'Deploy workflow?', icon: Rocket }, [ClientToolCallState.pending]: { text: 'Deploy as API?', icon: Rocket },
[ClientToolCallState.executing]: { text: 'Deploying workflow', icon: Loader2 }, [ClientToolCallState.executing]: { text: 'Deploying API', icon: Loader2 },
[ClientToolCallState.success]: { text: 'Deployed workflow', icon: Rocket }, [ClientToolCallState.success]: { text: 'Deployed API', icon: Rocket },
[ClientToolCallState.error]: { text: 'Failed to deploy workflow', icon: X }, [ClientToolCallState.error]: { text: 'Failed to deploy API', icon: XCircle },
[ClientToolCallState.aborted]: { [ClientToolCallState.aborted]: {
text: 'Aborted deploying workflow', text: 'Aborted deploying API',
icon: XCircle, icon: XCircle,
}, },
[ClientToolCallState.rejected]: { [ClientToolCallState.rejected]: {
text: 'Skipped deploying workflow', text: 'Skipped deploying API',
icon: XCircle, icon: XCircle,
}, },
}, },
@@ -83,9 +77,17 @@ export class DeployWorkflowClientTool extends BaseClientTool {
accept: { text: 'Deploy', icon: Rocket }, accept: { text: 'Deploy', icon: Rocket },
reject: { text: 'Skip', icon: XCircle }, 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) => { getDynamicText: (params, state) => {
const action = params?.action === 'undeploy' ? 'undeploy' : 'deploy' const action = params?.action === 'undeploy' ? 'undeploy' : 'deploy'
const deployType = params?.deployType || 'api'
// Check if workflow is already deployed // Check if workflow is already deployed
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
@@ -93,48 +95,32 @@ export class DeployWorkflowClientTool extends BaseClientTool {
? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed ? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed
: false : false
// Determine action text based on deployment status
let actionText = action let actionText = action
let actionTextIng = action === 'undeploy' ? 'undeploying' : 'deploying' 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) { if (action === 'deploy' && isAlreadyDeployed) {
actionText = 'redeploy' actionText = 'redeploy'
actionTextIng = 'redeploying' actionTextIng = 'redeploying'
actionTextPast = 'redeployed'
} }
const actionCapitalized = actionText.charAt(0).toUpperCase() + actionText.slice(1) 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) { switch (state) {
case ClientToolCallState.success: case ClientToolCallState.success:
return isChatDeploy return `API ${actionTextPast}`
? 'Opened chat deployment settings'
: `${actionCapitalized}ed workflow`
case ClientToolCallState.executing: case ClientToolCallState.executing:
return isChatDeploy return `${actionCapitalized}ing API`
? 'Opening chat deployment settings'
: `${actionCapitalized}ing workflow`
case ClientToolCallState.generating: case ClientToolCallState.generating:
return `Preparing to ${displayAction} workflow` return `Preparing to ${actionText} API`
case ClientToolCallState.pending: case ClientToolCallState.pending:
return `${displayActionCapitalized} workflow?` return `${actionCapitalized} API?`
case ClientToolCallState.error: case ClientToolCallState.error:
return `Failed to ${displayAction} workflow` return `Failed to ${actionText} API`
case ClientToolCallState.aborted: case ClientToolCallState.aborted:
return isChatDeploy return `Aborted ${actionTextIng} API`
? 'Aborted opening chat deployment'
: `Aborted ${actionTextIng} workflow`
case ClientToolCallState.rejected: case ClientToolCallState.rejected:
return isChatDeploy return `Skipped ${actionTextIng} API`
? 'Skipped opening chat deployment'
: `Skipped ${actionTextIng} workflow`
} }
return undefined return undefined
}, },
@@ -162,7 +148,7 @@ export class DeployWorkflowClientTool extends BaseClientTool {
return workspaceKeys.length > 0 || personalKeys.length > 0 return workspaceKeys.length > 0 || personalKeys.length > 0
} catch (error) { } catch (error) {
const logger = createLogger('DeployWorkflowClientTool') const logger = createLogger('DeployApiClientTool')
logger.warn('Failed to check API keys:', error) logger.warn('Failed to check API keys:', error)
return false return false
} }
@@ -175,23 +161,15 @@ export class DeployWorkflowClientTool extends BaseClientTool {
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'apikeys' } })) 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> { async handleReject(): Promise<void> {
await super.handleReject() await super.handleReject()
this.setState(ClientToolCallState.rejected) this.setState(ClientToolCallState.rejected)
} }
async handleAccept(args?: DeployWorkflowArgs): Promise<void> { async handleAccept(args?: DeployApiArgs): Promise<void> {
const logger = createLogger('DeployWorkflowClientTool') const logger = createLogger('DeployApiClientTool')
try { try {
const action = args?.action || 'deploy' const action = args?.action || 'deploy'
const deployType = args?.deployType || 'api'
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState() const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
const workflowId = args?.workflowId || activeWorkflowId const workflowId = args?.workflowId || activeWorkflowId
@@ -202,22 +180,6 @@ export class DeployWorkflowClientTool extends BaseClientTool {
const workflow = workflows[workflowId] const workflow = workflows[workflowId]
const workspaceId = workflow?.workspaceId 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 // For deploy action, check if user has API keys first
if (action === 'deploy') { if (action === 'deploy') {
if (!workspaceId) { if (!workspaceId) {
@@ -227,10 +189,7 @@ export class DeployWorkflowClientTool extends BaseClientTool {
const hasKeys = await this.hasApiKeys(workspaceId) const hasKeys = await this.hasApiKeys(workspaceId)
if (!hasKeys) { if (!hasKeys) {
// Mark as rejected since we can't deploy without an API key
this.setState(ClientToolCallState.rejected) this.setState(ClientToolCallState.rejected)
// Open the API keys modal to help user create one
this.openApiKeysModal() this.openApiKeysModal()
await this.markToolComplete( await this.markToolComplete(
@@ -248,7 +207,6 @@ export class DeployWorkflowClientTool extends BaseClientTool {
this.setState(ClientToolCallState.executing) this.setState(ClientToolCallState.executing)
// Perform the deploy/undeploy action
const endpoint = `/api/workflows/${workflowId}/deploy` const endpoint = `/api/workflows/${workflowId}/deploy`
const method = action === 'deploy' ? 'POST' : 'DELETE' const method = action === 'deploy' ? 'POST' : 'DELETE'
@@ -273,25 +231,21 @@ export class DeployWorkflowClientTool extends BaseClientTool {
} }
if (action === 'deploy') { if (action === 'deploy') {
// Generate the curl command for the deployed workflow (matching deploy modal format)
const appUrl = const appUrl =
typeof window !== 'undefined' typeof window !== 'undefined'
? window.location.origin ? window.location.origin
: process.env.NEXT_PUBLIC_APP_URL || 'https://app.sim.ai' : 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' 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 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 successMessage = 'Workflow deployed successfully as API. You can now call it via REST.'
const curlCommand = `curl -X POST -H "X-API-Key: ${apiKeyPlaceholder}" -H "Content-Type: application/json"${inputExample} ${endpoint}`
successMessage = 'Workflow deployed successfully. You can now call it via the API.'
resultData = { resultData = {
...resultData, ...resultData,
endpoint, endpoint: apiEndpoint,
curlCommand, curlCommand,
apiKeyPlaceholder, apiKeyPlaceholder,
} }
@@ -316,18 +270,21 @@ export class DeployWorkflowClientTool extends BaseClientTool {
setDeploymentStatus(workflowId, false, undefined, '') setDeploymentStatus(workflowId, false, undefined, '')
} }
const actionPast = action === 'undeploy' ? 'undeployed' : 'deployed' const actionPast = action === 'undeploy' ? 'undeployed' : 'deployed'
logger.info(`Workflow ${actionPast} and registry updated`) logger.info(`Workflow ${actionPast} as API and registry updated`)
} catch (error) { } catch (error) {
logger.warn('Failed to update workflow registry:', error) logger.warn('Failed to update workflow registry:', error)
} }
} catch (e: any) { } catch (e: any) {
logger.error('Deploy/undeploy failed', { message: e?.message }) logger.error('Deploy API failed', { message: e?.message })
this.setState(ClientToolCallState.error) 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) 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, type BaseClientToolMetadata,
ClientToolCallState, ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool' } 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 { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
import { stripWorkflowDiffMarkers } from '@/lib/workflows/diff' import { stripWorkflowDiffMarkers } from '@/lib/workflows/diff'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' 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.aborted]: { text: 'Aborted editing your workflow', icon: MinusCircle },
[ClientToolCallState.pending]: { text: 'Editing your workflow', icon: Loader2 }, [ClientToolCallState.pending]: { text: 'Editing your workflow', icon: Loader2 },
}, },
uiConfig: {
isSpecial: true,
customRenderer: 'edit_summary',
},
getDynamicText: (params, state) => { getDynamicText: (params, state) => {
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
if (workflowId) { 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, GetBlockOutputsResult,
type GetBlockOutputsResultType, type GetBlockOutputsResultType,
} from '@/lib/copilot/tools/shared/schemas' } from '@/lib/copilot/tools/shared/schemas'
import { normalizeName } from '@/executor/constants'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -90,10 +89,6 @@ export class GetBlockOutputsClientTool extends BaseClientTool {
if (!block?.type) continue if (!block?.type) continue
const blockName = block.name || block.type const blockName = block.name || block.type
const normalizedBlockName = normalizeName(blockName)
let insideSubflowOutputs: string[] | undefined
let outsideSubflowOutputs: string[] | undefined
const blockOutput: GetBlockOutputsResultType['blocks'][0] = { const blockOutput: GetBlockOutputsResultType['blocks'][0] = {
blockId, blockId,
@@ -102,6 +97,11 @@ export class GetBlockOutputsClientTool extends BaseClientTool {
outputs: [], outputs: [],
} }
// Include triggerMode if the block is in trigger mode
if (block.triggerMode) {
blockOutput.triggerMode = true
}
if (block.type === 'loop' || block.type === 'parallel') { if (block.type === 'loop' || block.type === 'parallel') {
const insidePaths = getSubflowInsidePaths(block.type, blockId, loops, parallels) const insidePaths = getSubflowInsidePaths(block.type, blockId, loops, parallels)
blockOutput.insideSubflowOutputs = formatOutputsWithPrefix(insidePaths, blockName) blockOutput.insideSubflowOutputs = formatOutputsWithPrefix(insidePaths, blockName)

View File

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

View File

@@ -29,7 +29,7 @@ export class GetWorkflowDataClientTool extends BaseClientTool {
[ClientToolCallState.pending]: { text: 'Fetching workflow data', icon: Database }, [ClientToolCallState.pending]: { text: 'Fetching workflow data', icon: Database },
[ClientToolCallState.executing]: { text: 'Fetching workflow data', icon: Loader2 }, [ClientToolCallState.executing]: { text: 'Fetching workflow data', icon: Loader2 },
[ClientToolCallState.aborted]: { text: 'Aborted fetching data', icon: XCircle }, [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.error]: { text: 'Failed to fetch data', icon: X },
[ClientToolCallState.rejected]: { text: 'Skipped fetching data', icon: XCircle }, [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 { interface ManageCustomToolArgs {
operation: 'add' | 'edit' | 'delete' operation: 'add' | 'edit' | 'delete' | 'list'
toolId?: string toolId?: string
schema?: CustomToolSchema schema?: CustomToolSchema
code?: string code?: string
@@ -81,7 +81,7 @@ export class ManageCustomToolClientTool extends BaseClientTool {
reject: { text: 'Skip', icon: XCircle }, reject: { text: 'Skip', icon: XCircle },
}, },
getDynamicText: (params, state) => { 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 // Return undefined if no operation yet - use static defaults
if (!operation) return undefined if (!operation) return undefined
@@ -105,19 +105,30 @@ export class ManageCustomToolClientTool extends BaseClientTool {
return verb === 'present' ? 'Edit' : verb === 'past' ? 'Edited' : 'Editing' return verb === 'present' ? 'Edit' : verb === 'past' ? 'Edited' : 'Editing'
case 'delete': case 'delete':
return verb === 'present' ? 'Delete' : verb === 'past' ? 'Deleted' : 'Deleting' 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 add: only show tool name in past tense (success)
// For edit/delete: always show tool name // For edit/delete: always show tool name
// For list: never show individual tool name, use plural
const shouldShowToolName = (currentState: ClientToolCallState) => { const shouldShowToolName = (currentState: ClientToolCallState) => {
if (operation === 'list') return false
if (operation === 'add') { if (operation === 'add') {
return currentState === ClientToolCallState.success return currentState === ClientToolCallState.success
} }
return true // edit and delete always show tool name 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) { switch (state) {
case ClientToolCallState.success: case ClientToolCallState.success:
@@ -188,16 +199,16 @@ export class ManageCustomToolClientTool extends BaseClientTool {
async execute(args?: ManageCustomToolArgs): Promise<void> { async execute(args?: ManageCustomToolArgs): Promise<void> {
this.currentArgs = args 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 // 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) await this.handleAccept(args)
} }
// edit/delete will wait for user confirmation via handleAccept // 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( private async executeOperation(
args: ManageCustomToolArgs | undefined, args: ManageCustomToolArgs | undefined,
@@ -235,6 +246,10 @@ export class ManageCustomToolClientTool extends BaseClientTool {
case 'delete': case 'delete':
await this.deleteCustomTool({ toolId, workspaceId }, logger) await this.deleteCustomTool({ toolId, workspaceId }, logger)
break break
case 'list':
// List operation is read-only, just mark as complete
await this.markToolComplete(200, 'Listed custom tools')
break
default: default:
throw new Error(`Unknown operation: ${operation}`) throw new Error(`Unknown operation: ${operation}`)
} }

View File

@@ -7,6 +7,7 @@ import {
ClientToolCallState, ClientToolCallState,
WORKFLOW_EXECUTION_TIMEOUT_MS, WORKFLOW_EXECUTION_TIMEOUT_MS,
} from '@/lib/copilot/tools/client/base-tool' } 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 { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useExecutionStore } from '@/stores/execution' import { useExecutionStore } from '@/stores/execution'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' 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.generating]: { text: 'Preparing to run your workflow', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Run this workflow?', icon: Play }, [ClientToolCallState.pending]: { text: 'Run this workflow?', icon: Play },
[ClientToolCallState.executing]: { text: 'Running your workflow', icon: Loader2 }, [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.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.aborted]: { text: 'Aborted workflow execution', icon: MinusCircle },
[ClientToolCallState.background]: { text: 'Running in background', icon: Play }, [ClientToolCallState.background]: { text: 'Running in background', icon: Play },
}, },
@@ -39,6 +40,49 @@ export class RunWorkflowClientTool extends BaseClientTool {
accept: { text: 'Run', icon: Play }, accept: { text: 'Run', icon: Play },
reject: { text: 'Skip', icon: MinusCircle }, 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) => { getDynamicText: (params, state) => {
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
if (workflowId) { if (workflowId) {
@@ -182,3 +226,6 @@ export class RunWorkflowClientTool extends BaseClientTool {
await this.handleAccept(args) 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, type BaseClientToolMetadata,
ClientToolCallState, ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool' } from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { useVariablesStore } from '@/stores/panel/variables/store' import { useVariablesStore } from '@/stores/panel/variables/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/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.pending]: { text: 'Set workflow variables?', icon: Settings2 },
[ClientToolCallState.executing]: { text: 'Setting workflow variables', icon: Loader2 }, [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.error]: { text: 'Failed to set workflow variables', icon: X },
[ClientToolCallState.aborted]: { text: 'Aborted setting variables', icon: XCircle }, [ClientToolCallState.aborted]: { text: 'Aborted setting variables', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped 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 }, accept: { text: 'Apply', icon: Settings2 },
reject: { text: 'Skip', icon: XCircle }, 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) => { getDynamicText: (params, state) => {
if (params?.operations && Array.isArray(params.operations)) { if (params?.operations && Array.isArray(params.operations)) {
const varNames = params.operations const varNames = params.operations
@@ -243,3 +266,9 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
await this.handleAccept(args) 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 { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { PROVIDER_DEFINITIONS } from '@/providers/models' import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { tools as toolsRegistry } from '@/tools/registry' import { tools as toolsRegistry } from '@/tools/registry'
import { getTrigger, isTriggerValid } from '@/triggers'
interface InputFieldSchema { interface InputFieldSchema {
type: string type: string
@@ -107,11 +108,12 @@ function resolveSubBlockOptions(sb: SubBlockConfig): string[] | undefined {
return undefined return undefined
} }
// Return the actual option ID/value that edit_workflow expects, not the display label
return rawOptions return rawOptions
.map((opt: any) => { .map((opt: any) => {
if (!opt) return undefined if (!opt) return undefined
if (typeof opt === 'object') { 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) return String(opt)
}) })
@@ -145,13 +147,20 @@ function matchesOperation(condition: any, operation: string): boolean {
*/ */
function extractInputsFromSubBlocks( function extractInputsFromSubBlocks(
subBlocks: SubBlockConfig[], subBlocks: SubBlockConfig[],
operation?: string operation?: string,
triggerMode?: boolean
): Record<string, InputFieldSchema> { ): Record<string, InputFieldSchema> {
const inputs: Record<string, InputFieldSchema> = {} const inputs: Record<string, InputFieldSchema> = {}
for (const sb of subBlocks) { for (const sb of subBlocks) {
// Skip trigger-mode subBlocks // Handle trigger vs non-trigger mode filtering
if (sb.mode === 'trigger') continue 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 // Skip hidden subBlocks
if (sb.hidden) continue if (sb.hidden) continue
@@ -247,12 +256,53 @@ function mapSubBlockTypeToSchemaType(type: string): string {
return typeMap[type] || '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 * 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> = {} 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 is specified, try to get outputs from the specific tool
if (operation) { if (operation) {
try { try {
@@ -300,16 +350,16 @@ export const getBlockConfigServerTool: BaseServerTool<
> = { > = {
name: 'get_block_config', name: 'get_block_config',
async execute( async execute(
{ blockType, operation }: GetBlockConfigInputType, { blockType, operation, trigger }: GetBlockConfigInputType,
context?: { userId: string } context?: { userId: string }
): Promise<GetBlockConfigResultType> { ): Promise<GetBlockConfigResultType> {
const logger = createLogger('GetBlockConfigServerTool') 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 permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
const allowedIntegrations = permissionConfig?.allowedIntegrations const allowedIntegrations = permissionConfig?.allowedIntegrations
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) { if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) {
throw new Error(`Block "${blockType}" is not available`) throw new Error(`Block "${blockType}" is not available`)
} }
@@ -318,6 +368,13 @@ export const getBlockConfigServerTool: BaseServerTool<
throw new Error(`Block not found: ${blockType}`) 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 is specified, validate it exists
if (operation) { if (operation) {
const operationSubBlock = blockConfig.subBlocks?.find((sb) => sb.id === '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 subBlocks = Array.isArray(blockConfig.subBlocks) ? blockConfig.subBlocks : []
const inputs = extractInputsFromSubBlocks(subBlocks, operation) const inputs = extractInputsFromSubBlocks(subBlocks, operation, trigger)
const outputs = extractOutputs(blockConfig, operation) const outputs = extractOutputs(blockConfig, operation, trigger)
const result = { const result = {
blockType, blockType,
blockName: blockConfig.name, blockName: blockConfig.name,
operation, operation,
trigger,
inputs, inputs,
outputs, outputs,
} }

View File

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

View File

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

View File

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

View File

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

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 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 { export interface CopilotToolCall {
id: string id: string
name: string name: string
state: ClientToolCallState state: ClientToolCallState
params?: Record<string, any> params?: Record<string, any>
display?: ClientToolDisplay 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 { export interface MessageFileAttachment {
@@ -42,6 +60,18 @@ export interface CopilotMessage {
errorType?: 'usage_limit' | 'unauthorized' | 'forbidden' | 'rate_limit' | 'upgrade_required' 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 // Contexts attached to a user message
export type ChatContext = export type ChatContext =
| { kind: 'past_chat'; chatId: string; label: string } | { kind: 'past_chat'; chatId: string; label: string }
@@ -131,18 +161,11 @@ export interface CopilotState {
// Per-message metadata captured at send-time for reliable stats // 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) // Auto-allowed integration tools (tools that can run without confirmation)
autoAllowedTools: string[] autoAllowedTools: string[]
// Message queue for messages sent while another is in progress
messageQueue: QueuedMessage[]
} }
export interface CopilotActions { export interface CopilotActions {
@@ -150,7 +173,6 @@ export interface CopilotActions {
setSelectedModel: (model: CopilotStore['selectedModel']) => Promise<void> setSelectedModel: (model: CopilotStore['selectedModel']) => Promise<void>
setAgentPrefetch: (prefetch: boolean) => void setAgentPrefetch: (prefetch: boolean) => void
setEnabledModels: (models: string[] | null) => void setEnabledModels: (models: string[] | null) => void
fetchContextUsage: () => Promise<void>
setWorkflowId: (workflowId: string | null) => Promise<void> setWorkflowId: (workflowId: string | null) => Promise<void>
validateCurrentChat: () => boolean validateCurrentChat: () => boolean
@@ -220,6 +242,19 @@ export interface CopilotActions {
addAutoAllowedTool: (toolId: string) => Promise<void> addAutoAllowedTool: (toolId: string) => Promise<void>
removeAutoAllowedTool: (toolId: string) => Promise<void> removeAutoAllowedTool: (toolId: string) => Promise<void>
isToolAutoAllowed: (toolId: string) => boolean 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 export type CopilotStore = CopilotState & CopilotActions

View File

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

View File

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

View File

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

View File

@@ -5,9 +5,13 @@ export interface SidebarState {
workspaceDropdownOpen: boolean workspaceDropdownOpen: boolean
sidebarWidth: number sidebarWidth: number
isCollapsed: boolean isCollapsed: boolean
/** Whether the sidebar is currently being resized */
isResizing: boolean
_hasHydrated: boolean _hasHydrated: boolean
setWorkspaceDropdownOpen: (isOpen: boolean) => void setWorkspaceDropdownOpen: (isOpen: boolean) => void
setSidebarWidth: (width: number) => void setSidebarWidth: (width: number) => void
setIsCollapsed: (isCollapsed: boolean) => void setIsCollapsed: (isCollapsed: boolean) => void
/** Updates the sidebar resize state */
setIsResizing: (isResizing: boolean) => void
setHasHydrated: (hasHydrated: 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) { if (triggerMessageId) {
fetch('/api/copilot/stats', { fetch('/api/copilot/stats', {
method: 'POST', method: 'POST',
@@ -285,14 +286,15 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
}).catch(() => {}) }).catch(() => {})
} }
const toolCallId = await findLatestEditWorkflowToolCallId() findLatestEditWorkflowToolCallId().then((toolCallId) => {
if (toolCallId) { if (toolCallId) {
try { getClientTool(toolCallId)
await getClientTool(toolCallId)?.handleAccept?.() ?.handleAccept?.()
} catch (error) { ?.catch?.((error: Error) => {
logger.warn('Failed to notify tool accept state', { error }) logger.warn('Failed to notify tool accept state', { error })
})
} }
} })
}, },
rejectChanges: async () => { rejectChanges: async () => {
@@ -327,27 +329,26 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
}) })
const afterReject = cloneWorkflowState(baselineWorkflow) 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 // Apply baseline state locally
applyWorkflowStateToStores(baselineWorkflowId, baselineWorkflow) applyWorkflowStateToStores(baselineWorkflowId, baselineWorkflow)
// Broadcast to other users // Emit event for undo/redo recording synchronously
logger.info('Broadcasting reject to other users', {
workflowId: activeWorkflowId,
blockCount: Object.keys(baselineWorkflow.blocks).length,
})
await enqueueReplaceWorkflowState({
workflowId: activeWorkflowId,
state: baselineWorkflow,
})
// Persist to database
const persisted = await persistWorkflowStateToServer(baselineWorkflowId, baselineWorkflow)
if (!persisted) {
throw new Error('Failed to restore baseline workflow state')
}
// Emit event for undo/redo recording
if (!(window as any).__skipDiffRecording) { if (!(window as any).__skipDiffRecording) {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('record-diff-operation', { 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) { if (_triggerMessageId) {
fetch('/api/copilot/stats', { fetch('/api/copilot/stats', {
method: 'POST', method: 'POST',
@@ -374,16 +394,15 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
}).catch(() => {}) }).catch(() => {})
} }
const toolCallId = await findLatestEditWorkflowToolCallId() findLatestEditWorkflowToolCallId().then((toolCallId) => {
if (toolCallId) { if (toolCallId) {
try { getClientTool(toolCallId)
await getClientTool(toolCallId)?.handleReject?.() ?.handleReject?.()
} catch (error) { ?.catch?.((error: Error) => {
logger.warn('Failed to notify tool reject state', { error }) logger.warn('Failed to notify tool reject state', { error })
})
} }
} })
get().clearDiff({ restoreBaseline: false })
}, },
reapplyDiffMarkers: () => { reapplyDiffMarkers: () => {

View File

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