mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 07:58:06 -05:00
Compare commits
63 Commits
fix/resize
...
feat/copil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75e6e4f230 | ||
|
|
963f1d561a | ||
|
|
5e9d8e1e9a | ||
|
|
152e942074 | ||
|
|
3359d7b0d8 | ||
|
|
b7d0f2053b | ||
|
|
be578e2ed7 | ||
|
|
1f6f58cf7f | ||
|
|
41d767e170 | ||
|
|
c5dc78ff08 | ||
|
|
ae6e29512a | ||
|
|
6639871c92 | ||
|
|
0ba5ec65f7 | ||
|
|
8597786962 | ||
|
|
1aada6ba57 | ||
|
|
a9edbd71f1 | ||
|
|
bd35dda8fa | ||
|
|
b2b06c3dd1 | ||
|
|
29eefd8416 | ||
|
|
51b2297e35 | ||
|
|
aa8da99ce2 | ||
|
|
2b2ed6df1a | ||
|
|
7305ecf4fa | ||
|
|
d2ef972bbb | ||
|
|
90c875b895 | ||
|
|
4280461cb8 | ||
|
|
3be426af8e | ||
|
|
8fcb0349d2 | ||
|
|
96dc2b7afd | ||
|
|
e95b6135ac | ||
|
|
2fe0afaef4 | ||
|
|
d86e43945f | ||
|
|
4fd5656c01 | ||
|
|
54f6047dd3 | ||
|
|
86f8e77293 | ||
|
|
05034edc83 | ||
|
|
7a925ad45c | ||
|
|
442d8a1f45 | ||
|
|
56366088d8 | ||
|
|
f6cd0cbc55 | ||
|
|
df80309c3b | ||
|
|
f415e5edc4 | ||
|
|
13a6e6c3fa | ||
|
|
f5ab7f21ae | ||
|
|
bfb6fffe38 | ||
|
|
4fbec0a43f | ||
|
|
585f5e365b | ||
|
|
3792bdd252 | ||
|
|
eb5d1f3e5b | ||
|
|
54ab82c8dd | ||
|
|
f895bf469b | ||
|
|
dd3209af06 | ||
|
|
b6ba3b50a7 | ||
|
|
b304233062 | ||
|
|
57e4b49bd6 | ||
|
|
e12dd204ed | ||
|
|
3d9d9cbc54 | ||
|
|
0f4ec962ad | ||
|
|
4827866f9a | ||
|
|
3e697d9ed9 | ||
|
|
4431a1a484 | ||
|
|
4d1a9a3f22 | ||
|
|
eb07a080fb |
@@ -802,29 +802,49 @@ export async function POST(req: NextRequest) {
|
||||
toolNames: toolCalls.map((tc) => tc?.name).filter(Boolean),
|
||||
})
|
||||
|
||||
// NOTE: Messages are saved by the client via update-messages endpoint with full contentBlocks.
|
||||
// Server only updates conversationId here to avoid overwriting client's richer save.
|
||||
// Save messages to database after streaming completes (including aborted messages)
|
||||
if (currentChat) {
|
||||
const updatedMessages = [...conversationHistory, userMessage]
|
||||
|
||||
// Save assistant message if there's any content or tool calls (even partial from abort)
|
||||
if (assistantContent.trim() || toolCalls.length > 0) {
|
||||
const assistantMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
timestamp: new Date().toISOString(),
|
||||
...(toolCalls.length > 0 && { toolCalls }),
|
||||
}
|
||||
updatedMessages.push(assistantMessage)
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Saving assistant message with content (${assistantContent.length} chars) and ${toolCalls.length} tool calls`
|
||||
)
|
||||
} else {
|
||||
logger.info(
|
||||
`[${tracker.requestId}] No assistant content or tool calls to save (aborted before response)`
|
||||
)
|
||||
}
|
||||
|
||||
// Persist only a safe conversationId to avoid continuing from a state that expects tool outputs
|
||||
const previousConversationId = currentChat?.conversationId as string | undefined
|
||||
const responseId = lastSafeDoneResponseId || previousConversationId || undefined
|
||||
|
||||
if (responseId) {
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
updatedAt: new Date(),
|
||||
conversationId: responseId,
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
// Update chat in database immediately (without title)
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
messages: updatedMessages,
|
||||
updatedAt: new Date(),
|
||||
...(responseId ? { conversationId: responseId } : {}),
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Updated conversationId for chat ${actualChatId}`,
|
||||
{
|
||||
updatedConversationId: responseId,
|
||||
}
|
||||
)
|
||||
}
|
||||
logger.info(`[${tracker.requestId}] Updated chat ${actualChatId} with new messages`, {
|
||||
messageCount: updatedMessages.length,
|
||||
savedUserMessage: true,
|
||||
savedAssistantMessage: assistantContent.trim().length > 0,
|
||||
updatedConversationId: responseId || null,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${tracker.requestId}] Error processing stream:`, error)
|
||||
|
||||
@@ -77,18 +77,6 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const { chatId, messages, planArtifact, config } = UpdateMessagesSchema.parse(body)
|
||||
|
||||
// Debug: Log what we're about to save
|
||||
const lastMsgParsed = messages[messages.length - 1]
|
||||
if (lastMsgParsed?.role === 'assistant') {
|
||||
logger.info(`[${tracker.requestId}] Parsed messages to save`, {
|
||||
messageCount: messages.length,
|
||||
lastMsgId: lastMsgParsed.id,
|
||||
lastMsgContentLength: lastMsgParsed.content?.length || 0,
|
||||
lastMsgContentBlockCount: lastMsgParsed.contentBlocks?.length || 0,
|
||||
lastMsgContentBlockTypes: lastMsgParsed.contentBlocks?.map((b: any) => b?.type) || [],
|
||||
})
|
||||
}
|
||||
|
||||
// Verify that the chat belongs to the user
|
||||
const [chat] = await db
|
||||
.select()
|
||||
|
||||
@@ -462,6 +462,9 @@ export default function PlaygroundPage() {
|
||||
<Avatar size='lg'>
|
||||
<AvatarFallback>LG</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar size='xl'>
|
||||
<AvatarFallback>XL</AvatarFallback>
|
||||
</Avatar>
|
||||
</VariantRow>
|
||||
<VariantRow label='with image'>
|
||||
<Avatar size='md'>
|
||||
@@ -502,6 +505,9 @@ export default function PlaygroundPage() {
|
||||
<Avatar size='lg' status='online'>
|
||||
<AvatarFallback>LG</AvatarFallback>
|
||||
</Avatar>
|
||||
<Avatar size='xl' status='online'>
|
||||
<AvatarFallback>XL</AvatarFallback>
|
||||
</Avatar>
|
||||
</VariantRow>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -303,8 +303,8 @@ export const DiffControls = memo(function DiffControls() {
|
||||
!isResizing && 'transition-[bottom,right] duration-100 ease-out'
|
||||
)}
|
||||
style={{
|
||||
bottom: 'calc(var(--terminal-height) + 16px)',
|
||||
right: 'calc(var(--panel-width) + 16px)',
|
||||
bottom: 'calc(var(--terminal-height) + 8px)',
|
||||
right: 'calc(var(--panel-width) + 8px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -470,8 +470,17 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
{/* Content blocks in chronological order */}
|
||||
{memoizedContentBlocks}
|
||||
|
||||
{/* Always show streaming indicator at the end while streaming */}
|
||||
{isStreaming && <StreamingIndicator />}
|
||||
{/* Show streaming indicator if streaming but no text content yet after tool calls */}
|
||||
{isStreaming &&
|
||||
!message.content &&
|
||||
message.contentBlocks?.every((block) => block.type === 'tool_call') && (
|
||||
<StreamingIndicator />
|
||||
)}
|
||||
|
||||
{/* Streaming indicator when no content yet */}
|
||||
{!cleanTextContent && !message.contentBlocks?.length && isStreaming && (
|
||||
<StreamingIndicator />
|
||||
)}
|
||||
|
||||
{message.errorType === 'usage_limit' && (
|
||||
<div className='flex gap-1.5'>
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronUp, LayoutList } from 'lucide-react'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
import { Button, Code, getCodeEditorProps, highlight, languages } from '@/components/emcn'
|
||||
import { Button, Code } from '@/components/emcn'
|
||||
import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
|
||||
import { getClientTool } from '@/lib/copilot/tools/client/manager'
|
||||
import { getRegisteredTools } from '@/lib/copilot/tools/client/registry'
|
||||
@@ -414,8 +413,6 @@ const ACTION_VERBS = [
|
||||
'Listed',
|
||||
'Editing',
|
||||
'Edited',
|
||||
'Executing',
|
||||
'Executed',
|
||||
'Running',
|
||||
'Ran',
|
||||
'Designing',
|
||||
@@ -754,70 +751,36 @@ function SubAgentToolCall({ toolCall: toolCallProp }: { toolCall: CopilotToolCal
|
||||
const safeInputs = inputs && typeof inputs === 'object' ? inputs : {}
|
||||
const inputEntries = Object.entries(safeInputs)
|
||||
if (inputEntries.length === 0) return null
|
||||
|
||||
/**
|
||||
* Format a value for display - handles objects, arrays, and primitives
|
||||
*/
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '-'
|
||||
if (typeof value === 'string') return value || '-'
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is a complex type (object or array)
|
||||
*/
|
||||
const isComplex = (value: unknown): boolean => {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-1.5 w-full overflow-hidden rounded-md border border-[var(--border-1)] bg-[var(--surface-1)]'>
|
||||
{/* Header */}
|
||||
<div className='flex items-center gap-[8px] border-[var(--border-1)] border-b bg-[var(--surface-2)] p-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-primary)]'>Input</span>
|
||||
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
{inputEntries.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* Input entries */}
|
||||
<div className='flex flex-col'>
|
||||
{inputEntries.map(([key, value], index) => {
|
||||
const formattedValue = formatValue(value)
|
||||
const needsCodeViewer = isComplex(value)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={clsx(
|
||||
'flex flex-col gap-1 px-[10px] py-[6px]',
|
||||
index > 0 && 'border-[var(--border-1)] border-t'
|
||||
)}
|
||||
>
|
||||
{/* Input key */}
|
||||
<span className='font-medium text-[11px] text-[var(--text-primary)]'>{key}</span>
|
||||
{/* Value display */}
|
||||
{needsCodeViewer ? (
|
||||
<Code.Viewer
|
||||
code={formattedValue}
|
||||
language='json'
|
||||
showGutter={false}
|
||||
className='max-h-[80px] min-h-0'
|
||||
/>
|
||||
) : (
|
||||
<span className='font-mono text-[11px] text-[var(--text-muted)] leading-[1.3]'>
|
||||
{formattedValue}
|
||||
<div className='mt-1.5 w-full overflow-hidden rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-1)]'>
|
||||
<table className='w-full table-fixed bg-transparent'>
|
||||
<thead className='bg-transparent'>
|
||||
<tr className='border-[var(--border-1)] border-b bg-transparent'>
|
||||
<th className='w-[36%] border-[var(--border-1)] border-r bg-transparent px-[10px] py-[5px] text-left font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Input
|
||||
</th>
|
||||
<th className='w-[64%] bg-transparent px-[10px] py-[5px] text-left font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='bg-transparent'>
|
||||
{inputEntries.map(([key, value]) => (
|
||||
<tr key={key} className='border-[var(--border-1)] border-t bg-transparent'>
|
||||
<td className='w-[36%] border-[var(--border-1)] border-r bg-transparent px-[10px] py-[6px]'>
|
||||
<span className='truncate font-medium text-[var(--text-primary)] text-xs'>
|
||||
{key}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className='w-[64%] bg-transparent px-[10px] py-[6px]'>
|
||||
<span className='font-mono text-[var(--text-muted)] text-xs'>
|
||||
{String(value)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2327,136 +2290,74 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
|
||||
const safeInputs = inputs && typeof inputs === 'object' ? inputs : {}
|
||||
const inputEntries = Object.entries(safeInputs)
|
||||
|
||||
// Don't show the section if there are no inputs
|
||||
// Don't show the table if there are no inputs
|
||||
if (inputEntries.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for display - handles objects, arrays, and primitives
|
||||
*/
|
||||
const formatValueForDisplay = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return ''
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
// For objects and arrays, use JSON.stringify with formatting
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string value back to its original type if possible
|
||||
*/
|
||||
const parseInputValue = (value: string, originalValue: unknown): unknown => {
|
||||
// If original was a primitive, keep as string
|
||||
if (typeof originalValue !== 'object' || originalValue === null) {
|
||||
return value
|
||||
}
|
||||
// Try to parse as JSON for objects/arrays
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is a complex type (object or array)
|
||||
*/
|
||||
const isComplexValue = (value: unknown): boolean => {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='w-full overflow-hidden rounded-md border border-[var(--border-1)] bg-[var(--surface-1)]'>
|
||||
{/* Header */}
|
||||
<div className='flex items-center gap-[8px] border-[var(--border-1)] border-b bg-[var(--surface-2)] p-[8px]'>
|
||||
<span className='font-medium text-[12px] text-[var(--text-primary)]'>Edit Input</span>
|
||||
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
{inputEntries.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* Input entries */}
|
||||
<div className='flex flex-col'>
|
||||
{inputEntries.map(([key, value], index) => {
|
||||
const isComplex = isComplexValue(value)
|
||||
const displayValue = formatValueForDisplay(value)
|
||||
|
||||
return (
|
||||
<div
|
||||
<div className='w-full overflow-hidden rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-1)]'>
|
||||
<table className='w-full table-fixed bg-transparent'>
|
||||
<thead className='bg-transparent'>
|
||||
<tr className='border-[var(--border-1)] border-b bg-transparent'>
|
||||
<th className='w-[36%] border-[var(--border-1)] border-r bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
Input
|
||||
</th>
|
||||
<th className='w-[64%] bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='bg-transparent'>
|
||||
{inputEntries.map(([key, value]) => (
|
||||
<tr
|
||||
key={key}
|
||||
className={clsx(
|
||||
'flex flex-col gap-1.5 px-[10px] py-[8px]',
|
||||
index > 0 && 'border-[var(--border-1)] border-t'
|
||||
)}
|
||||
className='group relative border-[var(--border-1)] border-t bg-transparent'
|
||||
>
|
||||
{/* Input key */}
|
||||
<span className='font-medium text-[11px] text-[var(--text-primary)]'>{key}</span>
|
||||
{/* Value editor */}
|
||||
{isComplex ? (
|
||||
<Code.Container className='max-h-[168px] min-h-[60px]'>
|
||||
<Code.Content>
|
||||
<Editor
|
||||
value={displayValue}
|
||||
onValueChange={(newCode) => {
|
||||
const parsedValue = parseInputValue(newCode, value)
|
||||
const newInputs = { ...safeInputs, [key]: parsedValue }
|
||||
<td className='relative w-[36%] border-[var(--border-1)] border-r bg-transparent p-0'>
|
||||
<div className='px-[10px] py-[8px]'>
|
||||
<span className='truncate font-medium text-[var(--text-primary)] text-xs'>
|
||||
{key}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className='relative w-[64%] bg-transparent p-0'>
|
||||
<div className='min-w-0 px-[10px] py-[8px]'>
|
||||
<input
|
||||
type='text'
|
||||
value={String(value)}
|
||||
onChange={(e) => {
|
||||
const newInputs = { ...safeInputs, [key]: e.target.value }
|
||||
|
||||
if (isNestedInWorkflowInput) {
|
||||
setEditedParams({ ...editedParams, workflow_input: newInputs })
|
||||
} else if (typeof editedParams.input === 'string') {
|
||||
setEditedParams({ ...editedParams, input: JSON.stringify(newInputs) })
|
||||
} else if (
|
||||
editedParams.input &&
|
||||
typeof editedParams.input === 'object'
|
||||
) {
|
||||
setEditedParams({ ...editedParams, input: newInputs })
|
||||
} else if (
|
||||
editedParams.inputs &&
|
||||
typeof editedParams.inputs === 'object'
|
||||
) {
|
||||
setEditedParams({ ...editedParams, inputs: newInputs })
|
||||
} else {
|
||||
setEditedParams({ ...editedParams, [key]: parsedValue })
|
||||
}
|
||||
}}
|
||||
highlight={(code) => highlight(code, languages.json, 'json')}
|
||||
{...getCodeEditorProps()}
|
||||
className={clsx(getCodeEditorProps().className, 'min-h-[40px]')}
|
||||
style={{ minHeight: '40px' }}
|
||||
/>
|
||||
</Code.Content>
|
||||
</Code.Container>
|
||||
) : (
|
||||
<input
|
||||
type='text'
|
||||
value={displayValue}
|
||||
onChange={(e) => {
|
||||
const parsedValue = parseInputValue(e.target.value, value)
|
||||
const newInputs = { ...safeInputs, [key]: parsedValue }
|
||||
|
||||
if (isNestedInWorkflowInput) {
|
||||
setEditedParams({ ...editedParams, workflow_input: newInputs })
|
||||
} else if (typeof editedParams.input === 'string') {
|
||||
setEditedParams({ ...editedParams, input: JSON.stringify(newInputs) })
|
||||
} else if (editedParams.input && typeof editedParams.input === 'object') {
|
||||
setEditedParams({ ...editedParams, input: newInputs })
|
||||
} else if (editedParams.inputs && typeof editedParams.inputs === 'object') {
|
||||
setEditedParams({ ...editedParams, inputs: newInputs })
|
||||
} else {
|
||||
setEditedParams({ ...editedParams, [key]: parsedValue })
|
||||
}
|
||||
}}
|
||||
className='w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-1)] px-[8px] py-[6px] font-medium font-mono text-[13px] text-[var(--text-primary)] outline-none transition-colors placeholder:text-[var(--text-muted)] focus:outline-none'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
// Determine how to update based on original structure
|
||||
if (isNestedInWorkflowInput) {
|
||||
// Update workflow_input
|
||||
setEditedParams({ ...editedParams, workflow_input: newInputs })
|
||||
} else if (typeof editedParams.input === 'string') {
|
||||
// Input was a JSON string, serialize back
|
||||
setEditedParams({ ...editedParams, input: JSON.stringify(newInputs) })
|
||||
} else if (editedParams.input && typeof editedParams.input === 'object') {
|
||||
// Input is an object
|
||||
setEditedParams({ ...editedParams, input: newInputs })
|
||||
} else if (
|
||||
editedParams.inputs &&
|
||||
typeof editedParams.inputs === 'object'
|
||||
) {
|
||||
// Inputs is an object
|
||||
setEditedParams({ ...editedParams, inputs: newInputs })
|
||||
} else {
|
||||
// Flat structure - update at base level
|
||||
setEditedParams({ ...editedParams, [key]: e.target.value })
|
||||
}
|
||||
}}
|
||||
className='w-full bg-transparent font-mono text-[var(--text-muted)] text-xs outline-none focus:text-[var(--text-primary)]'
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2542,8 +2443,8 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
|
||||
<ShimmerOverlayText
|
||||
text={displayName}
|
||||
active={isLoadingState}
|
||||
isSpecial={isSpecial}
|
||||
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
|
||||
isSpecial={false}
|
||||
className='font-[470] font-season text-[var(--text-muted)] text-sm'
|
||||
/>
|
||||
</div>
|
||||
{code && (
|
||||
|
||||
@@ -124,10 +124,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
isSendingMessage,
|
||||
})
|
||||
|
||||
// Handle scroll management (80px stickiness for copilot)
|
||||
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage, {
|
||||
stickinessThreshold: 80,
|
||||
})
|
||||
// Handle scroll management
|
||||
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage)
|
||||
|
||||
// Handle chat history grouping
|
||||
const { groupedChats, handleHistoryDropdownOpen: handleHistoryDropdownOpenHook } = useChatHistory(
|
||||
|
||||
@@ -148,7 +148,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
ref={blockRef}
|
||||
onClick={() => setCurrentBlockId(id)}
|
||||
className={cn(
|
||||
'relative cursor-pointer select-none rounded-[8px] border border-[var(--border-1)]',
|
||||
'relative cursor-pointer select-none rounded-[8px] border border-[var(--border)]',
|
||||
'transition-block-bg transition-ring',
|
||||
'z-[20]'
|
||||
)}
|
||||
|
||||
@@ -87,8 +87,8 @@ export const ActionBar = memo(
|
||||
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
// Check for start_trigger (unified start block) - prevent duplication but allow deletion
|
||||
const isStartBlock = blockType === 'starter' || blockType === 'start_trigger'
|
||||
const isResponseBlock = blockType === 'response'
|
||||
const isNoteBlock = blockType === 'note'
|
||||
|
||||
/**
|
||||
@@ -140,7 +140,7 @@ export const ActionBar = memo(
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{!isStartBlock && !isResponseBlock && (
|
||||
{!isStartBlock && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -4,7 +4,6 @@ export {
|
||||
computeParentUpdateEntries,
|
||||
getClampedPositionForNode,
|
||||
isInEditableElement,
|
||||
resolveParentChildSelectionConflicts,
|
||||
selectNodesDeferred,
|
||||
validateTriggerPaste,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers'
|
||||
@@ -13,7 +12,7 @@ export { useAutoLayout } from './use-auto-layout'
|
||||
export { BLOCK_DIMENSIONS, useBlockDimensions } from './use-block-dimensions'
|
||||
export { useBlockVisual } from './use-block-visual'
|
||||
export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow'
|
||||
export { calculateContainerDimensions, useNodeUtilities } from './use-node-utilities'
|
||||
export { useNodeUtilities } from './use-node-utilities'
|
||||
export { usePreventZoom } from './use-prevent-zoom'
|
||||
export { useScrollManagement } from './use-scroll-management'
|
||||
export { useWorkflowExecution } from './use-workflow-execution'
|
||||
|
||||
@@ -62,47 +62,6 @@ export function clampPositionToContainer(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates container dimensions based on child block positions.
|
||||
* Single source of truth for container sizing - ensures consistency between
|
||||
* live drag updates and final dimension calculations.
|
||||
*
|
||||
* @param childPositions - Array of child positions with their dimensions
|
||||
* @returns Calculated width and height for the container
|
||||
*/
|
||||
export function calculateContainerDimensions(
|
||||
childPositions: Array<{ x: number; y: number; width: number; height: number }>
|
||||
): { width: number; height: number } {
|
||||
if (childPositions.length === 0) {
|
||||
return {
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
let maxRight = 0
|
||||
let maxBottom = 0
|
||||
|
||||
for (const child of childPositions) {
|
||||
maxRight = Math.max(maxRight, child.x + child.width)
|
||||
maxBottom = Math.max(maxBottom, child.y + child.height)
|
||||
}
|
||||
|
||||
const width = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
|
||||
)
|
||||
const height = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
CONTAINER_DIMENSIONS.HEADER_HEIGHT +
|
||||
CONTAINER_DIMENSIONS.TOP_PADDING +
|
||||
maxBottom +
|
||||
CONTAINER_DIMENSIONS.BOTTOM_PADDING
|
||||
)
|
||||
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook providing utilities for node position, hierarchy, and dimension calculations
|
||||
*/
|
||||
@@ -347,16 +306,36 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
(id) => currentBlocks[id]?.data?.parentId === nodeId
|
||||
)
|
||||
|
||||
const childPositions = childBlockIds
|
||||
.map((childId) => {
|
||||
const child = currentBlocks[childId]
|
||||
if (!child?.position) return null
|
||||
const { width, height } = getBlockDimensions(childId)
|
||||
return { x: child.position.x, y: child.position.y, width, height }
|
||||
})
|
||||
.filter((p): p is NonNullable<typeof p> => p !== null)
|
||||
if (childBlockIds.length === 0) {
|
||||
return {
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
return calculateContainerDimensions(childPositions)
|
||||
let maxRight = 0
|
||||
let maxBottom = 0
|
||||
|
||||
for (const childId of childBlockIds) {
|
||||
const child = currentBlocks[childId]
|
||||
if (!child?.position) continue
|
||||
|
||||
const { width: childWidth, height: childHeight } = getBlockDimensions(childId)
|
||||
|
||||
maxRight = Math.max(maxRight, child.position.x + childWidth)
|
||||
maxBottom = Math.max(maxBottom, child.position.y + childHeight)
|
||||
}
|
||||
|
||||
const width = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
|
||||
)
|
||||
const height = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING
|
||||
)
|
||||
|
||||
return { width, height }
|
||||
},
|
||||
[getBlockDimensions]
|
||||
)
|
||||
|
||||
@@ -12,12 +12,6 @@ interface UseScrollManagementOptions {
|
||||
* - `auto`: immediate scroll to bottom (used by floating chat to avoid jitter).
|
||||
*/
|
||||
behavior?: 'auto' | 'smooth'
|
||||
/**
|
||||
* Distance from bottom (in pixels) within which auto-scroll stays active.
|
||||
* Lower values = less sticky (user can scroll away easier).
|
||||
* Default is 100px.
|
||||
*/
|
||||
stickinessThreshold?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,7 +34,6 @@ export function useScrollManagement(
|
||||
const programmaticScrollInProgressRef = useRef(false)
|
||||
const lastScrollTopRef = useRef(0)
|
||||
const scrollBehavior: 'auto' | 'smooth' = options?.behavior ?? 'smooth'
|
||||
const stickinessThreshold = options?.stickinessThreshold ?? 100
|
||||
|
||||
/**
|
||||
* Scrolls the container to the bottom with smooth animation
|
||||
@@ -81,7 +74,7 @@ export function useScrollManagement(
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
|
||||
const nearBottom = distanceFromBottom <= stickinessThreshold
|
||||
const nearBottom = distanceFromBottom <= 100
|
||||
setIsNearBottom(nearBottom)
|
||||
|
||||
if (isSendingMessage) {
|
||||
@@ -102,7 +95,7 @@ export function useScrollManagement(
|
||||
|
||||
// Track last scrollTop for direction detection
|
||||
lastScrollTopRef.current = scrollTop
|
||||
}, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream, stickinessThreshold])
|
||||
}, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream])
|
||||
|
||||
// Attach scroll listener
|
||||
useEffect(() => {
|
||||
@@ -181,20 +174,14 @@ export function useScrollManagement(
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
const nearBottom = distanceFromBottom <= stickinessThreshold
|
||||
const nearBottom = distanceFromBottom <= 120
|
||||
if (nearBottom) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}, 100)
|
||||
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [
|
||||
isSendingMessage,
|
||||
userHasScrolledDuringStream,
|
||||
getScrollContainer,
|
||||
scrollToBottom,
|
||||
stickinessThreshold,
|
||||
])
|
||||
}, [isSendingMessage, userHasScrolledDuringStream, getScrollContainer, scrollToBottom])
|
||||
|
||||
return {
|
||||
scrollAreaRef,
|
||||
|
||||
@@ -23,8 +23,7 @@ interface TriggerValidationResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that pasting/duplicating blocks won't violate constraints.
|
||||
* Checks both trigger constraints and single-instance block constraints.
|
||||
* Validates that pasting/duplicating trigger blocks won't violate constraints.
|
||||
* Returns validation result with error message if invalid.
|
||||
*/
|
||||
export function validateTriggerPaste(
|
||||
@@ -44,12 +43,6 @@ export function validateTriggerPaste(
|
||||
return { isValid: false, message }
|
||||
}
|
||||
}
|
||||
|
||||
const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue(existingBlocks, block.type)
|
||||
if (singleInstanceIssue) {
|
||||
const message = `A workflow can only have one ${singleInstanceIssue.blockName} block. ${action === 'paste' ? 'Please remove the existing one before pasting.' : 'Cannot duplicate.'}`
|
||||
return { isValid: false, message }
|
||||
}
|
||||
}
|
||||
return { isValid: true }
|
||||
}
|
||||
@@ -70,22 +63,19 @@ export function clearDragHighlights(): void {
|
||||
* Defers selection to next animation frame to allow displayNodes to sync from store first.
|
||||
* This is necessary because the component uses controlled state (nodes={displayNodes})
|
||||
* and newly added blocks need time to propagate through the store → derivedNodes → displayNodes cycle.
|
||||
* Automatically resolves parent-child selection conflicts to prevent wiggle during drag.
|
||||
*/
|
||||
export function selectNodesDeferred(
|
||||
nodeIds: string[],
|
||||
setDisplayNodes: (updater: (nodes: Node[]) => Node[]) => void,
|
||||
blocks: Record<string, { data?: { parentId?: string } }>
|
||||
setDisplayNodes: (updater: (nodes: Node[]) => Node[]) => void
|
||||
): void {
|
||||
const idsSet = new Set(nodeIds)
|
||||
requestAnimationFrame(() => {
|
||||
setDisplayNodes((nodes) => {
|
||||
const withSelection = nodes.map((node) => ({
|
||||
setDisplayNodes((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
...node,
|
||||
selected: idsSet.has(node.id),
|
||||
}))
|
||||
return resolveParentChildSelectionConflicts(withSelection, blocks)
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -189,26 +179,3 @@ export function computeParentUpdateEntries(
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves parent-child selection conflicts by deselecting children whose parent is also selected.
|
||||
*/
|
||||
export function resolveParentChildSelectionConflicts(
|
||||
nodes: Node[],
|
||||
blocks: Record<string, { data?: { parentId?: string } }>
|
||||
): Node[] {
|
||||
const selectedIds = new Set(nodes.filter((n) => n.selected).map((n) => n.id))
|
||||
|
||||
let hasConflict = false
|
||||
const resolved = nodes.map((n) => {
|
||||
if (!n.selected) return n
|
||||
const parentId = n.parentId || n.data?.parentId || blocks[n.id]?.data?.parentId
|
||||
if (parentId && selectedIds.has(parentId)) {
|
||||
hasConflict = true
|
||||
return { ...n, selected: false }
|
||||
}
|
||||
return n
|
||||
})
|
||||
|
||||
return hasConflict ? resolved : nodes
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
computeClampedPositionUpdates,
|
||||
getClampedPositionForNode,
|
||||
isInEditableElement,
|
||||
resolveParentChildSelectionConflicts,
|
||||
selectNodesDeferred,
|
||||
useAutoLayout,
|
||||
useCurrentWorkflow,
|
||||
@@ -56,7 +55,6 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useCanvasContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu'
|
||||
import {
|
||||
calculateContainerDimensions,
|
||||
clampPositionToContainer,
|
||||
estimateBlockDimensions,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities'
|
||||
@@ -699,8 +697,7 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
selectNodesDeferred(
|
||||
pastedBlocksArray.map((b) => b.id),
|
||||
setDisplayNodes,
|
||||
blocks
|
||||
setDisplayNodes
|
||||
)
|
||||
}, [
|
||||
hasClipboard,
|
||||
@@ -748,8 +745,7 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
selectNodesDeferred(
|
||||
pastedBlocksArray.map((b) => b.id),
|
||||
setDisplayNodes,
|
||||
blocks
|
||||
setDisplayNodes
|
||||
)
|
||||
}, [
|
||||
contextMenuBlocks,
|
||||
@@ -894,8 +890,7 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
selectNodesDeferred(
|
||||
pastedBlocks.map((b) => b.id),
|
||||
setDisplayNodes,
|
||||
blocks
|
||||
setDisplayNodes
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1134,18 +1129,17 @@ const WorkflowContent = React.memo(() => {
|
||||
)
|
||||
|
||||
/**
|
||||
* Checks if adding a block would violate constraints (triggers or single-instance blocks)
|
||||
* and shows notification if so.
|
||||
* Checks if adding a trigger block would violate constraints and shows notification if so.
|
||||
* @returns true if validation failed (caller should return early), false if ok to proceed
|
||||
*/
|
||||
const checkTriggerConstraints = useCallback(
|
||||
(blockType: string): boolean => {
|
||||
const triggerIssue = TriggerUtils.getTriggerAdditionIssue(blocks, blockType)
|
||||
if (triggerIssue) {
|
||||
const issue = TriggerUtils.getTriggerAdditionIssue(blocks, blockType)
|
||||
if (issue) {
|
||||
const message =
|
||||
triggerIssue.issue === 'legacy'
|
||||
issue.issue === 'legacy'
|
||||
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
|
||||
: `A workflow can only have one ${triggerIssue.triggerName} trigger block. Please remove the existing one before adding a new one.`
|
||||
: `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before adding a new one.`
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message,
|
||||
@@ -1153,17 +1147,6 @@ const WorkflowContent = React.memo(() => {
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue(blocks, blockType)
|
||||
if (singleInstanceIssue) {
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: `A workflow can only have one ${singleInstanceIssue.blockName} block. Please remove the existing one before adding a new one.`,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
[blocks, addNotification, activeWorkflowId]
|
||||
@@ -2042,19 +2025,10 @@ const WorkflowContent = React.memo(() => {
|
||||
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
|
||||
}, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent])
|
||||
|
||||
/** Handles node changes - applies changes and resolves parent-child selection conflicts. */
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
setDisplayNodes((nds) => {
|
||||
const updated = applyNodeChanges(changes, nds)
|
||||
const hasSelectionChange = changes.some(
|
||||
(c) => c.type === 'select' && (c as { selected?: boolean }).selected
|
||||
)
|
||||
return hasSelectionChange ? resolveParentChildSelectionConflicts(updated, blocks) : updated
|
||||
})
|
||||
},
|
||||
[blocks]
|
||||
)
|
||||
/** Handles node position changes - updates local state for smooth drag, syncs to store only on drag end. */
|
||||
const onNodesChange = useCallback((changes: NodeChange[]) => {
|
||||
setDisplayNodes((nds) => applyNodeChanges(changes, nds))
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Updates container dimensions in displayNodes during drag.
|
||||
@@ -2069,13 +2043,28 @@ const WorkflowContent = React.memo(() => {
|
||||
const childNodes = currentNodes.filter((n) => n.parentId === parentId)
|
||||
if (childNodes.length === 0) return currentNodes
|
||||
|
||||
const childPositions = childNodes.map((node) => {
|
||||
let maxRight = 0
|
||||
let maxBottom = 0
|
||||
|
||||
childNodes.forEach((node) => {
|
||||
const nodePosition = node.id === draggedNodeId ? draggedNodePosition : node.position
|
||||
const { width, height } = getBlockDimensions(node.id)
|
||||
return { x: nodePosition.x, y: nodePosition.y, width, height }
|
||||
const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id)
|
||||
|
||||
maxRight = Math.max(maxRight, nodePosition.x + nodeWidth)
|
||||
maxBottom = Math.max(maxBottom, nodePosition.y + nodeHeight)
|
||||
})
|
||||
|
||||
const { width: newWidth, height: newHeight } = calculateContainerDimensions(childPositions)
|
||||
const newWidth = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
|
||||
)
|
||||
const newHeight = Math.max(
|
||||
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
CONTAINER_DIMENSIONS.HEADER_HEIGHT +
|
||||
CONTAINER_DIMENSIONS.TOP_PADDING +
|
||||
maxBottom +
|
||||
CONTAINER_DIMENSIONS.BOTTOM_PADDING
|
||||
)
|
||||
|
||||
return currentNodes.map((node) => {
|
||||
if (node.id === parentId) {
|
||||
@@ -2843,38 +2832,27 @@ const WorkflowContent = React.memo(() => {
|
||||
}, [isShiftPressed])
|
||||
|
||||
const onSelectionEnd = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setIsSelectionDragActive(false)
|
||||
setDisplayNodes((nodes) => resolveParentChildSelectionConflicts(nodes, blocks))
|
||||
})
|
||||
}, [blocks])
|
||||
requestAnimationFrame(() => setIsSelectionDragActive(false))
|
||||
}, [])
|
||||
|
||||
/** Captures initial positions when selection drag starts (for marquee-selected nodes). */
|
||||
const onSelectionDragStart = useCallback(
|
||||
(_event: React.MouseEvent, nodes: Node[]) => {
|
||||
// Capture the parent ID of the first node as reference (they should all be in the same context)
|
||||
if (nodes.length > 0) {
|
||||
const firstNodeParentId = blocks[nodes[0].id]?.data?.parentId || null
|
||||
setDragStartParentId(firstNodeParentId)
|
||||
}
|
||||
|
||||
// Resolve parent-child conflicts and capture positions for undo/redo
|
||||
setDisplayNodes((allNodes) => resolveParentChildSelectionConflicts(allNodes, blocks))
|
||||
|
||||
// Filter to nodes that won't be deselected (exclude children whose parent is selected)
|
||||
const nodeIds = new Set(nodes.map((n) => n.id))
|
||||
const effectiveNodes = nodes.filter((n) => {
|
||||
const parentId = blocks[n.id]?.data?.parentId
|
||||
return !parentId || !nodeIds.has(parentId)
|
||||
})
|
||||
|
||||
// Capture all selected nodes' positions for undo/redo
|
||||
multiNodeDragStartRef.current.clear()
|
||||
effectiveNodes.forEach((n) => {
|
||||
const blk = blocks[n.id]
|
||||
if (blk) {
|
||||
nodes.forEach((n) => {
|
||||
const block = blocks[n.id]
|
||||
if (block) {
|
||||
multiNodeDragStartRef.current.set(n.id, {
|
||||
x: n.position.x,
|
||||
y: n.position.y,
|
||||
parentId: blk.data?.parentId,
|
||||
parentId: block.data?.parentId,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -2913,6 +2891,7 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
eligibleNodes.forEach((node) => {
|
||||
const absolutePos = getNodeAbsolutePosition(node.id)
|
||||
const block = blocks[node.id]
|
||||
const width = BLOCK_DIMENSIONS.FIXED_WIDTH
|
||||
const height = Math.max(
|
||||
node.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
|
||||
@@ -3138,11 +3117,13 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
/**
|
||||
* Handles node click to select the node in ReactFlow.
|
||||
* Parent-child conflict resolution happens automatically in onNodesChange.
|
||||
* This ensures clicking anywhere on a block (not just the drag handle)
|
||||
* selects it for delete/backspace and multi-select operations.
|
||||
*/
|
||||
const handleNodeClick = useCallback(
|
||||
(event: React.MouseEvent, node: Node) => {
|
||||
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
|
||||
|
||||
setNodes((nodes) =>
|
||||
nodes.map((n) => ({
|
||||
...n,
|
||||
|
||||
@@ -38,7 +38,7 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
|
||||
|
||||
return (
|
||||
<div
|
||||
className='relative select-none rounded-[8px] border border-[var(--border-1)]'
|
||||
className='relative select-none rounded-[8px] border border-[var(--border)]'
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
|
||||
@@ -147,12 +147,6 @@ export function ContextMenu({
|
||||
disableCreate = false,
|
||||
disableCreateFolder = false,
|
||||
}: ContextMenuProps) {
|
||||
// Section visibility for divider logic
|
||||
const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
|
||||
const hasEditSection =
|
||||
(showRename && onRename) || (showCreate && onCreate) || (showCreateFolder && onCreateFolder)
|
||||
const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
@@ -182,7 +176,7 @@ export function ContextMenu({
|
||||
Open in new tab
|
||||
</PopoverItem>
|
||||
)}
|
||||
{hasNavigationSection && (hasEditSection || hasCopySection) && <PopoverDivider />}
|
||||
{showOpenInNewTab && onOpenInNewTab && <PopoverDivider />}
|
||||
|
||||
{/* Edit and create actions */}
|
||||
{showRename && onRename && (
|
||||
@@ -220,7 +214,7 @@ export function ContextMenu({
|
||||
)}
|
||||
|
||||
{/* Copy and export actions */}
|
||||
{hasEditSection && hasCopySection && <PopoverDivider />}
|
||||
{(showDuplicate || showExport) && <PopoverDivider />}
|
||||
{showDuplicate && onDuplicate && (
|
||||
<PopoverItem
|
||||
disabled={disableDuplicate}
|
||||
@@ -245,7 +239,7 @@ export function ContextMenu({
|
||||
)}
|
||||
|
||||
{/* Destructive action */}
|
||||
{(hasNavigationSection || hasEditSection || hasCopySection) && <PopoverDivider />}
|
||||
<PopoverDivider />
|
||||
<PopoverItem
|
||||
disabled={disableDelete}
|
||||
onClick={() => {
|
||||
|
||||
@@ -657,7 +657,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
items={emailItems}
|
||||
onAdd={(value) => addEmail(value)}
|
||||
onRemove={removeEmailItem}
|
||||
onInputChange={() => setErrorMessage(null)}
|
||||
placeholder={
|
||||
!userPerms.canAdmin
|
||||
? 'Only administrators can invite new members'
|
||||
|
||||
@@ -17,7 +17,6 @@ export const ResponseBlock: BlockConfig<ResponseBlockOutput> = {
|
||||
category: 'blocks',
|
||||
bgColor: '#2F55FF',
|
||||
icon: ResponseIcon,
|
||||
singleInstance: true,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'dataMode',
|
||||
|
||||
@@ -320,7 +320,6 @@ export interface BlockConfig<T extends ToolResponse = ToolResponse> {
|
||||
subBlocks: SubBlockConfig[]
|
||||
triggerAllowed?: boolean
|
||||
authMode?: AuthMode
|
||||
singleInstance?: boolean
|
||||
tools: {
|
||||
access: string[]
|
||||
config?: {
|
||||
|
||||
@@ -16,6 +16,7 @@ const avatarVariants = cva('relative flex shrink-0 overflow-hidden rounded-full'
|
||||
sm: 'h-8 w-8',
|
||||
md: 'h-10 w-10',
|
||||
lg: 'h-12 w-12',
|
||||
xl: 'h-16 w-16',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -41,6 +42,7 @@ const avatarStatusVariants = cva(
|
||||
sm: 'h-2.5 w-2.5',
|
||||
md: 'h-3 w-3',
|
||||
lg: 'h-3.5 w-3.5',
|
||||
xl: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -166,8 +166,6 @@ export interface TagInputProps extends VariantProps<typeof tagInputVariants> {
|
||||
onAdd: (value: string) => boolean
|
||||
/** Callback when a tag is removed (receives value, index, and isValid) */
|
||||
onRemove: (value: string, index: number, isValid: boolean) => void
|
||||
/** Callback when the input value changes (useful for clearing errors) */
|
||||
onInputChange?: (value: string) => void
|
||||
/** Placeholder text for the input */
|
||||
placeholder?: string
|
||||
/** Placeholder text when there are existing tags */
|
||||
@@ -209,7 +207,6 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||
items,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onInputChange,
|
||||
placeholder = 'Enter values',
|
||||
placeholderWithTags = 'Add another',
|
||||
disabled = false,
|
||||
@@ -347,12 +344,10 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||
})
|
||||
|
||||
if (addedCount === 0 && pastedValues.length === 1) {
|
||||
const newValue = inputValue + pastedValues[0]
|
||||
setInputValue(newValue)
|
||||
onInputChange?.(newValue)
|
||||
setInputValue(inputValue + pastedValues[0])
|
||||
}
|
||||
},
|
||||
[onAdd, inputValue, onInputChange]
|
||||
[onAdd, inputValue]
|
||||
)
|
||||
|
||||
const handleBlur = React.useCallback(() => {
|
||||
@@ -427,10 +422,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||
name={name}
|
||||
type='text'
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value)
|
||||
onInputChange?.(e.target.value)
|
||||
}}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onBlur={handleBlur}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { isValidKey } from '@/lib/workflows/sanitization/key-validation'
|
||||
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { getAllBlocks, getBlock } from '@/blocks/registry'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { EDGE, normalizeName } from '@/executor/constants'
|
||||
@@ -63,8 +62,6 @@ type SkippedItemType =
|
||||
| 'invalid_subflow_parent'
|
||||
| 'nested_subflow_not_allowed'
|
||||
| 'duplicate_block_name'
|
||||
| 'duplicate_trigger'
|
||||
| 'duplicate_single_instance_block'
|
||||
|
||||
/**
|
||||
* Represents an item that was skipped during operation application
|
||||
@@ -1778,34 +1775,6 @@ function applyOperationsToWorkflowState(
|
||||
break
|
||||
}
|
||||
|
||||
const triggerIssue = TriggerUtils.getTriggerAdditionIssue(modifiedState.blocks, params.type)
|
||||
if (triggerIssue) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'duplicate_trigger',
|
||||
operationType: 'add',
|
||||
blockId: block_id,
|
||||
reason: `Cannot add ${triggerIssue.triggerName} - a workflow can only have one`,
|
||||
details: { requestedType: params.type, issue: triggerIssue.issue },
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// Check single-instance block constraints (e.g., Response block)
|
||||
const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue(
|
||||
modifiedState.blocks,
|
||||
params.type
|
||||
)
|
||||
if (singleInstanceIssue) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'duplicate_single_instance_block',
|
||||
operationType: 'add',
|
||||
blockId: block_id,
|
||||
reason: `Cannot add ${singleInstanceIssue.blockName} - a workflow can only have one`,
|
||||
details: { requestedType: params.type },
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// Create new block with proper structure
|
||||
const newBlock = createBlockFromParams(
|
||||
block_id,
|
||||
|
||||
@@ -592,34 +592,4 @@ export class TriggerUtils {
|
||||
const parentWithType = parent as T & { type?: string }
|
||||
return parentWithType.type === 'loop' || parentWithType.type === 'parallel'
|
||||
}
|
||||
|
||||
static isSingleInstanceBlockType(blockType: string): boolean {
|
||||
const blockConfig = getBlock(blockType)
|
||||
return blockConfig?.singleInstance === true
|
||||
}
|
||||
|
||||
static wouldViolateSingleInstanceBlock<T extends { type: string }>(
|
||||
blocks: T[] | Record<string, T>,
|
||||
blockType: string
|
||||
): boolean {
|
||||
if (!TriggerUtils.isSingleInstanceBlockType(blockType)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks)
|
||||
return blockArray.some((block) => block.type === blockType)
|
||||
}
|
||||
|
||||
static getSingleInstanceBlockIssue<T extends { type: string }>(
|
||||
blocks: T[] | Record<string, T>,
|
||||
blockType: string
|
||||
): { issue: 'duplicate'; blockName: string } | null {
|
||||
if (!TriggerUtils.wouldViolateSingleInstanceBlock(blocks, blockType)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const blockConfig = getBlock(blockType)
|
||||
const blockName = blockConfig?.name || blockType
|
||||
return { issue: 'duplicate', blockName }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,31 +271,11 @@ function resolveToolDisplay(
|
||||
if (cand?.text || cand?.icon) return { text: cand.text, icon: cand.icon }
|
||||
}
|
||||
} catch {}
|
||||
// Humanized fallback as last resort - include state verb for proper verb-noun styling
|
||||
// Humanized fallback as last resort
|
||||
try {
|
||||
if (toolName) {
|
||||
const formattedName = toolName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
// Add state verb prefix for verb-noun rendering in tool-call component
|
||||
let stateVerb: string
|
||||
switch (state) {
|
||||
case ClientToolCallState.pending:
|
||||
case ClientToolCallState.executing:
|
||||
stateVerb = 'Executing'
|
||||
break
|
||||
case ClientToolCallState.success:
|
||||
stateVerb = 'Executed'
|
||||
break
|
||||
case ClientToolCallState.error:
|
||||
stateVerb = 'Failed'
|
||||
break
|
||||
case ClientToolCallState.rejected:
|
||||
case ClientToolCallState.aborted:
|
||||
stateVerb = 'Skipped'
|
||||
break
|
||||
default:
|
||||
stateVerb = 'Executing'
|
||||
}
|
||||
return { text: `${stateVerb} ${formattedName}`, icon: undefined as any }
|
||||
const text = toolName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
return { text, icon: undefined as any }
|
||||
}
|
||||
} catch {}
|
||||
return undefined
|
||||
@@ -592,30 +572,8 @@ function stripTodoTags(text: string): string {
|
||||
*/
|
||||
function deepClone<T>(obj: T): T {
|
||||
try {
|
||||
const json = JSON.stringify(obj)
|
||||
if (!json || json === 'undefined') {
|
||||
logger.warn('[deepClone] JSON.stringify returned empty for object', {
|
||||
type: typeof obj,
|
||||
isArray: Array.isArray(obj),
|
||||
length: Array.isArray(obj) ? obj.length : undefined,
|
||||
})
|
||||
return obj
|
||||
}
|
||||
const parsed = JSON.parse(json)
|
||||
// Verify the clone worked
|
||||
if (Array.isArray(obj) && (!Array.isArray(parsed) || parsed.length !== obj.length)) {
|
||||
logger.warn('[deepClone] Array clone mismatch', {
|
||||
originalLength: obj.length,
|
||||
clonedLength: Array.isArray(parsed) ? parsed.length : 'not array',
|
||||
})
|
||||
}
|
||||
return parsed
|
||||
} catch (err) {
|
||||
logger.error('[deepClone] Failed to clone object', {
|
||||
error: String(err),
|
||||
type: typeof obj,
|
||||
isArray: Array.isArray(obj),
|
||||
})
|
||||
return JSON.parse(JSON.stringify(obj))
|
||||
} catch {
|
||||
return obj
|
||||
}
|
||||
}
|
||||
@@ -629,18 +587,11 @@ function serializeMessagesForDB(messages: CopilotMessage[]): any[] {
|
||||
const result = messages
|
||||
.map((msg) => {
|
||||
// Deep clone the entire message to ensure all nested data is serializable
|
||||
// Ensure timestamp is always a string (Zod schema requires it)
|
||||
let timestamp: string = msg.timestamp
|
||||
if (typeof timestamp !== 'string') {
|
||||
const ts = timestamp as any
|
||||
timestamp = ts instanceof Date ? ts.toISOString() : new Date().toISOString()
|
||||
}
|
||||
|
||||
const serialized: any = {
|
||||
id: msg.id,
|
||||
role: msg.role,
|
||||
content: msg.content || '',
|
||||
timestamp,
|
||||
timestamp: msg.timestamp,
|
||||
}
|
||||
|
||||
// Deep clone contentBlocks (the main rendering data)
|
||||
@@ -3200,7 +3151,7 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
model: selectedModel,
|
||||
}
|
||||
|
||||
const saveResponse = await fetch('/api/copilot/chat/update-messages', {
|
||||
await fetch('/api/copilot/chat/update-messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -3211,18 +3162,6 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
}),
|
||||
})
|
||||
|
||||
if (!saveResponse.ok) {
|
||||
const errorText = await saveResponse.text().catch(() => '')
|
||||
logger.error('[Stream Done] Failed to save messages to DB', {
|
||||
status: saveResponse.status,
|
||||
error: errorText,
|
||||
})
|
||||
} else {
|
||||
logger.info('[Stream Done] Successfully saved messages to DB', {
|
||||
messageCount: dbMessages.length,
|
||||
})
|
||||
}
|
||||
|
||||
// Update local chat object with plan artifact and config
|
||||
set({
|
||||
currentChat: {
|
||||
@@ -3231,9 +3170,7 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
config,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('[Stream Done] Exception saving messages', { error: String(err) })
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Post copilot_stats record (input/output tokens can be null for now)
|
||||
|
||||
@@ -41,10 +41,6 @@ export function getUniqueBlockName(baseName: string, existingBlocks: Record<stri
|
||||
return 'Start'
|
||||
}
|
||||
|
||||
if (normalizedBaseName === 'response') {
|
||||
return 'Response'
|
||||
}
|
||||
|
||||
const baseNameMatch = baseName.match(/^(.*?)(\s+\d+)?$/)
|
||||
const namePrefix = baseNameMatch ? baseNameMatch[1].trim() : baseName
|
||||
|
||||
|
||||
Reference in New Issue
Block a user