Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/sim/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,8 @@
--c-F4F4F4: #f4f4f4;
--c-F5F5F5: #f5f5f5;

--c-CFCFCF: #cfcfcf;

/* Blues and cyans */
--c-00B0B0: #00b0b0;
--c-264F78: #264f78;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -14,20 +13,27 @@ import {
PopoverTrigger,
Trash,
} from '@/components/emcn'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import {
extractBlockIdFromOutputId,
extractPathFromOutputId,
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'

Expand Down Expand Up @@ -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
*
Expand All @@ -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 {
Expand All @@ -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('')
Expand All @@ -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),
Expand Down Expand Up @@ -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 (
Expand All @@ -583,17 +725,32 @@ export function Chat() {
>
{/* Header with drag handle */}
<div
className='flex h-[32px] flex-shrink-0 cursor-grab items-center justify-between bg-[var(--surface-1)] p-0 active:cursor-grabbing'
className='flex h-[32px] flex-shrink-0 cursor-grab items-center justify-between gap-[10px] bg-[var(--surface-1)] p-0 active:cursor-grabbing'
onMouseDown={handleMouseDown}
>
<div className='flex items-center'>
<span className='flex-shrink-0 font-medium text-[14px] text-[var(--text-primary)]'>
Chat
</span>
</div>
<span className='flex-shrink-0 pr-[2px] font-medium text-[14px] text-[var(--text-primary)]'>
Chat
</span>

{/* Start inputs button and output selector - with max-width to prevent overflow */}
<div
className='ml-auto flex min-w-0 flex-shrink items-center gap-[6px]'
onMouseDown={(e) => e.stopPropagation()}
>
{shouldShowConfigureStartInputsButton && (
<Badge
variant='outline'
className='flex-none cursor-pointer whitespace-nowrap rounded-[6px]'
title='Add chat inputs to Start block'
onMouseDown={(e) => {
e.stopPropagation()
handleConfigureStartInputs()
}}
>
<span className='whitespace-nowrap text-[12px]'>Add inputs</span>
</Badge>
)}

{/* Output selector - centered with mx-auto */}
<div className='mr-[6px] ml-auto' onMouseDown={(e) => e.stopPropagation()}>
<OutputSelect
workflowId={activeWorkflowId}
selectedOutputs={selectedOutputs}
Expand All @@ -605,7 +762,7 @@ export function Chat() {
/>
</div>

<div className='flex items-center gap-[8px]'>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
{/* More menu with actions */}
<Popover variant='default'>
<PopoverTrigger asChild>
Expand All @@ -628,22 +785,22 @@ export function Chat() {
<PopoverItem
onClick={(e) => {
e.stopPropagation()
if (activeWorkflowId) clearChat(activeWorkflowId)
if (activeWorkflowId) exportChatCSV(activeWorkflowId)
}}
disabled={messages.length === 0}
disabled={workflowMessages.length === 0}
>
<Trash className='h-[14px] w-[14px]' />
<span>Clear</span>
<ArrowDownToLine className='h-[13px] w-[13px]' />
<span>Download</span>
</PopoverItem>
<PopoverItem
onClick={(e) => {
e.stopPropagation()
if (activeWorkflowId) exportChatCSV(activeWorkflowId)
if (activeWorkflowId) clearChat(activeWorkflowId)
}}
disabled={messages.length === 0}
disabled={workflowMessages.length === 0}
>
<ArrowDownToLine className='h-[14px] w-[14px]' />
<span>Download</span>
<Trash className='h-[13px] w-[13px]' />
<span>Clear</span>
</PopoverItem>
</PopoverScrollArea>
</PopoverContent>
Expand All @@ -662,7 +819,7 @@ export function Chat() {
<div className='flex-1 overflow-hidden'>
{workflowMessages.length === 0 ? (
<div className='flex h-full items-center justify-center text-[#8D8D8D] text-[13px]'>
No messages yet
Workflow input: {'<start.input>'}
</div>
) : (
<div ref={scrollAreaRef} className='h-full overflow-y-auto overflow-x-hidden'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down
Loading