-
+
c + c)
+ .join('')
+ }
+ return `#${cleaned}`
+}
interface ContextMenuProps {
/**
@@ -53,6 +82,14 @@ interface ContextMenuProps {
* Callback when delete is clicked
*/
onDelete: () => void
+ /**
+ * Callback when color is changed
+ */
+ onColorChange?: (color: string) => void
+ /**
+ * Current workflow color (for showing selected state)
+ */
+ currentColor?: string
/**
* Whether to show the open in new tab option (default: false)
* Set to true for items that can be opened in a new tab
@@ -83,11 +120,21 @@ interface ContextMenuProps {
* Set to true for items that can be exported (like workspaces)
*/
showExport?: boolean
+ /**
+ * Whether to show the change color option (default: false)
+ * Set to true for workflows to allow color customization
+ */
+ showColorChange?: boolean
/**
* Whether the export option is disabled (default: false)
* Set to true when user lacks permissions
*/
disableExport?: boolean
+ /**
+ * Whether the change color option is disabled (default: false)
+ * Set to true when user lacks permissions
+ */
+ disableColorChange?: boolean
/**
* Whether the rename option is disabled (default: false)
* Set to true when user lacks permissions
@@ -134,19 +181,76 @@ export function ContextMenu({
onDuplicate,
onExport,
onDelete,
+ onColorChange,
+ currentColor,
showOpenInNewTab = false,
showRename = true,
showCreate = false,
showCreateFolder = false,
showDuplicate = true,
showExport = false,
+ showColorChange = false,
disableExport = false,
+ disableColorChange = false,
disableRename = false,
disableDuplicate = false,
disableDelete = false,
disableCreate = false,
disableCreateFolder = false,
}: ContextMenuProps) {
+ const [hexInput, setHexInput] = useState(currentColor || '#ffffff')
+
+ // Sync hexInput when currentColor changes (e.g., opening menu on different workflow)
+ useEffect(() => {
+ setHexInput(currentColor || '#ffffff')
+ }, [currentColor])
+
+ const canSubmitHex = useMemo(() => {
+ if (!isValidHex(hexInput)) return false
+ const normalized = normalizeHex(hexInput)
+ if (currentColor && normalized.toLowerCase() === currentColor.toLowerCase()) return false
+ return true
+ }, [hexInput, currentColor])
+
+ const handleHexSubmit = useCallback(() => {
+ if (!canSubmitHex || !onColorChange) return
+
+ const normalized = normalizeHex(hexInput)
+ onColorChange(normalized)
+ setHexInput(normalized)
+ }, [hexInput, canSubmitHex, onColorChange])
+
+ const handleHexKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ handleHexSubmit()
+ }
+ },
+ [handleHexSubmit]
+ )
+
+ const handleHexChange = useCallback((e: React.ChangeEvent) => {
+ let value = e.target.value.trim()
+ if (value && !value.startsWith('#')) {
+ value = `#${value}`
+ }
+ value = value.slice(0, 1) + value.slice(1).replace(/[^0-9a-fA-F]/g, '')
+ setHexInput(value.slice(0, 7))
+ }, [])
+
+ const handleHexFocus = useCallback((e: React.FocusEvent) => {
+ e.target.select()
+ }, [])
+
+ const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
+ const hasEditSection =
+ (showRename && onRename) ||
+ (showCreate && onCreate) ||
+ (showCreateFolder && onCreateFolder) ||
+ (showColorChange && onColorChange)
+ const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport)
+
return (
-
+ e.preventDefault()}
+ onInteractOutside={(e) => e.preventDefault()}
+ >
+ {/* Back button - shown only when in a folder */}
+
+
{/* Navigation actions */}
{showOpenInNewTab && onOpenInNewTab && (
{
onOpenInNewTab()
onClose()
@@ -176,11 +291,12 @@ export function ContextMenu({
Open in new tab
)}
- {showOpenInNewTab && onOpenInNewTab && }
+ {hasNavigationSection && (hasEditSection || hasCopySection) && }
{/* Edit and create actions */}
{showRename && onRename && (
{
onRename()
@@ -192,6 +308,7 @@ export function ContextMenu({
)}
{showCreate && onCreate && (
{
onCreate()
@@ -203,6 +320,7 @@ export function ContextMenu({
)}
{showCreateFolder && onCreateFolder && (
{
onCreateFolder()
@@ -212,11 +330,72 @@ export function ContextMenu({
Create folder
)}
+ {showColorChange && onColorChange && (
+
+
+ {/* Preset colors */}
+
+ {WORKFLOW_COLORS.map(({ color, name }) => (
+ {
+ e.stopPropagation()
+ onColorChange(color)
+ }}
+ className={cn(
+ 'h-[20px] w-[20px] rounded-[4px]',
+ currentColor?.toLowerCase() === color.toLowerCase() && 'ring-1 ring-white'
+ )}
+ style={{ backgroundColor: color }}
+ />
+ ))}
+
+
+ {/* Hex input */}
+
+
+
e.stopPropagation()}
+ className='h-[20px] min-w-0 flex-1 rounded-[4px] bg-[#363636] px-[6px] text-[11px] text-white uppercase focus:outline-none'
+ />
+
{
+ e.stopPropagation()
+ handleHexSubmit()
+ }}
+ className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[var(--brand-tertiary-2)] text-white disabled:opacity-40'
+ >
+
+
+
+
+
+ )}
{/* Copy and export actions */}
- {(showDuplicate || showExport) && }
+ {hasEditSection && hasCopySection && }
{showDuplicate && onDuplicate && (
{
onDuplicate()
@@ -228,6 +407,7 @@ export function ContextMenu({
)}
{showExport && onExport && (
{
onExport()
@@ -239,8 +419,9 @@ export function ContextMenu({
)}
{/* Destructive action */}
-
+ {(hasNavigationSection || hasEditSection || hasCopySection) && }
{
onDelete()
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx
index ee65207b47..1dc249088d 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx
@@ -3,8 +3,9 @@
import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import clsx from 'clsx'
-import { ChevronRight, Folder, FolderOpen } from 'lucide-react'
+import { ChevronRight, Folder, FolderOpen, MoreHorizontal } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
+import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
@@ -23,10 +24,7 @@ import {
import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
import { useCreateWorkflow } from '@/hooks/queries/workflows'
import type { FolderTreeNode } from '@/stores/folders/types'
-import {
- generateCreativeWorkflowName,
- getNextWorkflowColor,
-} from '@/stores/workflows/registry/utils'
+import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
const logger = createLogger('FolderItem')
@@ -173,6 +171,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
menuRef,
handleContextMenu,
closeMenu,
+ preventDismiss,
} = useContextMenu()
// Rename hook
@@ -242,6 +241,40 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
[isEditing, handleRenameKeyDown, handleExpandKeyDown]
)
+ /**
+ * Handle more button pointerdown - prevents click-outside dismissal when toggling
+ */
+ const handleMorePointerDown = useCallback(() => {
+ if (isContextMenuOpen) {
+ preventDismiss()
+ }
+ }, [isContextMenuOpen, preventDismiss])
+
+ /**
+ * Handle more button click - toggles context menu at button position
+ */
+ const handleMoreClick = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ // Toggle: close if open, open if closed
+ if (isContextMenuOpen) {
+ closeMenu()
+ return
+ }
+
+ const rect = e.currentTarget.getBoundingClientRect()
+ handleContextMenu({
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ clientX: rect.right,
+ clientY: rect.top,
+ } as React.MouseEvent)
+ },
+ [isContextMenuOpen, closeMenu, handleContextMenu]
+ )
+
return (
<>
) : (
-
- {folder.name}
-
+ <>
+
+ {folder.name}
+
+
+
+
+ >
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx
index 685787bc92..506b9a6e24 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx
@@ -1,15 +1,24 @@
'use client'
-import { type CSSProperties, useEffect, useMemo, useState } from 'react'
-import Image from 'next/image'
-import { Tooltip } from '@/components/emcn'
+import { type CSSProperties, useEffect, useMemo } from 'react'
+import { Avatar, AvatarFallback, AvatarImage, Tooltip } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { getUserColor } from '@/lib/workspaces/colors'
import { useSocket } from '@/app/workspace/providers/socket-provider'
+import { SIDEBAR_WIDTH } from '@/stores/constants'
+import { useSidebarStore } from '@/stores/sidebar/store'
+
+/**
+ * Avatar display configuration for responsive layout.
+ */
+const AVATAR_CONFIG = {
+ MIN_COUNT: 3,
+ MAX_COUNT: 12,
+ WIDTH_PER_AVATAR: 20,
+} as const
interface AvatarsProps {
workflowId: string
- maxVisible?: number
/**
* Callback fired when the presence visibility changes.
* Used by parent components to adjust layout (e.g., text truncation spacing).
@@ -30,45 +39,29 @@ interface UserAvatarProps {
}
/**
- * Individual user avatar with error handling for image loading.
+ * Individual user avatar using emcn Avatar component.
* Falls back to colored circle with initials if image fails to load.
*/
function UserAvatar({ user, index }: UserAvatarProps) {
- const [imageError, setImageError] = useState(false)
const color = getUserColor(user.userId)
const initials = user.userName ? user.userName.charAt(0).toUpperCase() : '?'
- const hasAvatar = Boolean(user.avatarUrl) && !imageError
-
- // Reset error state when avatar URL changes
- useEffect(() => {
- setImageError(false)
- }, [user.avatarUrl])
const avatarElement = (
-
- {hasAvatar && user.avatarUrl ? (
-
+ {user.avatarUrl && (
+ setImageError(true)}
/>
- ) : (
- initials
)}
-
+
+ {initials}
+
+
)
if (user.userName) {
@@ -92,14 +85,26 @@ function UserAvatar({ user, index }: UserAvatarProps) {
* @param props - Component props
* @returns Avatar stack for workflow presence
*/
-export function Avatars({ workflowId, maxVisible = 3, onPresenceChange }: AvatarsProps) {
+export function Avatars({ workflowId, onPresenceChange }: AvatarsProps) {
const { presenceUsers, currentWorkflowId } = useSocket()
const { data: session } = useSession()
const currentUserId = session?.user?.id
+ const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
/**
- * Only show presence for the currently active workflow
- * Filter out the current user from the list
+ * Calculate max visible avatars based on sidebar width.
+ * Scales between MIN_COUNT and MAX_COUNT as sidebar expands.
+ */
+ const maxVisible = useMemo(() => {
+ const widthDelta = sidebarWidth - SIDEBAR_WIDTH.MIN
+ const additionalAvatars = Math.floor(widthDelta / AVATAR_CONFIG.WIDTH_PER_AVATAR)
+ const calculated = AVATAR_CONFIG.MIN_COUNT + additionalAvatars
+ return Math.max(AVATAR_CONFIG.MIN_COUNT, Math.min(AVATAR_CONFIG.MAX_COUNT, calculated))
+ }, [sidebarWidth])
+
+ /**
+ * Only show presence for the currently active workflow.
+ * Filter out the current user from the list.
*/
const workflowUsers = useMemo(() => {
if (currentWorkflowId !== workflowId) {
@@ -122,7 +127,6 @@ export function Avatars({ workflowId, maxVisible = 3, onPresenceChange }: Avatar
return { visibleUsers: visible, overflowCount: overflow }
}, [workflowUsers, maxVisible])
- // Notify parent when avatars are present or not
useEffect(() => {
const hasAnyAvatars = visibleUsers.length > 0
if (typeof onPresenceChange === 'function') {
@@ -135,26 +139,25 @@ export function Avatars({ workflowId, maxVisible = 3, onPresenceChange }: Avatar
}
return (
-
- {visibleUsers.map((user, index) => (
-
- ))}
-
+
{overflowCount > 0 && (
-
- +{overflowCount}
-
+
+
+ +{overflowCount}
+
+
{overflowCount} more user{overflowCount > 1 ? 's' : ''}
)}
+
+ {visibleUsers.map((user, index) => (
+
0 ? index + 1 : index} />
+ ))}
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx
index ffa481fa1b..2665ed3b24 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx
@@ -2,6 +2,7 @@
import { useCallback, useRef, useState } from 'react'
import clsx from 'clsx'
+import { MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -108,6 +109,16 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
window.open(`/workspace/${workspaceId}/w/${workflow.id}`, '_blank')
}, [workspaceId, workflow.id])
+ /**
+ * Changes the workflow color
+ */
+ const handleColorChange = useCallback(
+ (color: string) => {
+ updateWorkflow(workflow.id, { color })
+ },
+ [workflow.id, updateWorkflow]
+ )
+
/**
* Drag start handler - handles workflow dragging with multi-selection support
*
@@ -142,8 +153,38 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
menuRef,
handleContextMenu: handleContextMenuBase,
closeMenu,
+ preventDismiss,
} = useContextMenu()
+ /**
+ * Captures selection state for context menu operations
+ */
+ const captureSelectionState = useCallback(() => {
+ const { selectedWorkflows: currentSelection, selectOnly } = useFolderStore.getState()
+ const isCurrentlySelected = currentSelection.has(workflow.id)
+
+ if (!isCurrentlySelected) {
+ selectOnly(workflow.id)
+ }
+
+ const finalSelection = useFolderStore.getState().selectedWorkflows
+ const finalIsSelected = finalSelection.has(workflow.id)
+
+ const workflowIds =
+ finalIsSelected && finalSelection.size > 1 ? Array.from(finalSelection) : [workflow.id]
+
+ const workflowNames = workflowIds
+ .map((id) => workflows[id]?.name)
+ .filter((name): name is string => !!name)
+
+ capturedSelectionRef.current = {
+ workflowIds,
+ workflowNames: workflowNames.length > 1 ? workflowNames : workflowNames[0],
+ }
+
+ setCanDeleteCaptured(canDeleteWorkflows(workflowIds))
+ }, [workflow.id, workflows, canDeleteWorkflows])
+
/**
* Handle right-click - ensure proper selection behavior and capture selection state
* If right-clicking on an unselected workflow, select only that workflow
@@ -151,39 +192,46 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
*/
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
- // Check current selection state at time of right-click
- const { selectedWorkflows: currentSelection, selectOnly } = useFolderStore.getState()
- const isCurrentlySelected = currentSelection.has(workflow.id)
-
- // If this workflow is not in the current selection, select only this workflow
- if (!isCurrentlySelected) {
- selectOnly(workflow.id)
- }
-
- // Capture the selection state at right-click time
- const finalSelection = useFolderStore.getState().selectedWorkflows
- const finalIsSelected = finalSelection.has(workflow.id)
+ captureSelectionState()
+ handleContextMenuBase(e)
+ },
+ [captureSelectionState, handleContextMenuBase]
+ )
- const workflowIds =
- finalIsSelected && finalSelection.size > 1 ? Array.from(finalSelection) : [workflow.id]
+ /**
+ * Handle more button pointerdown - prevents click-outside dismissal when toggling
+ */
+ const handleMorePointerDown = useCallback(() => {
+ if (isContextMenuOpen) {
+ preventDismiss()
+ }
+ }, [isContextMenuOpen, preventDismiss])
- const workflowNames = workflowIds
- .map((id) => workflows[id]?.name)
- .filter((name): name is string => !!name)
+ /**
+ * Handle more button click - toggles context menu at button position
+ */
+ const handleMoreClick = useCallback(
+ (e: React.MouseEvent
) => {
+ e.preventDefault()
+ e.stopPropagation()
- // Store in ref so it persists even if selection changes
- capturedSelectionRef.current = {
- workflowIds,
- workflowNames: workflowNames.length > 1 ? workflowNames : workflowNames[0],
+ // Toggle: close if open, open if closed
+ if (isContextMenuOpen) {
+ closeMenu()
+ return
}
- // Check if the captured selection can be deleted
- setCanDeleteCaptured(canDeleteWorkflows(workflowIds))
-
- // If already selected with multiple selections, keep all selections
- handleContextMenuBase(e)
+ captureSelectionState()
+ // Open context menu aligned with the button
+ const rect = e.currentTarget.getBoundingClientRect()
+ handleContextMenuBase({
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ clientX: rect.right,
+ clientY: rect.top,
+ } as React.MouseEvent)
},
- [workflow.id, workflows, handleContextMenuBase, canDeleteWorkflows]
+ [isContextMenuOpen, closeMenu, captureSelectionState, handleContextMenuBase]
)
// Rename hook
@@ -309,7 +357,17 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
)}
{!isEditing && (
-
+ <>
+
+
+
+
+ >
)}
@@ -324,13 +382,17 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
onDuplicate={handleDuplicateWorkflow}
onExport={handleExportWorkflow}
onDelete={handleOpenDeleteModal}
+ onColorChange={handleColorChange}
+ currentColor={workflow.color}
showOpenInNewTab={selectedWorkflows.size <= 1}
showRename={selectedWorkflows.size <= 1}
showDuplicate={true}
showExport={true}
+ showColorChange={selectedWorkflows.size <= 1}
disableRename={!userPermissions.canEdit}
disableDuplicate={!userPermissions.canEdit}
disableExport={!userPermissions.canEdit}
+ disableColorChange={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit || !canDeleteCaptured}
/>
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx
index 4fc754478f..bd37fe4f18 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx
@@ -657,6 +657,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
items={emailItems}
onAdd={(value) => addEmail(value)}
onRemove={removeEmailItem}
+ onInputChange={() => setErrorMessage(null)}
placeholder={
!userPerms.canAdmin
? 'Only administrators can invite new members'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-context-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-context-menu.ts
index 13a6291e34..35b8546b2e 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-context-menu.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-context-menu.ts
@@ -27,6 +27,8 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
const [isOpen, setIsOpen] = useState(false)
const [position, setPosition] = useState
({ x: 0, y: 0 })
const menuRef = useRef(null)
+ // Used to prevent click-outside dismissal when trigger is clicked
+ const dismissPreventedRef = useRef(false)
/**
* Handle right-click event
@@ -55,6 +57,14 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
setIsOpen(false)
}, [])
+ /**
+ * Prevent the next click-outside from dismissing the menu.
+ * Call this on pointerdown of a toggle trigger to allow proper toggle behavior.
+ */
+ const preventDismiss = useCallback(() => {
+ dismissPreventedRef.current = true
+ }, [])
+
/**
* Handle clicks outside the menu to close it
*/
@@ -62,6 +72,11 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
if (!isOpen) return
const handleClickOutside = (e: MouseEvent) => {
+ // Check if dismissal was prevented (e.g., by toggle trigger's pointerdown)
+ if (dismissPreventedRef.current) {
+ dismissPreventedRef.current = false
+ return
+ }
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
closeMenu()
}
@@ -84,5 +99,6 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
menuRef,
handleContextMenu,
closeMenu,
+ preventDismiss,
}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts
index 0dbe6085b0..69b7877c06 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts
@@ -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
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts
index f88b1cf118..6e9bed0441 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-operations.ts
@@ -1,13 +1,11 @@
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
+import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { useCreateWorkflow, useWorkflows } from '@/hooks/queries/workflows'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
-import {
- generateCreativeWorkflowName,
- getNextWorkflowColor,
-} from '@/stores/workflows/registry/utils'
+import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
const logger = createLogger('useWorkflowOperations')
diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts
index 6ead0955e0..5d39e8739e 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-workflow.ts
@@ -1,10 +1,10 @@
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
+import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { useDuplicateWorkflowMutation } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
-import { getNextWorkflowColor } from '@/stores/workflows/registry/utils'
const logger = createLogger('useDuplicateWorkflow')
diff --git a/apps/sim/blocks/blocks/response.ts b/apps/sim/blocks/blocks/response.ts
index f8be9f687e..b5da9365e8 100644
--- a/apps/sim/blocks/blocks/response.ts
+++ b/apps/sim/blocks/blocks/response.ts
@@ -17,6 +17,7 @@ export const ResponseBlock: BlockConfig = {
category: 'blocks',
bgColor: '#2F55FF',
icon: ResponseIcon,
+ singleInstance: true,
subBlocks: [
{
id: 'dataMode',
diff --git a/apps/sim/blocks/blocks/router.ts b/apps/sim/blocks/blocks/router.ts
index 649fe5a750..ecc48f5b70 100644
--- a/apps/sim/blocks/blocks/router.ts
+++ b/apps/sim/blocks/blocks/router.ts
@@ -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:`
}
/**
diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts
index 28605a440d..a9cac75e19 100644
--- a/apps/sim/blocks/types.ts
+++ b/apps/sim/blocks/types.ts
@@ -320,6 +320,7 @@ export interface BlockConfig {
subBlocks: SubBlockConfig[]
triggerAllowed?: boolean
authMode?: AuthMode
+ singleInstance?: boolean
tools: {
access: string[]
config?: {
diff --git a/apps/sim/components/emcn/components/avatar/avatar.tsx b/apps/sim/components/emcn/components/avatar/avatar.tsx
index d2a0cecc96..9500f7ecbe 100644
--- a/apps/sim/components/emcn/components/avatar/avatar.tsx
+++ b/apps/sim/components/emcn/components/avatar/avatar.tsx
@@ -12,11 +12,10 @@ import { cn } from '@/lib/core/utils/cn'
const avatarVariants = cva('relative flex shrink-0 overflow-hidden rounded-full', {
variants: {
size: {
- xs: 'h-6 w-6',
- sm: 'h-8 w-8',
- md: 'h-10 w-10',
- lg: 'h-12 w-12',
- xl: 'h-16 w-16',
+ xs: 'h-3.5 w-3.5',
+ sm: 'h-6 w-6',
+ md: 'h-8 w-8',
+ lg: 'h-10 w-10',
},
},
defaultVariants: {
@@ -38,11 +37,10 @@ const avatarStatusVariants = cva(
away: 'bg-[#f59e0b]',
},
size: {
- xs: 'h-2 w-2',
- sm: 'h-2.5 w-2.5',
- md: 'h-3 w-3',
- lg: 'h-3.5 w-3.5',
- xl: 'h-4 w-4',
+ xs: 'h-1.5 w-1.5 border',
+ sm: 'h-2 w-2',
+ md: 'h-2.5 w-2.5',
+ lg: 'h-3 w-3',
},
},
defaultVariants: {
diff --git a/apps/sim/components/emcn/components/popover/popover.tsx b/apps/sim/components/emcn/components/popover/popover.tsx
index 0aa8237cee..d80841d677 100644
--- a/apps/sim/components/emcn/components/popover/popover.tsx
+++ b/apps/sim/components/emcn/components/popover/popover.tsx
@@ -52,6 +52,7 @@
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { Check, ChevronLeft, ChevronRight, Search } from 'lucide-react'
+import { createPortal } from 'react-dom'
import { cn } from '@/lib/core/utils/cn'
type PopoverSize = 'sm' | 'md'
@@ -166,6 +167,9 @@ interface PopoverContextValue {
colorScheme: PopoverColorScheme
searchQuery: string
setSearchQuery: (query: string) => void
+ /** ID of the last hovered item (for hover submenus) */
+ lastHoveredItem: string | null
+ setLastHoveredItem: (id: string | null) => void
}
const PopoverContext = React.createContext(null)
@@ -208,12 +212,24 @@ const Popover: React.FC = ({
variant = 'default',
size = 'md',
colorScheme = 'default',
+ open,
...props
}) => {
const [currentFolder, setCurrentFolder] = React.useState(null)
const [folderTitle, setFolderTitle] = React.useState(null)
const [onFolderSelect, setOnFolderSelect] = React.useState<(() => void) | null>(null)
const [searchQuery, setSearchQuery] = React.useState('')
+ const [lastHoveredItem, setLastHoveredItem] = React.useState(null)
+
+ React.useEffect(() => {
+ if (open === false) {
+ setCurrentFolder(null)
+ setFolderTitle(null)
+ setOnFolderSelect(null)
+ setSearchQuery('')
+ setLastHoveredItem(null)
+ }
+ }, [open])
const openFolder = React.useCallback(
(id: string, title: string, onLoad?: () => void | Promise, onSelect?: () => void) => {
@@ -246,6 +262,8 @@ const Popover: React.FC = ({
colorScheme,
searchQuery,
setSearchQuery,
+ lastHoveredItem,
+ setLastHoveredItem,
}),
[
openFolder,
@@ -257,12 +275,15 @@ const Popover: React.FC = ({
size,
colorScheme,
searchQuery,
+ lastHoveredItem,
]
)
return (
- {children}
+
+ {children}
+
)
}
@@ -496,7 +517,17 @@ export interface PopoverItemProps extends React.HTMLAttributes {
*/
const PopoverItem = React.forwardRef(
(
- { className, active, rootOnly, disabled, showCheck = false, children, onClick, ...props },
+ {
+ className,
+ active,
+ rootOnly,
+ disabled,
+ showCheck = false,
+ children,
+ onClick,
+ onMouseEnter,
+ ...props
+ },
ref
) => {
const context = React.useContext(PopoverContext)
@@ -514,6 +545,12 @@ const PopoverItem = React.forwardRef(
onClick?.(e)
}
+ const handleMouseEnter = (e: React.MouseEvent) => {
+ // Clear last hovered item to close any open hover submenus
+ context?.setLastHoveredItem(null)
+ onMouseEnter?.(e)
+ }
+
return (
(
aria-selected={active}
aria-disabled={disabled}
onClick={handleClick}
+ onMouseEnter={handleMouseEnter}
{...props}
>
{children}
@@ -589,44 +627,150 @@ export interface PopoverFolderProps extends Omit
(
- ({ className, id, title, icon, onOpen, onSelect, children, active, ...props }, ref) => {
- const { openFolder, currentFolder, isInFolder, variant, size, colorScheme } =
- usePopoverContext()
+ (
+ {
+ className,
+ id,
+ title,
+ icon,
+ onOpen,
+ onSelect,
+ children,
+ active,
+ expandOnHover = false,
+ ...props
+ },
+ ref
+ ) => {
+ const {
+ openFolder,
+ currentFolder,
+ isInFolder,
+ variant,
+ size,
+ colorScheme,
+ lastHoveredItem,
+ setLastHoveredItem,
+ } = usePopoverContext()
+ const [submenuPosition, setSubmenuPosition] = React.useState<{ top: number; left: number }>({
+ top: 0,
+ left: 0,
+ })
+ const triggerRef = React.useRef(null)
+
+ // Submenu is open when this folder is the last hovered item (for expandOnHover mode)
+ const isHoverOpen = expandOnHover && lastHoveredItem === id
+
+ // Merge refs
+ const mergedRef = React.useCallback(
+ (node: HTMLDivElement | null) => {
+ triggerRef.current = node
+ if (typeof ref === 'function') {
+ ref(node)
+ } else if (ref) {
+ ref.current = node
+ }
+ },
+ [ref]
+ )
+ // If we're in a folder and this isn't the current one, hide
if (isInFolder && currentFolder !== id) return null
+ // If this folder is open via click (inline mode), render children directly
if (currentFolder === id) return <>{children}>
+ const handleClickOpen = () => {
+ openFolder(id, title, onOpen, onSelect)
+ }
+
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
- openFolder(id, title, onOpen, onSelect)
+ if (expandOnHover) {
+ // In hover mode, clicking opens inline and clears hover state
+ setLastHoveredItem(null)
+ }
+ handleClickOpen()
+ }
+
+ const handleMouseEnter = () => {
+ if (!expandOnHover) return
+
+ // Calculate position for submenu
+ if (triggerRef.current) {
+ const rect = triggerRef.current.getBoundingClientRect()
+ const parentPopover = triggerRef.current.closest('[data-radix-popper-content-wrapper]')
+ const parentRect = parentPopover?.getBoundingClientRect()
+
+ // Position to the right of the parent popover with a small gap
+ setSubmenuPosition({
+ top: rect.top,
+ left: parentRect ? parentRect.right + 4 : rect.right + 4,
+ })
+ }
+
+ setLastHoveredItem(id)
+ onOpen?.()
}
return (
-
- {icon}
- {title}
-
-
+ <>
+
+ {icon}
+ {title}
+
+
+
+ {/* Hover submenu - rendered as a portal to escape overflow clipping */}
+ {isHoverOpen &&
+ typeof document !== 'undefined' &&
+ createPortal(
+
+ {children}
+
,
+ document.body
+ )}
+ >
)
}
)
@@ -665,7 +809,10 @@ const PopoverBackButton = React.forwardRef {
+ e.stopPropagation()
+ closeFolder()
+ }}
{...props}
>
diff --git a/apps/sim/components/emcn/components/tag-input/tag-input.tsx b/apps/sim/components/emcn/components/tag-input/tag-input.tsx
index a866220e66..8a1c2e832a 100644
--- a/apps/sim/components/emcn/components/tag-input/tag-input.tsx
+++ b/apps/sim/components/emcn/components/tag-input/tag-input.tsx
@@ -166,6 +166,8 @@ export interface TagInputProps extends VariantProps {
onAdd: (value: string) => boolean
/** Callback when a tag is removed (receives value, index, and isValid) */
onRemove: (value: string, index: number, isValid: boolean) => void
+ /** Callback when the input value changes (useful for clearing errors) */
+ onInputChange?: (value: string) => void
/** Placeholder text for the input */
placeholder?: string
/** Placeholder text when there are existing tags */
@@ -207,6 +209,7 @@ const TagInput = React.forwardRef(
items,
onAdd,
onRemove,
+ onInputChange,
placeholder = 'Enter values',
placeholderWithTags = 'Add another',
disabled = false,
@@ -344,10 +347,12 @@ const TagInput = React.forwardRef(
})
if (addedCount === 0 && pastedValues.length === 1) {
- setInputValue(inputValue + pastedValues[0])
+ const newValue = inputValue + pastedValues[0]
+ setInputValue(newValue)
+ onInputChange?.(newValue)
}
},
- [onAdd, inputValue]
+ [onAdd, inputValue, onInputChange]
)
const handleBlur = React.useCallback(() => {
@@ -422,7 +427,10 @@ const TagInput = React.forwardRef(
name={name}
type='text'
value={inputValue}
- onChange={(e) => setInputValue(e.target.value)}
+ onChange={(e) => {
+ setInputValue(e.target.value)
+ onInputChange?.(e.target.value)
+ }}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={handleBlur}
diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts
index b20d045054..c6290fd173 100644
--- a/apps/sim/executor/handlers/router/router-handler.ts
+++ b/apps/sim/executor/handlers/router/router-handler.ts
@@ -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
diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts
index 881a0a9939..eaabbab66e 100644
--- a/apps/sim/hooks/queries/workflows.ts
+++ b/apps/sim/hooks/queries/workflows.ts
@@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import {
createOptimisticMutationHandlers,
@@ -8,10 +9,7 @@ import {
} from '@/hooks/queries/utils/optimistic-mutation'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
-import {
- generateCreativeWorkflowName,
- getNextWorkflowColor,
-} from '@/stores/workflows/registry/utils'
+import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
diff --git a/apps/sim/lib/copilot/process-contents.ts b/apps/sim/lib/copilot/process-contents.ts
index ccb690964e..3b62b9197c 100644
--- a/apps/sim/lib/copilot/process-contents.ts
+++ b/apps/sim/lib/copilot/process-contents.ts
@@ -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
}
diff --git a/apps/sim/lib/copilot/tools/client/base-subagent-tool.ts b/apps/sim/lib/copilot/tools/client/base-subagent-tool.ts
new file mode 100644
index 0000000000..7a843dd882
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/base-subagent-tool.ts
@@ -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): Promise {
+ 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): Promise {
+ this.setState(ClientToolCallState.executing)
+ }
+ }
+}
diff --git a/apps/sim/lib/copilot/tools/client/base-tool.ts b/apps/sim/lib/copilot/tools/client/base-tool.ts
index ba748ebcd0..75b02bfe21 100644
--- a/apps/sim/lib/copilot/tools/client/base-tool.ts
+++ b/apps/sim/lib/copilot/tools/client/base-tool.ts
@@ -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
+ }
}
diff --git a/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts b/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts
index 26b2a71da4..6b3a15c531 100644
--- a/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts
+++ b/apps/sim/lib/copilot/tools/client/blocks/get-block-config.ts
@@ -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(() => '')
diff --git a/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts b/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts
index ee72db387f..41cd7bd8f6 100644
--- a/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts
+++ b/apps/sim/lib/copilot/tools/client/blocks/get-block-options.ts
@@ -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',
diff --git a/apps/sim/lib/copilot/tools/client/init-tool-configs.ts b/apps/sim/lib/copilot/tools/client/init-tool-configs.ts
new file mode 100644
index 0000000000..821e5ec8d6
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/init-tool-configs.ts
@@ -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'
diff --git a/apps/sim/lib/copilot/tools/client/other/auth.ts b/apps/sim/lib/copilot/tools/client/other/auth.ts
new file mode 100644
index 0000000000..b73a3f0036
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/other/auth.ts
@@ -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 {
+ this.setState(ClientToolCallState.executing)
+ }
+}
+
+// Register UI config at module load
+registerToolUIConfig(AuthClientTool.id, AuthClientTool.metadata.uiConfig!)
diff --git a/apps/sim/lib/copilot/tools/client/other/checkoff-todo.ts b/apps/sim/lib/copilot/tools/client/other/checkoff-todo.ts
index b5d95ff396..2a925d82dd 100644
--- a/apps/sim/lib/copilot/tools/client/other/checkoff-todo.ts
+++ b/apps/sim/lib/copilot/tools/client/other/checkoff-todo.ts
@@ -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 },
},
}
diff --git a/apps/sim/lib/copilot/tools/client/other/custom-tool.ts b/apps/sim/lib/copilot/tools/client/other/custom-tool.ts
new file mode 100644
index 0000000000..eab2818a80
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/other/custom-tool.ts
@@ -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 {
+ this.setState(ClientToolCallState.executing)
+ }
+}
+
+// Register UI config at module load
+registerToolUIConfig(CustomToolClientTool.id, CustomToolClientTool.metadata.uiConfig!)
diff --git a/apps/sim/lib/copilot/tools/client/other/debug.ts b/apps/sim/lib/copilot/tools/client/other/debug.ts
new file mode 100644
index 0000000000..6be16d8864
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/other/debug.ts
@@ -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 {
+ // 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!)
diff --git a/apps/sim/lib/copilot/tools/client/other/deploy.ts b/apps/sim/lib/copilot/tools/client/other/deploy.ts
new file mode 100644
index 0000000000..80e8f8bc63
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/other/deploy.ts
@@ -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 {
+ this.setState(ClientToolCallState.executing)
+ }
+}
+
+// Register UI config at module load
+registerToolUIConfig(DeployClientTool.id, DeployClientTool.metadata.uiConfig!)
diff --git a/apps/sim/lib/copilot/tools/client/other/edit.ts b/apps/sim/lib/copilot/tools/client/other/edit.ts
new file mode 100644
index 0000000000..85e67a927e
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/other/edit.ts
@@ -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 {
+ // 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!)
diff --git a/apps/sim/lib/copilot/tools/client/other/evaluate.ts b/apps/sim/lib/copilot/tools/client/other/evaluate.ts
new file mode 100644
index 0000000000..eaf7f542a2
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/other/evaluate.ts
@@ -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 {
+ this.setState(ClientToolCallState.executing)
+ }
+}
+
+// Register UI config at module load
+registerToolUIConfig(EvaluateClientTool.id, EvaluateClientTool.metadata.uiConfig!)
diff --git a/apps/sim/lib/copilot/tools/client/other/info.ts b/apps/sim/lib/copilot/tools/client/other/info.ts
new file mode 100644
index 0000000000..e4253a22c6
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/other/info.ts
@@ -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 {
+ this.setState(ClientToolCallState.executing)
+ }
+}
+
+// Register UI config at module load
+registerToolUIConfig(InfoClientTool.id, InfoClientTool.metadata.uiConfig!)
diff --git a/apps/sim/lib/copilot/tools/client/other/knowledge.ts b/apps/sim/lib/copilot/tools/client/other/knowledge.ts
new file mode 100644
index 0000000000..25c853c71e
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/other/knowledge.ts
@@ -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 {
+ this.setState(ClientToolCallState.executing)
+ }
+}
+
+// Register UI config at module load
+registerToolUIConfig(KnowledgeClientTool.id, KnowledgeClientTool.metadata.uiConfig!)
diff --git a/apps/sim/lib/copilot/tools/client/other/make-api-request.ts b/apps/sim/lib/copilot/tools/client/other/make-api-request.ts
index 30973ef219..8813f2edd7 100644
--- a/apps/sim/lib/copilot/tools/client/other/make-api-request.ts
+++ b/apps/sim/lib/copilot/tools/client/other/make-api-request.ts
@@ -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!)
diff --git a/apps/sim/lib/copilot/tools/client/other/mark-todo-in-progress.ts b/apps/sim/lib/copilot/tools/client/other/mark-todo-in-progress.ts
index e15637342d..fbed86ea82 100644
--- a/apps/sim/lib/copilot/tools/client/other/mark-todo-in-progress.ts
+++ b/apps/sim/lib/copilot/tools/client/other/mark-todo-in-progress.ts
@@ -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 },
diff --git a/apps/sim/lib/copilot/tools/client/other/oauth-request-access.ts b/apps/sim/lib/copilot/tools/client/other/oauth-request-access.ts
index 98fd84704f..725f73bc72 100644
--- a/apps/sim/lib/copilot/tools/client/other/oauth-request-access.ts
+++ b/apps/sim/lib/copilot/tools/client/other/oauth-request-access.ts
@@ -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)
diff --git a/apps/sim/lib/copilot/tools/client/other/plan.ts b/apps/sim/lib/copilot/tools/client/other/plan.ts
index ebd43a9ce4..63eaad7b4e 100644
--- a/apps/sim/lib/copilot/tools/client/other/plan.ts
+++ b/apps/sim/lib/copilot/tools/client/other/plan.ts
@@ -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 {
- 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 {
+ // 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!)
diff --git a/apps/sim/lib/copilot/tools/client/other/research.ts b/apps/sim/lib/copilot/tools/client/other/research.ts
new file mode 100644
index 0000000000..0a10e89899
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/other/research.ts
@@ -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 {
+ this.setState(ClientToolCallState.executing)
+ }
+}
+
+// Register UI config at module load
+registerToolUIConfig(ResearchClientTool.id, ResearchClientTool.metadata.uiConfig!)
diff --git a/apps/sim/lib/copilot/tools/client/other/search-documentation.ts b/apps/sim/lib/copilot/tools/client/other/search-documentation.ts
index 96d9e0d4ff..76b7756927 100644
--- a/apps/sim/lib/copilot/tools/client/other/search-documentation.ts
+++ b/apps/sim/lib/copilot/tools/client/other/search-documentation.ts
@@ -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 },
diff --git a/apps/sim/lib/copilot/tools/client/other/search-online.ts b/apps/sim/lib/copilot/tools/client/other/search-online.ts
index ad44c76c08..f5022c3f44 100644
--- a/apps/sim/lib/copilot/tools/client/other/search-online.ts
+++ b/apps/sim/lib/copilot/tools/client/other/search-online.ts
@@ -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 },
diff --git a/apps/sim/lib/copilot/tools/client/other/sleep.ts b/apps/sim/lib/copilot/tools/client/other/sleep.ts
index a50990c297..91949ea81a 100644
--- a/apps/sim/lib/copilot/tools/client/other/sleep.ts
+++ b/apps/sim/lib/copilot/tools/client/other/sleep.ts
@@ -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!)
diff --git a/apps/sim/lib/copilot/tools/client/other/test.ts b/apps/sim/lib/copilot/tools/client/other/test.ts
new file mode 100644
index 0000000000..3aa698aad4
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/other/test.ts
@@ -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 {
+ this.setState(ClientToolCallState.executing)
+ }
+}
+
+// Register UI config at module load
+registerToolUIConfig(TestClientTool.id, TestClientTool.metadata.uiConfig!)
diff --git a/apps/sim/lib/copilot/tools/client/other/tour.ts b/apps/sim/lib/copilot/tools/client/other/tour.ts
new file mode 100644
index 0000000000..8faca55877
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/other/tour.ts
@@ -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 {
+ this.setState(ClientToolCallState.executing)
+ }
+}
+
+// Register UI config at module load
+registerToolUIConfig(TourClientTool.id, TourClientTool.metadata.uiConfig!)
diff --git a/apps/sim/lib/copilot/tools/client/other/workflow.ts b/apps/sim/lib/copilot/tools/client/other/workflow.ts
new file mode 100644
index 0000000000..5b99e73e94
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/other/workflow.ts
@@ -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 {
+ this.setState(ClientToolCallState.executing)
+ }
+}
+
+// Register UI config at module load
+registerToolUIConfig(WorkflowClientTool.id, WorkflowClientTool.metadata.uiConfig!)
diff --git a/apps/sim/lib/copilot/tools/client/ui-config.ts b/apps/sim/lib/copilot/tools/client/ui-config.ts
new file mode 100644
index 0000000000..6fac1645c7
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/ui-config.ts
@@ -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) => Array<[string, ...any[]]>
+ /**
+ * Optional: Update params when a cell is edited.
+ * Returns the updated params object.
+ */
+ updateCell?: (
+ params: Record,
+ rowKey: string,
+ columnKey: string,
+ newValue: any
+ ) => Record
+}
+
+/**
+ * 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 = {}
+
+/**
+ * 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 {
+ return { ...toolUIConfigs }
+}
diff --git a/apps/sim/lib/copilot/tools/client/user/set-environment-variables.ts b/apps/sim/lib/copilot/tools/client/user/set-environment-variables.ts
index 02eab8d090..e4033ca85d 100644
--- a/apps/sim/lib/copilot/tools/client/user/set-environment-variables.ts
+++ b/apps/sim/lib/copilot/tools/client/user/set-environment-variables.ts
@@ -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!
+)
diff --git a/apps/sim/lib/copilot/tools/client/workflow/check-deployment-status.ts b/apps/sim/lib/copilot/tools/client/workflow/check-deployment-status.ts
index c17aa5e7d9..e2346a4c72 100644
--- a/apps/sim/lib/copilot/tools/client/workflow/check-deployment-status.ts
+++ b/apps/sim/lib/copilot/tools/client/workflow/check-deployment-status.ts
@@ -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,
+ }
- const deploymentTypes: string[] = []
+ // MCP deployment details - find servers that have this workflow as a tool
+ const mcpServerList = mcpServers?.data?.servers || []
+ const mcpToolDeployments: McpDeploymentDetails['servers'] = []
- if (isApiDeployed) {
- // Default to sync API, could be extended to detect streaming/async
- deploymentTypes.push('api')
+ 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
+ }
+ }
}
- if (isChatDeployed) {
- deploymentTypes.push('chat')
+ const isMcpDeployed = mcpToolDeployments.length > 0
+ const mcpDetails: McpDeploymentDetails = {
+ isDeployed: isMcpDeployed,
+ servers: mcpToolDeployments,
}
- const isDeployed = isApiDeployed || isChatDeployed
+ // Build deployment types list
+ const deploymentTypes: string[] = []
+ if (isApiDeployed) deploymentTypes.push('api')
+ if (isChatDeployed) deploymentTypes.push('chat')
+ if (isMcpDeployed) deploymentTypes.push('mcp')
- 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,
+ 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(', ')}`
+ }
+
+ this.setState(ClientToolCallState.success)
+ 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)
diff --git a/apps/sim/lib/copilot/tools/client/workflow/create-workspace-mcp-server.ts b/apps/sim/lib/copilot/tools/client/workflow/create-workspace-mcp-server.ts
new file mode 100644
index 0000000000..f50832184f
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/workflow/create-workspace-mcp-server.ts
@@ -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 {
+ await super.handleReject()
+ this.setState(ClientToolCallState.rejected)
+ }
+
+ async handleAccept(args?: CreateWorkspaceMcpServerArgs): Promise {
+ 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 {
+ await this.handleAccept(args)
+ }
+}
diff --git a/apps/sim/lib/copilot/tools/client/workflow/deploy-workflow.ts b/apps/sim/lib/copilot/tools/client/workflow/deploy-api.ts
similarity index 63%
rename from apps/sim/lib/copilot/tools/client/workflow/deploy-workflow.ts
rename to apps/sim/lib/copilot/tools/client/workflow/deploy-api.ts
index dda9d7844b..49abe62911 100644
--- a/apps/sim/lib/copilot/tools/client/workflow/deploy-workflow.ts
+++ b/apps/sim/lib/copilot/tools/client/workflow/deploy-api.ts
@@ -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 {
await super.handleReject()
this.setState(ClientToolCallState.rejected)
}
- async handleAccept(args?: DeployWorkflowArgs): Promise {
- const logger = createLogger('DeployWorkflowClientTool')
+ async handleAccept(args?: DeployApiArgs): Promise {
+ 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 {
+ async execute(args?: DeployApiArgs): Promise {
await this.handleAccept(args)
}
}
+
+// Register UI config at module load
+registerToolUIConfig(DeployApiClientTool.id, DeployApiClientTool.metadata.uiConfig!)
diff --git a/apps/sim/lib/copilot/tools/client/workflow/deploy-chat.ts b/apps/sim/lib/copilot/tools/client/workflow/deploy-chat.ts
new file mode 100644
index 0000000000..be08d72a35
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/workflow/deploy-chat.ts
@@ -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 {
+ await super.handleReject()
+ this.setState(ClientToolCallState.rejected)
+ }
+
+ async handleAccept(args?: DeployChatArgs): Promise {
+ 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 {
+ await this.handleAccept(args)
+ }
+}
+
+// Register UI config at module load
+registerToolUIConfig(DeployChatClientTool.id, DeployChatClientTool.metadata.uiConfig!)
diff --git a/apps/sim/lib/copilot/tools/client/workflow/deploy-mcp.ts b/apps/sim/lib/copilot/tools/client/workflow/deploy-mcp.ts
new file mode 100644
index 0000000000..080498473c
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/workflow/deploy-mcp.ts
@@ -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 {
+ await super.handleReject()
+ this.setState(ClientToolCallState.rejected)
+ }
+
+ async handleAccept(args?: DeployMcpArgs): Promise {
+ 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 | undefined
+ if (args?.parameterDescriptions && args.parameterDescriptions.length > 0) {
+ const properties: Record = {}
+ 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 {
+ await this.handleAccept(args)
+ }
+}
+
+// Register UI config at module load
+registerToolUIConfig(DeployMcpClientTool.id, DeployMcpClientTool.metadata.uiConfig!)
diff --git a/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts
index 20dd32fa7b..e65e89244e 100644
--- a/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts
+++ b/apps/sim/lib/copilot/tools/client/workflow/edit-workflow.ts
@@ -5,6 +5,7 @@ import {
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
+import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
import { stripWorkflowDiffMarkers } from '@/lib/workflows/diff'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
@@ -124,6 +125,10 @@ export class EditWorkflowClientTool extends BaseClientTool {
[ClientToolCallState.aborted]: { text: 'Aborted editing your workflow', icon: MinusCircle },
[ClientToolCallState.pending]: { text: 'Editing your workflow', icon: Loader2 },
},
+ uiConfig: {
+ isSpecial: true,
+ customRenderer: 'edit_summary',
+ },
getDynamicText: (params, state) => {
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
if (workflowId) {
@@ -412,3 +417,6 @@ export class EditWorkflowClientTool extends BaseClientTool {
})
}
}
+
+// Register UI config at module load
+registerToolUIConfig(EditWorkflowClientTool.id, EditWorkflowClientTool.metadata.uiConfig!)
diff --git a/apps/sim/lib/copilot/tools/client/workflow/get-block-outputs.ts b/apps/sim/lib/copilot/tools/client/workflow/get-block-outputs.ts
index 4e613e847c..d835678d3e 100644
--- a/apps/sim/lib/copilot/tools/client/workflow/get-block-outputs.ts
+++ b/apps/sim/lib/copilot/tools/client/workflow/get-block-outputs.ts
@@ -16,7 +16,6 @@ import {
GetBlockOutputsResult,
type GetBlockOutputsResultType,
} from '@/lib/copilot/tools/shared/schemas'
-import { normalizeName } from '@/executor/constants'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -90,10 +89,6 @@ export class GetBlockOutputsClientTool extends BaseClientTool {
if (!block?.type) continue
const blockName = block.name || block.type
- const normalizedBlockName = normalizeName(blockName)
-
- let insideSubflowOutputs: string[] | undefined
- let outsideSubflowOutputs: string[] | undefined
const blockOutput: GetBlockOutputsResultType['blocks'][0] = {
blockId,
@@ -102,6 +97,11 @@ export class GetBlockOutputsClientTool extends BaseClientTool {
outputs: [],
}
+ // Include triggerMode if the block is in trigger mode
+ if (block.triggerMode) {
+ blockOutput.triggerMode = true
+ }
+
if (block.type === 'loop' || block.type === 'parallel') {
const insidePaths = getSubflowInsidePaths(block.type, blockId, loops, parallels)
blockOutput.insideSubflowOutputs = formatOutputsWithPrefix(insidePaths, blockName)
diff --git a/apps/sim/lib/copilot/tools/client/workflow/get-block-upstream-references.ts b/apps/sim/lib/copilot/tools/client/workflow/get-block-upstream-references.ts
index bf3c1cf081..749c04919a 100644
--- a/apps/sim/lib/copilot/tools/client/workflow/get-block-upstream-references.ts
+++ b/apps/sim/lib/copilot/tools/client/workflow/get-block-upstream-references.ts
@@ -193,6 +193,11 @@ export class GetBlockUpstreamReferencesClientTool extends BaseClientTool {
outputs: formattedOutputs,
}
+ // Include triggerMode if the block is in trigger mode
+ if (block.triggerMode) {
+ entry.triggerMode = true
+ }
+
if (accessContext) entry.accessContext = accessContext
accessibleBlocks.push(entry)
}
diff --git a/apps/sim/lib/copilot/tools/client/workflow/get-workflow-data.ts b/apps/sim/lib/copilot/tools/client/workflow/get-workflow-data.ts
index 52689ff55b..657daa0a05 100644
--- a/apps/sim/lib/copilot/tools/client/workflow/get-workflow-data.ts
+++ b/apps/sim/lib/copilot/tools/client/workflow/get-workflow-data.ts
@@ -29,7 +29,7 @@ export class GetWorkflowDataClientTool extends BaseClientTool {
[ClientToolCallState.pending]: { text: 'Fetching workflow data', icon: Database },
[ClientToolCallState.executing]: { text: 'Fetching workflow data', icon: Loader2 },
[ClientToolCallState.aborted]: { text: 'Aborted fetching data', icon: XCircle },
- [ClientToolCallState.success]: { text: 'Workflow data retrieved', icon: Database },
+ [ClientToolCallState.success]: { text: 'Retrieved workflow data', icon: Database },
[ClientToolCallState.error]: { text: 'Failed to fetch data', icon: X },
[ClientToolCallState.rejected]: { text: 'Skipped fetching data', icon: XCircle },
},
diff --git a/apps/sim/lib/copilot/tools/client/workflow/list-workspace-mcp-servers.ts b/apps/sim/lib/copilot/tools/client/workflow/list-workspace-mcp-servers.ts
new file mode 100644
index 0000000000..1dad9fbf7c
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/client/workflow/list-workspace-mcp-servers.ts
@@ -0,0 +1,112 @@
+import { createLogger } from '@sim/logger'
+import { Loader2, Server, XCircle } from 'lucide-react'
+import {
+ BaseClientTool,
+ type BaseClientToolMetadata,
+ ClientToolCallState,
+} from '@/lib/copilot/tools/client/base-tool'
+import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
+
+interface ListWorkspaceMcpServersArgs {
+ workspaceId?: string
+}
+
+export interface WorkspaceMcpServer {
+ id: string
+ name: string
+ description: string | null
+ toolCount: number
+ toolNames: string[]
+}
+
+/**
+ * List workspace MCP servers tool.
+ * Returns a list of MCP servers available in the workspace that workflows can be deployed to.
+ */
+export class ListWorkspaceMcpServersClientTool extends BaseClientTool {
+ static readonly id = 'list_workspace_mcp_servers'
+
+ constructor(toolCallId: string) {
+ super(
+ toolCallId,
+ ListWorkspaceMcpServersClientTool.id,
+ ListWorkspaceMcpServersClientTool.metadata
+ )
+ }
+
+ static readonly metadata: BaseClientToolMetadata = {
+ displayNames: {
+ [ClientToolCallState.generating]: {
+ text: 'Getting MCP servers',
+ icon: Loader2,
+ },
+ [ClientToolCallState.pending]: { text: 'Getting MCP servers', icon: Loader2 },
+ [ClientToolCallState.executing]: { text: 'Getting MCP servers', icon: Loader2 },
+ [ClientToolCallState.success]: { text: 'Retrieved MCP servers', icon: Server },
+ [ClientToolCallState.error]: { text: 'Failed to get MCP servers', icon: XCircle },
+ [ClientToolCallState.aborted]: { text: 'Aborted getting MCP servers', icon: XCircle },
+ [ClientToolCallState.rejected]: { text: 'Skipped getting MCP servers', icon: XCircle },
+ },
+ interrupt: undefined,
+ }
+
+ async execute(args?: ListWorkspaceMcpServersArgs): Promise {
+ const logger = createLogger('ListWorkspaceMcpServersClientTool')
+ try {
+ this.setState(ClientToolCallState.executing)
+
+ // Get workspace ID from active workflow if not provided
+ const { activeWorkflowId, workflows } = useWorkflowRegistry.getState()
+ let workspaceId = args?.workspaceId
+
+ if (!workspaceId && activeWorkflowId) {
+ workspaceId = workflows[activeWorkflowId]?.workspaceId
+ }
+
+ if (!workspaceId) {
+ throw new Error('No workspace ID available')
+ }
+
+ const res = await fetch(`/api/mcp/workflow-servers?workspaceId=${workspaceId}`)
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}))
+ throw new Error(data.error || `Failed to fetch MCP servers (${res.status})`)
+ }
+
+ const data = await res.json()
+ const servers: WorkspaceMcpServer[] = (data.data?.servers || []).map((s: any) => ({
+ id: s.id,
+ name: s.name,
+ description: s.description,
+ toolCount: s.toolCount || 0,
+ toolNames: s.toolNames || [],
+ }))
+
+ this.setState(ClientToolCallState.success)
+
+ if (servers.length === 0) {
+ await this.markToolComplete(
+ 200,
+ 'No MCP servers found in this workspace. Use create_workspace_mcp_server to create one.',
+ { servers: [], count: 0 }
+ )
+ } else {
+ await this.markToolComplete(
+ 200,
+ `Found ${servers.length} MCP server(s) in the workspace.`,
+ {
+ servers,
+ count: servers.length,
+ }
+ )
+ }
+
+ logger.info(`Listed ${servers.length} MCP servers`)
+ } catch (e: any) {
+ logger.error('Failed to list MCP servers', { message: e?.message })
+ this.setState(ClientToolCallState.error)
+ await this.markToolComplete(500, e?.message || 'Failed to list MCP servers')
+ }
+ }
+}
diff --git a/apps/sim/lib/copilot/tools/client/workflow/manage-custom-tool.ts b/apps/sim/lib/copilot/tools/client/workflow/manage-custom-tool.ts
index 5fecb00112..202864e169 100644
--- a/apps/sim/lib/copilot/tools/client/workflow/manage-custom-tool.ts
+++ b/apps/sim/lib/copilot/tools/client/workflow/manage-custom-tool.ts
@@ -24,7 +24,7 @@ interface CustomToolSchema {
}
interface ManageCustomToolArgs {
- operation: 'add' | 'edit' | 'delete'
+ operation: 'add' | 'edit' | 'delete' | 'list'
toolId?: string
schema?: CustomToolSchema
code?: string
@@ -81,7 +81,7 @@ export class ManageCustomToolClientTool extends BaseClientTool {
reject: { text: 'Skip', icon: XCircle },
},
getDynamicText: (params, state) => {
- const operation = params?.operation as 'add' | 'edit' | 'delete' | undefined
+ const operation = params?.operation as 'add' | 'edit' | 'delete' | 'list' | undefined
// Return undefined if no operation yet - use static defaults
if (!operation) return undefined
@@ -105,19 +105,30 @@ export class ManageCustomToolClientTool extends BaseClientTool {
return verb === 'present' ? 'Edit' : verb === 'past' ? 'Edited' : 'Editing'
case 'delete':
return verb === 'present' ? 'Delete' : verb === 'past' ? 'Deleted' : 'Deleting'
+ case 'list':
+ return verb === 'present' ? 'List' : verb === 'past' ? 'Listed' : 'Listing'
+ default:
+ return verb === 'present' ? 'Manage' : verb === 'past' ? 'Managed' : 'Managing'
}
}
// For add: only show tool name in past tense (success)
// For edit/delete: always show tool name
+ // For list: never show individual tool name, use plural
const shouldShowToolName = (currentState: ClientToolCallState) => {
+ if (operation === 'list') return false
if (operation === 'add') {
return currentState === ClientToolCallState.success
}
return true // edit and delete always show tool name
}
- const nameText = shouldShowToolName(state) && toolName ? ` ${toolName}` : ' custom tool'
+ const nameText =
+ operation === 'list'
+ ? ' custom tools'
+ : shouldShowToolName(state) && toolName
+ ? ` ${toolName}`
+ : ' custom tool'
switch (state) {
case ClientToolCallState.success:
@@ -188,16 +199,16 @@ export class ManageCustomToolClientTool extends BaseClientTool {
async execute(args?: ManageCustomToolArgs): Promise {
this.currentArgs = args
- // For add operation, execute directly without confirmation
+ // For add and list operations, execute directly without confirmation
// For edit/delete, the copilot store will check hasInterrupt() and wait for confirmation
- if (args?.operation === 'add') {
+ if (args?.operation === 'add' || args?.operation === 'list') {
await this.handleAccept(args)
}
// edit/delete will wait for user confirmation via handleAccept
}
/**
- * Executes the custom tool operation (add, edit, or delete)
+ * Executes the custom tool operation (add, edit, delete, or list)
*/
private async executeOperation(
args: ManageCustomToolArgs | undefined,
@@ -235,6 +246,10 @@ export class ManageCustomToolClientTool extends BaseClientTool {
case 'delete':
await this.deleteCustomTool({ toolId, workspaceId }, logger)
break
+ case 'list':
+ // List operation is read-only, just mark as complete
+ await this.markToolComplete(200, 'Listed custom tools')
+ break
default:
throw new Error(`Unknown operation: ${operation}`)
}
diff --git a/apps/sim/lib/copilot/tools/client/workflow/run-workflow.ts b/apps/sim/lib/copilot/tools/client/workflow/run-workflow.ts
index f5daae7886..3b2c89df65 100644
--- a/apps/sim/lib/copilot/tools/client/workflow/run-workflow.ts
+++ b/apps/sim/lib/copilot/tools/client/workflow/run-workflow.ts
@@ -7,6 +7,7 @@ import {
ClientToolCallState,
WORKFLOW_EXECUTION_TIMEOUT_MS,
} from '@/lib/copilot/tools/client/base-tool'
+import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useExecutionStore } from '@/stores/execution'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -29,9 +30,9 @@ export class RunWorkflowClientTool extends BaseClientTool {
[ClientToolCallState.generating]: { text: 'Preparing to run your workflow', icon: Loader2 },
[ClientToolCallState.pending]: { text: 'Run this workflow?', icon: Play },
[ClientToolCallState.executing]: { text: 'Running your workflow', icon: Loader2 },
- [ClientToolCallState.success]: { text: 'Workflow executed', icon: Play },
+ [ClientToolCallState.success]: { text: 'Executed workflow', icon: Play },
[ClientToolCallState.error]: { text: 'Errored running workflow', icon: XCircle },
- [ClientToolCallState.rejected]: { text: 'Workflow execution skipped', icon: MinusCircle },
+ [ClientToolCallState.rejected]: { text: 'Skipped workflow execution', icon: MinusCircle },
[ClientToolCallState.aborted]: { text: 'Aborted workflow execution', icon: MinusCircle },
[ClientToolCallState.background]: { text: 'Running in background', icon: Play },
},
@@ -39,6 +40,49 @@ export class RunWorkflowClientTool extends BaseClientTool {
accept: { text: 'Run', icon: Play },
reject: { text: 'Skip', icon: MinusCircle },
},
+ uiConfig: {
+ isSpecial: true,
+ interrupt: {
+ accept: { text: 'Run', icon: Play },
+ reject: { text: 'Skip', icon: MinusCircle },
+ showAllowOnce: true,
+ showAllowAlways: true,
+ },
+ secondaryAction: {
+ text: 'Move to Background',
+ title: 'Move to Background',
+ variant: 'tertiary',
+ showInStates: [ClientToolCallState.executing],
+ completionMessage:
+ 'The user has chosen to move the workflow execution to the background. Check back with them later to know when the workflow execution is complete',
+ targetState: ClientToolCallState.background,
+ },
+ paramsTable: {
+ columns: [
+ { key: 'input', label: 'Input', width: '36%' },
+ { key: 'value', label: 'Value', width: '64%', editable: true, mono: true },
+ ],
+ extractRows: (params) => {
+ let inputs = params.input || params.inputs || params.workflow_input
+ if (typeof inputs === 'string') {
+ try {
+ inputs = JSON.parse(inputs)
+ } catch {
+ inputs = {}
+ }
+ }
+ if (params.workflow_input && typeof params.workflow_input === 'object') {
+ inputs = params.workflow_input
+ }
+ if (!inputs || typeof inputs !== 'object') {
+ const { workflowId, workflow_input, ...rest } = params
+ inputs = rest
+ }
+ const safeInputs = inputs && typeof inputs === 'object' ? inputs : {}
+ return Object.entries(safeInputs).map(([key, value]) => [key, key, String(value)])
+ },
+ },
+ },
getDynamicText: (params, state) => {
const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
if (workflowId) {
@@ -182,3 +226,6 @@ export class RunWorkflowClientTool extends BaseClientTool {
await this.handleAccept(args)
}
}
+
+// Register UI config at module load
+registerToolUIConfig(RunWorkflowClientTool.id, RunWorkflowClientTool.metadata.uiConfig!)
diff --git a/apps/sim/lib/copilot/tools/client/workflow/set-global-workflow-variables.ts b/apps/sim/lib/copilot/tools/client/workflow/set-global-workflow-variables.ts
index 8762865f8d..06b36a2b88 100644
--- a/apps/sim/lib/copilot/tools/client/workflow/set-global-workflow-variables.ts
+++ b/apps/sim/lib/copilot/tools/client/workflow/set-global-workflow-variables.ts
@@ -5,6 +5,7 @@ import {
type BaseClientToolMetadata,
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
+import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -39,7 +40,7 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
},
[ClientToolCallState.pending]: { text: 'Set workflow variables?', icon: Settings2 },
[ClientToolCallState.executing]: { text: 'Setting workflow variables', icon: Loader2 },
- [ClientToolCallState.success]: { text: 'Workflow variables updated', icon: Settings2 },
+ [ClientToolCallState.success]: { text: 'Updated workflow variables', icon: Settings2 },
[ClientToolCallState.error]: { text: 'Failed to set workflow variables', icon: X },
[ClientToolCallState.aborted]: { text: 'Aborted setting variables', icon: XCircle },
[ClientToolCallState.rejected]: { text: 'Skipped setting variables', icon: XCircle },
@@ -48,6 +49,28 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
accept: { text: 'Apply', icon: Settings2 },
reject: { text: 'Skip', icon: XCircle },
},
+ uiConfig: {
+ interrupt: {
+ accept: { text: 'Apply', icon: Settings2 },
+ reject: { text: 'Skip', icon: XCircle },
+ showAllowOnce: true,
+ showAllowAlways: true,
+ },
+ paramsTable: {
+ columns: [
+ { key: 'name', label: 'Name', width: '40%', editable: true, mono: true },
+ { key: 'value', label: 'Value', width: '60%', editable: true, mono: true },
+ ],
+ extractRows: (params) => {
+ const operations = params.operations || []
+ return operations.map((op: any, idx: number) => [
+ String(idx),
+ op.name || '',
+ String(op.value ?? ''),
+ ])
+ },
+ },
+ },
getDynamicText: (params, state) => {
if (params?.operations && Array.isArray(params.operations)) {
const varNames = params.operations
@@ -243,3 +266,9 @@ export class SetGlobalWorkflowVariablesClientTool extends BaseClientTool {
await this.handleAccept(args)
}
}
+
+// Register UI config at module load
+registerToolUIConfig(
+ SetGlobalWorkflowVariablesClientTool.id,
+ SetGlobalWorkflowVariablesClientTool.metadata.uiConfig!
+)
diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts b/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts
index 77c9446bdf..60bcad823d 100644
--- a/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts
+++ b/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts
@@ -10,6 +10,7 @@ import type { SubBlockConfig } from '@/blocks/types'
import { getUserPermissionConfig } from '@/executor/utils/permission-check'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
import { tools as toolsRegistry } from '@/tools/registry'
+import { getTrigger, isTriggerValid } from '@/triggers'
interface InputFieldSchema {
type: string
@@ -107,11 +108,12 @@ function resolveSubBlockOptions(sb: SubBlockConfig): string[] | undefined {
return undefined
}
+ // Return the actual option ID/value that edit_workflow expects, not the display label
return rawOptions
.map((opt: any) => {
if (!opt) return undefined
if (typeof opt === 'object') {
- return opt.label || opt.id
+ return opt.id || opt.label // Prefer id (actual value) over label (display name)
}
return String(opt)
})
@@ -145,13 +147,20 @@ function matchesOperation(condition: any, operation: string): boolean {
*/
function extractInputsFromSubBlocks(
subBlocks: SubBlockConfig[],
- operation?: string
+ operation?: string,
+ triggerMode?: boolean
): Record {
const inputs: Record = {}
for (const sb of subBlocks) {
- // Skip trigger-mode subBlocks
- if (sb.mode === 'trigger') continue
+ // Handle trigger vs non-trigger mode filtering
+ if (triggerMode) {
+ // In trigger mode, only include subBlocks with mode: 'trigger'
+ if (sb.mode !== 'trigger') continue
+ } else {
+ // In non-trigger mode, skip trigger-mode subBlocks
+ if (sb.mode === 'trigger') continue
+ }
// Skip hidden subBlocks
if (sb.hidden) continue
@@ -247,12 +256,53 @@ function mapSubBlockTypeToSchemaType(type: string): string {
return typeMap[type] || 'string'
}
+/**
+ * Extracts trigger outputs from the first available trigger
+ */
+function extractTriggerOutputs(blockConfig: any): Record {
+ const outputs: Record = {}
+
+ if (!blockConfig.triggers?.enabled || !blockConfig.triggers?.available?.length) {
+ return outputs
+ }
+
+ // Get the first available trigger's outputs as a baseline
+ const triggerId = blockConfig.triggers.available[0]
+ if (triggerId && isTriggerValid(triggerId)) {
+ const trigger = getTrigger(triggerId)
+ if (trigger.outputs) {
+ for (const [key, def] of Object.entries(trigger.outputs)) {
+ if (typeof def === 'string') {
+ outputs[key] = { type: def }
+ } else if (typeof def === 'object' && def !== null) {
+ const typedDef = def as { type?: string; description?: string }
+ outputs[key] = {
+ type: typedDef.type || 'any',
+ description: typedDef.description,
+ }
+ }
+ }
+ }
+ }
+
+ return outputs
+}
+
/**
* Extracts output schema from block config or tool
*/
-function extractOutputs(blockConfig: any, operation?: string): Record {
+function extractOutputs(
+ blockConfig: any,
+ operation?: string,
+ triggerMode?: boolean
+): Record {
const outputs: Record = {}
+ // In trigger mode, return trigger outputs
+ if (triggerMode && blockConfig.triggers?.enabled) {
+ return extractTriggerOutputs(blockConfig)
+ }
+
// If operation is specified, try to get outputs from the specific tool
if (operation) {
try {
@@ -300,16 +350,16 @@ export const getBlockConfigServerTool: BaseServerTool<
> = {
name: 'get_block_config',
async execute(
- { blockType, operation }: GetBlockConfigInputType,
+ { blockType, operation, trigger }: GetBlockConfigInputType,
context?: { userId: string }
): Promise {
const logger = createLogger('GetBlockConfigServerTool')
- logger.debug('Executing get_block_config', { blockType, operation })
+ logger.debug('Executing get_block_config', { blockType, operation, trigger })
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
const allowedIntegrations = permissionConfig?.allowedIntegrations
- if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) {
+ if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) {
throw new Error(`Block "${blockType}" is not available`)
}
@@ -318,6 +368,13 @@ export const getBlockConfigServerTool: BaseServerTool<
throw new Error(`Block not found: ${blockType}`)
}
+ // Validate trigger mode is supported for this block
+ if (trigger && !blockConfig.triggers?.enabled && !blockConfig.triggerAllowed) {
+ throw new Error(
+ `Block "${blockType}" does not support trigger mode. Only blocks with triggers.enabled or triggerAllowed can be used in trigger mode.`
+ )
+ }
+
// If operation is specified, validate it exists
if (operation) {
const operationSubBlock = blockConfig.subBlocks?.find((sb) => sb.id === 'operation')
@@ -334,13 +391,14 @@ export const getBlockConfigServerTool: BaseServerTool<
}
const subBlocks = Array.isArray(blockConfig.subBlocks) ? blockConfig.subBlocks : []
- const inputs = extractInputsFromSubBlocks(subBlocks, operation)
- const outputs = extractOutputs(blockConfig, operation)
+ const inputs = extractInputsFromSubBlocks(subBlocks, operation, trigger)
+ const outputs = extractOutputs(blockConfig, operation, trigger)
const result = {
blockType,
blockName: blockConfig.name,
operation,
+ trigger,
inputs,
outputs,
}
diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts b/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts
index 9898b11c21..595371b0da 100644
--- a/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts
+++ b/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts
@@ -24,7 +24,7 @@ export const getBlockOptionsServerTool: BaseServerTool<
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
const allowedIntegrations = permissionConfig?.allowedIntegrations
- if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockId)) {
+ if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) {
throw new Error(`Block "${blockId}" is not available`)
}
diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts
index 48851448dd..222288aabc 100644
--- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts
+++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts
@@ -31,7 +31,7 @@ export const getBlocksAndToolsServerTool: BaseServerTool<
Object.entries(blockRegistry)
.filter(([blockType, blockConfig]: [string, BlockConfig]) => {
if (blockConfig.hideFromToolbar) return false
- if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) return false
+ if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) return false
return true
})
.forEach(([blockType, blockConfig]: [string, BlockConfig]) => {
diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts
index ffaa05b696..03a675dcb7 100644
--- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts
+++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts
@@ -118,7 +118,7 @@ export const getBlocksMetadataServerTool: BaseServerTool<
const result: Record = {}
for (const blockId of blockIds || []) {
- if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockId)) {
+ if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) {
logger.debug('Block not allowed by permission group', { blockId })
continue
}
@@ -408,11 +408,8 @@ function extractInputs(metadata: CopilotBlockMetadata): {
}
if (schema.options && schema.options.length > 0) {
- if (schema.id === 'operation') {
- input.options = schema.options.map((opt) => opt.id)
- } else {
- input.options = schema.options.map((opt) => opt.label || opt.id)
- }
+ // Always return the id (actual value to use), not the display label
+ input.options = schema.options.map((opt) => opt.id || opt.label)
}
if (inputDef?.enum && Array.isArray(inputDef.enum)) {
diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts
index 3e6f84ed25..c5f3b75b4f 100644
--- a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts
+++ b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts
@@ -26,7 +26,7 @@ export const getTriggerBlocksServerTool: BaseServerTool<
Object.entries(blockRegistry).forEach(([blockType, blockConfig]: [string, BlockConfig]) => {
if (blockConfig.hideFromToolbar) return
- if (allowedIntegrations !== null && !allowedIntegrations?.includes(blockType)) return
+ if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) return
if (blockConfig.category === 'triggers') {
triggerBlockIds.push(blockType)
diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts
index 127ec1029b..07bac4194a 100644
--- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts
+++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts
@@ -11,6 +11,7 @@ import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { isValidKey } from '@/lib/workflows/sanitization/key-validation'
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
+import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getAllBlocks, getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { EDGE, normalizeName } from '@/executor/constants'
@@ -62,6 +63,8 @@ type SkippedItemType =
| 'invalid_subflow_parent'
| 'nested_subflow_not_allowed'
| 'duplicate_block_name'
+ | 'duplicate_trigger'
+ | 'duplicate_single_instance_block'
/**
* Represents an item that was skipped during operation application
@@ -1775,6 +1778,34 @@ function applyOperationsToWorkflowState(
break
}
+ const triggerIssue = TriggerUtils.getTriggerAdditionIssue(modifiedState.blocks, params.type)
+ if (triggerIssue) {
+ logSkippedItem(skippedItems, {
+ type: 'duplicate_trigger',
+ operationType: 'add',
+ blockId: block_id,
+ reason: `Cannot add ${triggerIssue.triggerName} - a workflow can only have one`,
+ details: { requestedType: params.type, issue: triggerIssue.issue },
+ })
+ break
+ }
+
+ // Check single-instance block constraints (e.g., Response block)
+ const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue(
+ modifiedState.blocks,
+ params.type
+ )
+ if (singleInstanceIssue) {
+ logSkippedItem(skippedItems, {
+ type: 'duplicate_single_instance_block',
+ operationType: 'add',
+ blockId: block_id,
+ reason: `Cannot add ${singleInstanceIssue.blockName} - a workflow can only have one`,
+ details: { requestedType: params.type },
+ })
+ break
+ }
+
// Create new block with proper structure
const newBlock = createBlockFromParams(
block_id,
diff --git a/apps/sim/lib/copilot/tools/shared/schemas.ts b/apps/sim/lib/copilot/tools/shared/schemas.ts
index eeda25cd7b..31fed5417d 100644
--- a/apps/sim/lib/copilot/tools/shared/schemas.ts
+++ b/apps/sim/lib/copilot/tools/shared/schemas.ts
@@ -57,11 +57,13 @@ export type GetBlockOptionsResultType = z.infer
export const GetBlockConfigInput = z.object({
blockType: z.string(),
operation: z.string().optional(),
+ trigger: z.boolean().optional(),
})
export const GetBlockConfigResult = z.object({
blockType: z.string(),
blockName: z.string(),
operation: z.string().optional(),
+ trigger: z.boolean().optional(),
inputs: z.record(z.any()),
outputs: z.record(z.any()),
})
@@ -114,6 +116,7 @@ export const GetBlockOutputsResult = z.object({
blockId: z.string(),
blockName: z.string(),
blockType: z.string(),
+ triggerMode: z.boolean().optional(),
outputs: z.array(z.string()),
insideSubflowOutputs: z.array(z.string()).optional(),
outsideSubflowOutputs: z.array(z.string()).optional(),
@@ -155,6 +158,7 @@ export const GetBlockUpstreamReferencesResult = z.object({
blockId: z.string(),
blockName: z.string(),
blockType: z.string(),
+ triggerMode: z.boolean().optional(),
outputs: z.array(z.string()),
accessContext: z.enum(['inside', 'outside']).optional(),
})
diff --git a/apps/sim/lib/workflows/colors.ts b/apps/sim/lib/workflows/colors.ts
new file mode 100644
index 0000000000..f4b34468ad
--- /dev/null
+++ b/apps/sim/lib/workflows/colors.ts
@@ -0,0 +1,75 @@
+/**
+ * Workflow color constants and utilities.
+ * Centralized location for all workflow color-related functionality.
+ *
+ * Colors are aligned with the brand color scheme:
+ * - Purple: brand-400 (#8e4cfb)
+ * - Blue: brand-secondary (#33b4ff)
+ * - Green: brand-tertiary (#22c55e)
+ * - Red: text-error (#ef4444)
+ * - Orange: warning (#f97316)
+ * - Pink: (#ec4899)
+ */
+
+/**
+ * Full list of available workflow colors with names.
+ * Used for color picker and random color assignment.
+ * Each base color has 6 vibrant shades optimized for both light and dark themes.
+ */
+export const WORKFLOW_COLORS = [
+ // Shade 1 - all base colors (brightest)
+ { color: '#c084fc', name: 'Purple 1' },
+ { color: '#5ed8ff', name: 'Blue 1' },
+ { color: '#4aea7f', name: 'Green 1' },
+ { color: '#ff6b6b', name: 'Red 1' },
+ { color: '#ff9642', name: 'Orange 1' },
+ { color: '#f472b6', name: 'Pink 1' },
+
+ // Shade 2 - all base colors
+ { color: '#a855f7', name: 'Purple 2' },
+ { color: '#38c8ff', name: 'Blue 2' },
+ { color: '#2ed96a', name: 'Green 2' },
+ { color: '#ff5555', name: 'Red 2' },
+ { color: '#ff8328', name: 'Orange 2' },
+ { color: '#ec4899', name: 'Pink 2' },
+
+ // Shade 3 - all base colors
+ { color: '#9333ea', name: 'Purple 3' },
+ { color: '#33b4ff', name: 'Blue 3' },
+ { color: '#22c55e', name: 'Green 3' },
+ { color: '#ef4444', name: 'Red 3' },
+ { color: '#f97316', name: 'Orange 3' },
+ { color: '#e11d89', name: 'Pink 3' },
+
+ // Shade 4 - all base colors
+ { color: '#8e4cfb', name: 'Purple 4' },
+ { color: '#1e9de8', name: 'Blue 4' },
+ { color: '#18b04c', name: 'Green 4' },
+ { color: '#dc3535', name: 'Red 4' },
+ { color: '#e56004', name: 'Orange 4' },
+ { color: '#d61c7a', name: 'Pink 4' },
+
+ // Shade 5 - all base colors
+ { color: '#7c3aed', name: 'Purple 5' },
+ { color: '#1486d1', name: 'Blue 5' },
+ { color: '#0e9b3a', name: 'Green 5' },
+ { color: '#c92626', name: 'Red 5' },
+ { color: '#d14d00', name: 'Orange 5' },
+ { color: '#be185d', name: 'Pink 5' },
+
+ // Shade 6 - all base colors (darkest)
+ { color: '#6322c9', name: 'Purple 6' },
+ { color: '#0a6fb8', name: 'Blue 6' },
+ { color: '#048628', name: 'Green 6' },
+ { color: '#b61717', name: 'Red 6' },
+ { color: '#bd3a00', name: 'Orange 6' },
+ { color: '#9d174d', name: 'Pink 6' },
+] as const
+
+/**
+ * Generates a random color for a new workflow
+ * @returns A hex color string from the available workflow colors
+ */
+export function getNextWorkflowColor(): string {
+ return WORKFLOW_COLORS[Math.floor(Math.random() * WORKFLOW_COLORS.length)].color
+}
diff --git a/apps/sim/lib/workflows/triggers/triggers.ts b/apps/sim/lib/workflows/triggers/triggers.ts
index dfb5601d2c..9af67a0390 100644
--- a/apps/sim/lib/workflows/triggers/triggers.ts
+++ b/apps/sim/lib/workflows/triggers/triggers.ts
@@ -592,4 +592,34 @@ export class TriggerUtils {
const parentWithType = parent as T & { type?: string }
return parentWithType.type === 'loop' || parentWithType.type === 'parallel'
}
+
+ static isSingleInstanceBlockType(blockType: string): boolean {
+ const blockConfig = getBlock(blockType)
+ return blockConfig?.singleInstance === true
+ }
+
+ static wouldViolateSingleInstanceBlock(
+ blocks: T[] | Record,
+ blockType: string
+ ): boolean {
+ if (!TriggerUtils.isSingleInstanceBlockType(blockType)) {
+ return false
+ }
+
+ const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks)
+ return blockArray.some((block) => block.type === blockType)
+ }
+
+ static getSingleInstanceBlockIssue(
+ blocks: T[] | Record,
+ blockType: string
+ ): { issue: 'duplicate'; blockName: string } | null {
+ if (!TriggerUtils.wouldViolateSingleInstanceBlock(blocks, blockType)) {
+ return null
+ }
+
+ const blockConfig = getBlock(blockType)
+ const blockName = blockConfig?.name || blockType
+ return { issue: 'duplicate', blockName }
+ }
}
diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts
index d00ca84c7a..d4e926c91e 100644
--- a/apps/sim/stores/panel/copilot/store.ts
+++ b/apps/sim/stores/panel/copilot/store.ts
@@ -25,22 +25,37 @@ import {
registerToolStateSync,
} from '@/lib/copilot/tools/client/manager'
import { NavigateUIClientTool } from '@/lib/copilot/tools/client/navigation/navigate-ui'
+import { AuthClientTool } from '@/lib/copilot/tools/client/other/auth'
import { CheckoffTodoClientTool } from '@/lib/copilot/tools/client/other/checkoff-todo'
+import { CustomToolClientTool } from '@/lib/copilot/tools/client/other/custom-tool'
+import { DebugClientTool } from '@/lib/copilot/tools/client/other/debug'
+import { DeployClientTool } from '@/lib/copilot/tools/client/other/deploy'
+import { EditClientTool } from '@/lib/copilot/tools/client/other/edit'
+import { EvaluateClientTool } from '@/lib/copilot/tools/client/other/evaluate'
+import { InfoClientTool } from '@/lib/copilot/tools/client/other/info'
+import { KnowledgeClientTool } from '@/lib/copilot/tools/client/other/knowledge'
import { MakeApiRequestClientTool } from '@/lib/copilot/tools/client/other/make-api-request'
import { MarkTodoInProgressClientTool } from '@/lib/copilot/tools/client/other/mark-todo-in-progress'
import { OAuthRequestAccessClientTool } from '@/lib/copilot/tools/client/other/oauth-request-access'
import { PlanClientTool } from '@/lib/copilot/tools/client/other/plan'
import { RememberDebugClientTool } from '@/lib/copilot/tools/client/other/remember-debug'
+import { ResearchClientTool } from '@/lib/copilot/tools/client/other/research'
import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/search-documentation'
import { SearchErrorsClientTool } from '@/lib/copilot/tools/client/other/search-errors'
import { SearchOnlineClientTool } from '@/lib/copilot/tools/client/other/search-online'
import { SearchPatternsClientTool } from '@/lib/copilot/tools/client/other/search-patterns'
import { SleepClientTool } from '@/lib/copilot/tools/client/other/sleep'
+import { TestClientTool } from '@/lib/copilot/tools/client/other/test'
+import { TourClientTool } from '@/lib/copilot/tools/client/other/tour'
+import { WorkflowClientTool } from '@/lib/copilot/tools/client/other/workflow'
import { createExecutionContext, getTool } from '@/lib/copilot/tools/client/registry'
import { GetCredentialsClientTool } from '@/lib/copilot/tools/client/user/get-credentials'
import { SetEnvironmentVariablesClientTool } from '@/lib/copilot/tools/client/user/set-environment-variables'
import { CheckDeploymentStatusClientTool } from '@/lib/copilot/tools/client/workflow/check-deployment-status'
-import { DeployWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/deploy-workflow'
+import { CreateWorkspaceMcpServerClientTool } from '@/lib/copilot/tools/client/workflow/create-workspace-mcp-server'
+import { DeployApiClientTool } from '@/lib/copilot/tools/client/workflow/deploy-api'
+import { DeployChatClientTool } from '@/lib/copilot/tools/client/workflow/deploy-chat'
+import { DeployMcpClientTool } from '@/lib/copilot/tools/client/workflow/deploy-mcp'
import { EditWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/edit-workflow'
import { GetBlockOutputsClientTool } from '@/lib/copilot/tools/client/workflow/get-block-outputs'
import { GetBlockUpstreamReferencesClientTool } from '@/lib/copilot/tools/client/workflow/get-block-upstream-references'
@@ -49,6 +64,7 @@ import { GetWorkflowConsoleClientTool } from '@/lib/copilot/tools/client/workflo
import { GetWorkflowDataClientTool } from '@/lib/copilot/tools/client/workflow/get-workflow-data'
import { GetWorkflowFromNameClientTool } from '@/lib/copilot/tools/client/workflow/get-workflow-from-name'
import { ListUserWorkflowsClientTool } from '@/lib/copilot/tools/client/workflow/list-user-workflows'
+import { ListWorkspaceMcpServersClientTool } from '@/lib/copilot/tools/client/workflow/list-workspace-mcp-servers'
import { ManageCustomToolClientTool } from '@/lib/copilot/tools/client/workflow/manage-custom-tool'
import { ManageMcpToolClientTool } from '@/lib/copilot/tools/client/workflow/manage-mcp-tool'
import { RunWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/run-workflow'
@@ -78,6 +94,19 @@ try {
// Known class-based client tools: map tool name -> instantiator
const CLIENT_TOOL_INSTANTIATORS: Record any> = {
+ plan: (id) => new PlanClientTool(id),
+ edit: (id) => new EditClientTool(id),
+ debug: (id) => new DebugClientTool(id),
+ test: (id) => new TestClientTool(id),
+ deploy: (id) => new DeployClientTool(id),
+ evaluate: (id) => new EvaluateClientTool(id),
+ auth: (id) => new AuthClientTool(id),
+ research: (id) => new ResearchClientTool(id),
+ knowledge: (id) => new KnowledgeClientTool(id),
+ custom_tool: (id) => new CustomToolClientTool(id),
+ tour: (id) => new TourClientTool(id),
+ info: (id) => new InfoClientTool(id),
+ workflow: (id) => new WorkflowClientTool(id),
run_workflow: (id) => new RunWorkflowClientTool(id),
get_workflow_console: (id) => new GetWorkflowConsoleClientTool(id),
get_blocks_and_tools: (id) => new GetBlocksAndToolsClientTool(id),
@@ -94,7 +123,6 @@ const CLIENT_TOOL_INSTANTIATORS: Record any> = {
get_credentials: (id) => new GetCredentialsClientTool(id),
knowledge_base: (id) => new KnowledgeBaseClientTool(id),
make_api_request: (id) => new MakeApiRequestClientTool(id),
- plan: (id) => new PlanClientTool(id),
checkoff_todo: (id) => new CheckoffTodoClientTool(id),
mark_todo_in_progress: (id) => new MarkTodoInProgressClientTool(id),
oauth_request_access: (id) => new OAuthRequestAccessClientTool(id),
@@ -108,7 +136,11 @@ const CLIENT_TOOL_INSTANTIATORS: Record any> = {
get_examples_rag: (id) => new GetExamplesRagClientTool(id),
get_operations_examples: (id) => new GetOperationsExamplesClientTool(id),
summarize_conversation: (id) => new SummarizeClientTool(id),
- deploy_workflow: (id) => new DeployWorkflowClientTool(id),
+ deploy_api: (id) => new DeployApiClientTool(id),
+ deploy_chat: (id) => new DeployChatClientTool(id),
+ deploy_mcp: (id) => new DeployMcpClientTool(id),
+ list_workspace_mcp_servers: (id) => new ListWorkspaceMcpServersClientTool(id),
+ create_workspace_mcp_server: (id) => new CreateWorkspaceMcpServerClientTool(id),
check_deployment_status: (id) => new CheckDeploymentStatusClientTool(id),
navigate_ui: (id) => new NavigateUIClientTool(id),
manage_custom_tool: (id) => new ManageCustomToolClientTool(id),
@@ -120,6 +152,19 @@ const CLIENT_TOOL_INSTANTIATORS: Record any> = {
// Read-only static metadata for class-based tools (no instances)
export const CLASS_TOOL_METADATA: Record = {
+ plan: (PlanClientTool as any)?.metadata,
+ edit: (EditClientTool as any)?.metadata,
+ debug: (DebugClientTool as any)?.metadata,
+ test: (TestClientTool as any)?.metadata,
+ deploy: (DeployClientTool as any)?.metadata,
+ evaluate: (EvaluateClientTool as any)?.metadata,
+ auth: (AuthClientTool as any)?.metadata,
+ research: (ResearchClientTool as any)?.metadata,
+ knowledge: (KnowledgeClientTool as any)?.metadata,
+ custom_tool: (CustomToolClientTool as any)?.metadata,
+ tour: (TourClientTool as any)?.metadata,
+ info: (InfoClientTool as any)?.metadata,
+ workflow: (WorkflowClientTool as any)?.metadata,
run_workflow: (RunWorkflowClientTool as any)?.metadata,
get_workflow_console: (GetWorkflowConsoleClientTool as any)?.metadata,
get_blocks_and_tools: (GetBlocksAndToolsClientTool as any)?.metadata,
@@ -136,7 +181,6 @@ export const CLASS_TOOL_METADATA: Record c.toUpperCase())
- return { text, icon: undefined as any }
+ const formattedName = toolName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
+ // Add state verb prefix for verb-noun rendering in tool-call component
+ let stateVerb: string
+ switch (state) {
+ case ClientToolCallState.pending:
+ case ClientToolCallState.executing:
+ stateVerb = 'Executing'
+ break
+ case ClientToolCallState.success:
+ stateVerb = 'Executed'
+ break
+ case ClientToolCallState.error:
+ stateVerb = 'Failed'
+ break
+ case ClientToolCallState.rejected:
+ case ClientToolCallState.aborted:
+ stateVerb = 'Skipped'
+ break
+ default:
+ stateVerb = 'Executing'
+ }
+ return { text: `${stateVerb} ${formattedName}`, icon: undefined as any }
}
} catch {}
return undefined
@@ -338,125 +406,65 @@ function abortAllInProgressTools(set: any, get: () => CopilotStore) {
}
// Normalize loaded messages so assistant messages render correctly from DB
+/**
+ * Loads messages from DB for UI rendering.
+ * Messages are stored exactly as they render, so we just need to:
+ * 1. Register client tool instances for any tool calls
+ * 2. Return the messages as-is
+ */
function normalizeMessagesForUI(messages: CopilotMessage[]): CopilotMessage[] {
try {
- return messages.map((message) => {
- if (message.role !== 'assistant') {
- // For user messages (and others), restore contexts from a saved contexts block
- if (Array.isArray(message.contentBlocks) && message.contentBlocks.length > 0) {
- const ctxBlock = (message.contentBlocks as any[]).find((b: any) => b?.type === 'contexts')
- if (ctxBlock && Array.isArray((ctxBlock as any).contexts)) {
- return {
- ...message,
- contexts: (ctxBlock as any).contexts,
- }
- }
- }
- return message
+ // Log what we're loading
+ for (const message of messages) {
+ if (message.role === 'assistant') {
+ logger.info('[normalizeMessagesForUI] Loading assistant message', {
+ id: message.id,
+ hasContent: !!message.content?.trim(),
+ contentBlockCount: message.contentBlocks?.length || 0,
+ contentBlockTypes: (message.contentBlocks as any[])?.map((b) => b?.type) || [],
+ })
}
+ }
- // Use existing contentBlocks ordering if present; otherwise only render text content
- const blocks: any[] = Array.isArray(message.contentBlocks)
- ? (message.contentBlocks as any[]).map((b: any) => {
- if (b?.type === 'tool_call' && b.toolCall) {
- // Ensure client tool instance is registered for this tool call
- ensureClientToolInstance(b.toolCall?.name, b.toolCall?.id)
-
- return {
- ...b,
- toolCall: {
- ...b.toolCall,
- state:
- isRejectedState(b.toolCall?.state) ||
- isReviewState(b.toolCall?.state) ||
- isBackgroundState(b.toolCall?.state) ||
- b.toolCall?.state === ClientToolCallState.success ||
- b.toolCall?.state === ClientToolCallState.error ||
- b.toolCall?.state === ClientToolCallState.aborted
- ? b.toolCall.state
- : ClientToolCallState.rejected,
- display: resolveToolDisplay(
- b.toolCall?.name,
- (isRejectedState(b.toolCall?.state) ||
- isReviewState(b.toolCall?.state) ||
- isBackgroundState(b.toolCall?.state) ||
- b.toolCall?.state === ClientToolCallState.success ||
- b.toolCall?.state === ClientToolCallState.error ||
- b.toolCall?.state === ClientToolCallState.aborted
- ? (b.toolCall?.state as any)
- : ClientToolCallState.rejected) as any,
- b.toolCall?.id,
- b.toolCall?.params
- ),
- },
- }
- }
- if (b?.type === TEXT_BLOCK_TYPE && typeof b.content === 'string') {
- return {
- ...b,
- content: stripTodoTags(b.content),
- }
- }
- return b
- })
- : []
-
- // Prepare toolCalls with display for non-block UI components, but do not fabricate blocks
- const updatedToolCalls = Array.isArray((message as any).toolCalls)
- ? (message as any).toolCalls.map((tc: any) => {
- // Ensure client tool instance is registered for this tool call
- ensureClientToolInstance(tc?.name, tc?.id)
-
- return {
- ...tc,
- state:
- isRejectedState(tc?.state) ||
- isReviewState(tc?.state) ||
- isBackgroundState(tc?.state) ||
- tc?.state === ClientToolCallState.success ||
- tc?.state === ClientToolCallState.error ||
- tc?.state === ClientToolCallState.aborted
- ? tc.state
- : ClientToolCallState.rejected,
- display: resolveToolDisplay(
- tc?.name,
- (isRejectedState(tc?.state) ||
- isReviewState(tc?.state) ||
- isBackgroundState(tc?.state) ||
- tc?.state === ClientToolCallState.success ||
- tc?.state === ClientToolCallState.error ||
- tc?.state === ClientToolCallState.aborted
- ? (tc?.state as any)
- : ClientToolCallState.rejected) as any,
- tc?.id,
- tc?.params
- ),
- }
- })
- : (message as any).toolCalls
-
- const sanitizedContent = stripTodoTags(message.content || '')
-
- return {
- ...message,
- content: sanitizedContent,
- ...(updatedToolCalls && { toolCalls: updatedToolCalls }),
- ...(blocks.length > 0
- ? { contentBlocks: blocks }
- : sanitizedContent.trim()
- ? {
- contentBlocks: [
- { type: TEXT_BLOCK_TYPE, content: sanitizedContent, timestamp: Date.now() },
- ],
- }
- : {}),
+ // Register client tool instances for all tool calls so they can be looked up
+ for (const message of messages) {
+ if (message.contentBlocks) {
+ for (const block of message.contentBlocks as any[]) {
+ if (block?.type === 'tool_call' && block.toolCall) {
+ registerToolCallInstances(block.toolCall)
+ }
+ }
}
- })
+ }
+ // Return messages as-is - they're already in the correct format for rendering
+ return messages
} catch {
return messages
}
}
+/**
+ * Recursively registers client tool instances for a tool call and its nested subagent tool calls.
+ */
+function registerToolCallInstances(toolCall: any): void {
+ if (!toolCall?.id) return
+ ensureClientToolInstance(toolCall.name, toolCall.id)
+
+ // Register nested subagent tool calls
+ if (Array.isArray(toolCall.subAgentBlocks)) {
+ for (const block of toolCall.subAgentBlocks) {
+ if (block?.type === 'subagent_tool_call' && block.toolCall) {
+ registerToolCallInstances(block.toolCall)
+ }
+ }
+ }
+ if (Array.isArray(toolCall.subAgentToolCalls)) {
+ for (const subTc of toolCall.subAgentToolCalls) {
+ registerToolCallInstances(subTc)
+ }
+ }
+}
+
// Simple object pool for content blocks
class ObjectPool {
private pool: T[] = []
@@ -578,62 +586,186 @@ function stripTodoTags(text: string): string {
.replace(/\n{2,}/g, '\n')
}
-function validateMessagesForLLM(messages: CopilotMessage[]): any[] {
- return messages
+/**
+ * Deep clones an object using JSON serialization.
+ * This ensures we strip any non-serializable data (functions, circular refs).
+ */
+function deepClone(obj: T): T {
+ try {
+ const json = JSON.stringify(obj)
+ if (!json || json === 'undefined') {
+ logger.warn('[deepClone] JSON.stringify returned empty for object', {
+ type: typeof obj,
+ isArray: Array.isArray(obj),
+ length: Array.isArray(obj) ? obj.length : undefined,
+ })
+ return obj
+ }
+ const parsed = JSON.parse(json)
+ // Verify the clone worked
+ if (Array.isArray(obj) && (!Array.isArray(parsed) || parsed.length !== obj.length)) {
+ logger.warn('[deepClone] Array clone mismatch', {
+ originalLength: obj.length,
+ clonedLength: Array.isArray(parsed) ? parsed.length : 'not array',
+ })
+ }
+ return parsed
+ } catch (err) {
+ logger.error('[deepClone] Failed to clone object', {
+ error: String(err),
+ type: typeof obj,
+ isArray: Array.isArray(obj),
+ })
+ return obj
+ }
+}
+
+/**
+ * Serializes messages for database storage.
+ * Deep clones all fields to ensure proper JSON serialization.
+ * This ensures they render identically when loaded back.
+ */
+function serializeMessagesForDB(messages: CopilotMessage[]): any[] {
+ const result = messages
.map((msg) => {
- // Build content from blocks if assistant content is empty (exclude thinking)
- let content = msg.content || ''
- if (msg.role === 'assistant' && !content.trim() && msg.contentBlocks?.length) {
- content = msg.contentBlocks
- .filter((b: any) => b?.type === 'text')
- .map((b: any) => String(b.content || ''))
- .join('')
- .trim()
- }
-
- // Strip thinking, design_workflow, and todo tags from content
- if (content) {
- content = stripTodoTags(
- content
- .replace(/[\s\S]*?<\/thinking>/g, '')
- .replace(/[\s\S]*?<\/design_workflow>/g, '')
- ).trim()
- }
-
- return {
+ // Deep clone the entire message to ensure all nested data is serializable
+ // Ensure timestamp is always a string (Zod schema requires it)
+ let timestamp: string = msg.timestamp
+ if (typeof timestamp !== 'string') {
+ const ts = timestamp as any
+ timestamp = ts instanceof Date ? ts.toISOString() : new Date().toISOString()
+ }
+
+ const serialized: any = {
id: msg.id,
role: msg.role,
- content,
- timestamp: msg.timestamp,
- ...(Array.isArray((msg as any).toolCalls) &&
- (msg as any).toolCalls.length > 0 && {
- toolCalls: (msg as any).toolCalls,
- }),
- ...(Array.isArray(msg.contentBlocks) &&
- msg.contentBlocks.length > 0 && {
- // Persist full contentBlocks including thinking so history can render it
- contentBlocks: msg.contentBlocks,
- }),
- ...(msg.fileAttachments &&
- msg.fileAttachments.length > 0 && {
- fileAttachments: msg.fileAttachments,
- }),
- ...((msg as any).contexts &&
- Array.isArray((msg as any).contexts) && {
- contexts: (msg as any).contexts,
- }),
+ content: msg.content || '',
+ timestamp,
+ }
+
+ // Deep clone contentBlocks (the main rendering data)
+ if (Array.isArray(msg.contentBlocks) && msg.contentBlocks.length > 0) {
+ serialized.contentBlocks = deepClone(msg.contentBlocks)
+ }
+
+ // Deep clone toolCalls
+ if (Array.isArray((msg as any).toolCalls) && (msg as any).toolCalls.length > 0) {
+ serialized.toolCalls = deepClone((msg as any).toolCalls)
+ }
+
+ // Deep clone file attachments
+ if (Array.isArray(msg.fileAttachments) && msg.fileAttachments.length > 0) {
+ serialized.fileAttachments = deepClone(msg.fileAttachments)
}
+
+ // Deep clone contexts
+ if (Array.isArray((msg as any).contexts) && (msg as any).contexts.length > 0) {
+ serialized.contexts = deepClone((msg as any).contexts)
+ }
+
+ // Deep clone citations
+ if (Array.isArray(msg.citations) && msg.citations.length > 0) {
+ serialized.citations = deepClone(msg.citations)
+ }
+
+ // Copy error type
+ if (msg.errorType) {
+ serialized.errorType = msg.errorType
+ }
+
+ return serialized
})
- .filter((m) => {
- if (m.role === 'assistant') {
- const hasText = typeof m.content === 'string' && m.content.trim().length > 0
- const hasTools = Array.isArray((m as any).toolCalls) && (m as any).toolCalls.length > 0
- const hasBlocks =
- Array.isArray((m as any).contentBlocks) && (m as any).contentBlocks.length > 0
- return hasText || hasTools || hasBlocks
+ .filter((msg) => {
+ // Filter out empty assistant messages
+ if (msg.role === 'assistant') {
+ const hasContent = typeof msg.content === 'string' && msg.content.trim().length > 0
+ const hasTools = Array.isArray(msg.toolCalls) && msg.toolCalls.length > 0
+ const hasBlocks = Array.isArray(msg.contentBlocks) && msg.contentBlocks.length > 0
+ return hasContent || hasTools || hasBlocks
}
return true
})
+
+ // Log what we're serializing
+ for (const msg of messages) {
+ if (msg.role === 'assistant') {
+ logger.info('[serializeMessagesForDB] Input assistant message', {
+ id: msg.id,
+ hasContent: !!msg.content?.trim(),
+ contentBlockCount: msg.contentBlocks?.length || 0,
+ contentBlockTypes: (msg.contentBlocks as any[])?.map((b) => b?.type) || [],
+ })
+ }
+ }
+
+ logger.info('[serializeMessagesForDB] Serialized messages', {
+ inputCount: messages.length,
+ outputCount: result.length,
+ sample:
+ result.length > 0
+ ? {
+ role: result[result.length - 1].role,
+ hasContent: !!result[result.length - 1].content,
+ contentBlockCount: result[result.length - 1].contentBlocks?.length || 0,
+ toolCallCount: result[result.length - 1].toolCalls?.length || 0,
+ }
+ : null,
+ })
+
+ return result
+}
+
+/**
+ * @deprecated Use serializeMessagesForDB instead.
+ */
+function validateMessagesForLLM(messages: CopilotMessage[]): any[] {
+ return serializeMessagesForDB(messages)
+}
+
+/**
+ * Extracts all tool calls from a toolCall object, including nested subAgentBlocks.
+ * Adds them to the provided map.
+ */
+function extractToolCallsRecursively(
+ toolCall: CopilotToolCall,
+ map: Record
+): void {
+ if (!toolCall?.id) return
+ map[toolCall.id] = toolCall
+
+ // Extract nested tool calls from subAgentBlocks
+ if (Array.isArray(toolCall.subAgentBlocks)) {
+ for (const block of toolCall.subAgentBlocks) {
+ if (block?.type === 'subagent_tool_call' && block.toolCall?.id) {
+ extractToolCallsRecursively(block.toolCall, map)
+ }
+ }
+ }
+
+ // Extract from subAgentToolCalls as well
+ if (Array.isArray(toolCall.subAgentToolCalls)) {
+ for (const subTc of toolCall.subAgentToolCalls) {
+ extractToolCallsRecursively(subTc, map)
+ }
+ }
+}
+
+/**
+ * Builds a complete toolCallsById map from normalized messages.
+ * Extracts all tool calls including nested subagent tool calls.
+ */
+function buildToolCallsById(messages: CopilotMessage[]): Record {
+ const toolCallsById: Record = {}
+ for (const msg of messages) {
+ if (msg.contentBlocks) {
+ for (const block of msg.contentBlocks as any[]) {
+ if (block?.type === 'tool_call' && block.toolCall?.id) {
+ extractToolCallsRecursively(block.toolCall, toolCallsById)
+ }
+ }
+ }
+ }
+ return toolCallsById
}
// Streaming context and SSE parsing
@@ -650,6 +782,14 @@ interface StreamingContext {
newChatId?: string
doneEventCount: number
streamComplete?: boolean
+ /** Track active subagent sessions by parent tool call ID */
+ subAgentParentToolCallId?: string
+ /** Track subagent content per parent tool call */
+ subAgentContent: Record
+ /** Track subagent tool calls per parent tool call */
+ subAgentToolCalls: Record
+ /** Track subagent streaming blocks per parent tool call */
+ subAgentBlocks: Record
}
type SSEHandler = (
@@ -1474,6 +1614,348 @@ const sseHandlers: Record = {
default: () => {},
}
+/**
+ * Helper to update a tool call with subagent data in both toolCallsById and contentBlocks
+ */
+function updateToolCallWithSubAgentData(
+ context: StreamingContext,
+ get: () => CopilotStore,
+ set: any,
+ parentToolCallId: string
+) {
+ const { toolCallsById } = get()
+ const parentToolCall = toolCallsById[parentToolCallId]
+ if (!parentToolCall) {
+ logger.warn('[SubAgent] updateToolCallWithSubAgentData: parent tool call not found', {
+ parentToolCallId,
+ availableToolCallIds: Object.keys(toolCallsById),
+ })
+ return
+ }
+
+ // Prepare subagent blocks array for ordered display
+ const blocks = context.subAgentBlocks[parentToolCallId] || []
+
+ const updatedToolCall: CopilotToolCall = {
+ ...parentToolCall,
+ subAgentContent: context.subAgentContent[parentToolCallId] || '',
+ subAgentToolCalls: context.subAgentToolCalls[parentToolCallId] || [],
+ subAgentBlocks: blocks,
+ subAgentStreaming: true,
+ }
+
+ logger.info('[SubAgent] Updating tool call with subagent data', {
+ parentToolCallId,
+ parentToolName: parentToolCall.name,
+ subAgentContentLength: updatedToolCall.subAgentContent?.length,
+ subAgentBlocksCount: updatedToolCall.subAgentBlocks?.length,
+ subAgentToolCallsCount: updatedToolCall.subAgentToolCalls?.length,
+ })
+
+ // Update in toolCallsById
+ const updatedMap = { ...toolCallsById, [parentToolCallId]: updatedToolCall }
+ set({ toolCallsById: updatedMap })
+
+ // Update in contentBlocks
+ let foundInContentBlocks = false
+ for (let i = 0; i < context.contentBlocks.length; i++) {
+ const b = context.contentBlocks[i] as any
+ if (b.type === 'tool_call' && b.toolCall?.id === parentToolCallId) {
+ context.contentBlocks[i] = { ...b, toolCall: updatedToolCall }
+ foundInContentBlocks = true
+ break
+ }
+ }
+
+ if (!foundInContentBlocks) {
+ logger.warn('[SubAgent] Parent tool call not found in contentBlocks', {
+ parentToolCallId,
+ contentBlocksCount: context.contentBlocks.length,
+ toolCallBlockIds: context.contentBlocks
+ .filter((b: any) => b.type === 'tool_call')
+ .map((b: any) => b.toolCall?.id),
+ })
+ }
+
+ updateStreamingMessage(set, context)
+}
+
+/**
+ * SSE handlers for subagent events (events with subagent field set)
+ * These handle content and tool calls from subagents like debug
+ */
+const subAgentSSEHandlers: Record = {
+ // Handle subagent response start (ignore - just a marker)
+ start: () => {
+ // Subagent start event - no action needed, parent is already tracked from subagent_start
+ },
+
+ // Handle subagent text content (reasoning/thinking)
+ content: (data, context, get, set) => {
+ const parentToolCallId = context.subAgentParentToolCallId
+ logger.info('[SubAgent] content event', {
+ parentToolCallId,
+ hasData: !!data.data,
+ dataPreview: typeof data.data === 'string' ? data.data.substring(0, 50) : null,
+ })
+ if (!parentToolCallId || !data.data) {
+ logger.warn('[SubAgent] content missing parentToolCallId or data', {
+ parentToolCallId,
+ hasData: !!data.data,
+ })
+ return
+ }
+
+ // Initialize if needed
+ if (!context.subAgentContent[parentToolCallId]) {
+ context.subAgentContent[parentToolCallId] = ''
+ }
+ if (!context.subAgentBlocks[parentToolCallId]) {
+ context.subAgentBlocks[parentToolCallId] = []
+ }
+
+ // Append content
+ context.subAgentContent[parentToolCallId] += data.data
+
+ // Update or create the last text block in subAgentBlocks
+ const blocks = context.subAgentBlocks[parentToolCallId]
+ const lastBlock = blocks[blocks.length - 1]
+ if (lastBlock && lastBlock.type === 'subagent_text') {
+ lastBlock.content = (lastBlock.content || '') + data.data
+ } else {
+ blocks.push({
+ type: 'subagent_text',
+ content: data.data,
+ timestamp: Date.now(),
+ })
+ }
+
+ updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
+ },
+
+ // Handle subagent reasoning (same as content for subagent display purposes)
+ reasoning: (data, context, get, set) => {
+ const parentToolCallId = context.subAgentParentToolCallId
+ const phase = data?.phase || data?.data?.phase
+ if (!parentToolCallId) return
+
+ // Initialize if needed
+ if (!context.subAgentContent[parentToolCallId]) {
+ context.subAgentContent[parentToolCallId] = ''
+ }
+ if (!context.subAgentBlocks[parentToolCallId]) {
+ context.subAgentBlocks[parentToolCallId] = []
+ }
+
+ // For reasoning, we just append the content (treating start/end as markers)
+ if (phase === 'start' || phase === 'end') return
+
+ const chunk = typeof data?.data === 'string' ? data.data : data?.content || ''
+ if (!chunk) return
+
+ context.subAgentContent[parentToolCallId] += chunk
+
+ // Update or create the last text block in subAgentBlocks
+ const blocks = context.subAgentBlocks[parentToolCallId]
+ const lastBlock = blocks[blocks.length - 1]
+ if (lastBlock && lastBlock.type === 'subagent_text') {
+ lastBlock.content = (lastBlock.content || '') + chunk
+ } else {
+ blocks.push({
+ type: 'subagent_text',
+ content: chunk,
+ timestamp: Date.now(),
+ })
+ }
+
+ updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
+ },
+
+ // Handle subagent tool_generating (tool is being generated)
+ tool_generating: () => {
+ // Tool generating event - no action needed, we'll handle the actual tool_call
+ },
+
+ // Handle subagent tool calls - also execute client tools
+ tool_call: async (data, context, get, set) => {
+ const parentToolCallId = context.subAgentParentToolCallId
+ if (!parentToolCallId) return
+
+ const toolData = data?.data || {}
+ const id: string | undefined = toolData.id || data?.toolCallId
+ const name: string | undefined = toolData.name || data?.toolName
+ if (!id || !name) return
+
+ // Arguments can come in different locations depending on SSE format
+ // Check multiple possible locations
+ let args = toolData.arguments || toolData.input || data?.arguments || data?.input
+
+ // If arguments is a string, try to parse it as JSON
+ if (typeof args === 'string') {
+ try {
+ args = JSON.parse(args)
+ } catch {
+ logger.warn('[SubAgent] Failed to parse arguments string', { args })
+ }
+ }
+
+ logger.info('[SubAgent] tool_call received', {
+ id,
+ name,
+ hasArgs: !!args,
+ argsKeys: args ? Object.keys(args) : [],
+ toolDataKeys: Object.keys(toolData),
+ dataKeys: Object.keys(data || {}),
+ })
+
+ // Initialize if needed
+ if (!context.subAgentToolCalls[parentToolCallId]) {
+ context.subAgentToolCalls[parentToolCallId] = []
+ }
+ if (!context.subAgentBlocks[parentToolCallId]) {
+ context.subAgentBlocks[parentToolCallId] = []
+ }
+
+ // Ensure client tool instance is registered (for execution)
+ ensureClientToolInstance(name, id)
+
+ // Create or update the subagent tool call
+ const existingIndex = context.subAgentToolCalls[parentToolCallId].findIndex(
+ (tc) => tc.id === id
+ )
+ const subAgentToolCall: CopilotToolCall = {
+ id,
+ name,
+ state: ClientToolCallState.pending,
+ ...(args ? { params: args } : {}),
+ display: resolveToolDisplay(name, ClientToolCallState.pending, id, args),
+ }
+
+ if (existingIndex >= 0) {
+ context.subAgentToolCalls[parentToolCallId][existingIndex] = subAgentToolCall
+ } else {
+ context.subAgentToolCalls[parentToolCallId].push(subAgentToolCall)
+
+ // Also add to ordered blocks
+ context.subAgentBlocks[parentToolCallId].push({
+ type: 'subagent_tool_call',
+ toolCall: subAgentToolCall,
+ timestamp: Date.now(),
+ })
+ }
+
+ // Also add to main toolCallsById for proper tool execution
+ const { toolCallsById } = get()
+ const updated = { ...toolCallsById, [id]: subAgentToolCall }
+ set({ toolCallsById: updated })
+
+ updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
+
+ // Execute client tools (same logic as main tool_call handler)
+ try {
+ const def = getTool(name)
+ if (def) {
+ const hasInterrupt =
+ typeof def.hasInterrupt === 'function'
+ ? !!def.hasInterrupt(args || {})
+ : !!def.hasInterrupt
+ if (!hasInterrupt) {
+ // Auto-execute tools without interrupts
+ const ctx = createExecutionContext({ toolCallId: id, toolName: name })
+ try {
+ await def.execute(ctx, args || {})
+ } catch (execErr: any) {
+ logger.error('[SubAgent] Tool execution failed', { id, name, error: execErr?.message })
+ }
+ }
+ } else {
+ // Fallback to class-based tools
+ const instance = getClientTool(id)
+ if (instance) {
+ const hasInterruptDisplays = !!instance.getInterruptDisplays?.()
+ if (!hasInterruptDisplays) {
+ try {
+ await instance.execute(args || {})
+ } catch (execErr: any) {
+ logger.error('[SubAgent] Class tool execution failed', {
+ id,
+ name,
+ error: execErr?.message,
+ })
+ }
+ }
+ }
+ }
+ } catch (e: any) {
+ logger.error('[SubAgent] Tool registry/execution error', { id, name, error: e?.message })
+ }
+ },
+
+ // Handle subagent tool results
+ tool_result: (data, context, get, set) => {
+ const parentToolCallId = context.subAgentParentToolCallId
+ if (!parentToolCallId) return
+
+ const toolCallId: string | undefined = data?.toolCallId || data?.data?.id
+ const success: boolean | undefined = data?.success !== false // Default to true if not specified
+ if (!toolCallId) return
+
+ // Initialize if needed
+ if (!context.subAgentToolCalls[parentToolCallId]) return
+ if (!context.subAgentBlocks[parentToolCallId]) return
+
+ // Update the subagent tool call state
+ const targetState = success ? ClientToolCallState.success : ClientToolCallState.error
+ const existingIndex = context.subAgentToolCalls[parentToolCallId].findIndex(
+ (tc) => tc.id === toolCallId
+ )
+
+ if (existingIndex >= 0) {
+ const existing = context.subAgentToolCalls[parentToolCallId][existingIndex]
+ const updatedSubAgentToolCall = {
+ ...existing,
+ state: targetState,
+ display: resolveToolDisplay(existing.name, targetState, toolCallId, existing.params),
+ }
+ context.subAgentToolCalls[parentToolCallId][existingIndex] = updatedSubAgentToolCall
+
+ // Also update in ordered blocks
+ for (const block of context.subAgentBlocks[parentToolCallId]) {
+ if (block.type === 'subagent_tool_call' && block.toolCall?.id === toolCallId) {
+ block.toolCall = updatedSubAgentToolCall
+ break
+ }
+ }
+
+ // Update the individual tool call in toolCallsById so ToolCall component gets latest state
+ const { toolCallsById } = get()
+ if (toolCallsById[toolCallId]) {
+ const updatedMap = {
+ ...toolCallsById,
+ [toolCallId]: updatedSubAgentToolCall,
+ }
+ set({ toolCallsById: updatedMap })
+ logger.info('[SubAgent] Updated subagent tool call state in toolCallsById', {
+ toolCallId,
+ name: existing.name,
+ state: targetState,
+ })
+ }
+ }
+
+ updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
+ },
+
+ // Handle subagent stream done - just update the streaming state
+ done: (data, context, get, set) => {
+ const parentToolCallId = context.subAgentParentToolCallId
+ if (!parentToolCallId) return
+
+ // Update the tool call with final content but keep streaming true until subagent_end
+ updateToolCallWithSubAgentData(context, get, set, parentToolCallId)
+ },
+}
+
// Debounced UI update queue for smoother streaming
const streamingUpdateQueue = new Map()
let streamingUpdateRAF: number | null = null
@@ -1610,8 +2092,8 @@ const initialState = {
streamingPlanContent: '',
toolCallsById: {} as Record,
suppressAutoSelect: false,
- contextUsage: null,
autoAllowedTools: [] as string[],
+ messageQueue: [] as import('./types').QueuedMessage[],
}
export const useCopilotStore = create()(
@@ -1622,7 +2104,7 @@ export const useCopilotStore = create()(
setMode: (mode) => set({ mode }),
// Clear messages (don't clear streamingPlanContent - let it persist)
- clearMessages: () => set({ messages: [], contextUsage: null }),
+ clearMessages: () => set({ messages: [] }),
// Workflow selection
setWorkflowId: async (workflowId: string | null) => {
@@ -1691,16 +2173,19 @@ export const useCopilotStore = create()(
const previousModel = get().selectedModel
// Optimistically set selected chat and normalize messages for UI
+ const normalizedMessages = normalizeMessagesForUI(chat.messages || [])
+ const toolCallsById = buildToolCallsById(normalizedMessages)
+
set({
currentChat: chat,
- messages: normalizeMessagesForUI(chat.messages || []),
+ messages: normalizedMessages,
+ toolCallsById,
planTodos: [],
showPlanTodos: false,
streamingPlanContent: planArtifact,
mode: chatMode,
selectedModel: chatModel as CopilotStore['selectedModel'],
suppressAutoSelect: false,
- contextUsage: null,
})
// Background-save the previous chat's latest messages, plan artifact, and config before switching (optimistic)
@@ -1733,18 +2218,7 @@ export const useCopilotStore = create()(
const latestChat = data.chats.find((c: CopilotChat) => c.id === chat.id)
if (latestChat) {
const normalizedMessages = normalizeMessagesForUI(latestChat.messages || [])
-
- // Build toolCallsById map from all tool calls in normalized messages
- const toolCallsById: Record = {}
- for (const msg of normalizedMessages) {
- if (msg.contentBlocks) {
- for (const block of msg.contentBlocks as any[]) {
- if (block?.type === 'tool_call' && block.toolCall?.id) {
- toolCallsById[block.toolCall.id] = block.toolCall
- }
- }
- }
- }
+ const toolCallsById = buildToolCallsById(normalizedMessages)
set({
currentChat: latestChat,
@@ -1752,15 +2226,11 @@ export const useCopilotStore = create()(
chats: (get().chats || []).map((c: CopilotChat) =>
c.id === chat.id ? latestChat : c
),
- contextUsage: null,
toolCallsById,
})
try {
await get().loadMessageCheckpoints(latestChat.id)
} catch {}
- // Fetch context usage for the selected chat
- logger.info('[Context Usage] Chat selected, fetching usage')
- await get().fetchContextUsage()
}
}
} catch {}
@@ -1798,7 +2268,6 @@ export const useCopilotStore = create()(
}
} catch {}
- logger.info('[Context Usage] New chat created, clearing context usage')
set({
currentChat: null,
messages: [],
@@ -1807,7 +2276,6 @@ export const useCopilotStore = create()(
showPlanTodos: false,
streamingPlanContent: '',
suppressAutoSelect: true,
- contextUsage: null,
})
},
@@ -1886,18 +2354,7 @@ export const useCopilotStore = create()(
const refreshedConfig = updatedCurrentChat.config || {}
const refreshedMode = refreshedConfig.mode || get().mode
const refreshedModel = refreshedConfig.model || get().selectedModel
-
- // Build toolCallsById map from all tool calls in normalized messages
- const toolCallsById: Record = {}
- for (const msg of normalizedMessages) {
- if (msg.contentBlocks) {
- for (const block of msg.contentBlocks as any[]) {
- if (block?.type === 'tool_call' && block.toolCall?.id) {
- toolCallsById[block.toolCall.id] = block.toolCall
- }
- }
- }
- }
+ const toolCallsById = buildToolCallsById(normalizedMessages)
set({
currentChat: updatedCurrentChat,
@@ -1928,17 +2385,7 @@ export const useCopilotStore = create()(
hasPlanArtifact: !!planArtifact,
})
- // Build toolCallsById map from all tool calls in normalized messages
- const toolCallsById: Record = {}
- for (const msg of normalizedMessages) {
- if (msg.contentBlocks) {
- for (const block of msg.contentBlocks as any[]) {
- if (block?.type === 'tool_call' && block.toolCall?.id) {
- toolCallsById[block.toolCall.id] = block.toolCall
- }
- }
- }
- }
+ const toolCallsById = buildToolCallsById(normalizedMessages)
set({
currentChat: mostRecentChat,
@@ -1969,7 +2416,7 @@ export const useCopilotStore = create()(
// Send a message (streaming only)
sendMessage: async (message: string, options = {}) => {
- const { workflowId, currentChat, mode, revertState } = get()
+ const { workflowId, currentChat, mode, revertState, isSendingMessage } = get()
const {
stream = true,
fileAttachments,
@@ -1984,6 +2431,15 @@ export const useCopilotStore = create()(
if (!workflowId) return
+ // If already sending a message, queue this one instead
+ if (isSendingMessage) {
+ get().addToQueue(message, { fileAttachments, contexts })
+ logger.info('[Copilot] Message queued (already sending)', {
+ queueLength: get().messageQueue.length + 1,
+ })
+ return
+ }
+
const abortController = new AbortController()
set({ isSendingMessage: true, error: null, abortController })
@@ -2192,14 +2648,6 @@ export const useCopilotStore = create()(
}).catch(() => {})
} catch {}
}
-
- // Fetch context usage after abort
- logger.info('[Context Usage] Message aborted, fetching usage')
- get()
- .fetchContextUsage()
- .catch((err) => {
- logger.warn('[Context Usage] Failed to fetch after abort', err)
- })
} catch {
set({ isSendingMessage: false, isAborting: false, abortController: null })
}
@@ -2540,6 +2988,9 @@ export const useCopilotStore = create()(
designWorkflowContent: '',
pendingContent: '',
doneEventCount: 0,
+ subAgentContent: {},
+ subAgentToolCalls: {},
+ subAgentBlocks: {},
}
if (isContinuation) {
@@ -2563,6 +3014,99 @@ export const useCopilotStore = create()(
const { abortController } = get()
if (abortController?.signal.aborted) break
+ // Log SSE events for debugging
+ logger.info('[SSE] Received event', {
+ type: data.type,
+ hasSubAgent: !!data.subagent,
+ subagent: data.subagent,
+ dataPreview:
+ typeof data.data === 'string'
+ ? data.data.substring(0, 100)
+ : JSON.stringify(data.data)?.substring(0, 100),
+ })
+
+ // Handle subagent_start to track parent tool call
+ if (data.type === 'subagent_start') {
+ const toolCallId = data.data?.tool_call_id
+ if (toolCallId) {
+ context.subAgentParentToolCallId = toolCallId
+ // Mark the parent tool call as streaming
+ const { toolCallsById } = get()
+ const parentToolCall = toolCallsById[toolCallId]
+ if (parentToolCall) {
+ const updatedToolCall: CopilotToolCall = {
+ ...parentToolCall,
+ subAgentStreaming: true,
+ }
+ const updatedMap = { ...toolCallsById, [toolCallId]: updatedToolCall }
+ set({ toolCallsById: updatedMap })
+ }
+ logger.info('[SSE] Subagent session started', {
+ subagent: data.subagent,
+ parentToolCallId: toolCallId,
+ })
+ }
+ continue
+ }
+
+ // Handle subagent_end to finalize subagent content
+ if (data.type === 'subagent_end') {
+ const parentToolCallId = context.subAgentParentToolCallId
+ if (parentToolCallId) {
+ // Mark subagent streaming as complete
+ const { toolCallsById } = get()
+ const parentToolCall = toolCallsById[parentToolCallId]
+ if (parentToolCall) {
+ const updatedToolCall: CopilotToolCall = {
+ ...parentToolCall,
+ subAgentContent: context.subAgentContent[parentToolCallId] || '',
+ subAgentToolCalls: context.subAgentToolCalls[parentToolCallId] || [],
+ subAgentBlocks: context.subAgentBlocks[parentToolCallId] || [],
+ subAgentStreaming: false, // Done streaming
+ }
+ const updatedMap = { ...toolCallsById, [parentToolCallId]: updatedToolCall }
+ set({ toolCallsById: updatedMap })
+ logger.info('[SSE] Subagent session ended', {
+ subagent: data.subagent,
+ parentToolCallId,
+ contentLength: context.subAgentContent[parentToolCallId]?.length || 0,
+ toolCallCount: context.subAgentToolCalls[parentToolCallId]?.length || 0,
+ })
+ }
+ }
+ context.subAgentParentToolCallId = undefined
+ continue
+ }
+
+ // Check if this is a subagent event (has subagent field)
+ if (data.subagent) {
+ const parentToolCallId = context.subAgentParentToolCallId
+ if (!parentToolCallId) {
+ logger.warn('[SSE] Subagent event without parent tool call ID', {
+ type: data.type,
+ subagent: data.subagent,
+ })
+ continue
+ }
+
+ logger.info('[SSE] Processing subagent event', {
+ type: data.type,
+ subagent: data.subagent,
+ parentToolCallId,
+ hasHandler: !!subAgentSSEHandlers[data.type],
+ })
+
+ const subAgentHandler = subAgentSSEHandlers[data.type]
+ if (subAgentHandler) {
+ await subAgentHandler(data, context, get, set)
+ } else {
+ logger.warn('[SSE] No handler for subagent event type', { type: data.type })
+ }
+ // Skip regular handlers for subagent events
+ if (context.streamComplete) break
+ continue
+ }
+
const handler = sseHandlers[data.type] || sseHandlers.default
await handler(data, context, get, set)
if (context.streamComplete) break
@@ -2614,18 +3158,49 @@ export const useCopilotStore = create()(
await get().handleNewChatCreation(context.newChatId)
}
+ // Process next message in queue if any
+ const nextInQueue = get().messageQueue[0]
+ if (nextInQueue) {
+ logger.info('[Queue] Processing next queued message', {
+ id: nextInQueue.id,
+ queueLength: get().messageQueue.length,
+ })
+ // Remove from queue and send
+ get().removeFromQueue(nextInQueue.id)
+ // Use setTimeout to avoid blocking the current execution
+ setTimeout(() => {
+ get().sendMessage(nextInQueue.content, {
+ stream: true,
+ fileAttachments: nextInQueue.fileAttachments,
+ contexts: nextInQueue.contexts,
+ messageId: nextInQueue.id,
+ })
+ }, 100)
+ }
+
// Persist full message state (including contentBlocks), plan artifact, and config to database
const { currentChat, streamingPlanContent, mode, selectedModel } = get()
if (currentChat) {
try {
const currentMessages = get().messages
+ // Debug: Log what we're about to serialize
+ const lastMsg = currentMessages[currentMessages.length - 1]
+ if (lastMsg?.role === 'assistant') {
+ logger.info('[Stream Done] About to serialize - last message state', {
+ id: lastMsg.id,
+ contentLength: lastMsg.content?.length || 0,
+ hasContentBlocks: !!lastMsg.contentBlocks,
+ contentBlockCount: lastMsg.contentBlocks?.length || 0,
+ contentBlockTypes: (lastMsg.contentBlocks as any[])?.map((b) => b?.type) || [],
+ })
+ }
const dbMessages = validateMessagesForLLM(currentMessages)
const config = {
mode,
model: selectedModel,
}
- await fetch('/api/copilot/chat/update-messages', {
+ const saveResponse = await fetch('/api/copilot/chat/update-messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -2636,6 +3211,18 @@ export const useCopilotStore = create()(
}),
})
+ if (!saveResponse.ok) {
+ const errorText = await saveResponse.text().catch(() => '')
+ logger.error('[Stream Done] Failed to save messages to DB', {
+ status: saveResponse.status,
+ error: errorText,
+ })
+ } else {
+ logger.info('[Stream Done] Successfully saved messages to DB', {
+ messageCount: dbMessages.length,
+ })
+ }
+
// Update local chat object with plan artifact and config
set({
currentChat: {
@@ -2644,7 +3231,9 @@ export const useCopilotStore = create()(
config,
},
})
- } catch {}
+ } catch (err) {
+ logger.error('[Stream Done] Exception saving messages', { error: String(err) })
+ }
}
// Post copilot_stats record (input/output tokens can be null for now)
@@ -2652,10 +3241,6 @@ export const useCopilotStore = create()(
// Removed: stats sending now occurs only on accept/reject with minimal payload
} catch {}
- // Fetch context usage after response completes
- logger.info('[Context Usage] Stream completed, fetching usage')
- await get().fetchContextUsage()
-
// Invalidate subscription queries to update usage
setTimeout(() => {
const queryClient = getQueryClient()
@@ -2833,86 +3418,11 @@ export const useCopilotStore = create()(
},
setSelectedModel: async (model) => {
- logger.info('[Context Usage] Model changed', { from: get().selectedModel, to: model })
set({ selectedModel: model })
- // Fetch context usage after model switch
- await get().fetchContextUsage()
},
setAgentPrefetch: (prefetch) => set({ agentPrefetch: prefetch }),
setEnabledModels: (models) => set({ enabledModels: models }),
- // Fetch context usage from sim-agent API
- fetchContextUsage: async () => {
- try {
- const { currentChat, selectedModel, workflowId } = get()
- logger.info('[Context Usage] Starting fetch', {
- hasChatId: !!currentChat?.id,
- hasWorkflowId: !!workflowId,
- chatId: currentChat?.id,
- workflowId,
- model: selectedModel,
- })
-
- if (!currentChat?.id || !workflowId) {
- logger.info('[Context Usage] Skipping: missing chat or workflow', {
- hasChatId: !!currentChat?.id,
- hasWorkflowId: !!workflowId,
- })
- return
- }
-
- const requestPayload = {
- chatId: currentChat.id,
- model: selectedModel,
- workflowId,
- }
-
- logger.info('[Context Usage] Calling API', requestPayload)
-
- // Call the backend API route which proxies to sim-agent
- const response = await fetch('/api/copilot/context-usage', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(requestPayload),
- })
-
- logger.info('[Context Usage] API response', { status: response.status, ok: response.ok })
-
- if (response.ok) {
- const data = await response.json()
- logger.info('[Context Usage] Received data', data)
-
- // Check for either tokensUsed or usage field
- if (
- data.tokensUsed !== undefined ||
- data.usage !== undefined ||
- data.percentage !== undefined
- ) {
- const contextUsage = {
- usage: data.tokensUsed || data.usage || 0,
- percentage: data.percentage || 0,
- model: data.model || selectedModel,
- contextWindow: data.contextWindow || data.context_window || 0,
- when: data.when || 'end',
- estimatedTokens: data.tokensUsed || data.estimated_tokens || data.estimatedTokens,
- }
- set({ contextUsage })
- logger.info('[Context Usage] Updated store', contextUsage)
- } else {
- logger.warn('[Context Usage] No usage data in response', data)
- }
- } else {
- const errorText = await response.text().catch(() => 'Unable to read error')
- logger.warn('[Context Usage] API call failed', {
- status: response.status,
- error: errorText,
- })
- }
- } catch (err) {
- logger.error('[Context Usage] Error fetching:', err)
- }
- },
-
executeIntegrationTool: async (toolCallId: string) => {
const { toolCallsById, workflowId } = get()
const toolCall = toolCallsById[toolCallId]
@@ -3096,6 +3606,72 @@ export const useCopilotStore = create()(
const { autoAllowedTools } = get()
return autoAllowedTools.includes(toolId)
},
+
+ // Message queue actions
+ addToQueue: (message, options) => {
+ const queuedMessage: import('./types').QueuedMessage = {
+ id: crypto.randomUUID(),
+ content: message,
+ fileAttachments: options?.fileAttachments,
+ contexts: options?.contexts,
+ queuedAt: Date.now(),
+ }
+ set({ messageQueue: [...get().messageQueue, queuedMessage] })
+ logger.info('[Queue] Message added to queue', {
+ id: queuedMessage.id,
+ queueLength: get().messageQueue.length,
+ })
+ },
+
+ removeFromQueue: (id) => {
+ set({ messageQueue: get().messageQueue.filter((m) => m.id !== id) })
+ logger.info('[Queue] Message removed from queue', {
+ id,
+ queueLength: get().messageQueue.length,
+ })
+ },
+
+ moveUpInQueue: (id) => {
+ const queue = [...get().messageQueue]
+ const index = queue.findIndex((m) => m.id === id)
+ if (index > 0) {
+ const item = queue[index]
+ queue.splice(index, 1)
+ queue.splice(index - 1, 0, item)
+ set({ messageQueue: queue })
+ logger.info('[Queue] Message moved up in queue', { id, newIndex: index - 1 })
+ }
+ },
+
+ sendNow: async (id) => {
+ const queue = get().messageQueue
+ const message = queue.find((m) => m.id === id)
+ if (!message) return
+
+ // Remove from queue first
+ get().removeFromQueue(id)
+
+ // If currently sending, abort and send this one
+ const { isSendingMessage } = get()
+ if (isSendingMessage) {
+ get().abortMessage()
+ // Wait a tick for abort to complete
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ }
+
+ // Send the message
+ await get().sendMessage(message.content, {
+ stream: true,
+ fileAttachments: message.fileAttachments,
+ contexts: message.contexts,
+ messageId: message.id,
+ })
+ },
+
+ clearQueue: () => {
+ set({ messageQueue: [] })
+ logger.info('[Queue] Queue cleared')
+ },
}))
)
diff --git a/apps/sim/stores/panel/copilot/types.ts b/apps/sim/stores/panel/copilot/types.ts
index f021aa7173..bf9b210d88 100644
--- a/apps/sim/stores/panel/copilot/types.ts
+++ b/apps/sim/stores/panel/copilot/types.ts
@@ -2,12 +2,30 @@ import type { ClientToolCallState, ClientToolDisplay } from '@/lib/copilot/tools
export type ToolState = ClientToolCallState
+/**
+ * Subagent content block for nested thinking/reasoning inside a tool call
+ */
+export interface SubAgentContentBlock {
+ type: 'subagent_text' | 'subagent_tool_call'
+ content?: string
+ toolCall?: CopilotToolCall
+ timestamp: number
+}
+
export interface CopilotToolCall {
id: string
name: string
state: ClientToolCallState
params?: Record
display?: ClientToolDisplay
+ /** Content streamed from a subagent (e.g., debug agent) */
+ subAgentContent?: string
+ /** Tool calls made by the subagent */
+ subAgentToolCalls?: CopilotToolCall[]
+ /** Structured content blocks for subagent (thinking + tool calls in order) */
+ subAgentBlocks?: SubAgentContentBlock[]
+ /** Whether subagent is currently streaming */
+ subAgentStreaming?: boolean
}
export interface MessageFileAttachment {
@@ -42,6 +60,18 @@ export interface CopilotMessage {
errorType?: 'usage_limit' | 'unauthorized' | 'forbidden' | 'rate_limit' | 'upgrade_required'
}
+/**
+ * A message queued for sending while another message is in progress.
+ * Like Cursor's queued message feature.
+ */
+export interface QueuedMessage {
+ id: string
+ content: string
+ fileAttachments?: MessageFileAttachment[]
+ contexts?: ChatContext[]
+ queuedAt: number
+}
+
// Contexts attached to a user message
export type ChatContext =
| { kind: 'past_chat'; chatId: string; label: string }
@@ -131,18 +161,11 @@ export interface CopilotState {
// Per-message metadata captured at send-time for reliable stats
- // Context usage tracking for percentage pill
- contextUsage: {
- usage: number
- percentage: number
- model: string
- contextWindow: number
- when: 'start' | 'end'
- estimatedTokens?: number
- } | null
-
// Auto-allowed integration tools (tools that can run without confirmation)
autoAllowedTools: string[]
+
+ // Message queue for messages sent while another is in progress
+ messageQueue: QueuedMessage[]
}
export interface CopilotActions {
@@ -150,7 +173,6 @@ export interface CopilotActions {
setSelectedModel: (model: CopilotStore['selectedModel']) => Promise
setAgentPrefetch: (prefetch: boolean) => void
setEnabledModels: (models: string[] | null) => void
- fetchContextUsage: () => Promise
setWorkflowId: (workflowId: string | null) => Promise
validateCurrentChat: () => boolean
@@ -220,6 +242,19 @@ export interface CopilotActions {
addAutoAllowedTool: (toolId: string) => Promise
removeAutoAllowedTool: (toolId: string) => Promise
isToolAutoAllowed: (toolId: string) => boolean
+
+ // Message queue actions
+ addToQueue: (
+ message: string,
+ options?: {
+ fileAttachments?: MessageFileAttachment[]
+ contexts?: ChatContext[]
+ }
+ ) => void
+ removeFromQueue: (id: string) => void
+ moveUpInQueue: (id: string) => void
+ sendNow: (id: string) => Promise
+ clearQueue: () => void
}
export type CopilotStore = CopilotState & CopilotActions
diff --git a/apps/sim/stores/panel/store.ts b/apps/sim/stores/panel/store.ts
index dfa2b8fcd9..5e7d0c7401 100644
--- a/apps/sim/stores/panel/store.ts
+++ b/apps/sim/stores/panel/store.ts
@@ -29,6 +29,10 @@ export const usePanelStore = create()(
document.documentElement.removeAttribute('data-panel-active-tab')
}
},
+ isResizing: false,
+ setIsResizing: (isResizing) => {
+ set({ isResizing })
+ },
_hasHydrated: false,
setHasHydrated: (hasHydrated) => {
set({ _hasHydrated: hasHydrated })
diff --git a/apps/sim/stores/panel/types.ts b/apps/sim/stores/panel/types.ts
index f514301a29..dc35074750 100644
--- a/apps/sim/stores/panel/types.ts
+++ b/apps/sim/stores/panel/types.ts
@@ -11,6 +11,10 @@ export interface PanelState {
setPanelWidth: (width: number) => void
activeTab: PanelTab
setActiveTab: (tab: PanelTab) => void
+ /** Whether the panel is currently being resized */
+ isResizing: boolean
+ /** Updates the panel resize state */
+ setIsResizing: (isResizing: boolean) => void
_hasHydrated: boolean
setHasHydrated: (hasHydrated: boolean) => void
}
diff --git a/apps/sim/stores/sidebar/store.ts b/apps/sim/stores/sidebar/store.ts
index a1575cae9a..8af8526f0b 100644
--- a/apps/sim/stores/sidebar/store.ts
+++ b/apps/sim/stores/sidebar/store.ts
@@ -9,6 +9,7 @@ export const useSidebarStore = create()(
workspaceDropdownOpen: false,
sidebarWidth: SIDEBAR_WIDTH.DEFAULT,
isCollapsed: false,
+ isResizing: false,
_hasHydrated: false,
setWorkspaceDropdownOpen: (isOpen) => set({ workspaceDropdownOpen: isOpen }),
setSidebarWidth: (width) => {
@@ -31,6 +32,9 @@ export const useSidebarStore = create()(
document.documentElement.style.setProperty('--sidebar-width', `${currentWidth}px`)
}
},
+ setIsResizing: (isResizing) => {
+ set({ isResizing })
+ },
setHasHydrated: (hasHydrated) => set({ _hasHydrated: hasHydrated }),
}),
{
diff --git a/apps/sim/stores/sidebar/types.ts b/apps/sim/stores/sidebar/types.ts
index f531e56a59..1151ee3741 100644
--- a/apps/sim/stores/sidebar/types.ts
+++ b/apps/sim/stores/sidebar/types.ts
@@ -5,9 +5,13 @@ export interface SidebarState {
workspaceDropdownOpen: boolean
sidebarWidth: number
isCollapsed: boolean
+ /** Whether the sidebar is currently being resized */
+ isResizing: boolean
_hasHydrated: boolean
setWorkspaceDropdownOpen: (isOpen: boolean) => void
setSidebarWidth: (width: number) => void
setIsCollapsed: (isCollapsed: boolean) => void
+ /** Updates the sidebar resize state */
+ setIsResizing: (isResizing: boolean) => void
setHasHydrated: (hasHydrated: boolean) => void
}
diff --git a/apps/sim/stores/workflow-diff/store.ts b/apps/sim/stores/workflow-diff/store.ts
index 5a4080451e..c21247c823 100644
--- a/apps/sim/stores/workflow-diff/store.ts
+++ b/apps/sim/stores/workflow-diff/store.ts
@@ -273,6 +273,7 @@ export const useWorkflowDiffStore = create {})
}
- const toolCallId = await findLatestEditWorkflowToolCallId()
- if (toolCallId) {
- try {
- await getClientTool(toolCallId)?.handleAccept?.()
- } catch (error) {
- logger.warn('Failed to notify tool accept state', { error })
+ findLatestEditWorkflowToolCallId().then((toolCallId) => {
+ if (toolCallId) {
+ getClientTool(toolCallId)
+ ?.handleAccept?.()
+ ?.catch?.((error: Error) => {
+ logger.warn('Failed to notify tool accept state', { error })
+ })
}
- }
+ })
},
rejectChanges: async () => {
@@ -327,27 +329,26 @@ export const useWorkflowDiffStore = create {
+ logger.error('Failed to broadcast reject to other users:', error)
+ })
+
+ // Persist to database in background
+ persistWorkflowStateToServer(baselineWorkflowId, baselineWorkflow).catch((error) => {
+ logger.error('Failed to persist baseline workflow state:', error)
+ })
+
if (_triggerMessageId) {
fetch('/api/copilot/stats', {
method: 'POST',
@@ -374,16 +394,15 @@ export const useWorkflowDiffStore = create {})
}
- const toolCallId = await findLatestEditWorkflowToolCallId()
- if (toolCallId) {
- try {
- await getClientTool(toolCallId)?.handleReject?.()
- } catch (error) {
- logger.warn('Failed to notify tool reject state', { error })
+ findLatestEditWorkflowToolCallId().then((toolCallId) => {
+ if (toolCallId) {
+ getClientTool(toolCallId)
+ ?.handleReject?.()
+ ?.catch?.((error: Error) => {
+ logger.warn('Failed to notify tool reject state', { error })
+ })
}
- }
-
- get().clearDiff({ restoreBaseline: false })
+ })
},
reapplyDiffMarkers: () => {
diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts
index 6ba54ec1fa..33d2c0d9ef 100644
--- a/apps/sim/stores/workflows/registry/store.ts
+++ b/apps/sim/stores/workflows/registry/store.ts
@@ -3,6 +3,7 @@ import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { withOptimisticUpdate } from '@/lib/core/utils/optimistic-update'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
+import { getNextWorkflowColor } from '@/lib/workflows/colors'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { useVariablesStore } from '@/stores/panel/variables/store'
import type {
@@ -11,7 +12,6 @@ import type {
WorkflowMetadata,
WorkflowRegistry,
} from '@/stores/workflows/registry/types'
-import { getNextWorkflowColor } from '@/stores/workflows/registry/utils'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getUniqueBlockName, regenerateBlockIds } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
diff --git a/apps/sim/stores/workflows/registry/utils.ts b/apps/sim/stores/workflows/registry/utils.ts
index e3a91c1a97..8be102e4a6 100644
--- a/apps/sim/stores/workflows/registry/utils.ts
+++ b/apps/sim/stores/workflows/registry/utils.ts
@@ -1,321 +1,410 @@
-// Available workflow colors
-export const WORKFLOW_COLORS = [
- // Blues - vibrant blue tones
- '#3972F6', // Blue (original)
- '#2E5BF5', // Deeper Blue
- '#1E4BF4', // Royal Blue
- '#0D3BF3', // Deep Royal Blue
-
- // Pinks/Magentas - vibrant pink and magenta tones
- '#F639DD', // Pink/Magenta (original)
- '#F529CF', // Deep Magenta
- '#F749E7', // Light Magenta
- '#F419C1', // Hot Pink
-
- // Oranges/Yellows - vibrant orange and yellow tones
- '#F6B539', // Orange/Yellow (original)
- '#F5A529', // Deep Orange
- '#F49519', // Burnt Orange
- '#F38509', // Deep Burnt Orange
-
- // Purples - vibrant purple tones
- '#8139F6', // Purple (original)
- '#7129F5', // Deep Purple
- '#6119F4', // Royal Purple
- '#5109F3', // Deep Royal Purple
-
- // Greens - vibrant green tones
- '#39B54A', // Green (original)
- '#29A53A', // Deep Green
- '#19952A', // Forest Green
- '#09851A', // Deep Forest Green
-
- // Teals/Cyans - vibrant teal and cyan tones
- '#39B5AB', // Teal (original)
- '#29A59B', // Deep Teal
- '#19958B', // Dark Teal
- '#09857B', // Deep Dark Teal
-
- // Reds/Red-Oranges - vibrant red and red-orange tones
- '#F66839', // Red/Orange (original)
- '#F55829', // Deep Red-Orange
- '#F44819', // Burnt Red
- '#F33809', // Deep Burnt Red
-
- // Additional vibrant colors for variety
- // Corals - warm coral tones
- '#F6397A', // Coral
- '#F5296A', // Deep Coral
- '#F7498A', // Light Coral
-
- // Crimsons - deep red tones
- '#DC143C', // Crimson
- '#CC042C', // Deep Crimson
- '#EC243C', // Light Crimson
- '#BC003C', // Dark Crimson
- '#FC343C', // Bright Crimson
-
- // Mint - fresh green tones
- '#00FF7F', // Mint Green
- '#00EF6F', // Deep Mint
- '#00DF5F', // Dark Mint
-
- // Slate - blue-gray tones
- '#6A5ACD', // Slate Blue
- '#5A4ABD', // Deep Slate
- '#4A3AAD', // Dark Slate
-
- // Amber - warm orange-yellow tones
- '#FFBF00', // Amber
- '#EFAF00', // Deep Amber
- '#DF9F00', // Dark Amber
-]
-
-// Generates a random color for a new workflow
-export function getNextWorkflowColor(): string {
- // Simply return a random color from the available colors
- return WORKFLOW_COLORS[Math.floor(Math.random() * WORKFLOW_COLORS.length)]
-}
-
-// Adjectives and nouns for creative workflow names
+// Cosmos-themed adjectives and nouns for creative workflow names (max 9 chars each)
const ADJECTIVES = [
+ // Light & Luminosity
+ 'Radiant',
+ 'Luminous',
'Blazing',
- 'Crystal',
- 'Golden',
- 'Silver',
- 'Mystic',
- 'Cosmic',
- 'Electric',
- 'Frozen',
- 'Burning',
- 'Shining',
- 'Dancing',
- 'Flying',
- 'Roaring',
- 'Whispering',
'Glowing',
- 'Sparkling',
- 'Thunder',
- 'Lightning',
- 'Storm',
- 'Ocean',
- 'Mountain',
- 'Forest',
- 'Desert',
- 'Arctic',
- 'Tropical',
- 'Midnight',
- 'Dawn',
- 'Sunset',
- 'Rainbow',
- 'Diamond',
- 'Ruby',
- 'Emerald',
- 'Sapphire',
- 'Pearl',
- 'Jade',
- 'Amber',
- 'Coral',
- 'Ivory',
- 'Obsidian',
- 'Marble',
- 'Velvet',
- 'Silk',
- 'Satin',
- 'Linen',
- 'Cotton',
- 'Wool',
- 'Cashmere',
- 'Denim',
- 'Neon',
- 'Pastel',
- 'Vibrant',
- 'Muted',
- 'Bold',
- 'Subtle',
'Bright',
- 'Dark',
- 'Ancient',
- 'Modern',
- 'Eternal',
- 'Swift',
- 'Radiant',
- 'Quantum',
+ 'Gleaming',
+ 'Shining',
+ 'Lustrous',
+ 'Flaring',
+ 'Vivid',
+ 'Dazzling',
+ 'Beaming',
+ 'Brilliant',
+ 'Lit',
+ 'Ablaze',
+ // Celestial Descriptors
'Stellar',
+ 'Cosmic',
+ 'Astral',
+ 'Galactic',
+ 'Nebular',
+ 'Orbital',
'Lunar',
'Solar',
+ 'Starlit',
+ 'Heavenly',
'Celestial',
- 'Ethereal',
- 'Phantom',
- 'Shadow',
+ 'Sidereal',
+ 'Planetary',
+ 'Starry',
+ 'Spacial',
+ // Scale & Magnitude
+ 'Infinite',
+ 'Vast',
+ 'Boundless',
+ 'Immense',
+ 'Colossal',
+ 'Titanic',
+ 'Massive',
+ 'Grand',
+ 'Supreme',
+ 'Ultimate',
+ 'Epic',
+ 'Enormous',
+ 'Gigantic',
+ 'Limitless',
+ 'Total',
+ // Temporal
+ 'Eternal',
+ 'Ancient',
+ 'Timeless',
+ 'Enduring',
+ 'Ageless',
+ 'Immortal',
+ 'Primal',
+ 'Nascent',
+ 'First',
+ 'Elder',
+ 'Lasting',
+ 'Undying',
+ 'Perpetual',
+ 'Final',
+ 'Prime',
+ // Movement & Energy
+ 'Sidbuck',
+ 'Swift',
+ 'Drifting',
+ 'Spinning',
+ 'Surging',
+ 'Pulsing',
+ 'Soaring',
+ 'Racing',
+ 'Falling',
+ 'Rising',
+ 'Circling',
+ 'Streaking',
+ 'Hurtling',
+ 'Floating',
+ 'Orbiting',
+ 'Spiraling',
+ // Colors of Space
'Crimson',
'Azure',
'Violet',
- 'Scarlet',
- 'Magenta',
- 'Turquoise',
'Indigo',
- 'Jade',
- 'Noble',
- 'Regal',
- 'Imperial',
- 'Royal',
- 'Supreme',
- 'Prime',
- 'Elite',
- 'Ultra',
- 'Mega',
- 'Hyper',
- 'Super',
- 'Neo',
- 'Cyber',
- 'Digital',
- 'Virtual',
- 'Sonic',
+ 'Amber',
+ 'Sapphire',
+ 'Obsidian',
+ 'Silver',
+ 'Golden',
+ 'Scarlet',
+ 'Cobalt',
+ 'Emerald',
+ 'Ruby',
+ 'Onyx',
+ 'Ivory',
+ // Physical Properties
+ 'Magnetic',
+ 'Quantum',
+ 'Thermal',
+ 'Photonic',
+ 'Ionic',
+ 'Plasma',
+ 'Spectral',
+ 'Charged',
+ 'Polar',
+ 'Dense',
'Atomic',
'Nuclear',
- 'Laser',
- 'Plasma',
- 'Magnetic',
+ 'Electric',
+ 'Kinetic',
+ 'Static',
+ // Atmosphere & Mystery
+ 'Ethereal',
+ 'Mystic',
+ 'Phantom',
+ 'Shadow',
+ 'Silent',
+ 'Distant',
+ 'Hidden',
+ 'Veiled',
+ 'Fading',
+ 'Arcane',
+ 'Cryptic',
+ 'Obscure',
+ 'Dim',
+ 'Dusky',
+ 'Shrouded',
+ // Temperature & State
+ 'Frozen',
+ 'Burning',
+ 'Molten',
+ 'Volatile',
+ 'Icy',
+ 'Fiery',
+ 'Cool',
+ 'Warm',
+ 'Cold',
+ 'Hot',
+ 'Searing',
+ 'Frigid',
+ 'Scalding',
+ 'Chilled',
+ 'Heated',
+ // Power & Force
+ 'Mighty',
+ 'Fierce',
+ 'Raging',
+ 'Wild',
+ 'Serene',
+ 'Tranquil',
+ 'Harmonic',
+ 'Resonant',
+ 'Steady',
+ 'Bold',
+ 'Potent',
+ 'Violent',
+ 'Calm',
+ 'Furious',
+ 'Forceful',
+ // Texture & Form
+ 'Smooth',
+ 'Jagged',
+ 'Fractured',
+ 'Solid',
+ 'Hollow',
+ 'Curved',
+ 'Sharp',
+ 'Fluid',
+ 'Rigid',
+ 'Warped',
+ // Rare & Precious
+ 'Noble',
+ 'Pure',
+ 'Rare',
+ 'Pristine',
+ 'Flawless',
+ 'Unique',
+ 'Exotic',
+ 'Sacred',
+ 'Divine',
+ 'Hallowed',
]
const NOUNS = [
- 'Phoenix',
- 'Dragon',
- 'Eagle',
- 'Wolf',
- 'Lion',
- 'Tiger',
- 'Panther',
- 'Falcon',
- 'Hawk',
- 'Raven',
- 'Swan',
- 'Dove',
- 'Butterfly',
- 'Firefly',
- 'Dragonfly',
- 'Hummingbird',
+ // Stars & Stellar Objects
+ 'Star',
+ 'Sun',
+ 'Pulsar',
+ 'Quasar',
+ 'Magnetar',
+ 'Nova',
+ 'Supernova',
+ 'Hypernova',
+ 'Neutron',
+ 'Dwarf',
+ 'Giant',
+ 'Protostar',
+ 'Blazar',
+ 'Cepheid',
+ 'Binary',
+ // Galaxies & Clusters
'Galaxy',
'Nebula',
+ 'Cluster',
+ 'Void',
+ 'Filament',
+ 'Halo',
+ 'Bulge',
+ 'Spiral',
+ 'Ellipse',
+ 'Arm',
+ 'Disk',
+ 'Shell',
+ 'Remnant',
+ 'Cloud',
+ 'Dust',
+ // Planets & Moons
+ 'Planet',
+ 'Moon',
+ 'World',
+ 'Exoplanet',
+ 'Jovian',
+ 'Titan',
+ 'Europa',
+ 'Io',
+ 'Callisto',
+ 'Ganymede',
+ 'Triton',
+ 'Phobos',
+ 'Deimos',
+ 'Enceladus',
+ 'Charon',
+ // Small Bodies
'Comet',
'Meteor',
- 'Star',
- 'Moon',
- 'Sun',
- 'Planet',
'Asteroid',
- 'Constellation',
- 'Aurora',
+ 'Meteorite',
+ 'Bolide',
+ 'Fireball',
+ 'Iceball',
+ 'Plutino',
+ 'Centaur',
+ 'Trojan',
+ 'Shard',
+ 'Fragment',
+ 'Debris',
+ 'Rock',
+ 'Ice',
+ // Constellations & Myths
+ 'Orion',
+ 'Andromeda',
+ 'Perseus',
+ 'Pegasus',
+ 'Phoenix',
+ 'Draco',
+ 'Cygnus',
+ 'Aquila',
+ 'Lyra',
+ 'Vega',
+ 'Centaurus',
+ 'Hydra',
+ 'Sirius',
+ 'Polaris',
+ 'Altair',
+ // Celestial Phenomena
'Eclipse',
- 'Solstice',
- 'Equinox',
+ 'Aurora',
+ 'Corona',
+ 'Flare',
+ 'Storm',
+ 'Vortex',
+ 'Jet',
+ 'Burst',
+ 'Pulse',
+ 'Wave',
+ 'Ripple',
+ 'Shimmer',
+ 'Glow',
+ 'Flash',
+ 'Spark',
+ // Cosmic Structures
'Horizon',
'Zenith',
- 'Castle',
- 'Tower',
- 'Bridge',
- 'Garden',
- 'Fountain',
- 'Palace',
- 'Temple',
- 'Cathedral',
- 'Lighthouse',
- 'Windmill',
- 'Waterfall',
- 'Canyon',
- 'Valley',
- 'Peak',
- 'Ridge',
- 'Cliff',
- 'Ocean',
- 'River',
- 'Lake',
+ 'Nadir',
+ 'Apex',
+ 'Meridian',
+ 'Equinox',
+ 'Solstice',
+ 'Transit',
+ 'Aphelion',
+ 'Orbit',
+ 'Axis',
+ 'Pole',
+ 'Equator',
+ 'Limb',
+ 'Arc',
+ // Space & Dimensions
+ 'Cosmos',
+ 'Universe',
+ 'Dimension',
+ 'Realm',
+ 'Expanse',
+ 'Infinity',
+ 'Continuum',
+ 'Manifold',
+ 'Abyss',
+ 'Ether',
+ 'Vacuum',
+ 'Space',
+ 'Fabric',
+ 'Plane',
+ 'Domain',
+ // Energy & Particles
+ 'Photon',
+ 'Neutrino',
+ 'Proton',
+ 'Electron',
+ 'Positron',
+ 'Quark',
+ 'Boson',
+ 'Fermion',
+ 'Tachyon',
+ 'Graviton',
+ 'Meson',
+ 'Gluon',
+ 'Lepton',
+ 'Muon',
+ 'Pion',
+ // Regions & Zones
+ 'Sector',
+ 'Quadrant',
+ 'Zone',
+ 'Belt',
+ 'Ring',
+ 'Field',
'Stream',
- 'Pond',
- 'Bay',
- 'Cove',
- 'Harbor',
- 'Island',
- 'Peninsula',
- 'Archipelago',
- 'Atoll',
- 'Reef',
- 'Lagoon',
- 'Fjord',
- 'Delta',
- 'Cake',
- 'Cookie',
- 'Muffin',
- 'Cupcake',
- 'Pie',
- 'Tart',
- 'Brownie',
- 'Donut',
- 'Pancake',
- 'Waffle',
- 'Croissant',
- 'Bagel',
- 'Pretzel',
- 'Biscuit',
- 'Scone',
- 'Crumpet',
- 'Thunder',
- 'Blizzard',
- 'Tornado',
- 'Hurricane',
- 'Tsunami',
- 'Volcano',
- 'Glacier',
- 'Avalanche',
- 'Vortex',
- 'Tempest',
- 'Maelstrom',
- 'Whirlwind',
- 'Cyclone',
- 'Typhoon',
- 'Monsoon',
- 'Anvil',
- 'Hammer',
- 'Forge',
- 'Blade',
- 'Sword',
- 'Shield',
- 'Arrow',
- 'Spear',
- 'Crown',
- 'Throne',
- 'Scepter',
- 'Orb',
- 'Gem',
- 'Crystal',
- 'Prism',
- 'Spectrum',
+ 'Current',
+ 'Wake',
+ 'Region',
+ 'Frontier',
+ 'Border',
+ 'Edge',
+ 'Margin',
+ 'Rim',
+ // Navigation & Discovery
'Beacon',
'Signal',
- 'Pulse',
- 'Wave',
- 'Surge',
- 'Tide',
- 'Current',
- 'Flow',
- 'Circuit',
- 'Node',
+ 'Probe',
+ 'Voyager',
+ 'Pioneer',
+ 'Seeker',
+ 'Wanderer',
+ 'Nomad',
+ 'Drifter',
+ 'Scout',
+ 'Explorer',
+ 'Ranger',
+ 'Surveyor',
+ 'Sentinel',
+ 'Watcher',
+ // Portals & Passages
+ 'Gateway',
+ 'Portal',
+ 'Nexus',
+ 'Bridge',
+ 'Conduit',
+ 'Channel',
+ 'Passage',
+ 'Rift',
+ 'Warp',
+ 'Fold',
+ 'Tunnel',
+ 'Crossing',
+ 'Link',
+ 'Path',
+ 'Route',
+ // Core & Systems
'Core',
'Matrix',
+ 'Lattice',
'Network',
- 'System',
- 'Engine',
+ 'Circuit',
+ 'Array',
'Reactor',
- 'Generator',
- 'Dynamo',
- 'Catalyst',
- 'Nexus',
- 'Portal',
- 'Gateway',
- 'Passage',
- 'Conduit',
- 'Channel',
+ 'Engine',
+ 'Forge',
+ 'Crucible',
+ 'Hub',
+ 'Node',
+ 'Kernel',
+ 'Center',
+ 'Heart',
+ // Cosmic Objects
+ 'Crater',
+ 'Rift',
+ 'Chasm',
+ 'Canyon',
+ 'Peak',
+ 'Ridge',
+ 'Basin',
+ 'Plateau',
+ 'Valley',
+ 'Trench',
]
/**
diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts
index ac0f529870..2caadeea1a 100644
--- a/apps/sim/stores/workflows/utils.ts
+++ b/apps/sim/stores/workflows/utils.ts
@@ -41,6 +41,10 @@ export function getUniqueBlockName(baseName: string, existingBlocks: Record