feat(chat): add 'add inputs' button to chat window (#2057)

* improvement(ui): workflow-block border

* feat(chat): add inputs button

* added socket event, reused existing utils to persist chat inputs

---------

Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
Emir Karabeg
2025-11-19 18:12:03 -08:00
committed by GitHub
parent 3468593f84
commit d9cb63ce5f
9 changed files with 278 additions and 76 deletions

View File

@@ -281,6 +281,8 @@
--c-F4F4F4: #f4f4f4;
--c-F5F5F5: #f5f5f5;
--c-CFCFCF: #cfcfcf;
/* Blues and cyans */
--c-00B0B0: #00b0b0;
--c-264F78: #264f78;

View File

@@ -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 */}
<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}
@@ -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>
@@ -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>
@@ -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'>

View File

@@ -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

View File

@@ -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() {
<Braces className='h-3 w-3' />
<span>Export workflow</span>
</PopoverItem>
<PopoverItem
onClick={handleImportWorkflow}
disabled={isImporting || !userPermissions.canEdit}
>
<ArrowDown className='h-3 w-3' />
<span>Import workflow</span>
</PopoverItem>
<PopoverItem
onClick={handleDuplicateWorkflow}
disabled={!userPermissions.canEdit || isDuplicating}
@@ -522,16 +507,6 @@ export function Panel() {
{/* Floating Variables Modal */}
<Variables />
{/* Hidden file input for workflow import */}
<input
ref={fileInputRef}
type='file'
accept='.json,.zip'
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</>
)
}

View File

@@ -721,7 +721,8 @@ export function Terminal() {
align='start'
sideOffset={4}
onClick={(e) => e.stopPropagation()}
style={{ minWidth: '120px', maxWidth: '120px' }}
minWidth={120}
maxWidth={200}
>
<PopoverScrollArea style={{ maxHeight: '140px' }}>
{uniqueBlocks.map((block, index) => {

View File

@@ -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 && (

View File

@@ -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

View File

@@ -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() !== ''
)
}

View File

@@ -13,7 +13,7 @@ const MAX_MESSAGES = 50
/**
* Floating chat dimensions
*/
const DEFAULT_WIDTH = 250
const DEFAULT_WIDTH = 330
const DEFAULT_HEIGHT = 286
/**