From 3ed177520a39ca34cccb7e8dadb2d918beba4bd6 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Date: Sat, 10 Jan 2026 11:22:11 -0800 Subject: [PATCH 01/10] fix(router): fix router ports (#2757) * Fix router block * Fix autoconnect edge for router * Fix lint * router block error path decision * improve router prompt --------- Co-authored-by: Vikhyath Mondreti --- apps/docs/content/docs/en/blocks/router.mdx | 10 ++++++-- .../condition-input/condition-input.tsx | 13 +++++++---- .../workflow-block/workflow-block.tsx | 6 +++-- .../[workspaceId]/w/[workflowId]/workflow.tsx | 8 +++++++ apps/sim/blocks/blocks/router.ts | 23 ++++++++++--------- .../handlers/router/router-handler.ts | 16 ++++++++++--- bun.lock | 1 - 7 files changed, 54 insertions(+), 23 deletions(-) diff --git a/apps/docs/content/docs/en/blocks/router.mdx b/apps/docs/content/docs/en/blocks/router.mdx index 44bac918e7..a9d4d008df 100644 --- a/apps/docs/content/docs/en/blocks/router.mdx +++ b/apps/docs/content/docs/en/blocks/router.mdx @@ -2,7 +2,6 @@ title: Router --- -import { Callout } from 'fumadocs-ui/components/callout' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' import { Image } from '@/components/ui/image' @@ -102,11 +101,18 @@ Input (Lead) → Router └── [Self-serve] → Workflow (Automated Onboarding) ``` +## Error Handling + +When the Router cannot determine an appropriate route for the given context, it will route to the **error path** instead of arbitrarily selecting a route. This happens when: + +- The context doesn't clearly match any of the defined route descriptions +- The AI determines that none of the available routes are appropriate + ## Best Practices - **Write clear route descriptions**: Each route description should clearly explain when that route should be selected. Be specific about the criteria. - **Make routes mutually exclusive**: When possible, ensure route descriptions don't overlap to prevent ambiguous routing decisions. -- **Include an error/fallback route**: Add a catch-all route for unexpected inputs that don't match other routes. +- **Connect an error path**: Handle cases where no route matches by connecting an error handler for graceful fallback behavior. - **Use descriptive route titles**: Route titles appear in the workflow canvas, so make them meaningful for readability. - **Test with diverse inputs**: Ensure the Router handles various input types, edge cases, and unexpected content. - **Monitor routing performance**: Review routing decisions regularly and refine route descriptions based on actual usage patterns. diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx index 04558ef969..27232889b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx @@ -654,17 +654,20 @@ export function ConditionInput({ } const removeBlock = (id: string) => { - if (isPreview || disabled || conditionalBlocks.length <= 2) return + if (isPreview || disabled) return + // Condition mode requires at least 2 blocks (if/else), router mode requires at least 1 + const minBlocks = isRouterMode ? 1 : 2 + if (conditionalBlocks.length <= minBlocks) return // Remove any associated edges before removing the block + const handlePrefix = isRouterMode ? `router-${id}` : `condition-${id}` const edgeIdsToRemove = edges - .filter((edge) => edge.sourceHandle?.startsWith(`condition-${id}`)) + .filter((edge) => edge.sourceHandle?.startsWith(handlePrefix)) .map((edge) => edge.id) if (edgeIdsToRemove.length > 0) { batchRemoveEdges(edgeIdsToRemove) } - if (conditionalBlocks.length === 1) return shouldPersistRef.current = true setConditionalBlocks((blocks) => updateBlockTitles(blocks.filter((block) => block.id !== id))) @@ -816,7 +819,9 @@ export function ConditionInput({ - - {/* Reject */} - - - {/* Accept */} - + + ⇧⌘ + + ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx index c4ede34df9..8c4e471b89 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx @@ -11,6 +11,7 @@ import { openCopilotWithMessage, useNotificationStore, } from '@/stores/notifications' +import { useSidebarStore } from '@/stores/sidebar/store' import { useTerminalStore } from '@/stores/terminal' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -19,7 +20,7 @@ const MAX_VISIBLE_NOTIFICATIONS = 4 /** * Notifications display component - * Positioned in the bottom-right workspace area, aligned with terminal and panel spacing + * Positioned in the bottom-left workspace area, reactive to sidebar width and terminal height * Shows both global notifications and workflow-specific notifications */ export const Notifications = memo(function Notifications() { @@ -36,6 +37,7 @@ export const Notifications = memo(function Notifications() { .slice(0, MAX_VISIBLE_NOTIFICATIONS) }, [allNotifications, activeWorkflowId]) const isTerminalResizing = useTerminalStore((state) => state.isResizing) + const isSidebarResizing = useSidebarStore((state) => state.isResizing) /** * Executes a notification action and handles side effects. @@ -103,12 +105,14 @@ export const Notifications = memo(function Notifications() { return null } + const isResizing = isTerminalResizing || isSidebarResizing + return (
{[...visibleNotifications].reverse().map((notification, index, stacked) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx index a647c50f03..e1bfda0baa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block.tsx @@ -3,74 +3,22 @@ import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { ChevronUp } from 'lucide-react' +import CopilotMarkdownRenderer from './markdown-renderer' /** - * Timer update interval in milliseconds + * Max height for thinking content before internal scrolling kicks in */ -const TIMER_UPDATE_INTERVAL = 100 +const THINKING_MAX_HEIGHT = 200 /** - * Milliseconds threshold for displaying as seconds + * Interval for auto-scroll during streaming (ms) */ -const SECONDS_THRESHOLD = 1000 +const SCROLL_INTERVAL = 100 /** - * Props for the ShimmerOverlayText component - */ -interface ShimmerOverlayTextProps { - /** Label text to display */ - label: string - /** Value text to display */ - value: string - /** Whether the shimmer animation is active */ - active?: boolean -} - -/** - * ShimmerOverlayText component for thinking block - * Applies shimmer effect to the "Thought for X.Xs" text during streaming - * - * @param props - Component props - * @returns Text with optional shimmer overlay effect + * Timer update interval in milliseconds */ -function ShimmerOverlayText({ label, value, active = false }: ShimmerOverlayTextProps) { - return ( - - {label} - {value} - {active ? ( - - ) : null} - - - ) -} +const TIMER_UPDATE_INTERVAL = 100 /** * Props for the ThinkingBlock component @@ -80,16 +28,19 @@ interface ThinkingBlockProps { content: string /** Whether the block is currently streaming */ isStreaming?: boolean - /** Persisted duration from content block */ - duration?: number - /** Persisted start time from content block */ - startTime?: number + /** Whether there are more content blocks after this one (e.g., tool calls) */ + hasFollowingContent?: boolean + /** Custom label for the thinking block (e.g., "Thinking", "Exploring"). Defaults to "Thought" */ + label?: string + /** Whether special tags (plan, options) are present - triggers collapse */ + hasSpecialTags?: boolean } /** * ThinkingBlock component displays AI reasoning/thinking process * Shows collapsible content with duration timer * Auto-expands during streaming and collapses when complete + * Auto-collapses when a tool call or other content comes in after it * * @param props - Component props * @returns Thinking block with expandable content and timer @@ -97,112 +48,248 @@ interface ThinkingBlockProps { export function ThinkingBlock({ content, isStreaming = false, - duration: persistedDuration, - startTime: persistedStartTime, + hasFollowingContent = false, + label = 'Thought', + hasSpecialTags = false, }: ThinkingBlockProps) { const [isExpanded, setIsExpanded] = useState(false) - const [duration, setDuration] = useState(persistedDuration ?? 0) + const [duration, setDuration] = useState(0) + const [userHasScrolledAway, setUserHasScrolledAway] = useState(false) const userCollapsedRef = useRef(false) - const startTimeRef = useRef(persistedStartTime ?? Date.now()) - - /** - * Updates start time reference when persisted start time changes - */ - useEffect(() => { - if (typeof persistedStartTime === 'number') { - startTimeRef.current = persistedStartTime - } - }, [persistedStartTime]) + const scrollContainerRef = useRef(null) + const startTimeRef = useRef(Date.now()) + const lastScrollTopRef = useRef(0) + const programmaticScrollRef = useRef(false) /** * Auto-expands block when streaming with content - * Auto-collapses when streaming ends + * Auto-collapses when streaming ends OR when following content arrives */ useEffect(() => { - if (!isStreaming) { + // Collapse if streaming ended or if there's following content (like a tool call) + if (!isStreaming || hasFollowingContent) { setIsExpanded(false) userCollapsedRef.current = false + setUserHasScrolledAway(false) return } if (!userCollapsedRef.current && content && content.trim().length > 0) { setIsExpanded(true) } - }, [isStreaming, content]) + }, [isStreaming, content, hasFollowingContent]) - /** - * Updates duration timer during streaming - * Uses persisted duration when available - */ + // Reset start time when streaming begins useEffect(() => { - if (typeof persistedDuration === 'number') { - setDuration(persistedDuration) - return + if (isStreaming && !hasFollowingContent) { + startTimeRef.current = Date.now() + setDuration(0) + setUserHasScrolledAway(false) } + }, [isStreaming, hasFollowingContent]) + + // Update duration timer during streaming (stop when following content arrives) + useEffect(() => { + // Stop timer if not streaming or if there's following content (thinking is done) + if (!isStreaming || hasFollowingContent) return + + const interval = setInterval(() => { + setDuration(Date.now() - startTimeRef.current) + }, TIMER_UPDATE_INTERVAL) + + return () => clearInterval(interval) + }, [isStreaming, hasFollowingContent]) - if (isStreaming) { - const interval = setInterval(() => { - setDuration(Date.now() - startTimeRef.current) - }, TIMER_UPDATE_INTERVAL) - return () => clearInterval(interval) + // Handle scroll events to detect user scrolling away + useEffect(() => { + const container = scrollContainerRef.current + if (!container || !isExpanded) return + + const handleScroll = () => { + if (programmaticScrollRef.current) return + + const { scrollTop, scrollHeight, clientHeight } = container + const distanceFromBottom = scrollHeight - scrollTop - clientHeight + const isNearBottom = distanceFromBottom <= 20 + + const delta = scrollTop - lastScrollTopRef.current + const movedUp = delta < -2 + + if (movedUp && !isNearBottom) { + setUserHasScrolledAway(true) + } + + // Re-stick if user scrolls back to bottom + if (userHasScrolledAway && isNearBottom) { + setUserHasScrolledAway(false) + } + + lastScrollTopRef.current = scrollTop } - setDuration(Date.now() - startTimeRef.current) - }, [isStreaming, persistedDuration]) + container.addEventListener('scroll', handleScroll, { passive: true }) + lastScrollTopRef.current = container.scrollTop + + return () => container.removeEventListener('scroll', handleScroll) + }, [isExpanded, userHasScrolledAway]) + + // Smart auto-scroll: only scroll if user hasn't scrolled away + useEffect(() => { + if (!isStreaming || !isExpanded || userHasScrolledAway) return + + const intervalId = window.setInterval(() => { + const container = scrollContainerRef.current + if (!container) return + + const { scrollTop, scrollHeight, clientHeight } = container + const distanceFromBottom = scrollHeight - scrollTop - clientHeight + const isNearBottom = distanceFromBottom <= 50 + + if (isNearBottom) { + programmaticScrollRef.current = true + container.scrollTo({ + top: container.scrollHeight, + behavior: 'smooth', + }) + window.setTimeout(() => { + programmaticScrollRef.current = false + }, 150) + } + }, SCROLL_INTERVAL) + + return () => window.clearInterval(intervalId) + }, [isStreaming, isExpanded, userHasScrolledAway]) /** - * Formats duration in milliseconds to human-readable format - * @param ms - Duration in milliseconds - * @returns Formatted string (e.g., "150ms" or "2.5s") + * Formats duration in milliseconds to seconds + * Always shows seconds, rounded to nearest whole second, minimum 1s */ const formatDuration = (ms: number) => { - if (ms < SECONDS_THRESHOLD) { - return `${ms}ms` - } - const seconds = (ms / SECONDS_THRESHOLD).toFixed(1) + const seconds = Math.max(1, Math.round(ms / 1000)) return `${seconds}s` } const hasContent = content && content.trim().length > 0 + // Thinking is "done" when streaming ends OR when there's following content (like a tool call) OR when special tags appear + const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags + const durationText = `${label} for ${formatDuration(duration)}` + // Convert past tense label to present tense for streaming (e.g., "Thought" → "Thinking") + const getStreamingLabel = (lbl: string) => { + if (lbl === 'Thought') return 'Thinking' + if (lbl.endsWith('ed')) return `${lbl.slice(0, -2)}ing` + return lbl + } + const streamingLabel = getStreamingLabel(label) + // During streaming: show header with shimmer effect + expanded content + if (!isThinkingDone) { + return ( +
+ {/* Define shimmer keyframes */} + + + +
+ {/* Render markdown during streaming with thinking text styling */} +
+ + +
+
+
+ ) + } + + // After done: show collapsible header with duration return ( -
+
- {isExpanded && ( -
-
-            {content}
-            {isStreaming && (
-              
-            )}
-          
+
+ {/* Use markdown renderer for completed content */} +
+
- )} +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx index b63bb21d4c..2ff11db336 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx @@ -1,9 +1,13 @@ 'use client' -import { type FC, memo, useMemo, useState } from 'react' -import { Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from 'lucide-react' +import { type FC, memo, useCallback, useMemo, useState } from 'react' +import { RotateCcw } from 'lucide-react' import { Button } from '@/components/emcn' -import { ToolCall } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components' +import { + OptionsSelector, + parseSpecialTags, + ToolCall, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components' import { FileAttachmentDisplay, SmoothStreamingText, @@ -15,8 +19,6 @@ import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId import { useCheckpointManagement, useMessageEditing, - useMessageFeedback, - useSuccessTimers, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks' import { UserInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input' import type { CopilotMessage as CopilotMessageType } from '@/stores/panel' @@ -40,6 +42,8 @@ interface CopilotMessageProps { onEditModeChange?: (isEditing: boolean, cancelCallback?: () => void) => void /** Callback when revert mode changes */ onRevertModeChange?: (isReverting: boolean) => void + /** Whether this is the last message in the conversation */ + isLastMessage?: boolean } /** @@ -59,6 +63,7 @@ const CopilotMessage: FC = memo( checkpointCount = 0, onEditModeChange, onRevertModeChange, + isLastMessage = false, }) => { const isUser = message.role === 'user' const isAssistant = message.role === 'assistant' @@ -88,22 +93,6 @@ const CopilotMessage: FC = memo( // UI state const [isHoveringMessage, setIsHoveringMessage] = useState(false) - // Success timers hook - const { - showCopySuccess, - showUpvoteSuccess, - showDownvoteSuccess, - handleCopy, - setShowUpvoteSuccess, - setShowDownvoteSuccess, - } = useSuccessTimers() - - // Message feedback hook - const { handleUpvote, handleDownvote } = useMessageFeedback(message, messages, { - setShowUpvoteSuccess, - setShowDownvoteSuccess, - }) - // Checkpoint management hook const { showRestoreConfirmation, @@ -153,14 +142,6 @@ const CopilotMessage: FC = memo( pendingEditRef, }) - /** - * Handles copying message content to clipboard - * Uses the success timer hook to show feedback - */ - const handleCopyContent = () => { - handleCopy(message.content) - } - // Get clean text content with double newline parsing const cleanTextContent = useMemo(() => { if (!message.content) return '' @@ -169,6 +150,42 @@ const CopilotMessage: FC = memo( return message.content.replace(/\n{3,}/g, '\n\n') }, [message.content]) + // Parse special tags from message content (options, plan) + // Parse during streaming to show options/plan as they stream in + const parsedTags = useMemo(() => { + if (isUser) return null + + // Try message.content first + if (message.content) { + const parsed = parseSpecialTags(message.content) + if (parsed.options || parsed.plan) return parsed + } + + // During streaming, check content blocks for options/plan + if (isStreaming && message.contentBlocks && message.contentBlocks.length > 0) { + for (const block of message.contentBlocks) { + if (block.type === 'text' && block.content) { + const parsed = parseSpecialTags(block.content) + if (parsed.options || parsed.plan) return parsed + } + } + } + + return message.content ? parseSpecialTags(message.content) : null + }, [message.content, message.contentBlocks, isUser, isStreaming]) + + // Get sendMessage from store for continuation actions + const sendMessage = useCopilotStore((s) => s.sendMessage) + + // Handler for option selection + const handleOptionSelect = useCallback( + (_optionKey: string, optionText: string) => { + // Send the option text as a message + sendMessage(optionText) + }, + [sendMessage] + ) + // Memoize content blocks to avoid re-rendering unchanged blocks const memoizedContentBlocks = useMemo(() => { if (!message.contentBlocks || message.contentBlocks.length === 0) { @@ -179,8 +196,12 @@ const CopilotMessage: FC = memo( if (block.type === 'text') { const isLastTextBlock = index === message.contentBlocks!.length - 1 && block.type === 'text' - // Clean content for this text block - const cleanBlockContent = block.content.replace(/\n{3,}/g, '\n\n') + // Always strip special tags from display (they're rendered separately as options/plan) + const parsed = parseSpecialTags(block.content) + const cleanBlockContent = parsed.cleanContent.replace(/\n{3,}/g, '\n\n') + + // Skip if no content after stripping tags + if (!cleanBlockContent.trim()) return null // Use smooth streaming for the last text block if we're streaming const shouldUseSmoothing = isStreaming && isLastTextBlock @@ -201,19 +222,14 @@ const CopilotMessage: FC = memo( ) } if (block.type === 'thinking') { - const isLastBlock = index === message.contentBlocks!.length - 1 - // Consider the thinking block streaming if the overall message is streaming - // and the block has not been finalized with a duration yet. This avoids - // freezing the timer when new blocks are appended after the thinking block. - const isStreamingThinking = isStreaming && (block as any).duration == null - + // Check if there are any blocks after this one (tool calls, text, etc.) + const hasFollowingContent = index < message.contentBlocks!.length - 1 return (
) @@ -467,53 +483,11 @@ const CopilotMessage: FC = memo( )} {message.errorType === 'usage_limit' && ( -
+
)} - {/* Action buttons for completed messages */} - {!isStreaming && cleanTextContent && ( -
- - - -
- )} - {/* Citations if available */} {message.citations && message.citations.length > 0 && (
@@ -533,6 +507,20 @@ const CopilotMessage: FC = memo(
)} + + {/* Options selector when agent presents choices - streams in but disabled until complete */} + {/* Disabled for previous messages (not isLastMessage) so only the latest options are interactive */} + {parsedTags?.options && Object.keys(parsedTags.options).length > 0 && ( + + )}
) @@ -570,6 +558,11 @@ const CopilotMessage: FC = memo( return false } + // If isLastMessage changed, re-render (for options visibility) + if (prevProps.isLastMessage !== nextProps.isLastMessage) { + return false + } + // For streaming messages, check if content actually changed if (nextProps.isStreaming) { const prevBlocks = prevMessage.contentBlocks || [] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/index.ts index 28de03e6cb..3ac19aac2d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/index.ts @@ -1,5 +1,6 @@ export * from './copilot-message/copilot-message' export * from './plan-mode-section/plan-mode-section' +export * from './queued-messages/queued-messages' export * from './todo-list/todo-list' export * from './tool-call/tool-call' export * from './user-input/user-input' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/queued-messages/queued-messages.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/queued-messages/queued-messages.tsx new file mode 100644 index 0000000000..2ed3200345 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/queued-messages/queued-messages.tsx @@ -0,0 +1,102 @@ +'use client' + +import { useCallback, useState } from 'react' +import { ArrowUp, ChevronDown, ChevronRight, Trash2 } from 'lucide-react' +import { useCopilotStore } from '@/stores/panel/copilot/store' + +/** + * Displays queued messages in a Cursor-style collapsible panel above the input box. + */ +export function QueuedMessages() { + const messageQueue = useCopilotStore((s) => s.messageQueue) + const removeFromQueue = useCopilotStore((s) => s.removeFromQueue) + const sendNow = useCopilotStore((s) => s.sendNow) + + const [isExpanded, setIsExpanded] = useState(true) + + const handleRemove = useCallback( + (id: string) => { + removeFromQueue(id) + }, + [removeFromQueue] + ) + + const handleSendNow = useCallback( + async (id: string) => { + await sendNow(id) + }, + [sendNow] + ) + + if (messageQueue.length === 0) return null + + return ( +
+ {/* Header */} + + + {/* Message list */} + {isExpanded && ( +
+ {messageQueue.map((msg) => ( +
+ {/* Radio indicator */} +
+
+
+ + {/* Message content */} +
+

{msg.content}

+
+ + {/* Actions - always visible */} +
+ + +
+
+ ))} +
+ )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx index 936080fad5..72dc6d6bcb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx @@ -1,13 +1,378 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import clsx from 'clsx' +import { ChevronUp, LayoutList } from 'lucide-react' import { Button, Code } from '@/components/emcn' import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool' import { getClientTool } from '@/lib/copilot/tools/client/manager' import { getRegisteredTools } from '@/lib/copilot/tools/client/registry' +// Initialize all tool UI configs +import '@/lib/copilot/tools/client/init-tool-configs' +import { + getSubagentLabels as getSubagentLabelsFromConfig, + getToolUIConfig, + hasInterrupt as hasInterruptFromConfig, + isSpecialTool as isSpecialToolFromConfig, +} from '@/lib/copilot/tools/client/ui-config' +import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer' +import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming' +import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block' +import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' +import { getBlock } from '@/blocks/registry' import type { CopilotToolCall } from '@/stores/panel' import { useCopilotStore } from '@/stores/panel' import { CLASS_TOOL_METADATA } from '@/stores/panel/copilot/store' +import type { SubAgentContentBlock } from '@/stores/panel/copilot/types' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' + +/** + * Parse special tags from content + */ +/** + * Plan step can be either a string or an object with title and plan + */ +type PlanStep = string | { title: string; plan?: string } + +/** + * Option can be either a string or an object with title and description + */ +type OptionItem = string | { title: string; description?: string } + +interface ParsedTags { + plan?: Record + planComplete?: boolean + options?: Record + optionsComplete?: boolean + cleanContent: string +} + +/** + * Try to parse partial JSON for streaming options. + * Attempts to extract complete key-value pairs from incomplete JSON. + */ +function parsePartialOptionsJson(jsonStr: string): Record | null { + // Try parsing as-is first (might be complete) + try { + return JSON.parse(jsonStr) + } catch { + // Continue to partial parsing + } + + // Try to extract complete key-value pairs from partial JSON + // Match patterns like "1": "some text" or "1": {"title": "text"} + const result: Record = {} + // Match complete string values: "key": "value" + const stringPattern = /"(\d+)":\s*"([^"]*?)"/g + let match + while ((match = stringPattern.exec(jsonStr)) !== null) { + result[match[1]] = match[2] + } + + // Match complete object values: "key": {"title": "value"} + const objectPattern = /"(\d+)":\s*\{[^}]*"title":\s*"([^"]*)"[^}]*\}/g + while ((match = objectPattern.exec(jsonStr)) !== null) { + result[match[1]] = { title: match[2] } + } + + return Object.keys(result).length > 0 ? result : null +} + +/** + * Try to parse partial JSON for streaming plan steps. + * Attempts to extract complete key-value pairs from incomplete JSON. + */ +function parsePartialPlanJson(jsonStr: string): Record | null { + // Try parsing as-is first (might be complete) + try { + return JSON.parse(jsonStr) + } catch { + // Continue to partial parsing + } + + // Try to extract complete key-value pairs from partial JSON + // Match patterns like "1": "step text" or "1": {"title": "text", "plan": "..."} + const result: Record = {} + + // Match complete string values: "key": "value" + const stringPattern = /"(\d+)":\s*"((?:[^"\\]|\\.)*)"/g + let match + while ((match = stringPattern.exec(jsonStr)) !== null) { + result[match[1]] = match[2].replace(/\\"/g, '"').replace(/\\n/g, '\n') + } + + // Match complete object values: "key": {"title": "text"} + // Use a more robust pattern that handles nested content + const objectPattern = /"(\d+)":\s*\{[^{}]*"title":\s*"((?:[^"\\]|\\.)*)"/g + while ((match = objectPattern.exec(jsonStr)) !== null) { + result[match[1]] = { title: match[2].replace(/\\"/g, '"').replace(/\\n/g, '\n') } + } + + return Object.keys(result).length > 0 ? result : null +} + +/** + * Parse and tags from content + */ +export function parseSpecialTags(content: string): ParsedTags { + const result: ParsedTags = { cleanContent: content } + + // Parse tag - check for complete tag first + const planMatch = content.match(/([\s\S]*?)<\/plan>/i) + if (planMatch) { + try { + result.plan = JSON.parse(planMatch[1]) + result.planComplete = true + result.cleanContent = result.cleanContent.replace(planMatch[0], '').trim() + } catch { + // Invalid JSON, ignore + } + } else { + // Check for streaming/incomplete plan tag + const streamingPlanMatch = content.match(/([\s\S]*)$/i) + if (streamingPlanMatch) { + const partialPlan = parsePartialPlanJson(streamingPlanMatch[1]) + if (partialPlan) { + result.plan = partialPlan + result.planComplete = false + } + // Strip the incomplete tag from clean content + result.cleanContent = result.cleanContent.replace(streamingPlanMatch[0], '').trim() + } + } + + // Parse tag - check for complete tag first + const optionsMatch = content.match(/([\s\S]*?)<\/options>/i) + if (optionsMatch) { + try { + result.options = JSON.parse(optionsMatch[1]) + result.optionsComplete = true + result.cleanContent = result.cleanContent.replace(optionsMatch[0], '').trim() + } catch { + // Invalid JSON, ignore + } + } else { + // Check for streaming/incomplete options tag + const streamingOptionsMatch = content.match(/([\s\S]*)$/i) + if (streamingOptionsMatch) { + const partialOptions = parsePartialOptionsJson(streamingOptionsMatch[1]) + if (partialOptions) { + result.options = partialOptions + result.optionsComplete = false + } + // Strip the incomplete tag from clean content + result.cleanContent = result.cleanContent.replace(streamingOptionsMatch[0], '').trim() + } + } + + // Strip partial opening tags like " + /** When true, uses smooth streaming animation for step titles */ + streaming?: boolean +}) { + const sortedSteps = useMemo(() => { + return Object.entries(steps) + .sort(([a], [b]) => { + const numA = Number.parseInt(a, 10) + const numB = Number.parseInt(b, 10) + if (!Number.isNaN(numA) && !Number.isNaN(numB)) return numA - numB + return a.localeCompare(b) + }) + .map(([num, step]) => { + // Extract title from step - handle both string and object formats + const title = typeof step === 'string' ? step : step.title + return [num, title] as const + }) + }, [steps]) + + if (sortedSteps.length === 0) return null + + return ( +
+
+ + To-dos + + {sortedSteps.length} + +
+
+ {sortedSteps.map(([num, title], index) => { + const isLastStep = index === sortedSteps.length - 1 + return ( +
+ + {index + 1}. + +
+ {streaming && isLastStep ? ( + + ) : ( + + )} +
+
+ ) + })} +
+
+ ) +} + +/** + * OptionsSelector component renders selectable options from the agent + * Supports keyboard navigation (arrow up/down, enter) and click selection + * After selection, shows the chosen option highlighted and others struck through + */ +export function OptionsSelector({ + options, + onSelect, + disabled = false, + enableKeyboardNav = false, + streaming = false, +}: { + options: Record + onSelect: (optionKey: string, optionText: string) => void + disabled?: boolean + /** Only enable keyboard navigation for the active options (last message) */ + enableKeyboardNav?: boolean + /** When true, looks enabled but interaction is disabled (for streaming state) */ + streaming?: boolean +}) { + const isInteractionDisabled = disabled || streaming + const sortedOptions = useMemo(() => { + return Object.entries(options) + .sort(([a], [b]) => { + const numA = Number.parseInt(a, 10) + const numB = Number.parseInt(b, 10) + if (!Number.isNaN(numA) && !Number.isNaN(numB)) return numA - numB + return a.localeCompare(b) + }) + .map(([key, option]) => { + const title = typeof option === 'string' ? option : option.title + const description = typeof option === 'string' ? undefined : option.description + return { key, title, description } + }) + }, [options]) + + const [hoveredIndex, setHoveredIndex] = useState(0) + const [chosenKey, setChosenKey] = useState(null) + const containerRef = useRef(null) + + const isLocked = chosenKey !== null + + // Handle keyboard navigation - only for the active options selector + useEffect(() => { + if (isInteractionDisabled || !enableKeyboardNav || isLocked) return + + const handleKeyDown = (e: KeyboardEvent) => { + // Only handle if the container or document body is focused (not when typing in input) + const activeElement = document.activeElement + const isInputFocused = + activeElement?.tagName === 'INPUT' || + activeElement?.tagName === 'TEXTAREA' || + activeElement?.getAttribute('contenteditable') === 'true' + + if (isInputFocused) return + + if (e.key === 'ArrowDown') { + e.preventDefault() + setHoveredIndex((prev) => Math.min(prev + 1, sortedOptions.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setHoveredIndex((prev) => Math.max(prev - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + const selected = sortedOptions[hoveredIndex] + if (selected) { + setChosenKey(selected.key) + onSelect(selected.key, selected.title) + } + } else if (/^[1-9]$/.test(e.key)) { + // Number keys select that option directly + const optionIndex = sortedOptions.findIndex((opt) => opt.key === e.key) + if (optionIndex !== -1) { + e.preventDefault() + const selected = sortedOptions[optionIndex] + setChosenKey(selected.key) + onSelect(selected.key, selected.title) + } + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isInteractionDisabled, enableKeyboardNav, isLocked, sortedOptions, hoveredIndex, onSelect]) + + if (sortedOptions.length === 0) return null + + return ( +
+ {sortedOptions.map((option, index) => { + const isHovered = index === hoveredIndex && !isLocked + const isChosen = option.key === chosenKey + const isRejected = isLocked && !isChosen + + return ( +
{ + if (!isInteractionDisabled && !isLocked) { + setChosenKey(option.key) + onSelect(option.key, option.title) + } + }} + onMouseEnter={() => { + if (!isLocked && !streaming) setHoveredIndex(index) + }} + className={clsx( + 'group flex cursor-pointer items-start gap-2 rounded-[6px] p-1', + 'hover:bg-[var(--surface-4)]', + disabled && !isChosen && 'cursor-not-allowed opacity-50', + streaming && 'pointer-events-none', + isLocked && 'cursor-default', + isHovered && !streaming && 'is-hovered bg-[var(--surface-4)]' + )} + > + + + + {streaming ? ( + + ) : ( + + )} + +
+ ) + })} +
+ ) +} interface ToolCallProps { toolCall?: CopilotToolCall @@ -67,6 +432,7 @@ const ACTION_VERBS = [ 'Planning', 'Planned', 'Preparing', + 'Prepared', 'Failed', 'Aborted', 'Skipped', @@ -104,6 +470,30 @@ const ACTION_VERBS = [ 'Sleeping', 'Slept', 'Resumed', + 'Connecting', + 'Connected', + 'Disconnecting', + 'Disconnected', + 'Loading', + 'Loaded', + 'Saving', + 'Saved', + 'Updating', + 'Updated', + 'Deleting', + 'Deleted', + 'Sending', + 'Sent', + 'Receiving', + 'Received', + 'Completing', + 'Completed', + 'Interrupting', + 'Interrupted', + 'Accessing', + 'Accessed', + 'Managing', + 'Managed', ] as const /** @@ -124,122 +514,1080 @@ function splitActionVerb(text: string): [string | null, string] { } } } - return [null, text] -} + return [null, text] +} + +/** + * Renders text with a subtle white shimmer overlay when active, creating a skeleton-like + * loading effect that passes over the existing words without replacing them. + * For special tool calls, uses a gradient color. For normal tools, highlights action verbs + * in a lighter color with the rest in default gray. + */ +function ShimmerOverlayText({ + text, + active = false, + className, + isSpecial = false, +}: ShimmerOverlayTextProps) { + const [actionVerb, remainder] = splitActionVerb(text) + + // Special tools: use tertiary-2 color for entire text with shimmer + if (isSpecial) { + return ( + + {text} + {active ? ( + + ) : null} + + + ) + } + + // Normal tools: two-tone rendering - action verb darker, noun lighter + // Light mode: primary (#2d2d2d) vs muted (#737373) for good contrast + // Dark mode: tertiary (#b3b3b3) vs muted (#787878) for good contrast + return ( + + {actionVerb ? ( + <> + + {actionVerb} + + {remainder} + + ) : ( + {text} + )} + {active ? ( + + ) : null} + + + ) +} + +/** + * SubAgentToolCall renders a nested tool call from a subagent in a muted/thinking style. + */ +function SubAgentToolCall({ toolCall: toolCallProp }: { toolCall: CopilotToolCall }) { + // Get live toolCall from store to ensure we have the latest state and params + const liveToolCall = useCopilotStore((s) => + toolCallProp.id ? s.toolCallsById[toolCallProp.id] : undefined + ) + const toolCall = liveToolCall || toolCallProp + + const displayName = getDisplayNameForSubAgent(toolCall) + + const isLoading = + toolCall.state === ClientToolCallState.generating || + toolCall.state === ClientToolCallState.pending || + toolCall.state === ClientToolCallState.executing + + const showButtons = shouldShowRunSkipButtons(toolCall) + const isSpecial = isSpecialToolCall(toolCall) + + // Get params for table rendering + const params = + (toolCall as any).parameters || (toolCall as any).input || (toolCall as any).params || {} + + // Render table for tools that support it + const renderSubAgentTable = () => { + if (toolCall.name === 'set_environment_variables') { + const variables = params.variables || params.env_vars || {} + const entries = Array.isArray(variables) + ? variables.map((v: any, i: number) => [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, (val as any).value] + } + return [key, val] + }) + if (entries.length === 0) return null + return ( +
+ + + + + + + + + {entries.map((entry) => { + const [key, value] = entry as [string, any] + return ( + + + + + ) + })} + +
+ Variable + + Value +
+ + {key} + + + + {String(value)} + +
+
+ ) + } + + if (toolCall.name === 'set_global_workflow_variables') { + const ops = Array.isArray(params.operations) ? (params.operations as any[]) : [] + if (ops.length === 0) return null + return ( +
+
+
+ Name +
+
+ Type +
+
+ Value +
+
+
+ {ops.map((op, idx) => ( +
+
+ + {String(op.name || '')} + +
+
+ + {String(op.type || '')} + +
+
+ + {op.value !== undefined ? String(op.value) : '—'} + +
+
+ ))} +
+
+ ) + } + + if (toolCall.name === 'run_workflow') { + 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 : {} + const inputEntries = Object.entries(safeInputs) + if (inputEntries.length === 0) return null + return ( +
+ + + + + + + + + {inputEntries.map(([key, value]) => ( + + + + + ))} + +
+ Input + + Value +
+ + {key} + + + + {String(value)} + +
+
+ ) + } + + return null + } + + // For edit_workflow, only show the WorkflowEditSummary component (replaces text display) + const isEditWorkflow = toolCall.name === 'edit_workflow' + const hasOperations = Array.isArray(params.operations) && params.operations.length > 0 + + return ( +
+ {/* Hide text display for edit_workflow when we have operations to show in summary */} + {!(isEditWorkflow && hasOperations) && ( + + )} + {renderSubAgentTable()} + {/* WorkflowEditSummary is rendered outside SubAgentContent for edit subagent */} + {showButtons && } +
+ ) +} + +/** + * Get display name for subagent tool calls + */ +function getDisplayNameForSubAgent(toolCall: CopilotToolCall): string { + const fromStore = toolCall.display?.text + if (fromStore) return fromStore + + const stateVerb = getStateVerb(toolCall.state) + const formattedName = formatToolName(toolCall.name) + return `${stateVerb} ${formattedName}` +} + +/** + * Max height for subagent content before internal scrolling kicks in + */ +const SUBAGENT_MAX_HEIGHT = 200 + +/** + * Interval for auto-scroll during streaming (ms) + */ +const SUBAGENT_SCROLL_INTERVAL = 100 + +/** + * Get the outer collapse header label for completed subagent tools. + * Uses the tool's UI config. + */ +function getSubagentCompletionLabel(toolName: string): string { + const labels = getSubagentLabelsFromConfig(toolName, false) + return labels?.completed ?? 'Thought' +} + +/** + * Get display labels for subagent tools. + * Uses the tool's UI config. + */ +function getSubagentLabels(toolName: string, isStreaming: boolean): string { + const labels = getSubagentLabelsFromConfig(toolName, isStreaming) + if (labels) { + return isStreaming ? labels.streaming : labels.completed + } + return isStreaming ? 'Processing' : 'Processed' +} + +/** + * SubAgentContent renders the streamed content and tool calls from a subagent + * with thinking-style styling (same as ThinkingBlock). + * Auto-collapses when streaming ends and has internal scrolling for long content. + */ +function SubAgentContent({ + blocks, + isStreaming = false, + toolName = 'debug', +}: { + blocks?: SubAgentContentBlock[] + isStreaming?: boolean + toolName?: string +}) { + const [isExpanded, setIsExpanded] = useState(false) + const [userHasScrolledAway, setUserHasScrolledAway] = useState(false) + const userCollapsedRef = useRef(false) + const scrollContainerRef = useRef(null) + const lastScrollTopRef = useRef(0) + const programmaticScrollRef = useRef(false) + + // Check if there are any tool calls (which means thinking should close) + const hasToolCalls = useMemo(() => { + if (!blocks) return false + return blocks.some((b) => b.type === 'subagent_tool_call' && b.toolCall) + }, [blocks]) + + // Auto-expand when streaming with content, auto-collapse when done or when tool call comes in + useEffect(() => { + if (!isStreaming || hasToolCalls) { + setIsExpanded(false) + userCollapsedRef.current = false + setUserHasScrolledAway(false) + return + } + + if (!userCollapsedRef.current && blocks && blocks.length > 0) { + setIsExpanded(true) + } + }, [isStreaming, blocks, hasToolCalls]) + + // Handle scroll events to detect user scrolling away + useEffect(() => { + const container = scrollContainerRef.current + if (!container || !isExpanded) return + + const handleScroll = () => { + if (programmaticScrollRef.current) return + + const { scrollTop, scrollHeight, clientHeight } = container + const distanceFromBottom = scrollHeight - scrollTop - clientHeight + const isNearBottom = distanceFromBottom <= 20 + + const delta = scrollTop - lastScrollTopRef.current + const movedUp = delta < -2 + + if (movedUp && !isNearBottom) { + setUserHasScrolledAway(true) + } + + // Re-stick if user scrolls back to bottom + if (userHasScrolledAway && isNearBottom) { + setUserHasScrolledAway(false) + } + + lastScrollTopRef.current = scrollTop + } + + container.addEventListener('scroll', handleScroll, { passive: true }) + lastScrollTopRef.current = container.scrollTop + + return () => container.removeEventListener('scroll', handleScroll) + }, [isExpanded, userHasScrolledAway]) + + // Smart auto-scroll: only scroll if user hasn't scrolled away + useEffect(() => { + if (!isStreaming || !isExpanded || userHasScrolledAway) return + + const intervalId = window.setInterval(() => { + const container = scrollContainerRef.current + if (!container) return + + const { scrollTop, scrollHeight, clientHeight } = container + const distanceFromBottom = scrollHeight - scrollTop - clientHeight + const isNearBottom = distanceFromBottom <= 50 + + if (isNearBottom) { + programmaticScrollRef.current = true + container.scrollTo({ + top: container.scrollHeight, + behavior: 'smooth', + }) + window.setTimeout(() => { + programmaticScrollRef.current = false + }, 150) + } + }, SUBAGENT_SCROLL_INTERVAL) + + return () => window.clearInterval(intervalId) + }, [isStreaming, isExpanded, userHasScrolledAway]) + + if (!blocks || blocks.length === 0) return null + + const hasContent = blocks.length > 0 + // Show "done" label when streaming ends OR when tool calls are present + const isThinkingDone = !isStreaming || hasToolCalls + const label = getSubagentLabels(toolName, !isThinkingDone) + + return ( +
+ {/* Define shimmer keyframes */} + {!isThinkingDone && ( + + )} + + +
+ {blocks.map((block, index) => { + if (block.type === 'subagent_text' && block.content) { + const isLastBlock = index === blocks.length - 1 + // Strip special tags from display (they're rendered separately) + const parsed = parseSpecialTags(block.content) + const displayContent = parsed.cleanContent + if (!displayContent) return null + return ( +
+                {displayContent}
+                {!isThinkingDone && isLastBlock && (
+                  
+                )}
+              
+ ) + } + + // All tool calls are rendered at top level, skip here + return null + })} +
+ + {/* Render PlanSteps for plan subagent when content contains tag */} + {toolName === 'plan' && + (() => { + // Combine all text content from blocks + const allText = blocks + .filter((b) => b.type === 'subagent_text' && b.content) + .map((b) => b.content) + .join('') + const parsed = parseSpecialTags(allText) + if (parsed.plan && Object.keys(parsed.plan).length > 0) { + return + } + return null + })()} +
+ ) +} + +/** + * SubAgentThinkingContent renders subagent blocks as simple thinking text (ThinkingBlock). + * Used for inline rendering within regular tool calls that have subagent content. + */ +function SubAgentThinkingContent({ + blocks, + isStreaming = false, +}: { + blocks: SubAgentContentBlock[] + isStreaming?: boolean +}) { + // Combine all text content from blocks + let allRawText = '' + let cleanText = '' + for (const block of blocks) { + if (block.type === 'subagent_text' && block.content) { + allRawText += block.content + const parsed = parseSpecialTags(block.content) + cleanText += parsed.cleanContent + } + } + + // Parse plan from all text + const allParsed = parseSpecialTags(allRawText) + + if (!cleanText.trim() && !allParsed.plan) return null + + // Check if special tags are present + const hasSpecialTags = !!(allParsed.plan && Object.keys(allParsed.plan).length > 0) + + return ( +
+ {cleanText.trim() && ( + + )} + {allParsed.plan && Object.keys(allParsed.plan).length > 0 && ( + + )} +
+ ) +} + +/** + * Subagents that should collapse when done streaming. + * Default behavior is to NOT collapse (stay expanded like edit). + * Only these specific subagents collapse into "Planned for Xs >" style headers. + */ +const COLLAPSIBLE_SUBAGENTS = new Set(['plan', 'debug', 'research', 'info']) + +/** + * SubagentContentRenderer handles the rendering of subagent content. + * - During streaming: Shows content at top level + * - When done (not streaming): Most subagents stay expanded, only specific ones collapse + * - Exception: plan, debug, research, info subagents collapse into a header + */ +function SubagentContentRenderer({ + toolCall, + shouldCollapse, +}: { + toolCall: CopilotToolCall + shouldCollapse: boolean +}) { + const [isExpanded, setIsExpanded] = useState(true) + const [duration, setDuration] = useState(0) + const startTimeRef = useRef(Date.now()) + + const isStreaming = !!toolCall.subAgentStreaming + + // Reset start time when streaming begins + useEffect(() => { + if (isStreaming) { + startTimeRef.current = Date.now() + setDuration(0) + } + }, [isStreaming]) + + // Update duration timer during streaming + useEffect(() => { + if (!isStreaming) return + + const interval = setInterval(() => { + setDuration(Date.now() - startTimeRef.current) + }, 100) + + return () => clearInterval(interval) + }, [isStreaming]) + + // Auto-collapse when streaming ends (only for collapsible subagents) + useEffect(() => { + if (!isStreaming && shouldCollapse) { + setIsExpanded(false) + } + }, [isStreaming, shouldCollapse]) + + // Build segments: each segment is either text content or a tool call + const segments: Array< + { type: 'text'; content: string } | { type: 'tool'; block: SubAgentContentBlock } + > = [] + let currentText = '' + let allRawText = '' + + for (const block of toolCall.subAgentBlocks || []) { + if (block.type === 'subagent_text' && block.content) { + allRawText += block.content + const parsed = parseSpecialTags(block.content) + currentText += parsed.cleanContent + } else if (block.type === 'subagent_tool_call' && block.toolCall) { + if (currentText.trim()) { + segments.push({ type: 'text', content: currentText }) + currentText = '' + } + segments.push({ type: 'tool', block }) + } + } + if (currentText.trim()) { + segments.push({ type: 'text', content: currentText }) + } -/** - * Renders text with a subtle white shimmer overlay when active, creating a skeleton-like - * loading effect that passes over the existing words without replacing them. - * For special tool calls, uses a gradient color. For normal tools, highlights action verbs - * in a lighter color with the rest in default gray. - */ -function ShimmerOverlayText({ - text, - active = false, - className, - isSpecial = false, -}: ShimmerOverlayTextProps) { - const [actionVerb, remainder] = splitActionVerb(text) + // Parse plan and options + const allParsed = parseSpecialTags(allRawText) + const hasSpecialTags = !!( + (allParsed.plan && Object.keys(allParsed.plan).length > 0) || + (allParsed.options && Object.keys(allParsed.options).length > 0) + ) - // Special tools: use tertiary-2 color for entire text with shimmer - if (isSpecial) { - return ( - - {text} - {active ? ( - - ) : null} - - + return ( +
+ +
+ ) + } + return null + })} + + ) + + // During streaming OR for non-collapsible subagents: show content at top level + if (isStreaming || !shouldCollapse) { + return ( +
+ {renderCollapsibleContent()} + {hasPlan && } +
) } - // Normal tools: two-tone rendering - action verb darker in light mode, lighter in dark mode + // Completed collapsible subagent (plan, debug, research, info): show collapsible header + // Plan artifact stays outside the collapsible return ( - - {actionVerb ? ( - <> - - {actionVerb} - - - {remainder} - - - ) : ( - {text} - )} - {active ? ( - + + +
+ {renderCollapsibleContent()} +
+ + {/* Plan stays outside the collapsible */} + {hasPlan && } +
) } /** * Determines if a tool call is "special" and should display with gradient styling. - * Only workflow operation tools (edit, build, run, deploy) get the purple gradient. + * Uses the tool's UI config. */ function isSpecialToolCall(toolCall: CopilotToolCall): boolean { - const workflowOperationTools = [ - 'edit_workflow', - 'build_workflow', - 'run_workflow', - 'deploy_workflow', - ] + return isSpecialToolFromConfig(toolCall.name) +} + +/** + * WorkflowEditSummary shows a full-width summary of workflow edits (like Cursor's diff). + * Displays: workflow name with stats (+N green, N orange, -N red) + * Expands inline on click to show individual blocks with their icons. + */ +function WorkflowEditSummary({ toolCall }: { toolCall: CopilotToolCall }) { + // Get block data from current workflow state + const blocks = useWorkflowStore((s) => s.blocks) + + // Cache block info on first render (before diff is applied) so we can show + // deleted blocks properly even after they're removed from the workflow + const cachedBlockInfoRef = useRef>({}) + + // Update cache with current block info (only add, never remove) + useEffect(() => { + for (const [blockId, block] of Object.entries(blocks)) { + if (!cachedBlockInfoRef.current[blockId]) { + cachedBlockInfoRef.current[blockId] = { + name: block.name || '', + type: block.type || '', + } + } + } + }, [blocks]) + + // Show for edit_workflow regardless of state + if (toolCall.name !== 'edit_workflow') { + return null + } + + // Extract operations from tool call params + const params = + (toolCall as any).parameters || (toolCall as any).input || (toolCall as any).params || {} + let operations = Array.isArray(params.operations) ? params.operations : [] + + // Fallback: check if operations are at top level of toolCall + if (operations.length === 0 && Array.isArray((toolCall as any).operations)) { + operations = (toolCall as any).operations + } + + // Group operations by type with block info + interface SubBlockPreview { + id: string + title: string + value: any + isPassword?: boolean + } + + interface BlockChange { + blockId: string + blockName: string + blockType: string + /** All subblocks for add operations */ + subBlocks?: SubBlockPreview[] + /** Only changed subblocks for edit operations */ + changedSubBlocks?: SubBlockPreview[] + } + + const addedBlocks: BlockChange[] = [] + const editedBlocks: BlockChange[] = [] + const deletedBlocks: BlockChange[] = [] + + for (const op of operations) { + const blockId = op.block_id + if (!blockId) continue + + // Get block info from current workflow state, cached state, or operation params + const currentBlock = blocks[blockId] + const cachedBlock = cachedBlockInfoRef.current[blockId] + let blockName = currentBlock?.name || cachedBlock?.name || '' + let blockType = currentBlock?.type || cachedBlock?.type || '' + + // For add operations, get info from params (type is stored as params.type) + if (op.operation_type === 'add' && op.params) { + blockName = blockName || op.params.name || '' + blockType = blockType || op.params.type || '' + } + + // For edit operations, also check params.type if block not in current state + if (op.operation_type === 'edit' && op.params && !blockType) { + blockType = op.params.type || '' + } + + // Skip edge-only edit operations (like how we don't highlight blocks on canvas for edge changes) + // An edit is edge-only if params only contains 'connections' and nothing else meaningful + if (op.operation_type === 'edit' && op.params) { + const paramKeys = Object.keys(op.params) + const isEdgeOnlyEdit = paramKeys.length === 1 && paramKeys[0] === 'connections' + if (isEdgeOnlyEdit) { + continue + } + } + + // For delete operations, check if block info was provided in operation + if (op.operation_type === 'delete') { + // Some delete operations may include block_name and block_type + blockName = blockName || op.block_name || '' + blockType = blockType || op.block_type || '' + } + + // Fallback name to type or ID + if (!blockName) blockName = blockType || blockId + + const change: BlockChange = { blockId, blockName, blockType } + + // Extract subblock info from operation params, ordered by block config + if (op.params?.inputs && typeof op.params.inputs === 'object') { + const inputs = op.params.inputs as Record + const blockConfig = getBlock(blockType) + + // Build subBlocks array + const subBlocks: SubBlockPreview[] = [] + + // Special handling for condition blocks - parse conditions JSON and render as separate rows + // This matches how the canvas renders condition blocks with "if", "else if", "else" rows + if (blockType === 'condition' && 'conditions' in inputs) { + const conditionsValue = inputs.conditions + const raw = typeof conditionsValue === 'string' ? conditionsValue : undefined + + try { + if (raw) { + const parsed = JSON.parse(raw) as unknown + if (Array.isArray(parsed)) { + parsed.forEach((item: unknown, index: number) => { + const conditionItem = item as { id?: string; value?: unknown } + const title = index === 0 ? 'if' : index === parsed.length - 1 ? 'else' : 'else if' + subBlocks.push({ + id: conditionItem?.id ?? `cond-${index}`, + title, + value: typeof conditionItem?.value === 'string' ? conditionItem.value : '', + isPassword: false, + }) + }) + } + } + } catch { + // Fallback: show default if/else + subBlocks.push({ id: 'if', title: 'if', value: '', isPassword: false }) + subBlocks.push({ id: 'else', title: 'else', value: '', isPassword: false }) + } + } else { + // Filter visible subblocks from config (same logic as canvas preview) + const visibleSubBlocks = + blockConfig?.subBlocks?.filter((sb) => { + // Skip hidden subblocks + if (sb.hidden) return false + if (sb.hideFromPreview) return false + // Skip advanced mode subblocks (not visible by default) + if (sb.mode === 'advanced') return false + // Skip trigger mode subblocks + if (sb.mode === 'trigger') return false + return true + }) ?? [] + + // Track seen ids to dedupe (same pattern as canvas preview using id as key) + const seenIds = new Set() + + // Add subblocks that are visible in config, in config order (first config per id wins) + for (const subBlockConfig of visibleSubBlocks) { + // Skip if we've already added this id (handles configs with same id but different conditions) + if (seenIds.has(subBlockConfig.id)) continue + + if (subBlockConfig.id in inputs) { + const value = inputs[subBlockConfig.id] + // Skip empty values and connections + if (value === null || value === undefined || value === '') continue + seenIds.add(subBlockConfig.id) + subBlocks.push({ + id: subBlockConfig.id, + title: subBlockConfig.title ?? subBlockConfig.id, + value, + isPassword: subBlockConfig.password === true, + }) + } + } + } + + if (subBlocks.length > 0) { + if (op.operation_type === 'add') { + change.subBlocks = subBlocks + } else if (op.operation_type === 'edit') { + change.changedSubBlocks = subBlocks + } + } + } + + switch (op.operation_type) { + case 'add': + addedBlocks.push(change) + break + case 'edit': + editedBlocks.push(change) + break + case 'delete': + deletedBlocks.push(change) + break + } + } + + const hasChanges = addedBlocks.length > 0 || editedBlocks.length > 0 || deletedBlocks.length > 0 + + if (!hasChanges) { + return null + } + + // Get block config by type (for icon and bgColor) + const getBlockConfig = (blockType: string) => { + return getBlock(blockType) + } + + // Render a single block item with action icon and details + const renderBlockItem = (change: BlockChange, type: 'add' | 'edit' | 'delete') => { + const blockConfig = getBlockConfig(change.blockType) + const Icon = blockConfig?.icon + const bgColor = blockConfig?.bgColor || '#6B7280' + + const actionIcons = { + add: { symbol: '+', color: 'text-[#22c55e]' }, + edit: { symbol: '~', color: 'text-[#f97316]' }, + delete: { symbol: '-', color: 'text-[#ef4444]' }, + } + const { symbol, color } = actionIcons[type] + + const subBlocksToShow = + type === 'add' ? change.subBlocks : type === 'edit' ? change.changedSubBlocks : undefined + + return ( +
+ {/* Block header - gray background like plan/table headers */} +
+
+ {/* Toolbar-style icon: colored square with white icon */} +
+ {Icon && } +
+ + {change.blockName} + +
+ {/* Action icon in top right */} + {symbol} +
+ + {/* Subblock details - dark background like table/plan body */} + {subBlocksToShow && subBlocksToShow.length > 0 && ( +
+ {subBlocksToShow.map((sb) => { + // Mask password fields like the canvas does + const displayValue = sb.isPassword ? '•••' : getDisplayValue(sb.value) + return ( +
+ + {sb.title}: + + + {displayValue} + +
+ ) + })} +
+ )} +
+ ) + } - return workflowOperationTools.includes(toolCall.name) + return ( +
+ {addedBlocks.map((change) => renderBlockItem(change, 'add'))} + {editedBlocks.map((change) => renderBlockItem(change, 'edit'))} + {deletedBlocks.map((change) => renderBlockItem(change, 'delete'))} +
+ ) } /** @@ -253,6 +1601,12 @@ function isIntegrationTool(toolName: string): boolean { } function shouldShowRunSkipButtons(toolCall: CopilotToolCall): boolean { + // First check UI config for interrupt + if (hasInterruptFromConfig(toolCall.name) && toolCall.state === 'pending') { + return true + } + + // Then check instance-level interrupt const instance = getClientTool(toolCall.id) let hasInterrupt = !!instance?.getInterruptDisplays?.() if (!hasInterrupt) { @@ -505,7 +1859,7 @@ function RunSkipButtons({ // Standardized buttons for all interrupt tools: Allow, Always Allow, Skip return ( -
+
@@ -521,8 +1875,10 @@ function RunSkipButtons({ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: ToolCallProps) { const [, forceUpdate] = useState({}) + // Get live toolCall from store to ensure we have the latest state + const effectiveId = toolCallId || toolCallProp?.id const liveToolCall = useCopilotStore((s) => - toolCallId ? s.toolCallsById[toolCallId] : undefined + effectiveId ? s.toolCallsById[effectiveId] : undefined ) const toolCall = liveToolCall || toolCallProp @@ -560,33 +1916,103 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: // Skip rendering some internal tools if (toolCall.name === 'checkoff_todo' || toolCall.name === 'mark_todo_in_progress') return null + // Special rendering for subagent tools - show as thinking text with tool calls at top level + const SUBAGENT_TOOLS = [ + 'plan', + 'edit', + 'debug', + 'test', + 'deploy', + 'evaluate', + 'auth', + 'research', + 'knowledge', + 'custom_tool', + 'tour', + 'info', + 'workflow', + ] + const isSubagentTool = SUBAGENT_TOOLS.includes(toolCall.name) + + // For ALL subagent tools, don't show anything until we have blocks with content + if (isSubagentTool) { + // Check if we have any meaningful content in blocks + const hasContent = toolCall.subAgentBlocks?.some( + (block) => + (block.type === 'subagent_text' && block.content?.trim()) || + (block.type === 'subagent_tool_call' && block.toolCall) + ) + + if (!hasContent) { + return null + } + } + + if (isSubagentTool && toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0) { + // Render subagent content using the dedicated component + return ( + + ) + } + // Get current mode from store to determine if we should render integration tools const mode = useCopilotStore.getState().mode + // Check if this is a completed/historical tool call (not pending/executing) + // Use string comparison to handle both enum values and string values from DB + const stateStr = String(toolCall.state) + const isCompletedToolCall = + stateStr === 'success' || + stateStr === 'error' || + stateStr === 'rejected' || + stateStr === 'aborted' + // Allow rendering if: // 1. Tool is in CLASS_TOOL_METADATA (client tools), OR - // 2. We're in build mode (integration tools are executed server-side) + // 2. We're in build mode (integration tools are executed server-side), OR + // 3. Tool call is already completed (historical - should always render) const isClientTool = !!CLASS_TOOL_METADATA[toolCall.name] const isIntegrationToolInBuildMode = mode === 'build' && !isClientTool - if (!isClientTool && !isIntegrationToolInBuildMode) { + if (!isClientTool && !isIntegrationToolInBuildMode && !isCompletedToolCall) { return null } + // Check if tool has params table config (meaning it's expandable) + const hasParamsTable = !!getToolUIConfig(toolCall.name)?.paramsTable const isExpandableTool = + hasParamsTable || toolCall.name === 'make_api_request' || toolCall.name === 'set_global_workflow_variables' || toolCall.name === 'run_workflow' const showButtons = shouldShowRunSkipButtons(toolCall) + + // Check UI config for secondary action + const toolUIConfig = getToolUIConfig(toolCall.name) + const secondaryAction = toolUIConfig?.secondaryAction + const showSecondaryAction = secondaryAction?.showInStates.includes( + toolCall.state as ClientToolCallState + ) + + // Legacy fallbacks for tools that haven't migrated to UI config const showMoveToBackground = - toolCall.name === 'run_workflow' && - (toolCall.state === (ClientToolCallState.executing as any) || - toolCall.state === ('executing' as any)) + showSecondaryAction && secondaryAction?.text === 'Move to Background' + ? true + : !secondaryAction && + toolCall.name === 'run_workflow' && + (toolCall.state === (ClientToolCallState.executing as any) || + toolCall.state === ('executing' as any)) const showWake = - toolCall.name === 'sleep' && - (toolCall.state === (ClientToolCallState.executing as any) || - toolCall.state === ('executing' as any)) + showSecondaryAction && secondaryAction?.text === 'Wake' + ? true + : !secondaryAction && + toolCall.name === 'sleep' && + (toolCall.state === (ClientToolCallState.executing as any) || + toolCall.state === ('executing' as any)) const handleStateChange = (state: any) => { forceUpdate({}) @@ -596,6 +2022,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: const displayName = getDisplayName(toolCall) const isLoadingState = + toolCall.state === ClientToolCallState.generating || toolCall.state === ClientToolCallState.pending || toolCall.state === ClientToolCallState.executing @@ -863,6 +2290,11 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: const safeInputs = inputs && typeof inputs === 'object' ? inputs : {} const inputEntries = Object.entries(safeInputs) + // Don't show the table if there are no inputs + if (inputEntries.length === 0) { + return null + } + return (
@@ -877,64 +2309,53 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: - {inputEntries.length === 0 ? ( - - + + - ) : ( - inputEntries.map(([key, value]) => ( - - - - - )) - )} + ))}
- No inputs provided + {inputEntries.map(([key, value]) => ( +
+
+ + {key} + +
+
+
+ { + const newInputs = { ...safeInputs, [key]: e.target.value } + + // Determine how to update based on original structure + if (isNestedInWorkflowInput) { + // Update workflow_input + setEditedParams({ ...editedParams, workflow_input: newInputs }) + } else if (typeof editedParams.input === 'string') { + // Input was a JSON string, serialize back + setEditedParams({ ...editedParams, input: JSON.stringify(newInputs) }) + } else if (editedParams.input && typeof editedParams.input === 'object') { + // Input is an object + setEditedParams({ ...editedParams, input: newInputs }) + } else if ( + editedParams.inputs && + typeof editedParams.inputs === 'object' + ) { + // Inputs is an object + setEditedParams({ ...editedParams, inputs: newInputs }) + } else { + // Flat structure - update at base level + setEditedParams({ ...editedParams, [key]: e.target.value }) + } + }} + className='w-full bg-transparent font-mono text-[var(--text-muted)] text-xs outline-none focus:text-[var(--text-primary)]' + /> +
-
- - {key} - -
-
-
- { - const newInputs = { ...safeInputs, [key]: e.target.value } - - // Determine how to update based on original structure - if (isNestedInWorkflowInput) { - // Update workflow_input - setEditedParams({ ...editedParams, workflow_input: newInputs }) - } else if (typeof editedParams.input === 'string') { - // Input was a JSON string, serialize back - setEditedParams({ ...editedParams, input: JSON.stringify(newInputs) }) - } else if ( - editedParams.input && - typeof editedParams.input === 'object' - ) { - // Input is an object - setEditedParams({ ...editedParams, input: newInputs }) - } else if ( - editedParams.inputs && - typeof editedParams.inputs === 'object' - ) { - // Inputs is an object - setEditedParams({ ...editedParams, inputs: newInputs }) - } else { - // Flat structure - update at base level - setEditedParams({ ...editedParams, [key]: e.target.value }) - } - }} - className='w-full bg-transparent font-mono text-[var(--text-muted)] text-xs outline-none focus:text-[var(--text-primary)]' - /> -
-
@@ -944,8 +2365,12 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: return null } - // Special handling for set_environment_variables - always stacked, always expanded - if (toolCall.name === 'set_environment_variables' && toolCall.state === 'pending') { + // Special handling for tools with alwaysExpanded config (e.g., set_environment_variables) + const isAlwaysExpanded = toolUIConfig?.alwaysExpanded + if ( + (isAlwaysExpanded || toolCall.name === 'set_environment_variables') && + toolCall.state === 'pending' + ) { const isEnvVarsClickable = isAutoAllowed const handleEnvVarsClick = () => { @@ -964,9 +2389,9 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]' />
-
{renderPendingDetails()}
+
{renderPendingDetails()}
{showRemoveAutoAllow && isAutoAllowed && ( -
+
) } - // Special rendering for function_execute - show code block - if (toolCall.name === 'function_execute') { + // Special rendering for tools with 'code' customRenderer (e.g., function_execute) + if (toolUIConfig?.customRenderer === 'code' || toolCall.name === 'function_execute') { const code = params.code || '' const isFunctionExecuteClickable = isAutoAllowed @@ -1016,12 +2448,12 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: />
{code && ( -
+
)} {showRemoveAutoAllow && isAutoAllowed && ( -
+
) } @@ -1067,9 +2506,9 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]' />
- {isExpandableTool && expanded &&
{renderPendingDetails()}
} + {isExpandableTool && expanded &&
{renderPendingDetails()}
} {showRemoveAutoAllow && isAutoAllowed && ( -
+
) : showWake ? ( -
+
) : null} + {/* Workflow edit summary - shows block changes after edit_workflow completes */} + + + {/* Render subagent content as thinking text */} + {toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && ( + + )}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/context-usage-indicator/context-usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/context-usage-indicator/context-usage-indicator.tsx deleted file mode 100644 index 26b00f89ef..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/context-usage-indicator/context-usage-indicator.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client' - -import { useMemo } from 'react' -import { Tooltip } from '@/components/emcn' - -interface ContextUsageIndicatorProps { - /** Usage percentage (0-100) */ - percentage: number - /** Size of the indicator in pixels */ - size?: number - /** Stroke width in pixels */ - strokeWidth?: number -} - -/** - * Circular context usage indicator showing percentage of context window used. - * Displays a progress ring that changes color based on usage level. - * - * @param props - Component props - * @returns Rendered context usage indicator - */ -export function ContextUsageIndicator({ - percentage, - size = 20, - strokeWidth = 2, -}: ContextUsageIndicatorProps) { - const radius = (size - strokeWidth) / 2 - const circumference = radius * 2 * Math.PI - const offset = circumference - (percentage / 100) * circumference - - const color = useMemo(() => { - if (percentage >= 90) return 'var(--text-error)' - if (percentage >= 75) return 'var(--warning)' - return 'var(--text-muted)' - }, [percentage]) - - const displayPercentage = useMemo(() => { - return Math.round(percentage) - }, [percentage]) - - return ( - - -
- - - - -
-
- {displayPercentage}% context used -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts index 071d26475b..fd7d64cff1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/index.ts @@ -1,6 +1,5 @@ export { AttachedFilesDisplay } from './attached-files-display/attached-files-display' export { ContextPills } from './context-pills/context-pills' -export { ContextUsageIndicator } from './context-usage-indicator/context-usage-indicator' export { MentionMenu } from './mention-menu/mention-menu' export { ModeSelector } from './mode-selector/mode-selector' export { ModelSelector } from './model-selector/model-selector' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts index dfed765148..7587f69a9c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts @@ -178,11 +178,12 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { /** * Opens file picker dialog + * Note: We allow file selection even when isLoading (streaming) so users can prepare images for the next message */ const handleFileSelect = useCallback(() => { - if (disabled || isLoading) return + if (disabled) return fileInputRef.current?.click() - }, [disabled, isLoading]) + }, [disabled]) /** * Handles file input change event diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx index a27e8d4843..b8ad537e66 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx @@ -117,7 +117,6 @@ const UserInput = forwardRef( const selectedModel = selectedModelOverride !== undefined ? selectedModelOverride : copilotStore.selectedModel const setSelectedModel = onModelChangeOverride || copilotStore.setSelectedModel - const contextUsage = copilotStore.contextUsage // Internal state const [internalMessage, setInternalMessage] = useState('') @@ -300,7 +299,8 @@ const UserInput = forwardRef( async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => { const targetMessage = overrideMessage ?? message const trimmedMessage = targetMessage.trim() - if (!trimmedMessage || disabled || isLoading) return + // Allow submission even when isLoading - store will queue the message + if (!trimmedMessage || disabled) return const failedUploads = fileAttachments.attachedFiles.filter((f) => !f.uploading && !f.key) if (failedUploads.length > 0) { @@ -746,7 +746,7 @@ const UserInput = forwardRef( title='Attach file' className={cn( 'cursor-pointer rounded-[6px] border-0 bg-transparent p-[0px] dark:bg-transparent', - (disabled || isLoading) && 'cursor-not-allowed opacity-50' + disabled && 'cursor-not-allowed opacity-50' )} > @@ -802,7 +802,7 @@ const UserInput = forwardRef(
- {/* Hidden File Input */} + {/* Hidden File Input - enabled during streaming so users can prepare images for the next message */} ( className='hidden' accept='image/*' multiple - disabled={disabled || isLoading} + disabled={disabled} />
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index e882a1dda3..550a5d9a54 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -22,9 +22,11 @@ import { PopoverTrigger, } from '@/components/emcn' import { Trash } from '@/components/emcn/icons/trash' +import { cn } from '@/lib/core/utils/cn' import { CopilotMessage, PlanModeSection, + QueuedMessages, TodoList, UserInput, Welcome, @@ -99,7 +101,6 @@ export const Copilot = forwardRef(({ panelWidth }, ref loadChats, messageCheckpoints, currentChat, - fetchContextUsage, selectChat, deleteChat, areChatsFresh, @@ -118,7 +119,6 @@ export const Copilot = forwardRef(({ panelWidth }, ref chatsLoadedForWorkflow, setCopilotWorkflowId, loadChats, - fetchContextUsage, loadAutoAllowedTools, currentChat, isSendingMessage, @@ -298,7 +298,8 @@ export const Copilot = forwardRef(({ panelWidth }, ref */ const handleSubmit = useCallback( async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => { - if (!query || isSendingMessage || !activeWorkflowId) return + // Allow submission even when isSendingMessage - store will queue the message + if (!query || !activeWorkflowId) return if (showPlanTodos) { const store = useCopilotStore.getState() @@ -316,7 +317,7 @@ export const Copilot = forwardRef(({ panelWidth }, ref logger.error('Failed to send message:', error) } }, - [isSendingMessage, activeWorkflowId, sendMessage, showPlanTodos] + [activeWorkflowId, sendMessage, showPlanTodos] ) /** @@ -443,7 +444,13 @@ export const Copilot = forwardRef(({ panelWidth }, ref {chat.title || 'New Chat'} -
+
+ {/* Queued messages (shown when messages are waiting) */} + + {/* Input area with integrated mode selector */}
Promise loadChats: (forceRefresh?: boolean) => Promise - fetchContextUsage: () => Promise loadAutoAllowedTools: () => Promise currentChat: any isSendingMessage: boolean @@ -30,7 +29,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) { chatsLoadedForWorkflow, setCopilotWorkflowId, loadChats, - fetchContextUsage, loadAutoAllowedTools, currentChat, isSendingMessage, @@ -102,18 +100,6 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) { isSendingMessage, ]) - /** - * Fetch context usage when component is initialized and has a current chat - */ - useEffect(() => { - if (isInitialized && currentChat?.id && activeWorkflowId) { - logger.info('[Copilot] Component initialized, fetching context usage') - fetchContextUsage().catch((err) => { - logger.warn('[Copilot] Failed to fetch context usage on mount', err) - }) - } - }, [isInitialized, currentChat?.id, activeWorkflowId, fetchContextUsage]) - /** * Load auto-allowed tools once on mount */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts index c0b78081dd..afccf1290d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect } from 'react' import { PANEL_WIDTH } from '@/stores/constants' import { usePanelStore } from '@/stores/panel' @@ -10,15 +10,14 @@ import { usePanelStore } from '@/stores/panel' * @returns Resize state and handlers */ export function usePanelResize() { - const { setPanelWidth } = usePanelStore() - const [isResizing, setIsResizing] = useState(false) + const { setPanelWidth, isResizing, setIsResizing } = usePanelStore() /** * Handles mouse down on resize handle */ const handleMouseDown = useCallback(() => { setIsResizing(true) - }, []) + }, [setIsResizing]) /** * Setup resize event listeners and body styles when resizing @@ -52,7 +51,7 @@ export function usePanelResize() { document.body.style.cursor = '' document.body.style.userSelect = '' } - }, [isResizing, setPanelWidth]) + }, [isResizing, setPanelWidth, setIsResizing]) return { isResizing, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx index 994feda685..709e7a32cc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx @@ -136,7 +136,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps(null) const prevEntriesLengthRef = useRef(0) const prevWorkflowEntriesLengthRef = useRef(0) + const isTerminalFocusedRef = useRef(false) const { setTerminalHeight, lastExpandedHeight, @@ -540,8 +541,11 @@ export function Terminal() { /** * Handle row click - toggle if clicking same entry * Disables auto-selection when user manually selects, re-enables when deselecting + * Also focuses the terminal to enable keyboard navigation */ const handleRowClick = useCallback((entry: ConsoleEntry) => { + // Focus the terminal to enable keyboard navigation + terminalRef.current?.focus() setSelectedEntry((prev) => { const isDeselecting = prev?.id === entry.id setAutoSelectEnabled(isDeselecting) @@ -562,6 +566,26 @@ export function Terminal() { setIsToggling(false) }, []) + /** + * Handle terminal focus - enables keyboard navigation + */ + const handleTerminalFocus = useCallback(() => { + isTerminalFocusedRef.current = true + }, []) + + /** + * Handle terminal blur - disables keyboard navigation + */ + const handleTerminalBlur = useCallback((e: React.FocusEvent) => { + // Only blur if focus is moving outside the terminal + if (!terminalRef.current?.contains(e.relatedTarget as Node)) { + isTerminalFocusedRef.current = false + } + }, []) + + /** + * Handle copy output to clipboard + */ const handleCopy = useCallback(() => { if (!selectedEntry) return @@ -792,9 +816,12 @@ export function Terminal() { /** * Handle keyboard navigation through logs * Disables auto-selection when user manually navigates + * Only active when the terminal is focused */ useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + // Only handle navigation when terminal is focused + if (!isTerminalFocusedRef.current) return if (isEventFromEditableElement(e)) return const activeElement = document.activeElement as HTMLElement | null const toolbarRoot = document.querySelector( @@ -829,9 +856,12 @@ export function Terminal() { /** * Handle keyboard navigation for input/output toggle * Left arrow shows output, right arrow shows input + * Only active when the terminal is focused */ useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + // Only handle navigation when terminal is focused + if (!isTerminalFocusedRef.current) return // Ignore when typing/navigating inside editable inputs/editors if (isEventFromEditableElement(e)) return @@ -936,6 +966,9 @@ export function Terminal() { isToggling && 'transition-[height] duration-100 ease-out' )} onTransitionEnd={handleTransitionEnd} + onFocus={handleTerminalFocus} + onBlur={handleTerminalBlur} + tabIndex={-1} aria-label='Terminal' >
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 9ecd441e30..fbd93d2491 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -199,8 +199,9 @@ const tryParseJson = (value: unknown): unknown => { /** * Formats a subblock value for display, intelligently handling nested objects and arrays. + * Used by both the canvas workflow blocks and copilot edit summaries. */ -const getDisplayValue = (value: unknown): string => { +export const getDisplayValue = (value: unknown): string => { if (value == null || value === '') return '-' // Try parsing JSON strings first @@ -630,10 +631,13 @@ export const WorkflowBlock = memo(function WorkflowBlock({ ? ((credValue as { value?: unknown }).value as string | undefined) : (credValue as string | undefined) if (prevCredRef.current !== cred) { + const hadPreviousCredential = prevCredRef.current !== undefined prevCredRef.current = cred - const keys = Object.keys(current) - const dependentKeys = keys.filter((k) => k !== 'credential') - dependentKeys.forEach((k) => collaborativeSetSubblockValue(id, k, '')) + if (hadPreviousCredential) { + const keys = Object.keys(current) + const dependentKeys = keys.filter((k) => k !== 'credential') + dependentKeys.forEach((k) => collaborativeSetSubblockValue(id, k, '')) + } } }, [id, collaborativeSetSubblockValue]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx index 907478e958..bb3569e21e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx @@ -93,7 +93,7 @@ const WorkflowEdgeComponent = ({ } else if (isErrorEdge) { color = 'var(--text-error)' } else if (edgeDiffStatus === 'new') { - color = 'var(--brand-tertiary)' + color = 'var(--brand-tertiary-2)' } else if (edgeRunStatus === 'success') { // Use green for preview mode, default for canvas execution color = previewExecutionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 5cd09e353d..ef25a7ded7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -3350,8 +3350,6 @@ const WorkflowContent = React.memo(() => { - - {/* Context Menus */} {
+ + {oauthModal && ( 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/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 6188e72812..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,11 +350,11 @@ 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 @@ -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-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index 35cad8d465..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 @@ -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/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/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index d00ca84c7a..9c49d38041 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 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 +566,157 @@ 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 { + return JSON.parse(JSON.stringify(obj)) + } catch { + 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 + const serialized: any = { id: msg.id, role: msg.role, - content, + content: msg.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, - }), } + + // 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 +733,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 +1565,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 +2043,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 +2055,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 +2124,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 +2169,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 +2177,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 +2219,6 @@ export const useCopilotStore = create()( } } catch {} - logger.info('[Context Usage] New chat created, clearing context usage') set({ currentChat: null, messages: [], @@ -1807,7 +2227,6 @@ export const useCopilotStore = create()( showPlanTodos: false, streamingPlanContent: '', suppressAutoSelect: true, - contextUsage: null, }) }, @@ -1886,18 +2305,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 +2336,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 +2367,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 +2382,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 +2599,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 +2939,9 @@ export const useCopilotStore = create()( designWorkflowContent: '', pendingContent: '', doneEventCount: 0, + subAgentContent: {}, + subAgentToolCalls: {}, + subAgentBlocks: {}, } if (isContinuation) { @@ -2563,6 +2965,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,11 +3109,42 @@ 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, @@ -2652,10 +3178,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 +3355,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 +3543,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: () => { From 9a16e7c20f5dbe0e4cf20b9a10432c8b8910328f Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 10 Jan 2026 12:16:32 -0800 Subject: [PATCH 04/10] improvement(response): only allow singleton (#2764) * improvement(response): only allow singleton * respect singleton triggers and blocks in copilot * don't show dup button for response * fix error message --- .../components/action-bar/action-bar.tsx | 4 +-- .../utils/workflow-canvas-helpers.ts | 9 +++++- .../[workspaceId]/w/[workflowId]/workflow.tsx | 22 ++++++++++--- apps/sim/blocks/blocks/response.ts | 1 + apps/sim/blocks/types.ts | 1 + .../tools/server/workflow/edit-workflow.ts | 31 +++++++++++++++++++ apps/sim/lib/workflows/triggers/triggers.ts | 30 ++++++++++++++++++ apps/sim/stores/workflows/utils.ts | 4 +++ 8 files changed, 94 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx index e861a7e71e..67de1760bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx @@ -87,8 +87,8 @@ export const ActionBar = memo( const userPermissions = useUserPermissionsContext() - // Check for start_trigger (unified start block) - prevent duplication but allow deletion const isStartBlock = blockType === 'starter' || blockType === 'start_trigger' + const isResponseBlock = blockType === 'response' const isNoteBlock = blockType === 'note' /** @@ -140,7 +140,7 @@ export const ActionBar = memo( )} - {!isStartBlock && ( + {!isStartBlock && !isResponseBlock && (
) } @@ -2443,8 +2542,8 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: {code && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx index 550a5d9a54..03053ccf3d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx @@ -124,8 +124,10 @@ export const Copilot = forwardRef(({ panelWidth }, ref isSendingMessage, }) - // Handle scroll management - const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage) + // Handle scroll management (80px stickiness for copilot) + const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage, { + stickinessThreshold: 80, + }) // Handle chat history grouping const { groupedChats, handleHistoryDropdownOpen: handleHistoryDropdownOpenHook } = useChatHistory( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts index e8299998c7..5959368e3a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts @@ -12,6 +12,12 @@ interface UseScrollManagementOptions { * - `auto`: immediate scroll to bottom (used by floating chat to avoid jitter). */ behavior?: 'auto' | 'smooth' + /** + * Distance from bottom (in pixels) within which auto-scroll stays active. + * Lower values = less sticky (user can scroll away easier). + * Default is 100px. + */ + stickinessThreshold?: number } /** @@ -34,6 +40,7 @@ export function useScrollManagement( const programmaticScrollInProgressRef = useRef(false) const lastScrollTopRef = useRef(0) const scrollBehavior: 'auto' | 'smooth' = options?.behavior ?? 'smooth' + const stickinessThreshold = options?.stickinessThreshold ?? 100 /** * Scrolls the container to the bottom with smooth animation @@ -74,7 +81,7 @@ export function useScrollManagement( const { scrollTop, scrollHeight, clientHeight } = scrollContainer const distanceFromBottom = scrollHeight - scrollTop - clientHeight - const nearBottom = distanceFromBottom <= 100 + const nearBottom = distanceFromBottom <= stickinessThreshold setIsNearBottom(nearBottom) if (isSendingMessage) { @@ -95,7 +102,7 @@ export function useScrollManagement( // Track last scrollTop for direction detection lastScrollTopRef.current = scrollTop - }, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream]) + }, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream, stickinessThreshold]) // Attach scroll listener useEffect(() => { @@ -174,14 +181,20 @@ export function useScrollManagement( const { scrollTop, scrollHeight, clientHeight } = scrollContainer const distanceFromBottom = scrollHeight - scrollTop - clientHeight - const nearBottom = distanceFromBottom <= 120 + const nearBottom = distanceFromBottom <= stickinessThreshold if (nearBottom) { scrollToBottom() } }, 100) return () => window.clearInterval(intervalId) - }, [isSendingMessage, userHasScrolledDuringStream, getScrollContainer, scrollToBottom]) + }, [ + isSendingMessage, + userHasScrolledDuringStream, + getScrollContainer, + scrollToBottom, + stickinessThreshold, + ]) return { scrollAreaRef, diff --git a/apps/sim/stores/panel/copilot/store.ts b/apps/sim/stores/panel/copilot/store.ts index 9c49d38041..d4e926c91e 100644 --- a/apps/sim/stores/panel/copilot/store.ts +++ b/apps/sim/stores/panel/copilot/store.ts @@ -271,11 +271,31 @@ function resolveToolDisplay( if (cand?.text || cand?.icon) return { text: cand.text, icon: cand.icon } } } catch {} - // Humanized fallback as last resort + // Humanized fallback as last resort - include state verb for proper verb-noun styling try { if (toolName) { - const text = toolName.replace(/_/g, ' ').replace(/\b\w/g, (c) => 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 @@ -572,8 +592,30 @@ function stripTodoTags(text: string): string { */ function deepClone(obj: T): T { try { - return JSON.parse(JSON.stringify(obj)) - } catch { + 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 } } @@ -587,11 +629,18 @@ function serializeMessagesForDB(messages: CopilotMessage[]): any[] { const result = messages .map((msg) => { // 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: msg.content || '', - timestamp: msg.timestamp, + timestamp, } // Deep clone contentBlocks (the main rendering data) @@ -3151,7 +3200,7 @@ export const useCopilotStore = create()( 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({ @@ -3162,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: { @@ -3170,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) From 7f18d96d32b2bc3bfbb4c9af136325dc7d7476e3 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 10 Jan 2026 21:30:34 -0800 Subject: [PATCH 09/10] feat(popover): add expandOnHover, added the ability to change the color of a workflow icon, new workflow naming convention (#2770) * feat(popover): add expandOnHover, added the ability to change the color of a workflow icon * updated workflow naming conventions --- .../access-control/access-control.tsx | 4 +- .../credential-sets/credential-sets.tsx | 6 +- .../components/context-menu/context-menu.tsx | 187 ++++- .../components/folder-item/folder-item.tsx | 65 +- .../workflow-item/avatars/avatars.tsx | 95 +-- .../workflow-item/workflow-item.tsx | 118 +++- .../sidebar/hooks/use-context-menu.ts | 16 + .../sidebar/hooks/use-workflow-operations.ts | 6 +- .../w/hooks/use-duplicate-workflow.ts | 2 +- .../emcn/components/avatar/avatar.tsx | 16 +- .../emcn/components/popover/popover.tsx | 199 +++++- apps/sim/hooks/queries/workflows.ts | 6 +- apps/sim/lib/workflows/colors.ts | 75 ++ apps/sim/stores/workflows/registry/store.ts | 2 +- apps/sim/stores/workflows/registry/utils.ts | 661 ++++++++++-------- 15 files changed, 1031 insertions(+), 427 deletions(-) create mode 100644 apps/sim/lib/workflows/colors.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx index 63c17c3af5..034f028290 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx @@ -163,7 +163,7 @@ function AddMembersModal({ className='flex items-center gap-[10px] rounded-[4px] px-[8px] py-[6px] hover:bg-[var(--surface-2)]' > - + {member.user?.image && ( )} @@ -663,7 +663,7 @@ export function AccessControl() { return (
- + {member.userImage && } } - // Detail view for a polling group if (viewingSet) { const activeMembers = members.filter((m) => m.status === 'active') const totalCount = activeMembers.length + pendingInvitations.length @@ -529,7 +527,7 @@ export function CredentialSets() { return (
- + {member.userImage && ( )} @@ -583,7 +581,7 @@ export function CredentialSets() { return (
- + 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,23 +181,74 @@ 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) { - // Section visibility for divider logic + 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) + (showRename && onRename) || + (showCreate && onCreate) || + (showCreateFolder && onCreateFolder) || + (showColorChange && onColorChange) const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport) return ( @@ -170,10 +268,21 @@ export function ContextMenu({ height: '1px', }} /> - + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + > + {/* Back button - shown only when in a folder */} + + {/* Navigation actions */} {showOpenInNewTab && onOpenInNewTab && ( { onOpenInNewTab() onClose() @@ -182,11 +291,12 @@ export function ContextMenu({ Open in new tab )} - {hasNavigationSection && (hasEditSection || hasCopySection) && } + {hasNavigationSection && (hasEditSection || hasCopySection) && } {/* Edit and create actions */} {showRename && onRename && ( { onRename() @@ -198,6 +308,7 @@ export function ContextMenu({ )} {showCreate && onCreate && ( { onCreate() @@ -209,6 +320,7 @@ export function ContextMenu({ )} {showCreateFolder && onCreateFolder && ( { onCreateFolder() @@ -218,11 +330,72 @@ export function ContextMenu({ Create folder )} + {showColorChange && onColorChange && ( + +
+ {/* Preset colors */} +
+ {WORKFLOW_COLORS.map(({ color, name }) => ( +
+ + {/* 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' + /> + +
+
+ + )} {/* Copy and export actions */} - {hasEditSection && hasCopySection && } + {hasEditSection && hasCopySection && } {showDuplicate && onDuplicate && ( { onDuplicate() @@ -234,6 +407,7 @@ export function ContextMenu({ )} {showExport && onExport && ( { onExport() @@ -245,8 +419,9 @@ export function ContextMenu({ )} {/* Destructive action */} - {(hasNavigationSection || hasEditSection || hasCopySection) && } + {(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/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-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/components/emcn/components/avatar/avatar.tsx b/apps/sim/components/emcn/components/avatar/avatar.tsx index e41c40a9fa..9500f7ecbe 100644 --- a/apps/sim/components/emcn/components/avatar/avatar.tsx +++ b/apps/sim/components/emcn/components/avatar/avatar.tsx @@ -12,10 +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', + xs: 'h-3.5 w-3.5', + sm: 'h-6 w-6', + md: 'h-8 w-8', + lg: 'h-10 w-10', }, }, defaultVariants: { @@ -37,10 +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', + 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/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/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/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', ] /** From 4941b5224bd042351cc73249b97d674db27323d1 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 11 Jan 2026 11:28:47 -0800 Subject: [PATCH 10/10] fix(resize): fix subflow resize on drag, children deselected in subflow on drag (#2771) * fix(resize): fix subflow resize on drag, children deselected in subflow on drag * ack PR comments * fix copy-paste subflows deselecting children * ack comments --- .../w/[workflowId]/hooks/index.ts | 4 +- .../[workflowId]/hooks/use-node-utilities.ts | 79 +++++++---- .../utils/workflow-canvas-helpers.ts | 44 +++--- .../[workspaceId]/w/[workflowId]/workflow.tsx | 126 ++++++++++-------- 4 files changed, 148 insertions(+), 105 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts index 3af268aa37..b80f7749a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts @@ -4,7 +4,7 @@ export { computeParentUpdateEntries, getClampedPositionForNode, isInEditableElement, - selectNodesDeferred, + resolveParentChildSelectionConflicts, validateTriggerPaste, } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers' export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './float' @@ -12,7 +12,7 @@ export { useAutoLayout } from './use-auto-layout' export { BLOCK_DIMENSIONS, useBlockDimensions } from './use-block-dimensions' export { useBlockVisual } from './use-block-visual' export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow' -export { useNodeUtilities } from './use-node-utilities' +export { calculateContainerDimensions, useNodeUtilities } from './use-node-utilities' export { usePreventZoom } from './use-prevent-zoom' export { useScrollManagement } from './use-scroll-management' export { useWorkflowExecution } from './use-workflow-execution' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts index d90317ea7e..e137506618 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts @@ -62,6 +62,47 @@ export function clampPositionToContainer( } } +/** + * Calculates container dimensions based on child block positions. + * Single source of truth for container sizing - ensures consistency between + * live drag updates and final dimension calculations. + * + * @param childPositions - Array of child positions with their dimensions + * @returns Calculated width and height for the container + */ +export function calculateContainerDimensions( + childPositions: Array<{ x: number; y: number; width: number; height: number }> +): { width: number; height: number } { + if (childPositions.length === 0) { + return { + width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + } + } + + let maxRight = 0 + let maxBottom = 0 + + for (const child of childPositions) { + maxRight = Math.max(maxRight, child.x + child.width) + maxBottom = Math.max(maxBottom, child.y + child.height) + } + + const width = Math.max( + CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING + ) + const height = Math.max( + CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + CONTAINER_DIMENSIONS.HEADER_HEIGHT + + CONTAINER_DIMENSIONS.TOP_PADDING + + maxBottom + + CONTAINER_DIMENSIONS.BOTTOM_PADDING + ) + + return { width, height } +} + /** * Hook providing utilities for node position, hierarchy, and dimension calculations */ @@ -306,36 +347,16 @@ export function useNodeUtilities(blocks: Record) { (id) => currentBlocks[id]?.data?.parentId === nodeId ) - if (childBlockIds.length === 0) { - return { - width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - } - } - - let maxRight = 0 - let maxBottom = 0 - - for (const childId of childBlockIds) { - const child = currentBlocks[childId] - if (!child?.position) continue - - const { width: childWidth, height: childHeight } = getBlockDimensions(childId) - - maxRight = Math.max(maxRight, child.position.x + childWidth) - maxBottom = Math.max(maxBottom, child.position.y + childHeight) - } - - const width = Math.max( - CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING - ) - const height = Math.max( - CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING - ) + const childPositions = childBlockIds + .map((childId) => { + const child = currentBlocks[childId] + if (!child?.position) return null + const { width, height } = getBlockDimensions(childId) + return { x: child.position.x, y: child.position.y, width, height } + }) + .filter((p): p is NonNullable => p !== null) - return { width, height } + return calculateContainerDimensions(childPositions) }, [getBlockDimensions] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts index 4f968febca..a6d6ea9136 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts @@ -65,27 +65,6 @@ export function clearDragHighlights(): void { document.body.style.cursor = '' } -/** - * Selects nodes by their IDs after paste/duplicate operations. - * Defers selection to next animation frame to allow displayNodes to sync from store first. - * This is necessary because the component uses controlled state (nodes={displayNodes}) - * and newly added blocks need time to propagate through the store → derivedNodes → displayNodes cycle. - */ -export function selectNodesDeferred( - nodeIds: string[], - setDisplayNodes: (updater: (nodes: Node[]) => Node[]) => void -): void { - const idsSet = new Set(nodeIds) - requestAnimationFrame(() => { - setDisplayNodes((nodes) => - nodes.map((node) => ({ - ...node, - selected: idsSet.has(node.id), - })) - ) - }) -} - interface BlockData { height?: number data?: { @@ -186,3 +165,26 @@ export function computeParentUpdateEntries( } }) } + +/** + * Resolves parent-child selection conflicts by deselecting children whose parent is also selected. + */ +export function resolveParentChildSelectionConflicts( + nodes: Node[], + blocks: Record +): Node[] { + const selectedIds = new Set(nodes.filter((n) => n.selected).map((n) => n.id)) + + let hasConflict = false + const resolved = nodes.map((n) => { + if (!n.selected) return n + const parentId = n.parentId || blocks[n.id]?.data?.parentId + if (parentId && selectedIds.has(parentId)) { + hasConflict = true + return { ...n, selected: false } + } + return n + }) + + return hasConflict ? resolved : nodes +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 03764beace..bbaad51aa8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -47,7 +47,7 @@ import { computeClampedPositionUpdates, getClampedPositionForNode, isInEditableElement, - selectNodesDeferred, + resolveParentChildSelectionConflicts, useAutoLayout, useCurrentWorkflow, useNodeUtilities, @@ -55,6 +55,7 @@ import { } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { useCanvasContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu' import { + calculateContainerDimensions, clampPositionToContainer, estimateBlockDimensions, } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities' @@ -356,6 +357,9 @@ const WorkflowContent = React.memo(() => { new Map() ) + /** Stores node IDs to select on next derivedNodes sync (for paste/duplicate operations). */ + const pendingSelectionRef = useRef | null>(null) + /** Re-applies diff markers when blocks change after socket rehydration. */ const blocksRef = useRef(blocks) useEffect(() => { @@ -687,6 +691,12 @@ const WorkflowContent = React.memo(() => { return } + // Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes) + pendingSelectionRef.current = new Set([ + ...(pendingSelectionRef.current ?? []), + ...pastedBlocksArray.map((b) => b.id), + ]) + collaborativeBatchAddBlocks( pastedBlocksArray, pastedEdges, @@ -694,11 +704,6 @@ const WorkflowContent = React.memo(() => { pastedParallels, pastedSubBlockValues ) - - selectNodesDeferred( - pastedBlocksArray.map((b) => b.id), - setDisplayNodes - ) }, [ hasClipboard, clipboard, @@ -735,6 +740,12 @@ const WorkflowContent = React.memo(() => { return } + // Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes) + pendingSelectionRef.current = new Set([ + ...(pendingSelectionRef.current ?? []), + ...pastedBlocksArray.map((b) => b.id), + ]) + collaborativeBatchAddBlocks( pastedBlocksArray, pastedEdges, @@ -742,11 +753,6 @@ const WorkflowContent = React.memo(() => { pastedParallels, pastedSubBlockValues ) - - selectNodesDeferred( - pastedBlocksArray.map((b) => b.id), - setDisplayNodes - ) }, [ contextMenuBlocks, copyBlocks, @@ -880,6 +886,12 @@ const WorkflowContent = React.memo(() => { return } + // Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes) + pendingSelectionRef.current = new Set([ + ...(pendingSelectionRef.current ?? []), + ...pastedBlocks.map((b) => b.id), + ]) + collaborativeBatchAddBlocks( pastedBlocks, pasteData.edges, @@ -887,11 +899,6 @@ const WorkflowContent = React.memo(() => { pasteData.parallels, pasteData.subBlockValues ) - - selectNodesDeferred( - pastedBlocks.map((b) => b.id), - setDisplayNodes - ) } } } @@ -1954,15 +1961,27 @@ const WorkflowContent = React.memo(() => { }, [isShiftPressed]) useEffect(() => { - // Preserve selection state when syncing from derivedNodes + // Check for pending selection (from paste/duplicate), otherwise preserve existing selection + const pendingSelection = pendingSelectionRef.current + pendingSelectionRef.current = null + setDisplayNodes((currentNodes) => { + if (pendingSelection) { + // Apply pending selection and resolve parent-child conflicts + const withSelection = derivedNodes.map((node) => ({ + ...node, + selected: pendingSelection.has(node.id), + })) + return resolveParentChildSelectionConflicts(withSelection, blocks) + } + // Preserve existing selection state const selectedIds = new Set(currentNodes.filter((n) => n.selected).map((n) => n.id)) return derivedNodes.map((node) => ({ ...node, selected: selectedIds.has(node.id), })) }) - }, [derivedNodes]) + }, [derivedNodes, blocks]) /** Handles ActionBar remove-from-subflow events. */ useEffect(() => { @@ -2037,10 +2056,17 @@ const WorkflowContent = React.memo(() => { window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener) }, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent]) - /** Handles node position changes - updates local state for smooth drag, syncs to store only on drag end. */ - const onNodesChange = useCallback((changes: NodeChange[]) => { - setDisplayNodes((nds) => applyNodeChanges(changes, nds)) - }, []) + /** Handles node changes - applies changes and resolves parent-child selection conflicts. */ + const onNodesChange = useCallback( + (changes: NodeChange[]) => { + setDisplayNodes((nds) => { + const updated = applyNodeChanges(changes, nds) + const hasSelectionChange = changes.some((c) => c.type === 'select') + return hasSelectionChange ? resolveParentChildSelectionConflicts(updated, blocks) : updated + }) + }, + [blocks] + ) /** * Updates container dimensions in displayNodes during drag. @@ -2055,28 +2081,13 @@ const WorkflowContent = React.memo(() => { const childNodes = currentNodes.filter((n) => n.parentId === parentId) if (childNodes.length === 0) return currentNodes - let maxRight = 0 - let maxBottom = 0 - - childNodes.forEach((node) => { + const childPositions = childNodes.map((node) => { const nodePosition = node.id === draggedNodeId ? draggedNodePosition : node.position - const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id) - - maxRight = Math.max(maxRight, nodePosition.x + nodeWidth) - maxBottom = Math.max(maxBottom, nodePosition.y + nodeHeight) + const { width, height } = getBlockDimensions(node.id) + return { x: nodePosition.x, y: nodePosition.y, width, height } }) - const newWidth = Math.max( - CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING - ) - const newHeight = Math.max( - CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - CONTAINER_DIMENSIONS.HEADER_HEIGHT + - CONTAINER_DIMENSIONS.TOP_PADDING + - maxBottom + - CONTAINER_DIMENSIONS.BOTTOM_PADDING - ) + const { width: newWidth, height: newHeight } = calculateContainerDimensions(childPositions) return currentNodes.map((node) => { if (node.id === parentId) { @@ -2844,30 +2855,42 @@ const WorkflowContent = React.memo(() => { }, [isShiftPressed]) const onSelectionEnd = useCallback(() => { - requestAnimationFrame(() => setIsSelectionDragActive(false)) - }, []) + requestAnimationFrame(() => { + setIsSelectionDragActive(false) + setDisplayNodes((nodes) => resolveParentChildSelectionConflicts(nodes, blocks)) + }) + }, [blocks]) /** Captures initial positions when selection drag starts (for marquee-selected nodes). */ const onSelectionDragStart = useCallback( (_event: React.MouseEvent, nodes: Node[]) => { - // Capture the parent ID of the first node as reference (they should all be in the same context) if (nodes.length > 0) { const firstNodeParentId = blocks[nodes[0].id]?.data?.parentId || null setDragStartParentId(firstNodeParentId) } - // Capture all selected nodes' positions for undo/redo + // Filter to nodes that won't be deselected (exclude children whose parent is selected) + const nodeIds = new Set(nodes.map((n) => n.id)) + const effectiveNodes = nodes.filter((n) => { + const parentId = blocks[n.id]?.data?.parentId + return !parentId || !nodeIds.has(parentId) + }) + + // Capture positions for undo/redo before applying display changes multiNodeDragStartRef.current.clear() - nodes.forEach((n) => { - const block = blocks[n.id] - if (block) { + effectiveNodes.forEach((n) => { + const blk = blocks[n.id] + if (blk) { multiNodeDragStartRef.current.set(n.id, { x: n.position.x, y: n.position.y, - parentId: block.data?.parentId, + parentId: blk.data?.parentId, }) } }) + + // Apply visual deselection of children + setDisplayNodes((allNodes) => resolveParentChildSelectionConflicts(allNodes, blocks)) }, [blocks] ) @@ -2903,7 +2926,6 @@ const WorkflowContent = React.memo(() => { eligibleNodes.forEach((node) => { const absolutePos = getNodeAbsolutePosition(node.id) - const block = blocks[node.id] const width = BLOCK_DIMENSIONS.FIXED_WIDTH const height = Math.max( node.height || BLOCK_DIMENSIONS.MIN_HEIGHT, @@ -3129,13 +3151,11 @@ const WorkflowContent = React.memo(() => { /** * Handles node click to select the node in ReactFlow. - * This ensures clicking anywhere on a block (not just the drag handle) - * selects it for delete/backspace and multi-select operations. + * Parent-child conflict resolution happens automatically in onNodesChange. */ const handleNodeClick = useCallback( (event: React.MouseEvent, node: Node) => { const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey - setNodes((nodes) => nodes.map((n) => ({ ...n,