diff --git a/sim/app/w/[id]/components/control-bar/control-bar.tsx b/sim/app/w/[id]/components/control-bar/control-bar.tsx index 7fcfa2843e..8b55a5b68b 100644 --- a/sim/app/w/[id]/components/control-bar/control-bar.tsx +++ b/sim/app/w/[id]/components/control-bar/control-bar.tsx @@ -898,6 +898,9 @@ export function ControlBar() { 'bg-[#802FFF] hover:bg-[#7028E6]', 'shadow-[0_0_0_0_#802FFF] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]', 'text-white transition-all duration-200', + (isExecuting || isMultiRunning) && + !isCancelling && + 'relative after:absolute after:inset-0 after:animate-pulse after:bg-white/20', 'disabled:opacity-50 disabled:hover:bg-[#802FFF] disabled:hover:shadow-none', 'rounded-l-none h-10' )} diff --git a/sim/app/w/[id]/components/panel/components/chat/chat.tsx b/sim/app/w/[id]/components/panel/components/chat/chat.tsx new file mode 100644 index 0000000000..b0d4f3bfcb --- /dev/null +++ b/sim/app/w/[id]/components/panel/components/chat/chat.tsx @@ -0,0 +1,343 @@ +'use client' + +import { KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react' +import { ArrowUp, ChevronDown } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { ScrollArea } from '@/components/ui/scroll-area' +import { cn } from '@/lib/utils' +import { useExecutionStore } from '@/stores/execution/store' +import { useChatStore } from '@/stores/panel/chat/store' +import { useConsoleStore } from '@/stores/panel/console/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { getBlock } from '@/blocks' +import { useWorkflowExecution } from '../../../../hooks/use-workflow-execution' +import { ChatMessage } from './components/chat-message' + +interface ChatProps { + panelWidth: number + chatMessage: string + setChatMessage: (message: string) => void +} + +export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) { + const [isOutputDropdownOpen, setIsOutputDropdownOpen] = useState(false) + const { activeWorkflowId } = useWorkflowRegistry() + const { messages, addMessage, selectedWorkflowOutputs, setSelectedWorkflowOutput } = + useChatStore() + const { entries } = useConsoleStore() + const blocks = useWorkflowStore((state) => state.blocks) + const messagesEndRef = useRef(null) + const dropdownRef = useRef(null) + + // Use the execution store state to track if a workflow is executing + const { isExecuting } = useExecutionStore() + + // Get workflow execution functionality + const { handleRunWorkflow, executionResult } = useWorkflowExecution() + + // Get workflow outputs for the dropdown + const workflowOutputs = useMemo(() => { + const outputs: { + id: string + label: string + blockId: string + blockName: string + blockType: string + path: string + }[] = [] + + if (!activeWorkflowId) return outputs + + // Process blocks to extract outputs + Object.values(blocks).forEach((block) => { + const blockName = block.name.replace(/\s+/g, '').toLowerCase() + + // Add response outputs + if (block.outputs && typeof block.outputs === 'object') { + const addOutput = (path: string, outputObj: any, prefix = '') => { + const fullPath = prefix ? `${prefix}.${path}` : path + + if (typeof outputObj === 'object' && outputObj !== null) { + // For objects, recursively add each property + Object.entries(outputObj).forEach(([key, value]) => { + addOutput(key, value, fullPath) + }) + } else { + // Add leaf node as output option + outputs.push({ + id: `${block.id}_${fullPath}`, + label: `${blockName}.${fullPath}`, + blockId: block.id, + blockName: block.name, + blockType: block.type, + path: fullPath, + }) + } + } + + // Start with the response object + if (block.outputs.response) { + addOutput('response', block.outputs.response) + } + } + }) + + return outputs + }, [blocks, activeWorkflowId]) + + // Get output entries from console for the dropdown + const outputEntries = useMemo(() => { + if (!activeWorkflowId) return [] + return entries.filter((entry) => entry.workflowId === activeWorkflowId && entry.output) + }, [entries, activeWorkflowId]) + + // Get filtered messages for current workflow + const workflowMessages = useMemo(() => { + if (!activeWorkflowId) return [] + return messages + .filter((msg) => msg.workflowId === activeWorkflowId) + .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) + }, [messages, activeWorkflowId]) + + // Get selected workflow output + const selectedOutput = useMemo(() => { + if (!activeWorkflowId) return null + const selectedId = selectedWorkflowOutputs[activeWorkflowId] + if (!selectedId) return outputEntries[0]?.id || null + return selectedId + }, [selectedWorkflowOutputs, activeWorkflowId, outputEntries]) + + // Get selected output display name + const selectedOutputDisplayName = useMemo(() => { + if (!selectedOutput) return 'Select output source' + const output = workflowOutputs.find((o) => o.id === selectedOutput) + return output + ? `${output.blockName.replace(/\s+/g, '').toLowerCase()}.${output.path}` + : 'Select output source' + }, [selectedOutput, workflowOutputs]) + + // Get selected output block info + const selectedOutputInfo = useMemo(() => { + if (!selectedOutput) return null + const output = workflowOutputs.find((o) => o.id === selectedOutput) + if (!output) return null + + return { + blockName: output.blockName, + blockId: output.blockId, + blockType: output.blockType, + path: output.path, + } + }, [selectedOutput, workflowOutputs]) + + // Auto-scroll to bottom when new messages are added + useEffect(() => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) + } + }, [workflowMessages]) + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOutputDropdownOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, []) + + // Handle send message + const handleSendMessage = async () => { + if (!chatMessage.trim() || !activeWorkflowId || isExecuting) return + + // Store the message being sent for reference + const sentMessage = chatMessage.trim() + + // Add user message + addMessage({ + content: sentMessage, + workflowId: activeWorkflowId, + type: 'user', + }) + + // Clear input + setChatMessage('') + + // Execute the workflow to generate a response, passing the chat message as input + // The workflow execution will trigger block executions which will add messages to the chat via the console store + await handleRunWorkflow({ input: sentMessage }) + } + + // Handle key press + const handleKeyPress = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + } + + // Handle output selection + const handleOutputSelection = (value: string) => { + if (activeWorkflowId) { + setSelectedWorkflowOutput(activeWorkflowId, value) + setIsOutputDropdownOpen(false) + } + } + + // Group output options by block + const groupedOutputs = useMemo(() => { + const groups: Record = {} + + // Group by block name + workflowOutputs.forEach((output) => { + if (!groups[output.blockName]) { + groups[output.blockName] = [] + } + groups[output.blockName].push(output) + }) + + return groups + }, [workflowOutputs]) + + // Get block color for an output + const getOutputColor = (blockId: string, blockType: string) => { + // Try to get the block's color from its configuration + const blockConfig = getBlock(blockType) + return blockConfig?.bgColor || '#2F55FF' // Default blue if not found + } + + return ( +
+ {/* Output Source Dropdown */} +
+
+ + + {isOutputDropdownOpen && workflowOutputs.length > 0 && ( +
+
+ {Object.entries(groupedOutputs).map(([blockName, outputs]) => ( +
+
+ {blockName} +
+
+ {outputs.map((output) => ( + + ))} +
+
+ ))} +
+
+ )} +
+
+ + {/* Main layout with fixed heights to ensure input stays visible */} +
+ {/* Chat messages section - Scrollable area */} +
+ +
+ {workflowMessages.length === 0 ? ( +
+ No messages yet +
+ ) : ( + workflowMessages.map((message) => ( + + )) + )} +
+
+ +
+ + {/* Input section - Fixed height */} +
+
+ setChatMessage(e.target.value)} + onKeyDown={handleKeyPress} + placeholder="Type a message..." + className="flex-1 focus-visible:ring-0 focus-visible:ring-offset-0 h-10" + disabled={!activeWorkflowId || isExecuting} + /> + +
+
+
+
+ ) +} diff --git a/sim/app/w/[id]/components/panel/components/chat/components/chat-message.tsx b/sim/app/w/[id]/components/panel/components/chat/components/chat-message.tsx new file mode 100644 index 0000000000..55e4ef25b6 --- /dev/null +++ b/sim/app/w/[id]/components/panel/components/chat/components/chat-message.tsx @@ -0,0 +1,97 @@ +import { useMemo } from 'react' +import { format, formatDistanceToNow } from 'date-fns' +import { Clock, Terminal, User } from 'lucide-react' +import { JSONView } from '../../console/components/json-view/json-view' + +interface ChatMessageProps { + message: { + id: string + content: any + timestamp: string | Date + type: 'user' | 'workflow' + } + containerWidth: number +} + +// Maximum character length for a word before it's broken up +const MAX_WORD_LENGTH = 25 + +const WordWrap = ({ text }: { text: string }) => { + if (!text) return null + + // Split text into words, keeping spaces and punctuation + const parts = text.split(/(\s+)/g) + + return ( + <> + {parts.map((part, index) => { + // If the part is whitespace or shorter than the max length, render it as is + if (part.match(/\s+/) || part.length <= MAX_WORD_LENGTH) { + return {part} + } + + // For long words, break them up into chunks + const chunks = [] + for (let i = 0; i < part.length; i += MAX_WORD_LENGTH) { + chunks.push(part.substring(i, i + MAX_WORD_LENGTH)) + } + + return ( + + {chunks.map((chunk, chunkIndex) => ( + {chunk} + ))} + + ) + })} + + ) +} + +export function ChatMessage({ message, containerWidth }: ChatMessageProps) { + const messageDate = useMemo(() => new Date(message.timestamp), [message.timestamp]) + + const relativeTime = useMemo(() => { + return formatDistanceToNow(messageDate, { addSuffix: true }) + }, [messageDate]) + + // Check if content is a JSON object + const isJsonObject = useMemo(() => { + return typeof message.content === 'object' && message.content !== null + }, [message.content]) + + // Format message content based on type + const formattedContent = useMemo(() => { + if (isJsonObject) { + return JSON.stringify(message.content) // Return stringified version for type safety + } + + return String(message.content) + }, [message.content, isJsonObject]) + + return ( +
+ {/* Header with time on left and message type on right */} +
+
+ + {relativeTime} +
+
+ {message.type !== 'user' && Workflow} +
+
+ + {/* Message content with proper word wrapping */} +
+ {isJsonObject ? ( + + ) : ( +
+ +
+ )} +
+
+ ) +} diff --git a/sim/app/w/[id]/components/panel/components/console/console.tsx b/sim/app/w/[id]/components/panel/components/console/console.tsx index 92da7e8e76..15987760ee 100644 --- a/sim/app/w/[id]/components/panel/components/console/console.tsx +++ b/sim/app/w/[id]/components/panel/components/console/console.tsx @@ -20,7 +20,7 @@ export function Console({ panelWidth }: ConsoleProps) { return ( -
+
{filteredEntries.length === 0 ? (
No console entries diff --git a/sim/app/w/[id]/components/panel/components/variables/variables.tsx b/sim/app/w/[id]/components/panel/components/variables/variables.tsx index f677565dee..e829747beb 100644 --- a/sim/app/w/[id]/components/panel/components/variables/variables.tsx +++ b/sim/app/w/[id]/components/panel/components/variables/variables.tsx @@ -156,7 +156,7 @@ export function Variables({ panelWidth }: VariablesProps) { return ( -
+
{/* Variables List */} {workflowVariables.length === 0 ? (
diff --git a/sim/app/w/[id]/components/panel/panel.tsx b/sim/app/w/[id]/components/panel/panel.tsx index ec1327f085..99aece16d1 100644 --- a/sim/app/w/[id]/components/panel/panel.tsx +++ b/sim/app/w/[id]/components/panel/panel.tsx @@ -3,15 +3,18 @@ import { useEffect, useState } from 'react' import { PanelRight } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useChatStore } from '@/stores/panel/chat/store' import { useConsoleStore } from '@/stores/panel/console/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { usePanelStore } from '../../../../../stores/panel/store' +import { Chat } from './components/chat/chat' import { Console } from './components/console/console' import { Variables } from './components/variables/variables' export function Panel() { const [width, setWidth] = useState(336) // 84 * 4 = 336px (default width) const [isDragging, setIsDragging] = useState(false) + const [chatMessage, setChatMessage] = useState('') const isOpen = usePanelStore((state) => state.isOpen) const togglePanel = usePanelStore((state) => state.togglePanel) @@ -19,6 +22,7 @@ export function Panel() { const setActiveTab = usePanelStore((state) => state.setActiveTab) const clearConsole = useConsoleStore((state) => state.clearConsole) + const clearChat = useChatStore((state) => state.clearChat) const { activeWorkflowId } = useWorkflowRegistry() const handleMouseDown = (e: React.MouseEvent) => { @@ -68,7 +72,7 @@ export function Panel() { return (
-
+ {/* Panel Header */} +
+
- {activeTab === 'console' && ( + {(activeTab === 'console' || activeTab === 'chat') && (
-
- {activeTab === 'console' ? ( + {/* Panel Content */} +
+ {activeTab === 'chat' ? ( + + ) : activeTab === 'console' ? ( ) : ( )}
-
+ {/* Panel Footer */} +