diff --git a/apps/sim/app/globals.css b/apps/sim/app/globals.css index 5da83a32b22..8e63a273ff3 100644 --- a/apps/sim/app/globals.css +++ b/apps/sim/app/globals.css @@ -281,6 +281,8 @@ --c-F4F4F4: #f4f4f4; --c-F5F5F5: #f5f5f5; + --c-CFCFCF: #cfcfcf; + /* Blues and cyans */ --c-00B0B0: #00b0b0; --c-264F78: #264f78; 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 index c6e2168b037..122ce9cc15f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -2,7 +2,6 @@ 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, @@ -14,6 +13,7 @@ import { PopoverTrigger, Trash, } from '@/components/emcn' +import { useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' import { extractBlockIdFromOutputId, @@ -21,13 +21,19 @@ import { parseOutputContentSafely, } from '@/lib/response-format' import { cn } from '@/lib/utils' +import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils' +import { StartBlockPath, TriggerUtils } from '@/lib/workflows/triggers' +import { START_BLOCK_RESERVED_FIELDS } from '@/lib/workflows/types' 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 { useOperationQueue } from '@/stores/operation-queue/store' import { useTerminalConsoleStore } from '@/stores/terminal' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { ChatMessage, OutputSelect } from './components' import { useChatBoundarySync, useChatDrag, useChatFileUpload, useChatResize } from './hooks' @@ -124,6 +130,14 @@ const formatOutputContent = (output: any): string => { return '' } +interface StartInputFormatField { + id?: string + name?: string + type?: string + value?: unknown + collapsed?: boolean +} + /** * Floating chat modal component * @@ -137,9 +151,10 @@ const formatOutputContent = (output: any): string => { * position across sessions using the floating chat store. */ export function Chat() { - const params = useParams() - const workspaceId = params.workspaceId as string const { activeWorkflowId } = useWorkflowRegistry() + const blocks = useWorkflowStore((state) => state.blocks) + const triggerWorkflowUpdate = useWorkflowStore((state) => state.triggerUpdate) + const setSubBlockValue = useSubBlockStore((state) => state.setValue) // Chat state (UI and messages from unified store) const { @@ -164,6 +179,8 @@ export function Chat() { const { entries } = useTerminalConsoleStore() const { isExecuting } = useExecutionStore() const { handleRunWorkflow } = useWorkflowExecution() + const { data: session } = useSession() + const { addToQueue } = useOperationQueue() // Local state const [chatMessage, setChatMessage] = useState('') @@ -190,6 +207,71 @@ export function Chat() { handleDrop, } = useChatFileUpload() + /** + * Resolves the unified start block for chat execution, if available. + */ + const startBlockCandidate = useMemo(() => { + if (!activeWorkflowId) { + return null + } + + if (!blocks || Object.keys(blocks).length === 0) { + return null + } + + const candidate = TriggerUtils.findStartBlock(blocks, 'chat') + if (!candidate || candidate.path !== StartBlockPath.UNIFIED) { + return null + } + + return candidate + }, [activeWorkflowId, blocks]) + + const startBlockId = startBlockCandidate?.blockId ?? null + + /** + * Reads the current input format for the unified start block from the subblock store, + * falling back to the workflow store if no explicit value is stored yet. + */ + const startBlockInputFormat = useSubBlockStore((state) => { + if (!activeWorkflowId || !startBlockId) { + return null + } + + const workflowValues = state.workflowValues[activeWorkflowId] + const fromStore = workflowValues?.[startBlockId]?.inputFormat + if (fromStore !== undefined && fromStore !== null) { + return fromStore + } + + const startBlock = blocks[startBlockId] + return startBlock?.subBlocks?.inputFormat?.value ?? null + }) + + /** + * Determines which reserved start inputs are missing from the input format. + */ + const missingStartReservedFields = useMemo(() => { + if (!startBlockId) { + return START_BLOCK_RESERVED_FIELDS + } + + const normalizedFields = normalizeInputFormatValue(startBlockInputFormat) + const existingNames = new Set( + normalizedFields + .map((field) => field.name) + .filter((name): name is string => typeof name === 'string' && name.trim() !== '') + .map((name) => name.trim().toLowerCase()) + ) + + return START_BLOCK_RESERVED_FIELDS.filter( + (fieldName) => !existingNames.has(fieldName.toLowerCase()) + ) + }, [startBlockId, startBlockInputFormat]) + + const shouldShowConfigureStartInputsButton = + Boolean(startBlockId) && missingStartReservedFields.length > 0 + // Get actual position (default if not set) const actualPosition = useMemo( () => getChatPosition(chatPosition, chatWidth, chatHeight), @@ -564,7 +646,67 @@ export function Chat() { setIsChatOpen(false) }, [setIsChatOpen]) - // Don't render if not open + /** + * Adds any missing reserved inputs (input, conversationId, files) to the unified start block. + */ + const handleConfigureStartInputs = useCallback(() => { + if (!activeWorkflowId || !startBlockId) { + logger.warn('Cannot configure start inputs: missing active workflow ID or start block ID') + return + } + + try { + const normalizedExisting = normalizeInputFormatValue(startBlockInputFormat) + + const newReservedFields: StartInputFormatField[] = missingStartReservedFields.map( + (fieldName) => { + const defaultType = fieldName === 'files' ? 'files' : 'string' + + return { + id: crypto.randomUUID(), + name: fieldName, + type: defaultType, + value: '', + collapsed: false, + } + } + ) + + const updatedFields: StartInputFormatField[] = [...newReservedFields, ...normalizedExisting] + + setSubBlockValue(startBlockId, 'inputFormat', updatedFields) + + const userId = session?.user?.id || 'unknown' + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: 'subblock-update', + target: 'subblock', + payload: { + blockId: startBlockId, + subblockId: 'inputFormat', + value: updatedFields, + }, + }, + workflowId: activeWorkflowId, + userId, + }) + + triggerWorkflowUpdate() + } catch (error) { + logger.error('Failed to configure start block reserved inputs', error) + } + }, [ + activeWorkflowId, + missingStartReservedFields, + setSubBlockValue, + startBlockId, + startBlockInputFormat, + triggerWorkflowUpdate, + session, + addToQueue, + ]) + if (!isChatOpen) return null return ( @@ -583,17 +725,32 @@ export function Chat() { > {/* Header with drag handle */}
-
- - Chat - -
+ + Chat + + + {/* Start inputs button and output selector - with max-width to prevent overflow */} +
e.stopPropagation()} + > + {shouldShowConfigureStartInputsButton && ( + { + e.stopPropagation() + handleConfigureStartInputs() + }} + > + Add inputs + + )} - {/* Output selector - centered with mx-auto */} -
e.stopPropagation()}>
-
+
{/* More menu with actions */} @@ -628,22 +785,22 @@ export function Chat() { { e.stopPropagation() - if (activeWorkflowId) clearChat(activeWorkflowId) + if (activeWorkflowId) exportChatCSV(activeWorkflowId) }} - disabled={messages.length === 0} + disabled={workflowMessages.length === 0} > - - Clear + + Download { e.stopPropagation() - if (activeWorkflowId) exportChatCSV(activeWorkflowId) + if (activeWorkflowId) clearChat(activeWorkflowId) }} - disabled={messages.length === 0} + disabled={workflowMessages.length === 0} > - - Download + + Clear @@ -662,7 +819,7 @@ export function Chat() {
{workflowMessages.length === 0 ? (
- No messages yet + Workflow input: {''}
) : (
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 index e9050264d81..08a3e17d27d 100644 --- 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 @@ -178,16 +178,11 @@ export function useChatResize({ (e: MouseEvent) => { if (!isResizingRef.current || !activeDirectionRef.current) return - const deltaX = e.clientX - resizeStartRef.current.x - const deltaY = e.clientY - resizeStartRef.current.y + let deltaX = e.clientX - resizeStartRef.current.x + let 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' @@ -199,6 +194,56 @@ export function useChatResize({ getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0' ) + // Clamp vertical drag when resizing from the top so the chat does not grow downward + // after its top edge hits the top of the viewport. + if (direction === 'top' || direction === 'top-left' || direction === 'top-right') { + // newY = initial.y + deltaY should never be less than 0 + const maxUpwardDelta = initial.y + if (deltaY < -maxUpwardDelta) { + deltaY = -maxUpwardDelta + } + } + + // Clamp vertical drag when resizing from the bottom so the chat does not grow upward + // after its bottom edge hits the top of the terminal. + if (direction === 'bottom' || direction === 'bottom-left' || direction === 'bottom-right') { + const maxBottom = window.innerHeight - terminalHeight + const initialBottom = initial.y + initial.height + const maxDeltaY = maxBottom - initialBottom + + if (deltaY > maxDeltaY) { + deltaY = maxDeltaY + } + } + + // Clamp horizontal drag when resizing from the left so the chat does not grow to the right + // after its left edge hits the sidebar. + if (direction === 'left' || direction === 'top-left' || direction === 'bottom-left') { + const minLeft = sidebarWidth + const minDeltaX = minLeft - initial.x + + if (deltaX < minDeltaX) { + deltaX = minDeltaX + } + } + + // Clamp horizontal drag when resizing from the right so the chat does not grow to the left + // after its right edge hits the panel. + if (direction === 'right' || direction === 'top-right' || direction === 'bottom-right') { + const maxRight = window.innerWidth - panelWidth + const initialRight = initial.x + initial.width + const maxDeltaX = maxRight - initialRight + + if (deltaX > maxDeltaX) { + deltaX = maxDeltaX + } + } + + let newX = initial.x + let newY = initial.y + let newWidth = initial.width + let newHeight = initial.height + // Calculate new dimensions based on resize direction switch (direction) { // Corners diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new.tsx index 7ce096f7111..20f93a9ca1e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useEffect, useRef, useState } from 'react' -import { ArrowDown, Braces, Square } from 'lucide-react' +import { Braces, Square } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { BubbleChatPreview, @@ -270,14 +270,6 @@ export function Panel() { workspaceId, ]) - /** - * Handles triggering file input for workflow import - */ - const handleImportWorkflow = useCallback(() => { - setIsMenuOpen(false) - fileInputRef.current?.click() - }, []) - // Compute run button state const canRun = userPermissions.canRead // Running only requires read permissions const isLoadingPermissions = userPermissions.isLoading @@ -349,13 +341,6 @@ export function Panel() { Export workflow - - - Import workflow - - - {/* Hidden file input for workflow import */} - ) } 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 2699a823fe6..6f8cbdff4ea 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 @@ -721,7 +721,8 @@ export function Terminal() { align='start' sideOffset={4} onClick={(e) => e.stopPropagation()} - style={{ minWidth: '120px', maxWidth: '120px' }} + minWidth={120} + maxWidth={200} > {uniqueBlocks.map((block, index) => { 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 cdced8dd364..7938954b5f0 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 @@ -794,7 +794,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ ref={contentRef} onClick={handleClick} className={cn( - 'relative z-[20] w-[250px] cursor-default select-none rounded-[8px] bg-[var(--surface-2)]' + 'relative z-[20] w-[250px] cursor-default select-none rounded-[8px] border border-[var(--border)] bg-[var(--surface-2)]' )} > {isPending && ( diff --git a/apps/sim/lib/workflows/block-outputs.ts b/apps/sim/lib/workflows/block-outputs.ts index 01c4d3cdf83..3027e113ff9 100644 --- a/apps/sim/lib/workflows/block-outputs.ts +++ b/apps/sim/lib/workflows/block-outputs.ts @@ -1,3 +1,4 @@ +import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils' import { classifyStartBlockType, StartBlockPath, TRIGGER_TYPES } from '@/lib/workflows/triggers' import { type InputFormatField, @@ -23,24 +24,6 @@ const UNIFIED_START_OUTPUTS: OutputDefinition = { files: { type: 'files', description: 'User uploaded files' }, } -function normalizeInputFormatValue(inputFormatValue: any): InputFormatField[] { - if ( - inputFormatValue === null || - inputFormatValue === undefined || - (Array.isArray(inputFormatValue) && inputFormatValue.length === 0) - ) { - return [] - } - - if (!Array.isArray(inputFormatValue)) { - return [] - } - - return inputFormatValue.filter( - (field) => field && typeof field === 'object' && field.name && field.name.trim() !== '' - ) -} - function applyInputFormatFields( inputFormat: InputFormatField[], outputs: OutputDefinition diff --git a/apps/sim/lib/workflows/input-format-utils.ts b/apps/sim/lib/workflows/input-format-utils.ts new file mode 100644 index 00000000000..fec752d89ff --- /dev/null +++ b/apps/sim/lib/workflows/input-format-utils.ts @@ -0,0 +1,39 @@ +import type { InputFormatField } from '@/lib/workflows/types' + +/** + * Normalizes an input format value into a list of valid fields. + * + * Filters out: + * - null or undefined values + * - Empty arrays + * - Non-array values + * - Fields without names + * - Fields with empty or whitespace-only names + * + * @param inputFormatValue - Raw input format value from subblock state + * @returns Array of validated input format fields + */ +export function normalizeInputFormatValue(inputFormatValue: unknown): InputFormatField[] { + // Handle null, undefined, and empty arrays + if ( + inputFormatValue === null || + inputFormatValue === undefined || + (Array.isArray(inputFormatValue) && inputFormatValue.length === 0) + ) { + return [] + } + + // Handle non-array values + if (!Array.isArray(inputFormatValue)) { + return [] + } + + // Filter valid fields + return inputFormatValue.filter( + (field): field is InputFormatField => + field && + typeof field === 'object' && + typeof field.name === 'string' && + field.name.trim() !== '' + ) +} diff --git a/apps/sim/stores/chat/store.ts b/apps/sim/stores/chat/store.ts index f2ef913af7c..b1fcff4a6d8 100644 --- a/apps/sim/stores/chat/store.ts +++ b/apps/sim/stores/chat/store.ts @@ -13,7 +13,7 @@ const MAX_MESSAGES = 50 /** * Floating chat dimensions */ -const DEFAULT_WIDTH = 250 +const DEFAULT_WIDTH = 330 const DEFAULT_HEIGHT = 286 /**