mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 07:58:06 -05:00
Compare commits
9 Commits
main
...
fix/action
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06782a881e | ||
|
|
75da0c3568 | ||
|
|
14a691ba8c | ||
|
|
530e2ac212 | ||
|
|
e59e2fdf2a | ||
|
|
9a16e7c20f | ||
|
|
283a521614 | ||
|
|
92fabe785d | ||
|
|
3ed177520a |
@@ -2,7 +2,6 @@
|
||||
title: Router
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
|
||||
import { Image } from '@/components/ui/image'
|
||||
|
||||
@@ -102,11 +101,18 @@ Input (Lead) → Router
|
||||
└── [Self-serve] → Workflow (Automated Onboarding)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
When the Router cannot determine an appropriate route for the given context, it will route to the **error path** instead of arbitrarily selecting a route. This happens when:
|
||||
|
||||
- The context doesn't clearly match any of the defined route descriptions
|
||||
- The AI determines that none of the available routes are appropriate
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Write clear route descriptions**: Each route description should clearly explain when that route should be selected. Be specific about the criteria.
|
||||
- **Make routes mutually exclusive**: When possible, ensure route descriptions don't overlap to prevent ambiguous routing decisions.
|
||||
- **Include an error/fallback route**: Add a catch-all route for unexpected inputs that don't match other routes.
|
||||
- **Connect an error path**: Handle cases where no route matches by connecting an error handler for graceful fallback behavior.
|
||||
- **Use descriptive route titles**: Route titles appear in the workflow canvas, so make them meaningful for readability.
|
||||
- **Test with diverse inputs**: Ensure the Router handles various input types, edge cases, and unexpected content.
|
||||
- **Monitor routing performance**: Review routing decisions regularly and refine route descriptions based on actual usage patterns.
|
||||
|
||||
11
apps/sim/app/_shell/providers/tooltip-provider.tsx
Normal file
11
apps/sim/app/_shell/providers/tooltip-provider.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
|
||||
interface TooltipProviderProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function TooltipProvider({ children }: TooltipProviderProps) {
|
||||
return <Tooltip.Provider>{children}</Tooltip.Provider>
|
||||
}
|
||||
@@ -58,6 +58,25 @@
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow canvas cursor styles
|
||||
* Override React Flow's default selection cursor based on canvas mode
|
||||
*/
|
||||
.workflow-container.canvas-mode-cursor .react-flow__pane,
|
||||
.workflow-container.canvas-mode-cursor .react-flow__selectionpane {
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
.workflow-container.canvas-mode-hand .react-flow__pane,
|
||||
.workflow-container.canvas-mode-hand .react-flow__selectionpane {
|
||||
cursor: grab !important;
|
||||
}
|
||||
|
||||
.workflow-container.canvas-mode-hand .react-flow__pane:active,
|
||||
.workflow-container.canvas-mode-hand .react-flow__selectionpane:active {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selected node ring indicator
|
||||
* Uses a pseudo-element overlay to match the original behavior (absolute inset-0 z-40)
|
||||
|
||||
@@ -17,25 +17,30 @@ const logger = createLogger('CopilotChatUpdateAPI')
|
||||
const UpdateMessagesSchema = z.object({
|
||||
chatId: z.string(),
|
||||
messages: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
role: z.enum(['user', 'assistant']),
|
||||
content: z.string(),
|
||||
timestamp: z.string(),
|
||||
toolCalls: z.array(z.any()).optional(),
|
||||
contentBlocks: z.array(z.any()).optional(),
|
||||
fileAttachments: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
key: z.string(),
|
||||
filename: z.string(),
|
||||
media_type: z.string(),
|
||||
size: z.number(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
role: z.enum(['user', 'assistant', 'system']),
|
||||
content: z.string(),
|
||||
timestamp: z.string(),
|
||||
toolCalls: z.array(z.any()).optional(),
|
||||
contentBlocks: z.array(z.any()).optional(),
|
||||
fileAttachments: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
key: z.string(),
|
||||
filename: z.string(),
|
||||
media_type: z.string(),
|
||||
size: z.number(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
contexts: z.array(z.any()).optional(),
|
||||
citations: z.array(z.any()).optional(),
|
||||
errorType: z.string().optional(),
|
||||
})
|
||||
.passthrough() // Preserve any additional fields for future compatibility
|
||||
),
|
||||
planArtifact: z.string().nullable().optional(),
|
||||
config: z
|
||||
@@ -57,6 +62,19 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
|
||||
// Debug: Log what we received
|
||||
const lastMsg = body.messages?.[body.messages.length - 1]
|
||||
if (lastMsg?.role === 'assistant') {
|
||||
logger.info(`[${tracker.requestId}] Received messages to save`, {
|
||||
messageCount: body.messages?.length,
|
||||
lastMsgId: lastMsg.id,
|
||||
lastMsgContentLength: lastMsg.content?.length || 0,
|
||||
lastMsgContentBlockCount: lastMsg.contentBlocks?.length || 0,
|
||||
lastMsgContentBlockTypes: lastMsg.contentBlocks?.map((b: any) => b?.type) || [],
|
||||
})
|
||||
}
|
||||
|
||||
const { chatId, messages, planArtifact, config } = UpdateMessagesSchema.parse(body)
|
||||
|
||||
// Verify that the chat belongs to the user
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ const SettingsSchema = z.object({
|
||||
superUserModeEnabled: z.boolean().optional(),
|
||||
errorNotificationsEnabled: z.boolean().optional(),
|
||||
snapToGridSize: z.number().min(0).max(50).optional(),
|
||||
showActionBar: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const defaultSettings = {
|
||||
@@ -39,6 +40,7 @@ const defaultSettings = {
|
||||
superUserModeEnabled: false,
|
||||
errorNotificationsEnabled: true,
|
||||
snapToGridSize: 0,
|
||||
showActionBar: true,
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
@@ -73,6 +75,7 @@ export async function GET() {
|
||||
superUserModeEnabled: userSettings.superUserModeEnabled ?? true,
|
||||
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
|
||||
snapToGridSize: userSettings.snapToGridSize ?? 0,
|
||||
showActionBar: userSettings.showActionBar ?? true,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
|
||||
@@ -11,6 +11,7 @@ import { HydrationErrorHandler } from '@/app/_shell/hydration-error-handler'
|
||||
import { QueryProvider } from '@/app/_shell/providers/query-provider'
|
||||
import { SessionProvider } from '@/app/_shell/providers/session-provider'
|
||||
import { ThemeProvider } from '@/app/_shell/providers/theme-provider'
|
||||
import { TooltipProvider } from '@/app/_shell/providers/tooltip-provider'
|
||||
import { season } from '@/app/_styles/fonts/season/season'
|
||||
|
||||
export const viewport: Viewport = {
|
||||
@@ -194,7 +195,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<ThemeProvider>
|
||||
<QueryProvider>
|
||||
<SessionProvider>
|
||||
<BrandedLayout>{children}</BrandedLayout>
|
||||
<TooltipProvider>
|
||||
<BrandedLayout>{children}</BrandedLayout>
|
||||
</TooltipProvider>
|
||||
</SessionProvider>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -21,12 +21,15 @@ import {
|
||||
Combobox,
|
||||
Connections,
|
||||
Copy,
|
||||
Cursor,
|
||||
DatePicker,
|
||||
DocumentAttachment,
|
||||
Duplicate,
|
||||
Expand,
|
||||
Eye,
|
||||
FolderCode,
|
||||
FolderPlus,
|
||||
Hand,
|
||||
HexSimple,
|
||||
Input,
|
||||
Key as KeyIcon,
|
||||
@@ -979,11 +982,14 @@ export default function PlaygroundPage() {
|
||||
{ Icon: ChevronDown, name: 'ChevronDown' },
|
||||
{ Icon: Connections, name: 'Connections' },
|
||||
{ Icon: Copy, name: 'Copy' },
|
||||
{ Icon: Cursor, name: 'Cursor' },
|
||||
{ Icon: DocumentAttachment, name: 'DocumentAttachment' },
|
||||
{ Icon: Duplicate, name: 'Duplicate' },
|
||||
{ Icon: Expand, name: 'Expand' },
|
||||
{ Icon: Eye, name: 'Eye' },
|
||||
{ Icon: FolderCode, name: 'FolderCode' },
|
||||
{ Icon: FolderPlus, name: 'FolderPlus' },
|
||||
{ Icon: Hand, name: 'Hand' },
|
||||
{ Icon: HexSimple, name: 'HexSimple' },
|
||||
{ Icon: KeyIcon, name: 'Key' },
|
||||
{ Icon: Layout, name: 'Layout' },
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { season } from '@/app/_styles/fonts/season/season'
|
||||
|
||||
export default function TemplatesLayoutClient({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
|
||||
<div className={`${season.variable} relative flex min-h-screen flex-col font-season`}>
|
||||
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
|
||||
{children}
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
<div className={`${season.variable} relative flex min-h-screen flex-col font-season`}>
|
||||
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
|
||||
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
|
||||
@@ -13,16 +12,14 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
|
||||
<SettingsLoader />
|
||||
<ProviderModelsLoader />
|
||||
<GlobalCommandsProvider>
|
||||
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
|
||||
<div className='flex h-screen w-full bg-[var(--bg)]'>
|
||||
<WorkspacePermissionsProvider>
|
||||
<div className='shrink-0' suppressHydrationWarning>
|
||||
<Sidebar />
|
||||
</div>
|
||||
{children}
|
||||
</WorkspacePermissionsProvider>
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
<div className='flex h-screen w-full bg-[var(--bg)]'>
|
||||
<WorkspacePermissionsProvider>
|
||||
<div className='shrink-0' suppressHydrationWarning>
|
||||
<Sidebar />
|
||||
</div>
|
||||
{children}
|
||||
</WorkspacePermissionsProvider>
|
||||
</div>
|
||||
</GlobalCommandsProvider>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { GlobalCommand } from '@/app/workspace/[workspaceId]/providers/glob
|
||||
* ad-hoc ids or shortcuts to ensure a single source of truth.
|
||||
*/
|
||||
export type CommandId =
|
||||
| 'accept-diff-changes'
|
||||
| 'add-agent'
|
||||
| 'goto-templates'
|
||||
| 'goto-logs'
|
||||
@@ -43,6 +44,11 @@ export interface CommandDefinition {
|
||||
* All global commands must be declared here to be usable.
|
||||
*/
|
||||
export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
|
||||
'accept-diff-changes': {
|
||||
id: 'accept-diff-changes',
|
||||
shortcut: 'Mod+Shift+Enter',
|
||||
allowInEditable: true,
|
||||
},
|
||||
'add-agent': {
|
||||
id: 'add-agent',
|
||||
shortcut: 'Mod+Shift+A',
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import {
|
||||
Button,
|
||||
ChevronDown,
|
||||
Cursor,
|
||||
Expand,
|
||||
Hand,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
Redo,
|
||||
Tooltip,
|
||||
Undo,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
|
||||
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useCanvasModeStore } from '@/stores/canvas-mode'
|
||||
import { useGeneralStore } from '@/stores/settings/general'
|
||||
import { useUndoRedoStore } from '@/stores/undo-redo'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('ActionBar')
|
||||
|
||||
export function ActionBar() {
|
||||
const reactFlowInstance = useReactFlow()
|
||||
const { zoomIn, zoomOut } = reactFlowInstance
|
||||
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
|
||||
const { mode, setMode } = useCanvasModeStore()
|
||||
const { undo, redo } = useCollaborativeWorkflow()
|
||||
const showActionBar = useGeneralStore((s) => s.showActionBar)
|
||||
const updateSetting = useUpdateGeneralSetting()
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const { data: session } = useSession()
|
||||
const userId = session?.user?.id || 'unknown'
|
||||
const stacks = useUndoRedoStore((s) => s.stacks)
|
||||
const key = activeWorkflowId && userId ? `${activeWorkflowId}:${userId}` : ''
|
||||
const stack = (key && stacks[key]) || { undo: [], redo: [] }
|
||||
const canUndo = stack.undo.length > 0
|
||||
const canRedo = stack.redo.length > 0
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
const [isCanvasModeOpen, setIsCanvasModeOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setContextMenu({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
|
||||
const handleHide = async () => {
|
||||
try {
|
||||
await updateSetting.mutateAsync({ key: 'showActionBar', value: false })
|
||||
} catch (error) {
|
||||
logger.error('Failed to hide action bar', error)
|
||||
} finally {
|
||||
setContextMenu(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!showActionBar) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className='-translate-x-1/2 fixed bottom-[calc(var(--terminal-height)+16px)] left-[calc((100vw+var(--sidebar-width)-var(--panel-width))/2)] z-10 flex h-[36px] items-center gap-[2px] rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] p-[4px] shadow-sm transition-[left,bottom] duration-100 ease-out'
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* Canvas Mode Selector */}
|
||||
<Popover
|
||||
open={isCanvasModeOpen}
|
||||
onOpenChange={setIsCanvasModeOpen}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center gap-[4px]'>
|
||||
<Button className='h-[28px] w-[28px] rounded-[6px] p-0' variant='active'>
|
||||
{mode === 'hand' ? (
|
||||
<Hand className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<Cursor className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
<Button className='!p-[2px] group' variant='ghost'>
|
||||
<ChevronDown className='h-[8px] w-[10px] text-[var(--text-muted)] group-hover:text-[var(--text-secondary)]' />
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align='center' side='top' sideOffset={8} maxWidth={100} minWidth={100}>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
setMode('cursor')
|
||||
setIsCanvasModeOpen(false)
|
||||
}}
|
||||
>
|
||||
<Cursor className='h-3 w-3' />
|
||||
<span>Pointer</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
setMode('hand')
|
||||
setIsCanvasModeOpen(false)
|
||||
}}
|
||||
>
|
||||
<Hand className='h-3 w-3' />
|
||||
<span>Mover</span>
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div className='mx-[4px] h-[20px] w-[1px] bg-[var(--border)]' />
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[28px] w-[28px] rounded-[6px] p-0 hover:bg-[var(--surface-5)]'
|
||||
onClick={undo}
|
||||
disabled={!canUndo}
|
||||
>
|
||||
<Undo className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<Tooltip.Shortcut keys='⌘Z'>Undo</Tooltip.Shortcut>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[28px] w-[28px] rounded-[6px] p-0 hover:bg-[var(--surface-5)]'
|
||||
onClick={redo}
|
||||
disabled={!canRedo}
|
||||
>
|
||||
<Redo className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<Tooltip.Shortcut keys='⌘⇧Z'>Redo</Tooltip.Shortcut>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<div className='mx-[4px] h-[20px] w-[1px] bg-[var(--border)]' />
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[28px] w-[28px] rounded-[6px] p-0 hover:bg-[var(--surface-5)]'
|
||||
onClick={() => zoomOut()}
|
||||
>
|
||||
<ZoomOut className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>Zoom out</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[28px] w-[28px] rounded-[6px] p-0 hover:bg-[var(--surface-5)]'
|
||||
onClick={() => zoomIn()}
|
||||
>
|
||||
<ZoomIn className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>Zoom in</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[28px] w-[28px] rounded-[6px] p-0 hover:bg-[var(--surface-5)]'
|
||||
onClick={() => fitViewToBounds({ padding: 0.1, duration: 300 })}
|
||||
>
|
||||
<Expand className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>Zoom to fit</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
open={contextMenu !== null}
|
||||
onOpenChange={(open) => !open && setContextMenu(null)}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
colorScheme='inverted'
|
||||
>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${contextMenu?.x ?? 0}px`,
|
||||
top: `${contextMenu?.y ?? 0}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
<PopoverItem onClick={handleHide}>Hide canvas controls</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ActionBar } from './action-bar'
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
PopoverItem,
|
||||
PopoverScrollArea,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
Trash,
|
||||
} from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
@@ -896,7 +897,7 @@ export function Chat() {
|
||||
|
||||
<div className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||
{/* More menu with actions */}
|
||||
<Popover variant='default' open={moreMenuOpen} onOpenChange={setMoreMenuOpen}>
|
||||
<Popover variant='default' size='sm' open={moreMenuOpen} onOpenChange={setMoreMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -1069,17 +1070,21 @@ export function Chat() {
|
||||
|
||||
{/* Buttons positioned absolutely on the right */}
|
||||
<div className='-translate-y-1/2 absolute top-1/2 right-[2px] flex items-center gap-[10px]'>
|
||||
<Badge
|
||||
onClick={() => document.getElementById('floating-chat-file-input')?.click()}
|
||||
title='Attach file'
|
||||
className={cn(
|
||||
'!bg-transparent !border-0 cursor-pointer rounded-[6px] p-[0px]',
|
||||
(!activeWorkflowId || isExecuting || chatFiles.length >= 15) &&
|
||||
'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<Paperclip className='!h-3.5 !w-3.5' />
|
||||
</Badge>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Badge
|
||||
onClick={() => document.getElementById('floating-chat-file-input')?.click()}
|
||||
className={cn(
|
||||
'!bg-transparent !border-0 cursor-pointer rounded-[6px] p-[0px]',
|
||||
(!activeWorkflowId || isExecuting || chatFiles.length >= 15) &&
|
||||
'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<Paperclip className='!h-3.5 !w-3.5' />
|
||||
</Badge>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Attach file</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
{isStreaming ? (
|
||||
<Button
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Layout, LibraryBig, Search } from 'lucide-react'
|
||||
import { Layout, Search } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { Button, Library } from '@/components/emcn'
|
||||
import { AgentIcon } from '@/components/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
@@ -41,7 +41,7 @@ const commands: CommandItem[] = [
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
icon: LibraryBig,
|
||||
icon: Library,
|
||||
shortcut: 'L',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -27,6 +27,9 @@ export function PaneContextMenu({
|
||||
onToggleVariables,
|
||||
onToggleChat,
|
||||
onInvite,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onFitView,
|
||||
isVariablesOpen = false,
|
||||
isChatOpen = false,
|
||||
hasClipboard = false,
|
||||
@@ -113,6 +116,33 @@ export function PaneContextMenu({
|
||||
<span className='ml-auto opacity-70 group-hover:opacity-100'>⇧L</span>
|
||||
</PopoverItem>
|
||||
|
||||
{/* View actions */}
|
||||
<PopoverDivider />
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onZoomIn()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Zoom In
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onZoomOut()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Zoom Out
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onFitView()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Fit to View
|
||||
</PopoverItem>
|
||||
|
||||
{/* Navigation actions */}
|
||||
<PopoverDivider />
|
||||
<PopoverItem
|
||||
|
||||
@@ -80,6 +80,9 @@ export interface PaneContextMenuProps {
|
||||
onToggleVariables: () => void
|
||||
onToggleChat: () => void
|
||||
onInvite: () => void
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onFitView: () => void
|
||||
/** Whether the variables panel is currently open */
|
||||
isVariablesOpen?: boolean
|
||||
/** Whether the chat panel is currently open */
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { memo, useCallback } from 'react'
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import clsx from 'clsx'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
import { useCopilotStore, usePanelStore } from '@/stores/panel'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -15,28 +15,20 @@ const logger = createLogger('DiffControls')
|
||||
|
||||
export const DiffControls = memo(function DiffControls() {
|
||||
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
|
||||
const {
|
||||
isShowingDiff,
|
||||
isDiffReady,
|
||||
hasActiveDiff,
|
||||
toggleDiffView,
|
||||
acceptChanges,
|
||||
rejectChanges,
|
||||
baselineWorkflow,
|
||||
} = useWorkflowDiffStore(
|
||||
useCallback(
|
||||
(state) => ({
|
||||
isShowingDiff: state.isShowingDiff,
|
||||
isDiffReady: state.isDiffReady,
|
||||
hasActiveDiff: state.hasActiveDiff,
|
||||
toggleDiffView: state.toggleDiffView,
|
||||
acceptChanges: state.acceptChanges,
|
||||
rejectChanges: state.rejectChanges,
|
||||
baselineWorkflow: state.baselineWorkflow,
|
||||
}),
|
||||
[]
|
||||
const isPanelResizing = usePanelStore((state) => state.isResizing)
|
||||
const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges, baselineWorkflow } =
|
||||
useWorkflowDiffStore(
|
||||
useCallback(
|
||||
(state) => ({
|
||||
isDiffReady: state.isDiffReady,
|
||||
hasActiveDiff: state.hasActiveDiff,
|
||||
acceptChanges: state.acceptChanges,
|
||||
rejectChanges: state.rejectChanges,
|
||||
baselineWorkflow: state.baselineWorkflow,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const { updatePreviewToolCallState, currentChat, messages } = useCopilotStore(
|
||||
useCallback(
|
||||
@@ -53,11 +45,6 @@ export const DiffControls = memo(function DiffControls() {
|
||||
useCallback((state) => ({ activeWorkflowId: state.activeWorkflowId }), [])
|
||||
)
|
||||
|
||||
const handleToggleDiff = useCallback(() => {
|
||||
logger.info('Toggling diff view', { currentState: isShowingDiff })
|
||||
toggleDiffView()
|
||||
}, [isShowingDiff, toggleDiffView])
|
||||
|
||||
const createCheckpoint = useCallback(async () => {
|
||||
if (!activeWorkflowId || !currentChat?.id) {
|
||||
logger.warn('Cannot create checkpoint: missing workflowId or chatId', {
|
||||
@@ -206,54 +193,47 @@ export const DiffControls = memo(function DiffControls() {
|
||||
}
|
||||
}, [activeWorkflowId, currentChat, messages, baselineWorkflow])
|
||||
|
||||
const handleAccept = useCallback(async () => {
|
||||
const handleAccept = useCallback(() => {
|
||||
logger.info('Accepting proposed changes with backup protection')
|
||||
|
||||
// Resolve target toolCallId for build/edit and update to terminal success state in the copilot store
|
||||
// This happens synchronously first for instant UI feedback
|
||||
try {
|
||||
// Create a checkpoint before applying changes so it appears under the triggering user message
|
||||
await createCheckpoint().catch((error) => {
|
||||
logger.warn('Failed to create checkpoint before accept:', error)
|
||||
})
|
||||
|
||||
// Resolve target toolCallId for build/edit and update to terminal success state in the copilot store
|
||||
try {
|
||||
const { toolCallsById, messages } = useCopilotStore.getState()
|
||||
let id: string | undefined
|
||||
outer: for (let mi = messages.length - 1; mi >= 0; mi--) {
|
||||
const m = messages[mi]
|
||||
if (m.role !== 'assistant' || !m.contentBlocks) continue
|
||||
const blocks = m.contentBlocks as any[]
|
||||
for (let bi = blocks.length - 1; bi >= 0; bi--) {
|
||||
const b = blocks[bi]
|
||||
if (b?.type === 'tool_call') {
|
||||
const tn = b.toolCall?.name
|
||||
if (tn === 'edit_workflow') {
|
||||
id = b.toolCall?.id
|
||||
break outer
|
||||
}
|
||||
const { toolCallsById, messages } = useCopilotStore.getState()
|
||||
let id: string | undefined
|
||||
outer: for (let mi = messages.length - 1; mi >= 0; mi--) {
|
||||
const m = messages[mi]
|
||||
if (m.role !== 'assistant' || !m.contentBlocks) continue
|
||||
const blocks = m.contentBlocks as any[]
|
||||
for (let bi = blocks.length - 1; bi >= 0; bi--) {
|
||||
const b = blocks[bi]
|
||||
if (b?.type === 'tool_call') {
|
||||
const tn = b.toolCall?.name
|
||||
if (tn === 'edit_workflow') {
|
||||
id = b.toolCall?.id
|
||||
break outer
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!id) {
|
||||
const candidates = Object.values(toolCallsById).filter((t) => t.name === 'edit_workflow')
|
||||
id = candidates.length ? candidates[candidates.length - 1].id : undefined
|
||||
}
|
||||
if (id) updatePreviewToolCallState('accepted', id)
|
||||
} catch {}
|
||||
}
|
||||
if (!id) {
|
||||
const candidates = Object.values(toolCallsById).filter((t) => t.name === 'edit_workflow')
|
||||
id = candidates.length ? candidates[candidates.length - 1].id : undefined
|
||||
}
|
||||
if (id) updatePreviewToolCallState('accepted', id)
|
||||
} catch {}
|
||||
|
||||
// Accept changes without blocking the UI; errors will be logged by the store handler
|
||||
acceptChanges().catch((error) => {
|
||||
logger.error('Failed to accept changes (background):', error)
|
||||
})
|
||||
// Accept changes without blocking the UI; errors will be logged by the store handler
|
||||
acceptChanges().catch((error) => {
|
||||
logger.error('Failed to accept changes (background):', error)
|
||||
})
|
||||
|
||||
logger.info('Accept triggered; UI will update optimistically')
|
||||
} catch (error) {
|
||||
logger.error('Failed to accept changes:', error)
|
||||
// Create checkpoint in the background (fire-and-forget) so it doesn't block UI
|
||||
createCheckpoint().catch((error) => {
|
||||
logger.warn('Failed to create checkpoint after accept:', error)
|
||||
})
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
logger.error('Workflow update failed:', errorMessage)
|
||||
alert(`Failed to save workflow changes: ${errorMessage}`)
|
||||
}
|
||||
logger.info('Accept triggered; UI will update optimistically')
|
||||
}, [createCheckpoint, updatePreviewToolCallState, acceptChanges])
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
@@ -293,54 +273,82 @@ export const DiffControls = memo(function DiffControls() {
|
||||
|
||||
const preventZoomRef = usePreventZoom()
|
||||
|
||||
// Register global command to accept changes (Cmd/Ctrl + Shift + Enter)
|
||||
const acceptCommand = useMemo(
|
||||
() =>
|
||||
createCommand({
|
||||
id: 'accept-diff-changes',
|
||||
handler: () => {
|
||||
if (hasActiveDiff && isDiffReady) {
|
||||
handleAccept()
|
||||
}
|
||||
},
|
||||
}),
|
||||
[hasActiveDiff, isDiffReady, handleAccept]
|
||||
)
|
||||
useRegisterGlobalCommands([acceptCommand])
|
||||
|
||||
// Don't show anything if no diff is available or diff is not ready
|
||||
if (!hasActiveDiff || !isDiffReady) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isResizing = isTerminalResizing || isPanelResizing
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={preventZoomRef}
|
||||
className={clsx(
|
||||
'-translate-x-1/2 fixed left-1/2 z-30',
|
||||
!isTerminalResizing && 'transition-[bottom] duration-100 ease-out'
|
||||
'fixed z-30',
|
||||
!isResizing && 'transition-[bottom,right] duration-100 ease-out'
|
||||
)}
|
||||
style={{ bottom: 'calc(var(--terminal-height) + 40px)' }}
|
||||
style={{
|
||||
bottom: 'calc(var(--terminal-height) + 8px)',
|
||||
right: 'calc(var(--panel-width) + 8px)',
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center gap-[6px] rounded-[10px] p-[6px]'>
|
||||
{/* Toggle (left, icon-only) */}
|
||||
<Button
|
||||
variant='active'
|
||||
onClick={handleToggleDiff}
|
||||
className='h-[30px] w-[30px] rounded-[8px] p-0'
|
||||
title={isShowingDiff ? 'View original' : 'Preview changes'}
|
||||
>
|
||||
{isShowingDiff ? (
|
||||
<Eye className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<EyeOff className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Reject */}
|
||||
<Button
|
||||
variant='active'
|
||||
<div
|
||||
className='group relative flex h-[30px] overflow-hidden rounded-[4px]'
|
||||
style={{ isolation: 'isolate' }}
|
||||
>
|
||||
{/* Reject side */}
|
||||
<button
|
||||
onClick={handleReject}
|
||||
className='h-[30px] rounded-[8px] px-3'
|
||||
title='Reject changes'
|
||||
className='relative flex h-full items-center border border-[var(--border)] bg-[var(--surface-4)] pr-[20px] pl-[12px] font-medium text-[13px] text-[var(--text-secondary)] transition-colors hover:border-[var(--border-1)] hover:bg-[var(--surface-6)] hover:text-[var(--text-primary)] dark:hover:bg-[var(--surface-5)]'
|
||||
style={{
|
||||
clipPath: 'polygon(0 0, calc(100% + 10px) 0, 100% 100%, 0 100%)',
|
||||
borderRadius: '4px 0 0 4px',
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
|
||||
{/* Accept */}
|
||||
<Button
|
||||
variant='tertiary'
|
||||
</button>
|
||||
{/* Slanted divider - split gray/green */}
|
||||
<div
|
||||
className='pointer-events-none absolute top-0 bottom-0 z-10'
|
||||
style={{
|
||||
left: '66px',
|
||||
width: '2px',
|
||||
transform: 'skewX(-18.4deg)',
|
||||
background:
|
||||
'linear-gradient(to right, var(--border) 50%, color-mix(in srgb, var(--brand-tertiary-2) 70%, black) 50%)',
|
||||
}}
|
||||
/>
|
||||
{/* Accept side */}
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
className='h-[30px] rounded-[8px] px-3'
|
||||
title='Accept changes'
|
||||
title='Accept changes (⇧⌘⏎)'
|
||||
className='-ml-[10px] relative flex h-full items-center border border-[rgba(0,0,0,0.15)] bg-[var(--brand-tertiary-2)] pr-[12px] pl-[20px] font-medium text-[13px] text-[var(--text-inverse)] transition-[background-color,border-color,fill,stroke] hover:brightness-110 dark:border-[rgba(255,255,255,0.1)]'
|
||||
style={{
|
||||
clipPath: 'polygon(10px 0, 100% 0, 100% 100%, 0 100%)',
|
||||
borderRadius: '0 4px 4px 0',
|
||||
}}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<kbd className='ml-2 rounded border border-white/20 bg-white/10 px-1.5 py-0.5 font-medium font-sans text-[10px]'>
|
||||
⇧⌘<span className='translate-y-[-1px]'>⏎</span>
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { ActionBar } from './action-bar'
|
||||
export { CommandList } from './command-list/command-list'
|
||||
export { Cursors } from './cursors/cursors'
|
||||
export { DiffControls } from './diff-controls/diff-controls'
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
openCopilotWithMessage,
|
||||
useNotificationStore,
|
||||
} from '@/stores/notifications'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { useTerminalStore } from '@/stores/terminal'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
@@ -19,7 +20,7 @@ const MAX_VISIBLE_NOTIFICATIONS = 4
|
||||
|
||||
/**
|
||||
* Notifications display component
|
||||
* Positioned in the bottom-right workspace area, aligned with terminal and panel spacing
|
||||
* Positioned in the bottom-left workspace area, reactive to sidebar width and terminal height
|
||||
* Shows both global notifications and workflow-specific notifications
|
||||
*/
|
||||
export const Notifications = memo(function Notifications() {
|
||||
@@ -36,6 +37,7 @@ export const Notifications = memo(function Notifications() {
|
||||
.slice(0, MAX_VISIBLE_NOTIFICATIONS)
|
||||
}, [allNotifications, activeWorkflowId])
|
||||
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
|
||||
const isSidebarResizing = useSidebarStore((state) => state.isResizing)
|
||||
|
||||
/**
|
||||
* Executes a notification action and handles side effects.
|
||||
@@ -103,12 +105,14 @@ export const Notifications = memo(function Notifications() {
|
||||
return null
|
||||
}
|
||||
|
||||
const isResizing = isTerminalResizing || isSidebarResizing
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={preventZoomRef}
|
||||
className={clsx(
|
||||
'fixed right-[calc(var(--panel-width)+16px)] bottom-[calc(var(--terminal-height)+16px)] z-30 flex flex-col items-end',
|
||||
!isTerminalResizing && 'transition-[bottom] duration-100 ease-out'
|
||||
'fixed bottom-[calc(var(--terminal-height)+16px)] left-[calc(var(--sidebar-width)+16px)] z-30 flex flex-col items-start',
|
||||
!isResizing && 'transition-[bottom,left] duration-100 ease-out'
|
||||
)}
|
||||
>
|
||||
{[...visibleNotifications].reverse().map((notification, index, stacked) => {
|
||||
|
||||
@@ -3,75 +3,23 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
import CopilotMarkdownRenderer from './markdown-renderer'
|
||||
|
||||
/**
|
||||
* Max height for thinking content before internal scrolling kicks in
|
||||
*/
|
||||
const THINKING_MAX_HEIGHT = 200
|
||||
|
||||
/**
|
||||
* Interval for auto-scroll during streaming (ms)
|
||||
*/
|
||||
const SCROLL_INTERVAL = 100
|
||||
|
||||
/**
|
||||
* Timer update interval in milliseconds
|
||||
*/
|
||||
const TIMER_UPDATE_INTERVAL = 100
|
||||
|
||||
/**
|
||||
* Milliseconds threshold for displaying as seconds
|
||||
*/
|
||||
const SECONDS_THRESHOLD = 1000
|
||||
|
||||
/**
|
||||
* Props for the ShimmerOverlayText component
|
||||
*/
|
||||
interface ShimmerOverlayTextProps {
|
||||
/** Label text to display */
|
||||
label: string
|
||||
/** Value text to display */
|
||||
value: string
|
||||
/** Whether the shimmer animation is active */
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* ShimmerOverlayText component for thinking block
|
||||
* Applies shimmer effect to the "Thought for X.Xs" text during streaming
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Text with optional shimmer overlay effect
|
||||
*/
|
||||
function ShimmerOverlayText({ label, value, active = false }: ShimmerOverlayTextProps) {
|
||||
return (
|
||||
<span className='relative inline-block'>
|
||||
<span className='text-[var(--text-tertiary)]'>{label}</span>
|
||||
<span className='text-[var(--text-muted)]'>{value}</span>
|
||||
{active ? (
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
|
||||
>
|
||||
<span
|
||||
className='block text-transparent'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
WebkitBackgroundClip: 'text',
|
||||
backgroundClip: 'text',
|
||||
animation: 'thinking-shimmer 1.4s ease-in-out infinite',
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{value}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
<style>{`
|
||||
@keyframes thinking-shimmer {
|
||||
0% { background-position: 150% 0; }
|
||||
50% { background-position: 0% 0; }
|
||||
100% { background-position: -150% 0; }
|
||||
}
|
||||
`}</style>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the ThinkingBlock component
|
||||
*/
|
||||
@@ -80,16 +28,19 @@ interface ThinkingBlockProps {
|
||||
content: string
|
||||
/** Whether the block is currently streaming */
|
||||
isStreaming?: boolean
|
||||
/** Persisted duration from content block */
|
||||
duration?: number
|
||||
/** Persisted start time from content block */
|
||||
startTime?: number
|
||||
/** Whether there are more content blocks after this one (e.g., tool calls) */
|
||||
hasFollowingContent?: boolean
|
||||
/** Custom label for the thinking block (e.g., "Thinking", "Exploring"). Defaults to "Thought" */
|
||||
label?: string
|
||||
/** Whether special tags (plan, options) are present - triggers collapse */
|
||||
hasSpecialTags?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* ThinkingBlock component displays AI reasoning/thinking process
|
||||
* Shows collapsible content with duration timer
|
||||
* Auto-expands during streaming and collapses when complete
|
||||
* Auto-collapses when a tool call or other content comes in after it
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Thinking block with expandable content and timer
|
||||
@@ -97,112 +48,248 @@ interface ThinkingBlockProps {
|
||||
export function ThinkingBlock({
|
||||
content,
|
||||
isStreaming = false,
|
||||
duration: persistedDuration,
|
||||
startTime: persistedStartTime,
|
||||
hasFollowingContent = false,
|
||||
label = 'Thought',
|
||||
hasSpecialTags = false,
|
||||
}: ThinkingBlockProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [duration, setDuration] = useState(persistedDuration ?? 0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [userHasScrolledAway, setUserHasScrolledAway] = useState(false)
|
||||
const userCollapsedRef = useRef<boolean>(false)
|
||||
const startTimeRef = useRef<number>(persistedStartTime ?? Date.now())
|
||||
|
||||
/**
|
||||
* Updates start time reference when persisted start time changes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (typeof persistedStartTime === 'number') {
|
||||
startTimeRef.current = persistedStartTime
|
||||
}
|
||||
}, [persistedStartTime])
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const startTimeRef = useRef<number>(Date.now())
|
||||
const lastScrollTopRef = useRef(0)
|
||||
const programmaticScrollRef = useRef(false)
|
||||
|
||||
/**
|
||||
* Auto-expands block when streaming with content
|
||||
* Auto-collapses when streaming ends
|
||||
* Auto-collapses when streaming ends OR when following content arrives
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isStreaming) {
|
||||
// Collapse if streaming ended or if there's following content (like a tool call)
|
||||
if (!isStreaming || hasFollowingContent) {
|
||||
setIsExpanded(false)
|
||||
userCollapsedRef.current = false
|
||||
setUserHasScrolledAway(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!userCollapsedRef.current && content && content.trim().length > 0) {
|
||||
setIsExpanded(true)
|
||||
}
|
||||
}, [isStreaming, content])
|
||||
}, [isStreaming, content, hasFollowingContent])
|
||||
|
||||
/**
|
||||
* Updates duration timer during streaming
|
||||
* Uses persisted duration when available
|
||||
*/
|
||||
// Reset start time when streaming begins
|
||||
useEffect(() => {
|
||||
if (typeof persistedDuration === 'number') {
|
||||
setDuration(persistedDuration)
|
||||
return
|
||||
if (isStreaming && !hasFollowingContent) {
|
||||
startTimeRef.current = Date.now()
|
||||
setDuration(0)
|
||||
setUserHasScrolledAway(false)
|
||||
}
|
||||
}, [isStreaming, hasFollowingContent])
|
||||
|
||||
// Update duration timer during streaming (stop when following content arrives)
|
||||
useEffect(() => {
|
||||
// Stop timer if not streaming or if there's following content (thinking is done)
|
||||
if (!isStreaming || hasFollowingContent) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setDuration(Date.now() - startTimeRef.current)
|
||||
}, TIMER_UPDATE_INTERVAL)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isStreaming, hasFollowingContent])
|
||||
|
||||
// Handle scroll events to detect user scrolling away
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container || !isExpanded) return
|
||||
|
||||
const handleScroll = () => {
|
||||
if (programmaticScrollRef.current) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
const isNearBottom = distanceFromBottom <= 20
|
||||
|
||||
const delta = scrollTop - lastScrollTopRef.current
|
||||
const movedUp = delta < -2
|
||||
|
||||
if (movedUp && !isNearBottom) {
|
||||
setUserHasScrolledAway(true)
|
||||
}
|
||||
|
||||
// Re-stick if user scrolls back to bottom
|
||||
if (userHasScrolledAway && isNearBottom) {
|
||||
setUserHasScrolledAway(false)
|
||||
}
|
||||
|
||||
lastScrollTopRef.current = scrollTop
|
||||
}
|
||||
|
||||
if (isStreaming) {
|
||||
const interval = setInterval(() => {
|
||||
setDuration(Date.now() - startTimeRef.current)
|
||||
}, TIMER_UPDATE_INTERVAL)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
lastScrollTopRef.current = container.scrollTop
|
||||
|
||||
setDuration(Date.now() - startTimeRef.current)
|
||||
}, [isStreaming, persistedDuration])
|
||||
return () => container.removeEventListener('scroll', handleScroll)
|
||||
}, [isExpanded, userHasScrolledAway])
|
||||
|
||||
// Smart auto-scroll: only scroll if user hasn't scrolled away
|
||||
useEffect(() => {
|
||||
if (!isStreaming || !isExpanded || userHasScrolledAway) return
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
const isNearBottom = distanceFromBottom <= 50
|
||||
|
||||
if (isNearBottom) {
|
||||
programmaticScrollRef.current = true
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
window.setTimeout(() => {
|
||||
programmaticScrollRef.current = false
|
||||
}, 150)
|
||||
}
|
||||
}, SCROLL_INTERVAL)
|
||||
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [isStreaming, isExpanded, userHasScrolledAway])
|
||||
|
||||
/**
|
||||
* Formats duration in milliseconds to human-readable format
|
||||
* @param ms - Duration in milliseconds
|
||||
* @returns Formatted string (e.g., "150ms" or "2.5s")
|
||||
* Formats duration in milliseconds to seconds
|
||||
* Always shows seconds, rounded to nearest whole second, minimum 1s
|
||||
*/
|
||||
const formatDuration = (ms: number) => {
|
||||
if (ms < SECONDS_THRESHOLD) {
|
||||
return `${ms}ms`
|
||||
}
|
||||
const seconds = (ms / SECONDS_THRESHOLD).toFixed(1)
|
||||
const seconds = Math.max(1, Math.round(ms / 1000))
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
const hasContent = content && content.trim().length > 0
|
||||
// Thinking is "done" when streaming ends OR when there's following content (like a tool call) OR when special tags appear
|
||||
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
|
||||
const durationText = `${label} for ${formatDuration(duration)}`
|
||||
// Convert past tense label to present tense for streaming (e.g., "Thought" → "Thinking")
|
||||
const getStreamingLabel = (lbl: string) => {
|
||||
if (lbl === 'Thought') return 'Thinking'
|
||||
if (lbl.endsWith('ed')) return `${lbl.slice(0, -2)}ing`
|
||||
return lbl
|
||||
}
|
||||
const streamingLabel = getStreamingLabel(label)
|
||||
|
||||
// During streaming: show header with shimmer effect + expanded content
|
||||
if (!isThinkingDone) {
|
||||
return (
|
||||
<div>
|
||||
{/* Define shimmer keyframes */}
|
||||
<style>{`
|
||||
@keyframes thinking-shimmer {
|
||||
0% { background-position: 150% 0; }
|
||||
50% { background-position: 0% 0; }
|
||||
100% { background-position: -150% 0; }
|
||||
}
|
||||
`}</style>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsExpanded((v) => {
|
||||
const next = !v
|
||||
if (!next) userCollapsedRef.current = true
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className='group inline-flex items-center gap-1 text-left font-[470] font-season text-[var(--text-secondary)] text-sm transition-colors hover:text-[var(--text-primary)]'
|
||||
type='button'
|
||||
>
|
||||
<span className='relative inline-block'>
|
||||
<span className='text-[var(--text-tertiary)]'>{streamingLabel}</span>
|
||||
<span
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
|
||||
>
|
||||
<span
|
||||
className='block text-transparent'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
WebkitBackgroundClip: 'text',
|
||||
backgroundClip: 'text',
|
||||
animation: 'thinking-shimmer 1.4s ease-in-out infinite',
|
||||
mixBlendMode: 'screen',
|
||||
}}
|
||||
>
|
||||
{streamingLabel}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{hasContent && (
|
||||
<ChevronUp
|
||||
className={clsx(
|
||||
'h-3 w-3 transition-all group-hover:opacity-100',
|
||||
isExpanded ? 'rotate-180 opacity-100' : 'rotate-90 opacity-0'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={clsx(
|
||||
'overflow-y-auto transition-all duration-300 ease-in-out',
|
||||
isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
{/* Render markdown during streaming with thinking text styling */}
|
||||
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-none [&_*]:!m-0 [&_*]:!p-0 [&_*]:!mb-0 [&_*]:!mt-0 [&_p]:!m-0 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_code]:!text-[11px] [&_ul]:!pl-4 [&_ul]:!my-0 [&_ol]:!pl-4 [&_ol]:!my-0 [&_li]:!my-0 [&_li]:!py-0 [&_br]:!leading-[0.5] whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-muted)] leading-none'>
|
||||
<CopilotMarkdownRenderer content={content} />
|
||||
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-[var(--text-muted)]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// After done: show collapsible header with duration
|
||||
return (
|
||||
<div className='mt-1 mb-0'>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsExpanded((v) => {
|
||||
const next = !v
|
||||
// If user collapses during streaming, remember to not auto-expand again
|
||||
if (!next && isStreaming) userCollapsedRef.current = true
|
||||
return next
|
||||
})
|
||||
setIsExpanded((v) => !v)
|
||||
}}
|
||||
className='mb-1 inline-flex items-center gap-1 text-left font-[470] font-season text-[var(--text-secondary)] text-sm transition-colors hover:text-[var(--text-primary)]'
|
||||
className='group inline-flex items-center gap-1 text-left font-[470] font-season text-[var(--text-secondary)] text-sm transition-colors hover:text-[var(--text-primary)]'
|
||||
type='button'
|
||||
disabled={!hasContent}
|
||||
>
|
||||
<ShimmerOverlayText
|
||||
label='Thought'
|
||||
value={` for ${formatDuration(duration)}`}
|
||||
active={isStreaming}
|
||||
/>
|
||||
<span className='text-[var(--text-tertiary)]'>{durationText}</span>
|
||||
{hasContent && (
|
||||
<ChevronUp
|
||||
className={clsx('h-3 w-3 transition-transform', isExpanded && 'rotate-180')}
|
||||
className={clsx(
|
||||
'h-3 w-3 transition-all group-hover:opacity-100',
|
||||
isExpanded ? 'rotate-180 opacity-100' : 'rotate-90 opacity-0'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className='ml-1 border-[var(--border-1)] border-l-2 pl-2'>
|
||||
<pre className='whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-tertiary)] leading-[1.15rem]'>
|
||||
{content}
|
||||
{isStreaming && (
|
||||
<span className='ml-1 inline-block h-2 w-1 animate-pulse bg-[var(--text-tertiary)]' />
|
||||
)}
|
||||
</pre>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={clsx(
|
||||
'overflow-y-auto transition-all duration-300 ease-in-out',
|
||||
isExpanded ? 'mt-1.5 max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
{/* Use markdown renderer for completed content */}
|
||||
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-none [&_*]:!m-0 [&_*]:!p-0 [&_*]:!mb-0 [&_*]:!mt-0 [&_p]:!m-0 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_code]:!text-[11px] [&_ul]:!pl-4 [&_ul]:!my-0 [&_ol]:!pl-4 [&_ol]:!my-0 [&_li]:!my-0 [&_li]:!py-0 [&_br]:!leading-[0.5] whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-muted)] leading-none'>
|
||||
<CopilotMarkdownRenderer content={content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { type FC, memo, useMemo, useState } from 'react'
|
||||
import { Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from 'lucide-react'
|
||||
import { type FC, memo, useCallback, useMemo, useState } from 'react'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { ToolCall } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
|
||||
import {
|
||||
OptionsSelector,
|
||||
parseSpecialTags,
|
||||
ToolCall,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
|
||||
import {
|
||||
FileAttachmentDisplay,
|
||||
SmoothStreamingText,
|
||||
@@ -15,8 +19,6 @@ import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId
|
||||
import {
|
||||
useCheckpointManagement,
|
||||
useMessageEditing,
|
||||
useMessageFeedback,
|
||||
useSuccessTimers,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks'
|
||||
import { UserInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
|
||||
import type { CopilotMessage as CopilotMessageType } from '@/stores/panel'
|
||||
@@ -40,6 +42,8 @@ interface CopilotMessageProps {
|
||||
onEditModeChange?: (isEditing: boolean, cancelCallback?: () => void) => void
|
||||
/** Callback when revert mode changes */
|
||||
onRevertModeChange?: (isReverting: boolean) => void
|
||||
/** Whether this is the last message in the conversation */
|
||||
isLastMessage?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,6 +63,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
checkpointCount = 0,
|
||||
onEditModeChange,
|
||||
onRevertModeChange,
|
||||
isLastMessage = false,
|
||||
}) => {
|
||||
const isUser = message.role === 'user'
|
||||
const isAssistant = message.role === 'assistant'
|
||||
@@ -88,22 +93,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
// UI state
|
||||
const [isHoveringMessage, setIsHoveringMessage] = useState(false)
|
||||
|
||||
// Success timers hook
|
||||
const {
|
||||
showCopySuccess,
|
||||
showUpvoteSuccess,
|
||||
showDownvoteSuccess,
|
||||
handleCopy,
|
||||
setShowUpvoteSuccess,
|
||||
setShowDownvoteSuccess,
|
||||
} = useSuccessTimers()
|
||||
|
||||
// Message feedback hook
|
||||
const { handleUpvote, handleDownvote } = useMessageFeedback(message, messages, {
|
||||
setShowUpvoteSuccess,
|
||||
setShowDownvoteSuccess,
|
||||
})
|
||||
|
||||
// Checkpoint management hook
|
||||
const {
|
||||
showRestoreConfirmation,
|
||||
@@ -153,14 +142,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
pendingEditRef,
|
||||
})
|
||||
|
||||
/**
|
||||
* Handles copying message content to clipboard
|
||||
* Uses the success timer hook to show feedback
|
||||
*/
|
||||
const handleCopyContent = () => {
|
||||
handleCopy(message.content)
|
||||
}
|
||||
|
||||
// Get clean text content with double newline parsing
|
||||
const cleanTextContent = useMemo(() => {
|
||||
if (!message.content) return ''
|
||||
@@ -169,6 +150,42 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
return message.content.replace(/\n{3,}/g, '\n\n')
|
||||
}, [message.content])
|
||||
|
||||
// Parse special tags from message content (options, plan)
|
||||
// Parse during streaming to show options/plan as they stream in
|
||||
const parsedTags = useMemo(() => {
|
||||
if (isUser) return null
|
||||
|
||||
// Try message.content first
|
||||
if (message.content) {
|
||||
const parsed = parseSpecialTags(message.content)
|
||||
if (parsed.options || parsed.plan) return parsed
|
||||
}
|
||||
|
||||
// During streaming, check content blocks for options/plan
|
||||
if (isStreaming && message.contentBlocks && message.contentBlocks.length > 0) {
|
||||
for (const block of message.contentBlocks) {
|
||||
if (block.type === 'text' && block.content) {
|
||||
const parsed = parseSpecialTags(block.content)
|
||||
if (parsed.options || parsed.plan) return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return message.content ? parseSpecialTags(message.content) : null
|
||||
}, [message.content, message.contentBlocks, isUser, isStreaming])
|
||||
|
||||
// Get sendMessage from store for continuation actions
|
||||
const sendMessage = useCopilotStore((s) => s.sendMessage)
|
||||
|
||||
// Handler for option selection
|
||||
const handleOptionSelect = useCallback(
|
||||
(_optionKey: string, optionText: string) => {
|
||||
// Send the option text as a message
|
||||
sendMessage(optionText)
|
||||
},
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
// Memoize content blocks to avoid re-rendering unchanged blocks
|
||||
const memoizedContentBlocks = useMemo(() => {
|
||||
if (!message.contentBlocks || message.contentBlocks.length === 0) {
|
||||
@@ -179,8 +196,12 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
if (block.type === 'text') {
|
||||
const isLastTextBlock =
|
||||
index === message.contentBlocks!.length - 1 && block.type === 'text'
|
||||
// Clean content for this text block
|
||||
const cleanBlockContent = block.content.replace(/\n{3,}/g, '\n\n')
|
||||
// Always strip special tags from display (they're rendered separately as options/plan)
|
||||
const parsed = parseSpecialTags(block.content)
|
||||
const cleanBlockContent = parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
|
||||
|
||||
// Skip if no content after stripping tags
|
||||
if (!cleanBlockContent.trim()) return null
|
||||
|
||||
// Use smooth streaming for the last text block if we're streaming
|
||||
const shouldUseSmoothing = isStreaming && isLastTextBlock
|
||||
@@ -201,19 +222,14 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
)
|
||||
}
|
||||
if (block.type === 'thinking') {
|
||||
const isLastBlock = index === message.contentBlocks!.length - 1
|
||||
// Consider the thinking block streaming if the overall message is streaming
|
||||
// and the block has not been finalized with a duration yet. This avoids
|
||||
// freezing the timer when new blocks are appended after the thinking block.
|
||||
const isStreamingThinking = isStreaming && (block as any).duration == null
|
||||
|
||||
// Check if there are any blocks after this one (tool calls, text, etc.)
|
||||
const hasFollowingContent = index < message.contentBlocks!.length - 1
|
||||
return (
|
||||
<div key={`thinking-${index}-${block.timestamp || index}`} className='w-full'>
|
||||
<ThinkingBlock
|
||||
content={block.content}
|
||||
isStreaming={isStreamingThinking}
|
||||
duration={block.duration}
|
||||
startTime={block.startTime}
|
||||
isStreaming={isStreaming}
|
||||
hasFollowingContent={hasFollowingContent}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -467,53 +483,11 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
)}
|
||||
|
||||
{message.errorType === 'usage_limit' && (
|
||||
<div className='mt-3 flex gap-1.5'>
|
||||
<div className='flex gap-1.5'>
|
||||
<UsageLimitActions />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons for completed messages */}
|
||||
{!isStreaming && cleanTextContent && (
|
||||
<div className='flex items-center gap-[8px] pt-[8px]'>
|
||||
<Button
|
||||
onClick={handleCopyContent}
|
||||
variant='ghost'
|
||||
title='Copy'
|
||||
className='!h-[14px] !w-[14px] !p-0'
|
||||
>
|
||||
{showCopySuccess ? (
|
||||
<Check className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
) : (
|
||||
<Copy className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpvote}
|
||||
variant='ghost'
|
||||
title='Upvote'
|
||||
className='!h-[14px] !w-[14px] !p-0'
|
||||
>
|
||||
{showUpvoteSuccess ? (
|
||||
<Check className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
) : (
|
||||
<ThumbsUp className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownvote}
|
||||
variant='ghost'
|
||||
title='Downvote'
|
||||
className='!h-[14px] !w-[14px] !p-0'
|
||||
>
|
||||
{showDownvoteSuccess ? (
|
||||
<Check className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
) : (
|
||||
<ThumbsDown className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Citations if available */}
|
||||
{message.citations && message.citations.length > 0 && (
|
||||
<div className='pt-1'>
|
||||
@@ -533,6 +507,20 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options selector when agent presents choices - streams in but disabled until complete */}
|
||||
{/* Disabled for previous messages (not isLastMessage) so only the latest options are interactive */}
|
||||
{parsedTags?.options && Object.keys(parsedTags.options).length > 0 && (
|
||||
<OptionsSelector
|
||||
options={parsedTags.options}
|
||||
onSelect={handleOptionSelect}
|
||||
disabled={!isLastMessage || isSendingMessage || isStreaming}
|
||||
enableKeyboardNav={
|
||||
isLastMessage && !isStreaming && parsedTags.optionsComplete === true
|
||||
}
|
||||
streaming={isStreaming || !parsedTags.optionsComplete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -570,6 +558,11 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
return false
|
||||
}
|
||||
|
||||
// If isLastMessage changed, re-render (for options visibility)
|
||||
if (prevProps.isLastMessage !== nextProps.isLastMessage) {
|
||||
return false
|
||||
}
|
||||
|
||||
// For streaming messages, check if content actually changed
|
||||
if (nextProps.isStreaming) {
|
||||
const prevBlocks = prevMessage.contentBlocks || []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './copilot-message/copilot-message'
|
||||
export * from './plan-mode-section/plan-mode-section'
|
||||
export * from './queued-messages/queued-messages'
|
||||
export * from './todo-list/todo-list'
|
||||
export * from './tool-call/tool-call'
|
||||
export * from './user-input/user-input'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
export { AttachedFilesDisplay } from './attached-files-display/attached-files-display'
|
||||
export { ContextPills } from './context-pills/context-pills'
|
||||
export { ContextUsageIndicator } from './context-usage-indicator/context-usage-indicator'
|
||||
export { MentionMenu } from './mention-menu/mention-menu'
|
||||
export { ModeSelector } from './mode-selector/mode-selector'
|
||||
export { ModelSelector } from './model-selector/model-selector'
|
||||
|
||||
@@ -178,11 +178,12 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
|
||||
|
||||
/**
|
||||
* Opens file picker dialog
|
||||
* Note: We allow file selection even when isLoading (streaming) so users can prepare images for the next message
|
||||
*/
|
||||
const handleFileSelect = useCallback(() => {
|
||||
if (disabled || isLoading) return
|
||||
if (disabled) return
|
||||
fileInputRef.current?.click()
|
||||
}, [disabled, isLoading])
|
||||
}, [disabled])
|
||||
|
||||
/**
|
||||
* Handles file input change event
|
||||
|
||||
@@ -117,7 +117,6 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
const selectedModel =
|
||||
selectedModelOverride !== undefined ? selectedModelOverride : copilotStore.selectedModel
|
||||
const setSelectedModel = onModelChangeOverride || copilotStore.setSelectedModel
|
||||
const contextUsage = copilotStore.contextUsage
|
||||
|
||||
// Internal state
|
||||
const [internalMessage, setInternalMessage] = useState('')
|
||||
@@ -300,7 +299,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => {
|
||||
const targetMessage = overrideMessage ?? message
|
||||
const trimmedMessage = targetMessage.trim()
|
||||
if (!trimmedMessage || disabled || isLoading) return
|
||||
// Allow submission even when isLoading - store will queue the message
|
||||
if (!trimmedMessage || disabled) return
|
||||
|
||||
const failedUploads = fileAttachments.attachedFiles.filter((f) => !f.uploading && !f.key)
|
||||
if (failedUploads.length > 0) {
|
||||
@@ -746,7 +746,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
title='Attach file'
|
||||
className={cn(
|
||||
'cursor-pointer rounded-[6px] border-0 bg-transparent p-[0px] dark:bg-transparent',
|
||||
(disabled || isLoading) && 'cursor-not-allowed opacity-50'
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<Image className='!h-3.5 !w-3.5 scale-x-110' />
|
||||
@@ -802,7 +802,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hidden File Input */}
|
||||
{/* Hidden File Input - enabled during streaming so users can prepare images for the next message */}
|
||||
<input
|
||||
ref={fileAttachments.fileInputRef}
|
||||
type='file'
|
||||
@@ -810,7 +810,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
className='hidden'
|
||||
accept='image/*'
|
||||
multiple
|
||||
disabled={disabled || isLoading}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,9 +22,11 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
CopilotMessage,
|
||||
PlanModeSection,
|
||||
QueuedMessages,
|
||||
TodoList,
|
||||
UserInput,
|
||||
Welcome,
|
||||
@@ -99,7 +101,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
loadChats,
|
||||
messageCheckpoints,
|
||||
currentChat,
|
||||
fetchContextUsage,
|
||||
selectChat,
|
||||
deleteChat,
|
||||
areChatsFresh,
|
||||
@@ -118,7 +119,6 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
chatsLoadedForWorkflow,
|
||||
setCopilotWorkflowId,
|
||||
loadChats,
|
||||
fetchContextUsage,
|
||||
loadAutoAllowedTools,
|
||||
currentChat,
|
||||
isSendingMessage,
|
||||
@@ -298,7 +298,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
*/
|
||||
const handleSubmit = useCallback(
|
||||
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => {
|
||||
if (!query || isSendingMessage || !activeWorkflowId) return
|
||||
// Allow submission even when isSendingMessage - store will queue the message
|
||||
if (!query || !activeWorkflowId) return
|
||||
|
||||
if (showPlanTodos) {
|
||||
const store = useCopilotStore.getState()
|
||||
@@ -316,7 +317,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
logger.error('Failed to send message:', error)
|
||||
}
|
||||
},
|
||||
[isSendingMessage, activeWorkflowId, sendMessage, showPlanTodos]
|
||||
[activeWorkflowId, sendMessage, showPlanTodos]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -443,7 +444,13 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
<span className='min-w-0 flex-1 truncate'>
|
||||
{chat.title || 'New Chat'}
|
||||
</span>
|
||||
<div className='flex flex-shrink-0 items-center gap-[4px] opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-shrink-0 items-center gap-[4px]',
|
||||
currentChat?.id !== chat.id &&
|
||||
'opacity-0 transition-opacity group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[16px] w-[16px] p-0'
|
||||
@@ -563,6 +570,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
onRevertModeChange={(isReverting) =>
|
||||
handleRevertModeChange(message.id, isReverting)
|
||||
}
|
||||
isLastMessage={index === messages.length - 1}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -588,6 +596,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Queued messages (shown when messages are waiting) */}
|
||||
<QueuedMessages />
|
||||
|
||||
{/* Input area with integrated mode selector */}
|
||||
<div className='flex-shrink-0 px-[8px] pb-[8px]'>
|
||||
<UserInput
|
||||
|
||||
@@ -11,7 +11,6 @@ interface UseCopilotInitializationProps {
|
||||
chatsLoadedForWorkflow: string | null
|
||||
setCopilotWorkflowId: (workflowId: string | null) => Promise<void>
|
||||
loadChats: (forceRefresh?: boolean) => Promise<void>
|
||||
fetchContextUsage: () => Promise<void>
|
||||
loadAutoAllowedTools: () => Promise<void>
|
||||
currentChat: any
|
||||
isSendingMessage: boolean
|
||||
@@ -30,7 +29,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
||||
chatsLoadedForWorkflow,
|
||||
setCopilotWorkflowId,
|
||||
loadChats,
|
||||
fetchContextUsage,
|
||||
loadAutoAllowedTools,
|
||||
currentChat,
|
||||
isSendingMessage,
|
||||
@@ -102,18 +100,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
||||
isSendingMessage,
|
||||
])
|
||||
|
||||
/**
|
||||
* Fetch context usage when component is initialized and has a current chat
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isInitialized && currentChat?.id && activeWorkflowId) {
|
||||
logger.info('[Copilot] Component initialized, fetching context usage')
|
||||
fetchContextUsage().catch((err) => {
|
||||
logger.warn('[Copilot] Failed to fetch context usage on mount', err)
|
||||
})
|
||||
}
|
||||
}, [isInitialized, currentChat?.id, activeWorkflowId, fetchContextUsage])
|
||||
|
||||
/**
|
||||
* Load auto-allowed tools once on mount
|
||||
*/
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Maximize2 } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ButtonGroupItem,
|
||||
Expand,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
@@ -222,7 +222,7 @@ export function GeneralDeploy({
|
||||
onClick={() => setShowExpandedPreview(true)}
|
||||
className='absolute right-[8px] bottom-[8px] z-10 h-[28px] w-[28px] cursor-pointer border border-[var(--border)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
<Maximize2 className='h-[14px] w-[14px]' />
|
||||
<Expand className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>See preview</Tooltip.Content>
|
||||
|
||||
@@ -654,17 +654,20 @@ export function ConditionInput({
|
||||
}
|
||||
|
||||
const removeBlock = (id: string) => {
|
||||
if (isPreview || disabled || conditionalBlocks.length <= 2) return
|
||||
if (isPreview || disabled) return
|
||||
// Condition mode requires at least 2 blocks (if/else), router mode requires at least 1
|
||||
const minBlocks = isRouterMode ? 1 : 2
|
||||
if (conditionalBlocks.length <= minBlocks) return
|
||||
|
||||
// Remove any associated edges before removing the block
|
||||
const handlePrefix = isRouterMode ? `router-${id}` : `condition-${id}`
|
||||
const edgeIdsToRemove = edges
|
||||
.filter((edge) => edge.sourceHandle?.startsWith(`condition-${id}`))
|
||||
.filter((edge) => edge.sourceHandle?.startsWith(handlePrefix))
|
||||
.map((edge) => edge.id)
|
||||
if (edgeIdsToRemove.length > 0) {
|
||||
batchRemoveEdges(edgeIdsToRemove)
|
||||
}
|
||||
|
||||
if (conditionalBlocks.length === 1) return
|
||||
shouldPersistRef.current = true
|
||||
setConditionalBlocks((blocks) => updateBlockTitles(blocks.filter((block) => block.id !== id)))
|
||||
|
||||
@@ -816,7 +819,9 @@ export function ConditionInput({
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => removeBlock(block.id)}
|
||||
disabled={isPreview || disabled || conditionalBlocks.length === 1}
|
||||
disabled={
|
||||
isPreview || disabled || conditionalBlocks.length <= (isRouterMode ? 1 : 2)
|
||||
}
|
||||
className='h-auto p-0 text-[var(--text-error)] hover:text-[var(--text-error)]'
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
|
||||
@@ -2,4 +2,3 @@ export { Copilot } from './copilot/copilot'
|
||||
export { Deploy } from './deploy/deploy'
|
||||
export { Editor } from './editor/editor'
|
||||
export { Toolbar } from './toolbar/toolbar'
|
||||
export { WorkflowControls } from './workflow-controls/workflow-controls'
|
||||
|
||||
@@ -327,12 +327,14 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
|
||||
/**
|
||||
* Handle search input blur.
|
||||
*
|
||||
* We intentionally keep search mode active after blur so that ArrowUp/Down
|
||||
* navigation continues to work after the first move from the search input
|
||||
* into the triggers/blocks list (e.g. when initiated via Mod+F).
|
||||
* If the search query is empty, deactivate search mode to show the search icon again.
|
||||
* If there's a query, keep search mode active so ArrowUp/Down navigation continues
|
||||
* to work after focus moves into the triggers/blocks list (e.g. when initiated via Mod+F).
|
||||
*/
|
||||
const handleSearchBlur = () => {
|
||||
// No-op by design
|
||||
if (!searchQuery.trim()) {
|
||||
setIsSearchActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Button, Redo, Undo } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useUndoRedoStore } from '@/stores/undo-redo'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
/**
|
||||
* Workflow controls component that provides undo/redo functionality.
|
||||
* Styled to align with the panel tab buttons.
|
||||
*/
|
||||
export function WorkflowControls() {
|
||||
const { undo, redo } = useCollaborativeWorkflow()
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const { data: session } = useSession()
|
||||
const userId = session?.user?.id || 'unknown'
|
||||
const stacks = useUndoRedoStore((s) => s.stacks)
|
||||
|
||||
const undoRedoSizes = (() => {
|
||||
const key = activeWorkflowId && userId ? `${activeWorkflowId}:${userId}` : ''
|
||||
const stack = (key && stacks[key]) || { undo: [], redo: [] }
|
||||
return { undoSize: stack.undo.length, redoSize: stack.redo.length }
|
||||
})()
|
||||
|
||||
const canUndo = undoRedoSizes.undoSize > 0
|
||||
const canRedo = undoRedoSizes.redoSize > 0
|
||||
|
||||
return (
|
||||
<div className='flex gap-[2px]'>
|
||||
<Button
|
||||
className='h-[28px] rounded-[6px] rounded-r-none border border-transparent px-[6px] py-[5px] hover:border-[var(--border-1)] hover:bg-[var(--surface-5)]'
|
||||
onClick={undo}
|
||||
variant={canUndo ? 'active' : 'ghost'}
|
||||
disabled={!canUndo}
|
||||
title='Undo (Cmd+Z)'
|
||||
>
|
||||
<Undo className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
<Button
|
||||
className='h-[28px] rounded-[6px] rounded-l-none border border-transparent px-[6px] py-[5px] hover:border-[var(--border-1)] hover:bg-[var(--surface-5)]'
|
||||
onClick={redo}
|
||||
variant={canRedo ? 'active' : 'ghost'}
|
||||
disabled={!canRedo}
|
||||
title='Redo (Cmd+Shift+Z)'
|
||||
>
|
||||
<Redo className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { PANEL_WIDTH } from '@/stores/constants'
|
||||
import { usePanelStore } from '@/stores/panel'
|
||||
|
||||
@@ -10,15 +10,14 @@ import { usePanelStore } from '@/stores/panel'
|
||||
* @returns Resize state and handlers
|
||||
*/
|
||||
export function usePanelResize() {
|
||||
const { setPanelWidth } = usePanelStore()
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const { setPanelWidth, isResizing, setIsResizing } = usePanelStore()
|
||||
|
||||
/**
|
||||
* Handles mouse down on resize handle
|
||||
*/
|
||||
const handleMouseDown = useCallback(() => {
|
||||
setIsResizing(true)
|
||||
}, [])
|
||||
}, [setIsResizing])
|
||||
|
||||
/**
|
||||
* Setup resize event listeners and body styles when resizing
|
||||
@@ -52,7 +51,7 @@ export function usePanelResize() {
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
}, [isResizing, setPanelWidth])
|
||||
}, [isResizing, setPanelWidth, setIsResizing])
|
||||
|
||||
return {
|
||||
isResizing,
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
BubbleChatClose,
|
||||
BubbleChatPreview,
|
||||
Button,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Cursor,
|
||||
Hand,
|
||||
Layout,
|
||||
Modal,
|
||||
ModalBody,
|
||||
@@ -43,6 +46,7 @@ import { useAutoLayout } from '@/app/workspace/[workspaceId]/w/[workflowId]/hook
|
||||
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
|
||||
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useCanvasModeStore } from '@/stores/canvas-mode'
|
||||
import { useChatStore } from '@/stores/chat/store'
|
||||
import type { PanelTab } from '@/stores/panel'
|
||||
import { usePanelStore, useVariablesStore as usePanelVariablesStore } from '@/stores/panel'
|
||||
@@ -92,6 +96,7 @@ export function Panel() {
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [isDuplicating, setIsDuplicating] = useState(false)
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [isCanvasModeOpen, setIsCanvasModeOpen] = useState(false)
|
||||
|
||||
// Hooks
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
@@ -160,6 +165,9 @@ export function Panel() {
|
||||
const { isChatOpen, setIsChatOpen } = useChatStore()
|
||||
const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore()
|
||||
|
||||
// Canvas mode
|
||||
const { mode: canvasMode, setMode: setCanvasMode } = useCanvasModeStore()
|
||||
|
||||
const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null
|
||||
|
||||
/**
|
||||
@@ -496,8 +504,58 @@ export function Panel() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Workflow Controls (Undo/Redo) */}
|
||||
{/* <WorkflowControls /> */}
|
||||
{/* Canvas Mode Selector */}
|
||||
<Popover
|
||||
open={isCanvasModeOpen}
|
||||
onOpenChange={setIsCanvasModeOpen}
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<div className='flex cursor-pointer items-center gap-[4px]'>
|
||||
<Button className='h-[28px] w-[28px] rounded-[6px] p-0' variant='active'>
|
||||
{canvasMode === 'hand' ? (
|
||||
<Hand className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<Cursor className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
<Button className='!p-[2px] group' variant='ghost'>
|
||||
<ChevronDown
|
||||
className={`h-[8px] w-[10px] text-[var(--text-muted)] transition-transform duration-100 group-hover:text-[var(--text-secondary)] ${
|
||||
isCanvasModeOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align='end'
|
||||
side='bottom'
|
||||
sideOffset={8}
|
||||
maxWidth={100}
|
||||
minWidth={100}
|
||||
>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
setCanvasMode('cursor')
|
||||
setIsCanvasModeOpen(false)
|
||||
}}
|
||||
>
|
||||
<Cursor className='h-3 w-3' />
|
||||
<span>Pointer</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
setCanvasMode('hand')
|
||||
setIsCanvasModeOpen(false)
|
||||
}}
|
||||
>
|
||||
<Hand className='h-3 w-3' />
|
||||
<span>Mover</span>
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Tab Content - Keep all tabs mounted but hidden to preserve state */}
|
||||
|
||||
@@ -136,7 +136,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
const ringStyles = cn(
|
||||
hasRing && 'ring-[1.75px]',
|
||||
(isFocused || isPreviewSelected) && 'ring-[var(--brand-secondary)]',
|
||||
diffStatus === 'new' && 'ring-[#22C55F]',
|
||||
diffStatus === 'new' && 'ring-[var(--brand-tertiary-2)]',
|
||||
diffStatus === 'edited' && 'ring-[var(--warning)]'
|
||||
)
|
||||
|
||||
@@ -148,7 +148,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
ref={blockRef}
|
||||
onClick={() => setCurrentBlockId(id)}
|
||||
className={cn(
|
||||
'relative cursor-pointer select-none rounded-[8px] border border-[var(--border)]',
|
||||
'workflow-drag-handle relative cursor-grab select-none rounded-[8px] border border-[var(--border)] [&:active]:cursor-grabbing',
|
||||
'transition-block-bg transition-ring',
|
||||
'z-[20]'
|
||||
)}
|
||||
@@ -166,11 +166,8 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
{/* Header Section */}
|
||||
<div
|
||||
className={cn(
|
||||
'workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing'
|
||||
'flex items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px]'
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
<div
|
||||
|
||||
@@ -306,6 +306,7 @@ export function Terminal() {
|
||||
const terminalRef = useRef<HTMLElement>(null)
|
||||
const prevEntriesLengthRef = useRef(0)
|
||||
const prevWorkflowEntriesLengthRef = useRef(0)
|
||||
const isTerminalFocusedRef = useRef(false)
|
||||
const {
|
||||
setTerminalHeight,
|
||||
lastExpandedHeight,
|
||||
@@ -540,8 +541,11 @@ export function Terminal() {
|
||||
/**
|
||||
* Handle row click - toggle if clicking same entry
|
||||
* Disables auto-selection when user manually selects, re-enables when deselecting
|
||||
* Also focuses the terminal to enable keyboard navigation
|
||||
*/
|
||||
const handleRowClick = useCallback((entry: ConsoleEntry) => {
|
||||
// Focus the terminal to enable keyboard navigation
|
||||
terminalRef.current?.focus()
|
||||
setSelectedEntry((prev) => {
|
||||
const isDeselecting = prev?.id === entry.id
|
||||
setAutoSelectEnabled(isDeselecting)
|
||||
@@ -562,6 +566,26 @@ export function Terminal() {
|
||||
setIsToggling(false)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle terminal focus - enables keyboard navigation
|
||||
*/
|
||||
const handleTerminalFocus = useCallback(() => {
|
||||
isTerminalFocusedRef.current = true
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle terminal blur - disables keyboard navigation
|
||||
*/
|
||||
const handleTerminalBlur = useCallback((e: React.FocusEvent) => {
|
||||
// Only blur if focus is moving outside the terminal
|
||||
if (!terminalRef.current?.contains(e.relatedTarget as Node)) {
|
||||
isTerminalFocusedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle copy output to clipboard
|
||||
*/
|
||||
const handleCopy = useCallback(() => {
|
||||
if (!selectedEntry) return
|
||||
|
||||
@@ -792,9 +816,12 @@ export function Terminal() {
|
||||
/**
|
||||
* Handle keyboard navigation through logs
|
||||
* Disables auto-selection when user manually navigates
|
||||
* Only active when the terminal is focused
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle navigation when terminal is focused
|
||||
if (!isTerminalFocusedRef.current) return
|
||||
if (isEventFromEditableElement(e)) return
|
||||
const activeElement = document.activeElement as HTMLElement | null
|
||||
const toolbarRoot = document.querySelector(
|
||||
@@ -829,9 +856,12 @@ export function Terminal() {
|
||||
/**
|
||||
* Handle keyboard navigation for input/output toggle
|
||||
* Left arrow shows output, right arrow shows input
|
||||
* Only active when the terminal is focused
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle navigation when terminal is focused
|
||||
if (!isTerminalFocusedRef.current) return
|
||||
// Ignore when typing/navigating inside editable inputs/editors
|
||||
if (isEventFromEditableElement(e)) return
|
||||
|
||||
@@ -936,6 +966,9 @@ export function Terminal() {
|
||||
isToggling && 'transition-[height] duration-100 ease-out'
|
||||
)}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
onFocus={handleTerminalFocus}
|
||||
onBlur={handleTerminalBlur}
|
||||
tabIndex={-1}
|
||||
aria-label='Terminal'
|
||||
>
|
||||
<div className='relative flex h-full border-[var(--border)] border-t'>
|
||||
|
||||
@@ -11,6 +11,16 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 }
|
||||
|
||||
const ACTION_BUTTON_STYLES = [
|
||||
'h-[23px] w-[23px] rounded-[8px] p-0',
|
||||
'border border-[var(--border)] bg-[var(--surface-5)]',
|
||||
'text-[var(--text-secondary)]',
|
||||
'hover:border-transparent hover:bg-[var(--brand-secondary)] hover:!text-[var(--text-inverse)]',
|
||||
'dark:border-transparent dark:bg-[var(--surface-7)] dark:hover:bg-[var(--brand-secondary)]',
|
||||
].join(' ')
|
||||
|
||||
const ICON_SIZE = 'h-[11px] w-[11px]'
|
||||
|
||||
/**
|
||||
* Props for the ActionBar component
|
||||
*/
|
||||
@@ -87,8 +97,8 @@ export const ActionBar = memo(
|
||||
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
// Check for start_trigger (unified start block) - prevent duplication but allow deletion
|
||||
const isStartBlock = blockType === 'starter' || blockType === 'start_trigger'
|
||||
const isResponseBlock = blockType === 'response'
|
||||
const isNoteBlock = blockType === 'note'
|
||||
|
||||
/**
|
||||
@@ -110,7 +120,9 @@ export const ActionBar = memo(
|
||||
'-top-[46px] absolute right-0',
|
||||
'flex flex-row items-center',
|
||||
'opacity-0 transition-opacity duration-200 group-hover:opacity-100',
|
||||
'gap-[5px] rounded-[10px] bg-[var(--surface-4)] p-[5px]'
|
||||
'gap-[5px] rounded-[10px] p-[5px]',
|
||||
'border border-[var(--border)] bg-[var(--surface-2)]',
|
||||
'dark:border-transparent dark:bg-[var(--surface-4)]'
|
||||
)}
|
||||
>
|
||||
{!isNoteBlock && (
|
||||
@@ -124,14 +136,10 @@ export const ActionBar = memo(
|
||||
collaborativeBatchToggleBlockEnabled([blockId])
|
||||
}
|
||||
}}
|
||||
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled}
|
||||
>
|
||||
{isEnabled ? (
|
||||
<Circle className='h-[11px] w-[11px]' />
|
||||
) : (
|
||||
<CircleOff className='h-[11px] w-[11px]' />
|
||||
)}
|
||||
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
@@ -140,7 +148,7 @@ export const ActionBar = memo(
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{!isStartBlock && (
|
||||
{!isStartBlock && !isResponseBlock && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
@@ -151,10 +159,10 @@ export const ActionBar = memo(
|
||||
handleDuplicateBlock()
|
||||
}
|
||||
}}
|
||||
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Copy className='h-[11px] w-[11px]' />
|
||||
<Copy className={ICON_SIZE} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{getTooltipMessage('Duplicate Block')}</Tooltip.Content>
|
||||
@@ -172,13 +180,13 @@ export const ActionBar = memo(
|
||||
collaborativeBatchToggleBlockHandles([blockId])
|
||||
}
|
||||
}}
|
||||
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled}
|
||||
>
|
||||
{horizontalHandles ? (
|
||||
<ArrowLeftRight className='h-[11px] w-[11px]' />
|
||||
<ArrowLeftRight className={ICON_SIZE} />
|
||||
) : (
|
||||
<ArrowUpDown className='h-[11px] w-[11px]' />
|
||||
<ArrowUpDown className={ICON_SIZE} />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
@@ -201,10 +209,10 @@ export const ActionBar = memo(
|
||||
)
|
||||
}
|
||||
}}
|
||||
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled || !userPermissions.canEdit}
|
||||
>
|
||||
<LogOut className='h-[11px] w-[11px]' />
|
||||
<LogOut className={ICON_SIZE} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{getTooltipMessage('Remove from Subflow')}</Tooltip.Content>
|
||||
@@ -221,10 +229,10 @@ export const ActionBar = memo(
|
||||
collaborativeBatchRemoveBlocks([blockId])
|
||||
}
|
||||
}}
|
||||
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Trash2 className='h-[11px] w-[11px]' />
|
||||
<Trash2 className={ICON_SIZE} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{getTooltipMessage('Delete Block')}</Tooltip.Content>
|
||||
|
||||
@@ -199,8 +199,9 @@ const tryParseJson = (value: unknown): unknown => {
|
||||
|
||||
/**
|
||||
* Formats a subblock value for display, intelligently handling nested objects and arrays.
|
||||
* Used by both the canvas workflow blocks and copilot edit summaries.
|
||||
*/
|
||||
const getDisplayValue = (value: unknown): string => {
|
||||
export const getDisplayValue = (value: unknown): string => {
|
||||
if (value == null || value === '') return '-'
|
||||
|
||||
// Try parsing JSON strings first
|
||||
@@ -630,10 +631,13 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
? ((credValue as { value?: unknown }).value as string | undefined)
|
||||
: (credValue as string | undefined)
|
||||
if (prevCredRef.current !== cred) {
|
||||
const hadPreviousCredential = prevCredRef.current !== undefined
|
||||
prevCredRef.current = cred
|
||||
const keys = Object.keys(current)
|
||||
const dependentKeys = keys.filter((k) => k !== 'credential')
|
||||
dependentKeys.forEach((k) => collaborativeSetSubblockValue(id, k, ''))
|
||||
if (hadPreviousCredential) {
|
||||
const keys = Object.keys(current)
|
||||
const dependentKeys = keys.filter((k) => k !== 'credential')
|
||||
dependentKeys.forEach((k) => collaborativeSetSubblockValue(id, k, ''))
|
||||
}
|
||||
}
|
||||
}, [id, collaborativeSetSubblockValue])
|
||||
|
||||
@@ -863,7 +867,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
return parsed.map((item: unknown, index: number) => {
|
||||
const routeItem = item as { id?: string; value?: string }
|
||||
return {
|
||||
id: routeItem?.id ?? `${id}-route-${index}`,
|
||||
// Use stable ID format that matches ConditionInput's generateStableId
|
||||
id: routeItem?.id ?? `${id}-route${index + 1}`,
|
||||
value: routeItem?.value ?? '',
|
||||
}
|
||||
})
|
||||
@@ -873,7 +878,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
logger.warn('Failed to parse router routes value', { error, blockId: id })
|
||||
}
|
||||
|
||||
return [{ id: `${id}-route-route1`, value: '' }]
|
||||
// Fallback must match ConditionInput's default: generateStableId(blockId, 'route1') = `${blockId}-route1`
|
||||
return [{ id: `${id}-route1`, value: '' }]
|
||||
}, [type, subBlockState, id])
|
||||
|
||||
/**
|
||||
@@ -944,7 +950,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
ref={contentRef}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'relative z-[20] w-[250px] cursor-default select-none rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)]'
|
||||
'workflow-drag-handle relative z-[20] w-[250px] cursor-grab select-none rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)] [&:active]:cursor-grabbing'
|
||||
)}
|
||||
>
|
||||
{isPending && (
|
||||
@@ -980,12 +986,9 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'workflow-drag-handle flex cursor-grab items-center justify-between p-[8px] [&:active]:cursor-grabbing',
|
||||
'flex items-center justify-between p-[8px]',
|
||||
hasContentBelowHeader && 'border-[var(--border-1)] border-b'
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||
<div
|
||||
|
||||
@@ -93,7 +93,7 @@ const WorkflowEdgeComponent = ({
|
||||
} else if (isErrorEdge) {
|
||||
color = 'var(--text-error)'
|
||||
} else if (edgeDiffStatus === 'new') {
|
||||
color = 'var(--brand-tertiary)'
|
||||
color = 'var(--brand-tertiary-2)'
|
||||
} else if (edgeRunStatus === 'success') {
|
||||
// Use green for preview mode, default for canvas execution
|
||||
color = previewExecutionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import type { AutoLayoutOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils'
|
||||
import { applyAutoLayoutAndUpdateStore as applyAutoLayoutStandalone } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils'
|
||||
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
|
||||
|
||||
export type { AutoLayoutOptions }
|
||||
|
||||
@@ -16,7 +17,8 @@ const logger = createLogger('useAutoLayout')
|
||||
* Note: This hook requires a ReactFlowProvider ancestor.
|
||||
*/
|
||||
export function useAutoLayout(workflowId: string | null) {
|
||||
const { fitView } = useReactFlow()
|
||||
const reactFlowInstance = useReactFlow()
|
||||
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
|
||||
|
||||
const applyAutoLayoutAndUpdateStore = useCallback(
|
||||
async (options: AutoLayoutOptions = {}) => {
|
||||
@@ -38,7 +40,7 @@ export function useAutoLayout(workflowId: string | null) {
|
||||
if (result.success) {
|
||||
logger.info('Auto layout completed successfully')
|
||||
requestAnimationFrame(() => {
|
||||
fitView({ padding: 0.8, duration: 600 })
|
||||
fitViewToBounds({ padding: 0.15, duration: 600 })
|
||||
})
|
||||
} else {
|
||||
logger.error('Auto layout failed:', result.error)
|
||||
@@ -52,7 +54,7 @@ export function useAutoLayout(workflowId: string | null) {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}, [applyAutoLayoutAndUpdateStore, fitView])
|
||||
}, [applyAutoLayoutAndUpdateStore, fitViewToBounds])
|
||||
|
||||
return {
|
||||
applyAutoLayoutAndUpdateStore,
|
||||
|
||||
@@ -23,7 +23,8 @@ interface TriggerValidationResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that pasting/duplicating trigger blocks won't violate constraints.
|
||||
* Validates that pasting/duplicating blocks won't violate constraints.
|
||||
* Checks both trigger constraints and single-instance block constraints.
|
||||
* Returns validation result with error message if invalid.
|
||||
*/
|
||||
export function validateTriggerPaste(
|
||||
@@ -43,6 +44,12 @@ export function validateTriggerPaste(
|
||||
return { isValid: false, message }
|
||||
}
|
||||
}
|
||||
|
||||
const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue(existingBlocks, block.type)
|
||||
if (singleInstanceIssue) {
|
||||
const message = `A workflow can only have one ${singleInstanceIssue.blockName} block. ${action === 'paste' ? 'Please remove the existing one before pasting.' : 'Cannot duplicate.'}`
|
||||
return { isValid: false, message }
|
||||
}
|
||||
}
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/b
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
ActionBar,
|
||||
CommandList,
|
||||
DiffControls,
|
||||
Notifications,
|
||||
@@ -62,8 +63,10 @@ import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { isAnnotationOnlyBlock } from '@/executor/constants'
|
||||
import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
|
||||
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
|
||||
import { useCanvasModeStore } from '@/stores/canvas-mode'
|
||||
import { useChatStore } from '@/stores/chat/store'
|
||||
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
|
||||
import { useExecutionStore } from '@/stores/execution'
|
||||
@@ -208,9 +211,9 @@ const WorkflowContent = React.memo(() => {
|
||||
const [isCanvasReady, setIsCanvasReady] = useState(false)
|
||||
const [potentialParentId, setPotentialParentId] = useState<string | null>(null)
|
||||
const [selectedEdges, setSelectedEdges] = useState<SelectedEdgesMap>(new Map())
|
||||
const [isShiftPressed, setIsShiftPressed] = useState(false)
|
||||
const [isSelectionDragActive, setIsSelectionDragActive] = useState(false)
|
||||
const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false)
|
||||
const canvasMode = useCanvasModeStore((state) => state.mode)
|
||||
const isHandMode = canvasMode === 'hand'
|
||||
const [oauthModal, setOauthModal] = useState<{
|
||||
provider: OAuthProvider
|
||||
serviceId: string
|
||||
@@ -221,7 +224,9 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { screenToFlowPosition, getNodes, setNodes, fitView, getIntersectingNodes } = useReactFlow()
|
||||
const reactFlowInstance = useReactFlow()
|
||||
const { screenToFlowPosition, getNodes, setNodes, getIntersectingNodes } = reactFlowInstance
|
||||
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
|
||||
const { emitCursorUpdate } = useSocket()
|
||||
|
||||
const workspaceId = params.workspaceId as string
|
||||
@@ -987,6 +992,14 @@ const WorkflowContent = React.memo(() => {
|
||||
const handleId = conditionHandles[0].getAttribute('data-handleid')
|
||||
if (handleId) return handleId
|
||||
}
|
||||
} else if (block.type === 'router_v2') {
|
||||
const routerHandles = document.querySelectorAll(
|
||||
`[data-nodeid^="${block.id}"][data-handleid^="router-"]`
|
||||
)
|
||||
if (routerHandles.length > 0) {
|
||||
const handleId = routerHandles[0].getAttribute('data-handleid')
|
||||
if (handleId) return handleId
|
||||
}
|
||||
} else if (block.type === 'loop') {
|
||||
return 'loop-end-source'
|
||||
} else if (block.type === 'parallel') {
|
||||
@@ -1121,17 +1134,18 @@ const WorkflowContent = React.memo(() => {
|
||||
)
|
||||
|
||||
/**
|
||||
* Checks if adding a trigger block would violate constraints and shows notification if so.
|
||||
* Checks if adding a block would violate constraints (triggers or single-instance blocks)
|
||||
* and shows notification if so.
|
||||
* @returns true if validation failed (caller should return early), false if ok to proceed
|
||||
*/
|
||||
const checkTriggerConstraints = useCallback(
|
||||
(blockType: string): boolean => {
|
||||
const issue = TriggerUtils.getTriggerAdditionIssue(blocks, blockType)
|
||||
if (issue) {
|
||||
const triggerIssue = TriggerUtils.getTriggerAdditionIssue(blocks, blockType)
|
||||
if (triggerIssue) {
|
||||
const message =
|
||||
issue.issue === 'legacy'
|
||||
triggerIssue.issue === 'legacy'
|
||||
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
|
||||
: `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before adding a new one.`
|
||||
: `A workflow can only have one ${triggerIssue.triggerName} trigger block. Please remove the existing one before adding a new one.`
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message,
|
||||
@@ -1139,6 +1153,17 @@ const WorkflowContent = React.memo(() => {
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue(blocks, blockType)
|
||||
if (singleInstanceIssue) {
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: `A workflow can only have one ${singleInstanceIssue.blockName} block. Please remove the existing one before adding a new one.`,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
[blocks, addNotification, activeWorkflowId]
|
||||
@@ -1478,10 +1503,10 @@ const WorkflowContent = React.memo(() => {
|
||||
foundNodes: changedNodes.length,
|
||||
})
|
||||
requestAnimationFrame(() => {
|
||||
fitView({
|
||||
fitViewToBounds({
|
||||
nodes: changedNodes,
|
||||
duration: 600,
|
||||
padding: 0.3,
|
||||
padding: 0.1,
|
||||
minZoom: 0.5,
|
||||
maxZoom: 1.0,
|
||||
})
|
||||
@@ -1489,18 +1514,18 @@ const WorkflowContent = React.memo(() => {
|
||||
} else {
|
||||
logger.info('Diff ready - no changed nodes found, fitting all')
|
||||
requestAnimationFrame(() => {
|
||||
fitView({ padding: 0.3, duration: 600 })
|
||||
fitViewToBounds({ padding: 0.1, duration: 600 })
|
||||
})
|
||||
}
|
||||
} else {
|
||||
logger.info('Diff ready - no changed blocks, fitting all')
|
||||
requestAnimationFrame(() => {
|
||||
fitView({ padding: 0.3, duration: 600 })
|
||||
fitViewToBounds({ padding: 0.1, duration: 600 })
|
||||
})
|
||||
}
|
||||
}
|
||||
prevDiffReadyRef.current = isDiffReady
|
||||
}, [isDiffReady, diffAnalysis, fitView, getNodes])
|
||||
}, [isDiffReady, diffAnalysis, fitViewToBounds, getNodes])
|
||||
|
||||
/** Displays trigger warning notifications. */
|
||||
useEffect(() => {
|
||||
@@ -1892,47 +1917,6 @@ const WorkflowContent = React.memo(() => {
|
||||
// Local state for nodes - allows smooth drag without store updates on every frame
|
||||
const [displayNodes, setDisplayNodes] = useState<Node[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') setIsShiftPressed(true)
|
||||
}
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') setIsShiftPressed(false)
|
||||
}
|
||||
const handleFocusLoss = () => {
|
||||
setIsShiftPressed(false)
|
||||
setIsSelectionDragActive(false)
|
||||
}
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
handleFocusLoss()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
window.addEventListener('blur', handleFocusLoss)
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
window.removeEventListener('blur', handleFocusLoss)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isShiftPressed) {
|
||||
document.body.style.userSelect = 'none'
|
||||
} else {
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
return () => {
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
}, [isShiftPressed])
|
||||
|
||||
useEffect(() => {
|
||||
// Preserve selection state when syncing from derivedNodes
|
||||
setDisplayNodes((currentNodes) => {
|
||||
@@ -2816,17 +2800,6 @@ const WorkflowContent = React.memo(() => {
|
||||
]
|
||||
)
|
||||
|
||||
// Lock selection mode when selection drag starts (captures Shift state at drag start)
|
||||
const onSelectionStart = useCallback(() => {
|
||||
if (isShiftPressed) {
|
||||
setIsSelectionDragActive(true)
|
||||
}
|
||||
}, [isShiftPressed])
|
||||
|
||||
const onSelectionEnd = useCallback(() => {
|
||||
requestAnimationFrame(() => setIsSelectionDragActive(false))
|
||||
}, [])
|
||||
|
||||
/** Captures initial positions when selection drag starts (for marquee-selected nodes). */
|
||||
const onSelectionDragStart = useCallback(
|
||||
(_event: React.MouseEvent, nodes: Node[]) => {
|
||||
@@ -2976,7 +2949,6 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
const onSelectionDragStop = useCallback(
|
||||
(_event: React.MouseEvent, nodes: any[]) => {
|
||||
requestAnimationFrame(() => setIsSelectionDragActive(false))
|
||||
clearDragHighlights()
|
||||
if (nodes.length === 0) return
|
||||
|
||||
@@ -3307,11 +3279,9 @@ const WorkflowContent = React.memo(() => {
|
||||
onPointerMove={handleCanvasPointerMove}
|
||||
onPointerLeave={handleCanvasPointerLeave}
|
||||
elementsSelectable={true}
|
||||
selectionOnDrag={isShiftPressed || isSelectionDragActive}
|
||||
selectionOnDrag={!isHandMode}
|
||||
selectionMode={SelectionMode.Partial}
|
||||
panOnDrag={isShiftPressed || isSelectionDragActive ? false : [0, 1]}
|
||||
onSelectionStart={onSelectionStart}
|
||||
onSelectionEnd={onSelectionEnd}
|
||||
panOnDrag={isHandMode ? [0, 1] : false}
|
||||
multiSelectionKeyCode={['Meta', 'Control', 'Shift']}
|
||||
nodesConnectable={effectivePermissions.canEdit}
|
||||
nodesDraggable={effectivePermissions.canEdit}
|
||||
@@ -3319,7 +3289,7 @@ const WorkflowContent = React.memo(() => {
|
||||
noWheelClassName='allow-scroll'
|
||||
edgesFocusable={true}
|
||||
edgesUpdatable={effectivePermissions.canEdit}
|
||||
className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'}`}
|
||||
className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'} ${isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'}`}
|
||||
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
|
||||
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
|
||||
onSelectionDragStart={effectivePermissions.canEdit ? onSelectionDragStart : undefined}
|
||||
@@ -3338,12 +3308,12 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
<Cursors />
|
||||
|
||||
<ActionBar />
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<LazyChat />
|
||||
</Suspense>
|
||||
|
||||
<DiffControls />
|
||||
|
||||
{/* Context Menus */}
|
||||
<BlockContextMenu
|
||||
isOpen={isBlockMenuOpen}
|
||||
@@ -3381,6 +3351,9 @@ const WorkflowContent = React.memo(() => {
|
||||
onToggleVariables={handleContextToggleVariables}
|
||||
onToggleChat={handleContextToggleChat}
|
||||
onInvite={handleContextInvite}
|
||||
onZoomIn={() => reactFlowInstance.zoomIn()}
|
||||
onZoomOut={() => reactFlowInstance.zoomOut()}
|
||||
onFitView={() => fitViewToBounds({ padding: 0.1, duration: 300 })}
|
||||
isVariablesOpen={isVariablesOpen}
|
||||
isChatOpen={isChatOpen}
|
||||
hasClipboard={hasClipboard()}
|
||||
@@ -3399,6 +3372,8 @@ const WorkflowContent = React.memo(() => {
|
||||
<Panel />
|
||||
</div>
|
||||
|
||||
<DiffControls />
|
||||
|
||||
<Terminal />
|
||||
|
||||
{oauthModal && (
|
||||
|
||||
@@ -87,6 +87,12 @@ function GeneralSkeleton() {
|
||||
<Skeleton className='h-8 w-[100px] rounded-[4px]' />
|
||||
</div>
|
||||
|
||||
{/* Show canvas controls row */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<Skeleton className='h-4 w-32' />
|
||||
<Skeleton className='h-[17px] w-[30px] rounded-full' />
|
||||
</div>
|
||||
|
||||
{/* Telemetry row */}
|
||||
<div className='flex items-center justify-between border-t pt-[16px]'>
|
||||
<Skeleton className='h-4 w-44' />
|
||||
@@ -310,6 +316,12 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowActionBarChange = async (checked: boolean) => {
|
||||
if (checked !== settings?.showActionBar && !updateSetting.isPending) {
|
||||
await updateSetting.mutateAsync({ key: 'showActionBar', value: checked })
|
||||
}
|
||||
}
|
||||
|
||||
const handleTrainingControlsChange = async (checked: boolean) => {
|
||||
if (checked !== settings?.showTrainingControls && !updateSetting.isPending) {
|
||||
await updateSetting.mutateAsync({ key: 'showTrainingControls', value: checked })
|
||||
@@ -519,6 +531,15 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='show-action-bar'>Show canvas controls</Label>
|
||||
<Switch
|
||||
id='show-action-bar'
|
||||
checked={settings?.showActionBar ?? true}
|
||||
onCheckedChange={handleShowActionBarChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between border-t pt-[16px]'>
|
||||
<Label htmlFor='telemetry'>Allow anonymous telemetry</Label>
|
||||
<Switch
|
||||
|
||||
@@ -11,9 +11,9 @@ export const PermissionsTableSkeleton = React.memo(() => (
|
||||
</div>
|
||||
<div className='flex flex-shrink-0 items-center'>
|
||||
<div className='inline-flex gap-[2px]'>
|
||||
<Skeleton className='h-[26px] w-[44px] rounded-[4px]' />
|
||||
<Skeleton className='h-[26px] w-[44px] rounded-[4px]' />
|
||||
<Skeleton className='h-[26px] w-[44px] rounded-[4px]' />
|
||||
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
|
||||
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
|
||||
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -194,6 +194,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
const matches = text.match(emailRegex) || []
|
||||
return [...new Set(matches.map((e) => e.toLowerCase()))]
|
||||
},
|
||||
tooltip: 'Upload emails',
|
||||
}),
|
||||
[userPerms.canAdmin]
|
||||
)
|
||||
|
||||
@@ -290,7 +290,7 @@ export function WorkspaceHeader({
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Switch workspace'
|
||||
className={`flex cursor-pointer items-center gap-[8px] rounded-[6px] bg-transparent px-[6px] py-[4px] transition-colors hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)] ${
|
||||
className={`group flex cursor-pointer items-center gap-[8px] rounded-[6px] bg-transparent px-[6px] py-[4px] transition-colors hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)] ${
|
||||
isCollapsed ? '' : '-mx-[6px] min-w-0 max-w-full'
|
||||
}`}
|
||||
title={activeWorkspace?.name || 'Loading...'}
|
||||
@@ -303,7 +303,7 @@ export function WorkspaceHeader({
|
||||
{activeWorkspace?.name || 'Loading...'}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-[8px] w-[12px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-100 ${
|
||||
className={`h-[8px] w-[10px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-100 group-hover:text-[var(--text-secondary)] ${
|
||||
isWorkspaceMenuOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
@@ -452,7 +452,7 @@ export function WorkspaceHeader({
|
||||
>
|
||||
{activeWorkspace?.name || 'Loading...'}
|
||||
</span>
|
||||
<ChevronDown className='h-[8px] w-[12px] flex-shrink-0 text-[var(--text-muted)]' />
|
||||
<ChevronDown className='h-[8px] w-[10px] flex-shrink-0 text-[var(--text-muted)]' />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { SIDEBAR_WIDTH } from '@/stores/constants'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
|
||||
@@ -10,8 +10,7 @@ import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
* @returns Resize state and handlers
|
||||
*/
|
||||
export function useSidebarResize() {
|
||||
const { setSidebarWidth } = useSidebarStore()
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const { setSidebarWidth, isResizing, setIsResizing } = useSidebarStore()
|
||||
|
||||
/**
|
||||
* Handles mouse down on resize handle
|
||||
|
||||
@@ -17,6 +17,7 @@ export const ResponseBlock: BlockConfig<ResponseBlockOutput> = {
|
||||
category: 'blocks',
|
||||
bgColor: '#2F55FF',
|
||||
icon: ResponseIcon,
|
||||
singleInstance: true,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'dataMode',
|
||||
|
||||
@@ -115,25 +115,26 @@ Description: ${route.value || 'No description provided'}
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
return `You are an intelligent routing agent. Your task is to analyze the provided context and select the most appropriate route from the available options.
|
||||
return `You are a DETERMINISTIC routing agent. You MUST select exactly ONE option.
|
||||
|
||||
Available Routes:
|
||||
${routesInfo}
|
||||
|
||||
Context to analyze:
|
||||
Context to route:
|
||||
${context}
|
||||
|
||||
Instructions:
|
||||
1. Carefully analyze the context against each route's description
|
||||
2. Select the route that best matches the context's intent and requirements
|
||||
3. Consider the semantic meaning, not just keyword matching
|
||||
4. If multiple routes could match, choose the most specific one
|
||||
ROUTING RULES:
|
||||
1. ALWAYS prefer selecting a route over NO_MATCH
|
||||
2. Pick the route whose description BEST matches the context, even if it's not a perfect match
|
||||
3. If the context is even partially related to a route's description, select that route
|
||||
4. ONLY output NO_MATCH if the context is completely unrelated to ALL route descriptions
|
||||
|
||||
Response Format:
|
||||
Return ONLY the route ID as a single string, no punctuation, no explanation.
|
||||
Example: "route-abc123"
|
||||
OUTPUT FORMAT:
|
||||
- Output EXACTLY one route ID (copied exactly as shown above) OR "NO_MATCH"
|
||||
- No explanation, no punctuation, no additional text
|
||||
- Just the route ID or NO_MATCH
|
||||
|
||||
Remember: Your response must be ONLY the route ID - no additional text, formatting, or explanation.`
|
||||
Your response:`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -320,6 +320,7 @@ export interface BlockConfig<T extends ToolResponse = ToolResponse> {
|
||||
subBlocks: SubBlockConfig[]
|
||||
triggerAllowed?: boolean
|
||||
authMode?: AuthMode
|
||||
singleInstance?: boolean
|
||||
tools: {
|
||||
access: string[]
|
||||
config?: {
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Paperclip, Plus, X } from 'lucide-react'
|
||||
import { Tooltip } from '@/components/emcn/components/tooltip/tooltip'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
/**
|
||||
@@ -151,6 +152,8 @@ export interface FileInputOptions {
|
||||
icon?: React.ComponentType<{ className?: string; strokeWidth?: number }>
|
||||
/** Extract values from file content. Each extracted value will be passed to onAdd. */
|
||||
extractValues?: (text: string) => string[]
|
||||
/** Tooltip text for the file input button */
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -465,17 +468,24 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||
)}
|
||||
</div>
|
||||
{fileInputEnabled && !disabled && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
fileInputRef.current?.click()
|
||||
}}
|
||||
className='absolute right-[8px] bottom-[9px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-secondary)]'
|
||||
aria-label='Upload file'
|
||||
>
|
||||
<FileIcon className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
</button>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
fileInputRef.current?.click()
|
||||
}}
|
||||
className='absolute right-[8px] bottom-[9px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-secondary)]'
|
||||
aria-label={fileInputOptions?.tooltip ?? 'Upload file'}
|
||||
>
|
||||
<FileIcon className='h-[14px] w-[14px]' strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{fileInputOptions?.tooltip ?? 'Upload file'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,12 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
/**
|
||||
* Tooltip provider component that must wrap your app or tooltip usage area.
|
||||
*/
|
||||
const Provider = TooltipPrimitive.Provider
|
||||
const Provider = ({
|
||||
delayDuration = 400,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Provider>) => (
|
||||
<TooltipPrimitive.Provider delayDuration={delayDuration} {...props} />
|
||||
)
|
||||
|
||||
/**
|
||||
* Root tooltip component that wraps trigger and content.
|
||||
|
||||
22
apps/sim/components/emcn/icons/cursor.tsx
Normal file
22
apps/sim/components/emcn/icons/cursor.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
export function Cursor(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d='M20.5056 10.7754C21.1225 10.5355 21.431 10.4155 21.5176 10.2459C21.5926 10.099 21.5903 9.92446 21.5115 9.77954C21.4205 9.61226 21.109 9.50044 20.486 9.2768L4.59629 3.5728C4.0866 3.38983 3.83175 3.29835 3.66514 3.35605C3.52029 3.40621 3.40645 3.52004 3.35629 3.6649C3.29859 3.8315 3.39008 4.08635 3.57304 4.59605L9.277 20.4858C9.50064 21.1088 9.61246 21.4203 9.77973 21.5113C9.92465 21.5901 10.0991 21.5924 10.2461 21.5174C10.4157 21.4308 10.5356 21.1223 10.7756 20.5054L13.3724 13.8278C13.4194 13.707 13.4429 13.6466 13.4792 13.5957C13.5114 13.5506 13.5508 13.5112 13.5959 13.479C13.6468 13.4427 13.7072 13.4192 13.828 13.3722L20.5056 10.7754Z'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
43
apps/sim/components/emcn/icons/expand.tsx
Normal file
43
apps/sim/components/emcn/icons/expand.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
export function Expand(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d='M15 3H21V9'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M9 21H3V15'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M21 3L14 10'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M3 21L10 14'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
43
apps/sim/components/emcn/icons/hand.tsx
Normal file
43
apps/sim/components/emcn/icons/hand.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
export function Hand(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d='M6.5 11V6.5C6.5 5.67157 7.17157 5 8 5C8.82843 5 9.5 5.67157 9.5 6.5V11'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M9.5 10.5V5.5C9.5 4.67157 10.1716 4 11 4C11.8284 4 12.5 4.67157 12.5 5.5V10.5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M12.5 10.5V6.5C12.5 5.67157 13.1716 5 14 5C14.8284 5 15.5 5.67157 15.5 6.5V10.5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M15.5 10.5V8.5C15.5 7.67157 16.1716 7 17 7C17.8284 7 18.5 7.67157 18.5 8.5V15.5C18.5 18.8137 15.8137 21.5 12.5 21.5H11.5C8.18629 21.5 5.5 18.8137 5.5 15.5V13C5.5 12.1716 6.17157 11.5 7 11.5C7.82843 11.5 8.5 12.1716 8.5 13'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -4,11 +4,14 @@ export { Card } from './card'
|
||||
export { ChevronDown } from './chevron-down'
|
||||
export { Connections } from './connections'
|
||||
export { Copy } from './copy'
|
||||
export { Cursor } from './cursor'
|
||||
export { DocumentAttachment } from './document-attachment'
|
||||
export { Duplicate } from './duplicate'
|
||||
export { Expand } from './expand'
|
||||
export { Eye } from './eye'
|
||||
export { FolderCode } from './folder-code'
|
||||
export { FolderPlus } from './folder-plus'
|
||||
export { Hand } from './hand'
|
||||
export { HexSimple } from './hex-simple'
|
||||
export { Key } from './key'
|
||||
export { Layout } from './layout'
|
||||
|
||||
@@ -17,14 +17,14 @@ export function Redo(props: SVGProps<SVGSVGElement>) {
|
||||
<path
|
||||
d='M9.5 4.5H4C2.61929 4.5 1.5 5.61929 1.5 7C1.5 8.38071 2.61929 9.5 4 9.5H7'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeWidth='1'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M8 2.5L10 4.5L8 6.5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeWidth='1'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
|
||||
@@ -17,14 +17,14 @@ export function Undo(props: SVGProps<SVGSVGElement>) {
|
||||
<path
|
||||
d='M2.5 4.5H8C9.38071 4.5 10.5 5.61929 10.5 7C10.5 8.38071 9.38071 9.5 8 9.5H5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeWidth='1'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M4 2.5L2 4.5L4 6.5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeWidth='1'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
|
||||
@@ -19,28 +19,28 @@ export function ZoomIn(props: SVGProps<SVGSVGElement>) {
|
||||
cy='5'
|
||||
r='3.5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeWidth='1'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M5 3.5V6.5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeWidth='1'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M3.5 5H6.5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeWidth='1'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M7.5 7.5L10.5 10.5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeWidth='1'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
|
||||
@@ -19,21 +19,21 @@ export function ZoomOut(props: SVGProps<SVGSVGElement>) {
|
||||
cy='5'
|
||||
r='3.5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeWidth='1'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M3.5 5H6.5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeWidth='1'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M7.5 7.5L10.5 10.5'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.5'
|
||||
strokeWidth='1'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
|
||||
@@ -278,14 +278,24 @@ export class RouterBlockHandler implements BlockHandler {
|
||||
const result = await response.json()
|
||||
|
||||
const chosenRouteId = result.content.trim()
|
||||
|
||||
if (chosenRouteId === 'NO_MATCH' || chosenRouteId.toUpperCase() === 'NO_MATCH') {
|
||||
logger.info('Router determined no route matches the context, routing to error path')
|
||||
throw new Error('Router could not determine a matching route for the given context')
|
||||
}
|
||||
|
||||
const chosenRoute = routes.find((r) => r.id === chosenRouteId)
|
||||
|
||||
// Throw error if LLM returns invalid route ID - this routes through error path
|
||||
if (!chosenRoute) {
|
||||
const availableRoutes = routes.map((r) => ({ id: r.id, title: r.title }))
|
||||
logger.error(
|
||||
`Invalid routing decision. Response content: "${result.content}", available routes:`,
|
||||
routes.map((r) => ({ id: r.id, title: r.title }))
|
||||
`Invalid routing decision. Response content: "${result.content}". Available routes:`,
|
||||
availableRoutes
|
||||
)
|
||||
throw new Error(
|
||||
`Router could not determine a valid route. LLM response: "${result.content}". Available route IDs: ${routes.map((r) => r.id).join(', ')}`
|
||||
)
|
||||
throw new Error(`Invalid routing decision: ${chosenRouteId}`)
|
||||
}
|
||||
|
||||
// Find the target block connected to this route's handle
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface GeneralSettings {
|
||||
billingUsageNotificationsEnabled: boolean
|
||||
errorNotificationsEnabled: boolean
|
||||
snapToGridSize: number
|
||||
showActionBar: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,6 +49,7 @@ async function fetchGeneralSettings(): Promise<GeneralSettings> {
|
||||
billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
|
||||
errorNotificationsEnabled: data.errorNotificationsEnabled ?? true,
|
||||
snapToGridSize: data.snapToGridSize ?? 0,
|
||||
showActionBar: data.showActionBar ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +71,7 @@ function syncSettingsToZustand(settings: GeneralSettings) {
|
||||
isBillingUsageNotificationsEnabled: settings.billingUsageNotificationsEnabled,
|
||||
isErrorNotificationsEnabled: settings.errorNotificationsEnabled,
|
||||
snapToGridSize: settings.snapToGridSize,
|
||||
showActionBar: settings.showActionBar,
|
||||
}
|
||||
|
||||
const hasChanges = Object.entries(newSettings).some(
|
||||
|
||||
185
apps/sim/hooks/use-canvas-viewport.ts
Normal file
185
apps/sim/hooks/use-canvas-viewport.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { Node, ReactFlowInstance } from 'reactflow'
|
||||
|
||||
interface VisibleBounds {
|
||||
width: number
|
||||
height: number
|
||||
offsetLeft: number
|
||||
offsetRight: number
|
||||
offsetBottom: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the visible canvas bounds accounting for sidebar, terminal, and panel overlays.
|
||||
* Works correctly regardless of whether the ReactFlow container extends under the sidebar or not.
|
||||
*/
|
||||
function getVisibleCanvasBounds(): VisibleBounds {
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
|
||||
const sidebarWidth = Number.parseInt(style.getPropertyValue('--sidebar-width') || '0', 10)
|
||||
const terminalHeight = Number.parseInt(style.getPropertyValue('--terminal-height') || '0', 10)
|
||||
const panelWidth = Number.parseInt(style.getPropertyValue('--panel-width') || '0', 10)
|
||||
|
||||
const flowContainer = document.querySelector('.react-flow')
|
||||
if (!flowContainer) {
|
||||
return {
|
||||
width: window.innerWidth - sidebarWidth - panelWidth,
|
||||
height: window.innerHeight - terminalHeight,
|
||||
offsetLeft: sidebarWidth,
|
||||
offsetRight: panelWidth,
|
||||
offsetBottom: terminalHeight,
|
||||
}
|
||||
}
|
||||
|
||||
const rect = flowContainer.getBoundingClientRect()
|
||||
|
||||
// Calculate actual visible area in screen coordinates
|
||||
// This works regardless of whether the container extends under overlays
|
||||
const visibleLeft = Math.max(rect.left, sidebarWidth)
|
||||
const visibleRight = Math.min(rect.right, window.innerWidth - panelWidth)
|
||||
const visibleBottom = Math.min(rect.bottom, window.innerHeight - terminalHeight)
|
||||
|
||||
// Calculate visible dimensions and offsets relative to the container
|
||||
const visibleWidth = Math.max(0, visibleRight - visibleLeft)
|
||||
const visibleHeight = Math.max(0, visibleBottom - rect.top)
|
||||
|
||||
return {
|
||||
width: visibleWidth,
|
||||
height: visibleHeight,
|
||||
offsetLeft: visibleLeft - rect.left,
|
||||
offsetRight: rect.right - visibleRight,
|
||||
offsetBottom: rect.bottom - visibleBottom,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the center of the visible canvas in screen coordinates.
|
||||
*/
|
||||
function getVisibleCanvasCenter(): { x: number; y: number } {
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
const sidebarWidth = Number.parseInt(style.getPropertyValue('--sidebar-width') || '0', 10)
|
||||
const panelWidth = Number.parseInt(style.getPropertyValue('--panel-width') || '0', 10)
|
||||
const terminalHeight = Number.parseInt(style.getPropertyValue('--terminal-height') || '0', 10)
|
||||
|
||||
const flowContainer = document.querySelector('.react-flow')
|
||||
if (!flowContainer) {
|
||||
const visibleWidth = window.innerWidth - sidebarWidth - panelWidth
|
||||
const visibleHeight = window.innerHeight - terminalHeight
|
||||
return {
|
||||
x: sidebarWidth + visibleWidth / 2,
|
||||
y: visibleHeight / 2,
|
||||
}
|
||||
}
|
||||
|
||||
const rect = flowContainer.getBoundingClientRect()
|
||||
|
||||
// Calculate actual visible area in screen coordinates
|
||||
const visibleLeft = Math.max(rect.left, sidebarWidth)
|
||||
const visibleRight = Math.min(rect.right, window.innerWidth - panelWidth)
|
||||
const visibleBottom = Math.min(rect.bottom, window.innerHeight - terminalHeight)
|
||||
|
||||
return {
|
||||
x: (visibleLeft + visibleRight) / 2,
|
||||
y: (rect.top + visibleBottom) / 2,
|
||||
}
|
||||
}
|
||||
|
||||
interface FitViewToBoundsOptions {
|
||||
padding?: number
|
||||
maxZoom?: number
|
||||
minZoom?: number
|
||||
duration?: number
|
||||
nodes?: Node[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook providing canvas viewport utilities that account for sidebar, panel, and terminal overlays.
|
||||
*/
|
||||
export function useCanvasViewport(reactFlowInstance: ReactFlowInstance | null) {
|
||||
/**
|
||||
* Gets the center of the visible canvas in flow coordinates.
|
||||
*/
|
||||
const getViewportCenter = useCallback(() => {
|
||||
if (!reactFlowInstance) {
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
const center = getVisibleCanvasCenter()
|
||||
return reactFlowInstance.screenToFlowPosition(center)
|
||||
}, [reactFlowInstance])
|
||||
|
||||
/**
|
||||
* Fits the view to show all nodes within the visible canvas bounds,
|
||||
* accounting for sidebar, panel, and terminal overlays.
|
||||
* @param padding - Fraction of viewport to leave as margin (0.1 = 10% on each side)
|
||||
*/
|
||||
const fitViewToBounds = useCallback(
|
||||
(options: FitViewToBoundsOptions = {}) => {
|
||||
if (!reactFlowInstance) return
|
||||
|
||||
const {
|
||||
padding = 0.1,
|
||||
maxZoom = 1,
|
||||
minZoom = 0.1,
|
||||
duration = 300,
|
||||
nodes: targetNodes,
|
||||
} = options
|
||||
|
||||
const nodes = targetNodes ?? reactFlowInstance.getNodes()
|
||||
if (nodes.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const bounds = getVisibleCanvasBounds()
|
||||
|
||||
// Calculate node bounds
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let maxY = Number.NEGATIVE_INFINITY
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const nodeWidth = node.width ?? 200
|
||||
const nodeHeight = node.height ?? 100
|
||||
|
||||
minX = Math.min(minX, node.position.x)
|
||||
minY = Math.min(minY, node.position.y)
|
||||
maxX = Math.max(maxX, node.position.x + nodeWidth)
|
||||
maxY = Math.max(maxY, node.position.y + nodeHeight)
|
||||
})
|
||||
|
||||
const contentWidth = maxX - minX
|
||||
const contentHeight = maxY - minY
|
||||
|
||||
// Apply padding as fraction of viewport (matches ReactFlow's fitView behavior)
|
||||
const availableWidth = bounds.width * (1 - padding * 2)
|
||||
const availableHeight = bounds.height * (1 - padding * 2)
|
||||
|
||||
// Calculate zoom to fit content in available area
|
||||
const zoomX = availableWidth / contentWidth
|
||||
const zoomY = availableHeight / contentHeight
|
||||
const zoom = Math.max(minZoom, Math.min(maxZoom, Math.min(zoomX, zoomY)))
|
||||
|
||||
// Calculate center of content in flow coordinates
|
||||
const contentCenterX = minX + contentWidth / 2
|
||||
const contentCenterY = minY + contentHeight / 2
|
||||
|
||||
// Calculate viewport position to center content in visible area
|
||||
// Account for sidebar offset on the left
|
||||
const visibleCenterX = bounds.offsetLeft + bounds.width / 2
|
||||
const visibleCenterY = bounds.height / 2
|
||||
|
||||
const x = visibleCenterX - contentCenterX * zoom
|
||||
const y = visibleCenterY - contentCenterY * zoom
|
||||
|
||||
reactFlowInstance.setViewport({ x, y, zoom }, { duration })
|
||||
},
|
||||
[reactFlowInstance]
|
||||
)
|
||||
|
||||
return {
|
||||
getViewportCenter,
|
||||
fitViewToBounds,
|
||||
getVisibleCanvasBounds,
|
||||
}
|
||||
}
|
||||
@@ -369,7 +369,7 @@ async function processBlockMetadata(
|
||||
if (userId) {
|
||||
const permissionConfig = await getUserPermissionConfig(userId)
|
||||
const allowedIntegrations = permissionConfig?.allowedIntegrations
|
||||
if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockId)) {
|
||||
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) {
|
||||
logger.debug('Block not allowed by permission group', { blockId, userId })
|
||||
return null
|
||||
}
|
||||
|
||||
120
apps/sim/lib/copilot/tools/client/base-subagent-tool.ts
Normal file
120
apps/sim/lib/copilot/tools/client/base-subagent-tool.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Lazy require in setState to avoid circular init issues
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ToolUIConfig } from './ui-config'
|
||||
|
||||
const baseToolLogger = createLogger('BaseClientTool')
|
||||
|
||||
@@ -51,6 +52,11 @@ export interface BaseClientToolMetadata {
|
||||
* If provided, this will override the default text in displayNames
|
||||
*/
|
||||
getDynamicText?: DynamicTextFormatter
|
||||
/**
|
||||
* UI configuration for how this tool renders in the tool-call component.
|
||||
* This replaces hardcoded logic in tool-call.tsx with declarative config.
|
||||
*/
|
||||
uiConfig?: ToolUIConfig
|
||||
}
|
||||
|
||||
export class BaseClientTool {
|
||||
@@ -258,4 +264,12 @@ export class BaseClientTool {
|
||||
hasInterrupt(): boolean {
|
||||
return !!this.metadata.interrupt
|
||||
}
|
||||
|
||||
/**
|
||||
* Get UI configuration for this tool.
|
||||
* Used by tool-call component to determine rendering behavior.
|
||||
*/
|
||||
getUIConfig(): ToolUIConfig | undefined {
|
||||
return this.metadata.uiConfig
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
interface GetBlockConfigArgs {
|
||||
blockType: string
|
||||
operation?: string
|
||||
trigger?: boolean
|
||||
}
|
||||
|
||||
export class GetBlockConfigClientTool extends BaseClientTool {
|
||||
@@ -28,7 +29,7 @@ export class GetBlockConfigClientTool extends BaseClientTool {
|
||||
[ClientToolCallState.generating]: { text: 'Getting block config', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Getting block config', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Getting block config', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Got block config', icon: FileCode },
|
||||
[ClientToolCallState.success]: { text: 'Retrieved block config', icon: FileCode },
|
||||
[ClientToolCallState.error]: { text: 'Failed to get block config', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted getting block config', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: {
|
||||
@@ -43,17 +44,17 @@ export class GetBlockConfigClientTool extends BaseClientTool {
|
||||
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return `Got ${blockName}${opSuffix} config`
|
||||
return `Retrieved ${blockName}${opSuffix} config`
|
||||
case ClientToolCallState.executing:
|
||||
case ClientToolCallState.generating:
|
||||
case ClientToolCallState.pending:
|
||||
return `Getting ${blockName}${opSuffix} config`
|
||||
return `Retrieving ${blockName}${opSuffix} config`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to get ${blockName}${opSuffix} config`
|
||||
return `Failed to retrieve ${blockName}${opSuffix} config`
|
||||
case ClientToolCallState.aborted:
|
||||
return `Aborted getting ${blockName}${opSuffix} config`
|
||||
return `Aborted retrieving ${blockName}${opSuffix} config`
|
||||
case ClientToolCallState.rejected:
|
||||
return `Skipped getting ${blockName}${opSuffix} config`
|
||||
return `Skipped retrieving ${blockName}${opSuffix} config`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
@@ -65,12 +66,15 @@ export class GetBlockConfigClientTool extends BaseClientTool {
|
||||
try {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
const { blockType, operation } = GetBlockConfigInput.parse(args || {})
|
||||
const { blockType, operation, trigger } = GetBlockConfigInput.parse(args || {})
|
||||
|
||||
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ toolName: 'get_block_config', payload: { blockType, operation } }),
|
||||
body: JSON.stringify({
|
||||
toolName: 'get_block_config',
|
||||
payload: { blockType, operation, trigger },
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text().catch(() => '')
|
||||
|
||||
@@ -27,7 +27,7 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
|
||||
[ClientToolCallState.generating]: { text: 'Getting block options', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Getting block options', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Getting block options', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Got block options', icon: ListFilter },
|
||||
[ClientToolCallState.success]: { text: 'Retrieved block options', icon: ListFilter },
|
||||
[ClientToolCallState.error]: { text: 'Failed to get block options', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted getting block options', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: {
|
||||
@@ -41,17 +41,17 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
|
||||
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return `Got ${blockName} options`
|
||||
return `Retrieved ${blockName} options`
|
||||
case ClientToolCallState.executing:
|
||||
case ClientToolCallState.generating:
|
||||
case ClientToolCallState.pending:
|
||||
return `Getting ${blockName} options`
|
||||
return `Retrieving ${blockName} options`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to get ${blockName} options`
|
||||
return `Failed to retrieve ${blockName} options`
|
||||
case ClientToolCallState.aborted:
|
||||
return `Aborted getting ${blockName} options`
|
||||
return `Aborted retrieving ${blockName} options`
|
||||
case ClientToolCallState.rejected:
|
||||
return `Skipped getting ${blockName} options`
|
||||
return `Skipped retrieving ${blockName} options`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
@@ -63,7 +63,20 @@ export class GetBlockOptionsClientTool extends BaseClientTool {
|
||||
try {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
const { blockId } = GetBlockOptionsInput.parse(args || {})
|
||||
// Handle both camelCase and snake_case parameter names, plus blockType as an alias
|
||||
const normalizedArgs = args
|
||||
? {
|
||||
blockId:
|
||||
args.blockId ||
|
||||
(args as any).block_id ||
|
||||
(args as any).blockType ||
|
||||
(args as any).block_type,
|
||||
}
|
||||
: {}
|
||||
|
||||
logger.info('execute called', { originalArgs: args, normalizedArgs })
|
||||
|
||||
const { blockId } = GetBlockOptionsInput.parse(normalizedArgs)
|
||||
|
||||
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
|
||||
method: 'POST',
|
||||
|
||||
48
apps/sim/lib/copilot/tools/client/init-tool-configs.ts
Normal file
48
apps/sim/lib/copilot/tools/client/init-tool-configs.ts
Normal 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'
|
||||
56
apps/sim/lib/copilot/tools/client/other/auth.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/auth.ts
Normal 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!)
|
||||
@@ -22,7 +22,7 @@ export class CheckoffTodoClientTool extends BaseClientTool {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Marking todo', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Marking todo', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Todo marked complete', icon: Check },
|
||||
[ClientToolCallState.success]: { text: 'Marked todo complete', icon: Check },
|
||||
[ClientToolCallState.error]: { text: 'Failed to mark todo', icon: XCircle },
|
||||
},
|
||||
}
|
||||
|
||||
56
apps/sim/lib/copilot/tools/client/other/custom-tool.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/custom-tool.ts
Normal 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!)
|
||||
60
apps/sim/lib/copilot/tools/client/other/debug.ts
Normal file
60
apps/sim/lib/copilot/tools/client/other/debug.ts
Normal 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!)
|
||||
56
apps/sim/lib/copilot/tools/client/other/deploy.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/deploy.ts
Normal 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!)
|
||||
61
apps/sim/lib/copilot/tools/client/other/edit.ts
Normal file
61
apps/sim/lib/copilot/tools/client/other/edit.ts
Normal 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!)
|
||||
56
apps/sim/lib/copilot/tools/client/other/evaluate.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/evaluate.ts
Normal 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!)
|
||||
56
apps/sim/lib/copilot/tools/client/other/info.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/info.ts
Normal 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!)
|
||||
56
apps/sim/lib/copilot/tools/client/other/knowledge.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/knowledge.ts
Normal 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!)
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
|
||||
|
||||
interface MakeApiRequestArgs {
|
||||
@@ -27,7 +28,7 @@ export class MakeApiRequestClientTool extends BaseClientTool {
|
||||
[ClientToolCallState.generating]: { text: 'Preparing API request', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Review API request', icon: Globe2 },
|
||||
[ClientToolCallState.executing]: { text: 'Executing API request', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'API request complete', icon: Globe2 },
|
||||
[ClientToolCallState.success]: { text: 'Completed API request', icon: Globe2 },
|
||||
[ClientToolCallState.error]: { text: 'Failed to execute API request', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped API request', icon: MinusCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted API request', icon: XCircle },
|
||||
@@ -36,6 +37,23 @@ export class MakeApiRequestClientTool extends BaseClientTool {
|
||||
accept: { text: 'Execute', icon: Globe2 },
|
||||
reject: { text: 'Skip', icon: MinusCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
interrupt: {
|
||||
accept: { text: 'Execute', icon: Globe2 },
|
||||
reject: { text: 'Skip', icon: MinusCircle },
|
||||
showAllowOnce: true,
|
||||
showAllowAlways: true,
|
||||
},
|
||||
paramsTable: {
|
||||
columns: [
|
||||
{ key: 'method', label: 'Method', width: '26%', editable: true, mono: true },
|
||||
{ key: 'url', label: 'Endpoint', width: '74%', editable: true, mono: true },
|
||||
],
|
||||
extractRows: (params) => {
|
||||
return [['request', (params.method || 'GET').toUpperCase(), params.url || '']]
|
||||
},
|
||||
},
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
if (params?.url && typeof params.url === 'string') {
|
||||
const method = params.method || 'GET'
|
||||
@@ -110,3 +128,6 @@ export class MakeApiRequestClientTool extends BaseClientTool {
|
||||
await this.handleAccept(args)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(MakeApiRequestClientTool.id, MakeApiRequestClientTool.metadata.uiConfig!)
|
||||
|
||||
@@ -23,7 +23,7 @@ export class MarkTodoInProgressClientTool extends BaseClientTool {
|
||||
[ClientToolCallState.generating]: { text: 'Marking todo in progress', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Marking todo in progress', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Marking todo in progress', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Todo marked in progress', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Marked todo in progress', icon: Loader2 },
|
||||
[ClientToolCallState.error]: { text: 'Failed to mark in progress', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted marking in progress', icon: MinusCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped marking in progress', icon: MinusCircle },
|
||||
|
||||
@@ -71,9 +71,9 @@ export class OAuthRequestAccessClientTool extends BaseClientTool {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Requesting integration access', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Requesting integration access', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Connecting integration', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Requesting integration access', icon: Loader2 },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped integration access', icon: MinusCircle },
|
||||
[ClientToolCallState.success]: { text: 'Integration connected', icon: CheckCircle },
|
||||
[ClientToolCallState.success]: { text: 'Requested integration access', icon: CheckCircle },
|
||||
[ClientToolCallState.error]: { text: 'Failed to request integration access', icon: X },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted integration access request', icon: XCircle },
|
||||
},
|
||||
@@ -87,17 +87,16 @@ export class OAuthRequestAccessClientTool extends BaseClientTool {
|
||||
switch (state) {
|
||||
case ClientToolCallState.generating:
|
||||
case ClientToolCallState.pending:
|
||||
return `Requesting ${name} access`
|
||||
case ClientToolCallState.executing:
|
||||
return `Connecting to ${name}`
|
||||
return `Requesting ${name} access`
|
||||
case ClientToolCallState.rejected:
|
||||
return `Skipped ${name} access`
|
||||
case ClientToolCallState.success:
|
||||
return `${name} connected`
|
||||
return `Requested ${name} access`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to connect ${name}`
|
||||
return `Failed to request ${name} access`
|
||||
case ClientToolCallState.aborted:
|
||||
return `Aborted ${name} connection`
|
||||
return `Aborted ${name} access request`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
@@ -151,9 +150,12 @@ export class OAuthRequestAccessClientTool extends BaseClientTool {
|
||||
})
|
||||
)
|
||||
|
||||
// Mark as success - the modal will handle the actual OAuth flow
|
||||
// Mark as success - the user opened the prompt, but connection is not guaranteed
|
||||
this.setState(ClientToolCallState.success)
|
||||
await this.markToolComplete(200, `Opened ${this.providerName} connection dialog`)
|
||||
await this.markToolComplete(
|
||||
200,
|
||||
`The user opened the ${this.providerName} connection prompt and may have connected. Check the connected integrations to verify the connection status.`
|
||||
)
|
||||
} catch (e) {
|
||||
logger.error('Failed to open OAuth connect modal', { error: e })
|
||||
this.setState(ClientToolCallState.error)
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ListTodo, Loader2, X, XCircle } from 'lucide-react'
|
||||
import { ListTodo, Loader2, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
|
||||
interface PlanArgs {
|
||||
objective?: string
|
||||
todoList?: Array<{ id?: string; content: string } | string>
|
||||
request: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan tool that spawns a subagent to plan an approach.
|
||||
* This tool auto-executes and the actual work is done by the plan subagent.
|
||||
* The subagent's output is streamed as nested content under this tool call.
|
||||
*/
|
||||
export class PlanClientTool extends BaseClientTool {
|
||||
static readonly id = 'plan'
|
||||
|
||||
@@ -22,48 +26,34 @@ export class PlanClientTool extends BaseClientTool {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Planning', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Planning', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Planning an approach', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Finished planning', icon: ListTodo },
|
||||
[ClientToolCallState.error]: { text: 'Failed to plan', icon: X },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted planning', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped planning approach', icon: XCircle },
|
||||
[ClientToolCallState.executing]: { text: 'Planning', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Planned', icon: ListTodo },
|
||||
[ClientToolCallState.error]: { text: 'Failed to plan', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped plan', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted plan', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
subagent: {
|
||||
streamingLabel: 'Planning',
|
||||
completedLabel: 'Planned',
|
||||
shouldCollapse: true,
|
||||
outputArtifacts: ['plan'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async execute(args?: PlanArgs): Promise<void> {
|
||||
const logger = createLogger('PlanClientTool')
|
||||
try {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
// Update store todos from args if present (client-side only)
|
||||
try {
|
||||
const todoList = args?.todoList
|
||||
if (Array.isArray(todoList)) {
|
||||
const todos = todoList.map((item: any, index: number) => ({
|
||||
id: (item && (item.id || item.todoId)) || `todo-${index}`,
|
||||
content: typeof item === 'string' ? item : item.content,
|
||||
completed: false,
|
||||
executing: false,
|
||||
}))
|
||||
const { useCopilotStore } = await import('@/stores/panel/copilot/store')
|
||||
const store = useCopilotStore.getState()
|
||||
if (store.setPlanTodos) {
|
||||
store.setPlanTodos(todos)
|
||||
useCopilotStore.setState({ showPlanTodos: true })
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to update plan todos in store', { message: (e as any)?.message })
|
||||
}
|
||||
|
||||
this.setState(ClientToolCallState.success)
|
||||
// Echo args back so store/tooling can parse todoList if needed
|
||||
await this.markToolComplete(200, 'Plan ready', args || {})
|
||||
this.setState(ClientToolCallState.success)
|
||||
} catch (e: any) {
|
||||
logger.error('execute failed', { message: e?.message })
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(500, e?.message || 'Failed to plan')
|
||||
}
|
||||
/**
|
||||
* Execute the plan tool.
|
||||
* This just marks the tool as executing - the actual planning work is done server-side
|
||||
* by the plan subagent, and its output is streamed as subagent events.
|
||||
*/
|
||||
async execute(_args?: PlanArgs): Promise<void> {
|
||||
// Immediately transition to executing state - no user confirmation needed
|
||||
this.setState(ClientToolCallState.executing)
|
||||
// The tool result will come from the server via tool_result event
|
||||
// when the plan subagent completes its work
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(PlanClientTool.id, PlanClientTool.metadata.uiConfig!)
|
||||
|
||||
56
apps/sim/lib/copilot/tools/client/other/research.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/research.ts
Normal 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!)
|
||||
@@ -25,7 +25,7 @@ export class SearchDocumentationClientTool extends BaseClientTool {
|
||||
[ClientToolCallState.generating]: { text: 'Searching documentation', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Searching documentation', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Searching documentation', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Documentation search complete', icon: BookOpen },
|
||||
[ClientToolCallState.success]: { text: 'Completed documentation search', icon: BookOpen },
|
||||
[ClientToolCallState.error]: { text: 'Failed to search docs', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted documentation search', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped documentation search', icon: MinusCircle },
|
||||
|
||||
@@ -27,7 +27,7 @@ export class SearchOnlineClientTool extends BaseClientTool {
|
||||
[ClientToolCallState.generating]: { text: 'Searching online', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Searching online', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Searching online', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Online search complete', icon: Globe },
|
||||
[ClientToolCallState.success]: { text: 'Completed online search', icon: Globe },
|
||||
[ClientToolCallState.error]: { text: 'Failed to search online', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped online search', icon: MinusCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted online search', icon: XCircle },
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
|
||||
/** Maximum sleep duration in seconds (3 minutes) */
|
||||
const MAX_SLEEP_SECONDS = 180
|
||||
@@ -39,11 +40,20 @@ export class SleepClientTool extends BaseClientTool {
|
||||
[ClientToolCallState.pending]: { text: 'Sleeping', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Sleeping', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Finished sleeping', icon: Moon },
|
||||
[ClientToolCallState.error]: { text: 'Sleep interrupted', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Sleep skipped', icon: MinusCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Sleep aborted', icon: MinusCircle },
|
||||
[ClientToolCallState.error]: { text: 'Interrupted sleep', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped sleep', icon: MinusCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted sleep', icon: MinusCircle },
|
||||
[ClientToolCallState.background]: { text: 'Resumed', icon: Moon },
|
||||
},
|
||||
uiConfig: {
|
||||
secondaryAction: {
|
||||
text: 'Wake',
|
||||
title: 'Wake',
|
||||
variant: 'tertiary',
|
||||
showInStates: [ClientToolCallState.executing],
|
||||
targetState: ClientToolCallState.background,
|
||||
},
|
||||
},
|
||||
// No interrupt - auto-execute immediately
|
||||
getDynamicText: (params, state) => {
|
||||
const seconds = params?.seconds
|
||||
@@ -142,3 +152,6 @@ export class SleepClientTool extends BaseClientTool {
|
||||
await this.handleAccept(args)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(SleepClientTool.id, SleepClientTool.metadata.uiConfig!)
|
||||
|
||||
56
apps/sim/lib/copilot/tools/client/other/test.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/test.ts
Normal 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!)
|
||||
56
apps/sim/lib/copilot/tools/client/other/tour.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/tour.ts
Normal 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!)
|
||||
56
apps/sim/lib/copilot/tools/client/other/workflow.ts
Normal file
56
apps/sim/lib/copilot/tools/client/other/workflow.ts
Normal 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!)
|
||||
238
apps/sim/lib/copilot/tools/client/ui-config.ts
Normal file
238
apps/sim/lib/copilot/tools/client/ui-config.ts
Normal 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 }
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -48,6 +49,33 @@ export class SetEnvironmentVariablesClientTool extends BaseClientTool {
|
||||
accept: { text: 'Apply', icon: Settings2 },
|
||||
reject: { text: 'Skip', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
alwaysExpanded: true,
|
||||
interrupt: {
|
||||
accept: { text: 'Apply', icon: Settings2 },
|
||||
reject: { text: 'Skip', icon: XCircle },
|
||||
showAllowOnce: true,
|
||||
showAllowAlways: true,
|
||||
},
|
||||
paramsTable: {
|
||||
columns: [
|
||||
{ key: 'name', label: 'Variable', width: '36%', editable: true },
|
||||
{ key: 'value', label: 'Value', width: '64%', editable: true, mono: true },
|
||||
],
|
||||
extractRows: (params) => {
|
||||
const variables = params.variables || {}
|
||||
const entries = Array.isArray(variables)
|
||||
? variables.map((v: any, i: number) => [String(i), v.name || `var_${i}`, v.value || ''])
|
||||
: Object.entries(variables).map(([key, val]) => {
|
||||
if (typeof val === 'object' && val !== null && 'value' in (val as any)) {
|
||||
return [key, key, (val as any).value]
|
||||
}
|
||||
return [key, key, val]
|
||||
})
|
||||
return entries as Array<[string, ...any[]]>
|
||||
},
|
||||
},
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
if (params?.variables && typeof params.variables === 'object') {
|
||||
const count = Object.keys(params.variables).length
|
||||
@@ -121,3 +149,9 @@ export class SetEnvironmentVariablesClientTool extends BaseClientTool {
|
||||
await this.handleAccept(args)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(
|
||||
SetEnvironmentVariablesClientTool.id,
|
||||
SetEnvironmentVariablesClientTool.metadata.uiConfig!
|
||||
)
|
||||
|
||||
@@ -11,6 +11,29 @@ interface CheckDeploymentStatusArgs {
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
interface ApiDeploymentDetails {
|
||||
isDeployed: boolean
|
||||
deployedAt: string | null
|
||||
endpoint: string | null
|
||||
}
|
||||
|
||||
interface ChatDeploymentDetails {
|
||||
isDeployed: boolean
|
||||
chatId: string | null
|
||||
identifier: string | null
|
||||
chatUrl: string | null
|
||||
}
|
||||
|
||||
interface McpDeploymentDetails {
|
||||
isDeployed: boolean
|
||||
servers: Array<{
|
||||
serverId: string
|
||||
serverName: string
|
||||
toolName: string
|
||||
toolDescription: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export class CheckDeploymentStatusClientTool extends BaseClientTool {
|
||||
static readonly id = 'check_deployment_status'
|
||||
|
||||
@@ -45,52 +68,116 @@ export class CheckDeploymentStatusClientTool extends BaseClientTool {
|
||||
try {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry.getState()
|
||||
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
|
||||
const workflowId = args?.workflowId || activeWorkflowId
|
||||
|
||||
if (!workflowId) {
|
||||
throw new Error('No workflow ID provided')
|
||||
}
|
||||
|
||||
// Fetch deployment status from API
|
||||
const [apiDeployRes, chatDeployRes] = await Promise.all([
|
||||
const workflow = workflows[workflowId]
|
||||
const workspaceId = workflow?.workspaceId
|
||||
|
||||
// Fetch deployment status from all sources
|
||||
const [apiDeployRes, chatDeployRes, mcpServersRes] = await Promise.all([
|
||||
fetch(`/api/workflows/${workflowId}/deploy`),
|
||||
fetch(`/api/workflows/${workflowId}/chat/status`),
|
||||
workspaceId ? fetch(`/api/mcp/workflow-servers?workspaceId=${workspaceId}`) : null,
|
||||
])
|
||||
|
||||
const apiDeploy = apiDeployRes.ok ? await apiDeployRes.json() : null
|
||||
const chatDeploy = chatDeployRes.ok ? await chatDeployRes.json() : null
|
||||
const mcpServers = mcpServersRes?.ok ? await mcpServersRes.json() : null
|
||||
|
||||
// API deployment details
|
||||
const isApiDeployed = apiDeploy?.isDeployed || false
|
||||
const appUrl = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
const apiDetails: ApiDeploymentDetails = {
|
||||
isDeployed: isApiDeployed,
|
||||
deployedAt: apiDeploy?.deployedAt || null,
|
||||
endpoint: isApiDeployed ? `${appUrl}/api/workflows/${workflowId}/execute` : null,
|
||||
}
|
||||
|
||||
// Chat deployment details
|
||||
const isChatDeployed = !!(chatDeploy?.isDeployed && chatDeploy?.deployment)
|
||||
const chatDetails: ChatDeploymentDetails = {
|
||||
isDeployed: isChatDeployed,
|
||||
chatId: chatDeploy?.deployment?.id || null,
|
||||
identifier: chatDeploy?.deployment?.identifier || null,
|
||||
chatUrl: isChatDeployed ? `${appUrl}/chat/${chatDeploy?.deployment?.identifier}` : null,
|
||||
}
|
||||
|
||||
// MCP deployment details - find servers that have this workflow as a tool
|
||||
const mcpServerList = mcpServers?.data?.servers || []
|
||||
const mcpToolDeployments: McpDeploymentDetails['servers'] = []
|
||||
|
||||
for (const server of mcpServerList) {
|
||||
// Check if this workflow is deployed as a tool on this server
|
||||
if (server.toolNames && Array.isArray(server.toolNames)) {
|
||||
// We need to fetch the actual tools to check if this workflow is there
|
||||
try {
|
||||
const toolsRes = await fetch(
|
||||
`/api/mcp/workflow-servers/${server.id}/tools?workspaceId=${workspaceId}`
|
||||
)
|
||||
if (toolsRes.ok) {
|
||||
const toolsData = await toolsRes.json()
|
||||
const tools = toolsData.data?.tools || []
|
||||
for (const tool of tools) {
|
||||
if (tool.workflowId === workflowId) {
|
||||
mcpToolDeployments.push({
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
toolName: tool.toolName,
|
||||
toolDescription: tool.toolDescription,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip this server if we can't fetch tools
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isMcpDeployed = mcpToolDeployments.length > 0
|
||||
const mcpDetails: McpDeploymentDetails = {
|
||||
isDeployed: isMcpDeployed,
|
||||
servers: mcpToolDeployments,
|
||||
}
|
||||
|
||||
// Build deployment types list
|
||||
const deploymentTypes: string[] = []
|
||||
if (isApiDeployed) deploymentTypes.push('api')
|
||||
if (isChatDeployed) deploymentTypes.push('chat')
|
||||
if (isMcpDeployed) deploymentTypes.push('mcp')
|
||||
|
||||
if (isApiDeployed) {
|
||||
// Default to sync API, could be extended to detect streaming/async
|
||||
deploymentTypes.push('api')
|
||||
const isDeployed = isApiDeployed || isChatDeployed || isMcpDeployed
|
||||
|
||||
// Build summary message
|
||||
let message = ''
|
||||
if (!isDeployed) {
|
||||
message = 'Workflow is not deployed'
|
||||
} else {
|
||||
const parts: string[] = []
|
||||
if (isApiDeployed) parts.push('API')
|
||||
if (isChatDeployed) parts.push(`Chat (${chatDetails.identifier})`)
|
||||
if (isMcpDeployed) {
|
||||
const serverNames = mcpToolDeployments.map((d) => d.serverName).join(', ')
|
||||
parts.push(`MCP (${serverNames})`)
|
||||
}
|
||||
message = `Workflow is deployed as: ${parts.join(', ')}`
|
||||
}
|
||||
|
||||
if (isChatDeployed) {
|
||||
deploymentTypes.push('chat')
|
||||
}
|
||||
|
||||
const isDeployed = isApiDeployed || isChatDeployed
|
||||
|
||||
this.setState(ClientToolCallState.success)
|
||||
await this.markToolComplete(
|
||||
200,
|
||||
isDeployed
|
||||
? `Workflow is deployed as: ${deploymentTypes.join(', ')}`
|
||||
: 'Workflow is not deployed',
|
||||
{
|
||||
isDeployed,
|
||||
deploymentTypes,
|
||||
apiDeployed: isApiDeployed,
|
||||
chatDeployed: isChatDeployed,
|
||||
deployedAt: apiDeploy?.deployedAt || null,
|
||||
}
|
||||
)
|
||||
await this.markToolComplete(200, message, {
|
||||
isDeployed,
|
||||
deploymentTypes,
|
||||
api: apiDetails,
|
||||
chat: chatDetails,
|
||||
mcp: mcpDetails,
|
||||
})
|
||||
|
||||
logger.info('Checked deployment status', { isDeployed, deploymentTypes })
|
||||
} catch (e: any) {
|
||||
logger.error('Check deployment status failed', { message: e?.message })
|
||||
this.setState(ClientToolCallState.error)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,40 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Loader2, Rocket, X, XCircle } from 'lucide-react'
|
||||
import { Loader2, Rocket, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
|
||||
import { getInputFormatExample } from '@/lib/workflows/operations/deployment-utils'
|
||||
import { useCopilotStore } from '@/stores/panel/copilot/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
interface DeployWorkflowArgs {
|
||||
interface DeployApiArgs {
|
||||
action: 'deploy' | 'undeploy'
|
||||
deployType?: 'api' | 'chat'
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
interface ApiKeysData {
|
||||
workspaceKeys: Array<{ id: string; name: string }>
|
||||
personalKeys: Array<{ id: string; name: string }>
|
||||
}
|
||||
|
||||
export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
static readonly id = 'deploy_workflow'
|
||||
/**
|
||||
* Deploy API tool for deploying workflows as REST APIs.
|
||||
* This tool handles both deploying and undeploying workflows via the API endpoint.
|
||||
*/
|
||||
export class DeployApiClientTool extends BaseClientTool {
|
||||
static readonly id = 'deploy_api'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, DeployWorkflowClientTool.id, DeployWorkflowClientTool.metadata)
|
||||
super(toolCallId, DeployApiClientTool.id, DeployApiClientTool.metadata)
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to provide dynamic button text based on action and deployType
|
||||
* Override to provide dynamic button text based on action
|
||||
*/
|
||||
getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined {
|
||||
// Get params from the copilot store
|
||||
const toolCallsById = useCopilotStore.getState().toolCallsById
|
||||
const toolCall = toolCallsById[this.toolCallId]
|
||||
const params = toolCall?.params as DeployWorkflowArgs | undefined
|
||||
const params = toolCall?.params as DeployApiArgs | undefined
|
||||
|
||||
const action = params?.action || 'deploy'
|
||||
const deployType = params?.deployType || 'api'
|
||||
|
||||
// Check if workflow is already deployed
|
||||
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
|
||||
@@ -45,13 +42,10 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed
|
||||
: false
|
||||
|
||||
let buttonText = action.charAt(0).toUpperCase() + action.slice(1)
|
||||
let buttonText = action === 'undeploy' ? 'Undeploy' : 'Deploy'
|
||||
|
||||
// Change to "Redeploy" if already deployed
|
||||
if (action === 'deploy' && isAlreadyDeployed) {
|
||||
buttonText = 'Redeploy'
|
||||
} else if (action === 'deploy' && deployType === 'chat') {
|
||||
buttonText = 'Deploy as chat'
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -63,19 +57,19 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: {
|
||||
text: 'Preparing to deploy workflow',
|
||||
text: 'Preparing to deploy API',
|
||||
icon: Loader2,
|
||||
},
|
||||
[ClientToolCallState.pending]: { text: 'Deploy workflow?', icon: Rocket },
|
||||
[ClientToolCallState.executing]: { text: 'Deploying workflow', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Deployed workflow', icon: Rocket },
|
||||
[ClientToolCallState.error]: { text: 'Failed to deploy workflow', icon: X },
|
||||
[ClientToolCallState.pending]: { text: 'Deploy as API?', icon: Rocket },
|
||||
[ClientToolCallState.executing]: { text: 'Deploying API', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Deployed API', icon: Rocket },
|
||||
[ClientToolCallState.error]: { text: 'Failed to deploy API', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: {
|
||||
text: 'Aborted deploying workflow',
|
||||
text: 'Aborted deploying API',
|
||||
icon: XCircle,
|
||||
},
|
||||
[ClientToolCallState.rejected]: {
|
||||
text: 'Skipped deploying workflow',
|
||||
text: 'Skipped deploying API',
|
||||
icon: XCircle,
|
||||
},
|
||||
},
|
||||
@@ -83,9 +77,17 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
accept: { text: 'Deploy', icon: Rocket },
|
||||
reject: { text: 'Skip', icon: XCircle },
|
||||
},
|
||||
uiConfig: {
|
||||
isSpecial: true,
|
||||
interrupt: {
|
||||
accept: { text: 'Deploy', icon: Rocket },
|
||||
reject: { text: 'Skip', icon: XCircle },
|
||||
showAllowOnce: true,
|
||||
showAllowAlways: true,
|
||||
},
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
const action = params?.action === 'undeploy' ? 'undeploy' : 'deploy'
|
||||
const deployType = params?.deployType || 'api'
|
||||
|
||||
// Check if workflow is already deployed
|
||||
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
|
||||
@@ -93,48 +95,32 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
? useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId)?.isDeployed
|
||||
: false
|
||||
|
||||
// Determine action text based on deployment status
|
||||
let actionText = action
|
||||
let actionTextIng = action === 'undeploy' ? 'undeploying' : 'deploying'
|
||||
let actionTextPast = action === 'undeploy' ? 'undeployed' : 'deployed'
|
||||
const actionTextPast = action === 'undeploy' ? 'undeployed' : 'deployed'
|
||||
|
||||
// If already deployed and action is deploy, change to redeploy
|
||||
if (action === 'deploy' && isAlreadyDeployed) {
|
||||
actionText = 'redeploy'
|
||||
actionTextIng = 'redeploying'
|
||||
actionTextPast = 'redeployed'
|
||||
}
|
||||
|
||||
const actionCapitalized = actionText.charAt(0).toUpperCase() + actionText.slice(1)
|
||||
|
||||
// Special text for chat deployment
|
||||
const isChatDeploy = action === 'deploy' && deployType === 'chat'
|
||||
const displayAction = isChatDeploy ? 'deploy as chat' : actionText
|
||||
const displayActionCapitalized = isChatDeploy ? 'Deploy as chat' : actionCapitalized
|
||||
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return isChatDeploy
|
||||
? 'Opened chat deployment settings'
|
||||
: `${actionCapitalized}ed workflow`
|
||||
return `API ${actionTextPast}`
|
||||
case ClientToolCallState.executing:
|
||||
return isChatDeploy
|
||||
? 'Opening chat deployment settings'
|
||||
: `${actionCapitalized}ing workflow`
|
||||
return `${actionCapitalized}ing API`
|
||||
case ClientToolCallState.generating:
|
||||
return `Preparing to ${displayAction} workflow`
|
||||
return `Preparing to ${actionText} API`
|
||||
case ClientToolCallState.pending:
|
||||
return `${displayActionCapitalized} workflow?`
|
||||
return `${actionCapitalized} API?`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to ${displayAction} workflow`
|
||||
return `Failed to ${actionText} API`
|
||||
case ClientToolCallState.aborted:
|
||||
return isChatDeploy
|
||||
? 'Aborted opening chat deployment'
|
||||
: `Aborted ${actionTextIng} workflow`
|
||||
return `Aborted ${actionTextIng} API`
|
||||
case ClientToolCallState.rejected:
|
||||
return isChatDeploy
|
||||
? 'Skipped opening chat deployment'
|
||||
: `Skipped ${actionTextIng} workflow`
|
||||
return `Skipped ${actionTextIng} API`
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
@@ -162,7 +148,7 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
|
||||
return workspaceKeys.length > 0 || personalKeys.length > 0
|
||||
} catch (error) {
|
||||
const logger = createLogger('DeployWorkflowClientTool')
|
||||
const logger = createLogger('DeployApiClientTool')
|
||||
logger.warn('Failed to check API keys:', error)
|
||||
return false
|
||||
}
|
||||
@@ -175,23 +161,15 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'apikeys' } }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the deploy modal to the chat tab
|
||||
*/
|
||||
private openDeployModal(tab: 'api' | 'chat' = 'api'): void {
|
||||
window.dispatchEvent(new CustomEvent('open-deploy-modal', { detail: { tab } }))
|
||||
}
|
||||
|
||||
async handleReject(): Promise<void> {
|
||||
await super.handleReject()
|
||||
this.setState(ClientToolCallState.rejected)
|
||||
}
|
||||
|
||||
async handleAccept(args?: DeployWorkflowArgs): Promise<void> {
|
||||
const logger = createLogger('DeployWorkflowClientTool')
|
||||
async handleAccept(args?: DeployApiArgs): Promise<void> {
|
||||
const logger = createLogger('DeployApiClientTool')
|
||||
try {
|
||||
const action = args?.action || 'deploy'
|
||||
const deployType = args?.deployType || 'api'
|
||||
const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
|
||||
const workflowId = args?.workflowId || activeWorkflowId
|
||||
|
||||
@@ -202,22 +180,6 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
const workflow = workflows[workflowId]
|
||||
const workspaceId = workflow?.workspaceId
|
||||
|
||||
// For chat deployment, just open the deploy modal
|
||||
if (action === 'deploy' && deployType === 'chat') {
|
||||
this.setState(ClientToolCallState.success)
|
||||
this.openDeployModal('chat')
|
||||
await this.markToolComplete(
|
||||
200,
|
||||
'Opened chat deployment settings. Configure and deploy your workflow as a chat interface.',
|
||||
{
|
||||
action,
|
||||
deployType,
|
||||
openedModal: true,
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// For deploy action, check if user has API keys first
|
||||
if (action === 'deploy') {
|
||||
if (!workspaceId) {
|
||||
@@ -227,10 +189,7 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
const hasKeys = await this.hasApiKeys(workspaceId)
|
||||
|
||||
if (!hasKeys) {
|
||||
// Mark as rejected since we can't deploy without an API key
|
||||
this.setState(ClientToolCallState.rejected)
|
||||
|
||||
// Open the API keys modal to help user create one
|
||||
this.openApiKeysModal()
|
||||
|
||||
await this.markToolComplete(
|
||||
@@ -248,7 +207,6 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
// Perform the deploy/undeploy action
|
||||
const endpoint = `/api/workflows/${workflowId}/deploy`
|
||||
const method = action === 'deploy' ? 'POST' : 'DELETE'
|
||||
|
||||
@@ -273,25 +231,21 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
}
|
||||
|
||||
if (action === 'deploy') {
|
||||
// Generate the curl command for the deployed workflow (matching deploy modal format)
|
||||
const appUrl =
|
||||
typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: process.env.NEXT_PUBLIC_APP_URL || 'https://app.sim.ai'
|
||||
const endpoint = `${appUrl}/api/workflows/${workflowId}/execute`
|
||||
const apiEndpoint = `${appUrl}/api/workflows/${workflowId}/execute`
|
||||
const apiKeyPlaceholder = '$SIM_API_KEY'
|
||||
|
||||
// Get input format example (returns empty string if no inputs, or -d flag with example data)
|
||||
const inputExample = getInputFormatExample(false)
|
||||
const curlCommand = `curl -X POST -H "X-API-Key: ${apiKeyPlaceholder}" -H "Content-Type: application/json"${inputExample} ${apiEndpoint}`
|
||||
|
||||
// Match the exact format from deploy modal
|
||||
const curlCommand = `curl -X POST -H "X-API-Key: ${apiKeyPlaceholder}" -H "Content-Type: application/json"${inputExample} ${endpoint}`
|
||||
|
||||
successMessage = 'Workflow deployed successfully. You can now call it via the API.'
|
||||
successMessage = 'Workflow deployed successfully as API. You can now call it via REST.'
|
||||
|
||||
resultData = {
|
||||
...resultData,
|
||||
endpoint,
|
||||
endpoint: apiEndpoint,
|
||||
curlCommand,
|
||||
apiKeyPlaceholder,
|
||||
}
|
||||
@@ -316,18 +270,21 @@ export class DeployWorkflowClientTool extends BaseClientTool {
|
||||
setDeploymentStatus(workflowId, false, undefined, '')
|
||||
}
|
||||
const actionPast = action === 'undeploy' ? 'undeployed' : 'deployed'
|
||||
logger.info(`Workflow ${actionPast} and registry updated`)
|
||||
logger.info(`Workflow ${actionPast} as API and registry updated`)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to update workflow registry:', error)
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error('Deploy/undeploy failed', { message: e?.message })
|
||||
logger.error('Deploy API failed', { message: e?.message })
|
||||
this.setState(ClientToolCallState.error)
|
||||
await this.markToolComplete(500, e?.message || 'Failed to deploy/undeploy workflow')
|
||||
await this.markToolComplete(500, e?.message || 'Failed to deploy API')
|
||||
}
|
||||
}
|
||||
|
||||
async execute(args?: DeployWorkflowArgs): Promise<void> {
|
||||
async execute(args?: DeployApiArgs): Promise<void> {
|
||||
await this.handleAccept(args)
|
||||
}
|
||||
}
|
||||
|
||||
// Register UI config at module load
|
||||
registerToolUIConfig(DeployApiClientTool.id, DeployApiClientTool.metadata.uiConfig!)
|
||||
365
apps/sim/lib/copilot/tools/client/workflow/deploy-chat.ts
Normal file
365
apps/sim/lib/copilot/tools/client/workflow/deploy-chat.ts
Normal 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!)
|
||||
211
apps/sim/lib/copilot/tools/client/workflow/deploy-mcp.ts
Normal file
211
apps/sim/lib/copilot/tools/client/workflow/deploy-mcp.ts
Normal 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!)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user