mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-12 16:38:15 -05:00
Compare commits
11 Commits
fix/action
...
feat/copil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94eff3f742 | ||
|
|
e7b2630361 | ||
|
|
0f51055ee1 | ||
|
|
0aec9ef571 | ||
|
|
cb4db20a5f | ||
|
|
4941b5224b | ||
|
|
7f18d96d32 | ||
|
|
e347486f50 | ||
|
|
e21cc1132b | ||
|
|
ab32a19cf4 | ||
|
|
ead2413b95 |
@@ -802,49 +802,29 @@ export async function POST(req: NextRequest) {
|
||||
toolNames: toolCalls.map((tc) => tc?.name).filter(Boolean),
|
||||
})
|
||||
|
||||
// Save messages to database after streaming completes (including aborted messages)
|
||||
// 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.
|
||||
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
|
||||
|
||||
// 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!))
|
||||
if (responseId) {
|
||||
await db
|
||||
.update(copilotChats)
|
||||
.set({
|
||||
updatedAt: new Date(),
|
||||
conversationId: responseId,
|
||||
})
|
||||
.where(eq(copilotChats.id, actualChatId!))
|
||||
|
||||
logger.info(`[${tracker.requestId}] Updated chat ${actualChatId} with new messages`, {
|
||||
messageCount: updatedMessages.length,
|
||||
savedUserMessage: true,
|
||||
savedAssistantMessage: assistantContent.trim().length > 0,
|
||||
updatedConversationId: responseId || null,
|
||||
})
|
||||
logger.info(
|
||||
`[${tracker.requestId}] Updated conversationId for chat ${actualChatId}`,
|
||||
{
|
||||
updatedConversationId: responseId,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${tracker.requestId}] Error processing stream:`, error)
|
||||
|
||||
@@ -77,6 +77,18 @@ 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,9 +462,6 @@ 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'>
|
||||
@@ -505,9 +502,6 @@ 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) + 8px)',
|
||||
right: 'calc(var(--panel-width) + 8px)',
|
||||
bottom: 'calc(var(--terminal-height) + 16px)',
|
||||
right: 'calc(var(--panel-width) + 16px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -470,17 +470,8 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
{/* Content blocks in chronological order */}
|
||||
{memoizedContentBlocks}
|
||||
|
||||
{/* 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 />
|
||||
)}
|
||||
{/* Always show streaming indicator at the end while streaming */}
|
||||
{isStreaming && <StreamingIndicator />}
|
||||
|
||||
{message.errorType === 'usage_limit' && (
|
||||
<div className='flex gap-1.5'>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronUp, LayoutList } from 'lucide-react'
|
||||
import { Button, Code } from '@/components/emcn'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
import { Button, Code, getCodeEditorProps, highlight, languages } 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'
|
||||
@@ -413,6 +414,8 @@ const ACTION_VERBS = [
|
||||
'Listed',
|
||||
'Editing',
|
||||
'Edited',
|
||||
'Executing',
|
||||
'Executed',
|
||||
'Running',
|
||||
'Ran',
|
||||
'Designing',
|
||||
@@ -751,36 +754,70 @@ 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-[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}
|
||||
<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}
|
||||
</span>
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2290,74 +2327,136 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
|
||||
const safeInputs = inputs && typeof inputs === 'object' ? inputs : {}
|
||||
const inputEntries = Object.entries(safeInputs)
|
||||
|
||||
// Don't show the table if there are no inputs
|
||||
// Don't show the section if there are no inputs
|
||||
if (inputEntries.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<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='group relative border-[var(--border-1)] border-t bg-transparent'
|
||||
>
|
||||
<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 }
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
/**
|
||||
* 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
|
||||
key={key}
|
||||
className={clsx(
|
||||
'flex flex-col gap-1.5 px-[10px] py-[8px]',
|
||||
index > 0 && 'border-[var(--border-1)] border-t'
|
||||
)}
|
||||
>
|
||||
{/* 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 }
|
||||
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2443,8 +2542,8 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
|
||||
<ShimmerOverlayText
|
||||
text={displayName}
|
||||
active={isLoadingState}
|
||||
isSpecial={false}
|
||||
className='font-[470] font-season text-[var(--text-muted)] text-sm'
|
||||
isSpecial={isSpecial}
|
||||
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
|
||||
/>
|
||||
</div>
|
||||
{code && (
|
||||
|
||||
@@ -124,8 +124,10 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
isSendingMessage,
|
||||
})
|
||||
|
||||
// Handle scroll management
|
||||
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage)
|
||||
// Handle scroll management (80px stickiness for copilot)
|
||||
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage, {
|
||||
stickinessThreshold: 80,
|
||||
})
|
||||
|
||||
// Handle chat history grouping
|
||||
const { groupedChats, handleHistoryDropdownOpen: handleHistoryDropdownOpenHook } = useChatHistory(
|
||||
|
||||
@@ -108,7 +108,7 @@ export function Panel() {
|
||||
// Delete workflow hook
|
||||
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
|
||||
workspaceId,
|
||||
getWorkflowIds: () => activeWorkflowId || '',
|
||||
workflowIds: activeWorkflowId || '',
|
||||
isActive: true,
|
||||
onSuccess: () => setIsDeleteModalOpen(false),
|
||||
})
|
||||
|
||||
@@ -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)]',
|
||||
'relative cursor-pointer select-none rounded-[8px] border border-[var(--border-1)]',
|
||||
'transition-block-bg transition-ring',
|
||||
'z-[20]'
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ export {
|
||||
computeParentUpdateEntries,
|
||||
getClampedPositionForNode,
|
||||
isInEditableElement,
|
||||
selectNodesDeferred,
|
||||
resolveParentChildSelectionConflicts,
|
||||
validateTriggerPaste,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers'
|
||||
export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './float'
|
||||
@@ -12,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 { useNodeUtilities } from './use-node-utilities'
|
||||
export { calculateContainerDimensions, useNodeUtilities } from './use-node-utilities'
|
||||
export { usePreventZoom } from './use-prevent-zoom'
|
||||
export { useScrollManagement } from './use-scroll-management'
|
||||
export { useWorkflowExecution } from './use-workflow-execution'
|
||||
|
||||
@@ -62,6 +62,47 @@ 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
|
||||
*/
|
||||
@@ -306,36 +347,16 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
(id) => currentBlocks[id]?.data?.parentId === nodeId
|
||||
)
|
||||
|
||||
if (childBlockIds.length === 0) {
|
||||
return {
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
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 }
|
||||
return calculateContainerDimensions(childPositions)
|
||||
},
|
||||
[getBlockDimensions]
|
||||
)
|
||||
|
||||
@@ -12,6 +12,12 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,6 +40,7 @@ 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
|
||||
@@ -74,7 +81,7 @@ export function useScrollManagement(
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
|
||||
const nearBottom = distanceFromBottom <= 100
|
||||
const nearBottom = distanceFromBottom <= stickinessThreshold
|
||||
setIsNearBottom(nearBottom)
|
||||
|
||||
if (isSendingMessage) {
|
||||
@@ -95,7 +102,7 @@ export function useScrollManagement(
|
||||
|
||||
// Track last scrollTop for direction detection
|
||||
lastScrollTopRef.current = scrollTop
|
||||
}, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream])
|
||||
}, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream, stickinessThreshold])
|
||||
|
||||
// Attach scroll listener
|
||||
useEffect(() => {
|
||||
@@ -174,14 +181,20 @@ export function useScrollManagement(
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
const nearBottom = distanceFromBottom <= 120
|
||||
const nearBottom = distanceFromBottom <= stickinessThreshold
|
||||
if (nearBottom) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}, 100)
|
||||
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [isSendingMessage, userHasScrolledDuringStream, getScrollContainer, scrollToBottom])
|
||||
}, [
|
||||
isSendingMessage,
|
||||
userHasScrolledDuringStream,
|
||||
getScrollContainer,
|
||||
scrollToBottom,
|
||||
stickinessThreshold,
|
||||
])
|
||||
|
||||
return {
|
||||
scrollAreaRef,
|
||||
|
||||
@@ -50,7 +50,7 @@ export function getBlockRingStyles(options: BlockRingOptions): {
|
||||
!isPending &&
|
||||
!isDeletedBlock &&
|
||||
diffStatus === 'new' &&
|
||||
'ring-[var(--brand-tertiary)]',
|
||||
'ring-[var(--brand-tertiary-2)]',
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isDeletedBlock &&
|
||||
|
||||
@@ -65,27 +65,6 @@ export function clearDragHighlights(): void {
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects nodes by their IDs after paste/duplicate operations.
|
||||
* 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.
|
||||
*/
|
||||
export function selectNodesDeferred(
|
||||
nodeIds: string[],
|
||||
setDisplayNodes: (updater: (nodes: Node[]) => Node[]) => void
|
||||
): void {
|
||||
const idsSet = new Set(nodeIds)
|
||||
requestAnimationFrame(() => {
|
||||
setDisplayNodes((nodes) =>
|
||||
nodes.map((node) => ({
|
||||
...node,
|
||||
selected: idsSet.has(node.id),
|
||||
}))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
interface BlockData {
|
||||
height?: number
|
||||
data?: {
|
||||
@@ -186,3 +165,26 @@ 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 || 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,7 @@ import {
|
||||
computeClampedPositionUpdates,
|
||||
getClampedPositionForNode,
|
||||
isInEditableElement,
|
||||
selectNodesDeferred,
|
||||
resolveParentChildSelectionConflicts,
|
||||
useAutoLayout,
|
||||
useCurrentWorkflow,
|
||||
useNodeUtilities,
|
||||
@@ -55,6 +55,7 @@ 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'
|
||||
@@ -356,6 +357,9 @@ const WorkflowContent = React.memo(() => {
|
||||
new Map()
|
||||
)
|
||||
|
||||
/** Stores node IDs to select on next derivedNodes sync (for paste/duplicate operations). */
|
||||
const pendingSelectionRef = useRef<Set<string> | null>(null)
|
||||
|
||||
/** Re-applies diff markers when blocks change after socket rehydration. */
|
||||
const blocksRef = useRef(blocks)
|
||||
useEffect(() => {
|
||||
@@ -687,6 +691,12 @@ const WorkflowContent = React.memo(() => {
|
||||
return
|
||||
}
|
||||
|
||||
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
|
||||
pendingSelectionRef.current = new Set([
|
||||
...(pendingSelectionRef.current ?? []),
|
||||
...pastedBlocksArray.map((b) => b.id),
|
||||
])
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
pastedBlocksArray,
|
||||
pastedEdges,
|
||||
@@ -694,11 +704,6 @@ const WorkflowContent = React.memo(() => {
|
||||
pastedParallels,
|
||||
pastedSubBlockValues
|
||||
)
|
||||
|
||||
selectNodesDeferred(
|
||||
pastedBlocksArray.map((b) => b.id),
|
||||
setDisplayNodes
|
||||
)
|
||||
}, [
|
||||
hasClipboard,
|
||||
clipboard,
|
||||
@@ -735,6 +740,12 @@ const WorkflowContent = React.memo(() => {
|
||||
return
|
||||
}
|
||||
|
||||
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
|
||||
pendingSelectionRef.current = new Set([
|
||||
...(pendingSelectionRef.current ?? []),
|
||||
...pastedBlocksArray.map((b) => b.id),
|
||||
])
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
pastedBlocksArray,
|
||||
pastedEdges,
|
||||
@@ -742,11 +753,6 @@ const WorkflowContent = React.memo(() => {
|
||||
pastedParallels,
|
||||
pastedSubBlockValues
|
||||
)
|
||||
|
||||
selectNodesDeferred(
|
||||
pastedBlocksArray.map((b) => b.id),
|
||||
setDisplayNodes
|
||||
)
|
||||
}, [
|
||||
contextMenuBlocks,
|
||||
copyBlocks,
|
||||
@@ -880,6 +886,12 @@ const WorkflowContent = React.memo(() => {
|
||||
return
|
||||
}
|
||||
|
||||
// Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes)
|
||||
pendingSelectionRef.current = new Set([
|
||||
...(pendingSelectionRef.current ?? []),
|
||||
...pastedBlocks.map((b) => b.id),
|
||||
])
|
||||
|
||||
collaborativeBatchAddBlocks(
|
||||
pastedBlocks,
|
||||
pasteData.edges,
|
||||
@@ -887,11 +899,6 @@ const WorkflowContent = React.memo(() => {
|
||||
pasteData.parallels,
|
||||
pasteData.subBlockValues
|
||||
)
|
||||
|
||||
selectNodesDeferred(
|
||||
pastedBlocks.map((b) => b.id),
|
||||
setDisplayNodes
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1954,15 +1961,27 @@ const WorkflowContent = React.memo(() => {
|
||||
}, [isShiftPressed])
|
||||
|
||||
useEffect(() => {
|
||||
// Preserve selection state when syncing from derivedNodes
|
||||
// Check for pending selection (from paste/duplicate), otherwise preserve existing selection
|
||||
const pendingSelection = pendingSelectionRef.current
|
||||
pendingSelectionRef.current = null
|
||||
|
||||
setDisplayNodes((currentNodes) => {
|
||||
if (pendingSelection) {
|
||||
// Apply pending selection and resolve parent-child conflicts
|
||||
const withSelection = derivedNodes.map((node) => ({
|
||||
...node,
|
||||
selected: pendingSelection.has(node.id),
|
||||
}))
|
||||
return resolveParentChildSelectionConflicts(withSelection, blocks)
|
||||
}
|
||||
// Preserve existing selection state
|
||||
const selectedIds = new Set(currentNodes.filter((n) => n.selected).map((n) => n.id))
|
||||
return derivedNodes.map((node) => ({
|
||||
...node,
|
||||
selected: selectedIds.has(node.id),
|
||||
}))
|
||||
})
|
||||
}, [derivedNodes])
|
||||
}, [derivedNodes, blocks])
|
||||
|
||||
/** Handles ActionBar remove-from-subflow events. */
|
||||
useEffect(() => {
|
||||
@@ -2037,10 +2056,17 @@ const WorkflowContent = React.memo(() => {
|
||||
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
|
||||
}, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent])
|
||||
|
||||
/** 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))
|
||||
}, [])
|
||||
/** 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')
|
||||
return hasSelectionChange ? resolveParentChildSelectionConflicts(updated, blocks) : updated
|
||||
})
|
||||
},
|
||||
[blocks]
|
||||
)
|
||||
|
||||
/**
|
||||
* Updates container dimensions in displayNodes during drag.
|
||||
@@ -2055,28 +2081,13 @@ const WorkflowContent = React.memo(() => {
|
||||
const childNodes = currentNodes.filter((n) => n.parentId === parentId)
|
||||
if (childNodes.length === 0) return currentNodes
|
||||
|
||||
let maxRight = 0
|
||||
let maxBottom = 0
|
||||
|
||||
childNodes.forEach((node) => {
|
||||
const childPositions = childNodes.map((node) => {
|
||||
const nodePosition = node.id === draggedNodeId ? draggedNodePosition : node.position
|
||||
const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id)
|
||||
|
||||
maxRight = Math.max(maxRight, nodePosition.x + nodeWidth)
|
||||
maxBottom = Math.max(maxBottom, nodePosition.y + nodeHeight)
|
||||
const { width, height } = getBlockDimensions(node.id)
|
||||
return { x: nodePosition.x, y: nodePosition.y, width, height }
|
||||
})
|
||||
|
||||
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
|
||||
)
|
||||
const { width: newWidth, height: newHeight } = calculateContainerDimensions(childPositions)
|
||||
|
||||
return currentNodes.map((node) => {
|
||||
if (node.id === parentId) {
|
||||
@@ -2844,30 +2855,42 @@ const WorkflowContent = React.memo(() => {
|
||||
}, [isShiftPressed])
|
||||
|
||||
const onSelectionEnd = useCallback(() => {
|
||||
requestAnimationFrame(() => setIsSelectionDragActive(false))
|
||||
}, [])
|
||||
requestAnimationFrame(() => {
|
||||
setIsSelectionDragActive(false)
|
||||
setDisplayNodes((nodes) => resolveParentChildSelectionConflicts(nodes, blocks))
|
||||
})
|
||||
}, [blocks])
|
||||
|
||||
/** 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)
|
||||
}
|
||||
|
||||
// Capture all selected nodes' positions for undo/redo
|
||||
// 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 positions for undo/redo before applying display changes
|
||||
multiNodeDragStartRef.current.clear()
|
||||
nodes.forEach((n) => {
|
||||
const block = blocks[n.id]
|
||||
if (block) {
|
||||
effectiveNodes.forEach((n) => {
|
||||
const blk = blocks[n.id]
|
||||
if (blk) {
|
||||
multiNodeDragStartRef.current.set(n.id, {
|
||||
x: n.position.x,
|
||||
y: n.position.y,
|
||||
parentId: block.data?.parentId,
|
||||
parentId: blk.data?.parentId,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Apply visual deselection of children
|
||||
setDisplayNodes((allNodes) => resolveParentChildSelectionConflicts(allNodes, blocks))
|
||||
},
|
||||
[blocks]
|
||||
)
|
||||
@@ -2903,7 +2926,6 @@ 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,
|
||||
@@ -3129,13 +3151,11 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
/**
|
||||
* Handles node click to select the node in ReactFlow.
|
||||
* This ensures clicking anywhere on a block (not just the drag handle)
|
||||
* selects it for delete/backspace and multi-select operations.
|
||||
* Parent-child conflict resolution happens automatically in onNodesChange.
|
||||
*/
|
||||
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)]'
|
||||
className='relative select-none rounded-[8px] border border-[var(--border-1)]'
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
|
||||
@@ -163,7 +163,7 @@ function AddMembersModal({
|
||||
className='flex items-center gap-[10px] rounded-[4px] px-[8px] py-[6px] hover:bg-[var(--surface-2)]'
|
||||
>
|
||||
<Checkbox checked={isSelected} />
|
||||
<Avatar size='xs'>
|
||||
<Avatar size='sm'>
|
||||
{member.user?.image && (
|
||||
<AvatarImage src={member.user.image} alt={name} />
|
||||
)}
|
||||
@@ -663,7 +663,7 @@ export function AccessControl() {
|
||||
return (
|
||||
<div key={member.id} className='flex items-center justify-between'>
|
||||
<div className='flex flex-1 items-center gap-[12px]'>
|
||||
<Avatar size='sm'>
|
||||
<Avatar size='md'>
|
||||
{member.userImage && <AvatarImage src={member.userImage} alt={name} />}
|
||||
<AvatarFallback
|
||||
style={{
|
||||
|
||||
@@ -434,12 +434,10 @@ export function CredentialSets() {
|
||||
filteredOwnedSets.length === 0 &&
|
||||
!hasNoContent
|
||||
|
||||
// Early returns AFTER all hooks
|
||||
if (membershipsLoading || invitationsLoading) {
|
||||
return <CredentialSetsSkeleton />
|
||||
}
|
||||
|
||||
// Detail view for a polling group
|
||||
if (viewingSet) {
|
||||
const activeMembers = members.filter((m) => m.status === 'active')
|
||||
const totalCount = activeMembers.length + pendingInvitations.length
|
||||
@@ -529,7 +527,7 @@ export function CredentialSets() {
|
||||
return (
|
||||
<div key={member.id} className='flex items-center justify-between'>
|
||||
<div className='flex flex-1 items-center gap-[12px]'>
|
||||
<Avatar size='sm'>
|
||||
<Avatar size='md'>
|
||||
{member.userImage && (
|
||||
<AvatarImage src={member.userImage} alt={name} />
|
||||
)}
|
||||
@@ -583,7 +581,7 @@ export function CredentialSets() {
|
||||
return (
|
||||
<div key={invitation.id} className='flex items-center justify-between'>
|
||||
<div className='flex flex-1 items-center gap-[12px]'>
|
||||
<Avatar size='sm'>
|
||||
<Avatar size='md'>
|
||||
<AvatarFallback
|
||||
style={{ background: getUserColor(email) }}
|
||||
className='border-0 text-white'
|
||||
|
||||
@@ -1,12 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverBackButton,
|
||||
PopoverContent,
|
||||
PopoverDivider,
|
||||
PopoverFolder,
|
||||
PopoverItem,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { WORKFLOW_COLORS } from '@/lib/workflows/colors'
|
||||
|
||||
/**
|
||||
* Validates a hex color string.
|
||||
* Accepts 3 or 6 character hex codes with or without #.
|
||||
*/
|
||||
function isValidHex(hex: string): boolean {
|
||||
const cleaned = hex.replace('#', '')
|
||||
return /^[0-9A-Fa-f]{3}$|^[0-9A-Fa-f]{6}$/.test(cleaned)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a hex color to lowercase 6-character format with #.
|
||||
*/
|
||||
function normalizeHex(hex: string): string {
|
||||
let cleaned = hex.replace('#', '').toLowerCase()
|
||||
if (cleaned.length === 3) {
|
||||
cleaned = cleaned
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
}
|
||||
return `#${cleaned}`
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
/**
|
||||
@@ -53,6 +82,14 @@ interface ContextMenuProps {
|
||||
* Callback when delete is clicked
|
||||
*/
|
||||
onDelete: () => void
|
||||
/**
|
||||
* Callback when color is changed
|
||||
*/
|
||||
onColorChange?: (color: string) => void
|
||||
/**
|
||||
* Current workflow color (for showing selected state)
|
||||
*/
|
||||
currentColor?: string
|
||||
/**
|
||||
* Whether to show the open in new tab option (default: false)
|
||||
* Set to true for items that can be opened in a new tab
|
||||
@@ -83,11 +120,21 @@ interface ContextMenuProps {
|
||||
* Set to true for items that can be exported (like workspaces)
|
||||
*/
|
||||
showExport?: boolean
|
||||
/**
|
||||
* Whether to show the change color option (default: false)
|
||||
* Set to true for workflows to allow color customization
|
||||
*/
|
||||
showColorChange?: boolean
|
||||
/**
|
||||
* Whether the export option is disabled (default: false)
|
||||
* Set to true when user lacks permissions
|
||||
*/
|
||||
disableExport?: boolean
|
||||
/**
|
||||
* Whether the change color option is disabled (default: false)
|
||||
* Set to true when user lacks permissions
|
||||
*/
|
||||
disableColorChange?: boolean
|
||||
/**
|
||||
* Whether the rename option is disabled (default: false)
|
||||
* Set to true when user lacks permissions
|
||||
@@ -134,19 +181,76 @@ export function ContextMenu({
|
||||
onDuplicate,
|
||||
onExport,
|
||||
onDelete,
|
||||
onColorChange,
|
||||
currentColor,
|
||||
showOpenInNewTab = false,
|
||||
showRename = true,
|
||||
showCreate = false,
|
||||
showCreateFolder = false,
|
||||
showDuplicate = true,
|
||||
showExport = false,
|
||||
showColorChange = false,
|
||||
disableExport = false,
|
||||
disableColorChange = false,
|
||||
disableRename = false,
|
||||
disableDuplicate = false,
|
||||
disableDelete = false,
|
||||
disableCreate = false,
|
||||
disableCreateFolder = false,
|
||||
}: ContextMenuProps) {
|
||||
const [hexInput, setHexInput] = useState(currentColor || '#ffffff')
|
||||
|
||||
// Sync hexInput when currentColor changes (e.g., opening menu on different workflow)
|
||||
useEffect(() => {
|
||||
setHexInput(currentColor || '#ffffff')
|
||||
}, [currentColor])
|
||||
|
||||
const canSubmitHex = useMemo(() => {
|
||||
if (!isValidHex(hexInput)) return false
|
||||
const normalized = normalizeHex(hexInput)
|
||||
if (currentColor && normalized.toLowerCase() === currentColor.toLowerCase()) return false
|
||||
return true
|
||||
}, [hexInput, currentColor])
|
||||
|
||||
const handleHexSubmit = useCallback(() => {
|
||||
if (!canSubmitHex || !onColorChange) return
|
||||
|
||||
const normalized = normalizeHex(hexInput)
|
||||
onColorChange(normalized)
|
||||
setHexInput(normalized)
|
||||
}, [hexInput, canSubmitHex, onColorChange])
|
||||
|
||||
const handleHexKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleHexSubmit()
|
||||
}
|
||||
},
|
||||
[handleHexSubmit]
|
||||
)
|
||||
|
||||
const handleHexChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let value = e.target.value.trim()
|
||||
if (value && !value.startsWith('#')) {
|
||||
value = `#${value}`
|
||||
}
|
||||
value = value.slice(0, 1) + value.slice(1).replace(/[^0-9a-fA-F]/g, '')
|
||||
setHexInput(value.slice(0, 7))
|
||||
}, [])
|
||||
|
||||
const handleHexFocus = useCallback((e: React.FocusEvent<HTMLInputElement>) => {
|
||||
e.target.select()
|
||||
}, [])
|
||||
|
||||
const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
|
||||
const hasEditSection =
|
||||
(showRename && onRename) ||
|
||||
(showCreate && onCreate) ||
|
||||
(showCreateFolder && onCreateFolder) ||
|
||||
(showColorChange && onColorChange)
|
||||
const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
@@ -164,10 +268,21 @@ export function ContextMenu({
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
<PopoverContent
|
||||
ref={menuRef}
|
||||
align='start'
|
||||
side='bottom'
|
||||
sideOffset={4}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* Back button - shown only when in a folder */}
|
||||
<PopoverBackButton />
|
||||
|
||||
{/* Navigation actions */}
|
||||
{showOpenInNewTab && onOpenInNewTab && (
|
||||
<PopoverItem
|
||||
rootOnly
|
||||
onClick={() => {
|
||||
onOpenInNewTab()
|
||||
onClose()
|
||||
@@ -176,11 +291,12 @@ export function ContextMenu({
|
||||
Open in new tab
|
||||
</PopoverItem>
|
||||
)}
|
||||
{showOpenInNewTab && onOpenInNewTab && <PopoverDivider />}
|
||||
{hasNavigationSection && (hasEditSection || hasCopySection) && <PopoverDivider rootOnly />}
|
||||
|
||||
{/* Edit and create actions */}
|
||||
{showRename && onRename && (
|
||||
<PopoverItem
|
||||
rootOnly
|
||||
disabled={disableRename}
|
||||
onClick={() => {
|
||||
onRename()
|
||||
@@ -192,6 +308,7 @@ export function ContextMenu({
|
||||
)}
|
||||
{showCreate && onCreate && (
|
||||
<PopoverItem
|
||||
rootOnly
|
||||
disabled={disableCreate}
|
||||
onClick={() => {
|
||||
onCreate()
|
||||
@@ -203,6 +320,7 @@ export function ContextMenu({
|
||||
)}
|
||||
{showCreateFolder && onCreateFolder && (
|
||||
<PopoverItem
|
||||
rootOnly
|
||||
disabled={disableCreateFolder}
|
||||
onClick={() => {
|
||||
onCreateFolder()
|
||||
@@ -212,11 +330,72 @@ export function ContextMenu({
|
||||
Create folder
|
||||
</PopoverItem>
|
||||
)}
|
||||
{showColorChange && onColorChange && (
|
||||
<PopoverFolder
|
||||
id='color-picker'
|
||||
title='Change color'
|
||||
expandOnHover
|
||||
className={disableColorChange ? 'pointer-events-none opacity-50' : ''}
|
||||
>
|
||||
<div className='flex w-[140px] flex-col gap-[8px] p-[2px]'>
|
||||
{/* Preset colors */}
|
||||
<div className='grid grid-cols-6 gap-[4px]'>
|
||||
{WORKFLOW_COLORS.map(({ color, name }) => (
|
||||
<button
|
||||
key={color}
|
||||
type='button'
|
||||
title={name}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setHexInput(color)
|
||||
}}
|
||||
className={cn(
|
||||
'h-[20px] w-[20px] rounded-[4px]',
|
||||
hexInput.toLowerCase() === color.toLowerCase() && 'ring-1 ring-white'
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Hex input */}
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<div
|
||||
className='h-[20px] w-[20px] flex-shrink-0 rounded-[4px]'
|
||||
style={{
|
||||
backgroundColor: isValidHex(hexInput) ? normalizeHex(hexInput) : '#ffffff',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type='text'
|
||||
value={hexInput}
|
||||
onChange={handleHexChange}
|
||||
onKeyDown={handleHexKeyDown}
|
||||
onFocus={handleHexFocus}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='h-[20px] min-w-0 flex-1 rounded-[4px] bg-[#363636] px-[6px] text-[11px] text-white uppercase caret-white focus:outline-none'
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
disabled={!canSubmitHex}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleHexSubmit()
|
||||
}}
|
||||
className='flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[var(--brand-tertiary-2)] text-white disabled:opacity-40'
|
||||
>
|
||||
<Check className='h-[12px] w-[12px]' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverFolder>
|
||||
)}
|
||||
|
||||
{/* Copy and export actions */}
|
||||
{(showDuplicate || showExport) && <PopoverDivider />}
|
||||
{hasEditSection && hasCopySection && <PopoverDivider rootOnly />}
|
||||
{showDuplicate && onDuplicate && (
|
||||
<PopoverItem
|
||||
rootOnly
|
||||
disabled={disableDuplicate}
|
||||
onClick={() => {
|
||||
onDuplicate()
|
||||
@@ -228,6 +407,7 @@ export function ContextMenu({
|
||||
)}
|
||||
{showExport && onExport && (
|
||||
<PopoverItem
|
||||
rootOnly
|
||||
disabled={disableExport}
|
||||
onClick={() => {
|
||||
onExport()
|
||||
@@ -239,8 +419,9 @@ export function ContextMenu({
|
||||
)}
|
||||
|
||||
{/* Destructive action */}
|
||||
<PopoverDivider />
|
||||
{(hasNavigationSection || hasEditSection || hasCopySection) && <PopoverDivider rootOnly />}
|
||||
<PopoverItem
|
||||
rootOnly
|
||||
disabled={disableDelete}
|
||||
onClick={() => {
|
||||
onDelete()
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronRight, Folder, FolderOpen } from 'lucide-react'
|
||||
import { ChevronRight, Folder, FolderOpen, MoreHorizontal } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { getNextWorkflowColor } from '@/lib/workflows/colors'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
|
||||
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
|
||||
@@ -19,14 +20,12 @@ import {
|
||||
useCanDelete,
|
||||
useDeleteFolder,
|
||||
useDuplicateFolder,
|
||||
useExportFolder,
|
||||
} from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
|
||||
import { useCreateWorkflow } from '@/hooks/queries/workflows'
|
||||
import type { FolderTreeNode } from '@/stores/folders/types'
|
||||
import {
|
||||
generateCreativeWorkflowName,
|
||||
getNextWorkflowColor,
|
||||
} from '@/stores/workflows/registry/utils'
|
||||
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
|
||||
|
||||
const logger = createLogger('FolderItem')
|
||||
|
||||
@@ -59,23 +58,24 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
const { canDeleteFolder } = useCanDelete({ workspaceId })
|
||||
const canDelete = useMemo(() => canDeleteFolder(folder.id), [canDeleteFolder, folder.id])
|
||||
|
||||
// Delete modal state
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
|
||||
// Delete folder hook
|
||||
const { isDeleting, handleDeleteFolder } = useDeleteFolder({
|
||||
workspaceId,
|
||||
getFolderIds: () => folder.id,
|
||||
folderIds: folder.id,
|
||||
onSuccess: () => setIsDeleteModalOpen(false),
|
||||
})
|
||||
|
||||
// Duplicate folder hook
|
||||
const { handleDuplicateFolder } = useDuplicateFolder({
|
||||
workspaceId,
|
||||
getFolderIds: () => folder.id,
|
||||
folderIds: folder.id,
|
||||
})
|
||||
|
||||
const { isExporting, hasWorkflows, handleExportFolder } = useExportFolder({
|
||||
workspaceId,
|
||||
folderId: folder.id,
|
||||
})
|
||||
|
||||
// Folder expand hook - must be declared before callbacks that use expandFolder
|
||||
const {
|
||||
isExpanded,
|
||||
handleToggleExpanded,
|
||||
@@ -92,7 +92,6 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
*/
|
||||
const handleCreateWorkflowInFolder = useCallback(async () => {
|
||||
try {
|
||||
// Generate name and color upfront for optimistic updates
|
||||
const name = generateCreativeWorkflowName()
|
||||
const color = getNextWorkflowColor()
|
||||
|
||||
@@ -105,15 +104,12 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
|
||||
if (result.id) {
|
||||
router.push(`/workspace/${workspaceId}/w/${result.id}`)
|
||||
// Expand the parent folder so the new workflow is visible
|
||||
expandFolder()
|
||||
// Scroll to the newly created workflow
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: result.id } })
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
// Error already handled by mutation's onError callback
|
||||
logger.error('Failed to create workflow in folder:', error)
|
||||
}
|
||||
}, [createWorkflowMutation, workspaceId, folder.id, router, expandFolder])
|
||||
@@ -130,9 +126,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
parentId: folder.id,
|
||||
})
|
||||
if (result.id) {
|
||||
// Expand the parent folder so the new folder is visible
|
||||
expandFolder()
|
||||
// Scroll to the newly created folder
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: result.id } })
|
||||
)
|
||||
@@ -149,7 +143,6 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
*/
|
||||
const onDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
// Don't start drag if editing
|
||||
if (isEditing) {
|
||||
e.preventDefault()
|
||||
return
|
||||
@@ -161,21 +154,19 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
[folder.id]
|
||||
)
|
||||
|
||||
// Item drag hook
|
||||
const { isDragging, shouldPreventClickRef, handleDragStart, handleDragEnd } = useItemDrag({
|
||||
onDragStart,
|
||||
})
|
||||
|
||||
// Context menu hook
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
position,
|
||||
menuRef,
|
||||
handleContextMenu,
|
||||
closeMenu,
|
||||
preventDismiss,
|
||||
} = useContextMenu()
|
||||
|
||||
// Rename hook
|
||||
const {
|
||||
isEditing,
|
||||
editValue,
|
||||
@@ -242,6 +233,39 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
[isEditing, handleRenameKeyDown, handleExpandKeyDown]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle more button pointerdown - prevents click-outside dismissal when toggling
|
||||
*/
|
||||
const handleMorePointerDown = useCallback(() => {
|
||||
if (isContextMenuOpen) {
|
||||
preventDismiss()
|
||||
}
|
||||
}, [isContextMenuOpen, preventDismiss])
|
||||
|
||||
/**
|
||||
* Handle more button click - toggles context menu at button position
|
||||
*/
|
||||
const handleMoreClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (isContextMenuOpen) {
|
||||
closeMenu()
|
||||
return
|
||||
}
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
handleContextMenu({
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
clientX: rect.right,
|
||||
clientY: rect.top,
|
||||
} as React.MouseEvent)
|
||||
},
|
||||
[isContextMenuOpen, closeMenu, handleContextMenu]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -303,12 +327,22 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
spellCheck='false'
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className='truncate font-medium text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{folder.name}
|
||||
</span>
|
||||
<>
|
||||
<span
|
||||
className='min-w-0 flex-1 truncate font-medium text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]'
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{folder.name}
|
||||
</span>
|
||||
<button
|
||||
type='button'
|
||||
onPointerDown={handleMorePointerDown}
|
||||
onClick={handleMoreClick}
|
||||
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] opacity-0 transition-opacity hover:bg-[var(--surface-7)] group-hover:opacity-100'
|
||||
>
|
||||
<MoreHorizontal className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -322,13 +356,16 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
onCreate={handleCreateWorkflowInFolder}
|
||||
onCreateFolder={handleCreateFolderInFolder}
|
||||
onDuplicate={handleDuplicateFolder}
|
||||
onExport={handleExportFolder}
|
||||
onDelete={() => setIsDeleteModalOpen(true)}
|
||||
showCreate={true}
|
||||
showCreateFolder={true}
|
||||
showExport={true}
|
||||
disableRename={!userPermissions.canEdit}
|
||||
disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending}
|
||||
disableCreateFolder={!userPermissions.canEdit || createFolderMutation.isPending}
|
||||
disableDuplicate={!userPermissions.canEdit}
|
||||
disableDuplicate={!userPermissions.canEdit || !hasWorkflows}
|
||||
disableExport={!userPermissions.canEdit || isExporting || !hasWorkflows}
|
||||
disableDelete={!userPermissions.canEdit || !canDelete}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { type CSSProperties, useEffect, useMemo, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { type CSSProperties, useEffect, useMemo } from 'react'
|
||||
import { Avatar, AvatarFallback, AvatarImage, Tooltip } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { getUserColor } from '@/lib/workspaces/colors'
|
||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
||||
import { SIDEBAR_WIDTH } from '@/stores/constants'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
|
||||
/**
|
||||
* Avatar display configuration for responsive layout.
|
||||
*/
|
||||
const AVATAR_CONFIG = {
|
||||
MIN_COUNT: 3,
|
||||
MAX_COUNT: 12,
|
||||
WIDTH_PER_AVATAR: 20,
|
||||
} as const
|
||||
|
||||
interface AvatarsProps {
|
||||
workflowId: string
|
||||
maxVisible?: number
|
||||
/**
|
||||
* Callback fired when the presence visibility changes.
|
||||
* Used by parent components to adjust layout (e.g., text truncation spacing).
|
||||
@@ -30,45 +39,29 @@ interface UserAvatarProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual user avatar with error handling for image loading.
|
||||
* Individual user avatar using emcn Avatar component.
|
||||
* Falls back to colored circle with initials if image fails to load.
|
||||
*/
|
||||
function UserAvatar({ user, index }: UserAvatarProps) {
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const color = getUserColor(user.userId)
|
||||
const initials = user.userName ? user.userName.charAt(0).toUpperCase() : '?'
|
||||
const hasAvatar = Boolean(user.avatarUrl) && !imageError
|
||||
|
||||
// Reset error state when avatar URL changes
|
||||
useEffect(() => {
|
||||
setImageError(false)
|
||||
}, [user.avatarUrl])
|
||||
|
||||
const avatarElement = (
|
||||
<div
|
||||
className='relative flex h-[14px] w-[14px] flex-shrink-0 cursor-default items-center justify-center overflow-hidden rounded-full font-semibold text-[7px] text-white'
|
||||
style={
|
||||
{
|
||||
background: hasAvatar ? undefined : color,
|
||||
zIndex: 10 - index,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{hasAvatar && user.avatarUrl ? (
|
||||
<Image
|
||||
<Avatar size='xs' style={{ zIndex: index + 1 } as CSSProperties}>
|
||||
{user.avatarUrl && (
|
||||
<AvatarImage
|
||||
src={user.avatarUrl}
|
||||
alt={user.userName ? `${user.userName}'s avatar` : 'User avatar'}
|
||||
fill
|
||||
sizes='14px'
|
||||
className='object-cover'
|
||||
referrerPolicy='no-referrer'
|
||||
unoptimized
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</div>
|
||||
<AvatarFallback
|
||||
style={{ background: color }}
|
||||
className='border-0 font-semibold text-[7px] text-white'
|
||||
>
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)
|
||||
|
||||
if (user.userName) {
|
||||
@@ -92,14 +85,26 @@ function UserAvatar({ user, index }: UserAvatarProps) {
|
||||
* @param props - Component props
|
||||
* @returns Avatar stack for workflow presence
|
||||
*/
|
||||
export function Avatars({ workflowId, maxVisible = 3, onPresenceChange }: AvatarsProps) {
|
||||
export function Avatars({ workflowId, onPresenceChange }: AvatarsProps) {
|
||||
const { presenceUsers, currentWorkflowId } = useSocket()
|
||||
const { data: session } = useSession()
|
||||
const currentUserId = session?.user?.id
|
||||
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
|
||||
|
||||
/**
|
||||
* Only show presence for the currently active workflow
|
||||
* Filter out the current user from the list
|
||||
* Calculate max visible avatars based on sidebar width.
|
||||
* Scales between MIN_COUNT and MAX_COUNT as sidebar expands.
|
||||
*/
|
||||
const maxVisible = useMemo(() => {
|
||||
const widthDelta = sidebarWidth - SIDEBAR_WIDTH.MIN
|
||||
const additionalAvatars = Math.floor(widthDelta / AVATAR_CONFIG.WIDTH_PER_AVATAR)
|
||||
const calculated = AVATAR_CONFIG.MIN_COUNT + additionalAvatars
|
||||
return Math.max(AVATAR_CONFIG.MIN_COUNT, Math.min(AVATAR_CONFIG.MAX_COUNT, calculated))
|
||||
}, [sidebarWidth])
|
||||
|
||||
/**
|
||||
* Only show presence for the currently active workflow.
|
||||
* Filter out the current user from the list.
|
||||
*/
|
||||
const workflowUsers = useMemo(() => {
|
||||
if (currentWorkflowId !== workflowId) {
|
||||
@@ -122,7 +127,6 @@ export function Avatars({ workflowId, maxVisible = 3, onPresenceChange }: Avatar
|
||||
return { visibleUsers: visible, overflowCount: overflow }
|
||||
}, [workflowUsers, maxVisible])
|
||||
|
||||
// Notify parent when avatars are present or not
|
||||
useEffect(() => {
|
||||
const hasAnyAvatars = visibleUsers.length > 0
|
||||
if (typeof onPresenceChange === 'function') {
|
||||
@@ -135,26 +139,25 @@ export function Avatars({ workflowId, maxVisible = 3, onPresenceChange }: Avatar
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='-space-x-1 ml-[-8px] flex items-center'>
|
||||
{visibleUsers.map((user, index) => (
|
||||
<UserAvatar key={user.socketId} user={user} index={index} />
|
||||
))}
|
||||
|
||||
<div className='-space-x-1 flex items-center'>
|
||||
{overflowCount > 0 && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div
|
||||
className='relative flex h-[14px] w-[14px] flex-shrink-0 cursor-default items-center justify-center overflow-hidden rounded-full bg-[#404040] font-semibold text-[7px] text-white'
|
||||
style={{ zIndex: 10 - visibleUsers.length } as CSSProperties}
|
||||
>
|
||||
+{overflowCount}
|
||||
</div>
|
||||
<Avatar size='xs' style={{ zIndex: 0 } as CSSProperties}>
|
||||
<AvatarFallback className='border-0 bg-[#404040] font-semibold text-[7px] text-white'>
|
||||
+{overflowCount}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom'>
|
||||
{overflowCount} more user{overflowCount > 1 ? 's' : ''}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{visibleUsers.map((user, index) => (
|
||||
<UserAvatar key={user.socketId} user={user} index={overflowCount > 0 ? index + 1 : index} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { MoreHorizontal } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -45,19 +46,15 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const isSelected = selectedWorkflows.has(workflow.id)
|
||||
|
||||
// Can delete check hook
|
||||
const { canDeleteWorkflows } = useCanDelete({ workspaceId })
|
||||
|
||||
// Delete modal state
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [workflowIdsToDelete, setWorkflowIdsToDelete] = useState<string[]>([])
|
||||
const [deleteModalNames, setDeleteModalNames] = useState<string | string[]>('')
|
||||
const [canDeleteCaptured, setCanDeleteCaptured] = useState(true)
|
||||
|
||||
// Presence avatars state
|
||||
const [hasAvatars, setHasAvatars] = useState(false)
|
||||
|
||||
// Capture selection at right-click time (using ref to persist across renders)
|
||||
const capturedSelectionRef = useRef<{
|
||||
workflowIds: string[]
|
||||
workflowNames: string | string[]
|
||||
@@ -67,7 +64,6 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
* Handle opening the delete modal - uses pre-captured selection state
|
||||
*/
|
||||
const handleOpenDeleteModal = useCallback(() => {
|
||||
// Use the selection captured at right-click time
|
||||
if (capturedSelectionRef.current) {
|
||||
setWorkflowIdsToDelete(capturedSelectionRef.current.workflowIds)
|
||||
setDeleteModalNames(capturedSelectionRef.current.workflowNames)
|
||||
@@ -75,39 +71,39 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Delete workflow hook
|
||||
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
|
||||
workspaceId,
|
||||
getWorkflowIds: () => workflowIdsToDelete,
|
||||
workflowIds: workflowIdsToDelete,
|
||||
isActive: (workflowIds) => workflowIds.includes(params.workflowId as string),
|
||||
onSuccess: () => setIsDeleteModalOpen(false),
|
||||
})
|
||||
|
||||
// Duplicate workflow hook
|
||||
const { handleDuplicateWorkflow } = useDuplicateWorkflow({
|
||||
workspaceId,
|
||||
getWorkflowIds: () => {
|
||||
// Use the selection captured at right-click time
|
||||
return capturedSelectionRef.current?.workflowIds || []
|
||||
},
|
||||
})
|
||||
const { handleDuplicateWorkflow: duplicateWorkflow } = useDuplicateWorkflow({ workspaceId })
|
||||
|
||||
// Export workflow hook
|
||||
const { handleExportWorkflow } = useExportWorkflow({
|
||||
workspaceId,
|
||||
getWorkflowIds: () => {
|
||||
// Use the selection captured at right-click time
|
||||
return capturedSelectionRef.current?.workflowIds || []
|
||||
},
|
||||
})
|
||||
const { handleExportWorkflow: exportWorkflow } = useExportWorkflow({ workspaceId })
|
||||
const handleDuplicateWorkflow = useCallback(() => {
|
||||
const workflowIds = capturedSelectionRef.current?.workflowIds || []
|
||||
if (workflowIds.length === 0) return
|
||||
duplicateWorkflow(workflowIds)
|
||||
}, [duplicateWorkflow])
|
||||
|
||||
const handleExportWorkflow = useCallback(() => {
|
||||
const workflowIds = capturedSelectionRef.current?.workflowIds || []
|
||||
if (workflowIds.length === 0) return
|
||||
exportWorkflow(workflowIds)
|
||||
}, [exportWorkflow])
|
||||
|
||||
/**
|
||||
* Opens the workflow in a new browser tab
|
||||
*/
|
||||
const handleOpenInNewTab = useCallback(() => {
|
||||
window.open(`/workspace/${workspaceId}/w/${workflow.id}`, '_blank')
|
||||
}, [workspaceId, workflow.id])
|
||||
|
||||
const handleColorChange = useCallback(
|
||||
(color: string) => {
|
||||
updateWorkflow(workflow.id, { color })
|
||||
},
|
||||
[workflow.id, updateWorkflow]
|
||||
)
|
||||
|
||||
/**
|
||||
* Drag start handler - handles workflow dragging with multi-selection support
|
||||
*
|
||||
@@ -115,7 +111,6 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
*/
|
||||
const onDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
// Don't start drag if editing
|
||||
if (isEditing) {
|
||||
e.preventDefault()
|
||||
return
|
||||
@@ -130,20 +125,48 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
[isSelected, selectedWorkflows, workflow.id]
|
||||
)
|
||||
|
||||
// Item drag hook
|
||||
const { isDragging, shouldPreventClickRef, handleDragStart, handleDragEnd } = useItemDrag({
|
||||
onDragStart,
|
||||
})
|
||||
|
||||
// Context menu hook
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
position,
|
||||
menuRef,
|
||||
handleContextMenu: handleContextMenuBase,
|
||||
closeMenu,
|
||||
preventDismiss,
|
||||
} = useContextMenu()
|
||||
|
||||
/**
|
||||
* Captures selection state for context menu operations
|
||||
*/
|
||||
const captureSelectionState = useCallback(() => {
|
||||
const { selectedWorkflows: currentSelection, selectOnly } = useFolderStore.getState()
|
||||
const isCurrentlySelected = currentSelection.has(workflow.id)
|
||||
|
||||
if (!isCurrentlySelected) {
|
||||
selectOnly(workflow.id)
|
||||
}
|
||||
|
||||
const finalSelection = useFolderStore.getState().selectedWorkflows
|
||||
const finalIsSelected = finalSelection.has(workflow.id)
|
||||
|
||||
const workflowIds =
|
||||
finalIsSelected && finalSelection.size > 1 ? Array.from(finalSelection) : [workflow.id]
|
||||
|
||||
const workflowNames = workflowIds
|
||||
.map((id) => workflows[id]?.name)
|
||||
.filter((name): name is string => !!name)
|
||||
|
||||
capturedSelectionRef.current = {
|
||||
workflowIds,
|
||||
workflowNames: workflowNames.length > 1 ? workflowNames : workflowNames[0],
|
||||
}
|
||||
|
||||
setCanDeleteCaptured(canDeleteWorkflows(workflowIds))
|
||||
}, [workflow.id, workflows, canDeleteWorkflows])
|
||||
|
||||
/**
|
||||
* Handle right-click - ensure proper selection behavior and capture selection state
|
||||
* If right-clicking on an unselected workflow, select only that workflow
|
||||
@@ -151,42 +174,46 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
*/
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Check current selection state at time of right-click
|
||||
const { selectedWorkflows: currentSelection, selectOnly } = useFolderStore.getState()
|
||||
const isCurrentlySelected = currentSelection.has(workflow.id)
|
||||
|
||||
// If this workflow is not in the current selection, select only this workflow
|
||||
if (!isCurrentlySelected) {
|
||||
selectOnly(workflow.id)
|
||||
}
|
||||
|
||||
// Capture the selection state at right-click time
|
||||
const finalSelection = useFolderStore.getState().selectedWorkflows
|
||||
const finalIsSelected = finalSelection.has(workflow.id)
|
||||
|
||||
const workflowIds =
|
||||
finalIsSelected && finalSelection.size > 1 ? Array.from(finalSelection) : [workflow.id]
|
||||
|
||||
const workflowNames = workflowIds
|
||||
.map((id) => workflows[id]?.name)
|
||||
.filter((name): name is string => !!name)
|
||||
|
||||
// Store in ref so it persists even if selection changes
|
||||
capturedSelectionRef.current = {
|
||||
workflowIds,
|
||||
workflowNames: workflowNames.length > 1 ? workflowNames : workflowNames[0],
|
||||
}
|
||||
|
||||
// Check if the captured selection can be deleted
|
||||
setCanDeleteCaptured(canDeleteWorkflows(workflowIds))
|
||||
|
||||
// If already selected with multiple selections, keep all selections
|
||||
captureSelectionState()
|
||||
handleContextMenuBase(e)
|
||||
},
|
||||
[workflow.id, workflows, handleContextMenuBase, canDeleteWorkflows]
|
||||
[captureSelectionState, handleContextMenuBase]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle more button pointerdown - prevents click-outside dismissal when toggling
|
||||
*/
|
||||
const handleMorePointerDown = useCallback(() => {
|
||||
if (isContextMenuOpen) {
|
||||
preventDismiss()
|
||||
}
|
||||
}, [isContextMenuOpen, preventDismiss])
|
||||
|
||||
/**
|
||||
* Handle more button click - toggles context menu at button position
|
||||
*/
|
||||
const handleMoreClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (isContextMenuOpen) {
|
||||
closeMenu()
|
||||
return
|
||||
}
|
||||
|
||||
captureSelectionState()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
handleContextMenuBase({
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
clientX: rect.right,
|
||||
clientY: rect.top,
|
||||
} as React.MouseEvent)
|
||||
},
|
||||
[isContextMenuOpen, closeMenu, captureSelectionState, handleContextMenuBase]
|
||||
)
|
||||
|
||||
// Rename hook
|
||||
const {
|
||||
isEditing,
|
||||
editValue,
|
||||
@@ -233,12 +260,10 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
|
||||
const isModifierClick = e.shiftKey || e.metaKey || e.ctrlKey
|
||||
|
||||
// Prevent default link behavior when using modifier keys
|
||||
if (isModifierClick) {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
// Use metaKey (Cmd on Mac) or ctrlKey (Ctrl on Windows/Linux)
|
||||
onWorkflowClick(workflow.id, e.shiftKey, e.metaKey || e.ctrlKey)
|
||||
},
|
||||
[shouldPreventClickRef, workflow.id, onWorkflowClick, isEditing]
|
||||
@@ -309,7 +334,17 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
)}
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<Avatars workflowId={workflow.id} maxVisible={3} onPresenceChange={setHasAvatars} />
|
||||
<>
|
||||
<Avatars workflowId={workflow.id} onPresenceChange={setHasAvatars} />
|
||||
<button
|
||||
type='button'
|
||||
onPointerDown={handleMorePointerDown}
|
||||
onClick={handleMoreClick}
|
||||
className='flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center rounded-[4px] opacity-0 transition-opacity hover:bg-[var(--surface-7)] group-hover:opacity-100'
|
||||
>
|
||||
<MoreHorizontal className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
@@ -324,13 +359,17 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
onDuplicate={handleDuplicateWorkflow}
|
||||
onExport={handleExportWorkflow}
|
||||
onDelete={handleOpenDeleteModal}
|
||||
onColorChange={handleColorChange}
|
||||
currentColor={workflow.color}
|
||||
showOpenInNewTab={selectedWorkflows.size <= 1}
|
||||
showRename={selectedWorkflows.size <= 1}
|
||||
showDuplicate={true}
|
||||
showExport={true}
|
||||
showColorChange={selectedWorkflows.size <= 1}
|
||||
disableRename={!userPermissions.canEdit}
|
||||
disableDuplicate={!userPermissions.canEdit}
|
||||
disableExport={!userPermissions.canEdit}
|
||||
disableColorChange={!userPermissions.canEdit}
|
||||
disableDelete={!userPermissions.canEdit || !canDeleteCaptured}
|
||||
/>
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
useDragDrop,
|
||||
useWorkflowSelection,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks/use-import-workflow'
|
||||
import { useFolders } from '@/hooks/queries/folders'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import type { FolderTreeNode } from '@/stores/folders/types'
|
||||
@@ -25,15 +24,13 @@ const TREE_SPACING = {
|
||||
interface WorkflowListProps {
|
||||
regularWorkflows: WorkflowMetadata[]
|
||||
isLoading?: boolean
|
||||
isImporting: boolean
|
||||
setIsImporting: (value: boolean) => void
|
||||
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* WorkflowList component displays workflows organized by folders with drag-and-drop support.
|
||||
* Uses the workflow import hook for handling JSON imports.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Workflow list with folders and drag-drop support
|
||||
@@ -41,8 +38,7 @@ interface WorkflowListProps {
|
||||
export function WorkflowList({
|
||||
regularWorkflows,
|
||||
isLoading = false,
|
||||
isImporting,
|
||||
setIsImporting,
|
||||
handleFileChange,
|
||||
fileInputRef,
|
||||
scrollContainerRef,
|
||||
}: WorkflowListProps) {
|
||||
@@ -65,9 +61,6 @@ export function WorkflowList({
|
||||
createFolderHeaderHoverHandlers,
|
||||
} = useDragDrop()
|
||||
|
||||
// Workflow import hook
|
||||
const { handleFileChange } = useImportWorkflow({ workspaceId })
|
||||
|
||||
// Set scroll container when ref changes
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
|
||||
@@ -657,6 +657,7 @@ 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'
|
||||
|
||||
@@ -27,6 +27,8 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [position, setPosition] = useState<ContextMenuPosition>({ x: 0, y: 0 })
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
// Used to prevent click-outside dismissal when trigger is clicked
|
||||
const dismissPreventedRef = useRef(false)
|
||||
|
||||
/**
|
||||
* Handle right-click event
|
||||
@@ -55,6 +57,14 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
|
||||
setIsOpen(false)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Prevent the next click-outside from dismissing the menu.
|
||||
* Call this on pointerdown of a toggle trigger to allow proper toggle behavior.
|
||||
*/
|
||||
const preventDismiss = useCallback(() => {
|
||||
dismissPreventedRef.current = true
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle clicks outside the menu to close it
|
||||
*/
|
||||
@@ -62,6 +72,11 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
// Check if dismissal was prevented (e.g., by toggle trigger's pointerdown)
|
||||
if (dismissPreventedRef.current) {
|
||||
dismissPreventedRef.current = false
|
||||
return
|
||||
}
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
closeMenu()
|
||||
}
|
||||
@@ -84,5 +99,6 @@ export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
|
||||
menuRef,
|
||||
handleContextMenu,
|
||||
closeMenu,
|
||||
preventDismiss,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useCallback } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { getNextWorkflowColor } from '@/lib/workflows/colors'
|
||||
import { useCreateWorkflow, useWorkflows } from '@/hooks/queries/workflows'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import {
|
||||
generateCreativeWorkflowName,
|
||||
getNextWorkflowColor,
|
||||
} from '@/stores/workflows/registry/utils'
|
||||
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
|
||||
|
||||
const logger = createLogger('useWorkflowOperations')
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ArrowDown, Database, HelpCircle, Layout, Plus, Search, Settings } from 'lucide-react'
|
||||
import { Database, HelpCircle, Layout, Plus, Search, Settings } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation'
|
||||
import { Button, FolderPlus, Library, Tooltip } from '@/components/emcn'
|
||||
import { Button, Download, FolderPlus, Library, Loader, Tooltip } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import {
|
||||
useDuplicateWorkspace,
|
||||
useExportWorkspace,
|
||||
useImportWorkflow,
|
||||
useImportWorkspace,
|
||||
} from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
@@ -85,9 +86,11 @@ export function Sidebar() {
|
||||
const isCollapsed = hasHydrated ? isCollapsedStore : false
|
||||
const isOnWorkflowPage = !!workflowId
|
||||
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const workspaceFileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { isImporting, handleFileChange: handleImportFileChange } = useImportWorkflow({
|
||||
workspaceId,
|
||||
})
|
||||
const { isImporting: isImportingWorkspace, handleImportWorkspace: importWorkspace } =
|
||||
useImportWorkspace()
|
||||
const { handleExportWorkspace: exportWorkspace } = useExportWorkspace()
|
||||
@@ -213,7 +216,7 @@ export function Sidebar() {
|
||||
}, [activeNavItemHref])
|
||||
|
||||
const { handleDuplicateWorkspace: duplicateWorkspace } = useDuplicateWorkspace({
|
||||
getWorkspaceId: () => workspaceId,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
const searchModalWorkflows = useMemo(
|
||||
@@ -565,21 +568,31 @@ export function Sidebar() {
|
||||
Workflows
|
||||
</div>
|
||||
<div className='flex items-center justify-center gap-[10px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='translate-y-[-0.25px] p-[1px]'
|
||||
onClick={handleImportWorkflow}
|
||||
disabled={isImporting || !canEdit}
|
||||
>
|
||||
<ArrowDown className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>{isImporting ? 'Importing workflow...' : 'Import workflow'}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{isImporting ? (
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='translate-y-[-0.25px] p-[1px]'
|
||||
disabled={!canEdit || isImporting}
|
||||
>
|
||||
<Loader className='h-[14px] w-[14px]' animate />
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='translate-y-[-0.25px] p-[1px]'
|
||||
onClick={handleImportWorkflow}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
<Download className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Import workflows</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
@@ -622,8 +635,7 @@ export function Sidebar() {
|
||||
<WorkflowList
|
||||
regularWorkflows={regularWorkflows}
|
||||
isLoading={isLoading}
|
||||
isImporting={isImporting}
|
||||
setIsImporting={setIsImporting}
|
||||
handleFileChange={handleImportFileChange}
|
||||
fileInputRef={fileInputRef}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ export { useDeleteWorkflow } from './use-delete-workflow'
|
||||
export { useDuplicateFolder } from './use-duplicate-folder'
|
||||
export { useDuplicateWorkflow } from './use-duplicate-workflow'
|
||||
export { useDuplicateWorkspace } from './use-duplicate-workspace'
|
||||
export { useExportFolder } from './use-export-folder'
|
||||
export { useExportWorkflow } from './use-export-workflow'
|
||||
export { useExportWorkspace } from './use-export-workspace'
|
||||
export { useImportWorkflow } from './use-import-workflow'
|
||||
|
||||
@@ -11,10 +11,9 @@ interface UseDeleteFolderProps {
|
||||
*/
|
||||
workspaceId: string
|
||||
/**
|
||||
* Function that returns the folder ID(s) to delete
|
||||
* This function is called when deletion occurs to get fresh selection state
|
||||
* The folder ID(s) to delete
|
||||
*/
|
||||
getFolderIds: () => string | string[]
|
||||
folderIds: string | string[]
|
||||
/**
|
||||
* Optional callback after successful deletion
|
||||
*/
|
||||
@@ -24,17 +23,10 @@ interface UseDeleteFolderProps {
|
||||
/**
|
||||
* Hook for managing folder deletion.
|
||||
*
|
||||
* Handles:
|
||||
* - Single or bulk folder deletion
|
||||
* - Calling delete API for each folder
|
||||
* - Loading state management
|
||||
* - Error handling and logging
|
||||
* - Clearing selection after deletion
|
||||
*
|
||||
* @param props - Hook configuration
|
||||
* @returns Delete folder handlers and state
|
||||
*/
|
||||
export function useDeleteFolder({ workspaceId, getFolderIds, onSuccess }: UseDeleteFolderProps) {
|
||||
export function useDeleteFolder({ workspaceId, folderIds, onSuccess }: UseDeleteFolderProps) {
|
||||
const deleteFolderMutation = useDeleteFolderMutation()
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
@@ -46,23 +38,18 @@ export function useDeleteFolder({ workspaceId, getFolderIds, onSuccess }: UseDel
|
||||
return
|
||||
}
|
||||
|
||||
if (!folderIds) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
// Get fresh folder IDs at deletion time
|
||||
const folderIdsOrId = getFolderIds()
|
||||
if (!folderIdsOrId) {
|
||||
return
|
||||
}
|
||||
const folderIdsToDelete = Array.isArray(folderIds) ? folderIds : [folderIds]
|
||||
|
||||
// Normalize to array for consistent handling
|
||||
const folderIdsToDelete = Array.isArray(folderIdsOrId) ? folderIdsOrId : [folderIdsOrId]
|
||||
|
||||
// Delete each folder sequentially
|
||||
for (const folderId of folderIdsToDelete) {
|
||||
await deleteFolderMutation.mutateAsync({ id: folderId, workspaceId })
|
||||
}
|
||||
|
||||
// Clear selection after successful deletion
|
||||
const { clearSelection } = useFolderStore.getState()
|
||||
clearSelection()
|
||||
|
||||
@@ -74,7 +61,7 @@ export function useDeleteFolder({ workspaceId, getFolderIds, onSuccess }: UseDel
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}, [getFolderIds, isDeleting, deleteFolderMutation, workspaceId, onSuccess])
|
||||
}, [folderIds, isDeleting, deleteFolderMutation, workspaceId, onSuccess])
|
||||
|
||||
return {
|
||||
isDeleting,
|
||||
|
||||
@@ -12,10 +12,9 @@ interface UseDeleteWorkflowProps {
|
||||
*/
|
||||
workspaceId: string
|
||||
/**
|
||||
* Function that returns the workflow ID(s) to delete
|
||||
* This function is called when deletion occurs to get fresh selection state
|
||||
* Workflow ID(s) to delete
|
||||
*/
|
||||
getWorkflowIds: () => string | string[]
|
||||
workflowIds: string | string[]
|
||||
/**
|
||||
* Whether the active workflow is being deleted
|
||||
* Can be a boolean or a function that receives the workflow IDs
|
||||
@@ -30,20 +29,12 @@ interface UseDeleteWorkflowProps {
|
||||
/**
|
||||
* Hook for managing workflow deletion with navigation logic.
|
||||
*
|
||||
* Handles:
|
||||
* - Single or bulk workflow deletion
|
||||
* - Finding next workflow to navigate to
|
||||
* - Navigating before deletion (if active workflow)
|
||||
* - Removing workflow(s) from registry
|
||||
* - Loading state management
|
||||
* - Error handling and logging
|
||||
*
|
||||
* @param props - Hook configuration
|
||||
* @returns Delete workflow handlers and state
|
||||
*/
|
||||
export function useDeleteWorkflow({
|
||||
workspaceId,
|
||||
getWorkflowIds,
|
||||
workflowIds,
|
||||
isActive = false,
|
||||
onSuccess,
|
||||
}: UseDeleteWorkflowProps) {
|
||||
@@ -59,30 +50,21 @@ export function useDeleteWorkflow({
|
||||
return
|
||||
}
|
||||
|
||||
if (!workflowIds) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
// Get fresh workflow IDs at deletion time
|
||||
const workflowIdsOrId = getWorkflowIds()
|
||||
if (!workflowIdsOrId) {
|
||||
return
|
||||
}
|
||||
const workflowIdsToDelete = Array.isArray(workflowIds) ? workflowIds : [workflowIds]
|
||||
|
||||
// Normalize to array for consistent handling
|
||||
const workflowIdsToDelete = Array.isArray(workflowIdsOrId)
|
||||
? workflowIdsOrId
|
||||
: [workflowIdsOrId]
|
||||
|
||||
// Determine if active workflow is being deleted
|
||||
const isActiveWorkflowBeingDeleted =
|
||||
typeof isActive === 'function' ? isActive(workflowIdsToDelete) : isActive
|
||||
|
||||
// Find next workflow to navigate to (if active workflow is being deleted)
|
||||
const sidebarWorkflows = Object.values(workflows).filter((w) => w.workspaceId === workspaceId)
|
||||
|
||||
// Find which specific workflow is the active one (if any in the deletion list)
|
||||
let activeWorkflowId: string | null = null
|
||||
if (isActiveWorkflowBeingDeleted && typeof isActive === 'function') {
|
||||
// Check each workflow being deleted to find which one is active
|
||||
activeWorkflowId =
|
||||
workflowIdsToDelete.find((id) => isActive([id])) || workflowIdsToDelete[0]
|
||||
} else {
|
||||
@@ -93,13 +75,11 @@ export function useDeleteWorkflow({
|
||||
|
||||
let nextWorkflowId: string | null = null
|
||||
if (isActiveWorkflowBeingDeleted && sidebarWorkflows.length > workflowIdsToDelete.length) {
|
||||
// Find the first workflow that's not being deleted
|
||||
const remainingWorkflows = sidebarWorkflows.filter(
|
||||
(w) => !workflowIdsToDelete.includes(w.id)
|
||||
)
|
||||
|
||||
if (remainingWorkflows.length > 0) {
|
||||
// Try to find the next workflow after the current one
|
||||
const workflowsAfterCurrent = remainingWorkflows.filter((w) => {
|
||||
const idx = sidebarWorkflows.findIndex((sw) => sw.id === w.id)
|
||||
return idx > currentIndex
|
||||
@@ -108,13 +88,11 @@ export function useDeleteWorkflow({
|
||||
if (workflowsAfterCurrent.length > 0) {
|
||||
nextWorkflowId = workflowsAfterCurrent[0].id
|
||||
} else {
|
||||
// Otherwise, use the first remaining workflow
|
||||
nextWorkflowId = remainingWorkflows[0].id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate first if this is the active workflow
|
||||
if (isActiveWorkflowBeingDeleted) {
|
||||
if (nextWorkflowId) {
|
||||
router.push(`/workspace/${workspaceId}/w/${nextWorkflowId}`)
|
||||
@@ -123,10 +101,8 @@ export function useDeleteWorkflow({
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all workflows
|
||||
await Promise.all(workflowIdsToDelete.map((id) => removeWorkflow(id)))
|
||||
|
||||
// Clear selection after successful deletion
|
||||
const { clearSelection } = useFolderStore.getState()
|
||||
clearSelection()
|
||||
|
||||
@@ -138,16 +114,7 @@ export function useDeleteWorkflow({
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}, [
|
||||
getWorkflowIds,
|
||||
isDeleting,
|
||||
workflows,
|
||||
workspaceId,
|
||||
isActive,
|
||||
router,
|
||||
removeWorkflow,
|
||||
onSuccess,
|
||||
])
|
||||
}, [workflowIds, isDeleting, workflows, workspaceId, isActive, router, removeWorkflow, onSuccess])
|
||||
|
||||
return {
|
||||
isDeleting,
|
||||
|
||||
@@ -7,7 +7,10 @@ const logger = createLogger('useDuplicateFolder')
|
||||
|
||||
interface UseDuplicateFolderProps {
|
||||
workspaceId: string
|
||||
getFolderIds: () => string | string[]
|
||||
/**
|
||||
* The folder ID(s) to duplicate
|
||||
*/
|
||||
folderIds: string | string[]
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
@@ -17,11 +20,7 @@ interface UseDuplicateFolderProps {
|
||||
* @param props - Hook configuration
|
||||
* @returns Duplicate folder handlers and state
|
||||
*/
|
||||
export function useDuplicateFolder({
|
||||
workspaceId,
|
||||
getFolderIds,
|
||||
onSuccess,
|
||||
}: UseDuplicateFolderProps) {
|
||||
export function useDuplicateFolder({ workspaceId, folderIds, onSuccess }: UseDuplicateFolderProps) {
|
||||
const duplicateFolderMutation = useDuplicateFolderMutation()
|
||||
const [isDuplicating, setIsDuplicating] = useState(false)
|
||||
|
||||
@@ -46,21 +45,17 @@ export function useDuplicateFolder({
|
||||
return
|
||||
}
|
||||
|
||||
if (!folderIds) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDuplicating(true)
|
||||
try {
|
||||
// Get fresh folder IDs at duplication time
|
||||
const folderIdsOrId = getFolderIds()
|
||||
if (!folderIdsOrId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize to array for consistent handling
|
||||
const folderIdsToDuplicate = Array.isArray(folderIdsOrId) ? folderIdsOrId : [folderIdsOrId]
|
||||
const folderIdsToDuplicate = Array.isArray(folderIds) ? folderIds : [folderIds]
|
||||
|
||||
const duplicatedIds: string[] = []
|
||||
const folderStore = useFolderStore.getState()
|
||||
|
||||
// Duplicate each folder sequentially
|
||||
for (const folderId of folderIdsToDuplicate) {
|
||||
const folder = folderStore.getFolderById(folderId)
|
||||
|
||||
@@ -72,7 +67,6 @@ export function useDuplicateFolder({
|
||||
const siblingNames = new Set(
|
||||
folderStore.getChildFolders(folder.parentId).map((sibling) => sibling.name)
|
||||
)
|
||||
// Avoid colliding with the original folder name
|
||||
siblingNames.add(folder.name)
|
||||
|
||||
const duplicateName = generateDuplicateName(folder.name, siblingNames)
|
||||
@@ -90,7 +84,6 @@ export function useDuplicateFolder({
|
||||
}
|
||||
}
|
||||
|
||||
// Clear selection after successful duplication
|
||||
const { clearSelection } = useFolderStore.getState()
|
||||
clearSelection()
|
||||
|
||||
@@ -107,7 +100,7 @@ export function useDuplicateFolder({
|
||||
setIsDuplicating(false)
|
||||
}
|
||||
}, [
|
||||
getFolderIds,
|
||||
folderIds,
|
||||
generateDuplicateName,
|
||||
isDuplicating,
|
||||
duplicateFolderMutation,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { getNextWorkflowColor } from '@/lib/workflows/colors'
|
||||
import { useDuplicateWorkflowMutation } from '@/hooks/queries/workflows'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { getNextWorkflowColor } from '@/stores/workflows/registry/utils'
|
||||
|
||||
const logger = createLogger('useDuplicateWorkflow')
|
||||
|
||||
@@ -13,11 +13,6 @@ interface UseDuplicateWorkflowProps {
|
||||
* Current workspace ID
|
||||
*/
|
||||
workspaceId: string
|
||||
/**
|
||||
* Function that returns the workflow ID(s) to duplicate
|
||||
* This function is called when duplication occurs to get fresh selection state
|
||||
*/
|
||||
getWorkflowIds: () => string | string[]
|
||||
/**
|
||||
* Optional callback after successful duplication
|
||||
*/
|
||||
@@ -27,89 +22,72 @@ interface UseDuplicateWorkflowProps {
|
||||
/**
|
||||
* Hook for managing workflow duplication with optimistic updates.
|
||||
*
|
||||
* Handles:
|
||||
* - Single or bulk workflow duplication
|
||||
* - Optimistic UI updates (shows new workflow immediately)
|
||||
* - Automatic rollback on failure
|
||||
* - Loading state management
|
||||
* - Error handling and logging
|
||||
* - Clearing selection after duplication
|
||||
* - Navigation to duplicated workflow (single only)
|
||||
*
|
||||
* @param props - Hook configuration
|
||||
* @returns Duplicate workflow handlers and state
|
||||
*/
|
||||
export function useDuplicateWorkflow({
|
||||
workspaceId,
|
||||
getWorkflowIds,
|
||||
onSuccess,
|
||||
}: UseDuplicateWorkflowProps) {
|
||||
export function useDuplicateWorkflow({ workspaceId, onSuccess }: UseDuplicateWorkflowProps) {
|
||||
const router = useRouter()
|
||||
const { workflows } = useWorkflowRegistry()
|
||||
const duplicateMutation = useDuplicateWorkflowMutation()
|
||||
|
||||
/**
|
||||
* Duplicate the workflow(s)
|
||||
* @param workflowIds - The workflow ID(s) to duplicate
|
||||
*/
|
||||
const handleDuplicateWorkflow = useCallback(async () => {
|
||||
if (duplicateMutation.isPending) {
|
||||
return
|
||||
}
|
||||
const handleDuplicateWorkflow = useCallback(
|
||||
async (workflowIds: string | string[]) => {
|
||||
if (!workflowIds || (Array.isArray(workflowIds) && workflowIds.length === 0)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get fresh workflow IDs at duplication time
|
||||
const workflowIdsOrId = getWorkflowIds()
|
||||
if (!workflowIdsOrId) {
|
||||
return
|
||||
}
|
||||
if (duplicateMutation.isPending) {
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize to array for consistent handling
|
||||
const workflowIdsToDuplicate = Array.isArray(workflowIdsOrId)
|
||||
? workflowIdsOrId
|
||||
: [workflowIdsOrId]
|
||||
const workflowIdsToDuplicate = Array.isArray(workflowIds) ? workflowIds : [workflowIds]
|
||||
|
||||
const duplicatedIds: string[] = []
|
||||
const duplicatedIds: string[] = []
|
||||
|
||||
try {
|
||||
// Duplicate each workflow sequentially
|
||||
for (const sourceId of workflowIdsToDuplicate) {
|
||||
const sourceWorkflow = workflows[sourceId]
|
||||
if (!sourceWorkflow) {
|
||||
logger.warn(`Workflow ${sourceId} not found, skipping`)
|
||||
continue
|
||||
try {
|
||||
for (const sourceId of workflowIdsToDuplicate) {
|
||||
const sourceWorkflow = workflows[sourceId]
|
||||
if (!sourceWorkflow) {
|
||||
logger.warn(`Workflow ${sourceId} not found, skipping`)
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await duplicateMutation.mutateAsync({
|
||||
workspaceId,
|
||||
sourceId,
|
||||
name: `${sourceWorkflow.name} (Copy)`,
|
||||
description: sourceWorkflow.description,
|
||||
color: getNextWorkflowColor(),
|
||||
folderId: sourceWorkflow.folderId,
|
||||
})
|
||||
|
||||
duplicatedIds.push(result.id)
|
||||
}
|
||||
|
||||
const result = await duplicateMutation.mutateAsync({
|
||||
workspaceId,
|
||||
sourceId,
|
||||
name: `${sourceWorkflow.name} (Copy)`,
|
||||
description: sourceWorkflow.description,
|
||||
color: getNextWorkflowColor(),
|
||||
folderId: sourceWorkflow.folderId,
|
||||
const { clearSelection } = useFolderStore.getState()
|
||||
clearSelection()
|
||||
|
||||
logger.info('Workflow(s) duplicated successfully', {
|
||||
workflowIds: workflowIdsToDuplicate,
|
||||
duplicatedIds,
|
||||
})
|
||||
|
||||
duplicatedIds.push(result.id)
|
||||
if (duplicatedIds.length === 1) {
|
||||
router.push(`/workspace/${workspaceId}/w/${duplicatedIds[0]}`)
|
||||
}
|
||||
|
||||
onSuccess?.()
|
||||
} catch (error) {
|
||||
logger.error('Error duplicating workflow(s):', { error })
|
||||
throw error
|
||||
}
|
||||
|
||||
// Clear selection after successful duplication
|
||||
const { clearSelection } = useFolderStore.getState()
|
||||
clearSelection()
|
||||
|
||||
logger.info('Workflow(s) duplicated successfully', {
|
||||
workflowIds: workflowIdsToDuplicate,
|
||||
duplicatedIds,
|
||||
})
|
||||
|
||||
// Navigate to duplicated workflow if single duplication
|
||||
if (duplicatedIds.length === 1) {
|
||||
router.push(`/workspace/${workspaceId}/w/${duplicatedIds[0]}`)
|
||||
}
|
||||
|
||||
onSuccess?.()
|
||||
} catch (error) {
|
||||
logger.error('Error duplicating workflow(s):', { error })
|
||||
throw error
|
||||
}
|
||||
}, [getWorkflowIds, duplicateMutation, workflows, workspaceId, router, onSuccess])
|
||||
},
|
||||
[duplicateMutation, workflows, workspaceId, router, onSuccess]
|
||||
)
|
||||
|
||||
return {
|
||||
isDuplicating: duplicateMutation.isPending,
|
||||
|
||||
@@ -6,10 +6,9 @@ const logger = createLogger('useDuplicateWorkspace')
|
||||
|
||||
interface UseDuplicateWorkspaceProps {
|
||||
/**
|
||||
* Function that returns the workspace ID to duplicate
|
||||
* This function is called when duplication occurs to get fresh state
|
||||
* The workspace ID to duplicate
|
||||
*/
|
||||
getWorkspaceId: () => string | null
|
||||
workspaceId: string | null
|
||||
/**
|
||||
* Optional callback after successful duplication
|
||||
*/
|
||||
@@ -19,17 +18,10 @@ interface UseDuplicateWorkspaceProps {
|
||||
/**
|
||||
* Hook for managing workspace duplication.
|
||||
*
|
||||
* Handles:
|
||||
* - Workspace duplication
|
||||
* - Calling duplicate API
|
||||
* - Loading state management
|
||||
* - Error handling and logging
|
||||
* - Navigation to duplicated workspace
|
||||
*
|
||||
* @param props - Hook configuration
|
||||
* @returns Duplicate workspace handlers and state
|
||||
*/
|
||||
export function useDuplicateWorkspace({ getWorkspaceId, onSuccess }: UseDuplicateWorkspaceProps) {
|
||||
export function useDuplicateWorkspace({ workspaceId, onSuccess }: UseDuplicateWorkspaceProps) {
|
||||
const router = useRouter()
|
||||
const [isDuplicating, setIsDuplicating] = useState(false)
|
||||
|
||||
@@ -38,18 +30,12 @@ export function useDuplicateWorkspace({ getWorkspaceId, onSuccess }: UseDuplicat
|
||||
*/
|
||||
const handleDuplicateWorkspace = useCallback(
|
||||
async (workspaceName: string) => {
|
||||
if (isDuplicating) {
|
||||
if (isDuplicating || !workspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDuplicating(true)
|
||||
try {
|
||||
// Get fresh workspace ID at duplication time
|
||||
const workspaceId = getWorkspaceId()
|
||||
if (!workspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}/duplicate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -70,7 +56,6 @@ export function useDuplicateWorkspace({ getWorkspaceId, onSuccess }: UseDuplicat
|
||||
workflowsCount: duplicatedWorkspace.workflowsCount,
|
||||
})
|
||||
|
||||
// Navigate to duplicated workspace
|
||||
router.push(`/workspace/${duplicatedWorkspace.id}/w`)
|
||||
|
||||
onSuccess?.()
|
||||
@@ -83,7 +68,7 @@ export function useDuplicateWorkspace({ getWorkspaceId, onSuccess }: UseDuplicat
|
||||
setIsDuplicating(false)
|
||||
}
|
||||
},
|
||||
[getWorkspaceId, isDuplicating, router, onSuccess]
|
||||
[workspaceId, isDuplicating, router, onSuccess]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import JSZip from 'jszip'
|
||||
import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
import type { Variable } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('useExportFolder')
|
||||
|
||||
interface UseExportFolderProps {
|
||||
/**
|
||||
* Current workspace ID
|
||||
*/
|
||||
workspaceId: string
|
||||
/**
|
||||
* The folder ID to export
|
||||
*/
|
||||
folderId: string
|
||||
/**
|
||||
* Optional callback after successful export
|
||||
*/
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collects all workflow IDs within a folder and its subfolders.
|
||||
*
|
||||
* @param folderId - The folder ID to collect workflows from
|
||||
* @param workflows - All workflows in the workspace
|
||||
* @param folders - All folders in the workspace
|
||||
* @returns Array of workflow IDs
|
||||
*/
|
||||
function collectWorkflowsInFolder(
|
||||
folderId: string,
|
||||
workflows: Record<string, WorkflowMetadata>,
|
||||
folders: Record<string, WorkflowFolder>
|
||||
): string[] {
|
||||
const workflowIds: string[] = []
|
||||
|
||||
for (const workflow of Object.values(workflows)) {
|
||||
if (workflow.folderId === folderId) {
|
||||
workflowIds.push(workflow.id)
|
||||
}
|
||||
}
|
||||
|
||||
for (const folder of Object.values(folders)) {
|
||||
if (folder.parentId === folderId) {
|
||||
const childWorkflowIds = collectWorkflowsInFolder(folder.id, workflows, folders)
|
||||
workflowIds.push(...childWorkflowIds)
|
||||
}
|
||||
}
|
||||
|
||||
return workflowIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing folder export to ZIP.
|
||||
*
|
||||
* @param props - Hook configuration
|
||||
* @returns Export folder handlers and state
|
||||
*/
|
||||
export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportFolderProps) {
|
||||
const { workflows } = useWorkflowRegistry()
|
||||
const { folders } = useFolderStore()
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
/**
|
||||
* Check if the folder has any workflows (recursively)
|
||||
*/
|
||||
const hasWorkflows = useMemo(() => {
|
||||
if (!folderId) return false
|
||||
return collectWorkflowsInFolder(folderId, workflows, folders).length > 0
|
||||
}, [folderId, workflows, folders])
|
||||
|
||||
/**
|
||||
* Download file helper
|
||||
*/
|
||||
const downloadFile = (content: Blob, filename: string, mimeType = 'application/zip') => {
|
||||
try {
|
||||
const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
logger.error('Failed to download file:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all workflows in the folder (including nested subfolders) to ZIP
|
||||
*/
|
||||
const handleExportFolder = useCallback(async () => {
|
||||
if (isExporting) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!folderId) {
|
||||
logger.warn('No folder ID provided for export')
|
||||
return
|
||||
}
|
||||
|
||||
setIsExporting(true)
|
||||
try {
|
||||
const folderStore = useFolderStore.getState()
|
||||
const folder = folderStore.getFolderById(folderId)
|
||||
|
||||
if (!folder) {
|
||||
logger.warn('Folder not found for export', { folderId })
|
||||
return
|
||||
}
|
||||
|
||||
const workflowIdsToExport = collectWorkflowsInFolder(folderId, workflows, folderStore.folders)
|
||||
|
||||
if (workflowIdsToExport.length === 0) {
|
||||
logger.warn('No workflows found in folder to export', { folderId, folderName: folder.name })
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('Starting folder export', {
|
||||
folderId,
|
||||
folderName: folder.name,
|
||||
workflowCount: workflowIdsToExport.length,
|
||||
})
|
||||
|
||||
const exportedWorkflows: Array<{ name: string; content: string }> = []
|
||||
|
||||
for (const workflowId of workflowIdsToExport) {
|
||||
try {
|
||||
const workflow = workflows[workflowId]
|
||||
if (!workflow) {
|
||||
logger.warn(`Workflow ${workflowId} not found in registry`)
|
||||
continue
|
||||
}
|
||||
|
||||
const workflowResponse = await fetch(`/api/workflows/${workflowId}`)
|
||||
if (!workflowResponse.ok) {
|
||||
logger.error(`Failed to fetch workflow ${workflowId}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const { data: workflowData } = await workflowResponse.json()
|
||||
if (!workflowData?.state) {
|
||||
logger.warn(`Workflow ${workflowId} has no state`)
|
||||
continue
|
||||
}
|
||||
|
||||
const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`)
|
||||
let workflowVariables: Record<string, Variable> | undefined
|
||||
if (variablesResponse.ok) {
|
||||
const variablesData = await variablesResponse.json()
|
||||
workflowVariables = variablesData?.data
|
||||
}
|
||||
|
||||
const workflowState = {
|
||||
...workflowData.state,
|
||||
metadata: {
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
color: workflow.color,
|
||||
exportedAt: new Date().toISOString(),
|
||||
},
|
||||
variables: workflowVariables,
|
||||
}
|
||||
|
||||
const exportState = sanitizeForExport(workflowState)
|
||||
const jsonString = JSON.stringify(exportState, null, 2)
|
||||
|
||||
exportedWorkflows.push({
|
||||
name: workflow.name,
|
||||
content: jsonString,
|
||||
})
|
||||
|
||||
logger.info(`Workflow ${workflowId} exported successfully`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to export workflow ${workflowId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (exportedWorkflows.length === 0) {
|
||||
logger.warn('No workflows were successfully exported from folder', {
|
||||
folderId,
|
||||
folderName: folder.name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const zip = new JSZip()
|
||||
const seenFilenames = new Set<string>()
|
||||
|
||||
for (const exportedWorkflow of exportedWorkflows) {
|
||||
const baseName = exportedWorkflow.name.replace(/[^a-z0-9]/gi, '-')
|
||||
let filename = `${baseName}.json`
|
||||
let counter = 1
|
||||
while (seenFilenames.has(filename.toLowerCase())) {
|
||||
filename = `${baseName}-${counter}.json`
|
||||
counter++
|
||||
}
|
||||
seenFilenames.add(filename.toLowerCase())
|
||||
zip.file(filename, exportedWorkflow.content)
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' })
|
||||
const zipFilename = `${folder.name.replace(/[^a-z0-9]/gi, '-')}-export.zip`
|
||||
downloadFile(zipBlob, zipFilename, 'application/zip')
|
||||
|
||||
const { clearSelection } = useFolderStore.getState()
|
||||
clearSelection()
|
||||
|
||||
logger.info('Folder exported successfully', {
|
||||
folderId,
|
||||
folderName: folder.name,
|
||||
workflowCount: exportedWorkflows.length,
|
||||
})
|
||||
|
||||
onSuccess?.()
|
||||
} catch (error) {
|
||||
logger.error('Error exporting folder:', { error })
|
||||
throw error
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}, [folderId, isExporting, workflows, folders, onSuccess])
|
||||
|
||||
return {
|
||||
isExporting,
|
||||
hasWorkflows,
|
||||
handleExportFolder,
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,6 @@ interface UseExportWorkflowProps {
|
||||
* Current workspace ID
|
||||
*/
|
||||
workspaceId: string
|
||||
/**
|
||||
* Function that returns the workflow ID(s) to export
|
||||
* This function is called when export occurs to get fresh selection state
|
||||
*/
|
||||
getWorkflowIds: () => string | string[]
|
||||
/**
|
||||
* Optional callback after successful export
|
||||
*/
|
||||
@@ -27,23 +22,10 @@ interface UseExportWorkflowProps {
|
||||
/**
|
||||
* Hook for managing workflow export to JSON.
|
||||
*
|
||||
* Handles:
|
||||
* - Single or bulk workflow export
|
||||
* - Fetching workflow data and variables from API
|
||||
* - Sanitizing workflow state for export
|
||||
* - Downloading as JSON file(s)
|
||||
* - Loading state management
|
||||
* - Error handling and logging
|
||||
* - Clearing selection after export
|
||||
*
|
||||
* @param props - Hook configuration
|
||||
* @returns Export workflow handlers and state
|
||||
*/
|
||||
export function useExportWorkflow({
|
||||
workspaceId,
|
||||
getWorkflowIds,
|
||||
onSuccess,
|
||||
}: UseExportWorkflowProps) {
|
||||
export function useExportWorkflow({ workspaceId, onSuccess }: UseExportWorkflowProps) {
|
||||
const { workflows } = useWorkflowRegistry()
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
@@ -75,130 +57,129 @@ export function useExportWorkflow({
|
||||
* - Single workflow: exports as JSON file
|
||||
* - Multiple workflows: exports as ZIP file containing all JSON files
|
||||
* Fetches workflow data from API to support bulk export of non-active workflows
|
||||
* @param workflowIds - The workflow ID(s) to export
|
||||
*/
|
||||
const handleExportWorkflow = useCallback(async () => {
|
||||
if (isExporting) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsExporting(true)
|
||||
try {
|
||||
// Get fresh workflow IDs at export time
|
||||
const workflowIdsOrId = getWorkflowIds()
|
||||
if (!workflowIdsOrId) {
|
||||
const handleExportWorkflow = useCallback(
|
||||
async (workflowIds: string | string[]) => {
|
||||
if (isExporting) {
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize to array for consistent handling
|
||||
const workflowIdsToExport = Array.isArray(workflowIdsOrId)
|
||||
? workflowIdsOrId
|
||||
: [workflowIdsOrId]
|
||||
if (!workflowIds || (Array.isArray(workflowIds) && workflowIds.length === 0)) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('Starting workflow export', {
|
||||
workflowIdsToExport,
|
||||
count: workflowIdsToExport.length,
|
||||
})
|
||||
setIsExporting(true)
|
||||
try {
|
||||
const workflowIdsToExport = Array.isArray(workflowIds) ? workflowIds : [workflowIds]
|
||||
|
||||
const exportedWorkflows: Array<{ name: string; content: string }> = []
|
||||
logger.info('Starting workflow export', {
|
||||
workflowIdsToExport,
|
||||
count: workflowIdsToExport.length,
|
||||
})
|
||||
|
||||
// Export each workflow
|
||||
for (const workflowId of workflowIdsToExport) {
|
||||
try {
|
||||
const workflow = workflows[workflowId]
|
||||
if (!workflow) {
|
||||
logger.warn(`Workflow ${workflowId} not found in registry`)
|
||||
continue
|
||||
}
|
||||
const exportedWorkflows: Array<{ name: string; content: string }> = []
|
||||
|
||||
// Fetch workflow state from API
|
||||
const workflowResponse = await fetch(`/api/workflows/${workflowId}`)
|
||||
if (!workflowResponse.ok) {
|
||||
logger.error(`Failed to fetch workflow ${workflowId}`)
|
||||
continue
|
||||
}
|
||||
for (const workflowId of workflowIdsToExport) {
|
||||
try {
|
||||
const workflow = workflows[workflowId]
|
||||
if (!workflow) {
|
||||
logger.warn(`Workflow ${workflowId} not found in registry`)
|
||||
continue
|
||||
}
|
||||
|
||||
const { data: workflowData } = await workflowResponse.json()
|
||||
if (!workflowData?.state) {
|
||||
logger.warn(`Workflow ${workflowId} has no state`)
|
||||
continue
|
||||
}
|
||||
const workflowResponse = await fetch(`/api/workflows/${workflowId}`)
|
||||
if (!workflowResponse.ok) {
|
||||
logger.error(`Failed to fetch workflow ${workflowId}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Fetch workflow variables (API returns Record format directly)
|
||||
const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`)
|
||||
let workflowVariables: Record<string, Variable> | undefined
|
||||
if (variablesResponse.ok) {
|
||||
const variablesData = await variablesResponse.json()
|
||||
workflowVariables = variablesData?.data
|
||||
}
|
||||
const { data: workflowData } = await workflowResponse.json()
|
||||
if (!workflowData?.state) {
|
||||
logger.warn(`Workflow ${workflowId} has no state`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Prepare export state
|
||||
const workflowState = {
|
||||
...workflowData.state,
|
||||
metadata: {
|
||||
const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`)
|
||||
let workflowVariables: Record<string, Variable> | undefined
|
||||
if (variablesResponse.ok) {
|
||||
const variablesData = await variablesResponse.json()
|
||||
workflowVariables = variablesData?.data
|
||||
}
|
||||
|
||||
const workflowState = {
|
||||
...workflowData.state,
|
||||
metadata: {
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
color: workflow.color,
|
||||
exportedAt: new Date().toISOString(),
|
||||
},
|
||||
variables: workflowVariables,
|
||||
}
|
||||
|
||||
const exportState = sanitizeForExport(workflowState)
|
||||
const jsonString = JSON.stringify(exportState, null, 2)
|
||||
|
||||
exportedWorkflows.push({
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
color: workflow.color,
|
||||
exportedAt: new Date().toISOString(),
|
||||
},
|
||||
variables: workflowVariables,
|
||||
content: jsonString,
|
||||
})
|
||||
|
||||
logger.info(`Workflow ${workflowId} exported successfully`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to export workflow ${workflowId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (exportedWorkflows.length === 0) {
|
||||
logger.warn('No workflows were successfully exported')
|
||||
return
|
||||
}
|
||||
|
||||
if (exportedWorkflows.length === 1) {
|
||||
const filename = `${exportedWorkflows[0].name.replace(/[^a-z0-9]/gi, '-')}.json`
|
||||
downloadFile(exportedWorkflows[0].content, filename, 'application/json')
|
||||
} else {
|
||||
const zip = new JSZip()
|
||||
const seenFilenames = new Set<string>()
|
||||
|
||||
for (const exportedWorkflow of exportedWorkflows) {
|
||||
const baseName = exportedWorkflow.name.replace(/[^a-z0-9]/gi, '-')
|
||||
let filename = `${baseName}.json`
|
||||
let counter = 1
|
||||
while (seenFilenames.has(filename.toLowerCase())) {
|
||||
filename = `${baseName}-${counter}.json`
|
||||
counter++
|
||||
}
|
||||
seenFilenames.add(filename.toLowerCase())
|
||||
zip.file(filename, exportedWorkflow.content)
|
||||
}
|
||||
|
||||
const exportState = sanitizeForExport(workflowState)
|
||||
const jsonString = JSON.stringify(exportState, null, 2)
|
||||
|
||||
exportedWorkflows.push({
|
||||
name: workflow.name,
|
||||
content: jsonString,
|
||||
})
|
||||
|
||||
logger.info(`Workflow ${workflowId} exported successfully`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to export workflow ${workflowId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (exportedWorkflows.length === 0) {
|
||||
logger.warn('No workflows were successfully exported')
|
||||
return
|
||||
}
|
||||
|
||||
// Download as single JSON or ZIP depending on count
|
||||
if (exportedWorkflows.length === 1) {
|
||||
// Single workflow - download as JSON
|
||||
const filename = `${exportedWorkflows[0].name.replace(/[^a-z0-9]/gi, '-')}.json`
|
||||
downloadFile(exportedWorkflows[0].content, filename, 'application/json')
|
||||
} else {
|
||||
// Multiple workflows - download as ZIP
|
||||
const zip = new JSZip()
|
||||
|
||||
for (const exportedWorkflow of exportedWorkflows) {
|
||||
const filename = `${exportedWorkflow.name.replace(/[^a-z0-9]/gi, '-')}.json`
|
||||
zip.file(filename, exportedWorkflow.content)
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' })
|
||||
const zipFilename = `workflows-export-${Date.now()}.zip`
|
||||
downloadFile(zipBlob, zipFilename, 'application/zip')
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' })
|
||||
const zipFilename = `workflows-export-${Date.now()}.zip`
|
||||
downloadFile(zipBlob, zipFilename, 'application/zip')
|
||||
const { clearSelection } = useFolderStore.getState()
|
||||
clearSelection()
|
||||
|
||||
logger.info('Workflow(s) exported successfully', {
|
||||
workflowIds: workflowIdsToExport,
|
||||
count: exportedWorkflows.length,
|
||||
format: exportedWorkflows.length === 1 ? 'JSON' : 'ZIP',
|
||||
})
|
||||
|
||||
onSuccess?.()
|
||||
} catch (error) {
|
||||
logger.error('Error exporting workflow(s):', { error })
|
||||
throw error
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
|
||||
// Clear selection after successful export
|
||||
const { clearSelection } = useFolderStore.getState()
|
||||
clearSelection()
|
||||
|
||||
logger.info('Workflow(s) exported successfully', {
|
||||
workflowIds: workflowIdsToExport,
|
||||
count: exportedWorkflows.length,
|
||||
format: exportedWorkflows.length === 1 ? 'JSON' : 'ZIP',
|
||||
})
|
||||
|
||||
onSuccess?.()
|
||||
} catch (error) {
|
||||
logger.error('Error exporting workflow(s):', { error })
|
||||
throw error
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}, [getWorkflowIds, isExporting, workflows, onSuccess])
|
||||
},
|
||||
[isExporting, workflows, onSuccess]
|
||||
)
|
||||
|
||||
return {
|
||||
isExporting,
|
||||
|
||||
@@ -44,21 +44,18 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {})
|
||||
try {
|
||||
logger.info('Exporting workspace', { workspaceId })
|
||||
|
||||
// Fetch all workflows in workspace
|
||||
const workflowsResponse = await fetch(`/api/workflows?workspaceId=${workspaceId}`)
|
||||
if (!workflowsResponse.ok) {
|
||||
throw new Error('Failed to fetch workflows')
|
||||
}
|
||||
const { data: workflows } = await workflowsResponse.json()
|
||||
|
||||
// Fetch all folders in workspace
|
||||
const foldersResponse = await fetch(`/api/folders?workspaceId=${workspaceId}`)
|
||||
if (!foldersResponse.ok) {
|
||||
throw new Error('Failed to fetch folders')
|
||||
}
|
||||
const foldersData = await foldersResponse.json()
|
||||
|
||||
// Export each workflow
|
||||
const workflowsToExport: WorkflowExportData[] = []
|
||||
|
||||
for (const workflow of workflows) {
|
||||
|
||||
@@ -33,6 +33,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
const createWorkflowMutation = useCreateWorkflow()
|
||||
const queryClient = useQueryClient()
|
||||
const createFolderMutation = useCreateFolder()
|
||||
const clearDiff = useWorkflowDiffStore((state) => state.clearDiff)
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
|
||||
/**
|
||||
@@ -48,9 +49,8 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
}
|
||||
|
||||
const workflowName = extractWorkflowName(content, filename)
|
||||
useWorkflowDiffStore.getState().clearDiff()
|
||||
clearDiff()
|
||||
|
||||
// Extract color from metadata
|
||||
const parsedContent = JSON.parse(content)
|
||||
const workflowColor =
|
||||
parsedContent.state?.metadata?.color || parsedContent.metadata?.color || '#3972F6'
|
||||
@@ -63,7 +63,6 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
})
|
||||
const newWorkflowId = result.id
|
||||
|
||||
// Update workflow color if we extracted one
|
||||
if (workflowColor !== '#3972F6') {
|
||||
await fetch(`/api/workflows/${newWorkflowId}`, {
|
||||
method: 'PATCH',
|
||||
@@ -72,16 +71,13 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
})
|
||||
}
|
||||
|
||||
// Save workflow state
|
||||
await fetch(`/api/workflows/${newWorkflowId}/state`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(workflowData),
|
||||
})
|
||||
|
||||
// Save variables if any (handle both legacy Array and current Record formats)
|
||||
if (workflowData.variables) {
|
||||
// Convert to Record format for API (handles backwards compatibility with old Array exports)
|
||||
const variablesArray = Array.isArray(workflowData.variables)
|
||||
? workflowData.variables
|
||||
: Object.values(workflowData.variables)
|
||||
@@ -114,7 +110,7 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
logger.info(`Imported workflow: ${workflowName}`)
|
||||
return newWorkflowId
|
||||
},
|
||||
[createWorkflowMutation, workspaceId]
|
||||
[clearDiff, createWorkflowMutation, workspaceId]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -134,7 +130,6 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
const importedWorkflowIds: string[] = []
|
||||
|
||||
if (hasZip && fileArray.length === 1) {
|
||||
// Import from ZIP - preserves folder structure
|
||||
const zipFile = fileArray[0]
|
||||
const { workflows: extractedWorkflows, metadata } = await extractWorkflowsFromZip(zipFile)
|
||||
|
||||
@@ -149,7 +144,6 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
try {
|
||||
let targetFolderId = importFolder.id
|
||||
|
||||
// Recreate nested folder structure
|
||||
if (workflow.folderPath.length > 0) {
|
||||
const folderPathKey = workflow.folderPath.join('/')
|
||||
|
||||
@@ -187,7 +181,6 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
}
|
||||
}
|
||||
} else if (jsonFiles.length > 0) {
|
||||
// Import multiple JSON files or single JSON
|
||||
const extractedWorkflows = await extractWorkflowsFromFiles(jsonFiles)
|
||||
|
||||
for (const workflow of extractedWorkflows) {
|
||||
@@ -200,22 +193,21 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Reload workflows and folders to show newly imported ones
|
||||
await queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId) })
|
||||
await queryClient.invalidateQueries({ queryKey: folderKeys.list(workspaceId) })
|
||||
|
||||
logger.info(`Import complete. Imported ${importedWorkflowIds.length} workflow(s)`)
|
||||
|
||||
// Navigate to first imported workflow if any
|
||||
if (importedWorkflowIds.length > 0) {
|
||||
router.push(`/workspace/${workspaceId}/w/${importedWorkflowIds[0]}`)
|
||||
router.push(
|
||||
`/workspace/${workspaceId}/w/${importedWorkflowIds[importedWorkflowIds.length - 1]}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to import workflows:', error)
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
|
||||
// Reset file input
|
||||
if (event.target) {
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
@@ -21,15 +21,6 @@ interface UseImportWorkspaceProps {
|
||||
/**
|
||||
* Hook for managing workspace import from ZIP files.
|
||||
*
|
||||
* Handles:
|
||||
* - Extracting workflows from ZIP file
|
||||
* - Creating new workspace
|
||||
* - Recreating folder structure
|
||||
* - Importing all workflows with states and variables
|
||||
* - Navigation to imported workspace
|
||||
* - Loading state management
|
||||
* - Error handling and logging
|
||||
*
|
||||
* @param props - Hook configuration
|
||||
* @returns Import workspace handlers and state
|
||||
*/
|
||||
@@ -37,6 +28,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
const router = useRouter()
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const createFolderMutation = useCreateFolder()
|
||||
const clearDiff = useWorkflowDiffStore((state) => state.clearDiff)
|
||||
|
||||
/**
|
||||
* Handle workspace import from ZIP file
|
||||
@@ -56,7 +48,6 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
try {
|
||||
logger.info('Importing workspace from ZIP')
|
||||
|
||||
// Extract workflows from ZIP
|
||||
const { workflows: extractedWorkflows, metadata } = await extractWorkflowsFromZip(zipFile)
|
||||
|
||||
if (extractedWorkflows.length === 0) {
|
||||
@@ -64,7 +55,6 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
return
|
||||
}
|
||||
|
||||
// Create new workspace
|
||||
const workspaceName = metadata?.workspaceName || zipFile.name.replace(/\.zip$/i, '')
|
||||
const createResponse = await fetch('/api/workspaces', {
|
||||
method: 'POST',
|
||||
@@ -81,7 +71,6 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
|
||||
const folderMap = new Map<string, string>()
|
||||
|
||||
// Import workflows
|
||||
for (const workflow of extractedWorkflows) {
|
||||
try {
|
||||
const { data: workflowData, errors: parseErrors } = parseWorkflowJson(workflow.content)
|
||||
@@ -91,7 +80,6 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
continue
|
||||
}
|
||||
|
||||
// Recreate folder structure
|
||||
let targetFolderId: string | null = null
|
||||
if (workflow.folderPath.length > 0) {
|
||||
const folderPathKey = workflow.folderPath.join('/')
|
||||
@@ -120,14 +108,12 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
}
|
||||
|
||||
const workflowName = extractWorkflowName(workflow.content, workflow.name)
|
||||
useWorkflowDiffStore.getState().clearDiff()
|
||||
clearDiff()
|
||||
|
||||
// Extract color from workflow metadata
|
||||
const parsedContent = JSON.parse(workflow.content)
|
||||
const workflowColor =
|
||||
parsedContent.state?.metadata?.color || parsedContent.metadata?.color || '#3972F6'
|
||||
|
||||
// Create workflow
|
||||
const createWorkflowResponse = await fetch('/api/workflows', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -147,7 +133,6 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
|
||||
const newWorkflow = await createWorkflowResponse.json()
|
||||
|
||||
// Save workflow state
|
||||
const stateResponse = await fetch(`/api/workflows/${newWorkflow.id}/state`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -159,9 +144,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
continue
|
||||
}
|
||||
|
||||
// Save variables if any (handle both legacy Array and current Record formats)
|
||||
if (workflowData.variables) {
|
||||
// Convert to Record format for API (handles backwards compatibility with old Array exports)
|
||||
const variablesArray = Array.isArray(workflowData.variables)
|
||||
? workflowData.variables
|
||||
: Object.values(workflowData.variables)
|
||||
@@ -199,7 +182,6 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
|
||||
logger.info(`Workspace import complete. Imported ${extractedWorkflows.length} workflows`)
|
||||
|
||||
// Navigate to new workspace
|
||||
router.push(`/workspace/${newWorkspace.id}/w`)
|
||||
|
||||
onSuccess?.()
|
||||
@@ -210,7 +192,7 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {})
|
||||
setIsImporting(false)
|
||||
}
|
||||
},
|
||||
[isImporting, router, onSuccess, createFolderMutation]
|
||||
[isImporting, router, onSuccess, createFolderMutation, clearDiff]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -12,11 +12,10 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
const avatarVariants = cva('relative flex shrink-0 overflow-hidden rounded-full', {
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'h-6 w-6',
|
||||
sm: 'h-8 w-8',
|
||||
md: 'h-10 w-10',
|
||||
lg: 'h-12 w-12',
|
||||
xl: 'h-16 w-16',
|
||||
xs: 'h-3.5 w-3.5',
|
||||
sm: 'h-6 w-6',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -38,11 +37,10 @@ const avatarStatusVariants = cva(
|
||||
away: 'bg-[#f59e0b]',
|
||||
},
|
||||
size: {
|
||||
xs: 'h-2 w-2',
|
||||
sm: 'h-2.5 w-2.5',
|
||||
md: 'h-3 w-3',
|
||||
lg: 'h-3.5 w-3.5',
|
||||
xl: 'h-4 w-4',
|
||||
xs: 'h-1.5 w-1.5 border',
|
||||
sm: 'h-2 w-2',
|
||||
md: 'h-2.5 w-2.5',
|
||||
lg: 'h-3 w-3',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
import * as React from 'react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
import { Check, ChevronLeft, ChevronRight, Search } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
type PopoverSize = 'sm' | 'md'
|
||||
@@ -166,6 +167,9 @@ interface PopoverContextValue {
|
||||
colorScheme: PopoverColorScheme
|
||||
searchQuery: string
|
||||
setSearchQuery: (query: string) => void
|
||||
/** ID of the last hovered item (for hover submenus) */
|
||||
lastHoveredItem: string | null
|
||||
setLastHoveredItem: (id: string | null) => void
|
||||
}
|
||||
|
||||
const PopoverContext = React.createContext<PopoverContextValue | null>(null)
|
||||
@@ -208,12 +212,24 @@ const Popover: React.FC<PopoverProps> = ({
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
colorScheme = 'default',
|
||||
open,
|
||||
...props
|
||||
}) => {
|
||||
const [currentFolder, setCurrentFolder] = React.useState<string | null>(null)
|
||||
const [folderTitle, setFolderTitle] = React.useState<string | null>(null)
|
||||
const [onFolderSelect, setOnFolderSelect] = React.useState<(() => void) | null>(null)
|
||||
const [searchQuery, setSearchQuery] = React.useState<string>('')
|
||||
const [lastHoveredItem, setLastHoveredItem] = React.useState<string | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open === false) {
|
||||
setCurrentFolder(null)
|
||||
setFolderTitle(null)
|
||||
setOnFolderSelect(null)
|
||||
setSearchQuery('')
|
||||
setLastHoveredItem(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const openFolder = React.useCallback(
|
||||
(id: string, title: string, onLoad?: () => void | Promise<void>, onSelect?: () => void) => {
|
||||
@@ -246,6 +262,8 @@ const Popover: React.FC<PopoverProps> = ({
|
||||
colorScheme,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
lastHoveredItem,
|
||||
setLastHoveredItem,
|
||||
}),
|
||||
[
|
||||
openFolder,
|
||||
@@ -257,12 +275,15 @@ const Popover: React.FC<PopoverProps> = ({
|
||||
size,
|
||||
colorScheme,
|
||||
searchQuery,
|
||||
lastHoveredItem,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<PopoverContext.Provider value={contextValue}>
|
||||
<PopoverPrimitive.Root {...props}>{children}</PopoverPrimitive.Root>
|
||||
<PopoverPrimitive.Root open={open} {...props}>
|
||||
{children}
|
||||
</PopoverPrimitive.Root>
|
||||
</PopoverContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -496,7 +517,17 @@ export interface PopoverItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
*/
|
||||
const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
|
||||
(
|
||||
{ className, active, rootOnly, disabled, showCheck = false, children, onClick, ...props },
|
||||
{
|
||||
className,
|
||||
active,
|
||||
rootOnly,
|
||||
disabled,
|
||||
showCheck = false,
|
||||
children,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const context = React.useContext(PopoverContext)
|
||||
@@ -514,6 +545,12 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
|
||||
onClick?.(e)
|
||||
}
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Clear last hovered item to close any open hover submenus
|
||||
context?.setLastHoveredItem(null)
|
||||
onMouseEnter?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -529,6 +566,7 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
|
||||
aria-selected={active}
|
||||
aria-disabled={disabled}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -589,44 +627,150 @@ export interface PopoverFolderProps extends Omit<React.HTMLAttributes<HTMLDivEle
|
||||
children?: React.ReactNode
|
||||
/** Whether currently active/selected */
|
||||
active?: boolean
|
||||
/**
|
||||
* Expand folder on hover to show submenu alongside parent
|
||||
* When true, hovering shows a floating submenu; clicking still uses inline navigation
|
||||
* @default false
|
||||
*/
|
||||
expandOnHover?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Expandable folder that shows nested content.
|
||||
* Supports two modes:
|
||||
* - Click mode (default): Replaces parent content, shows back button
|
||||
* - Hover mode (expandOnHover): Shows floating submenu alongside parent
|
||||
*/
|
||||
const PopoverFolder = React.forwardRef<HTMLDivElement, PopoverFolderProps>(
|
||||
({ className, id, title, icon, onOpen, onSelect, children, active, ...props }, ref) => {
|
||||
const { openFolder, currentFolder, isInFolder, variant, size, colorScheme } =
|
||||
usePopoverContext()
|
||||
(
|
||||
{
|
||||
className,
|
||||
id,
|
||||
title,
|
||||
icon,
|
||||
onOpen,
|
||||
onSelect,
|
||||
children,
|
||||
active,
|
||||
expandOnHover = false,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const {
|
||||
openFolder,
|
||||
currentFolder,
|
||||
isInFolder,
|
||||
variant,
|
||||
size,
|
||||
colorScheme,
|
||||
lastHoveredItem,
|
||||
setLastHoveredItem,
|
||||
} = usePopoverContext()
|
||||
const [submenuPosition, setSubmenuPosition] = React.useState<{ top: number; left: number }>({
|
||||
top: 0,
|
||||
left: 0,
|
||||
})
|
||||
const triggerRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
// Submenu is open when this folder is the last hovered item (for expandOnHover mode)
|
||||
const isHoverOpen = expandOnHover && lastHoveredItem === id
|
||||
|
||||
// Merge refs
|
||||
const mergedRef = React.useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
triggerRef.current = node
|
||||
if (typeof ref === 'function') {
|
||||
ref(node)
|
||||
} else if (ref) {
|
||||
ref.current = node
|
||||
}
|
||||
},
|
||||
[ref]
|
||||
)
|
||||
|
||||
// If we're in a folder and this isn't the current one, hide
|
||||
if (isInFolder && currentFolder !== id) return null
|
||||
// If this folder is open via click (inline mode), render children directly
|
||||
if (currentFolder === id) return <>{children}</>
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const handleClickOpen = () => {
|
||||
openFolder(id, title, onOpen, onSelect)
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (expandOnHover) {
|
||||
// In hover mode, clicking opens inline and clears hover state
|
||||
setLastHoveredItem(null)
|
||||
}
|
||||
handleClickOpen()
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!expandOnHover) return
|
||||
|
||||
// Calculate position for submenu
|
||||
if (triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const parentPopover = triggerRef.current.closest('[data-radix-popper-content-wrapper]')
|
||||
const parentRect = parentPopover?.getBoundingClientRect()
|
||||
|
||||
// Position to the right of the parent popover with a small gap
|
||||
setSubmenuPosition({
|
||||
top: rect.top,
|
||||
left: parentRect ? parentRect.right + 4 : rect.right + 4,
|
||||
})
|
||||
}
|
||||
|
||||
setLastHoveredItem(id)
|
||||
onOpen?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
STYLES.itemBase,
|
||||
STYLES.colorScheme[colorScheme].text,
|
||||
STYLES.size[size].item,
|
||||
getItemStateClasses(variant, colorScheme, !!active),
|
||||
className
|
||||
)}
|
||||
role='menuitem'
|
||||
aria-haspopup='true'
|
||||
aria-expanded={false}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
<span className='flex-1'>{title}</span>
|
||||
<ChevronRight className={STYLES.size[size].icon} />
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
ref={mergedRef}
|
||||
className={cn(
|
||||
STYLES.itemBase,
|
||||
STYLES.colorScheme[colorScheme].text,
|
||||
STYLES.size[size].item,
|
||||
getItemStateClasses(variant, colorScheme, !!active || isHoverOpen),
|
||||
className
|
||||
)}
|
||||
role='menuitem'
|
||||
aria-haspopup='true'
|
||||
aria-expanded={isHoverOpen}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
<span className='flex-1'>{title}</span>
|
||||
<ChevronRight className={STYLES.size[size].icon} />
|
||||
</div>
|
||||
|
||||
{/* Hover submenu - rendered as a portal to escape overflow clipping */}
|
||||
{isHoverOpen &&
|
||||
typeof document !== 'undefined' &&
|
||||
createPortal(
|
||||
<div
|
||||
className={cn(
|
||||
'fixed z-[10000201] min-w-[120px]',
|
||||
STYLES.content,
|
||||
STYLES.colorScheme[colorScheme].content,
|
||||
'shadow-lg'
|
||||
)}
|
||||
style={{
|
||||
top: submenuPosition.top,
|
||||
left: submenuPosition.left,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -665,7 +809,10 @@ const PopoverBackButton = React.forwardRef<HTMLDivElement, PopoverBackButtonProp
|
||||
className
|
||||
)}
|
||||
role='button'
|
||||
onClick={closeFolder}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
closeFolder()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className={STYLES.size[size].icon} />
|
||||
|
||||
@@ -166,6 +166,8 @@ 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 */
|
||||
@@ -207,6 +209,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||
items,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onInputChange,
|
||||
placeholder = 'Enter values',
|
||||
placeholderWithTags = 'Add another',
|
||||
disabled = false,
|
||||
@@ -344,10 +347,12 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||
})
|
||||
|
||||
if (addedCount === 0 && pastedValues.length === 1) {
|
||||
setInputValue(inputValue + pastedValues[0])
|
||||
const newValue = inputValue + pastedValues[0]
|
||||
setInputValue(newValue)
|
||||
onInputChange?.(newValue)
|
||||
}
|
||||
},
|
||||
[onAdd, inputValue]
|
||||
[onAdd, inputValue, onInputChange]
|
||||
)
|
||||
|
||||
const handleBlur = React.useCallback(() => {
|
||||
@@ -422,7 +427,10 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||
name={name}
|
||||
type='text'
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value)
|
||||
onInputChange?.(e.target.value)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onBlur={handleBlur}
|
||||
|
||||
22
apps/sim/components/emcn/icons/animate/download.module.css
Normal file
22
apps/sim/components/emcn/icons/animate/download.module.css
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Download icon animation
|
||||
* Subtle continuous animation for import/download states
|
||||
* Arrow gently pulses down to suggest downloading motion
|
||||
*/
|
||||
|
||||
@keyframes arrow-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(1.5px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.animated-download-svg {
|
||||
animation: arrow-pulse 1.5s ease-in-out infinite;
|
||||
transform-origin: center center;
|
||||
}
|
||||
42
apps/sim/components/emcn/icons/download.tsx
Normal file
42
apps/sim/components/emcn/icons/download.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { SVGProps } from 'react'
|
||||
import styles from '@/components/emcn/icons/animate/download.module.css'
|
||||
|
||||
export interface DownloadProps extends SVGProps<SVGSVGElement> {
|
||||
/**
|
||||
* Enable animation on the download icon
|
||||
* @default false
|
||||
*/
|
||||
animate?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Download icon component with optional CSS-based animation
|
||||
* Based on lucide arrow-down icon structure.
|
||||
* When animate is false, this is a lightweight static icon with no animation overhead.
|
||||
* When animate is true, CSS module animations are applied for a subtle pulsing effect.
|
||||
* @param props - SVG properties including className, animate, etc.
|
||||
*/
|
||||
export function Download({ animate = false, className, ...props }: DownloadProps) {
|
||||
const svgClassName = animate
|
||||
? `${styles['animated-download-svg']} ${className || ''}`.trim()
|
||||
: className
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
className={svgClassName}
|
||||
{...props}
|
||||
>
|
||||
<path d='M12 5v14' />
|
||||
<path d='m19 12-7 7-7-7' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export { ChevronDown } from './chevron-down'
|
||||
export { Connections } from './connections'
|
||||
export { Copy } from './copy'
|
||||
export { DocumentAttachment } from './document-attachment'
|
||||
export { Download } from './download'
|
||||
export { Duplicate } from './duplicate'
|
||||
export { Eye } from './eye'
|
||||
export { FolderCode } from './folder-code'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getNextWorkflowColor } from '@/lib/workflows/colors'
|
||||
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
|
||||
import {
|
||||
createOptimisticMutationHandlers,
|
||||
@@ -8,10 +9,7 @@ import {
|
||||
} from '@/hooks/queries/utils/optimistic-mutation'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
import {
|
||||
generateCreativeWorkflowName,
|
||||
getNextWorkflowColor,
|
||||
} from '@/stores/workflows/registry/utils'
|
||||
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { BookOpen, Loader2, MinusCircle, XCircle } from 'lucide-react'
|
||||
import {
|
||||
BaseClientTool,
|
||||
type BaseClientToolMetadata,
|
||||
ClientToolCallState,
|
||||
} from '@/lib/copilot/tools/client/base-tool'
|
||||
|
||||
export class SearchLibraryDocsClientTool extends BaseClientTool {
|
||||
static readonly id = 'search_library_docs'
|
||||
|
||||
constructor(toolCallId: string) {
|
||||
super(toolCallId, SearchLibraryDocsClientTool.id, SearchLibraryDocsClientTool.metadata)
|
||||
}
|
||||
|
||||
static readonly metadata: BaseClientToolMetadata = {
|
||||
displayNames: {
|
||||
[ClientToolCallState.generating]: { text: 'Reading docs', icon: Loader2 },
|
||||
[ClientToolCallState.pending]: { text: 'Reading docs', icon: Loader2 },
|
||||
[ClientToolCallState.executing]: { text: 'Reading docs', icon: Loader2 },
|
||||
[ClientToolCallState.success]: { text: 'Read docs', icon: BookOpen },
|
||||
[ClientToolCallState.error]: { text: 'Failed to read docs', icon: XCircle },
|
||||
[ClientToolCallState.aborted]: { text: 'Aborted reading docs', icon: XCircle },
|
||||
[ClientToolCallState.rejected]: { text: 'Skipped reading docs', icon: MinusCircle },
|
||||
},
|
||||
getDynamicText: (params, state) => {
|
||||
const libraryName = params?.library_name
|
||||
if (libraryName && typeof libraryName === 'string') {
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return `Read ${libraryName} docs`
|
||||
case ClientToolCallState.executing:
|
||||
case ClientToolCallState.generating:
|
||||
case ClientToolCallState.pending:
|
||||
return `Reading ${libraryName} docs`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to read ${libraryName} docs`
|
||||
case ClientToolCallState.aborted:
|
||||
return `Aborted reading ${libraryName} docs`
|
||||
case ClientToolCallState.rejected:
|
||||
return `Skipped reading ${libraryName} docs`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
return
|
||||
}
|
||||
}
|
||||
75
apps/sim/lib/workflows/colors.ts
Normal file
75
apps/sim/lib/workflows/colors.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Workflow color constants and utilities.
|
||||
* Centralized location for all workflow color-related functionality.
|
||||
*
|
||||
* Colors are aligned with the brand color scheme:
|
||||
* - Purple: brand-400 (#8e4cfb)
|
||||
* - Blue: brand-secondary (#33b4ff)
|
||||
* - Green: brand-tertiary (#22c55e)
|
||||
* - Red: text-error (#ef4444)
|
||||
* - Orange: warning (#f97316)
|
||||
* - Pink: (#ec4899)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Full list of available workflow colors with names.
|
||||
* Used for color picker and random color assignment.
|
||||
* Each base color has 6 vibrant shades optimized for both light and dark themes.
|
||||
*/
|
||||
export const WORKFLOW_COLORS = [
|
||||
// Shade 1 - all base colors (brightest)
|
||||
{ color: '#c084fc', name: 'Purple 1' },
|
||||
{ color: '#5ed8ff', name: 'Blue 1' },
|
||||
{ color: '#4aea7f', name: 'Green 1' },
|
||||
{ color: '#ff6b6b', name: 'Red 1' },
|
||||
{ color: '#ff9642', name: 'Orange 1' },
|
||||
{ color: '#f472b6', name: 'Pink 1' },
|
||||
|
||||
// Shade 2 - all base colors
|
||||
{ color: '#a855f7', name: 'Purple 2' },
|
||||
{ color: '#38c8ff', name: 'Blue 2' },
|
||||
{ color: '#2ed96a', name: 'Green 2' },
|
||||
{ color: '#ff5555', name: 'Red 2' },
|
||||
{ color: '#ff8328', name: 'Orange 2' },
|
||||
{ color: '#ec4899', name: 'Pink 2' },
|
||||
|
||||
// Shade 3 - all base colors
|
||||
{ color: '#9333ea', name: 'Purple 3' },
|
||||
{ color: '#33b4ff', name: 'Blue 3' },
|
||||
{ color: '#22c55e', name: 'Green 3' },
|
||||
{ color: '#ef4444', name: 'Red 3' },
|
||||
{ color: '#f97316', name: 'Orange 3' },
|
||||
{ color: '#e11d89', name: 'Pink 3' },
|
||||
|
||||
// Shade 4 - all base colors
|
||||
{ color: '#8e4cfb', name: 'Purple 4' },
|
||||
{ color: '#1e9de8', name: 'Blue 4' },
|
||||
{ color: '#18b04c', name: 'Green 4' },
|
||||
{ color: '#dc3535', name: 'Red 4' },
|
||||
{ color: '#e56004', name: 'Orange 4' },
|
||||
{ color: '#d61c7a', name: 'Pink 4' },
|
||||
|
||||
// Shade 5 - all base colors
|
||||
{ color: '#7c3aed', name: 'Purple 5' },
|
||||
{ color: '#1486d1', name: 'Blue 5' },
|
||||
{ color: '#0e9b3a', name: 'Green 5' },
|
||||
{ color: '#c92626', name: 'Red 5' },
|
||||
{ color: '#d14d00', name: 'Orange 5' },
|
||||
{ color: '#be185d', name: 'Pink 5' },
|
||||
|
||||
// Shade 6 - all base colors (darkest)
|
||||
{ color: '#6322c9', name: 'Purple 6' },
|
||||
{ color: '#0a6fb8', name: 'Blue 6' },
|
||||
{ color: '#048628', name: 'Green 6' },
|
||||
{ color: '#b61717', name: 'Red 6' },
|
||||
{ color: '#bd3a00', name: 'Orange 6' },
|
||||
{ color: '#9d174d', name: 'Pink 6' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Generates a random color for a new workflow
|
||||
* @returns A hex color string from the available workflow colors
|
||||
*/
|
||||
export function getNextWorkflowColor(): string {
|
||||
return WORKFLOW_COLORS[Math.floor(Math.random() * WORKFLOW_COLORS.length)].color
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import { RememberDebugClientTool } from '@/lib/copilot/tools/client/other/rememb
|
||||
import { ResearchClientTool } from '@/lib/copilot/tools/client/other/research'
|
||||
import { SearchDocumentationClientTool } from '@/lib/copilot/tools/client/other/search-documentation'
|
||||
import { SearchErrorsClientTool } from '@/lib/copilot/tools/client/other/search-errors'
|
||||
import { SearchLibraryDocsClientTool } from '@/lib/copilot/tools/client/other/search-library-docs'
|
||||
import { SearchOnlineClientTool } from '@/lib/copilot/tools/client/other/search-online'
|
||||
import { SearchPatternsClientTool } from '@/lib/copilot/tools/client/other/search-patterns'
|
||||
import { SleepClientTool } from '@/lib/copilot/tools/client/other/sleep'
|
||||
@@ -116,6 +117,7 @@ const CLIENT_TOOL_INSTANTIATORS: Record<string, (id: string) => any> = {
|
||||
get_trigger_blocks: (id) => new GetTriggerBlocksClientTool(id),
|
||||
search_online: (id) => new SearchOnlineClientTool(id),
|
||||
search_documentation: (id) => new SearchDocumentationClientTool(id),
|
||||
search_library_docs: (id) => new SearchLibraryDocsClientTool(id),
|
||||
search_patterns: (id) => new SearchPatternsClientTool(id),
|
||||
search_errors: (id) => new SearchErrorsClientTool(id),
|
||||
remember_debug: (id) => new RememberDebugClientTool(id),
|
||||
@@ -174,6 +176,7 @@ export const CLASS_TOOL_METADATA: Record<string, BaseClientToolMetadata | undefi
|
||||
get_trigger_blocks: (GetTriggerBlocksClientTool as any)?.metadata,
|
||||
search_online: (SearchOnlineClientTool as any)?.metadata,
|
||||
search_documentation: (SearchDocumentationClientTool as any)?.metadata,
|
||||
search_library_docs: (SearchLibraryDocsClientTool as any)?.metadata,
|
||||
search_patterns: (SearchPatternsClientTool as any)?.metadata,
|
||||
search_errors: (SearchErrorsClientTool as any)?.metadata,
|
||||
remember_debug: (RememberDebugClientTool as any)?.metadata,
|
||||
@@ -271,11 +274,31 @@ function resolveToolDisplay(
|
||||
if (cand?.text || cand?.icon) return { text: cand.text, icon: cand.icon }
|
||||
}
|
||||
} catch {}
|
||||
// Humanized fallback as last resort
|
||||
// Humanized fallback as last resort - include state verb for proper verb-noun styling
|
||||
try {
|
||||
if (toolName) {
|
||||
const text = toolName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
return { text, icon: undefined as any }
|
||||
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 }
|
||||
}
|
||||
} catch {}
|
||||
return undefined
|
||||
@@ -572,8 +595,30 @@ function stripTodoTags(text: string): string {
|
||||
*/
|
||||
function deepClone<T>(obj: T): T {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(obj))
|
||||
} catch {
|
||||
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 obj
|
||||
}
|
||||
}
|
||||
@@ -587,11 +632,18 @@ 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: msg.timestamp,
|
||||
timestamp,
|
||||
}
|
||||
|
||||
// Deep clone contentBlocks (the main rendering data)
|
||||
@@ -3151,7 +3203,7 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
model: selectedModel,
|
||||
}
|
||||
|
||||
await fetch('/api/copilot/chat/update-messages', {
|
||||
const saveResponse = await fetch('/api/copilot/chat/update-messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -3162,6 +3214,18 @@ 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: {
|
||||
@@ -3170,7 +3234,9 @@ export const useCopilotStore = create<CopilotStore>()(
|
||||
config,
|
||||
},
|
||||
})
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
logger.error('[Stream Done] Exception saving messages', { error: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
// Post copilot_stats record (input/output tokens can be null for now)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { withOptimisticUpdate } from '@/lib/core/utils/optimistic-update'
|
||||
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
|
||||
import { getNextWorkflowColor } from '@/lib/workflows/colors'
|
||||
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
|
||||
import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import type {
|
||||
@@ -11,7 +12,6 @@ import type {
|
||||
WorkflowMetadata,
|
||||
WorkflowRegistry,
|
||||
} from '@/stores/workflows/registry/types'
|
||||
import { getNextWorkflowColor } from '@/stores/workflows/registry/utils'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { getUniqueBlockName, regenerateBlockIds } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -97,11 +97,18 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
set((state) => ({
|
||||
workflows: mapped,
|
||||
error: null,
|
||||
hydration:
|
||||
state.hydration.phase === 'state-loading'
|
||||
set((state) => {
|
||||
// Preserve hydration if workflow is loading or already ready and still exists
|
||||
const shouldPreserveHydration =
|
||||
state.hydration.phase === 'state-loading' ||
|
||||
(state.hydration.phase === 'ready' &&
|
||||
state.hydration.workflowId &&
|
||||
mapped[state.hydration.workflowId])
|
||||
|
||||
return {
|
||||
workflows: mapped,
|
||||
error: null,
|
||||
hydration: shouldPreserveHydration
|
||||
? state.hydration
|
||||
: {
|
||||
phase: 'metadata-ready',
|
||||
@@ -110,7 +117,8 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
requestId: null,
|
||||
error: null,
|
||||
},
|
||||
}))
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
failMetadataLoad: (workspaceId: string | null, errorMessage: string) => {
|
||||
|
||||
@@ -1,321 +1,410 @@
|
||||
// Available workflow colors
|
||||
export const WORKFLOW_COLORS = [
|
||||
// Blues - vibrant blue tones
|
||||
'#3972F6', // Blue (original)
|
||||
'#2E5BF5', // Deeper Blue
|
||||
'#1E4BF4', // Royal Blue
|
||||
'#0D3BF3', // Deep Royal Blue
|
||||
|
||||
// Pinks/Magentas - vibrant pink and magenta tones
|
||||
'#F639DD', // Pink/Magenta (original)
|
||||
'#F529CF', // Deep Magenta
|
||||
'#F749E7', // Light Magenta
|
||||
'#F419C1', // Hot Pink
|
||||
|
||||
// Oranges/Yellows - vibrant orange and yellow tones
|
||||
'#F6B539', // Orange/Yellow (original)
|
||||
'#F5A529', // Deep Orange
|
||||
'#F49519', // Burnt Orange
|
||||
'#F38509', // Deep Burnt Orange
|
||||
|
||||
// Purples - vibrant purple tones
|
||||
'#8139F6', // Purple (original)
|
||||
'#7129F5', // Deep Purple
|
||||
'#6119F4', // Royal Purple
|
||||
'#5109F3', // Deep Royal Purple
|
||||
|
||||
// Greens - vibrant green tones
|
||||
'#39B54A', // Green (original)
|
||||
'#29A53A', // Deep Green
|
||||
'#19952A', // Forest Green
|
||||
'#09851A', // Deep Forest Green
|
||||
|
||||
// Teals/Cyans - vibrant teal and cyan tones
|
||||
'#39B5AB', // Teal (original)
|
||||
'#29A59B', // Deep Teal
|
||||
'#19958B', // Dark Teal
|
||||
'#09857B', // Deep Dark Teal
|
||||
|
||||
// Reds/Red-Oranges - vibrant red and red-orange tones
|
||||
'#F66839', // Red/Orange (original)
|
||||
'#F55829', // Deep Red-Orange
|
||||
'#F44819', // Burnt Red
|
||||
'#F33809', // Deep Burnt Red
|
||||
|
||||
// Additional vibrant colors for variety
|
||||
// Corals - warm coral tones
|
||||
'#F6397A', // Coral
|
||||
'#F5296A', // Deep Coral
|
||||
'#F7498A', // Light Coral
|
||||
|
||||
// Crimsons - deep red tones
|
||||
'#DC143C', // Crimson
|
||||
'#CC042C', // Deep Crimson
|
||||
'#EC243C', // Light Crimson
|
||||
'#BC003C', // Dark Crimson
|
||||
'#FC343C', // Bright Crimson
|
||||
|
||||
// Mint - fresh green tones
|
||||
'#00FF7F', // Mint Green
|
||||
'#00EF6F', // Deep Mint
|
||||
'#00DF5F', // Dark Mint
|
||||
|
||||
// Slate - blue-gray tones
|
||||
'#6A5ACD', // Slate Blue
|
||||
'#5A4ABD', // Deep Slate
|
||||
'#4A3AAD', // Dark Slate
|
||||
|
||||
// Amber - warm orange-yellow tones
|
||||
'#FFBF00', // Amber
|
||||
'#EFAF00', // Deep Amber
|
||||
'#DF9F00', // Dark Amber
|
||||
]
|
||||
|
||||
// Generates a random color for a new workflow
|
||||
export function getNextWorkflowColor(): string {
|
||||
// Simply return a random color from the available colors
|
||||
return WORKFLOW_COLORS[Math.floor(Math.random() * WORKFLOW_COLORS.length)]
|
||||
}
|
||||
|
||||
// Adjectives and nouns for creative workflow names
|
||||
// Cosmos-themed adjectives and nouns for creative workflow names (max 9 chars each)
|
||||
const ADJECTIVES = [
|
||||
'Blazing',
|
||||
'Crystal',
|
||||
'Golden',
|
||||
'Silver',
|
||||
'Mystic',
|
||||
'Cosmic',
|
||||
'Electric',
|
||||
'Frozen',
|
||||
'Burning',
|
||||
'Shining',
|
||||
'Dancing',
|
||||
'Flying',
|
||||
'Roaring',
|
||||
'Whispering',
|
||||
'Glowing',
|
||||
'Sparkling',
|
||||
'Thunder',
|
||||
'Lightning',
|
||||
'Storm',
|
||||
'Ocean',
|
||||
'Mountain',
|
||||
'Forest',
|
||||
'Desert',
|
||||
'Arctic',
|
||||
'Tropical',
|
||||
'Midnight',
|
||||
'Dawn',
|
||||
'Sunset',
|
||||
'Rainbow',
|
||||
'Diamond',
|
||||
'Ruby',
|
||||
'Emerald',
|
||||
'Sapphire',
|
||||
'Pearl',
|
||||
'Jade',
|
||||
'Amber',
|
||||
'Coral',
|
||||
'Ivory',
|
||||
'Obsidian',
|
||||
'Marble',
|
||||
'Velvet',
|
||||
'Silk',
|
||||
'Satin',
|
||||
'Linen',
|
||||
'Cotton',
|
||||
'Wool',
|
||||
'Cashmere',
|
||||
'Denim',
|
||||
'Neon',
|
||||
'Pastel',
|
||||
'Vibrant',
|
||||
'Muted',
|
||||
'Bold',
|
||||
'Subtle',
|
||||
'Bright',
|
||||
'Dark',
|
||||
'Ancient',
|
||||
'Modern',
|
||||
'Eternal',
|
||||
'Swift',
|
||||
// Light & Luminosity
|
||||
'Radiant',
|
||||
'Quantum',
|
||||
'Luminous',
|
||||
'Blazing',
|
||||
'Glowing',
|
||||
'Bright',
|
||||
'Gleaming',
|
||||
'Shining',
|
||||
'Lustrous',
|
||||
'Flaring',
|
||||
'Vivid',
|
||||
'Dazzling',
|
||||
'Beaming',
|
||||
'Brilliant',
|
||||
'Lit',
|
||||
'Ablaze',
|
||||
// Celestial Descriptors
|
||||
'Stellar',
|
||||
'Cosmic',
|
||||
'Astral',
|
||||
'Galactic',
|
||||
'Nebular',
|
||||
'Orbital',
|
||||
'Lunar',
|
||||
'Solar',
|
||||
'Starlit',
|
||||
'Heavenly',
|
||||
'Celestial',
|
||||
'Ethereal',
|
||||
'Phantom',
|
||||
'Shadow',
|
||||
'Sidereal',
|
||||
'Planetary',
|
||||
'Starry',
|
||||
'Spacial',
|
||||
// Scale & Magnitude
|
||||
'Infinite',
|
||||
'Vast',
|
||||
'Boundless',
|
||||
'Immense',
|
||||
'Colossal',
|
||||
'Titanic',
|
||||
'Massive',
|
||||
'Grand',
|
||||
'Supreme',
|
||||
'Ultimate',
|
||||
'Epic',
|
||||
'Enormous',
|
||||
'Gigantic',
|
||||
'Limitless',
|
||||
'Total',
|
||||
// Temporal
|
||||
'Eternal',
|
||||
'Ancient',
|
||||
'Timeless',
|
||||
'Enduring',
|
||||
'Ageless',
|
||||
'Immortal',
|
||||
'Primal',
|
||||
'Nascent',
|
||||
'First',
|
||||
'Elder',
|
||||
'Lasting',
|
||||
'Undying',
|
||||
'Perpetual',
|
||||
'Final',
|
||||
'Prime',
|
||||
// Movement & Energy
|
||||
'Sidbuck',
|
||||
'Swift',
|
||||
'Drifting',
|
||||
'Spinning',
|
||||
'Surging',
|
||||
'Pulsing',
|
||||
'Soaring',
|
||||
'Racing',
|
||||
'Falling',
|
||||
'Rising',
|
||||
'Circling',
|
||||
'Streaking',
|
||||
'Hurtling',
|
||||
'Floating',
|
||||
'Orbiting',
|
||||
'Spiraling',
|
||||
// Colors of Space
|
||||
'Crimson',
|
||||
'Azure',
|
||||
'Violet',
|
||||
'Scarlet',
|
||||
'Magenta',
|
||||
'Turquoise',
|
||||
'Indigo',
|
||||
'Jade',
|
||||
'Noble',
|
||||
'Regal',
|
||||
'Imperial',
|
||||
'Royal',
|
||||
'Supreme',
|
||||
'Prime',
|
||||
'Elite',
|
||||
'Ultra',
|
||||
'Mega',
|
||||
'Hyper',
|
||||
'Super',
|
||||
'Neo',
|
||||
'Cyber',
|
||||
'Digital',
|
||||
'Virtual',
|
||||
'Sonic',
|
||||
'Amber',
|
||||
'Sapphire',
|
||||
'Obsidian',
|
||||
'Silver',
|
||||
'Golden',
|
||||
'Scarlet',
|
||||
'Cobalt',
|
||||
'Emerald',
|
||||
'Ruby',
|
||||
'Onyx',
|
||||
'Ivory',
|
||||
// Physical Properties
|
||||
'Magnetic',
|
||||
'Quantum',
|
||||
'Thermal',
|
||||
'Photonic',
|
||||
'Ionic',
|
||||
'Plasma',
|
||||
'Spectral',
|
||||
'Charged',
|
||||
'Polar',
|
||||
'Dense',
|
||||
'Atomic',
|
||||
'Nuclear',
|
||||
'Laser',
|
||||
'Plasma',
|
||||
'Magnetic',
|
||||
'Electric',
|
||||
'Kinetic',
|
||||
'Static',
|
||||
// Atmosphere & Mystery
|
||||
'Ethereal',
|
||||
'Mystic',
|
||||
'Phantom',
|
||||
'Shadow',
|
||||
'Silent',
|
||||
'Distant',
|
||||
'Hidden',
|
||||
'Veiled',
|
||||
'Fading',
|
||||
'Arcane',
|
||||
'Cryptic',
|
||||
'Obscure',
|
||||
'Dim',
|
||||
'Dusky',
|
||||
'Shrouded',
|
||||
// Temperature & State
|
||||
'Frozen',
|
||||
'Burning',
|
||||
'Molten',
|
||||
'Volatile',
|
||||
'Icy',
|
||||
'Fiery',
|
||||
'Cool',
|
||||
'Warm',
|
||||
'Cold',
|
||||
'Hot',
|
||||
'Searing',
|
||||
'Frigid',
|
||||
'Scalding',
|
||||
'Chilled',
|
||||
'Heated',
|
||||
// Power & Force
|
||||
'Mighty',
|
||||
'Fierce',
|
||||
'Raging',
|
||||
'Wild',
|
||||
'Serene',
|
||||
'Tranquil',
|
||||
'Harmonic',
|
||||
'Resonant',
|
||||
'Steady',
|
||||
'Bold',
|
||||
'Potent',
|
||||
'Violent',
|
||||
'Calm',
|
||||
'Furious',
|
||||
'Forceful',
|
||||
// Texture & Form
|
||||
'Smooth',
|
||||
'Jagged',
|
||||
'Fractured',
|
||||
'Solid',
|
||||
'Hollow',
|
||||
'Curved',
|
||||
'Sharp',
|
||||
'Fluid',
|
||||
'Rigid',
|
||||
'Warped',
|
||||
// Rare & Precious
|
||||
'Noble',
|
||||
'Pure',
|
||||
'Rare',
|
||||
'Pristine',
|
||||
'Flawless',
|
||||
'Unique',
|
||||
'Exotic',
|
||||
'Sacred',
|
||||
'Divine',
|
||||
'Hallowed',
|
||||
]
|
||||
|
||||
const NOUNS = [
|
||||
'Phoenix',
|
||||
'Dragon',
|
||||
'Eagle',
|
||||
'Wolf',
|
||||
'Lion',
|
||||
'Tiger',
|
||||
'Panther',
|
||||
'Falcon',
|
||||
'Hawk',
|
||||
'Raven',
|
||||
'Swan',
|
||||
'Dove',
|
||||
'Butterfly',
|
||||
'Firefly',
|
||||
'Dragonfly',
|
||||
'Hummingbird',
|
||||
// Stars & Stellar Objects
|
||||
'Star',
|
||||
'Sun',
|
||||
'Pulsar',
|
||||
'Quasar',
|
||||
'Magnetar',
|
||||
'Nova',
|
||||
'Supernova',
|
||||
'Hypernova',
|
||||
'Neutron',
|
||||
'Dwarf',
|
||||
'Giant',
|
||||
'Protostar',
|
||||
'Blazar',
|
||||
'Cepheid',
|
||||
'Binary',
|
||||
// Galaxies & Clusters
|
||||
'Galaxy',
|
||||
'Nebula',
|
||||
'Cluster',
|
||||
'Void',
|
||||
'Filament',
|
||||
'Halo',
|
||||
'Bulge',
|
||||
'Spiral',
|
||||
'Ellipse',
|
||||
'Arm',
|
||||
'Disk',
|
||||
'Shell',
|
||||
'Remnant',
|
||||
'Cloud',
|
||||
'Dust',
|
||||
// Planets & Moons
|
||||
'Planet',
|
||||
'Moon',
|
||||
'World',
|
||||
'Exoplanet',
|
||||
'Jovian',
|
||||
'Titan',
|
||||
'Europa',
|
||||
'Io',
|
||||
'Callisto',
|
||||
'Ganymede',
|
||||
'Triton',
|
||||
'Phobos',
|
||||
'Deimos',
|
||||
'Enceladus',
|
||||
'Charon',
|
||||
// Small Bodies
|
||||
'Comet',
|
||||
'Meteor',
|
||||
'Star',
|
||||
'Moon',
|
||||
'Sun',
|
||||
'Planet',
|
||||
'Asteroid',
|
||||
'Constellation',
|
||||
'Aurora',
|
||||
'Meteorite',
|
||||
'Bolide',
|
||||
'Fireball',
|
||||
'Iceball',
|
||||
'Plutino',
|
||||
'Centaur',
|
||||
'Trojan',
|
||||
'Shard',
|
||||
'Fragment',
|
||||
'Debris',
|
||||
'Rock',
|
||||
'Ice',
|
||||
// Constellations & Myths
|
||||
'Orion',
|
||||
'Andromeda',
|
||||
'Perseus',
|
||||
'Pegasus',
|
||||
'Phoenix',
|
||||
'Draco',
|
||||
'Cygnus',
|
||||
'Aquila',
|
||||
'Lyra',
|
||||
'Vega',
|
||||
'Centaurus',
|
||||
'Hydra',
|
||||
'Sirius',
|
||||
'Polaris',
|
||||
'Altair',
|
||||
// Celestial Phenomena
|
||||
'Eclipse',
|
||||
'Solstice',
|
||||
'Equinox',
|
||||
'Horizon',
|
||||
'Zenith',
|
||||
'Castle',
|
||||
'Tower',
|
||||
'Bridge',
|
||||
'Garden',
|
||||
'Fountain',
|
||||
'Palace',
|
||||
'Temple',
|
||||
'Cathedral',
|
||||
'Lighthouse',
|
||||
'Windmill',
|
||||
'Waterfall',
|
||||
'Canyon',
|
||||
'Valley',
|
||||
'Peak',
|
||||
'Ridge',
|
||||
'Cliff',
|
||||
'Ocean',
|
||||
'River',
|
||||
'Lake',
|
||||
'Stream',
|
||||
'Pond',
|
||||
'Bay',
|
||||
'Cove',
|
||||
'Harbor',
|
||||
'Island',
|
||||
'Peninsula',
|
||||
'Archipelago',
|
||||
'Atoll',
|
||||
'Reef',
|
||||
'Lagoon',
|
||||
'Fjord',
|
||||
'Delta',
|
||||
'Cake',
|
||||
'Cookie',
|
||||
'Muffin',
|
||||
'Cupcake',
|
||||
'Pie',
|
||||
'Tart',
|
||||
'Brownie',
|
||||
'Donut',
|
||||
'Pancake',
|
||||
'Waffle',
|
||||
'Croissant',
|
||||
'Bagel',
|
||||
'Pretzel',
|
||||
'Biscuit',
|
||||
'Scone',
|
||||
'Crumpet',
|
||||
'Thunder',
|
||||
'Blizzard',
|
||||
'Tornado',
|
||||
'Hurricane',
|
||||
'Tsunami',
|
||||
'Volcano',
|
||||
'Glacier',
|
||||
'Avalanche',
|
||||
'Aurora',
|
||||
'Corona',
|
||||
'Flare',
|
||||
'Storm',
|
||||
'Vortex',
|
||||
'Tempest',
|
||||
'Maelstrom',
|
||||
'Whirlwind',
|
||||
'Cyclone',
|
||||
'Typhoon',
|
||||
'Monsoon',
|
||||
'Anvil',
|
||||
'Hammer',
|
||||
'Forge',
|
||||
'Blade',
|
||||
'Sword',
|
||||
'Shield',
|
||||
'Arrow',
|
||||
'Spear',
|
||||
'Crown',
|
||||
'Throne',
|
||||
'Scepter',
|
||||
'Orb',
|
||||
'Gem',
|
||||
'Crystal',
|
||||
'Prism',
|
||||
'Spectrum',
|
||||
'Beacon',
|
||||
'Signal',
|
||||
'Jet',
|
||||
'Burst',
|
||||
'Pulse',
|
||||
'Wave',
|
||||
'Surge',
|
||||
'Tide',
|
||||
'Ripple',
|
||||
'Shimmer',
|
||||
'Glow',
|
||||
'Flash',
|
||||
'Spark',
|
||||
// Cosmic Structures
|
||||
'Horizon',
|
||||
'Zenith',
|
||||
'Nadir',
|
||||
'Apex',
|
||||
'Meridian',
|
||||
'Equinox',
|
||||
'Solstice',
|
||||
'Transit',
|
||||
'Aphelion',
|
||||
'Orbit',
|
||||
'Axis',
|
||||
'Pole',
|
||||
'Equator',
|
||||
'Limb',
|
||||
'Arc',
|
||||
// Space & Dimensions
|
||||
'Cosmos',
|
||||
'Universe',
|
||||
'Dimension',
|
||||
'Realm',
|
||||
'Expanse',
|
||||
'Infinity',
|
||||
'Continuum',
|
||||
'Manifold',
|
||||
'Abyss',
|
||||
'Ether',
|
||||
'Vacuum',
|
||||
'Space',
|
||||
'Fabric',
|
||||
'Plane',
|
||||
'Domain',
|
||||
// Energy & Particles
|
||||
'Photon',
|
||||
'Neutrino',
|
||||
'Proton',
|
||||
'Electron',
|
||||
'Positron',
|
||||
'Quark',
|
||||
'Boson',
|
||||
'Fermion',
|
||||
'Tachyon',
|
||||
'Graviton',
|
||||
'Meson',
|
||||
'Gluon',
|
||||
'Lepton',
|
||||
'Muon',
|
||||
'Pion',
|
||||
// Regions & Zones
|
||||
'Sector',
|
||||
'Quadrant',
|
||||
'Zone',
|
||||
'Belt',
|
||||
'Ring',
|
||||
'Field',
|
||||
'Stream',
|
||||
'Current',
|
||||
'Flow',
|
||||
'Circuit',
|
||||
'Node',
|
||||
'Core',
|
||||
'Matrix',
|
||||
'Network',
|
||||
'System',
|
||||
'Engine',
|
||||
'Reactor',
|
||||
'Generator',
|
||||
'Dynamo',
|
||||
'Catalyst',
|
||||
'Nexus',
|
||||
'Portal',
|
||||
'Wake',
|
||||
'Region',
|
||||
'Frontier',
|
||||
'Border',
|
||||
'Edge',
|
||||
'Margin',
|
||||
'Rim',
|
||||
// Navigation & Discovery
|
||||
'Beacon',
|
||||
'Signal',
|
||||
'Probe',
|
||||
'Voyager',
|
||||
'Pioneer',
|
||||
'Seeker',
|
||||
'Wanderer',
|
||||
'Nomad',
|
||||
'Drifter',
|
||||
'Scout',
|
||||
'Explorer',
|
||||
'Ranger',
|
||||
'Surveyor',
|
||||
'Sentinel',
|
||||
'Watcher',
|
||||
// Portals & Passages
|
||||
'Gateway',
|
||||
'Passage',
|
||||
'Portal',
|
||||
'Nexus',
|
||||
'Bridge',
|
||||
'Conduit',
|
||||
'Channel',
|
||||
'Passage',
|
||||
'Rift',
|
||||
'Warp',
|
||||
'Fold',
|
||||
'Tunnel',
|
||||
'Crossing',
|
||||
'Link',
|
||||
'Path',
|
||||
'Route',
|
||||
// Core & Systems
|
||||
'Core',
|
||||
'Matrix',
|
||||
'Lattice',
|
||||
'Network',
|
||||
'Circuit',
|
||||
'Array',
|
||||
'Reactor',
|
||||
'Engine',
|
||||
'Forge',
|
||||
'Crucible',
|
||||
'Hub',
|
||||
'Node',
|
||||
'Kernel',
|
||||
'Center',
|
||||
'Heart',
|
||||
// Cosmic Objects
|
||||
'Crater',
|
||||
'Rift',
|
||||
'Chasm',
|
||||
'Canyon',
|
||||
'Peak',
|
||||
'Ridge',
|
||||
'Basin',
|
||||
'Plateau',
|
||||
'Valley',
|
||||
'Trench',
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
17
bun.lock
17
bun.lock
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "simstudio",
|
||||
@@ -11,7 +12,7 @@
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.0.0",
|
||||
"turbo": "2.7.3",
|
||||
"turbo": "2.7.4",
|
||||
},
|
||||
},
|
||||
"apps/docs": {
|
||||
@@ -3379,19 +3380,19 @@
|
||||
|
||||
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||
|
||||
"turbo": ["turbo@2.7.3", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.3", "turbo-darwin-arm64": "2.7.3", "turbo-linux-64": "2.7.3", "turbo-linux-arm64": "2.7.3", "turbo-windows-64": "2.7.3", "turbo-windows-arm64": "2.7.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+HjKlP4OfYk+qzvWNETA3cUO5UuK6b5MSc2UJOKyvBceKucQoQGb2g7HlC2H1GHdkfKrk4YF1VPvROkhVZDDLQ=="],
|
||||
"turbo": ["turbo@2.7.4", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.4", "turbo-darwin-arm64": "2.7.4", "turbo-linux-64": "2.7.4", "turbo-linux-arm64": "2.7.4", "turbo-windows-64": "2.7.4", "turbo-windows-arm64": "2.7.4" }, "bin": { "turbo": "bin/turbo" } }, "sha512-bkO4AddmDishzJB2ze7aYYPaejMoJVfS0XnaR6RCdXFOY8JGJfQE+l9fKiV7uDPa5Ut44gmOWJL3894CIMeH9g=="],
|
||||
|
||||
"turbo-darwin-64": ["turbo-darwin-64@2.7.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-aZHhvRiRHXbJw1EcEAq4aws1hsVVUZ9DPuSFaq9VVFAKCup7niIEwc22glxb7240yYEr1vLafdQ2U294Vcwz+w=="],
|
||||
"turbo-darwin-64": ["turbo-darwin-64@2.7.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xDR30ltfkSsRfGzABBckvl1nz1cZ3ssTujvdj+TPwOweeDRvZ0e06t5DS0rmRBvyKpgGs42K/EK6Mn2qLlFY9A=="],
|
||||
|
||||
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CkVrHSq+Bnhl9sX2LQgqQYVfLTWC2gvI74C4758OmU0djfrssDKU9d4YQF0AYXXhIIRZipSXfxClQziIMD+EAg=="],
|
||||
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-P7sjqXtOL/+nYWPvcDGWhi8wf8M8mZHHB8XEzw2VX7VJrS8IGHyJHGD1AYfDvhAEcr7pnk3gGifz3/xyhI655w=="],
|
||||
|
||||
"turbo-linux-64": ["turbo-linux-64@2.7.3", "", { "os": "linux", "cpu": "x64" }, "sha512-GqDsCNnzzr89kMaLGpRALyigUklzgxIrSy2pHZVXyifgczvYPnLglex78Aj3T2gu+T3trPPH2iJ+pWucVOCC2Q=="],
|
||||
"turbo-linux-64": ["turbo-linux-64@2.7.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GofFOxRO/IhG8BcPyMSSB3Y2+oKQotsaYbHxL9yD6JPb20/o35eo+zUSyazOtilAwDHnak5dorAJFoFU8MIg2A=="],
|
||||
|
||||
"turbo-linux-arm64": ["turbo-linux-arm64@2.7.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-NdCDTfIcIo3dWjsiaAHlxu5gW61Ed/8maah1IAF/9E3EtX0aAHNiBMbuYLZaR4vRJ7BeVkYB6xKWRtdFLZ0y3g=="],
|
||||
"turbo-linux-arm64": ["turbo-linux-arm64@2.7.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+RQKgNjksVPxYAyAgmDV7w/1qj++qca+nSNTAOKGOfJiDtSvRKoci89oftJ6anGs00uamLKVEQ712TI/tfNAIw=="],
|
||||
|
||||
"turbo-windows-64": ["turbo-windows-64@2.7.3", "", { "os": "win32", "cpu": "x64" }, "sha512-7bVvO987daXGSJVYBoG8R4Q+csT1pKIgLJYZevXRQ0Hqw0Vv4mKme/TOjYXs9Qb1xMKh51Tb3bXKDbd8/4G08g=="],
|
||||
"turbo-windows-64": ["turbo-windows-64@2.7.4", "", { "os": "win32", "cpu": "x64" }, "sha512-rfak1+g+ON3czs1mDYsCS4X74ZmK6gOgRQTXjDICtzvR4o61paqtgAYtNPofcVsMWeF4wvCajSeoAkkeAnQ1kg=="],
|
||||
|
||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.7.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-nTodweTbPmkvwMu/a55XvjMsPtuyUSC+sV7f/SR57K36rB2I0YG21qNETN+00LOTUW9B3omd8XkiXJkt4kx/cw=="],
|
||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.7.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-1ZgBNjNRbDu/fPeqXuX9i26x3CJ/Y1gcwUpQ+Vp7kN9Un6RZ9kzs164f/knrjcu5E+szCRexVjRSJay1k5jApA=="],
|
||||
|
||||
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.0.0",
|
||||
"turbo": "2.7.3"
|
||||
"turbo": "2.7.4"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,json,css,scss}": [
|
||||
|
||||
Reference in New Issue
Block a user