diff --git a/apps/sim/app/globals.css b/apps/sim/app/globals.css index ce107429c6..72042f3d0a 100644 --- a/apps/sim/app/globals.css +++ b/apps/sim/app/globals.css @@ -20,7 +20,7 @@ --panel-width: 244px; --toolbar-triggers-height: 300px; --editor-connections-height: 200px; - --terminal-height: 30px; + --terminal-height: 100px; } .sidebar-container { diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 6d8a3ec361..fcc1be4eda 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -114,7 +114,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) var toolbarParsed = JSON.parse(toolbarStored); var toolbarState = toolbarParsed?.state; var toolbarTriggersHeight = toolbarState?.toolbarTriggersHeight; - if (toolbarTriggersHeight !== undefined && toolbarTriggersHeight >= 100 && toolbarTriggersHeight <= 800) { + if (toolbarTriggersHeight !== undefined && toolbarTriggersHeight >= 30 && toolbarTriggersHeight <= 800) { document.documentElement.style.setProperty('--toolbar-triggers-height', toolbarTriggersHeight + 'px'); } } @@ -144,13 +144,13 @@ export default function RootLayout({ children }: { children: React.ReactNode }) var terminalParsed = JSON.parse(terminalStored); var terminalState = terminalParsed?.state; var terminalHeight = terminalState?.terminalHeight; - var maxTerminalHeight = window.innerHeight * 0.5; + var maxTerminalHeight = window.innerHeight * 0.7; - // Cap stored height at 50% of viewport + // Cap stored height at 70% of viewport if (terminalHeight >= 30 && terminalHeight <= maxTerminalHeight) { document.documentElement.style.setProperty('--terminal-height', terminalHeight + 'px'); } else if (terminalHeight > maxTerminalHeight) { - // If stored height exceeds 50%, cap it + // If stored height exceeds 70%, cap it document.documentElement.style.setProperty('--terminal-height', maxTerminalHeight + 'px'); } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx new file mode 100644 index 0000000000..d215a189d7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -0,0 +1,826 @@ +'use client' + +import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { AlertCircle, ArrowDownToLine, ArrowUp, MoreVertical, Paperclip, X } from 'lucide-react' +import { useParams } from 'next/navigation' +import { + Badge, + Button, + Input, + Popover, + PopoverContent, + PopoverItem, + PopoverScrollArea, + PopoverTrigger, + Trash, +} from '@/components/emcn' +import { createLogger } from '@/lib/logs/console/logger' +import { + extractBlockIdFromOutputId, + extractPathFromOutputId, + parseOutputContentSafely, +} from '@/lib/response-format' +import { cn } from '@/lib/utils' +import { useScrollManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' +import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' +import type { BlockLog, ExecutionResult } from '@/executor/types' +import { getChatPosition, useChatStore } from '@/stores/chat/store' +import { useExecutionStore } from '@/stores/execution/store' +import { useTerminalConsoleStore } from '@/stores/terminal' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { ChatMessage, OutputSelect } from './components' +import { useChatBoundarySync, useChatDrag, useChatFileUpload, useChatResize } from './hooks' + +const logger = createLogger('FloatingChat') + +/** + * Formats file size in human-readable format + * @param bytes - Size in bytes + * @returns Formatted string with appropriate unit (B, KB, MB, GB) + */ +const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(1024)) + return `${Math.round((bytes / 1024 ** i) * 10) / 10} ${units[i]}` +} + +/** + * Reads files and converts them to data URLs for image display + * @param chatFiles - Array of chat files to process + * @returns Promise resolving to array of files with data URLs for images + */ +const processFileAttachments = async (chatFiles: any[]) => { + return Promise.all( + chatFiles.map(async (file) => { + let dataUrl = '' + if (file.type.startsWith('image/')) { + try { + dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = reject + reader.readAsDataURL(file.file) + }) + } catch (error) { + logger.error('Error reading file as data URL:', error) + } + } + return { + id: file.id, + name: file.name, + type: file.type, + size: file.size, + dataUrl, + } + }) + ) +} + +/** + * Extracts output value from logs based on output ID + * @param logs - Array of block logs from workflow execution + * @param outputId - Output identifier in format blockId or blockId.path + * @returns Extracted output value or undefined if not found + */ +const extractOutputFromLogs = (logs: BlockLog[] | undefined, outputId: string): any | undefined => { + const blockId = extractBlockIdFromOutputId(outputId) + const path = extractPathFromOutputId(outputId, blockId) + const log = logs?.find((l) => l.blockId === blockId) + + if (!log) return undefined + + let output = log.output + + if (path) { + output = parseOutputContentSafely(output) + const pathParts = path.split('.') + let current = output + for (const part of pathParts) { + if (current && typeof current === 'object' && part in current) { + current = current[part] + } else { + return undefined + } + } + return current + } + + return output +} + +/** + * Formats output content for display in chat + * @param output - Output value to format (string, object, or other) + * @returns Formatted string, markdown code block for objects, or empty string + */ +const formatOutputContent = (output: any): string => { + if (typeof output === 'string') { + return output + } + if (output && typeof output === 'object') { + return `\`\`\`json\n${JSON.stringify(output, null, 2)}\n\`\`\`` + } + return '' +} + +/** + * Floating chat modal component + * + * A draggable chat interface positioned over the workflow canvas that allows users to: + * - Send messages and execute workflows + * - Upload and attach files + * - View streaming responses + * - Select workflow outputs as context + * + * The modal is constrained by sidebar, panel, and terminal dimensions and persists + * position across sessions using the floating chat store. + */ +export function Chat() { + const params = useParams() + const workspaceId = params.workspaceId as string + const { activeWorkflowId } = useWorkflowRegistry() + + // Chat state (UI and messages from unified store) + const { + isChatOpen, + chatPosition, + chatWidth, + chatHeight, + setIsChatOpen, + setChatPosition, + setChatDimensions, + messages, + addMessage, + selectedWorkflowOutputs, + setSelectedWorkflowOutput, + appendMessageContent, + finalizeMessageStream, + getConversationId, + clearChat, + exportChatCSV, + } = useChatStore() + + const { entries } = useTerminalConsoleStore() + const { isExecuting } = useExecutionStore() + const { handleRunWorkflow } = useWorkflowExecution() + + // Local state + const [chatMessage, setChatMessage] = useState('') + const [promptHistory, setPromptHistory] = useState([]) + const [historyIndex, setHistoryIndex] = useState(-1) + + // Refs + const inputRef = useRef(null) + const timeoutRef = useRef(null) + const abortControllerRef = useRef(null) + + // File upload hook + const { + chatFiles, + uploadErrors, + isDragOver, + removeFile, + clearFiles, + clearErrors, + handleFileInputChange, + handleDragEnter, + handleDragOver, + handleDragLeave, + handleDrop, + } = useChatFileUpload() + + // Get actual position (default if not set) + const actualPosition = useMemo( + () => getChatPosition(chatPosition, chatWidth, chatHeight), + [chatPosition, chatWidth, chatHeight] + ) + + // Drag hook + const { handleMouseDown } = useChatDrag({ + position: actualPosition, + width: chatWidth, + height: chatHeight, + onPositionChange: setChatPosition, + }) + + // Boundary sync hook - keeps chat within bounds when layout changes + useChatBoundarySync({ + isOpen: isChatOpen, + position: actualPosition, + width: chatWidth, + height: chatHeight, + onPositionChange: setChatPosition, + }) + + // Resize hook - enables resizing from all edges and corners + const { + cursor: resizeCursor, + handleMouseMove: handleResizeMouseMove, + handleMouseLeave: handleResizeMouseLeave, + handleMouseDown: handleResizeMouseDown, + } = useChatResize({ + position: actualPosition, + width: chatWidth, + height: chatHeight, + onPositionChange: setChatPosition, + onDimensionsChange: setChatDimensions, + }) + + // Get output entries from console + 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]) + + // Check if any message is currently streaming + const isStreaming = useMemo(() => { + // Match copilot semantics: only treat as streaming if the LAST message is streaming + const lastMessage = workflowMessages[workflowMessages.length - 1] + return Boolean(lastMessage?.isStreaming) + }, [workflowMessages]) + + // Map chat messages to copilot message format (type -> role) for scroll hook + const messagesForScrollHook = useMemo(() => { + return workflowMessages.map((msg) => ({ + ...msg, + role: msg.type, + })) + }, [workflowMessages]) + + // Scroll management hook - reuse copilot's implementation + const { scrollAreaRef, scrollToBottom } = useScrollManagement(messagesForScrollHook, isStreaming) + + // Memoize user messages for performance + const userMessages = useMemo(() => { + return workflowMessages + .filter((msg) => msg.type === 'user') + .map((msg) => msg.content) + .filter((content): content is string => typeof content === 'string') + }, [workflowMessages]) + + // Update prompt history when workflow changes + useEffect(() => { + if (!activeWorkflowId) { + setPromptHistory([]) + setHistoryIndex(-1) + return + } + + setPromptHistory(userMessages) + setHistoryIndex(-1) + }, [activeWorkflowId, userMessages]) + + /** + * Auto-scroll to bottom when messages load + */ + useEffect(() => { + if (workflowMessages.length > 0 && isChatOpen) { + scrollToBottom() + } + }, [workflowMessages.length, scrollToBottom, isChatOpen]) + + // Get selected workflow outputs (deduplicated) + const selectedOutputs = useMemo(() => { + if (!activeWorkflowId) return [] + const selected = selectedWorkflowOutputs[activeWorkflowId] + return selected && selected.length > 0 ? [...new Set(selected)] : [] + }, [selectedWorkflowOutputs, activeWorkflowId]) + + /** + * Focuses the input field with optional delay + */ + const focusInput = useCallback((delay = 0) => { + timeoutRef.current && clearTimeout(timeoutRef.current) + + timeoutRef.current = setTimeout(() => { + if (inputRef.current && document.contains(inputRef.current)) { + inputRef.current.focus({ preventScroll: true }) + } + }, delay) + }, []) + + // Cleanup on unmount + useEffect(() => { + return () => { + timeoutRef.current && clearTimeout(timeoutRef.current) + abortControllerRef.current?.abort() + } + }, []) + + /** + * Processes streaming response from workflow execution + */ + const processStreamingResponse = useCallback( + async (stream: ReadableStream, responseMessageId: string) => { + const reader = stream.getReader() + const decoder = new TextDecoder() + let accumulatedContent = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) { + finalizeMessageStream(responseMessageId) + break + } + + const chunk = decoder.decode(value) + const lines = chunk.split('\n\n') + + for (const line of lines) { + if (!line.startsWith('data: ')) continue + + const data = line.substring(6) + if (data === '[DONE]') continue + + try { + const json = JSON.parse(data) + const { event, data: eventData, chunk: contentChunk } = json + + if (event === 'final' && eventData) { + const result = eventData as ExecutionResult + + if ('success' in result && !result.success) { + const errorMessage = result.error || 'Workflow execution failed' + appendMessageContent( + responseMessageId, + `${accumulatedContent ? '\n\n' : ''}Error: ${errorMessage}` + ) + finalizeMessageStream(responseMessageId) + return + } + + finalizeMessageStream(responseMessageId) + } else if (contentChunk) { + accumulatedContent += contentChunk + appendMessageContent(responseMessageId, contentChunk) + } + } catch (e) { + logger.error('Error parsing stream data:', e) + } + } + } + } catch (error) { + logger.error('Error processing stream:', error) + } finally { + focusInput(100) + } + }, + [appendMessageContent, finalizeMessageStream, focusInput] + ) + + /** + * Handles workflow execution response + */ + const handleWorkflowResponse = useCallback( + (result: any) => { + if (!result || !activeWorkflowId) return + + // Handle streaming response + if ('stream' in result && result.stream instanceof ReadableStream) { + const responseMessageId = crypto.randomUUID() + addMessage({ + id: responseMessageId, + content: '', + workflowId: activeWorkflowId, + type: 'workflow', + isStreaming: true, + }) + processStreamingResponse(result.stream, responseMessageId) + return + } + + // Handle success with logs + if ('success' in result && result.success && 'logs' in result) { + selectedOutputs + .map((outputId) => extractOutputFromLogs(result.logs, outputId)) + .filter((output) => output !== undefined) + .forEach((output) => { + const content = formatOutputContent(output) + if (content) { + addMessage({ + content, + workflowId: activeWorkflowId, + type: 'workflow', + }) + } + }) + return + } + + // Handle error response + if ('success' in result && !result.success) { + const errorMessage = 'error' in result ? result.error : 'Workflow execution failed.' + addMessage({ + content: `Error: ${errorMessage}`, + workflowId: activeWorkflowId, + type: 'workflow', + }) + } + }, + [activeWorkflowId, selectedOutputs, addMessage, processStreamingResponse] + ) + + /** + * Sends a chat message and executes the workflow + */ + const handleSendMessage = useCallback(async () => { + if ((!chatMessage.trim() && chatFiles.length === 0) || !activeWorkflowId || isExecuting) return + + const sentMessage = chatMessage.trim() + + // Update prompt history (only if new unique message) + if (sentMessage && promptHistory[promptHistory.length - 1] !== sentMessage) { + setPromptHistory((prev) => [...prev, sentMessage]) + } + setHistoryIndex(-1) + + // Reset abort controller + abortControllerRef.current?.abort() + abortControllerRef.current = new AbortController() + + const conversationId = getConversationId(activeWorkflowId) + + try { + // Process file attachments + const attachmentsWithData = await processFileAttachments(chatFiles) + + // Add user message + const messageContent = + sentMessage || (chatFiles.length > 0 ? `Uploaded ${chatFiles.length} file(s)` : '') + addMessage({ + content: messageContent, + workflowId: activeWorkflowId, + type: 'user', + attachments: attachmentsWithData, + }) + + // Prepare workflow input + const workflowInput: any = { + input: sentMessage, + conversationId, + } + + if (chatFiles.length > 0) { + workflowInput.files = chatFiles.map((chatFile) => ({ + name: chatFile.name, + size: chatFile.size, + type: chatFile.type, + file: chatFile.file, + })) + workflowInput.onUploadError = (message: string) => { + logger.error('File upload error:', message) + } + } + + // Clear input and files + setChatMessage('') + clearFiles() + clearErrors() + focusInput(10) + + // Execute workflow + const result = await handleRunWorkflow(workflowInput) + handleWorkflowResponse(result) + } catch (error) { + logger.error('Error in handleSendMessage:', error) + } + + focusInput(100) + }, [ + chatMessage, + chatFiles, + activeWorkflowId, + isExecuting, + promptHistory, + getConversationId, + addMessage, + handleRunWorkflow, + handleWorkflowResponse, + focusInput, + clearFiles, + clearErrors, + ]) + + /** + * Handles keyboard input for chat + */ + const handleKeyPress = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } else if (e.key === 'ArrowUp') { + e.preventDefault() + if (promptHistory.length > 0) { + const newIndex = + historyIndex === -1 ? promptHistory.length - 1 : Math.max(0, historyIndex - 1) + setHistoryIndex(newIndex) + setChatMessage(promptHistory[newIndex]) + } + } else if (e.key === 'ArrowDown') { + e.preventDefault() + if (historyIndex >= 0) { + const newIndex = historyIndex + 1 + if (newIndex >= promptHistory.length) { + setHistoryIndex(-1) + setChatMessage('') + } else { + setHistoryIndex(newIndex) + setChatMessage(promptHistory[newIndex]) + } + } + } + }, + [handleSendMessage, promptHistory, historyIndex] + ) + + /** + * Handles output selection changes + */ + const handleOutputSelection = useCallback( + (values: string[]) => { + if (!activeWorkflowId) return + + const dedupedValues = [...new Set(values)] + setSelectedWorkflowOutput(activeWorkflowId, dedupedValues) + }, + [activeWorkflowId, setSelectedWorkflowOutput] + ) + + /** + * Closes the chat modal + */ + const handleClose = useCallback(() => { + setIsChatOpen(false) + }, [setIsChatOpen]) + + // Don't render if not open + if (!isChatOpen) return null + + return ( +
+ {/* Header with drag handle */} +
+
+ Chat +
+ + {/* Output selector - centered with mx-auto */} +
e.stopPropagation()}> + +
+ +
+ {/* More menu with actions */} + + + + + + + { + e.stopPropagation() + if (activeWorkflowId) exportChatCSV(activeWorkflowId) + }} + disabled={messages.length === 0} + > + + Download + + { + e.stopPropagation() + if (activeWorkflowId) clearChat(activeWorkflowId) + }} + disabled={messages.length === 0} + > + + Clear + + + + + + {/* Close button */} + +
+
+ + {/* Chat content */} +
+ {/* Messages */} +
+ {workflowMessages.length === 0 ? ( +
+ No messages yet +
+ ) : ( +
+
+ {workflowMessages.map((message) => ( + + ))} +
+
+ )} +
+ + {/* Input section */} +
+ {/* Error messages */} + {uploadErrors.length > 0 && ( +
+
+
+ +
+
+ File upload error +
+
+ {uploadErrors.map((err, idx) => ( +
+ {err} +
+ ))} +
+
+
+
+
+ )} + + {/* Combined input container */} +
+ {/* File thumbnails */} + {chatFiles.length > 0 && ( +
+ {chatFiles.map((file) => { + const isImage = file.type.startsWith('image/') + const previewUrl = isImage ? URL.createObjectURL(file.file) : null + + return ( +
+ {previewUrl ? ( + {file.name} URL.revokeObjectURL(previewUrl)} + /> + ) : ( +
+
+ {file.name} +
+
+ {formatFileSize(file.size)} +
+
+ )} + + +
+ ) + })} +
+ )} + + {/* Input field with inline buttons */} +
+ { + setChatMessage(e.target.value) + setHistoryIndex(-1) + }} + onKeyDown={handleKeyPress} + placeholder={isDragOver ? 'Drop files here...' : 'Type a message...'} + className='w-full border-0 bg-transparent pr-[56px] pl-[4px] shadow-none focus-visible:ring-0 focus-visible:ring-offset-0' + disabled={!activeWorkflowId || isExecuting} + /> + + {/* Buttons positioned absolutely on the right */} +
+ document.getElementById('floating-chat-file-input')?.click()} + title='Attach file' + className={cn( + 'cursor-pointer rounded-[6px] bg-transparent p-[0px] dark:bg-transparent', + (!activeWorkflowId || isExecuting || chatFiles.length >= 15) && + 'cursor-not-allowed opacity-50' + )} + > + + + + +
+
+ + {/* Hidden file input */} + +
+
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx new file mode 100644 index 0000000000..da26a12ffa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx @@ -0,0 +1,194 @@ +import { useMemo } from 'react' +import { File, FileText, Image as ImageIcon } from 'lucide-react' +import { StreamingIndicator } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/copilot-message/components/smooth-streaming' + +interface ChatAttachment { + id: string + name: string + type: string + dataUrl: string + size?: number +} + +interface ChatMessageProps { + message: { + id: string + content: any + timestamp: string | Date + type: 'user' | 'workflow' + isStreaming?: boolean + attachments?: ChatAttachment[] + } +} + +const MAX_WORD_LENGTH = 25 + +/** + * Formats file size in human-readable format + */ +const formatFileSize = (bytes?: number): string => { + if (!bytes || bytes === 0) return '' + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(1024)) + return `${Math.round((bytes / 1024 ** i) * 10) / 10} ${sizes[i]}` +} + +/** + * Returns appropriate icon for file type + */ +const getFileIcon = (type: string) => { + if (type.includes('pdf')) return + if (type.startsWith('image/')) return + if (type.includes('text') || type.includes('json')) + return + return +} + +/** + * Opens image attachment in new window + */ +const openImageInNewWindow = (dataUrl: string, fileName: string) => { + const newWindow = window.open('', '_blank') + if (!newWindow) return + + newWindow.document.write(` + + + + ${fileName} + + + + ${fileName} + + + `) + newWindow.document.close() +} + +/** + * Component for wrapping long words to prevent overflow + */ +const WordWrap = ({ text }: { text: string }) => { + if (!text) return null + + const parts = text.split(/(\s+)/g) + + return ( + <> + {parts.map((part, index) => { + if (part.match(/\s+/) || part.length <= MAX_WORD_LENGTH) { + return {part} + } + + 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} + ))} + + ) + })} + + ) +} + +/** + * Renders a chat message with optional file attachments + */ +export function ChatMessage({ message }: ChatMessageProps) { + const formattedContent = useMemo(() => { + if (typeof message.content === 'object' && message.content !== null) { + return JSON.stringify(message.content, null, 2) + } + return String(message.content || '') + }, [message.content]) + + const handleAttachmentClick = (attachment: ChatAttachment) => { + const validDataUrl = attachment.dataUrl?.trim() + if (validDataUrl?.startsWith('data:')) { + openImageInNewWindow(validDataUrl, attachment.name) + } + } + + if (message.type === 'user') { + return ( +
+ {message.attachments && message.attachments.length > 0 && ( +
+ {message.attachments.map((attachment) => { + const isImage = attachment.type.startsWith('image/') + const hasValidDataUrl = + attachment.dataUrl?.trim() && attachment.dataUrl.startsWith('data:') + + return ( +
{ + if (hasValidDataUrl) { + e.preventDefault() + e.stopPropagation() + handleAttachmentClick(attachment) + } + }} + > + {isImage && hasValidDataUrl ? ( + {attachment.name} + ) : ( + <> +
+ {getFileIcon(attachment.type)} +
+
+
+ {attachment.name} +
+ {attachment.size && ( +
+ {formatFileSize(attachment.size)} +
+ )} +
+ + )} +
+ ) + })} +
+ )} + + {formattedContent && !formattedContent.startsWith('Uploaded') && ( +
+
+ +
+
+ )} +
+ ) + } + + return ( +
+
+ + {message.isStreaming && } +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/index.ts similarity index 63% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/index.ts rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/index.ts index 80d8f0a1b7..d856183e02 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/index.ts @@ -1,3 +1,2 @@ -export { ChatFileUpload } from './chat-file-upload/chat-file-upload' export { ChatMessage } from './chat-message/chat-message' export { OutputSelect } from './output-select/output-select' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx new file mode 100644 index 0000000000..aade88c6c8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx @@ -0,0 +1,327 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { Check } from 'lucide-react' +import { + Badge, + Popover, + PopoverContent, + PopoverItem, + PopoverScrollArea, + PopoverSection, + PopoverTrigger, +} from '@/components/emcn' +import { extractFieldsFromSchema, parseResponseFormatSafely } from '@/lib/response-format' +import { getBlock } from '@/blocks' +import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' + +interface OutputSelectProps { + workflowId: string | null + selectedOutputs: string[] + onOutputSelect: (outputIds: string[]) => void + disabled?: boolean + placeholder?: string + valueMode?: 'id' | 'label' +} + +export function OutputSelect({ + workflowId, + selectedOutputs = [], + onOutputSelect, + disabled = false, + placeholder = 'Select outputs', + valueMode = 'id', +}: OutputSelectProps) { + const [open, setOpen] = useState(false) + const triggerRef = useRef(null) + const popoverRef = useRef(null) + const blocks = useWorkflowStore((state) => state.blocks) + const { isShowingDiff, isDiffReady, diffWorkflow } = useWorkflowDiffStore() + const subBlockValues = useSubBlockStore((state) => + workflowId ? state.workflowValues[workflowId] : null + ) + + /** + * Uses diff blocks when in diff mode, otherwise main blocks + */ + const workflowBlocks = isShowingDiff && isDiffReady && diffWorkflow ? diffWorkflow.blocks : blocks + + /** + * Extracts all available workflow outputs for the dropdown + */ + const workflowOutputs = useMemo(() => { + const outputs: Array<{ + id: string + label: string + blockId: string + blockName: string + blockType: string + path: string + }> = [] + + if (!workflowId || !workflowBlocks || typeof workflowBlocks !== 'object') { + return outputs + } + + const blockArray = Object.values(workflowBlocks) + if (blockArray.length === 0) return outputs + + blockArray.forEach((block) => { + if (block.type === 'starter' || !block?.id || !block?.type) return + + const blockName = + block.name && typeof block.name === 'string' + ? block.name.replace(/\s+/g, '').toLowerCase() + : `block-${block.id}` + + const blockConfig = getBlock(block.type) + const responseFormatValue = + isShowingDiff && isDiffReady && diffWorkflow + ? diffWorkflow.blocks[block.id]?.subBlocks?.responseFormat?.value + : subBlockValues?.[block.id]?.responseFormat + const responseFormat = parseResponseFormatSafely(responseFormatValue, block.id) + + let outputsToProcess: Record = {} + + if (responseFormat) { + const schemaFields = extractFieldsFromSchema(responseFormat) + if (schemaFields.length > 0) { + schemaFields.forEach((field) => { + outputsToProcess[field.name] = { type: field.type } + }) + } else { + outputsToProcess = blockConfig?.outputs || {} + } + } else { + outputsToProcess = blockConfig?.outputs || {} + } + + if (Object.keys(outputsToProcess).length === 0) return + + const addOutput = (path: string, outputObj: any, prefix = '') => { + const fullPath = prefix ? `${prefix}.${path}` : path + const createOutput = () => ({ + id: `${block.id}_${fullPath}`, + label: `${blockName}.${fullPath}`, + blockId: block.id, + blockName: block.name || `Block ${block.id}`, + blockType: block.type, + path: fullPath, + }) + + if ( + typeof outputObj !== 'object' || + outputObj === null || + ('type' in outputObj && typeof outputObj.type === 'string') || + Array.isArray(outputObj) + ) { + outputs.push(createOutput()) + return + } + + Object.entries(outputObj).forEach(([key, value]) => { + addOutput(key, value, fullPath) + }) + } + + Object.entries(outputsToProcess).forEach(([key, value]) => { + addOutput(key, value) + }) + }) + + return outputs + }, [workflowBlocks, workflowId, isShowingDiff, isDiffReady, diffWorkflow, blocks, subBlockValues]) + + /** + * Checks if output is selected by id or label + */ + const isSelectedValue = (o: { id: string; label: string }) => + selectedOutputs.includes(o.id) || selectedOutputs.includes(o.label) + + /** + * Gets display text for selected outputs + */ + const selectedOutputsDisplayText = useMemo(() => { + if (!selectedOutputs || selectedOutputs.length === 0) { + return placeholder + } + + const validOutputs = selectedOutputs.filter((val) => + workflowOutputs.some((o) => o.id === val || o.label === val) + ) + + if (validOutputs.length === 0) { + return placeholder + } + + if (validOutputs.length === 1) { + const output = workflowOutputs.find( + (o) => o.id === validOutputs[0] || o.label === validOutputs[0] + ) + return output?.label || placeholder + } + + return `${validOutputs.length} outputs` + }, [selectedOutputs, workflowOutputs, placeholder]) + + /** + * Groups outputs by block and sorts by distance from starter block + */ + const groupedOutputs = useMemo(() => { + const groups: Record = {} + const blockDistances: Record = {} + const edges = useWorkflowStore.getState().edges + + const starterBlock = Object.values(blocks).find((block) => block.type === 'starter') + const starterBlockId = starterBlock?.id + + if (starterBlockId) { + const adjList: Record = {} + edges.forEach((edge) => { + if (!adjList[edge.source]) adjList[edge.source] = [] + adjList[edge.source].push(edge.target) + }) + + const visited = new Set() + const queue: Array<[string, number]> = [[starterBlockId, 0]] + + while (queue.length > 0) { + const [currentNodeId, distance] = queue.shift()! + if (visited.has(currentNodeId)) continue + + visited.add(currentNodeId) + blockDistances[currentNodeId] = distance + + const outgoingNodeIds = adjList[currentNodeId] || [] + outgoingNodeIds.forEach((targetId) => { + queue.push([targetId, distance + 1]) + }) + } + } + + workflowOutputs.forEach((output) => { + if (!groups[output.blockName]) groups[output.blockName] = [] + groups[output.blockName].push(output) + }) + + return Object.entries(groups) + .map(([blockName, outputs]) => ({ + blockName, + outputs, + distance: blockDistances[outputs[0]?.blockId] || 0, + })) + .sort((a, b) => b.distance - a.distance) + .reduce( + (acc, { blockName, outputs }) => { + acc[blockName] = outputs + return acc + }, + {} as Record + ) + }, [workflowOutputs, blocks]) + + /** + * Gets block color for an output + */ + const getOutputColor = (blockId: string, blockType: string) => { + const blockConfig = getBlock(blockType) + return blockConfig?.bgColor || '#2F55FF' + } + + /** + * Handles output selection - toggle selection + */ + const handleOutputSelection = (value: string) => { + const emittedValue = + valueMode === 'label' ? value : workflowOutputs.find((o) => o.label === value)?.id || value + const index = selectedOutputs.indexOf(emittedValue) + + const newSelectedOutputs = + index === -1 + ? [...new Set([...selectedOutputs, emittedValue])] + : selectedOutputs.filter((id) => id !== emittedValue) + + onOutputSelect(newSelectedOutputs) + } + + /** + * Closes popover when clicking outside + */ + useEffect(() => { + if (!open) return + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node + const insideTrigger = triggerRef.current?.contains(target) + const insidePopover = popoverRef.current?.contains(target) + + if (!insideTrigger && !insidePopover) { + setOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [open]) + + return ( + + +
+ { + if (disabled || workflowOutputs.length === 0) return + e.stopPropagation() + setOpen((prev) => !prev) + }} + > + {selectedOutputsDisplayText} + +
+
+ e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > + + {Object.entries(groupedOutputs).map(([blockName, outputs]) => ( +
+ {blockName} + {outputs.map((output) => ( + handleOutputSelection(output.label)} + > +
+ + {blockName.charAt(0).toUpperCase()} + +
+ {output.path} + {isSelectedValue(output) && } +
+ ))} +
+ ))} +
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/index.ts new file mode 100644 index 0000000000..dc85c4971b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/index.ts @@ -0,0 +1,5 @@ +export { useChatBoundarySync } from './use-chat-boundary-sync' +export { useChatDrag } from './use-chat-drag' +export type { ChatFile } from './use-chat-file-upload' +export { useChatFileUpload } from './use-chat-file-upload' +export { useChatResize } from './use-chat-resize' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-boundary-sync.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-boundary-sync.ts new file mode 100644 index 0000000000..54be332579 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-boundary-sync.ts @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useRef } from 'react' + +interface UseChatBoundarySyncProps { + isOpen: boolean + position: { x: number; y: number } + width: number + height: number + onPositionChange: (position: { x: number; y: number }) => void +} + +/** + * Hook to synchronize chat position with layout boundary changes + * Keeps chat within bounds when sidebar, panel, or terminal resize + * Uses requestAnimationFrame for smooth real-time updates + */ +export function useChatBoundarySync({ + isOpen, + position, + width, + height, + onPositionChange, +}: UseChatBoundarySyncProps) { + const rafIdRef = useRef(null) + const positionRef = useRef(position) + const previousDimensionsRef = useRef({ sidebarWidth: 0, panelWidth: 0, terminalHeight: 0 }) + + // Keep position ref up to date + positionRef.current = position + + const checkAndUpdatePosition = useCallback(() => { + // Get current layout dimensions + const sidebarWidth = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0' + ) + const panelWidth = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0' + ) + const terminalHeight = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0' + ) + + // Check if dimensions actually changed + const prev = previousDimensionsRef.current + if ( + prev.sidebarWidth === sidebarWidth && + prev.panelWidth === panelWidth && + prev.terminalHeight === terminalHeight + ) { + return // No change, skip update + } + + // Update previous dimensions + previousDimensionsRef.current = { sidebarWidth, panelWidth, terminalHeight } + + // Calculate bounds + const minX = sidebarWidth + const maxX = window.innerWidth - panelWidth - width + const minY = 0 + const maxY = window.innerHeight - terminalHeight - height + + const currentPos = positionRef.current + + // Check if current position is out of bounds + if (currentPos.x < minX || currentPos.x > maxX || currentPos.y < minY || currentPos.y > maxY) { + // Constrain to new bounds + const newPosition = { + x: Math.max(minX, Math.min(maxX, currentPos.x)), + y: Math.max(minY, Math.min(maxY, currentPos.y)), + } + onPositionChange(newPosition) + } + }, [width, height, onPositionChange]) + + useEffect(() => { + if (!isOpen) return + + const handleResize = () => { + // Cancel any pending animation frame + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current) + } + + // Schedule update on next animation frame for smooth 60fps updates + rafIdRef.current = requestAnimationFrame(() => { + checkAndUpdatePosition() + rafIdRef.current = null + }) + } + + // Listen for window resize + window.addEventListener('resize', handleResize) + + // Create MutationObserver to watch for CSS variable changes + // This fires immediately when sidebar/panel/terminal resize + const observer = new MutationObserver(handleResize) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['style'], + }) + + // Initial check + checkAndUpdatePosition() + + return () => { + window.removeEventListener('resize', handleResize) + observer.disconnect() + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current) + } + } + }, [isOpen, checkAndUpdatePosition]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-drag.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-drag.ts new file mode 100644 index 0000000000..643e1d6a84 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-drag.ts @@ -0,0 +1,93 @@ +import { useCallback, useEffect, useRef } from 'react' +import { constrainChatPosition } from '@/stores/chat/store' + +interface UseChatDragProps { + position: { x: number; y: number } + width: number + height: number + onPositionChange: (position: { x: number; y: number }) => void +} + +/** + * Hook for handling drag functionality of floating chat modal + * Provides mouse event handlers and manages drag state + */ +export function useChatDrag({ position, width, height, onPositionChange }: UseChatDragProps) { + const isDraggingRef = useRef(false) + const dragStartRef = useRef({ x: 0, y: 0 }) + const initialPositionRef = useRef({ x: 0, y: 0 }) + + /** + * Handle mouse down on drag handle - start dragging + */ + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + // Only left click + if (e.button !== 0) return + + e.preventDefault() + e.stopPropagation() + + isDraggingRef.current = true + dragStartRef.current = { x: e.clientX, y: e.clientY } + initialPositionRef.current = { ...position } + + // Add dragging cursor to body + document.body.style.cursor = 'grabbing' + document.body.style.userSelect = 'none' + }, + [position] + ) + + /** + * Handle mouse move - update position while dragging + */ + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDraggingRef.current) return + + const deltaX = e.clientX - dragStartRef.current.x + const deltaY = e.clientY - dragStartRef.current.y + + const newPosition = { + x: initialPositionRef.current.x + deltaX, + y: initialPositionRef.current.y + deltaY, + } + + // Constrain to bounds + const constrainedPosition = constrainChatPosition(newPosition, width, height) + onPositionChange(constrainedPosition) + }, + [onPositionChange, width, height] + ) + + /** + * Handle mouse up - stop dragging + */ + const handleMouseUp = useCallback(() => { + if (!isDraggingRef.current) return + + isDraggingRef.current = false + + // Remove dragging cursor + document.body.style.cursor = '' + document.body.style.userSelect = '' + }, []) + + /** + * Set up global mouse event listeners + */ + useEffect(() => { + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) + + return () => { + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + } + }, [handleMouseMove, handleMouseUp]) + + return { + handleMouseDown, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.ts new file mode 100644 index 0000000000..b9ce26a0fd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-file-upload.ts @@ -0,0 +1,172 @@ +import { useCallback, useState } from 'react' + +export interface ChatFile { + id: string + name: string + size: number + type: string + file: File +} + +const MAX_FILES = 15 +const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB + +/** + * Hook for handling file uploads in the chat modal + * Manages file state, validation, and drag-drop functionality + */ +export function useChatFileUpload() { + const [chatFiles, setChatFiles] = useState([]) + const [uploadErrors, setUploadErrors] = useState([]) + const [dragCounter, setDragCounter] = useState(0) + + const isDragOver = dragCounter > 0 + + /** + * Validate and add files + */ + const addFiles = useCallback( + (files: File[]) => { + const remainingSlots = Math.max(0, MAX_FILES - chatFiles.length) + const candidateFiles = files.slice(0, remainingSlots) + const errors: string[] = [] + const validNewFiles: ChatFile[] = [] + + for (const file of candidateFiles) { + // Check file size + if (file.size > MAX_FILE_SIZE) { + errors.push(`${file.name} is too large (max 10MB)`) + continue + } + + // Check for duplicates + const isDuplicate = chatFiles.some( + (existingFile) => existingFile.name === file.name && existingFile.size === file.size + ) + if (isDuplicate) { + errors.push(`${file.name} already added`) + continue + } + + validNewFiles.push({ + id: crypto.randomUUID(), + name: file.name, + size: file.size, + type: file.type, + file, + }) + } + + if (errors.length > 0) { + setUploadErrors(errors) + } + + if (validNewFiles.length > 0) { + setChatFiles([...chatFiles, ...validNewFiles]) + // Clear errors when files are successfully added + if (errors.length === 0) { + setUploadErrors([]) + } + } + }, + [chatFiles] + ) + + /** + * Remove a file + */ + const removeFile = useCallback((fileId: string) => { + setChatFiles((prev) => prev.filter((f) => f.id !== fileId)) + }, []) + + /** + * Clear all files + */ + const clearFiles = useCallback(() => { + setChatFiles([]) + setUploadErrors([]) + }, []) + + /** + * Clear errors + */ + const clearErrors = useCallback(() => { + setUploadErrors([]) + }, []) + + /** + * Handle file input change + */ + const handleFileInputChange = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files + if (!files) return + + const fileArray = Array.from(files) + addFiles(fileArray) + + // Reset input value to allow selecting the same file again + e.target.value = '' + }, + [addFiles] + ) + + /** + * Handle drag enter + */ + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragCounter((prev) => prev + 1) + }, []) + + /** + * Handle drag over + */ + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'copy' + }, []) + + /** + * Handle drag leave + */ + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragCounter((prev) => Math.max(0, prev - 1)) + }, []) + + /** + * Handle drop + */ + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragCounter(0) + + const droppedFiles = Array.from(e.dataTransfer.files) + if (droppedFiles.length > 0) { + addFiles(droppedFiles) + } + }, + [addFiles] + ) + + return { + chatFiles, + uploadErrors, + isDragOver, + addFiles, + removeFile, + clearFiles, + clearErrors, + handleFileInputChange, + handleDragEnter, + handleDragOver, + handleDragLeave, + handleDrop, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-resize.ts new file mode 100644 index 0000000000..e9050264d8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-resize.ts @@ -0,0 +1,315 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + MAX_CHAT_HEIGHT, + MAX_CHAT_WIDTH, + MIN_CHAT_HEIGHT, + MIN_CHAT_WIDTH, +} from '@/stores/chat/store' + +interface UseChatResizeProps { + position: { x: number; y: number } + width: number + height: number + onPositionChange: (position: { x: number; y: number }) => void + onDimensionsChange: (dimensions: { width: number; height: number }) => void +} + +/** + * Resize direction types - supports all 8 directions (4 corners + 4 edges) + */ +type ResizeDirection = + | 'top-left' + | 'top' + | 'top-right' + | 'right' + | 'bottom-right' + | 'bottom' + | 'bottom-left' + | 'left' + | null + +/** + * Edge detection threshold in pixels (matches sidebar/panel resize handle width) + */ +const EDGE_THRESHOLD = 8 + +/** + * Hook for handling multi-directional resize functionality of floating chat modal + * Supports resizing from all 8 directions: 4 corners and 4 edges + */ +export function useChatResize({ + position, + width, + height, + onPositionChange, + onDimensionsChange, +}: UseChatResizeProps) { + const [cursor, setCursor] = useState('') + const isResizingRef = useRef(false) + const activeDirectionRef = useRef(null) + const resizeStartRef = useRef({ x: 0, y: 0 }) + const initialStateRef = useRef({ + x: 0, + y: 0, + width: 0, + height: 0, + }) + + /** + * Detect which edge or corner the mouse is near + * @param e - Mouse event + * @param chatElement - Chat container element + * @returns The direction the mouse is near, or null + */ + const detectResizeDirection = useCallback( + (e: React.MouseEvent, chatElement: HTMLElement): ResizeDirection => { + const rect = chatElement.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + + const isNearTop = y <= EDGE_THRESHOLD + const isNearBottom = y >= rect.height - EDGE_THRESHOLD + const isNearLeft = x <= EDGE_THRESHOLD + const isNearRight = x >= rect.width - EDGE_THRESHOLD + + // Check corners first (they take priority over edges) + if (isNearTop && isNearLeft) return 'top-left' + if (isNearTop && isNearRight) return 'top-right' + if (isNearBottom && isNearLeft) return 'bottom-left' + if (isNearBottom && isNearRight) return 'bottom-right' + + // Check edges + if (isNearTop) return 'top' + if (isNearBottom) return 'bottom' + if (isNearLeft) return 'left' + if (isNearRight) return 'right' + + return null + }, + [] + ) + + /** + * Get cursor style for a given resize direction + */ + const getCursorForDirection = useCallback((direction: ResizeDirection): string => { + switch (direction) { + case 'top-left': + case 'bottom-right': + return 'nwse-resize' + case 'top-right': + case 'bottom-left': + return 'nesw-resize' + case 'top': + case 'bottom': + return 'ns-resize' + case 'left': + case 'right': + return 'ew-resize' + default: + return '' + } + }, []) + + /** + * Handle mouse move over chat - update cursor based on proximity to edges/corners + */ + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (isResizingRef.current) return + + const chatElement = e.currentTarget as HTMLElement + const direction = detectResizeDirection(e, chatElement) + const newCursor = getCursorForDirection(direction) + + if (newCursor !== cursor) { + setCursor(newCursor) + } + }, + [cursor, detectResizeDirection, getCursorForDirection] + ) + + /** + * Handle mouse leave - reset cursor + */ + const handleMouseLeave = useCallback(() => { + if (!isResizingRef.current) { + setCursor('') + } + }, []) + + /** + * Handle mouse down on edge/corner - start resizing + */ + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + // Only left click + if (e.button !== 0) return + + const chatElement = e.currentTarget as HTMLElement + const direction = detectResizeDirection(e, chatElement) + + if (!direction) return + + e.preventDefault() + e.stopPropagation() + + isResizingRef.current = true + activeDirectionRef.current = direction + resizeStartRef.current = { x: e.clientX, y: e.clientY } + initialStateRef.current = { + x: position.x, + y: position.y, + width, + height, + } + + // Set cursor on body + document.body.style.cursor = getCursorForDirection(direction) + document.body.style.userSelect = 'none' + }, + [position, width, height, detectResizeDirection, getCursorForDirection] + ) + + /** + * Handle global mouse move - update dimensions while resizing + */ + const handleGlobalMouseMove = useCallback( + (e: MouseEvent) => { + if (!isResizingRef.current || !activeDirectionRef.current) return + + const deltaX = e.clientX - resizeStartRef.current.x + const deltaY = e.clientY - resizeStartRef.current.y + const initial = initialStateRef.current + const direction = activeDirectionRef.current + + let newX = initial.x + let newY = initial.y + let newWidth = initial.width + let newHeight = initial.height + + // Get layout bounds + const sidebarWidth = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0' + ) + const panelWidth = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0' + ) + const terminalHeight = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0' + ) + + // Calculate new dimensions based on resize direction + switch (direction) { + // Corners + case 'top-left': + newWidth = initial.width - deltaX + newHeight = initial.height - deltaY + newX = initial.x + deltaX + newY = initial.y + deltaY + break + case 'top-right': + newWidth = initial.width + deltaX + newHeight = initial.height - deltaY + newY = initial.y + deltaY + break + case 'bottom-left': + newWidth = initial.width - deltaX + newHeight = initial.height + deltaY + newX = initial.x + deltaX + break + case 'bottom-right': + newWidth = initial.width + deltaX + newHeight = initial.height + deltaY + break + + // Edges + case 'top': + newHeight = initial.height - deltaY + newY = initial.y + deltaY + break + case 'bottom': + newHeight = initial.height + deltaY + break + case 'left': + newWidth = initial.width - deltaX + newX = initial.x + deltaX + break + case 'right': + newWidth = initial.width + deltaX + break + } + + // Constrain dimensions to min/max + const constrainedWidth = Math.max(MIN_CHAT_WIDTH, Math.min(MAX_CHAT_WIDTH, newWidth)) + const constrainedHeight = Math.max(MIN_CHAT_HEIGHT, Math.min(MAX_CHAT_HEIGHT, newHeight)) + + // Adjust position if dimensions were constrained on left/top edges + if (direction === 'top-left' || direction === 'bottom-left' || direction === 'left') { + if (constrainedWidth !== newWidth) { + newX = initial.x + initial.width - constrainedWidth + } + } + if (direction === 'top-left' || direction === 'top-right' || direction === 'top') { + if (constrainedHeight !== newHeight) { + newY = initial.y + initial.height - constrainedHeight + } + } + + // Constrain position to bounds + const minX = sidebarWidth + const maxX = window.innerWidth - panelWidth - constrainedWidth + const minY = 0 + const maxY = window.innerHeight - terminalHeight - constrainedHeight + + const finalX = Math.max(minX, Math.min(maxX, newX)) + const finalY = Math.max(minY, Math.min(maxY, newY)) + + // Update state + onDimensionsChange({ + width: constrainedWidth, + height: constrainedHeight, + }) + onPositionChange({ + x: finalX, + y: finalY, + }) + }, + [onDimensionsChange, onPositionChange] + ) + + /** + * Handle global mouse up - stop resizing + */ + const handleGlobalMouseUp = useCallback(() => { + if (!isResizingRef.current) return + + isResizingRef.current = false + activeDirectionRef.current = null + + // Remove cursor from body + document.body.style.cursor = '' + document.body.style.userSelect = '' + setCursor('') + }, []) + + /** + * Set up global mouse event listeners + */ + useEffect(() => { + window.addEventListener('mousemove', handleGlobalMouseMove) + window.addEventListener('mouseup', handleGlobalMouseUp) + + return () => { + window.removeEventListener('mousemove', handleGlobalMouseMove) + window.removeEventListener('mouseup', handleGlobalMouseUp) + } + }, [handleGlobalMouseMove, handleGlobalMouseUp]) + + return { + cursor, + handleMouseMove, + handleMouseLeave, + handleMouseDown, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx index 0aaa0abf31..836c5b1193 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx @@ -23,12 +23,12 @@ import { } from '@/components/ui' import { createLogger } from '@/lib/logs/console/logger' import { getEmailDomain } from '@/lib/urls/utils' +import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select' import { AuthSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector' import { IdentifierInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/identifier-input' import { SuccessView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/success-view' import { useChatDeployment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-deployment' import { useChatForm } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form' -import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select' const logger = createLogger('ChatDeploy') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-deployment.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-deployment.ts index cec1488111..b99486a9c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-deployment.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-deployment.ts @@ -2,7 +2,7 @@ import { useCallback, useState } from 'react' import { z } from 'zod' import { createLogger } from '@/lib/logs/console/logger' import type { ChatFormData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form' -import type { OutputConfig } from '@/stores/panel/chat/types' +import type { OutputConfig } from '@/stores/chat/store' const logger = createLogger('ChatDeployment') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx index 4ba1a4cb4f..abb39c3425 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components/example-command/example-command.tsx @@ -12,7 +12,7 @@ import { } from '@/components/ui/dropdown-menu' import { Label } from '@/components/ui/label' import { getEnv, isTruthy } from '@/lib/env' -import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select' +import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select' interface ExampleCommandProps { command: string diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/copilot-message/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/copilot-message/components/index.ts index e713631e35..cca1f618e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/copilot-message/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/copilot-message/components/index.ts @@ -1,4 +1,4 @@ export * from './file-display' -export * from './markdown-renderer' +export { default as CopilotMarkdownRenderer } from './markdown-renderer' export * from './smooth-streaming' export * from './thinking-block' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/copilot-message/components/markdown-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/copilot-message/components/markdown-renderer.tsx index 81a351590f..308d238948 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/copilot-message/components/markdown-renderer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/copilot-message/components/markdown-renderer.tsx @@ -6,6 +6,11 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { Tooltip } from '@/components/emcn' +/** + * Recursively extracts text content from React elements + * @param element - React node to extract text from + * @returns Concatenated text content + */ const getTextContent = (element: React.ReactNode): string => { if (typeof element === 'string') { return element @@ -91,7 +96,12 @@ if (typeof document !== 'undefined') { } } -// Link component with preview +/** + * Link component with hover preview tooltip + * Displays full URL on hover for better UX + * @param props - Component props with href and children + * @returns Link element with tooltip preview + */ function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) { return ( @@ -112,10 +122,22 @@ function LinkWithPreview({ href, children }: { href: string; children: React.Rea ) } +/** + * Props for the CopilotMarkdownRenderer component + */ interface CopilotMarkdownRendererProps { + /** Markdown content to render */ content: string } +/** + * CopilotMarkdownRenderer renders markdown content with custom styling + * Supports GitHub-flavored markdown, code blocks with syntax highlighting, + * tables, links with preview, and more + * + * @param props - Component props + * @returns Rendered markdown content + */ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRendererProps) { const [copiedCodeBlocks, setCopiedCodeBlocks] = useState>({}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/copilot-message/components/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/copilot-message/components/thinking-block.tsx index 31128afd53..676c06fcd4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/copilot-message/components/thinking-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/copilot-message/components/thinking-block.tsx @@ -15,18 +15,25 @@ const TIMER_UPDATE_INTERVAL = 100 const SECONDS_THRESHOLD = 1000 /** - * ShimmerOverlayText component for thinking block - * Applies shimmer effect to the "Thought for X.Xs" text during streaming + * Props for the ShimmerOverlayText component */ -function ShimmerOverlayText({ - label, - value, - active = false, -}: { +interface ShimmerOverlayTextProps { + /** Label text to display */ label: string + /** Value text to display */ value: string + /** Whether the shimmer animation is active */ active?: boolean -}) { +} + +/** + * ShimmerOverlayText component for thinking block + * Applies shimmer effect to the "Thought for X.Xs" text during streaming + * + * @param props - Component props + * @returns Text with optional shimmer overlay effect + */ +function ShimmerOverlayText({ label, value, active = false }: ShimmerOverlayTextProps) { return ( {label} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/attached-files-display.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/attached-files-display/attached-files-display.tsx similarity index 88% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/attached-files-display.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/attached-files-display/attached-files-display.tsx index 3e3b70cb1d..a240cabc9c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/attached-files-display.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/attached-files-display/attached-files-display.tsx @@ -1,7 +1,7 @@ 'use client' import { FileText, Image, Loader2, X } from 'lucide-react' -import { Button } from '@/components/ui' +import { Button } from '@/components/emcn' interface AttachedFile { id: string @@ -67,11 +67,11 @@ export function AttachedFilesDisplay({ const isImageFile = (type: string) => type.startsWith('image/') return ( -
+
{files.map((file) => (
onFileClick(file)} > @@ -103,12 +103,11 @@ export function AttachedFilesDisplay({ {!file.uploading && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/context-pills.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/context-pills/context-pills.tsx similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/context-pills.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/context-pills/context-pills.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/index.ts index f410cc9c33..fd7d64cff1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/index.ts @@ -1,5 +1,5 @@ -export { AttachedFilesDisplay } from './attached-files-display' -export { ContextPills } from './context-pills' -export { MentionMenuPortal } from './mention-menu-portal' -export { ModeSelector } from './mode-selector' -export { ModelSelector } from './model-selector' +export { AttachedFilesDisplay } from './attached-files-display/attached-files-display' +export { ContextPills } from './context-pills/context-pills' +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-new/components/copilot/components/user-input/components/mention-menu-portal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/mention-menu/mention-menu.tsx similarity index 98% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/mention-menu-portal.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/mention-menu/mention-menu.tsx index 89f059b2b6..fc9755edfd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/mention-menu-portal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/mention-menu/mention-menu.tsx @@ -10,9 +10,9 @@ import { PopoverItem, PopoverScrollArea, } from '@/components/emcn' -import type { useMentionData } from '../hooks/use-mention-data' -import type { useMentionMenu } from '../hooks/use-mention-menu' -import { formatTimestamp } from '../utils' +import type { useMentionData } from '../../hooks/use-mention-data' +import type { useMentionMenu } from '../../hooks/use-mention-menu' +import { formatTimestamp } from '../../utils' /** * Common text styling for loading and empty states @@ -50,7 +50,7 @@ interface AggregatedItem { icon?: React.ReactNode } -interface MentionMenuPortalProps { +interface MentionMenuProps { mentionMenu: ReturnType mentionData: ReturnType message: string @@ -67,19 +67,19 @@ interface MentionMenuPortalProps { } /** - * Portal component for mention menu dropdown. + * MentionMenu component for mention menu dropdown. * Handles rendering of mention options, submenus, and aggregated search results. * Manages keyboard navigation and selection of mentions. * * @param props - Component props - * @returns Rendered mention menu portal + * @returns Rendered mention menu */ -export function MentionMenuPortal({ +export function MentionMenu({ mentionMenu, mentionData, message, insertHandlers, -}: MentionMenuPortalProps) { +}: MentionMenuProps) { const { mentionMenuRef, menuListRef, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/mode-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/mode-selector/mode-selector.tsx similarity index 69% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/mode-selector.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/mode-selector/mode-selector.tsx index f2c87dbefe..8ea44c96e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/mode-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/mode-selector/mode-selector.tsx @@ -9,7 +9,6 @@ import { PopoverContent, PopoverItem, PopoverScrollArea, - Tooltip, } from '@/components/emcn' import { cn } from '@/lib/utils' @@ -114,44 +113,19 @@ export function ModeSelector({ mode, onModeChange, isNearTop, disabled }: ModeSe side={isNearTop ? 'bottom' : 'top'} align='start' sideOffset={4} - className='w-[160px]' + style={{ width: '120px', minWidth: '120px' }} onOpenAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} > - - - handleSelect('ask')}> - - Ask - - - - Ask mode can help answer questions about your workflow, tell you about Sim, and guide - you in building/editing. - - - - - handleSelect('build')}> - - Build - - - - Build mode can build, edit, and interact with your workflows (Recommended) - - + handleSelect('ask')}> + + Ask + + handleSelect('build')}> + + Build + diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/model-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/model-selector/model-selector.tsx similarity index 98% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/model-selector.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/model-selector/model-selector.tsx index c94f030b94..7c639ed010 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/model-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/components/model-selector/model-selector.tsx @@ -10,7 +10,7 @@ import { PopoverScrollArea, } from '@/components/emcn' import { getProviderIcon } from '@/providers/utils' -import { MODEL_OPTIONS } from '../constants' +import { MODEL_OPTIONS } from '../../constants' interface ModelSelectorProps { /** Currently selected model */ @@ -120,7 +120,6 @@ export function ModelSelector({ selectedModel, isNearTop, onModelSelect }: Model align='start' sideOffset={4} maxHeight={280} - className='w-[220px]' onOpenAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/user-input.tsx index ede20cfa1c..16607f4575 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/components/user-input/user-input.tsx @@ -21,7 +21,7 @@ import type { ChatContext } from '@/stores/panel-new/copilot/types' import { AttachedFilesDisplay, ContextPills, - MentionMenuPortal, + MentionMenu, ModelSelector, ModeSelector, } from './components' @@ -569,8 +569,7 @@ const UserInput = forwardRef( ref={setInputContainerRef} className={cn( 'relative rounded-[4px] border border-[#3D3D3D] bg-[#282828] px-[6px] py-[6px] transition-colors dark:bg-[#363636]', - fileAttachments.isDragging && - 'border-[var(--brand-primary-hover-hex)] bg-purple-50/50 dark:border-[var(--brand-primary-hover-hex)] dark:bg-purple-950/20' + fileAttachments.isDragging && 'ring-[#33B4FF] ring-[1.75px]' )} onDragEnter={fileAttachments.handleDragEnter} onDragLeave={fileAttachments.handleDragLeave} @@ -642,7 +641,7 @@ const UserInput = forwardRef( {/* Mention Menu Portal */} {mentionMenu.showMentionMenu && createPortal( - + state.getWorkflowDeploymentStatus(activeWorkflowId) + ) + const isDeployed = deploymentStatus?.isDeployed || false + + // Fetch and manage deployed state + const { deployedState, isLoadingDeployedState, refetchDeployedState } = useDeployedState({ + workflowId: activeWorkflowId, + isDeployed, + isRegistryLoading, + }) + + // Detect changes between current and deployed state + const { changeDetected, setChangeDetected } = useChangeDetection({ + workflowId: activeWorkflowId, + deployedState, + isLoadingDeployedState, + }) + + // Handle deployment operations + const { isDeploying, handleDeployClick } = useDeployment({ + workflowId: activeWorkflowId, + isDeployed, + refetchDeployedState, + }) + + const canDeploy = userPermissions.canAdmin + const isDisabled = isDeploying || !canDeploy + const isPreviousVersionActive = isDeployed && changeDetected + + /** + * Handle deploy button click + */ + const onDeployClick = useCallback(async () => { + if (!canDeploy || !activeWorkflowId) return + + const result = await handleDeployClick() + if (result.shouldOpenModal) { + setIsModalOpen(true) + } + }, [canDeploy, activeWorkflowId, handleDeployClick]) + + const refetchWithErrorHandling = async () => { + if (!activeWorkflowId) return + + try { + await refetchDeployedState() + } catch (error) { + // Error already logged in hook + } + } + + return ( + <> + + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/deploy/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/deploy/hooks/index.ts new file mode 100644 index 0000000000..6d0f191100 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/deploy/hooks/index.ts @@ -0,0 +1,3 @@ +export { useChangeDetection } from './use-change-detection' +export { useDeployedState } from './use-deployed-state' +export { useDeployment } from './use-deployment' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/deploy/hooks/use-change-detection.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/deploy/hooks/use-change-detection.ts new file mode 100644 index 0000000000..e9adbebc26 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/deploy/hooks/use-change-detection.ts @@ -0,0 +1,111 @@ +import { useEffect, useMemo, useState } from 'react' +import { createLogger } from '@/lib/logs/console/logger' +import { useDebounce } from '@/hooks/use-debounce' +import { useOperationQueueStore } from '@/stores/operation-queue/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +const logger = createLogger('useChangeDetection') + +interface UseChangeDetectionProps { + workflowId: string | null + deployedState: WorkflowState | null + isLoadingDeployedState: boolean +} + +/** + * Hook to detect changes between current workflow state and deployed state + * Uses API-based change detection for accuracy + */ +export function useChangeDetection({ + workflowId, + deployedState, + isLoadingDeployedState, +}: UseChangeDetectionProps) { + const [changeDetected, setChangeDetected] = useState(false) + const [blockStructureVersion, setBlockStructureVersion] = useState(0) + const [edgeStructureVersion, setEdgeStructureVersion] = useState(0) + const [subBlockStructureVersion, setSubBlockStructureVersion] = useState(0) + + // Get current store state for change detection + const currentBlocks = useWorkflowStore((state) => state.blocks) + const currentEdges = useWorkflowStore((state) => state.edges) + const lastSaved = useWorkflowStore((state) => state.lastSaved) + const subBlockValues = useSubBlockStore((state) => + workflowId ? state.workflowValues[workflowId] : null + ) + + // Track structure changes + useEffect(() => { + setBlockStructureVersion((version) => version + 1) + }, [currentBlocks]) + + useEffect(() => { + setEdgeStructureVersion((version) => version + 1) + }, [currentEdges]) + + useEffect(() => { + setSubBlockStructureVersion((version) => version + 1) + }, [subBlockValues]) + + // Reset version counters when workflow changes + useEffect(() => { + setBlockStructureVersion(0) + setEdgeStructureVersion(0) + setSubBlockStructureVersion(0) + }, [workflowId]) + + // Create trigger for status check + const statusCheckTrigger = useMemo(() => { + return JSON.stringify({ + lastSaved: lastSaved ?? 0, + blockVersion: blockStructureVersion, + edgeVersion: edgeStructureVersion, + subBlockVersion: subBlockStructureVersion, + }) + }, [lastSaved, blockStructureVersion, edgeStructureVersion, subBlockStructureVersion]) + + const debouncedStatusCheckTrigger = useDebounce(statusCheckTrigger, 500) + + useEffect(() => { + // Avoid off-by-one false positives: wait until operation queue is idle + const { operations, isProcessing } = useOperationQueueStore.getState() + const hasPendingOps = + isProcessing || operations.some((op) => op.status === 'pending' || op.status === 'processing') + + if (!workflowId || !deployedState) { + setChangeDetected(false) + return + } + + if (isLoadingDeployedState || hasPendingOps) { + return + } + + // Use the workflow status API to get accurate change detection + // This uses the same logic as the deployment API (reading from normalized tables) + const checkForChanges = async () => { + try { + const response = await fetch(`/api/workflows/${workflowId}/status`) + if (response.ok) { + const data = await response.json() + setChangeDetected(data.needsRedeployment || false) + } else { + logger.error('Failed to fetch workflow status:', response.status, response.statusText) + setChangeDetected(false) + } + } catch (error) { + logger.error('Error fetching workflow status:', error) + setChangeDetected(false) + } + } + + checkForChanges() + }, [workflowId, deployedState, debouncedStatusCheckTrigger, isLoadingDeployedState]) + + return { + changeDetected, + setChangeDetected, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/deploy/hooks/use-deployed-state.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/deploy/hooks/use-deployed-state.ts new file mode 100644 index 0000000000..3192393d4b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/deploy/hooks/use-deployed-state.ts @@ -0,0 +1,112 @@ +import { useEffect, useState } from 'react' +import { createLogger } from '@/lib/logs/console/logger' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import type { WorkflowState } from '@/stores/workflows/workflow/types' + +const logger = createLogger('useDeployedState') + +interface UseDeployedStateProps { + workflowId: string | null + isDeployed: boolean + isRegistryLoading: boolean +} + +/** + * Hook to fetch and manage deployed workflow state + * Includes race condition protection for workflow changes + */ +export function useDeployedState({ + workflowId, + isDeployed, + isRegistryLoading, +}: UseDeployedStateProps) { + const [deployedState, setDeployedState] = useState(null) + const [isLoadingDeployedState, setIsLoadingDeployedState] = useState(false) + + const setNeedsRedeploymentFlag = useWorkflowRegistry( + (state) => state.setWorkflowNeedsRedeployment + ) + + /** + * Fetches the deployed state of the workflow from the server + * This is the single source of truth for deployed workflow state + */ + const fetchDeployedState = async () => { + if (!workflowId || !isDeployed) { + setDeployedState(null) + return + } + + // Store the workflow ID at the start of the request to prevent race conditions + const requestWorkflowId = workflowId + + // Helper to get current active workflow ID for race condition checks + const getCurrentActiveWorkflowId = () => useWorkflowRegistry.getState().activeWorkflowId + + try { + setIsLoadingDeployedState(true) + + const response = await fetch(`/api/workflows/${requestWorkflowId}/deployed`) + + // Check if the workflow ID changed during the request (user navigated away) + if (requestWorkflowId !== getCurrentActiveWorkflowId()) { + logger.debug('Workflow changed during deployed state fetch, ignoring response') + return + } + + if (!response.ok) { + if (response.status === 404) { + setDeployedState(null) + return + } + throw new Error(`Failed to fetch deployed state: ${response.statusText}`) + } + + const data = await response.json() + + if (requestWorkflowId === getCurrentActiveWorkflowId()) { + setDeployedState(data.deployedState || null) + } else { + logger.debug('Workflow changed after deployed state response, ignoring result') + } + } catch (error) { + logger.error('Error fetching deployed state:', { error }) + if (requestWorkflowId === getCurrentActiveWorkflowId()) { + setDeployedState(null) + } + } finally { + if (requestWorkflowId === getCurrentActiveWorkflowId()) { + setIsLoadingDeployedState(false) + } + } + } + + useEffect(() => { + if (!workflowId) { + setDeployedState(null) + setIsLoadingDeployedState(false) + return + } + + if (isRegistryLoading) { + setDeployedState(null) + setIsLoadingDeployedState(false) + return + } + + if (isDeployed) { + setNeedsRedeploymentFlag(workflowId, false) + fetchDeployedState() + } else { + setDeployedState(null) + setIsLoadingDeployedState(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workflowId, isDeployed, isRegistryLoading, setNeedsRedeploymentFlag]) + + return { + deployedState, + isLoadingDeployedState, + refetchDeployedState: fetchDeployedState, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/deploy/hooks/use-deployment.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/deploy/hooks/use-deployment.ts new file mode 100644 index 0000000000..8d2186546d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/deploy/hooks/use-deployment.ts @@ -0,0 +1,76 @@ +import { useCallback, useState } from 'react' +import { createLogger } from '@/lib/logs/console/logger' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +const logger = createLogger('useDeployment') + +interface UseDeploymentProps { + workflowId: string | null + isDeployed: boolean + refetchDeployedState: () => Promise +} + +/** + * Hook to manage deployment operations (deploy, undeploy, redeploy) + */ +export function useDeployment({ + workflowId, + isDeployed, + refetchDeployedState, +}: UseDeploymentProps) { + const [isDeploying, setIsDeploying] = useState(false) + const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus) + + /** + * Handle initial deployment and open modal + */ + const handleDeployClick = useCallback(async () => { + if (!workflowId) return { success: false, shouldOpenModal: false } + + // If undeployed, deploy first then open modal + if (!isDeployed) { + setIsDeploying(true) + try { + const response = await fetch(`/api/workflows/${workflowId}/deploy`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + deployChatEnabled: false, + }), + }) + + if (response.ok) { + const responseData = await response.json() + const isDeployedStatus = responseData.isDeployed ?? false + const deployedAtTime = responseData.deployedAt + ? new Date(responseData.deployedAt) + : undefined + setDeploymentStatus( + workflowId, + isDeployedStatus, + deployedAtTime, + responseData.apiKey || '' + ) + await refetchDeployedState() + return { success: true, shouldOpenModal: true } + } + return { success: false, shouldOpenModal: true } + } catch (error) { + logger.error('Error deploying workflow:', error) + return { success: false, shouldOpenModal: true } + } finally { + setIsDeploying(false) + } + } + + // If already deployed, just signal to open modal + return { success: true, shouldOpenModal: true } + }, [workflowId, isDeployed, refetchDeployedState, setDeploymentStatus]) + + return { + isDeploying, + handleDeployClick, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/deploy/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/deploy/index.ts new file mode 100644 index 0000000000..1bac3bffea --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/deploy/index.ts @@ -0,0 +1 @@ +export { Deploy } from './deploy' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/short-input/short-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/short-input/short-input.tsx index fb9ecce522..2741c9c9fb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/short-input/short-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/short-input/short-input.tsx @@ -435,7 +435,7 @@ export function ShortInput({
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/starter/input-format.tsx index dc69f4441c..0642ffff43 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/starter/input-format.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/starter/input-format.tsx @@ -337,7 +337,7 @@ export function FieldFormat({ ref={(el) => { if (el) overlayRefs.current[field.id] = el }} - className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[7px] font-medium font-sans text-sm' + className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-[8px] py-[6px] font-medium font-sans text-sm' style={{ overflowX: 'auto' }} >
{ @@ -38,6 +39,9 @@ interface McpToolsListProps { disabled?: boolean } +/** + * Displays a filtered list of MCP tools with proper section header and separator + */ export function McpToolsList({ mcpTools, searchQuery, @@ -47,55 +51,48 @@ export function McpToolsList({ }: McpToolsListProps) { const filteredTools = mcpTools.filter((tool) => customFilter(tool.name, searchQuery || '') > 0) - if (mcpTools.length === 0 || filteredTools.length === 0) { + if (filteredTools.length === 0) { return null } return ( <> -
MCP Tools
- - {filteredTools.map((mcpTool) => ( - { - if (disabled) return + MCP Tools + {filteredTools.map((mcpTool) => ( + { + if (disabled) return - const newTool: StoredTool = { - type: 'mcp', - title: mcpTool.name, - toolId: mcpTool.id, - params: { - serverId: mcpTool.serverId, - toolName: mcpTool.name, - serverName: mcpTool.serverName, - }, - isExpanded: true, - usageControl: 'auto', - schema: mcpTool.inputSchema, - } + const newTool: StoredTool = { + type: 'mcp', + title: mcpTool.name, + toolId: mcpTool.id, + params: { + serverId: mcpTool.serverId, + toolName: mcpTool.name, + serverName: mcpTool.serverName, + }, + isExpanded: true, + usageControl: 'auto', + schema: mcpTool.inputSchema, + } - onToolSelect(newTool) - }} - className='flex cursor-pointer items-center gap-2' + onToolSelect(newTool) + }} + > +
-
- -
- - {mcpTool.name} - - - ))} - - + +
+ + {mcpTool.name} + +
+ ))} ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/tool-command/tool-command.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/tool-command/tool-command.tsx index 6147db78dd..a90bdabe78 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/tool-command/tool-command.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/tool-command/tool-command.tsx @@ -6,10 +6,8 @@ import { useContext, useEffect, useMemo, - useRef, useState, } from 'react' -import { Search } from 'lucide-react' import { cn } from '@/lib/utils' type CommandContextType = { @@ -37,12 +35,7 @@ interface CommandProps { children: ReactNode className?: string filter?: (value: string, search: string) => number -} - -interface CommandInputProps { - placeholder?: string - className?: string - onValueChange?: (value: string) => void + searchQuery?: string } interface CommandListProps { @@ -55,12 +48,6 @@ interface CommandEmptyProps { className?: string } -interface CommandGroupProps { - children: ReactNode - className?: string - heading?: string -} - interface CommandItemProps { children: ReactNode className?: string @@ -73,12 +60,20 @@ interface CommandSeparatorProps { className?: string } -export function Command({ children, className, filter }: CommandProps) { - const [searchQuery, setSearchQuery] = useState('') +export function Command({ + children, + className, + filter, + searchQuery: externalSearchQuery, +}: CommandProps) { + const [internalSearchQuery, setInternalSearchQuery] = useState('') const [activeIndex, setActiveIndex] = useState(-1) const [items, setItems] = useState([]) const [filteredItems, setFilteredItems] = useState([]) + // Use external searchQuery if provided, otherwise use internal state + const searchQuery = externalSearchQuery ?? internalSearchQuery + const registerItem = useCallback((id: string) => { setItems((prev) => { if (prev.includes(id)) return prev @@ -156,7 +151,7 @@ export function Command({ children, className, filter }: CommandProps) { const contextValue = useMemo( () => ({ searchQuery, - setSearchQuery, + setSearchQuery: setInternalSearchQuery, activeIndex, setActiveIndex, filteredItems, @@ -169,60 +164,15 @@ export function Command({ children, className, filter }: CommandProps) { return ( -
+
{children}
) } -export function CommandInput({ - placeholder = 'Search...', - className, - onValueChange, -}: CommandInputProps) { - const { searchQuery, setSearchQuery } = useCommandContext() - const inputRef = useRef(null) - - const handleChange = (e: React.ChangeEvent) => { - const value = e.target.value - setSearchQuery(value) - onValueChange?.(value) - } - - useEffect(() => { - inputRef.current?.focus() - }, []) - - return ( -
- - -
- ) -} - export function CommandList({ children, className }: CommandListProps) { - return ( -
- {children} -
- ) + return
{children}
} export function CommandEmpty({ children, className }: CommandEmptyProps) { @@ -231,23 +181,7 @@ export function CommandEmpty({ children, className }: CommandEmptyProps) { if (filteredItems.length > 0) return null return ( -
- {children} -
- ) -} - -export function CommandGroup({ children, className, heading }: CommandGroupProps) { - return ( -
- {heading && ( -
{heading}
- )} +
{children}
) @@ -279,8 +213,8 @@ export function CommandItem({
-
- ) : null - })()} + {!isCustomTool && isExpandedForDisplay && ( +
+ {/* Operation dropdown for tools with multiple operations */} + {(() => { + const hasOperations = hasMultipleOperations(tool.type) + const operationOptions = hasOperations ? getOperationOptions(tool.type) : [] - {/* OAuth credential selector if required */} - {requiresOAuth && oauthConfig && ( -
-
Account
+ return hasOperations && operationOptions.length > 0 ? ( +
+
Operation
- - handleParamChange(toolIndex, 'credential', value) - } - provider={oauthConfig.provider as OAuthProvider} - requiredScopes={oauthConfig.additionalScopes || []} - label={`Select ${oauthConfig.provider} account`} - serviceId={oauthConfig.provider} - disabled={disabled} - /> +
- )} + ) : null + })()} + + {/* OAuth credential selector if required */} + {requiresOAuth && oauthConfig && ( +
+
Account
+
+ handleParamChange(toolIndex, 'credential', value)} + provider={oauthConfig.provider as OAuthProvider} + requiredScopes={oauthConfig.additionalScopes || []} + label={`Select ${oauthConfig.provider} account`} + serviceId={oauthConfig.provider} + disabled={disabled} + /> +
+
+ )} - {/* Tool parameters */} - {(() => { - const filteredParams = displayParams.filter((param) => - evaluateParameterCondition(param, tool) - ) - const groupedParams: { [key: string]: ToolParameterConfig[] } = {} - const standaloneParams: ToolParameterConfig[] = [] - - // Group checkbox-list parameters by their UI component title - filteredParams.forEach((param) => { - const paramConfig = param as ToolParameterConfig - if ( - paramConfig.uiComponent?.type === 'checkbox-list' && - paramConfig.uiComponent?.title - ) { - const groupKey = paramConfig.uiComponent.title - if (!groupedParams[groupKey]) { - groupedParams[groupKey] = [] - } - groupedParams[groupKey].push(paramConfig) - } else { - standaloneParams.push(paramConfig) + {/* Tool parameters */} + {(() => { + const filteredParams = displayParams.filter((param) => + evaluateParameterCondition(param, tool) + ) + const groupedParams: { [key: string]: ToolParameterConfig[] } = {} + const standaloneParams: ToolParameterConfig[] = [] + + // Group checkbox-list parameters by their UI component title + filteredParams.forEach((param) => { + const paramConfig = param as ToolParameterConfig + if ( + paramConfig.uiComponent?.type === 'checkbox-list' && + paramConfig.uiComponent?.title + ) { + const groupKey = paramConfig.uiComponent.title + if (!groupedParams[groupKey]) { + groupedParams[groupKey] = [] } - }) - - const renderedElements: React.ReactNode[] = [] - - // Render grouped checkbox-lists - Object.entries(groupedParams).forEach(([groupTitle, params]) => { - const firstParam = params[0] as ToolParameterConfig - const groupValue = JSON.stringify( - params.reduce( - (acc, p) => ({ ...acc, [p.id]: tool.params[p.id] === 'true' }), - {} - ) + groupedParams[groupKey].push(paramConfig) + } else { + standaloneParams.push(paramConfig) + } + }) + + const renderedElements: React.ReactNode[] = [] + + // Render grouped checkbox-lists + Object.entries(groupedParams).forEach(([groupTitle, params]) => { + const firstParam = params[0] as ToolParameterConfig + const groupValue = JSON.stringify( + params.reduce( + (acc, p) => ({ ...acc, [p.id]: tool.params[p.id] === 'true' }), + {} ) + ) - renderedElements.push( -
-
- {groupTitle} -
-
- +
+ {groupTitle} +
+
+ { + try { + const parsed = JSON.parse(value) + params.forEach((param) => { + handleParamChange( + toolIndex, + param.id, + parsed[param.id] ? 'true' : 'false' + ) + }) + } catch (e) { + // Handle error + } + }} + uiComponent={firstParam.uiComponent} + disabled={disabled} + /> +
+
+ ) + }) + + // Render standalone parameters + standaloneParams.forEach((param) => { + renderedElements.push( +
+
+ {param.uiComponent?.title || formatParameterLabel(param.id)} + {param.required && param.visibility === 'user-only' && ( + * + )} + {(!param.required || param.visibility !== 'user-only') && ( + (Optional) + )} +
+
+ {param.uiComponent ? ( + renderParameterInput( + param, + tool.params[param.id] || '', + (value) => handleParamChange(toolIndex, param.id, value), + toolIndex, + tool.params + ) + ) : ( + { - try { - const parsed = JSON.parse(value) - params.forEach((param) => { - handleParamChange( - toolIndex, - param.id, - parsed[param.id] ? 'true' : 'false' - ) - }) - } catch (e) { - // Handle error - } + subBlockId={`${subBlockId}-tool-${toolIndex}-${param.id}`} + placeholder={param.description} + password={isPasswordParameter(param.id)} + config={{ + id: `${subBlockId}-tool-${toolIndex}-${param.id}`, + type: 'short-input', + title: param.id, }} - uiComponent={firstParam.uiComponent} - disabled={disabled} + value={tool.params[param.id] || ''} + onChange={(value) => + handleParamChange(toolIndex, param.id, value) + } /> -
-
- ) - }) - - // Render standalone parameters - standaloneParams.forEach((param) => { - renderedElements.push( -
-
- {param.uiComponent?.title || formatParameterLabel(param.id)} - {param.required && param.visibility === 'user-only' && ( - * - )} - {(!param.required || param.visibility !== 'user-only') && ( - - (Optional) - - )} -
-
- {param.uiComponent ? ( - renderParameterInput( - param, - tool.params[param.id] || '', - (value) => handleParamChange(toolIndex, param.id, value), - toolIndex, - tool.params - ) - ) : ( - - handleParamChange(toolIndex, param.id, value) - } - /> - )} -
+ )}
- ) - }) +
+ ) + }) - return renderedElements - })()} -
- )} -
+ return renderedElements + })()} +
+ )}
) })} - {/* Drop zone for the end of the list */} - {selectedTools.length > 0 && draggedIndex !== null && ( -
handleDragOver(e, selectedTools.length)} - onDrop={(e) => handleDrop(e, selectedTools.length)} - /> - )} - + {/* Add Tool Button */} - +
+
+ + Add Tool +
+
- - - - No tools found. - + + + + + No tools found + { setOpen(false) setCustomToolModalOpen(true) }} - className='mb-1 flex cursor-pointer items-center gap-2' > -
- +
+
- Create Tool + Create Tool -
- +
+
- Add MCP Server + Add MCP Server {/* Display saved custom tools at the top */} - {customTools.length > 0 && ( - <> - -
- Custom Tools -
- - {customTools.map((customTool) => ( + {(() => { + const matchingCustomTools = customTools.filter( + (tool) => customFilter(tool.title, searchQuery || '') > 0 + ) + if (matchingCustomTools.length === 0) return null + + return ( + <> + Custom Tools + {matchingCustomTools.map((customTool) => ( -
- +
+
- {customTool.title} + {customTool.title} ))} - - - - )} + + ) + })()} {/* Display MCP tools */} {/* Display built-in tools */} - {toolBlocks.some( - (block) => customFilter(block.name, searchQuery || '') > 0 - ) && ( - <> -
- Built-in Tools -
- - {toolBlocks.map((block) => ( + {(() => { + const matchingBlocks = toolBlocks.filter( + (block) => customFilter(block.name, searchQuery || '') > 0 + ) + if (matchingBlocks.length === 0) return null + + return ( + <> + Built-in Tools + {matchingBlocks.map((block) => ( handleSelectTool(block)} - className='flex cursor-pointer items-center gap-2' >
- +
- {block.name} + {block.name}
))} -
- - )} - - - + + ) + })()} + + + -
+ )} {/* Custom Tool Modal */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/editor.tsx index 0e6ffb7ba4..17e96fdf9d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/editor.tsx @@ -200,7 +200,7 @@ export function Editor() {
{!currentBlockId || !currentBlock ? ( -
+
Select a block to edit
) : ( @@ -212,7 +212,7 @@ export function Editor() { >
{subBlocks.length === 0 ? ( -
+
This block has no subblocks
) : ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/index.ts index 6af193afbc..e661875717 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/index.ts @@ -1,4 +1,5 @@ export { Copilot } from './copilot/copilot' +export { Deploy } from './deploy/deploy' export { Editor } from './editor/editor' -export { Toolbar } from './toolbar' +export { Toolbar } from './toolbar/toolbar' export { WorkflowControls } from './workflow-controls/workflow-controls' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/components/drag-preview.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/components/drag-preview.ts new file mode 100644 index 0000000000..e09e765b46 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/components/drag-preview.ts @@ -0,0 +1,74 @@ +/** + * Information needed to create a drag preview for a toolbar item + */ +export interface DragItemInfo { + name: string + bgColor: string + iconElement?: HTMLElement | null +} + +/** + * Creates a custom drag preview element that looks like a workflow block. + * This provides a consistent visual experience when dragging items from the toolbar to the canvas. + * + * @param info - Information about the item being dragged + * @returns HTML element to use as drag preview + */ +export function createDragPreview(info: DragItemInfo): HTMLElement { + const preview = document.createElement('div') + preview.style.cssText = ` + width: 250px; + background: #232323; + border-radius: 8px; + padding: 8px 9px; + display: flex; + align-items: center; + gap: 10px; + font-family: system-ui, -apple-system, sans-serif; + position: fixed; + top: -500px; + left: 0; + pointer-events: none; + z-index: 9999; + ` + + // Create icon container + const iconContainer = document.createElement('div') + iconContainer.style.cssText = ` + width: 24px; + height: 24px; + border-radius: 6px; + background: ${info.bgColor}; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + ` + + // Clone the actual icon if provided + if (info.iconElement) { + const clonedIcon = info.iconElement.cloneNode(true) as HTMLElement + clonedIcon.style.width = '16px' + clonedIcon.style.height = '16px' + clonedIcon.style.color = 'white' + clonedIcon.style.flexShrink = '0' + iconContainer.appendChild(clonedIcon) + } + + // Create text element + const text = document.createElement('span') + text.textContent = info.name + text.style.cssText = ` + color: #FFFFFF; + font-size: 16px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + ` + + preview.appendChild(iconContainer) + preview.appendChild(text) + + return preview +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/components/index.ts new file mode 100644 index 0000000000..574774d0ee --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/components/index.ts @@ -0,0 +1 @@ +export { createDragPreview, type DragItemInfo } from './drag-preview' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/hooks/index.ts index ae24e0c4dd..1b542fd84a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/hooks/index.ts @@ -1,2 +1,2 @@ export { useToolbarItemInteractions } from './use-toolbar-item-interactions' -export { useToolbarResize } from './use-toolbar-resize' +export { calculateTriggerHeights, useToolbarResize } from './use-toolbar-resize' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/hooks/use-toolbar-item-interactions.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/hooks/use-toolbar-item-interactions.ts index 00065be5e2..e69a06c3a6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/hooks/use-toolbar-item-interactions.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/hooks/use-toolbar-item-interactions.ts @@ -1,5 +1,6 @@ -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import { createLogger } from '@/lib/logs/console/logger' +import { createDragPreview, type DragItemInfo } from '../components' const logger = createLogger('ToolbarItemInteractions') @@ -20,15 +21,23 @@ interface UseToolbarItemInteractionsProps { export function useToolbarItemInteractions({ disabled = false, }: UseToolbarItemInteractionsProps = {}) { + const dragPreviewRef = useRef(null) + /** - * Handle drag start for toolbar items + * Handle drag start for toolbar items with custom drag preview * * @param e - React drag event * @param type - Block type identifier * @param enableTriggerMode - Whether to enable trigger mode for the block + * @param dragItemInfo - Information for creating custom drag preview */ const handleDragStart = useCallback( - (e: React.DragEvent, type: string, enableTriggerMode = false) => { + ( + e: React.DragEvent, + type: string, + enableTriggerMode = false, + dragItemInfo?: DragItemInfo + ) => { if (disabled) { e.preventDefault() return @@ -43,6 +52,36 @@ export function useToolbarItemInteractions({ }) ) e.dataTransfer.effectAllowed = 'move' + + // Create and set custom drag preview if item info is provided + if (dragItemInfo) { + // Clean up any existing preview first + if (dragPreviewRef.current && document.body.contains(dragPreviewRef.current)) { + document.body.removeChild(dragPreviewRef.current) + } + + const preview = createDragPreview(dragItemInfo) + document.body.appendChild(preview) + dragPreviewRef.current = preview + + // Force browser to render the element by triggering reflow + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + preview.offsetHeight + + // Set the custom drag image with offset to center it on cursor + e.dataTransfer.setDragImage(preview, 125, 20) + + // Clean up the preview element after drag ends + const cleanup = () => { + if (dragPreviewRef.current && document.body.contains(dragPreviewRef.current)) { + document.body.removeChild(dragPreviewRef.current) + dragPreviewRef.current = null + } + } + + // Schedule cleanup after a short delay to ensure drag has started + setTimeout(cleanup, 100) + } } catch (error) { logger.error('Failed to set drag data:', error) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/hooks/use-toolbar-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/hooks/use-toolbar-resize.ts index 8324ec2063..d1d58f3ca8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/hooks/use-toolbar-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/hooks/use-toolbar-resize.ts @@ -2,9 +2,50 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useToolbarStore } from '@/stores/panel-new/toolbar/store' /** - * Minimum height for a section (in pixels) + * Minimum height for the blocks section (in pixels) + * The triggers section minimum will be calculated dynamically based on header height */ -const MIN_SECTION_HEIGHT = 100 +export const MIN_BLOCKS_SECTION_HEIGHT = 100 + +/** + * Calculates height boundaries and optimal height for the triggers section + * + * @param containerRef - Reference to the container element + * @param triggersContentRef - Reference to the triggers content element + * @param triggersHeaderRef - Reference to the triggers header element + * @returns Object containing minHeight, maxHeight, and optimalHeight for triggers section + */ +export function calculateTriggerHeights( + containerRef: React.RefObject, + triggersContentRef: React.RefObject, + triggersHeaderRef: React.RefObject +): { minHeight: number; maxHeight: number; optimalHeight: number } { + const defaultHeight = MIN_BLOCKS_SECTION_HEIGHT + + if (!containerRef.current || !triggersHeaderRef.current) { + return { minHeight: defaultHeight, maxHeight: defaultHeight, optimalHeight: defaultHeight } + } + + const parentHeight = containerRef.current.getBoundingClientRect().height + const headerHeight = triggersHeaderRef.current.getBoundingClientRect().height + + // Minimum triggers height is just the header + const minHeight = headerHeight + + // Calculate optimal and maximum heights based on actual content + let maxHeight = parentHeight - MIN_BLOCKS_SECTION_HEIGHT + let optimalHeight = minHeight + + if (triggersContentRef.current) { + const contentHeight = triggersContentRef.current.scrollHeight + // Optimal height = header + actual content (shows all triggers without scrolling) + optimalHeight = Math.min(headerHeight + contentHeight, maxHeight) + // Maximum height shouldn't exceed full content height + maxHeight = Math.min(maxHeight, headerHeight + contentHeight) + } + + return { minHeight, maxHeight, optimalHeight } +} /** * Props for the useToolbarResize hook @@ -65,28 +106,15 @@ export function useToolbarResize({ const deltaY = e.clientY - startYRef.current let newHeight = startHeightRef.current + deltaY - const parentContainer = containerRef.current - if (parentContainer) { - const parentHeight = parentContainer.getBoundingClientRect().height - - // Calculate maximum triggers height based on actual content - let maxTriggersHeight = parentHeight - MIN_SECTION_HEIGHT - - if (triggersContentRef.current && triggersHeaderRef.current) { - const contentHeight = triggersContentRef.current.scrollHeight - const headerHeight = triggersHeaderRef.current.getBoundingClientRect().height - - // Maximum height = header + content (this shows all triggers without scrolling) - const fullContentHeight = headerHeight + contentHeight - - // Don't allow triggers to exceed its full content height - maxTriggersHeight = Math.min(maxTriggersHeight, fullContentHeight) - } + // Calculate height boundaries and clamp the new height + const { minHeight, maxHeight } = calculateTriggerHeights( + containerRef, + triggersContentRef, + triggersHeaderRef + ) - // Ensure minimum for triggers section and respect maximum - newHeight = Math.max(MIN_SECTION_HEIGHT, Math.min(maxTriggersHeight, newHeight)) - setToolbarTriggersHeight(newHeight) - } + newHeight = Math.max(minHeight, Math.min(maxHeight, newHeight)) + setToolbarTriggersHeight(newHeight) } const handleMouseUp = () => { @@ -108,5 +136,6 @@ export function useToolbarResize({ return { handleMouseDown, + isResizing, } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/toolbar.tsx index 52c16bc151..d8cb45c152 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/toolbar/toolbar.tsx @@ -1,6 +1,6 @@ 'use client' -import { useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { Search } from 'lucide-react' import { Button } from '@/components/emcn' @@ -12,7 +12,8 @@ import { import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config' import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config' import type { BlockConfig } from '@/blocks/types' -import { useToolbarItemInteractions, useToolbarResize } from './hooks' +import { useToolbarStore } from '@/stores/panel-new/toolbar/store' +import { calculateTriggerHeights, useToolbarItemInteractions, useToolbarResize } from './hooks' interface BlockItem { name: string @@ -102,27 +103,48 @@ function getBlocks() { return cachedBlocks } +interface ToolbarProps { + /** Whether the toolbar tab is currently active */ + isActive?: boolean +} + /** * Toolbar component displaying triggers and blocks in a resizable split view. * Top half shows triggers, bottom half shows blocks, with a resizable divider between them. * + * @param props - Component props + * @param props.isActive - Whether the toolbar tab is currently active * @returns Toolbar view with triggers and blocks */ -export function Toolbar() { +/** + * Threshold for determining if triggers are at minimum height (in pixels) + * Triggers slightly above header height are considered at minimum + */ +const TRIGGERS_MIN_THRESHOLD = 50 + +export function Toolbar({ isActive = true }: ToolbarProps) { const containerRef = useRef(null) const triggersContentRef = useRef(null) const triggersHeaderRef = useRef(null) + const blocksHeaderRef = useRef(null) const searchInputRef = useRef(null) // Search state const [isSearchActive, setIsSearchActive] = useState(false) const [searchQuery, setSearchQuery] = useState('') + // Toggle animation state + const [isToggling, setIsToggling] = useState(false) + + // Toolbar store + const { toolbarTriggersHeight, setToolbarTriggersHeight, preSearchHeight, setPreSearchHeight } = + useToolbarStore() + // Toolbar item interactions hook const { handleDragStart, handleItemClick } = useToolbarItemInteractions({ disabled: false }) // Toolbar resize hook - const { handleMouseDown } = useToolbarResize({ + const { handleMouseDown, isResizing } = useToolbarResize({ containerRef, triggersContentRef, triggersHeaderRef, @@ -132,6 +154,19 @@ export function Toolbar() { const triggers = getTriggers() const blocks = getBlocks() + // Determine if triggers are at minimum height (blocks are fully expanded) + const isTriggersAtMinimum = toolbarTriggersHeight <= TRIGGERS_MIN_THRESHOLD + + /** + * Clear search when tab becomes inactive + */ + useEffect(() => { + if (!isActive) { + setIsSearchActive(false) + setSearchQuery('') + } + }, [isActive]) + /** * Filter items based on search query */ @@ -147,6 +182,58 @@ export function Toolbar() { return blocks.filter((block) => block.name.toLowerCase().includes(query)) }, [blocks, searchQuery]) + /** + * Adjust heights based on search results + * - If no triggers found, collapse triggers to minimum (expand blocks) + * - If no blocks found, expand triggers to maximum (collapse blocks) + * - If triggers are found, dynamically resize to show all filtered triggers without scrolling + */ + useEffect(() => { + const hasSearchQuery = searchQuery.trim().length > 0 + const triggersCount = filteredTriggers.length + const blocksCount = filteredBlocks.length + + // Save pre-search height when search starts + if (hasSearchQuery && preSearchHeight === null) { + setPreSearchHeight(toolbarTriggersHeight) + } + + // Restore pre-search height when search is cleared + if (!hasSearchQuery && preSearchHeight !== null) { + setToolbarTriggersHeight(preSearchHeight) + setPreSearchHeight(null) + return + } + + // Adjust heights based on search results + if (hasSearchQuery) { + const { minHeight, maxHeight, optimalHeight } = calculateTriggerHeights( + containerRef, + triggersContentRef, + triggersHeaderRef + ) + + if (triggersCount === 0 && blocksCount > 0) { + // No triggers found - collapse triggers to minimum (expand blocks) + setToolbarTriggersHeight(minHeight) + } else if (blocksCount === 0 && triggersCount > 0) { + // No blocks found - expand triggers to maximum (collapse blocks) + setToolbarTriggersHeight(maxHeight) + } else if (triggersCount > 0) { + // Triggers are present - use optimal height to show all filtered triggers + setToolbarTriggersHeight(optimalHeight) + } + } + }, [ + searchQuery, + filteredTriggers.length, + filteredBlocks.length, + preSearchHeight, + toolbarTriggersHeight, + setToolbarTriggersHeight, + setPreSearchHeight, + ]) + /** * Handle search icon click to activate search mode */ @@ -166,10 +253,38 @@ export function Toolbar() { } } + /** + * Handle blocks header click - toggle between min and max. + * If triggers are greater than minimum, collapse to minimum (just header). + * If triggers are at minimum, expand to maximum (full content height). + */ + const handleBlocksHeaderClick = useCallback(() => { + setIsToggling(true) + + const { minHeight, maxHeight } = calculateTriggerHeights( + containerRef, + triggersContentRef, + triggersHeaderRef + ) + + // Toggle between min and max + setToolbarTriggersHeight(isTriggersAtMinimum ? maxHeight : minHeight) + }, [isTriggersAtMinimum, setToolbarTriggersHeight]) + + /** + * Handle transition end - reset toggling state + */ + const handleTransitionEnd = useCallback(() => { + setIsToggling(false) + }, []) + return (
{/* Header */} -
+

Toolbar

{!isSearchActive ? ( @@ -197,8 +312,12 @@ export function Toolbar() {
{/* Triggers Section */}
{filteredTriggers.map((trigger) => { const Icon = trigger.icon + const isTriggerCapable = hasTriggerCapability(trigger) return (
- handleDragStart(e, trigger.type, hasTriggerCapability(trigger)) - } - onClick={() => handleItemClick(trigger.type, hasTriggerCapability(trigger))} + onDragStart={(e) => { + const iconElement = e.currentTarget.querySelector('.toolbar-item-icon') + handleDragStart(e, trigger.type, isTriggerCapable, { + name: trigger.name, + bgColor: trigger.bgColor, + iconElement: iconElement as HTMLElement | null, + }) + }} + onClick={() => handleItemClick(trigger.type, isTriggerCapable)} className={clsx( 'group flex h-[25px] items-center gap-[8px] rounded-[8px] px-[5px] text-[14px]', 'cursor-pointer hover:bg-[#2C2C2C] active:cursor-grabbing dark:hover:bg-[#2C2C2C]' @@ -230,7 +355,7 @@ export function Toolbar() { {Icon && ( -
+
Blocks
@@ -273,7 +402,14 @@ export function Toolbar() {
handleDragStart(e, block.type, false)} + onDragStart={(e) => { + const iconElement = e.currentTarget.querySelector('.toolbar-item-icon') + handleDragStart(e, block.type, false, { + name: block.name, + bgColor: block.bgColor ?? '#666666', + iconElement: iconElement as HTMLElement | null, + }) + }} onClick={() => handleItemClick(block.type, false)} className={clsx( 'group flex h-[25px] items-center gap-[8px] rounded-[8px] px-[5.5px] text-[14px]', @@ -287,7 +423,7 @@ export function Toolbar() { {Icon && ( setIsDeleteModalOpen(false), + }) + // Usage limits hook const { usageExceeded } = useUsageLimits({ context: 'user', @@ -94,6 +102,9 @@ export function Panel() { // Panel resize hook const { handleMouseDown } = usePanelResize() + // Chat state + const { isChatOpen, setIsChatOpen } = useChatStore() + const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null /** @@ -215,49 +226,6 @@ export function Panel() { workspaceId, ]) - /** - * Handles deleting the current workflow after confirmation - */ - const handleDeleteWorkflow = useCallback(async () => { - if (!activeWorkflowId || !userPermissions.canEdit || isDeleting) { - return - } - - setIsDeleting(true) - try { - // Find next workflow to navigate to - const sidebarWorkflows = Object.values(workflows).filter((w) => w.workspaceId === workspaceId) - const currentIndex = sidebarWorkflows.findIndex((w) => w.id === activeWorkflowId) - - let nextWorkflowId: string | null = null - if (sidebarWorkflows.length > 1) { - if (currentIndex < sidebarWorkflows.length - 1) { - nextWorkflowId = sidebarWorkflows[currentIndex + 1].id - } else if (currentIndex > 0) { - nextWorkflowId = sidebarWorkflows[currentIndex - 1].id - } - } - - // Navigate first - if (nextWorkflowId) { - router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`) - } else { - router.push(`/workspace/${workspaceId}`) - } - - // Then delete - const { removeWorkflow: registryRemoveWorkflow } = useWorkflowRegistry.getState() - await registryRemoveWorkflow(activeWorkflowId) - - setIsDeleteModalOpen(false) - logger.info('Workflow deleted successfully') - } catch (error) { - logger.error('Error deleting workflow:', error) - } finally { - setIsDeleting(false) - } - }, [activeWorkflowId, userPermissions.canEdit, isDeleting, workflows, workspaceId, router]) - // Compute run button state const canRun = userPermissions.canRead // Running only requires read permissions const isLoadingPermissions = userPermissions.isLoading @@ -325,17 +293,18 @@ export function Panel() { -
{/* Deploy and Run */}
- +
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx deleted file mode 100644 index 5d4a2dd170..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/chat.tsx +++ /dev/null @@ -1,897 +0,0 @@ -'use client' - -import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { AlertCircle, ArrowDown, ArrowUp, File, FileText, Image, Paperclip, X } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { ScrollArea } from '@/components/ui/scroll-area' -import { createLogger } from '@/lib/logs/console/logger' -import { - extractBlockIdFromOutputId, - extractPathFromOutputId, - parseOutputContentSafely, -} from '@/lib/response-format' -import { - ChatMessage, - OutputSelect, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components' -import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' -import type { BlockLog, ExecutionResult } from '@/executor/types' -import { useExecutionStore } from '@/stores/execution/store' -import { useChatStore } from '@/stores/panel/chat/store' -import { useTerminalConsoleStore } from '@/stores/terminal' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -const logger = createLogger('ChatPanel') - -interface ChatFile { - id: string - name: string - size: number - type: string - file: File -} - -interface ChatProps { - chatMessage: string - setChatMessage: (message: string) => void -} - -export function Chat({ chatMessage, setChatMessage }: ChatProps) { - const { activeWorkflowId } = useWorkflowRegistry() - - const { - messages, - addMessage, - selectedWorkflowOutputs, - setSelectedWorkflowOutput, - appendMessageContent, - finalizeMessageStream, - getConversationId, - } = useChatStore() - const { entries } = useTerminalConsoleStore() - const messagesEndRef = useRef(null) - const scrollAreaRef = useRef(null) - const inputRef = useRef(null) - const timeoutRef = useRef(null) - const abortControllerRef = useRef(null) - - // Debug component lifecycle - useEffect(() => { - logger.info('[ChatPanel] Component mounted', { activeWorkflowId }) - return () => { - logger.info('[ChatPanel] Component unmounting', { activeWorkflowId }) - } - }, []) - - // Prompt history state - const [promptHistory, setPromptHistory] = useState([]) - const [historyIndex, setHistoryIndex] = useState(-1) - - // File upload state - const [chatFiles, setChatFiles] = useState([]) - const [isUploadingFiles, setIsUploadingFiles] = useState(false) - const [uploadErrors, setUploadErrors] = useState([]) - const [dragCounter, setDragCounter] = useState(0) - const isDragOver = dragCounter > 0 - // Scroll state - const [isNearBottom, setIsNearBottom] = useState(true) - const [showScrollButton, setShowScrollButton] = useState(false) - - // Use the execution store state to track if a workflow is executing - const { isExecuting } = useExecutionStore() - - // Get workflow execution functionality - const { handleRunWorkflow } = useWorkflowExecution() - - // 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]) - - // Memoize user messages for performance - const userMessages = useMemo(() => { - return workflowMessages - .filter((msg) => msg.type === 'user') - .map((msg) => msg.content) - .filter((content): content is string => typeof content === 'string') - }, [workflowMessages]) - - // Update prompt history when workflow changes - useEffect(() => { - if (!activeWorkflowId) { - setPromptHistory([]) - setHistoryIndex(-1) - return - } - - setPromptHistory(userMessages) - setHistoryIndex(-1) - }, [activeWorkflowId, userMessages]) - - // Get selected workflow outputs - const selectedOutputs = useMemo(() => { - if (!activeWorkflowId) return [] - const selected = selectedWorkflowOutputs[activeWorkflowId] - - if (!selected || selected.length === 0) { - // Return empty array when nothing is explicitly selected - return [] - } - - // Ensure we have no duplicates in the selection - const dedupedSelection = [...new Set(selected)] - - // If deduplication removed items, update the store - if (dedupedSelection.length !== selected.length) { - setSelectedWorkflowOutput(activeWorkflowId, dedupedSelection) - return dedupedSelection - } - - return selected - }, [selectedWorkflowOutputs, activeWorkflowId, setSelectedWorkflowOutput]) - - // Focus input helper with proper cleanup - const focusInput = useCallback((delay = 0) => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current) - } - - timeoutRef.current = setTimeout(() => { - if (inputRef.current && document.contains(inputRef.current)) { - inputRef.current.focus({ preventScroll: true }) - } - }, delay) - }, []) - - // Scroll to bottom function - const scrollToBottom = useCallback(() => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) - } - }, []) - - // Handle scroll events to track user position - const handleScroll = useCallback(() => { - const scrollArea = scrollAreaRef.current - if (!scrollArea) return - - // Find the viewport element inside the ScrollArea - const viewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]') - if (!viewport) return - - const { scrollTop, scrollHeight, clientHeight } = viewport - const distanceFromBottom = scrollHeight - scrollTop - clientHeight - - // Consider "near bottom" if within 100px of bottom - const nearBottom = distanceFromBottom <= 100 - setIsNearBottom(nearBottom) - setShowScrollButton(!nearBottom) - }, []) - - // Cleanup on unmount - useEffect(() => { - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current) - } - if (abortControllerRef.current) { - abortControllerRef.current.abort() - } - } - }, []) - - // Attach scroll listener - useEffect(() => { - const scrollArea = scrollAreaRef.current - if (!scrollArea) return - - // Find the viewport element inside the ScrollArea - const viewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]') - if (!viewport) return - - viewport.addEventListener('scroll', handleScroll, { passive: true }) - - // Also listen for scrollend event if available (for smooth scrolling) - if ('onscrollend' in viewport) { - viewport.addEventListener('scrollend', handleScroll, { passive: true }) - } - - // Initial scroll state check with small delay to ensure DOM is ready - setTimeout(handleScroll, 100) - - return () => { - viewport.removeEventListener('scroll', handleScroll) - if ('onscrollend' in viewport) { - viewport.removeEventListener('scrollend', handleScroll) - } - } - }, [handleScroll]) - - // Auto-scroll to bottom when new messages are added, but only if user is near bottom - // Exception: Always scroll when sending a new message - useEffect(() => { - if (workflowMessages.length === 0) return - - const lastMessage = workflowMessages[workflowMessages.length - 1] - const isNewUserMessage = lastMessage?.type === 'user' - - // Always scroll for new user messages, or only if near bottom for assistant messages - if ((isNewUserMessage || isNearBottom) && messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) - // Let the scroll event handler update the state naturally after animation completes - } - }, [workflowMessages, isNearBottom]) - - // Handle send message - const handleSendMessage = useCallback(async () => { - if ( - (!chatMessage.trim() && chatFiles.length === 0) || - !activeWorkflowId || - isExecuting || - isUploadingFiles - ) - return - - // Store the message being sent for reference - const sentMessage = chatMessage.trim() - - // Add to prompt history if it's not already the most recent - if ( - sentMessage && - (promptHistory.length === 0 || promptHistory[promptHistory.length - 1] !== sentMessage) - ) { - setPromptHistory((prev) => [...prev, sentMessage]) - } - - // Reset history index - setHistoryIndex(-1) - - // Cancel any existing operations - if (abortControllerRef.current) { - abortControllerRef.current.abort() - } - abortControllerRef.current = new AbortController() - - // Get the conversationId for this workflow before adding the message - const conversationId = getConversationId(activeWorkflowId) - let result: any = null - - try { - // Read files as data URLs for display in chat (only images to avoid localStorage quota issues) - const attachmentsWithData = await Promise.all( - chatFiles.map(async (file) => { - let dataUrl = '' - // Only read images as data URLs to avoid storing large files in localStorage - if (file.type.startsWith('image/')) { - try { - dataUrl = await new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(reader.result as string) - reader.onerror = reject - reader.readAsDataURL(file.file) - }) - } catch (error) { - logger.error('Error reading file as data URL:', error) - } - } - return { - id: file.id, - name: file.name, - type: file.type, - size: file.size, - dataUrl, - } - }) - ) - - // Add user message with attachments (include all files, even non-images without dataUrl) - addMessage({ - content: - sentMessage || (chatFiles.length > 0 ? `Uploaded ${chatFiles.length} file(s)` : ''), - workflowId: activeWorkflowId, - type: 'user', - attachments: attachmentsWithData, - }) - - // Prepare workflow input - const workflowInput: any = { - input: sentMessage, - conversationId: conversationId, - } - - // Add files if any (pass the File objects directly) - if (chatFiles.length > 0) { - workflowInput.files = chatFiles.map((chatFile) => ({ - name: chatFile.name, - size: chatFile.size, - type: chatFile.type, - file: chatFile.file, // Pass the actual File object - })) - workflowInput.onUploadError = (message: string) => { - setUploadErrors((prev) => [...prev, message]) - } - } - - // Clear input and files, refocus immediately - setChatMessage('') - setChatFiles([]) - setUploadErrors([]) - focusInput(10) - - // Execute the workflow to generate a response - logger.info('[ChatPanel] Executing workflow with input', { workflowInput, activeWorkflowId }) - result = await handleRunWorkflow(workflowInput) - logger.info('[ChatPanel] Workflow execution completed', { - hasStream: result && 'stream' in result, - }) - } catch (error) { - logger.error('Error in handleSendMessage:', error) - setIsUploadingFiles(false) - // You might want to show an error message to the user here - return - } - - // Check if we got a streaming response - if (result && 'stream' in result && result.stream instanceof ReadableStream) { - // Create a single message for all outputs (like chat client does) - const responseMessageId = crypto.randomUUID() - let accumulatedContent = '' - - // Add initial streaming message - logger.info('[ChatPanel] Creating streaming message', { responseMessageId }) - addMessage({ - id: responseMessageId, - content: '', - workflowId: activeWorkflowId, - type: 'workflow', - isStreaming: true, - }) - - const reader = result.stream.getReader() - const decoder = new TextDecoder() - - const processStream = async () => { - while (true) { - const { done, value } = await reader.read() - if (done) { - // Finalize the streaming message - finalizeMessageStream(responseMessageId) - break - } - - const chunk = decoder.decode(value) - const lines = chunk.split('\n\n') - - for (const line of lines) { - if (line.startsWith('data: ')) { - const data = line.substring(6) - - if (data === '[DONE]') { - continue - } - - try { - const json = JSON.parse(data) - const { blockId, chunk: contentChunk, event, data: eventData } = json - - if (event === 'final' && eventData) { - const result = eventData as ExecutionResult - - // If final result is a failure, surface error and stop - if ('success' in result && !result.success) { - // Update the existing message with error - appendMessageContent( - responseMessageId, - `${accumulatedContent ? '\n\n' : ''}Error: ${result.error || 'Workflow execution failed'}` - ) - finalizeMessageStream(responseMessageId) - - // Stop processing - return - } - - // Final event just marks completion, content already streamed - finalizeMessageStream(responseMessageId) - } else if (blockId && contentChunk) { - // Accumulate all content into the single message - accumulatedContent += contentChunk - logger.debug('[ChatPanel] Appending chunk', { - blockId, - chunkLength: contentChunk.length, - responseMessageId, - chunk: contentChunk.substring(0, 20), - }) - appendMessageContent(responseMessageId, contentChunk) - } - } catch (e) { - logger.error('Error parsing stream data:', e) - } - } - } - } - } - - processStream() - .catch((e) => logger.error('Error processing stream:', e)) - .finally(() => { - // Restore focus after streaming completes - focusInput(100) - }) - } else if (result && 'success' in result && result.success && 'logs' in result) { - const finalOutputs: any[] = [] - - if (selectedOutputs?.length > 0) { - for (const outputId of selectedOutputs) { - const blockIdForOutput = extractBlockIdFromOutputId(outputId) - const path = extractPathFromOutputId(outputId, blockIdForOutput) - const log = result.logs?.find((l: BlockLog) => l.blockId === blockIdForOutput) - - if (log) { - let output = log.output - - if (path) { - // Parse JSON content safely - output = parseOutputContentSafely(output) - - const pathParts = path.split('.') - let current = output - for (const part of pathParts) { - if (current && typeof current === 'object' && part in current) { - current = current[part] - } else { - current = undefined - break - } - } - output = current - } - if (output !== undefined) { - finalOutputs.push(output) - } - } - } - } - - // Only show outputs if something was explicitly selected - // If no outputs are selected, don't show anything - - // Add a new message for each resolved output - finalOutputs.forEach((output) => { - let content = '' - if (typeof output === 'string') { - content = output - } else if (output && typeof output === 'object') { - // For structured responses, pretty print the JSON - content = `\`\`\`json\n${JSON.stringify(output, null, 2)}\n\`\`\`` - } - - if (content) { - addMessage({ - content, - workflowId: activeWorkflowId, - type: 'workflow', - }) - } - }) - } else if (result && 'success' in result && !result.success) { - addMessage({ - content: `Error: ${'error' in result ? result.error : 'Workflow execution failed.'}`, - workflowId: activeWorkflowId, - type: 'workflow', - }) - } - - // Restore focus after workflow execution completes - focusInput(100) - }, [ - chatMessage, - chatFiles, - isUploadingFiles, - activeWorkflowId, - isExecuting, - promptHistory, - getConversationId, - addMessage, - handleRunWorkflow, - selectedOutputs, - setSelectedWorkflowOutput, - appendMessageContent, - finalizeMessageStream, - focusInput, - setChatMessage, - setChatFiles, - setUploadErrors, - ]) - - // Handle key press - const handleKeyPress = useCallback( - (e: KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSendMessage() - } else if (e.key === 'ArrowUp') { - e.preventDefault() - if (promptHistory.length > 0) { - const newIndex = - historyIndex === -1 ? promptHistory.length - 1 : Math.max(0, historyIndex - 1) - setHistoryIndex(newIndex) - setChatMessage(promptHistory[newIndex]) - } - } else if (e.key === 'ArrowDown') { - e.preventDefault() - if (historyIndex >= 0) { - const newIndex = historyIndex + 1 - if (newIndex >= promptHistory.length) { - setHistoryIndex(-1) - setChatMessage('') - } else { - setHistoryIndex(newIndex) - setChatMessage(promptHistory[newIndex]) - } - } - } - }, - [handleSendMessage, promptHistory, historyIndex, setChatMessage] - ) - - // Handle output selection - const handleOutputSelection = useCallback( - (values: string[]) => { - // Ensure no duplicates in selection - const dedupedValues = [...new Set(values)] - - if (activeWorkflowId) { - // If array is empty, explicitly set to empty array to ensure complete reset - if (dedupedValues.length === 0) { - setSelectedWorkflowOutput(activeWorkflowId, []) - } else { - setSelectedWorkflowOutput(activeWorkflowId, dedupedValues) - } - } - }, - [activeWorkflowId, setSelectedWorkflowOutput] - ) - - return ( -
- {/* Output Source Dropdown */} -
- -
- - {/* Main layout with fixed heights to ensure input stays visible */} -
- {/* Chat messages section - Scrollable area */} -
- {workflowMessages.length === 0 ? ( -
- No messages yet -
- ) : ( -
- -
- {workflowMessages.map((message) => ( - - ))} -
-
- -
- )} - - {/* Scroll to bottom button */} - {showScrollButton && ( -
- -
- )} -
- - {/* Input section - Fixed height */} -
{ - e.preventDefault() - e.stopPropagation() - if (!(!activeWorkflowId || isExecuting || isUploadingFiles)) { - setDragCounter((prev) => prev + 1) - } - }} - onDragOver={(e) => { - e.preventDefault() - e.stopPropagation() - if (!(!activeWorkflowId || isExecuting || isUploadingFiles)) { - e.dataTransfer.dropEffect = 'copy' - } - }} - onDragLeave={(e) => { - e.preventDefault() - e.stopPropagation() - setDragCounter((prev) => Math.max(0, prev - 1)) - }} - onDrop={(e) => { - e.preventDefault() - e.stopPropagation() - setDragCounter(0) - if (!(!activeWorkflowId || isExecuting || isUploadingFiles)) { - const droppedFiles = Array.from(e.dataTransfer.files) - if (droppedFiles.length > 0) { - const remainingSlots = Math.max(0, 15 - chatFiles.length) - const candidateFiles = droppedFiles.slice(0, remainingSlots) - const errors: string[] = [] - const validNewFiles: ChatFile[] = [] - - for (const file of candidateFiles) { - if (file.size > 10 * 1024 * 1024) { - errors.push(`${file.name} is too large (max 10MB)`) - continue - } - - const isDuplicate = chatFiles.some( - (existingFile) => - existingFile.name === file.name && existingFile.size === file.size - ) - if (isDuplicate) { - errors.push(`${file.name} already added`) - continue - } - - validNewFiles.push({ - id: crypto.randomUUID(), - name: file.name, - size: file.size, - type: file.type, - file, - }) - } - - if (errors.length > 0) { - setUploadErrors(errors) - } - - if (validNewFiles.length > 0) { - setChatFiles([...chatFiles, ...validNewFiles]) - setUploadErrors([]) // Clear errors when files are successfully added - } - } - } - }} - > - {/* Error messages */} - {uploadErrors.length > 0 && ( -
-
-
- -
-
- File upload error -
-
- {uploadErrors.map((err, idx) => ( -
- {err} -
- ))} -
-
-
-
-
- )} - - {/* Combined input container matching copilot style */} -
- {/* File thumbnails */} - {chatFiles.length > 0 && ( -
- {chatFiles.map((file) => { - const isImage = file.type.startsWith('image/') - let previewUrl: string | null = null - if (isImage) { - const blobUrl = URL.createObjectURL(file.file) - if (blobUrl.startsWith('blob:')) { - previewUrl = blobUrl - } - } - const getFileIcon = (type: string) => { - if (type.includes('pdf')) - return - if (type.startsWith('image/')) - return - if (type.includes('text') || type.includes('json')) - return - return - } - const formatFileSize = (bytes: number) => { - if (bytes === 0) return '0 B' - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}` - } - - return ( -
- {previewUrl ? ( - {file.name} - ) : ( - <> -
- {getFileIcon(file.type)} -
-
-
- {file.name} -
-
- {formatFileSize(file.size)} -
-
- - )} - - {/* Remove button */} - -
- ) - })} -
- )} - - {/* Input row */} -
- {/* Attach button */} - - - {/* Hidden file input */} - { - const files = e.target.files - if (!files) return - - const newFiles: ChatFile[] = [] - const errors: string[] = [] - for (let i = 0; i < files.length; i++) { - if (chatFiles.length + newFiles.length >= 15) { - errors.push('Maximum 15 files allowed') - break - } - const file = files[i] - if (file.size > 10 * 1024 * 1024) { - errors.push(`${file.name} is too large (max 10MB)`) - continue - } - - // Check for duplicates - const isDuplicate = chatFiles.some( - (existingFile) => - existingFile.name === file.name && existingFile.size === file.size - ) - if (isDuplicate) { - errors.push(`${file.name} already added`) - continue - } - - newFiles.push({ - id: crypto.randomUUID(), - name: file.name, - size: file.size, - type: file.type, - file, - }) - } - if (errors.length > 0) setUploadErrors(errors) - if (newFiles.length > 0) { - setChatFiles([...chatFiles, ...newFiles]) - setUploadErrors([]) // Clear errors when files are successfully added - } - e.target.value = '' - }} - className='hidden' - disabled={!activeWorkflowId || isExecuting || isUploadingFiles} - /> - - {/* Text input */} - { - setChatMessage(e.target.value) - setHistoryIndex(-1) - }} - onKeyDown={handleKeyPress} - placeholder={isDragOver ? 'Drop files here...' : 'Type a message...'} - className='h-8 flex-1 border-0 bg-transparent font-sans text-foreground text-sm shadow-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' - disabled={!activeWorkflowId || isExecuting || isUploadingFiles} - /> - - {/* Send button */} - -
-
-
-
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-file-upload/chat-file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-file-upload/chat-file-upload.tsx deleted file mode 100644 index 5fb3b3893e..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-file-upload/chat-file-upload.tsx +++ /dev/null @@ -1,234 +0,0 @@ -'use client' - -import { useRef, useState } from 'react' -import { File, FileText, Image, Paperclip, X } from 'lucide-react' -import { createLogger } from '@/lib/logs/console/logger' - -const logger = createLogger('ChatFileUpload') - -interface ChatFile { - id: string - name: string - size: number - type: string - file: File -} - -interface ChatFileUploadProps { - files: ChatFile[] - onFilesChange: (files: ChatFile[]) => void - maxFiles?: number - maxSize?: number // in MB - acceptedTypes?: string[] - disabled?: boolean - onError?: (errors: string[]) => void -} - -export function ChatFileUpload({ - files, - onFilesChange, - maxFiles = 15, - maxSize = 10, - acceptedTypes = ['*'], - disabled = false, - onError, -}: ChatFileUploadProps) { - const [isDragOver, setIsDragOver] = useState(false) - const fileInputRef = useRef(null) - - const handleFileSelect = (selectedFiles: FileList | null) => { - if (!selectedFiles || disabled) return - - const newFiles: ChatFile[] = [] - const errors: string[] = [] - - for (let i = 0; i < selectedFiles.length; i++) { - const file = selectedFiles[i] - - // Check file count limit - if (files.length + newFiles.length >= maxFiles) { - errors.push(`Maximum ${maxFiles} files allowed`) - break - } - - // Check file size - if (file.size > maxSize * 1024 * 1024) { - errors.push(`${file.name} is too large (max ${maxSize}MB)`) - continue - } - - // Check file type if specified - if (acceptedTypes.length > 0 && !acceptedTypes.includes('*')) { - const isAccepted = acceptedTypes.some((type) => { - if (type.endsWith('/*')) { - return file.type.startsWith(type.slice(0, -1)) - } - return file.type === type - }) - - if (!isAccepted) { - errors.push(`${file.name} type not supported`) - continue - } - } - - // Check for duplicates - const isDuplicate = files.some( - (existingFile) => existingFile.name === file.name && existingFile.size === file.size - ) - - if (isDuplicate) { - errors.push(`${file.name} already added`) - continue - } - - newFiles.push({ - id: crypto.randomUUID(), - name: file.name, - size: file.size, - type: file.type, - file, - }) - } - - if (errors.length > 0) { - logger.warn('File upload errors:', errors) - onError?.(errors) - } - - if (newFiles.length > 0) { - onFilesChange([...files, ...newFiles]) - } - } - - const handleRemoveFile = (fileId: string) => { - onFilesChange(files.filter((f) => f.id !== fileId)) - } - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - if (!disabled) { - setIsDragOver(true) - e.dataTransfer.dropEffect = 'copy' - } - } - - const handleDragEnter = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - if (!disabled) { - setIsDragOver(true) - } - } - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDragOver(false) - } - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDragOver(false) - if (!disabled) { - handleFileSelect(e.dataTransfer.files) - } - } - - const getFileIcon = (type: string) => { - if (type.startsWith('image/')) return - if (type.includes('text') || type.includes('json')) return - return - } - - const formatFileSize = (bytes: number) => { - if (bytes === 0) return '0 B' - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}` - } - - return ( -
- {/* File Upload Button */} -
- - - { - handleFileSelect(e.target.files) - if (fileInputRef.current) { - fileInputRef.current.value = '' - } - }} - className='hidden' - accept={acceptedTypes.join(',')} - disabled={disabled} - /> - - {files.length > 0 && ( - - {files.length}/{maxFiles} files - - )} -
- - {/* File List */} - {files.length > 0 && ( -
- {files.map((file) => ( -
- {getFileIcon(file.type)} - - {file.name} - - - {formatFileSize(file.size)} - - -
- ))} -
- )} - - {/* Drag and Drop Area (when dragging) */} - {isDragOver && ( -
-
-

Drop files here to attach

-
-
- )} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-message/chat-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-message/chat-message.tsx deleted file mode 100644 index 816dae5a00..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/chat-message/chat-message.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { useMemo } from 'react' -import { File, FileText, Image as ImageIcon } from 'lucide-react' - -interface ChatAttachment { - id: string - name: string - type: string - dataUrl: string - size?: number -} - -interface ChatMessageProps { - message: { - id: string - content: any - timestamp: string | Date - type: 'user' | 'workflow' - isStreaming?: boolean - attachments?: ChatAttachment[] - } -} - -// 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 }: ChatMessageProps) { - // Format message content as text - const formattedContent = useMemo(() => { - if (typeof message.content === 'object' && message.content !== null) { - return JSON.stringify(message.content, null, 2) - } - return String(message.content || '') - }, [message.content]) - - // Render human messages as chat bubbles - if (message.type === 'user') { - return ( -
- {/* File attachments displayed above the message, completely separate from message box */} - {message.attachments && message.attachments.length > 0 && ( -
-
- {message.attachments.map((attachment) => { - const isImage = attachment.type.startsWith('image/') - const getFileIcon = (type: string) => { - if (type.includes('pdf')) - return - if (type.startsWith('image/')) - return - if (type.includes('text') || type.includes('json')) - return - return - } - const formatFileSize = (bytes?: number) => { - if (!bytes || bytes === 0) return '' - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}` - } - - return ( -
{ - const validDataUrl = attachment.dataUrl?.trim() - if (validDataUrl?.startsWith('data:')) { - e.preventDefault() - e.stopPropagation() - const newWindow = window.open('', '_blank') - if (newWindow) { - newWindow.document.write(` - - - - ${attachment.name} - - - - ${attachment.name} - - - `) - newWindow.document.close() - } - } - }} - > - {isImage && - attachment.dataUrl?.trim() && - attachment.dataUrl.startsWith('data:') ? ( - {attachment.name} - ) : ( - <> -
- {getFileIcon(attachment.type)} -
-
-
- {attachment.name} -
- {attachment.size && ( -
- {formatFileSize(attachment.size)} -
- )} -
- - )} -
- ) - })} -
-
- )} - - {/* Only render message bubble if there's actual text content (not just file count message) */} - {formattedContent && !formattedContent.startsWith('Uploaded') && ( -
-
-
-
- -
-
-
-
- )} -
- ) - } - - // Render agent/workflow messages as full-width text - return ( -
-
-
- - {message.isStreaming && ( - - )} -
-
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx deleted file mode 100644 index 2247e482eb..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/chat/components/output-select/output-select.tsx +++ /dev/null @@ -1,515 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { Check, ChevronDown } from 'lucide-react' -import { createPortal } from 'react-dom' -import { extractFieldsFromSchema, parseResponseFormatSafely } from '@/lib/response-format' -import { cn } from '@/lib/utils' -import { getBlock } from '@/blocks' -import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' - -interface OutputSelectProps { - workflowId: string | null - selectedOutputs: string[] - onOutputSelect: (outputIds: string[]) => void - disabled?: boolean - placeholder?: string - valueMode?: 'id' | 'label' -} - -export function OutputSelect({ - workflowId, - selectedOutputs = [], - onOutputSelect, - disabled = false, - placeholder = 'Select output sources', - valueMode = 'id', -}: OutputSelectProps) { - const [isOutputDropdownOpen, setIsOutputDropdownOpen] = useState(false) - const dropdownRef = useRef(null) - const portalRef = useRef(null) - const [portalStyle, setPortalStyle] = useState<{ - top: number - left: number - width: number - height: number - } | null>(null) - const blocks = useWorkflowStore((state) => state.blocks) - const { isShowingDiff, isDiffReady, diffWorkflow } = useWorkflowDiffStore() - // Find all scrollable ancestors so the dropdown can stay pinned on scroll - const getScrollableAncestors = (el: HTMLElement | null): (HTMLElement | Window)[] => { - const ancestors: (HTMLElement | Window)[] = [] - let node: HTMLElement | null = el?.parentElement || null - const isScrollable = (elem: HTMLElement) => { - const style = window.getComputedStyle(elem) - const overflowY = style.overflowY - const overflow = style.overflow - const hasScroll = elem.scrollHeight > elem.clientHeight - return ( - hasScroll && - (overflowY === 'auto' || - overflowY === 'scroll' || - overflow === 'auto' || - overflow === 'scroll') - ) - } - - while (node && node !== document.body) { - if (isScrollable(node)) ancestors.push(node) - node = node.parentElement - } - - // Always include window as a fallback - ancestors.push(window) - return ancestors - } - - // Track subblock store state to ensure proper reactivity - const subBlockValues = useSubBlockStore((state) => - workflowId ? state.workflowValues[workflowId] : null - ) - - // Use diff blocks when in diff mode AND diff is ready, otherwise use main blocks - const workflowBlocks = isShowingDiff && isDiffReady && diffWorkflow ? diffWorkflow.blocks : blocks - - // Get workflow outputs for the dropdown - const workflowOutputs = useMemo(() => { - const outputs: { - id: string - label: string - blockId: string - blockName: string - blockType: string - path: string - }[] = [] - - if (!workflowId) return outputs - - // Check if workflowBlocks is defined - if (!workflowBlocks || typeof workflowBlocks !== 'object') { - return outputs - } - - // Check if we actually have blocks to process - const blockArray = Object.values(workflowBlocks) - if (blockArray.length === 0) { - return outputs - } - - // Process blocks to extract outputs - blockArray.forEach((block) => { - // Skip starter/start blocks - if (block.type === 'starter') return - - // Add defensive check to ensure block exists and has required properties - if (!block || !block.id || !block.type) { - return - } - - // Add defensive check to ensure block.name exists and is a string - const blockName = - block.name && typeof block.name === 'string' - ? block.name.replace(/\s+/g, '').toLowerCase() - : `block-${block.id}` - - // Get block configuration from registry to get outputs - const blockConfig = getBlock(block.type) - - // Check for custom response format first - // In diff mode, get value from diff blocks; otherwise use store - const responseFormatValue = - isShowingDiff && isDiffReady && diffWorkflow - ? diffWorkflow.blocks[block.id]?.subBlocks?.responseFormat?.value - : subBlockValues?.[block.id]?.responseFormat - const responseFormat = parseResponseFormatSafely(responseFormatValue, block.id) - - let outputsToProcess: Record = {} - - if (responseFormat) { - // Use custom schema properties if response format is specified - const schemaFields = extractFieldsFromSchema(responseFormat) - if (schemaFields.length > 0) { - // Convert schema fields to output structure - schemaFields.forEach((field) => { - outputsToProcess[field.name] = { type: field.type } - }) - } else { - // Fallback to block config outputs if schema extraction failed - outputsToProcess = blockConfig?.outputs || {} - } - } else { - // Use block config outputs instead of block.outputs - outputsToProcess = blockConfig?.outputs || {} - } - - // Add response outputs - if (Object.keys(outputsToProcess).length > 0) { - const addOutput = (path: string, outputObj: any, prefix = '') => { - const fullPath = prefix ? `${prefix}.${path}` : path - - // If not an object or is null, treat as leaf node - if (typeof outputObj !== 'object' || outputObj === null) { - const output = { - id: `${block.id}_${fullPath}`, - label: `${blockName}.${fullPath}`, - blockId: block.id, - blockName: block.name || `Block ${block.id}`, - blockType: block.type, - path: fullPath, - } - outputs.push(output) - return - } - - // If has 'type' property, treat as schema definition (leaf node) - if ('type' in outputObj && typeof outputObj.type === 'string') { - const output = { - id: `${block.id}_${fullPath}`, - label: `${blockName}.${fullPath}`, - blockId: block.id, - blockName: block.name || `Block ${block.id}`, - blockType: block.type, - path: fullPath, - } - outputs.push(output) - return - } - - // For objects without type, recursively add each property - if (!Array.isArray(outputObj)) { - Object.entries(outputObj).forEach(([key, value]) => { - addOutput(key, value, fullPath) - }) - } else { - // For arrays, treat as leaf node - outputs.push({ - id: `${block.id}_${fullPath}`, - label: `${blockName}.${fullPath}`, - blockId: block.id, - blockName: block.name || `Block ${block.id}`, - blockType: block.type, - path: fullPath, - }) - } - } - - // Process all output properties directly (flattened structure) - Object.entries(outputsToProcess).forEach(([key, value]) => { - addOutput(key, value) - }) - } - }) - - return outputs - }, [workflowBlocks, workflowId, isShowingDiff, isDiffReady, diffWorkflow, blocks, subBlockValues]) - - // Utility to check selected by id or label - const isSelectedValue = (o: { id: string; label: string }) => - selectedOutputs.includes(o.id) || selectedOutputs.includes(o.label) - - // Get selected outputs display text - const selectedOutputsDisplayText = useMemo(() => { - if (!selectedOutputs || selectedOutputs.length === 0) { - return placeholder - } - - // Ensure all selected outputs exist in the workflowOutputs array by id or label - const validOutputs = selectedOutputs.filter((val) => - workflowOutputs.some((o) => o.id === val || o.label === val) - ) - - if (validOutputs.length === 0) { - return placeholder - } - - if (validOutputs.length === 1) { - const output = workflowOutputs.find( - (o) => o.id === validOutputs[0] || o.label === validOutputs[0] - ) - if (output) { - return output.label - } - return placeholder - } - - return `${validOutputs.length} outputs selected` - }, [selectedOutputs, workflowOutputs, placeholder]) - - // Get first selected output info for display icon - const selectedOutputInfo = useMemo(() => { - if (!selectedOutputs || selectedOutputs.length === 0) return null - - const validOutputs = selectedOutputs.filter((val) => - workflowOutputs.some((o) => o.id === val || o.label === val) - ) - if (validOutputs.length === 0) return null - - const output = workflowOutputs.find( - (o) => o.id === validOutputs[0] || o.label === validOutputs[0] - ) - if (!output) return null - - return { - blockName: output.blockName, - blockId: output.blockId, - blockType: output.blockType, - path: output.path, - } - }, [selectedOutputs, workflowOutputs]) - - // Group output options by block - const groupedOutputs = useMemo(() => { - const groups: Record = {} - const blockDistances: Record = {} - const edges = useWorkflowStore.getState().edges - - // Find the starter block - const starterBlock = Object.values(blocks).find((block) => block.type === 'starter') - const starterBlockId = starterBlock?.id - - // Calculate distances from starter block if it exists - if (starterBlockId) { - // Build an adjacency list for faster traversal - const adjList: Record = {} - for (const edge of edges) { - if (!adjList[edge.source]) { - adjList[edge.source] = [] - } - adjList[edge.source].push(edge.target) - } - - // BFS to find distances from starter block - const visited = new Set() - const queue: [string, number][] = [[starterBlockId, 0]] // [nodeId, distance] - - while (queue.length > 0) { - const [currentNodeId, distance] = queue.shift()! - - if (visited.has(currentNodeId)) continue - visited.add(currentNodeId) - blockDistances[currentNodeId] = distance - - // Get all outgoing edges from the adjacency list - const outgoingNodeIds = adjList[currentNodeId] || [] - - // Add all target nodes to the queue with incremented distance - for (const targetId of outgoingNodeIds) { - queue.push([targetId, distance + 1]) - } - } - } - - // Group by block name - workflowOutputs.forEach((output) => { - if (!groups[output.blockName]) { - groups[output.blockName] = [] - } - groups[output.blockName].push(output) - }) - - // Convert to array of [blockName, outputs] for sorting - const groupsArray = Object.entries(groups).map(([blockName, outputs]) => { - // Find the blockId for this group (using the first output's blockId) - const blockId = outputs[0]?.blockId - // Get the distance for this block (or default to 0 if not found) - const distance = blockId ? blockDistances[blockId] || 0 : 0 - return { blockName, outputs, distance } - }) - - // Sort by distance (descending - furthest first) - groupsArray.sort((a, b) => b.distance - a.distance) - - // Convert back to record - return groupsArray.reduce( - (acc, { blockName, outputs }) => { - acc[blockName] = outputs - return acc - }, - {} as Record - ) - }, [workflowOutputs, blocks]) - - // 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 - } - - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - const target = event.target as Node - const insideTrigger = dropdownRef.current?.contains(target) - const insidePortal = portalRef.current?.contains(target) - if (!insideTrigger && !insidePortal) { - setIsOutputDropdownOpen(false) - } - } - - document.addEventListener('mousedown', handleClickOutside) - return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - }, []) - - // Position the portal dropdown relative to the trigger button - useEffect(() => { - const updatePosition = () => { - if (!isOutputDropdownOpen || !dropdownRef.current) return - const rect = dropdownRef.current.getBoundingClientRect() - const available = Math.max(140, window.innerHeight - rect.bottom - 12) - const height = Math.min(available, 240) - setPortalStyle({ top: rect.bottom + 4, left: rect.left, width: rect.width, height }) - } - - let attachedScrollTargets: (HTMLElement | Window)[] = [] - let rafId: number | null = null - if (isOutputDropdownOpen) { - updatePosition() - window.addEventListener('resize', updatePosition) - attachedScrollTargets = getScrollableAncestors(dropdownRef.current) - attachedScrollTargets.forEach((target) => - target.addEventListener('scroll', updatePosition, { passive: true }) - ) - const loop = () => { - updatePosition() - rafId = requestAnimationFrame(loop) - } - rafId = requestAnimationFrame(loop) - } - - return () => { - window.removeEventListener('resize', updatePosition) - attachedScrollTargets.forEach((target) => - target.removeEventListener('scroll', updatePosition) - ) - if (rafId) cancelAnimationFrame(rafId) - } - }, [isOutputDropdownOpen]) - - // Handle output selection - toggle selection - const handleOutputSelection = (value: string) => { - const emittedValue = - valueMode === 'label' ? value : workflowOutputs.find((o) => o.label === value)?.id || value - let newSelectedOutputs: string[] - const index = selectedOutputs.indexOf(emittedValue) - - if (index === -1) { - newSelectedOutputs = [...new Set([...selectedOutputs, emittedValue])] - } else { - newSelectedOutputs = selectedOutputs.filter((id) => id !== emittedValue) - } - - onOutputSelect(newSelectedOutputs) - } - - return ( -
- - - {isOutputDropdownOpen && - workflowOutputs.length > 0 && - portalStyle && - createPortal( -
-
-
{ - // Keep wheel scroll inside the dropdown and avoid dialog/body scroll locks - e.stopPropagation() - }} - > - {Object.entries(groupedOutputs).map(([blockName, outputs]) => ( -
-
- {blockName} -
-
- {outputs.map((output) => ( - - ))} -
-
- ))} -
-
-
, - document.body - )} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index df743c3ea7..a4594580ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -10,13 +10,13 @@ import { } from '@/components/ui/dropdown-menu' import { LandingPromptStorage } from '@/lib/browser-storage' import { createLogger } from '@/lib/logs/console/logger' -import { useChatStore } from '@/stores/panel/chat/store' +import { useChatStore } from '@/stores/chat/store' import { usePanelStore } from '@/stores/panel/store' import { useCopilotStore } from '@/stores/panel-new/copilot/store' import { useTerminalConsoleStore } from '@/stores/terminal' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { Copilot } from '../panel-new/components/copilot/copilot' -import { Chat } from './components/chat/chat' +// import { Chat } from './components/chat/chat' import { Console } from './components/console/console' import { Variables } from './components/variables/variables' @@ -605,7 +605,7 @@ export function Panel() {
{/* Keep all tabs mounted but hidden to preserve state and animations */}
- + {/* */}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts index 2be399ec94..bdafb76c60 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts @@ -3,9 +3,10 @@ import { useTerminalStore } from '@/stores/terminal' /** * Constants for output panel sizing + * Must match MIN_OUTPUT_PANEL_WIDTH_PX and BLOCK_COLUMN_WIDTH_PX in terminal.tsx */ const MIN_WIDTH = 300 -const BLOCK_COLUMN_WIDTH = 200 // Must match COLUMN_WIDTHS.BLOCK in terminal.tsx +const BLOCK_COLUMN_WIDTH = 240 /** * Custom hook to handle output panel horizontal resize functionality. diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts index a434c1f72e..c8406c720f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts @@ -5,12 +5,12 @@ import { useTerminalStore } from '@/stores/terminal' * Constants for terminal sizing */ const MIN_HEIGHT = 30 -const MAX_HEIGHT_PERCENTAGE = 0.5 // 50% of viewport height +const MAX_HEIGHT_PERCENTAGE = 0.7 // 70% of viewport height /** * Custom hook to handle terminal resize functionality. * Manages mouse events for resizing and enforces min/max height constraints. - * Maximum height is capped at 50% of the viewport height for optimal layout. + * Maximum height is capped at 70% of the viewport height for optimal layout. * * @returns Resize state and handlers */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index 1a22e26275..514f1a9d7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -2,7 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' -import { Check, ChevronDown, Clipboard, MoreHorizontal, RepeatIcon, SplitIcon } from 'lucide-react' +import { + Check, + ChevronDown, + Clipboard, + MoreHorizontal, + RepeatIcon, + SplitIcon, + Trash2, +} from 'lucide-react' import { Button, Code, @@ -30,20 +38,40 @@ const NEAR_MIN_THRESHOLD = 40 const DEFAULT_EXPANDED_HEIGHT = 300 /** - * Column width constants + * Column width constants - numeric values for calculations + */ +const BLOCK_COLUMN_WIDTH_PX = 240 +const MIN_OUTPUT_PANEL_WIDTH_PX = 300 + +/** + * Column width constants - Tailwind classes for styling */ const COLUMN_WIDTHS = { - BLOCK: 'w-[200px]', + BLOCK: 'w-[240px]', STATUS: 'w-[120px]', DURATION: 'w-[120px]', + RUN_ID: 'w-[120px]', + TIMESTAMP: 'w-[120px]', OUTPUT_PANEL: 'w-[400px]', } as const +/** + * Color palette for run IDs - matching code syntax highlighting colors + */ +const RUN_ID_COLORS = [ + { text: '#4ADE80' }, // Green + { text: '#F472B6' }, // Pink + { text: '#60C5FF' }, // Blue + { text: '#FF8533' }, // Orange + { text: '#C084FC' }, // Purple + { text: '#FCD34D' }, // Yellow +] as const + /** * Shared styling constants */ -const HEADER_TEXT_CLASS = 'font-medium text-[#8D8D8D] text-[13px] dark:text-[#8D8D8D]' -const ROW_TEXT_CLASS = 'font-medium text-[#D2D2D2] text-[13px] dark:text-[#D2D2D2]' +const HEADER_TEXT_CLASS = 'font-medium text-[#AEAEAE] text-[12px] dark:text-[#AEAEAE]' +const ROW_TEXT_CLASS = 'font-medium text-[#D2D2D2] text-[12px] dark:text-[#D2D2D2]' const COLUMN_BASE_CLASS = 'flex-shrink-0' /** @@ -117,6 +145,43 @@ const ToggleButton = ({ ) +/** + * Formats timestamp to H:MM:SS AM/PM TZ format + */ +const formatTimestamp = (timestamp: string): string => { + const date = new Date(timestamp) + const fullString = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + hour12: true, + timeZoneName: 'short', + }) + // Format: "5:54:55 PM PST" - return as is + return fullString +} + +/** + * Truncates execution ID for display as run ID + */ +const formatRunId = (executionId?: string): string => { + if (!executionId) return '-' + return executionId.slice(0, 8) +} + +/** + * Gets color for a run ID based on its index in the execution ID order map + */ +const getRunIdColor = ( + executionId: string | undefined, + executionIdOrderMap: Map +) => { + if (!executionId) return null + const colorIndex = executionIdOrderMap.get(executionId) + if (colorIndex === undefined) return null + return RUN_ID_COLORS[colorIndex % RUN_ID_COLORS.length] +} + /** * Terminal component with resizable height that persists across page refreshes. * @@ -141,12 +206,14 @@ export function Terminal() { setHasHydrated, } = useTerminalStore() const entries = useTerminalConsoleStore((state) => state.entries) + const clearWorkflowConsole = useTerminalConsoleStore((state) => state.clearWorkflowConsole) const { activeWorkflowId } = useWorkflowRegistry() const [selectedEntry, setSelectedEntry] = useState(null) const [isToggling, setIsToggling] = useState(false) const [displayPopoverOpen, setDisplayPopoverOpen] = useState(false) const [wrapText, setWrapText] = useState(true) const [showCopySuccess, setShowCopySuccess] = useState(false) + const [showInput, setShowInput] = useState(false) // Terminal resize hooks const { handleMouseDown } = useTerminalResize() @@ -162,6 +229,54 @@ export function Terminal() { return entries.filter((entry) => entry.workflowId === activeWorkflowId) }, [entries, activeWorkflowId]) + /** + * Create stable execution ID to color index mapping based on order of first appearance. + * Once an execution ID is assigned a color index, it keeps that index. + */ + const executionIdOrderMap = useMemo(() => { + const orderMap = new Map() + let colorIndex = 0 + + // Process entries in reverse order (oldest first) since entries array is newest-first + for (let i = filteredEntries.length - 1; i >= 0; i--) { + const entry = filteredEntries[i] + if (entry.executionId && !orderMap.has(entry.executionId)) { + orderMap.set(entry.executionId, colorIndex) + colorIndex++ + } + } + + return orderMap + }, [filteredEntries]) + + /** + * Check if input data exists for selected entry + */ + const hasInputData = useMemo(() => { + if (!selectedEntry?.input) return false + return typeof selectedEntry.input === 'object' + ? Object.keys(selectedEntry.input).length > 0 + : true + }, [selectedEntry]) + + /** + * Check if this is a function block with code input + */ + const shouldShowCodeDisplay = useMemo(() => { + if (!selectedEntry || !showInput || selectedEntry.blockType !== 'function') return false + const input = selectedEntry.input + return typeof input === 'object' && input && 'code' in input && typeof input.code === 'string' + }, [selectedEntry, showInput]) + + /** + * Get the data to display in the output panel + */ + const outputData = useMemo(() => { + if (!selectedEntry) return null + if (selectedEntry.error) return selectedEntry.error + return showInput ? selectedEntry.input : selectedEntry.output + }, [selectedEntry, showInput]) + /** * Handle row click - toggle if clicking same entry */ @@ -178,7 +293,7 @@ export function Terminal() { if (isExpanded) { setTerminalHeight(MIN_HEIGHT) } else { - const maxHeight = window.innerHeight * 0.5 + const maxHeight = window.innerHeight * 0.7 const targetHeight = Math.min(DEFAULT_EXPANDED_HEIGHT, maxHeight) setTerminalHeight(targetHeight) } @@ -197,12 +312,27 @@ export function Terminal() { const handleCopy = useCallback(() => { if (!selectedEntry) return - const dataToCopy = selectedEntry.error || selectedEntry.output - const textToCopy = JSON.stringify(dataToCopy, null, 2) + const textToCopy = shouldShowCodeDisplay + ? selectedEntry.input.code + : JSON.stringify(outputData, null, 2) navigator.clipboard.writeText(textToCopy) setShowCopySuccess(true) - }, [selectedEntry]) + }, [selectedEntry, outputData, shouldShowCodeDisplay]) + + /** + * Handle clear console for current workflow + */ + const handleClearConsole = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (activeWorkflowId) { + clearWorkflowConsole(activeWorkflowId) + setSelectedEntry(null) + } + }, + [activeWorkflowId, clearWorkflowConsole] + ) /** * Mark hydration as complete on mount @@ -211,6 +341,30 @@ export function Terminal() { setHasHydrated(true) }, [setHasHydrated]) + /** + * Adjust showInput when selected entry changes + * Stay on input view if the new entry has input data + */ + useEffect(() => { + if (!selectedEntry) { + setShowInput(false) + return + } + + // If we're viewing input but the new entry has no input, switch to output + if (showInput) { + const newHasInput = + selectedEntry.input && + (typeof selectedEntry.input === 'object' + ? Object.keys(selectedEntry.input).length > 0 + : true) + + if (!newHasInput) { + setShowInput(false) + } + } + }, [selectedEntry, showInput]) + /** * Reset copy success state after 2 seconds */ @@ -223,6 +377,33 @@ export function Terminal() { } }, [showCopySuccess]) + /** + * Handle keyboard navigation through logs + */ + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!selectedEntry || filteredEntries.length === 0) return + + // Only handle arrow keys + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return + + // Prevent default scrolling behavior + e.preventDefault() + + const currentIndex = filteredEntries.findIndex((entry) => entry.id === selectedEntry.id) + if (currentIndex === -1) return + + if (e.key === 'ArrowUp' && currentIndex > 0) { + setSelectedEntry(filteredEntries[currentIndex - 1]) + } else if (e.key === 'ArrowDown' && currentIndex < filteredEntries.length - 1) { + setSelectedEntry(filteredEntries[currentIndex + 1]) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [selectedEntry, filteredEntries]) + /** * Adjust output panel width when sidebar or panel width changes. * Ensures output panel doesn't exceed maximum allowed width. @@ -240,12 +421,11 @@ export function Terminal() { // Calculate max width: total terminal width minus block column width const terminalWidth = window.innerWidth - sidebarWidth - panelWidth - const maxWidth = terminalWidth - 200 // COLUMN_WIDTHS.BLOCK - const minWidth = 300 + const maxWidth = terminalWidth - BLOCK_COLUMN_WIDTH_PX // If current output panel width exceeds max, clamp it - if (outputPanelWidth > maxWidth && maxWidth >= minWidth) { - setOutputPanelWidth(Math.max(maxWidth, minWidth)) + if (outputPanelWidth > maxWidth && maxWidth >= MIN_OUTPUT_PANEL_WIDTH_PX) { + setOutputPanelWidth(Math.max(maxWidth, MIN_OUTPUT_PANEL_WIDTH_PX)) } } @@ -305,9 +485,28 @@ export function Terminal() { > + + {!selectedEntry && ( -
+
+ {filteredEntries.length > 0 && ( + + + + + + Clear console + + + )} { @@ -322,7 +521,7 @@ export function Terminal() { {/* Rows */}
{filteredEntries.length === 0 ? ( -
+
No logs yet
) : ( @@ -330,6 +529,7 @@ export function Terminal() { const statusInfo = getStatusInfo(entry.success, entry.error) const isSelected = selectedEntry?.id === entry.id const BlockIcon = getBlockIcon(entry.blockType) + const runIdColor = getRunIdColor(entry.executionId, executionIdOrderMap) return (
handleRowClick(entry)} > + {/* Block */}
{entry.blockName}
+ + {/* Status */}
{statusInfo ? (
{statusInfo.label} @@ -379,6 +582,29 @@ export function Terminal() { - )}
+ + {/* Run ID */} + + + + {formatRunId(entry.executionId)} + + + {entry.executionId && ( + + {entry.executionId} + + )} + + + {/* Duration */} {formatDuration(entry.durationMs)} + + {/* Timestamp */} + + {formatTimestamp(entry.timestamp)} +
) }) @@ -413,11 +651,54 @@ export function Terminal() { {/* Header */}
- Output -
+
+ + {hasInputData && ( + + )} +
+
@@ -500,6 +781,23 @@ export function Terminal() { + {filteredEntries.length > 0 && ( + + + + + + Clear console + + + )} { @@ -514,25 +812,33 @@ export function Terminal() {
- {displayMode === 'raw' ? ( + {shouldShowCodeDisplay ? ( - ) : ( - + ) : ( + )}
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 b6ca25762c..fb279cb53f 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 @@ -164,7 +164,9 @@ const getDisplayValue = (value: unknown): string => { */ const SubBlockRow = ({ title, value }: { title: string; value?: string }) => (
- {title} + + {title} + {value !== undefined && ( {value} 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 159f92efe5..951cd41d3b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts @@ -1,4 +1,5 @@ export { useAutoLayout } from './use-auto-layout' export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow' export { useNodeUtilities } from './use-node-utilities' +export { useScrollManagement } from './use-scroll-management' export { useWorkflowExecution } from './use-workflow-execution' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/hooks/use-scroll-management.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/copilot/hooks/use-scroll-management.ts rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 9422537f32..4f76ed807a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -442,9 +442,7 @@ export function useWorkflowExecution() { } // Get selected outputs from chat store - const chatStore = await import('@/stores/panel/chat/store').then( - (mod) => mod.useChatStore - ) + const chatStore = await import('@/stores/chat/store').then((mod) => mod.useChatStore) const selectedOutputs = chatStore .getState() .getSelectedWorkflowOutput(activeWorkflowId) @@ -707,7 +705,7 @@ export function useWorkflowExecution() { let selectedOutputs: string[] | undefined if (isExecutingFromChat && activeWorkflowId) { // Get selected outputs from chat store - const chatStore = await import('@/stores/panel/chat/store').then((mod) => mod.useChatStore) + const chatStore = await import('@/stores/chat/store').then((mod) => mod.useChatStore) selectedOutputs = chatStore.getState().getSelectedWorkflowOutput(activeWorkflowId) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index b1df41dfaa..5b1643cd60 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -15,6 +15,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { TriggerUtils } from '@/lib/workflows/triggers' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { DiffControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components' +import { Chat } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat' import { UserAvatarStack } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/user-avatar-stack' import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index' import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new' @@ -2050,6 +2051,9 @@ const WorkflowContent = React.memo(() => { + {/* Floating chat modal */} + + {/* Show DiffControls if diff is available (regardless of current view mode) */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu.tsx new file mode 100644 index 0000000000..cc6ba7332d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu.tsx @@ -0,0 +1,82 @@ +'use client' + +import { Pencil } from 'lucide-react' +import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn' +import { Trash } from '@/components/emcn/icons' + +interface ContextMenuProps { + /** + * Whether the context menu is open + */ + isOpen: boolean + /** + * Position of the context menu + */ + position: { x: number; y: number } + /** + * Ref for the menu element + */ + menuRef: React.RefObject + /** + * Callback when menu should close + */ + onClose: () => void + /** + * Callback when rename is clicked + */ + onRename: () => void + /** + * Callback when delete is clicked + */ + onDelete: () => void +} + +/** + * Reusable context menu component for workflow and folder items. + * Displays rename and delete options in a popover at the right-click position. + * + * @param props - Component props + * @returns Context menu popover + */ +export function ContextMenu({ + isOpen, + position, + menuRef, + onClose, + onRename, + onDelete, +}: ContextMenuProps) { + return ( + + + + { + onRename() + onClose() + }} + > + + Rename + + { + onDelete() + onClose() + }} + > + + Delete + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/delete-modal/delete-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/delete-modal/delete-modal.tsx new file mode 100644 index 0000000000..3e4ef1c252 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/delete-modal/delete-modal.tsx @@ -0,0 +1,94 @@ +'use client' + +import { + Modal, + ModalContent, + ModalDescription, + ModalFooter, + ModalHeader, + ModalTitle, +} from '@/components/emcn' +import { Button } from '@/components/ui/button' + +interface DeleteModalProps { + /** + * Whether the modal is open + */ + isOpen: boolean + /** + * Callback when modal should close + */ + onClose: () => void + /** + * Callback when delete is confirmed + */ + onConfirm: () => void + /** + * Whether the delete operation is in progress + */ + isDeleting: boolean + /** + * Type of item being deleted + */ + itemType: 'workflow' | 'folder' + /** + * Name of the item being deleted (optional, for display) + */ + itemName?: string +} + +/** + * Reusable delete confirmation modal for workflow and folder items. + * Displays a warning message and confirmation buttons. + * + * @param props - Component props + * @returns Delete confirmation modal + */ +export function DeleteModal({ + isOpen, + onClose, + onConfirm, + isDeleting, + itemType, + itemName, +}: DeleteModalProps) { + const title = itemType === 'workflow' ? 'Delete workflow?' : 'Delete folder?' + + const description = + itemType === 'workflow' + ? 'Deleting this workflow will permanently remove all associated blocks, executions, and configuration.' + : 'Deleting this folder will permanently remove all associated workflows, logs, and knowledge bases.' + + return ( + + + + {title} + + {description}{' '} + + This action cannot be undone. + + + + + + + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx index dd610d5a9e..2720743e9b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/folder-item/folder-item.tsx @@ -1,11 +1,16 @@ 'use client' -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import clsx from 'clsx' import { ChevronRight, Folder, FolderOpen } from 'lucide-react' -import type { FolderTreeNode } from '@/stores/folders/store' -import { useFolderExpand } from '../../../../hooks/use-folder-expand' -import { useItemDrag } from '../../../../hooks/use-item-drag' +import { useParams } from 'next/navigation' +import { createLogger } from '@/lib/logs/console/logger' +import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store' +import { useContextMenu, useFolderExpand, useItemDrag, useItemRename } from '../../../../hooks' +import { ContextMenu } from '../context-menu/context-menu' +import { DeleteModal } from '../delete-modal/delete-modal' + +const logger = createLogger('FolderItem') interface FolderItemProps { folder: FolderTreeNode @@ -25,8 +30,20 @@ interface FolderItemProps { * @returns Folder item with drag and expand support */ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { + const params = useParams() + const workspaceId = params.workspaceId as string + const { updateFolderAPI, deleteFolder } = useFolderStore() + + // Delete modal state + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + // Folder expand hook - const { isExpanded, handleToggleExpanded, handleKeyDown } = useFolderExpand({ + const { + isExpanded, + handleToggleExpanded, + handleKeyDown: handleExpandKeyDown, + } = useFolderExpand({ folderId: folder.id, }) @@ -37,6 +54,12 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { */ const onDragStart = useCallback( (e: React.DragEvent) => { + // Don't start drag if editing + if (isEditing) { + e.preventDefault() + return + } + e.dataTransfer.setData('folder-id', folder.id) e.dataTransfer.effectAllowed = 'move' }, @@ -48,6 +71,50 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { onDragStart, }) + // Context menu hook + const { + isOpen: isContextMenuOpen, + position, + menuRef, + handleContextMenu, + closeMenu, + } = useContextMenu() + + // Rename hook + const { + isEditing, + editValue, + isRenaming, + inputRef, + setEditValue, + handleStartEdit, + handleKeyDown: handleRenameKeyDown, + handleInputBlur, + } = useItemRename({ + initialName: folder.name, + onSave: async (newName) => { + await updateFolderAPI(folder.id, { name: newName }) + }, + itemType: 'folder', + itemId: folder.id, + }) + + /** + * Handle delete folder after confirmation + */ + const handleDeleteFolder = useCallback(async () => { + setIsDeleting(true) + try { + await deleteFolder(folder.id, workspaceId) + setIsDeleteModalOpen(false) + logger.info('Folder deleted successfully') + } catch (error) { + logger.error('Error deleting folder:', error) + } finally { + setIsDeleting(false) + } + }, [folder.id, workspaceId, deleteFolder]) + /** * Handle click - toggles folder expansion * @@ -57,52 +124,114 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) { (e: React.MouseEvent) => { e.stopPropagation() - if (shouldPreventClickRef.current) { + if (shouldPreventClickRef.current || isEditing) { e.preventDefault() return } handleToggleExpanded() }, - [handleToggleExpanded, shouldPreventClickRef] + [handleToggleExpanded, shouldPreventClickRef, isEditing] + ) + + /** + * Combined keyboard handler for both expand and rename + */ + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (isEditing) { + handleRenameKeyDown(e) + } else { + handleExpandKeyDown(e) + } + }, + [isEditing, handleRenameKeyDown, handleExpandKeyDown] ) return ( -
- + + {isExpanded ? ( +
+ + {/* Context Menu */} + setIsDeleteModalOpen(true)} + /> + + {/* Delete Modal */} + setIsDeleteModalOpen(false)} + onConfirm={handleDeleteFolder} + isDeleting={isDeleting} + itemType='folder' + itemName={folder.name} + /> + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/index.ts new file mode 100644 index 0000000000..793641e757 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/index.ts @@ -0,0 +1,3 @@ +export { ContextMenu } from './context-menu/context-menu' +export { FolderItem } from './folder-item/folder-item' +export { WorkflowItem } from './workflow-item/workflow-item' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/workflow-item/workflow-item.tsx index 9a74d4c7e0..9e83682012 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/workflow-item/workflow-item.tsx @@ -1,12 +1,19 @@ 'use client' -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import clsx from 'clsx' import Link from 'next/link' import { useParams } from 'next/navigation' +import { createLogger } from '@/lib/logs/console/logger' +import { useDeleteWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' import { useFolderStore } from '@/stores/folders/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' -import { useItemDrag } from '../../../../hooks/use-item-drag' +import { useContextMenu, useItemDrag, useItemRename } from '../../../../hooks' +import { ContextMenu } from '../context-menu/context-menu' +import { DeleteModal } from '../delete-modal/delete-modal' + +const logger = createLogger('WorkflowItem') interface WorkflowItemProps { workflow: WorkflowMetadata @@ -26,8 +33,20 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf const params = useParams() const workspaceId = params.workspaceId as string const { selectedWorkflows } = useFolderStore() + const { updateWorkflow } = useWorkflowRegistry() const isSelected = selectedWorkflows.has(workflow.id) + // Delete modal state + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) + + // Delete workflow hook + const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({ + workspaceId, + workflowId: workflow.id, + isActive: active, + onSuccess: () => setIsDeleteModalOpen(false), + }) + /** * Drag start handler - handles workflow dragging with multi-selection support * @@ -35,6 +54,12 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf */ const onDragStart = useCallback( (e: React.DragEvent) => { + // Don't start drag if editing + if (isEditing) { + e.preventDefault() + return + } + const workflowIds = isSelected && selectedWorkflows.size > 1 ? Array.from(selectedWorkflows) : [workflow.id] @@ -49,6 +74,34 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf onDragStart, }) + // Context menu hook + const { + isOpen: isContextMenuOpen, + position, + menuRef, + handleContextMenu, + closeMenu, + } = useContextMenu() + + // Rename hook + const { + isEditing, + editValue, + isRenaming, + inputRef, + setEditValue, + handleStartEdit, + handleKeyDown, + handleInputBlur, + } = useItemRename({ + initialName: workflow.name, + onSave: async (newName) => { + await updateWorkflow(workflow.id, { name: newName }) + }, + itemType: 'workflow', + itemId: workflow.id, + }) + /** * Handle click - manages workflow selection with shift-key and cmd/ctrl-key support * @@ -58,7 +111,7 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf (e: React.MouseEvent) => { e.stopPropagation() - if (shouldPreventClickRef.current) { + if (shouldPreventClickRef.current || isEditing) { e.preventDefault() return } @@ -73,38 +126,89 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf // Use metaKey (Cmd on Mac) or ctrlKey (Ctrl on Windows/Linux) onWorkflowClick(workflow.id, e.shiftKey, e.metaKey || e.ctrlKey) }, - [shouldPreventClickRef, workflow.id, onWorkflowClick] + [shouldPreventClickRef, workflow.id, onWorkflowClick, isEditing] ) return ( - 1 && !active ? 'bg-[#2C2C2C] dark:bg-[#2C2C2C]' : '', - isDragging ? 'opacity-50' : '' - )} - draggable - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - onClick={handleClick} - > -
- + 1 && !active + ? 'bg-[#2C2C2C] dark:bg-[#2C2C2C]' + : '', + isDragging ? 'opacity-50' : '' )} + draggable={!isEditing} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onClick={handleClick} + onContextMenu={handleContextMenu} > - {workflow.name} - - +
+ {isEditing ? ( + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleInputBlur} + className={clsx( + 'min-w-0 flex-1 border-0 bg-transparent p-0 font-medium text-[14px] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0', + active + ? 'text-[#E6E6E6] dark:text-[#E6E6E6]' + : 'text-[#AEAEAE] group-hover:text-[#E6E6E6] dark:text-[#AEAEAE] dark:group-hover:text-[#E6E6E6]' + )} + maxLength={100} + disabled={isRenaming} + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + }} + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + spellCheck='false' + /> + ) : ( + + {workflow.name} + + )} + + + {/* Context Menu */} + setIsDeleteModalOpen(true)} + /> + + {/* Delete Confirmation Modal */} + setIsDeleteModalOpen(false)} + onConfirm={handleDeleteWorkflow} + isDeleting={isDeleting} + itemType='workflow' + itemName={workflow.name} + /> + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts index d94cd2888e..724f183f93 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/index.ts @@ -1,7 +1,9 @@ +export { useContextMenu } from './use-context-menu' export { useDragDrop } from './use-drag-drop' export { useFolderExpand } from './use-folder-expand' export { useFolderOperations } from './use-folder-operations' export { useItemDrag } from './use-item-drag' +export { useItemRename } from './use-item-rename' export { useSidebarResize } from './use-sidebar-resize' export { useWorkflowImport } from './use-workflow-import' export { useWorkflowOperations } from './use-workflow-operations' 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 new file mode 100644 index 0000000000..13a6291e34 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-context-menu.ts @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +interface UseContextMenuProps { + /** + * Callback when context menu should open + */ + onContextMenu?: (e: React.MouseEvent) => void +} + +interface ContextMenuPosition { + x: number + y: number +} + +/** + * Hook for managing context menu (right-click) state and positioning. + * + * Handles: + * - Right-click event prevention and positioning + * - Menu open/close state + * - Click-outside detection to close menu + * + * @param props - Hook configuration + * @returns Context menu state and handlers + */ +export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) { + const [isOpen, setIsOpen] = useState(false) + const [position, setPosition] = useState({ x: 0, y: 0 }) + const menuRef = useRef(null) + + /** + * Handle right-click event + */ + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + // Calculate position relative to viewport + const x = e.clientX + const y = e.clientY + + setPosition({ x, y }) + setIsOpen(true) + + onContextMenu?.(e) + }, + [onContextMenu] + ) + + /** + * Close the context menu + */ + const closeMenu = useCallback(() => { + setIsOpen(false) + }, []) + + /** + * Handle clicks outside the menu to close it + */ + useEffect(() => { + if (!isOpen) return + + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + closeMenu() + } + } + + // Small delay to prevent immediate close from the same click that opened the menu + const timeoutId = setTimeout(() => { + document.addEventListener('click', handleClickOutside) + }, 0) + + return () => { + clearTimeout(timeoutId) + document.removeEventListener('click', handleClickOutside) + } + }, [isOpen, closeMenu]) + + return { + isOpen, + position, + menuRef, + handleContextMenu, + closeMenu, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-item-rename.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-item-rename.ts new file mode 100644 index 0000000000..d62a96bc24 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-item-rename.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('useItemRename') + +interface UseItemRenameProps { + /** + * Current item name + */ + initialName: string + /** + * Callback to save the new name + */ + onSave: (newName: string) => Promise + /** + * Item type for logging + */ + itemType: 'workflow' | 'folder' + /** + * Item ID for logging + */ + itemId: string +} + +/** + * Hook for managing inline rename functionality for workflows and folders. + * + * Handles: + * - Edit state management + * - Input value tracking + * - Save/cancel operations + * - Keyboard shortcuts (Enter to save, Escape to cancel) + * - Auto-focus and selection + * - Loading state during save + * + * @param props - Hook configuration + * @returns Rename state and handlers + */ +export function useItemRename({ initialName, onSave, itemType, itemId }: UseItemRenameProps) { + const [isEditing, setIsEditing] = useState(false) + const [editValue, setEditValue] = useState(initialName) + const [isRenaming, setIsRenaming] = useState(false) + const inputRef = useRef(null) + + /** + * Update edit value when initial name changes + */ + useEffect(() => { + setEditValue(initialName) + }, [initialName]) + + /** + * Focus and select input when entering edit mode + */ + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus() + inputRef.current.select() + } + }, [isEditing]) + + /** + * Start editing mode + */ + const handleStartEdit = useCallback(() => { + setIsEditing(true) + setEditValue(initialName) + }, [initialName]) + + /** + * Save the new name + */ + const handleSaveEdit = useCallback(async () => { + const trimmedValue = editValue.trim() + + // If empty or unchanged, just cancel + if (!trimmedValue || trimmedValue === initialName) { + setIsEditing(false) + setEditValue(initialName) + return + } + + setIsRenaming(true) + try { + await onSave(trimmedValue) + logger.info(`Successfully renamed ${itemType} from "${initialName}" to "${trimmedValue}"`) + setIsEditing(false) + } catch (error) { + logger.error(`Failed to rename ${itemType}:`, { + error, + itemId, + oldName: initialName, + newName: trimmedValue, + }) + // Reset to original name on error + setEditValue(initialName) + } finally { + setIsRenaming(false) + } + }, [editValue, initialName, onSave, itemType, itemId]) + + /** + * Cancel editing and restore original name + */ + const handleCancelEdit = useCallback(() => { + setIsEditing(false) + setEditValue(initialName) + }, [initialName]) + + /** + * Handle keyboard shortcuts + */ + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSaveEdit() + } else if (e.key === 'Escape') { + e.preventDefault() + handleCancelEdit() + } + }, + [handleSaveEdit, handleCancelEdit] + ) + + /** + * Handle input blur (unfocus) + */ + const handleInputBlur = useCallback(() => { + handleSaveEdit() + }, [handleSaveEdit]) + + return { + isEditing, + editValue, + isRenaming, + inputRef, + setEditValue, + handleStartEdit, + handleSaveEdit, + handleCancelEdit, + handleKeyDown, + handleInputBlur, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/index.ts new file mode 100644 index 0000000000..444b56cc42 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/index.ts @@ -0,0 +1,3 @@ +export { useAutoScroll } from './use-auto-scroll' +export { useDeleteWorkflow } from './use-delete-workflow' +export { useKeyboardShortcuts } from './use-keyboard-shortcuts' diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts new file mode 100644 index 0000000000..a13616fe34 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts @@ -0,0 +1,99 @@ +import { useCallback, useState } from 'react' +import { useRouter } from 'next/navigation' +import { createLogger } from '@/lib/logs/console/logger' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' + +const logger = createLogger('useDeleteWorkflow') + +interface UseDeleteWorkflowProps { + /** + * Current workspace ID + */ + workspaceId: string + /** + * ID of the workflow to delete + */ + workflowId: string + /** + * Whether this is the currently active workflow + */ + isActive?: boolean + /** + * Optional callback after successful deletion + */ + onSuccess?: () => void +} + +/** + * Hook for managing workflow deletion with navigation logic. + * + * Handles: + * - Finding next workflow to navigate to + * - Navigating before deletion (if active workflow) + * - Removing workflow from registry + * - Loading state management + * - Error handling and logging + * + * @param props - Hook configuration + * @returns Delete workflow handlers and state + */ +export function useDeleteWorkflow({ + workspaceId, + workflowId, + isActive = false, + onSuccess, +}: UseDeleteWorkflowProps) { + const router = useRouter() + const { workflows, removeWorkflow } = useWorkflowRegistry() + const [isDeleting, setIsDeleting] = useState(false) + + /** + * Delete the workflow and navigate if needed + */ + const handleDeleteWorkflow = useCallback(async () => { + if (!workflowId || isDeleting) { + return + } + + setIsDeleting(true) + try { + // Find next workflow to navigate to + const sidebarWorkflows = Object.values(workflows).filter((w) => w.workspaceId === workspaceId) + const currentIndex = sidebarWorkflows.findIndex((w) => w.id === workflowId) + + let nextWorkflowId: string | null = null + if (sidebarWorkflows.length > 1) { + if (currentIndex < sidebarWorkflows.length - 1) { + nextWorkflowId = sidebarWorkflows[currentIndex + 1].id + } else if (currentIndex > 0) { + nextWorkflowId = sidebarWorkflows[currentIndex - 1].id + } + } + + // Navigate first if this is the active workflow + if (isActive) { + if (nextWorkflowId) { + router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`) + } else { + router.push(`/workspace/${workspaceId}/w`) + } + } + + // Then delete + await removeWorkflow(workflowId) + + logger.info('Workflow deleted successfully', { workflowId }) + onSuccess?.() + } catch (error) { + logger.error('Error deleting workflow:', { error, workflowId }) + throw error + } finally { + setIsDeleting(false) + } + }, [workflowId, isDeleting, workflows, workspaceId, isActive, router, removeWorkflow, onSuccess]) + + return { + isDeleting, + handleDeleteWorkflow, + } +} diff --git a/apps/sim/blocks/blocks/google_drive.ts b/apps/sim/blocks/blocks/google_drive.ts index bed75914ff..d109a85501 100644 --- a/apps/sim/blocks/blocks/google_drive.ts +++ b/apps/sim/blocks/blocks/google_drive.ts @@ -272,7 +272,7 @@ export const GoogleDriveBlock: BlockConfig = { // Export format for Google Workspace files (download operation) { id: 'mimeType', - title: 'Export Format (for Google Workspace files)', + title: 'Export Format', type: 'dropdown', options: [ { label: 'Plain Text (text/plain)', id: 'text/plain' }, diff --git a/apps/sim/components/emcn/components/combobox/combobox.tsx b/apps/sim/components/emcn/components/combobox/combobox.tsx index d17fb3653d..51620a73d2 100644 --- a/apps/sim/components/emcn/components/combobox/combobox.tsx +++ b/apps/sim/components/emcn/components/combobox/combobox.tsx @@ -17,7 +17,7 @@ import { Input } from '../input/input' import { Popover, PopoverAnchor, PopoverContent, PopoverScrollArea } from '../popover/popover' const comboboxVariants = cva( - 'flex w-full rounded-[4px] border border-[#3D3D3D] bg-[#282828] dark:bg-[#363636] px-[8px] py-[7px] font-sans font-medium text-sm text-[#E6E6E6] placeholder:text-[#787878] dark:placeholder:text-[#787878] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 hover:border-[#4A4A4A] hover:bg-[#363636] dark:hover:border-[#454545] dark:hover:bg-[#3D3D3D]', + 'flex w-full rounded-[4px] border border-[#3D3D3D] bg-[#282828] dark:bg-[#363636] px-[8px] py-[6px] font-sans font-medium text-sm text-[#E6E6E6] placeholder:text-[#787878] dark:placeholder:text-[#787878] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 hover:border-[#4A4A4A] hover:bg-[#363636] dark:hover:border-[#454545] dark:hover:bg-[#3D3D3D]', { variants: { variant: { @@ -353,7 +353,7 @@ const Combobox = forwardRef( {...inputProps} /> {(overlayContent || SelectedIcon) && ( -
+
{overlayContent ? ( overlayContent ) : ( diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts index c47f896048..7e978a0659 100644 --- a/apps/sim/components/emcn/components/index.ts +++ b/apps/sim/components/emcn/components/index.ts @@ -35,6 +35,8 @@ export { type PopoverItemProps, PopoverScrollArea, type PopoverScrollAreaProps, + PopoverSearch, + type PopoverSearchProps, PopoverSection, type PopoverSectionProps, PopoverTrigger, diff --git a/apps/sim/components/emcn/components/input/input.tsx b/apps/sim/components/emcn/components/input/input.tsx index 4004cd07c5..c369738409 100644 --- a/apps/sim/components/emcn/components/input/input.tsx +++ b/apps/sim/components/emcn/components/input/input.tsx @@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const inputVariants = cva( - 'flex w-full rounded-[4px] border border-[#3D3D3D] bg-[#282828] dark:bg-[#363636] px-[8px] py-[7px] font-medium font-sans text-sm text-foreground transition-colors placeholder:text-[#787878] dark:placeholder:text-[#787878] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50', + 'flex w-full rounded-[4px] border border-[#3D3D3D] bg-[#282828] dark:bg-[#363636] px-[8px] py-[6px] font-medium font-sans text-sm text-foreground transition-colors placeholder:text-[#787878] dark:placeholder:text-[#787878] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50', { variants: { variant: { diff --git a/apps/sim/components/emcn/components/popover/popover.tsx b/apps/sim/components/emcn/components/popover/popover.tsx index 2bdd395059..771c3afff4 100644 --- a/apps/sim/components/emcn/components/popover/popover.tsx +++ b/apps/sim/components/emcn/components/popover/popover.tsx @@ -51,7 +51,7 @@ import * as React from 'react' import * as PopoverPrimitive from '@radix-ui/react-popover' -import { ChevronLeft, ChevronRight } from 'lucide-react' +import { ChevronLeft, ChevronRight, Search } from 'lucide-react' import { cn } from '@/lib/utils' /** @@ -90,6 +90,8 @@ interface PopoverContextValue { isInFolder: boolean folderTitle: string | null variant: PopoverVariant + searchQuery: string + setSearchQuery: (query: string) => void } const PopoverContext = React.createContext(null) @@ -124,6 +126,7 @@ export interface PopoverProps extends PopoverPrimitive.PopoverProps { const Popover: React.FC = ({ children, variant = 'default', ...props }) => { const [currentFolder, setCurrentFolder] = React.useState(null) const [folderTitle, setFolderTitle] = React.useState(null) + const [searchQuery, setSearchQuery] = React.useState('') const openFolder = React.useCallback( (id: string, title: string, onLoad?: () => void | Promise) => { @@ -149,8 +152,10 @@ const Popover: React.FC = ({ children, variant = 'default', ...pro isInFolder: currentFolder !== null, folderTitle, variant, + searchQuery, + setSearchQuery, }), - [openFolder, closeFolder, currentFolder, folderTitle, variant] + [openFolder, closeFolder, currentFolder, folderTitle, variant, searchQuery] ) return ( @@ -261,10 +266,10 @@ const PopoverContent = React.forwardRef< className )} style={{ - ...style, maxHeight: `${maxHeight || 400}px`, maxWidth: 'calc(100vw - 16px)', minWidth: '160px', + ...style, }} > {children} @@ -530,6 +535,65 @@ const PopoverBackButton = React.forwardRef { + /** + * Placeholder text for the search input + * @default 'Search...' + */ + placeholder?: string + /** + * Callback when search query changes + */ + onValueChange?: (value: string) => void +} + +/** + * Search input component for filtering popover items. + * + * @example + * ```tsx + * + * + * + * + * // items + * + * + * + * ``` + */ +const PopoverSearch = React.forwardRef( + ({ className, placeholder = 'Search...', onValueChange, ...props }, ref) => { + const { searchQuery, setSearchQuery } = usePopoverContext() + const inputRef = React.useRef(null) + + const handleChange = (e: React.ChangeEvent) => { + const value = e.target.value + setSearchQuery(value) + onValueChange?.(value) + } + + React.useEffect(() => { + inputRef.current?.focus() + }, []) + + return ( +
+ + +
+ ) + } +) + +PopoverSearch.displayName = 'PopoverSearch' + export { Popover, PopoverTrigger, @@ -540,4 +604,5 @@ export { PopoverSection, PopoverFolder, PopoverBackButton, + PopoverSearch, } diff --git a/apps/sim/stores/panel/chat/store.ts b/apps/sim/stores/chat/store.ts similarity index 54% rename from apps/sim/stores/panel/chat/store.ts rename to apps/sim/stores/chat/store.ts index 8c72b7766c..949411b5f3 100644 --- a/apps/sim/stores/panel/chat/store.ts +++ b/apps/sim/stores/chat/store.ts @@ -2,17 +2,176 @@ import { v4 as uuidv4 } from 'uuid' import { create } from 'zustand' import { devtools, persist } from 'zustand/middleware' import { createLogger } from '@/lib/logs/console/logger' -import type { ChatMessage, ChatStore } from '@/stores/panel/chat/types' const logger = createLogger('ChatStore') -// MAX across all workflows +/** + * Maximum number of messages to store across all workflows + */ const MAX_MESSAGES = 50 -export const useChatStore = create()( +/** + * Floating chat dimensions + */ +const DEFAULT_WIDTH = 250 +const DEFAULT_HEIGHT = 286 + +/** + * Minimum chat dimensions (same as baseline default) + */ +export const MIN_CHAT_WIDTH = DEFAULT_WIDTH +export const MIN_CHAT_HEIGHT = DEFAULT_HEIGHT + +/** + * Maximum chat dimensions + */ +export const MAX_CHAT_WIDTH = 500 +export const MAX_CHAT_HEIGHT = 600 + +/** + * Position interface for floating chat + */ +interface ChatPosition { + x: number + y: number +} + +/** + * Chat attachment interface + */ +export interface ChatAttachment { + id: string + name: string + type: string + dataUrl: string + size?: number +} + +/** + * Chat message interface + */ +export interface ChatMessage { + id: string + content: string | any + workflowId: string + type: 'user' | 'workflow' + timestamp: string + blockId?: string + isStreaming?: boolean + attachments?: ChatAttachment[] +} + +/** + * Output configuration for chat deployments + */ +export interface OutputConfig { + blockId: string + path: string +} + +/** + * Chat dimensions interface + */ +export interface ChatDimensions { + width: number + height: number +} + +/** + * Chat store state interface combining UI state and message data + */ +interface ChatState { + // UI State + isChatOpen: boolean + chatPosition: ChatPosition | null + chatWidth: number + chatHeight: number + setIsChatOpen: (open: boolean) => void + setChatPosition: (position: ChatPosition) => void + setChatDimensions: (dimensions: ChatDimensions) => void + resetChatPosition: () => void + + // Message State + messages: ChatMessage[] + selectedWorkflowOutputs: Record + conversationIds: Record + + // Message Actions + addMessage: (message: Omit & { id?: string }) => void + clearChat: (workflowId: string | null) => void + exportChatCSV: (workflowId: string) => void + setSelectedWorkflowOutput: (workflowId: string, outputIds: string[]) => void + getSelectedWorkflowOutput: (workflowId: string) => string[] + appendMessageContent: (messageId: string, content: string) => void + finalizeMessageStream: (messageId: string) => void + getConversationId: (workflowId: string) => string + generateNewConversationId: (workflowId: string) => string +} + +/** + * Calculate default center position based on available canvas space + */ +const calculateDefaultPosition = (): ChatPosition => { + if (typeof window === 'undefined') { + return { x: 100, y: 100 } + } + + // Get current layout dimensions + const sidebarWidth = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0' + ) + const panelWidth = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0' + ) + const terminalHeight = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0' + ) + + // Calculate available space + const availableWidth = window.innerWidth - sidebarWidth - panelWidth + const availableHeight = window.innerHeight - terminalHeight + + // Center in available space + const x = sidebarWidth + (availableWidth - DEFAULT_WIDTH) / 2 + const y = (availableHeight - DEFAULT_HEIGHT) / 2 + + return { x, y } +} + +/** + * Floating chat store + * Manages the open/close state, position, messages, and all chat functionality + */ +export const useChatStore = create()( devtools( persist( (set, get) => ({ + // UI State + isChatOpen: false, + chatPosition: null, + chatWidth: DEFAULT_WIDTH, + chatHeight: DEFAULT_HEIGHT, + + setIsChatOpen: (open) => { + set({ isChatOpen: open }) + }, + + setChatPosition: (position) => { + set({ chatPosition: position }) + }, + + setChatDimensions: (dimensions) => { + set({ + chatWidth: Math.max(MIN_CHAT_WIDTH, Math.min(MAX_CHAT_WIDTH, dimensions.width)), + chatHeight: Math.max(MIN_CHAT_HEIGHT, Math.min(MAX_CHAT_HEIGHT, dimensions.height)), + }) + }, + + resetChatPosition: () => { + set({ chatPosition: null }) + }, + + // Message State messages: [], selectedWorkflowOutputs: {}, conversationIds: {}, @@ -65,7 +224,9 @@ export const useChatStore = create()( return } - // Helper function to safely stringify and escape CSV values + /** + * Safely stringify and escape CSV values + */ const formatCSVValue = (value: any): string => { if (value === null || value === undefined) { return '' @@ -134,10 +295,6 @@ export const useChatStore = create()( } }, - getWorkflowMessages: (workflowId) => { - return get().messages.filter((message) => message.workflowId === workflowId) - }, - setSelectedWorkflowOutput: (workflowId, outputIds) => { set((state) => { // Create a new copy of the selections state @@ -237,3 +394,82 @@ export const useChatStore = create()( ) ) ) + +/** + * Get the default chat dimensions + */ +export const getDefaultChatDimensions = () => ({ + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, +}) + +/** + * Calculate constrained position ensuring chat stays within bounds + * @param position - Current position to constrain + * @param width - Chat width + * @param height - Chat height + * @returns Constrained position + */ +export const constrainChatPosition = ( + position: ChatPosition, + width: number = DEFAULT_WIDTH, + height: number = DEFAULT_HEIGHT +): ChatPosition => { + if (typeof window === 'undefined') { + return position + } + + // Get current layout dimensions + const sidebarWidth = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0' + ) + const panelWidth = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0' + ) + const terminalHeight = Number.parseInt( + getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0' + ) + + // Calculate bounds + const minX = sidebarWidth + const maxX = window.innerWidth - panelWidth - width + const minY = 0 + const maxY = window.innerHeight - terminalHeight - height + + // Constrain position + return { + x: Math.max(minX, Math.min(maxX, position.x)), + y: Math.max(minY, Math.min(maxY, position.y)), + } +} + +/** + * Get chat position (default if not set or if invalid) + * @param storedPosition - Stored position from store + * @param width - Chat width + * @param height - Chat height + * @returns Valid chat position + */ +export const getChatPosition = ( + storedPosition: ChatPosition | null, + width: number = DEFAULT_WIDTH, + height: number = DEFAULT_HEIGHT +): ChatPosition => { + if (!storedPosition) { + return calculateDefaultPosition() + } + + // Validate stored position is still within bounds + const constrained = constrainChatPosition(storedPosition, width, height) + + // If position significantly changed, it's likely invalid (window resized, etc) + // Return default position + const deltaX = Math.abs(constrained.x - storedPosition.x) + const deltaY = Math.abs(constrained.y - storedPosition.y) + + if (deltaX > 100 || deltaY > 100) { + return calculateDefaultPosition() + } + + return constrained +} diff --git a/apps/sim/stores/panel-new/toolbar/store.ts b/apps/sim/stores/panel-new/toolbar/store.ts index d14c1ca534..4168c5c145 100644 --- a/apps/sim/stores/panel-new/toolbar/store.ts +++ b/apps/sim/stores/panel-new/toolbar/store.ts @@ -3,9 +3,10 @@ import { persist } from 'zustand/middleware' /** * Toolbar triggers height constraints + * Minimum is set low to allow collapsing to just the header height (~30-40px) */ const DEFAULT_TOOLBAR_TRIGGERS_HEIGHT = 300 -const MIN_TOOLBAR_HEIGHT = 100 +const MIN_TOOLBAR_HEIGHT = 30 const MAX_TOOLBAR_HEIGHT = 800 /** @@ -14,6 +15,8 @@ const MAX_TOOLBAR_HEIGHT = 800 interface ToolbarState { toolbarTriggersHeight: number setToolbarTriggersHeight: (height: number) => void + preSearchHeight: number | null + setPreSearchHeight: (height: number | null) => void } export const useToolbarStore = create()( @@ -31,6 +34,8 @@ export const useToolbarStore = create()( ) } }, + preSearchHeight: null, + setPreSearchHeight: (height) => set({ preSearchHeight: height }), }), { name: 'toolbar-state', diff --git a/apps/sim/stores/panel/chat/types.ts b/apps/sim/stores/panel/chat/types.ts deleted file mode 100644 index 62a9eda097..0000000000 --- a/apps/sim/stores/panel/chat/types.ts +++ /dev/null @@ -1,39 +0,0 @@ -export interface ChatAttachment { - id: string - name: string - type: string - dataUrl: string - size?: number -} - -export interface ChatMessage { - id: string - content: string | any - workflowId: string - type: 'user' | 'workflow' - timestamp: string - blockId?: string - isStreaming?: boolean - attachments?: ChatAttachment[] -} - -export interface OutputConfig { - blockId: string - path: string -} - -export interface ChatStore { - messages: ChatMessage[] - selectedWorkflowOutputs: Record - conversationIds: Record - addMessage: (message: Omit & { id?: string }) => void - clearChat: (workflowId: string | null) => void - exportChatCSV: (workflowId: string) => void - getWorkflowMessages: (workflowId: string) => ChatMessage[] - setSelectedWorkflowOutput: (workflowId: string, outputIds: string[]) => void - getSelectedWorkflowOutput: (workflowId: string) => string[] - appendMessageContent: (messageId: string, content: string) => void - finalizeMessageStream: (messageId: string) => void - getConversationId: (workflowId: string) => string - generateNewConversationId: (workflowId: string) => string -} diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts index 0198b30c3e..fb044c8df4 100644 --- a/apps/sim/stores/terminal/console/store.ts +++ b/apps/sim/stores/terminal/console/store.ts @@ -97,6 +97,21 @@ export const useTerminalConsoleStore = create()( return get().entries[0] }, + /** + * Clears console entries for a specific workflow + * @param workflowId - The workflow ID to clear entries for + */ + clearWorkflowConsole: (workflowId: string) => { + set((state) => ({ + entries: state.entries.filter((entry) => entry.workflowId !== workflowId), + })) + }, + + /** + * Clears all console entries or entries for a specific workflow + * @param workflowId - The workflow ID to clear entries for, or null to clear all + * @deprecated Use clearWorkflowConsole for clearing specific workflows + */ clearConsole: (workflowId: string | null) => { set((state) => ({ entries: workflowId diff --git a/apps/sim/stores/terminal/console/types.ts b/apps/sim/stores/terminal/console/types.ts index 0fea51c210..450f946e3e 100644 --- a/apps/sim/stores/terminal/console/types.ts +++ b/apps/sim/stores/terminal/console/types.ts @@ -47,6 +47,7 @@ export interface ConsoleStore { entries: ConsoleEntry[] isOpen: boolean addConsole: (entry: Omit) => ConsoleEntry + clearWorkflowConsole: (workflowId: string) => void clearConsole: (workflowId: string | null) => void exportConsoleCSV: (workflowId: string) => void getWorkflowEntries: (workflowId: string) => ConsoleEntry[] diff --git a/apps/sim/stores/terminal/store.ts b/apps/sim/stores/terminal/store.ts index d64e9ae678..4220c2ad3c 100644 --- a/apps/sim/stores/terminal/store.ts +++ b/apps/sim/stores/terminal/store.ts @@ -22,10 +22,10 @@ interface TerminalState { /** * Terminal height constraints - * Note: Maximum height is enforced dynamically at 50% of viewport height in the resize hook + * Note: Maximum height is enforced dynamically at 70% of viewport height in the resize hook */ const MIN_TERMINAL_HEIGHT = 30 -const DEFAULT_TERMINAL_HEIGHT = 30 +const DEFAULT_TERMINAL_HEIGHT = 100 /** * Output panel width constraints