Skip to content

Commit d9cb63c

Browse files
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 <[email protected]>
1 parent 3468593 commit d9cb63c

File tree

9 files changed

+278
-76
lines changed

9 files changed

+278
-76
lines changed

apps/sim/app/globals.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,8 @@
281281
--c-F4F4F4: #f4f4f4;
282282
--c-F5F5F5: #f5f5f5;
283283

284+
--c-CFCFCF: #cfcfcf;
285+
284286
/* Blues and cyans */
285287
--c-00B0B0: #00b0b0;
286288
--c-264F78: #264f78;

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx

Lines changed: 179 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { AlertCircle, ArrowDownToLine, ArrowUp, MoreVertical, Paperclip, X } from 'lucide-react'
5-
import { useParams } from 'next/navigation'
65
import {
76
Badge,
87
Button,
@@ -14,20 +13,27 @@ import {
1413
PopoverTrigger,
1514
Trash,
1615
} from '@/components/emcn'
16+
import { useSession } from '@/lib/auth-client'
1717
import { createLogger } from '@/lib/logs/console/logger'
1818
import {
1919
extractBlockIdFromOutputId,
2020
extractPathFromOutputId,
2121
parseOutputContentSafely,
2222
} from '@/lib/response-format'
2323
import { cn } from '@/lib/utils'
24+
import { normalizeInputFormatValue } from '@/lib/workflows/input-format-utils'
25+
import { StartBlockPath, TriggerUtils } from '@/lib/workflows/triggers'
26+
import { START_BLOCK_RESERVED_FIELDS } from '@/lib/workflows/types'
2427
import { useScrollManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
2528
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
2629
import type { BlockLog, ExecutionResult } from '@/executor/types'
2730
import { getChatPosition, useChatStore } from '@/stores/chat/store'
2831
import { useExecutionStore } from '@/stores/execution/store'
32+
import { useOperationQueue } from '@/stores/operation-queue/store'
2933
import { useTerminalConsoleStore } from '@/stores/terminal'
3034
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
35+
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
36+
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
3137
import { ChatMessage, OutputSelect } from './components'
3238
import { useChatBoundarySync, useChatDrag, useChatFileUpload, useChatResize } from './hooks'
3339

@@ -124,6 +130,14 @@ const formatOutputContent = (output: any): string => {
124130
return ''
125131
}
126132

133+
interface StartInputFormatField {
134+
id?: string
135+
name?: string
136+
type?: string
137+
value?: unknown
138+
collapsed?: boolean
139+
}
140+
127141
/**
128142
* Floating chat modal component
129143
*
@@ -137,9 +151,10 @@ const formatOutputContent = (output: any): string => {
137151
* position across sessions using the floating chat store.
138152
*/
139153
export function Chat() {
140-
const params = useParams()
141-
const workspaceId = params.workspaceId as string
142154
const { activeWorkflowId } = useWorkflowRegistry()
155+
const blocks = useWorkflowStore((state) => state.blocks)
156+
const triggerWorkflowUpdate = useWorkflowStore((state) => state.triggerUpdate)
157+
const setSubBlockValue = useSubBlockStore((state) => state.setValue)
143158

144159
// Chat state (UI and messages from unified store)
145160
const {
@@ -164,6 +179,8 @@ export function Chat() {
164179
const { entries } = useTerminalConsoleStore()
165180
const { isExecuting } = useExecutionStore()
166181
const { handleRunWorkflow } = useWorkflowExecution()
182+
const { data: session } = useSession()
183+
const { addToQueue } = useOperationQueue()
167184

168185
// Local state
169186
const [chatMessage, setChatMessage] = useState('')
@@ -190,6 +207,71 @@ export function Chat() {
190207
handleDrop,
191208
} = useChatFileUpload()
192209

210+
/**
211+
* Resolves the unified start block for chat execution, if available.
212+
*/
213+
const startBlockCandidate = useMemo(() => {
214+
if (!activeWorkflowId) {
215+
return null
216+
}
217+
218+
if (!blocks || Object.keys(blocks).length === 0) {
219+
return null
220+
}
221+
222+
const candidate = TriggerUtils.findStartBlock(blocks, 'chat')
223+
if (!candidate || candidate.path !== StartBlockPath.UNIFIED) {
224+
return null
225+
}
226+
227+
return candidate
228+
}, [activeWorkflowId, blocks])
229+
230+
const startBlockId = startBlockCandidate?.blockId ?? null
231+
232+
/**
233+
* Reads the current input format for the unified start block from the subblock store,
234+
* falling back to the workflow store if no explicit value is stored yet.
235+
*/
236+
const startBlockInputFormat = useSubBlockStore((state) => {
237+
if (!activeWorkflowId || !startBlockId) {
238+
return null
239+
}
240+
241+
const workflowValues = state.workflowValues[activeWorkflowId]
242+
const fromStore = workflowValues?.[startBlockId]?.inputFormat
243+
if (fromStore !== undefined && fromStore !== null) {
244+
return fromStore
245+
}
246+
247+
const startBlock = blocks[startBlockId]
248+
return startBlock?.subBlocks?.inputFormat?.value ?? null
249+
})
250+
251+
/**
252+
* Determines which reserved start inputs are missing from the input format.
253+
*/
254+
const missingStartReservedFields = useMemo(() => {
255+
if (!startBlockId) {
256+
return START_BLOCK_RESERVED_FIELDS
257+
}
258+
259+
const normalizedFields = normalizeInputFormatValue(startBlockInputFormat)
260+
const existingNames = new Set(
261+
normalizedFields
262+
.map((field) => field.name)
263+
.filter((name): name is string => typeof name === 'string' && name.trim() !== '')
264+
.map((name) => name.trim().toLowerCase())
265+
)
266+
267+
return START_BLOCK_RESERVED_FIELDS.filter(
268+
(fieldName) => !existingNames.has(fieldName.toLowerCase())
269+
)
270+
}, [startBlockId, startBlockInputFormat])
271+
272+
const shouldShowConfigureStartInputsButton =
273+
Boolean(startBlockId) && missingStartReservedFields.length > 0
274+
193275
// Get actual position (default if not set)
194276
const actualPosition = useMemo(
195277
() => getChatPosition(chatPosition, chatWidth, chatHeight),
@@ -564,7 +646,67 @@ export function Chat() {
564646
setIsChatOpen(false)
565647
}, [setIsChatOpen])
566648

567-
// Don't render if not open
649+
/**
650+
* Adds any missing reserved inputs (input, conversationId, files) to the unified start block.
651+
*/
652+
const handleConfigureStartInputs = useCallback(() => {
653+
if (!activeWorkflowId || !startBlockId) {
654+
logger.warn('Cannot configure start inputs: missing active workflow ID or start block ID')
655+
return
656+
}
657+
658+
try {
659+
const normalizedExisting = normalizeInputFormatValue(startBlockInputFormat)
660+
661+
const newReservedFields: StartInputFormatField[] = missingStartReservedFields.map(
662+
(fieldName) => {
663+
const defaultType = fieldName === 'files' ? 'files' : 'string'
664+
665+
return {
666+
id: crypto.randomUUID(),
667+
name: fieldName,
668+
type: defaultType,
669+
value: '',
670+
collapsed: false,
671+
}
672+
}
673+
)
674+
675+
const updatedFields: StartInputFormatField[] = [...newReservedFields, ...normalizedExisting]
676+
677+
setSubBlockValue(startBlockId, 'inputFormat', updatedFields)
678+
679+
const userId = session?.user?.id || 'unknown'
680+
addToQueue({
681+
id: crypto.randomUUID(),
682+
operation: {
683+
operation: 'subblock-update',
684+
target: 'subblock',
685+
payload: {
686+
blockId: startBlockId,
687+
subblockId: 'inputFormat',
688+
value: updatedFields,
689+
},
690+
},
691+
workflowId: activeWorkflowId,
692+
userId,
693+
})
694+
695+
triggerWorkflowUpdate()
696+
} catch (error) {
697+
logger.error('Failed to configure start block reserved inputs', error)
698+
}
699+
}, [
700+
activeWorkflowId,
701+
missingStartReservedFields,
702+
setSubBlockValue,
703+
startBlockId,
704+
startBlockInputFormat,
705+
triggerWorkflowUpdate,
706+
session,
707+
addToQueue,
708+
])
709+
568710
if (!isChatOpen) return null
569711

570712
return (
@@ -583,17 +725,32 @@ export function Chat() {
583725
>
584726
{/* Header with drag handle */}
585727
<div
586-
className='flex h-[32px] flex-shrink-0 cursor-grab items-center justify-between bg-[var(--surface-1)] p-0 active:cursor-grabbing'
728+
className='flex h-[32px] flex-shrink-0 cursor-grab items-center justify-between gap-[10px] bg-[var(--surface-1)] p-0 active:cursor-grabbing'
587729
onMouseDown={handleMouseDown}
588730
>
589-
<div className='flex items-center'>
590-
<span className='flex-shrink-0 font-medium text-[14px] text-[var(--text-primary)]'>
591-
Chat
592-
</span>
593-
</div>
731+
<span className='flex-shrink-0 pr-[2px] font-medium text-[14px] text-[var(--text-primary)]'>
732+
Chat
733+
</span>
734+
735+
{/* Start inputs button and output selector - with max-width to prevent overflow */}
736+
<div
737+
className='ml-auto flex min-w-0 flex-shrink items-center gap-[6px]'
738+
onMouseDown={(e) => e.stopPropagation()}
739+
>
740+
{shouldShowConfigureStartInputsButton && (
741+
<Badge
742+
variant='outline'
743+
className='flex-none cursor-pointer whitespace-nowrap rounded-[6px]'
744+
title='Add chat inputs to Start block'
745+
onMouseDown={(e) => {
746+
e.stopPropagation()
747+
handleConfigureStartInputs()
748+
}}
749+
>
750+
<span className='whitespace-nowrap text-[12px]'>Add inputs</span>
751+
</Badge>
752+
)}
594753

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

608-
<div className='flex items-center gap-[8px]'>
765+
<div className='flex flex-shrink-0 items-center gap-[8px]'>
609766
{/* More menu with actions */}
610767
<Popover variant='default'>
611768
<PopoverTrigger asChild>
@@ -628,22 +785,22 @@ export function Chat() {
628785
<PopoverItem
629786
onClick={(e) => {
630787
e.stopPropagation()
631-
if (activeWorkflowId) clearChat(activeWorkflowId)
788+
if (activeWorkflowId) exportChatCSV(activeWorkflowId)
632789
}}
633-
disabled={messages.length === 0}
790+
disabled={workflowMessages.length === 0}
634791
>
635-
<Trash className='h-[14px] w-[14px]' />
636-
<span>Clear</span>
792+
<ArrowDownToLine className='h-[13px] w-[13px]' />
793+
<span>Download</span>
637794
</PopoverItem>
638795
<PopoverItem
639796
onClick={(e) => {
640797
e.stopPropagation()
641-
if (activeWorkflowId) exportChatCSV(activeWorkflowId)
798+
if (activeWorkflowId) clearChat(activeWorkflowId)
642799
}}
643-
disabled={messages.length === 0}
800+
disabled={workflowMessages.length === 0}
644801
>
645-
<ArrowDownToLine className='h-[14px] w-[14px]' />
646-
<span>Download</span>
802+
<Trash className='h-[13px] w-[13px]' />
803+
<span>Clear</span>
647804
</PopoverItem>
648805
</PopoverScrollArea>
649806
</PopoverContent>
@@ -662,7 +819,7 @@ export function Chat() {
662819
<div className='flex-1 overflow-hidden'>
663820
{workflowMessages.length === 0 ? (
664821
<div className='flex h-full items-center justify-center text-[#8D8D8D] text-[13px]'>
665-
No messages yet
822+
Workflow input: {'<start.input>'}
666823
</div>
667824
) : (
668825
<div ref={scrollAreaRef} className='h-full overflow-y-auto overflow-x-hidden'>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/hooks/use-chat-resize.ts

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -178,16 +178,11 @@ export function useChatResize({
178178
(e: MouseEvent) => {
179179
if (!isResizingRef.current || !activeDirectionRef.current) return
180180

181-
const deltaX = e.clientX - resizeStartRef.current.x
182-
const deltaY = e.clientY - resizeStartRef.current.y
181+
let deltaX = e.clientX - resizeStartRef.current.x
182+
let deltaY = e.clientY - resizeStartRef.current.y
183183
const initial = initialStateRef.current
184184
const direction = activeDirectionRef.current
185185

186-
let newX = initial.x
187-
let newY = initial.y
188-
let newWidth = initial.width
189-
let newHeight = initial.height
190-
191186
// Get layout bounds
192187
const sidebarWidth = Number.parseInt(
193188
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
@@ -199,6 +194,56 @@ export function useChatResize({
199194
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
200195
)
201196

197+
// Clamp vertical drag when resizing from the top so the chat does not grow downward
198+
// after its top edge hits the top of the viewport.
199+
if (direction === 'top' || direction === 'top-left' || direction === 'top-right') {
200+
// newY = initial.y + deltaY should never be less than 0
201+
const maxUpwardDelta = initial.y
202+
if (deltaY < -maxUpwardDelta) {
203+
deltaY = -maxUpwardDelta
204+
}
205+
}
206+
207+
// Clamp vertical drag when resizing from the bottom so the chat does not grow upward
208+
// after its bottom edge hits the top of the terminal.
209+
if (direction === 'bottom' || direction === 'bottom-left' || direction === 'bottom-right') {
210+
const maxBottom = window.innerHeight - terminalHeight
211+
const initialBottom = initial.y + initial.height
212+
const maxDeltaY = maxBottom - initialBottom
213+
214+
if (deltaY > maxDeltaY) {
215+
deltaY = maxDeltaY
216+
}
217+
}
218+
219+
// Clamp horizontal drag when resizing from the left so the chat does not grow to the right
220+
// after its left edge hits the sidebar.
221+
if (direction === 'left' || direction === 'top-left' || direction === 'bottom-left') {
222+
const minLeft = sidebarWidth
223+
const minDeltaX = minLeft - initial.x
224+
225+
if (deltaX < minDeltaX) {
226+
deltaX = minDeltaX
227+
}
228+
}
229+
230+
// Clamp horizontal drag when resizing from the right so the chat does not grow to the left
231+
// after its right edge hits the panel.
232+
if (direction === 'right' || direction === 'top-right' || direction === 'bottom-right') {
233+
const maxRight = window.innerWidth - panelWidth
234+
const initialRight = initial.x + initial.width
235+
const maxDeltaX = maxRight - initialRight
236+
237+
if (deltaX > maxDeltaX) {
238+
deltaX = maxDeltaX
239+
}
240+
}
241+
242+
let newX = initial.x
243+
let newY = initial.y
244+
let newWidth = initial.width
245+
let newHeight = initial.height
246+
202247
// Calculate new dimensions based on resize direction
203248
switch (direction) {
204249
// Corners

0 commit comments

Comments
 (0)