Compare commits

..

5 Commits

61 changed files with 10872 additions and 698 deletions

View File

@@ -0,0 +1,11 @@
'use client'
import { Tooltip } from '@/components/emcn'
interface TooltipProviderProps {
children: React.ReactNode
}
export function TooltipProvider({ children }: TooltipProviderProps) {
return <Tooltip.Provider>{children}</Tooltip.Provider>
}

View File

@@ -58,6 +58,25 @@
pointer-events: none !important;
}
/**
* Workflow canvas cursor styles
* Override React Flow's default selection cursor based on canvas mode
*/
.workflow-container.canvas-mode-cursor .react-flow__pane,
.workflow-container.canvas-mode-cursor .react-flow__selectionpane {
cursor: default !important;
}
.workflow-container.canvas-mode-hand .react-flow__pane,
.workflow-container.canvas-mode-hand .react-flow__selectionpane {
cursor: grab !important;
}
.workflow-container.canvas-mode-hand .react-flow__pane:active,
.workflow-container.canvas-mode-hand .react-flow__selectionpane:active {
cursor: grabbing !important;
}
/**
* Selected node ring indicator
* Uses a pseudo-element overlay to match the original behavior (absolute inset-0 z-40)

View File

@@ -802,29 +802,49 @@ export async function POST(req: NextRequest) {
toolNames: toolCalls.map((tc) => tc?.name).filter(Boolean),
})
// NOTE: Messages are saved by the client via update-messages endpoint with full contentBlocks.
// Server only updates conversationId here to avoid overwriting client's richer save.
// Save messages to database after streaming completes (including aborted messages)
if (currentChat) {
const updatedMessages = [...conversationHistory, userMessage]
// Save assistant message if there's any content or tool calls (even partial from abort)
if (assistantContent.trim() || toolCalls.length > 0) {
const assistantMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: assistantContent,
timestamp: new Date().toISOString(),
...(toolCalls.length > 0 && { toolCalls }),
}
updatedMessages.push(assistantMessage)
logger.info(
`[${tracker.requestId}] Saving assistant message with content (${assistantContent.length} chars) and ${toolCalls.length} tool calls`
)
} else {
logger.info(
`[${tracker.requestId}] No assistant content or tool calls to save (aborted before response)`
)
}
// Persist only a safe conversationId to avoid continuing from a state that expects tool outputs
const previousConversationId = currentChat?.conversationId as string | undefined
const responseId = lastSafeDoneResponseId || previousConversationId || undefined
if (responseId) {
await db
.update(copilotChats)
.set({
updatedAt: new Date(),
conversationId: responseId,
})
.where(eq(copilotChats.id, actualChatId!))
// Update chat in database immediately (without title)
await db
.update(copilotChats)
.set({
messages: updatedMessages,
updatedAt: new Date(),
...(responseId ? { conversationId: responseId } : {}),
})
.where(eq(copilotChats.id, actualChatId!))
logger.info(
`[${tracker.requestId}] Updated conversationId for chat ${actualChatId}`,
{
updatedConversationId: responseId,
}
)
}
logger.info(`[${tracker.requestId}] Updated chat ${actualChatId} with new messages`, {
messageCount: updatedMessages.length,
savedUserMessage: true,
savedAssistantMessage: assistantContent.trim().length > 0,
updatedConversationId: responseId || null,
})
}
} catch (error) {
logger.error(`[${tracker.requestId}] Error processing stream:`, error)

View File

@@ -77,18 +77,6 @@ export async function POST(req: NextRequest) {
const { chatId, messages, planArtifact, config } = UpdateMessagesSchema.parse(body)
// Debug: Log what we're about to save
const lastMsgParsed = messages[messages.length - 1]
if (lastMsgParsed?.role === 'assistant') {
logger.info(`[${tracker.requestId}] Parsed messages to save`, {
messageCount: messages.length,
lastMsgId: lastMsgParsed.id,
lastMsgContentLength: lastMsgParsed.content?.length || 0,
lastMsgContentBlockCount: lastMsgParsed.contentBlocks?.length || 0,
lastMsgContentBlockTypes: lastMsgParsed.contentBlocks?.map((b: any) => b?.type) || [],
})
}
// Verify that the chat belongs to the user
const [chat] = await db
.select()

View File

@@ -27,6 +27,7 @@ const SettingsSchema = z.object({
superUserModeEnabled: z.boolean().optional(),
errorNotificationsEnabled: z.boolean().optional(),
snapToGridSize: z.number().min(0).max(50).optional(),
showActionBar: z.boolean().optional(),
})
const defaultSettings = {
@@ -39,6 +40,7 @@ const defaultSettings = {
superUserModeEnabled: false,
errorNotificationsEnabled: true,
snapToGridSize: 0,
showActionBar: true,
}
export async function GET() {
@@ -73,6 +75,7 @@ export async function GET() {
superUserModeEnabled: userSettings.superUserModeEnabled ?? true,
errorNotificationsEnabled: userSettings.errorNotificationsEnabled ?? true,
snapToGridSize: userSettings.snapToGridSize ?? 0,
showActionBar: userSettings.showActionBar ?? true,
},
},
{ status: 200 }

View File

@@ -11,6 +11,7 @@ import { HydrationErrorHandler } from '@/app/_shell/hydration-error-handler'
import { QueryProvider } from '@/app/_shell/providers/query-provider'
import { SessionProvider } from '@/app/_shell/providers/session-provider'
import { ThemeProvider } from '@/app/_shell/providers/theme-provider'
import { TooltipProvider } from '@/app/_shell/providers/tooltip-provider'
import { season } from '@/app/_styles/fonts/season/season'
export const viewport: Viewport = {
@@ -194,7 +195,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<ThemeProvider>
<QueryProvider>
<SessionProvider>
<BrandedLayout>{children}</BrandedLayout>
<TooltipProvider>
<BrandedLayout>{children}</BrandedLayout>
</TooltipProvider>
</SessionProvider>
</QueryProvider>
</ThemeProvider>

View File

@@ -21,12 +21,15 @@ import {
Combobox,
Connections,
Copy,
Cursor,
DatePicker,
DocumentAttachment,
Duplicate,
Expand,
Eye,
FolderCode,
FolderPlus,
Hand,
HexSimple,
Input,
Key as KeyIcon,
@@ -462,6 +465,9 @@ export default function PlaygroundPage() {
<Avatar size='lg'>
<AvatarFallback>LG</AvatarFallback>
</Avatar>
<Avatar size='xl'>
<AvatarFallback>XL</AvatarFallback>
</Avatar>
</VariantRow>
<VariantRow label='with image'>
<Avatar size='md'>
@@ -502,6 +508,9 @@ export default function PlaygroundPage() {
<Avatar size='lg' status='online'>
<AvatarFallback>LG</AvatarFallback>
</Avatar>
<Avatar size='xl' status='online'>
<AvatarFallback>XL</AvatarFallback>
</Avatar>
</VariantRow>
</Section>
@@ -973,11 +982,14 @@ export default function PlaygroundPage() {
{ Icon: ChevronDown, name: 'ChevronDown' },
{ Icon: Connections, name: 'Connections' },
{ Icon: Copy, name: 'Copy' },
{ Icon: Cursor, name: 'Cursor' },
{ Icon: DocumentAttachment, name: 'DocumentAttachment' },
{ Icon: Duplicate, name: 'Duplicate' },
{ Icon: Expand, name: 'Expand' },
{ Icon: Eye, name: 'Eye' },
{ Icon: FolderCode, name: 'FolderCode' },
{ Icon: FolderPlus, name: 'FolderPlus' },
{ Icon: Hand, name: 'Hand' },
{ Icon: HexSimple, name: 'HexSimple' },
{ Icon: KeyIcon, name: 'Key' },
{ Icon: Layout, name: 'Layout' },

View File

@@ -1,15 +1,12 @@
'use client'
import { Tooltip } from '@/components/emcn'
import { season } from '@/app/_styles/fonts/season/season'
export default function TemplatesLayoutClient({ children }: { children: React.ReactNode }) {
return (
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
<div className={`${season.variable} relative flex min-h-screen flex-col font-season`}>
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
{children}
</div>
</Tooltip.Provider>
<div className={`${season.variable} relative flex min-h-screen flex-col font-season`}>
<div className='-z-50 pointer-events-none fixed inset-0 bg-white' />
{children}
</div>
)
}

View File

@@ -1,6 +1,5 @@
'use client'
import { Tooltip } from '@/components/emcn'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
@@ -13,16 +12,14 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
<SettingsLoader />
<ProviderModelsLoader />
<GlobalCommandsProvider>
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
<div className='flex h-screen w-full bg-[var(--bg)]'>
<WorkspacePermissionsProvider>
<div className='shrink-0' suppressHydrationWarning>
<Sidebar />
</div>
{children}
</WorkspacePermissionsProvider>
</div>
</Tooltip.Provider>
<div className='flex h-screen w-full bg-[var(--bg)]'>
<WorkspacePermissionsProvider>
<div className='shrink-0' suppressHydrationWarning>
<Sidebar />
</div>
{children}
</WorkspacePermissionsProvider>
</div>
</GlobalCommandsProvider>
</>
)

View File

@@ -0,0 +1,222 @@
'use client'
import { useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useReactFlow } from 'reactflow'
import {
Button,
ChevronDown,
Cursor,
Expand,
Hand,
Popover,
PopoverAnchor,
PopoverContent,
PopoverItem,
PopoverTrigger,
Redo,
Tooltip,
Undo,
ZoomIn,
ZoomOut,
} from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useCanvasModeStore } from '@/stores/canvas-mode'
import { useGeneralStore } from '@/stores/settings/general'
import { useUndoRedoStore } from '@/stores/undo-redo'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('ActionBar')
export function ActionBar() {
const reactFlowInstance = useReactFlow()
const { zoomIn, zoomOut } = reactFlowInstance
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
const { mode, setMode } = useCanvasModeStore()
const { undo, redo } = useCollaborativeWorkflow()
const showActionBar = useGeneralStore((s) => s.showActionBar)
const updateSetting = useUpdateGeneralSetting()
const { activeWorkflowId } = useWorkflowRegistry()
const { data: session } = useSession()
const userId = session?.user?.id || 'unknown'
const stacks = useUndoRedoStore((s) => s.stacks)
const key = activeWorkflowId && userId ? `${activeWorkflowId}:${userId}` : ''
const stack = (key && stacks[key]) || { undo: [], redo: [] }
const canUndo = stack.undo.length > 0
const canRedo = stack.redo.length > 0
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
const [isCanvasModeOpen, setIsCanvasModeOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault()
setContextMenu({ x: e.clientX, y: e.clientY })
}
const handleHide = async () => {
try {
await updateSetting.mutateAsync({ key: 'showActionBar', value: false })
} catch (error) {
logger.error('Failed to hide action bar', error)
} finally {
setContextMenu(null)
}
}
if (!showActionBar) {
return null
}
return (
<>
<div
className='-translate-x-1/2 fixed bottom-[calc(var(--terminal-height)+16px)] left-[calc((100vw+var(--sidebar-width)-var(--panel-width))/2)] z-10 flex h-[36px] items-center gap-[2px] rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] p-[4px] shadow-sm transition-[left,bottom] duration-100 ease-out'
onContextMenu={handleContextMenu}
>
{/* Canvas Mode Selector */}
<Popover
open={isCanvasModeOpen}
onOpenChange={setIsCanvasModeOpen}
variant='secondary'
size='sm'
>
<PopoverTrigger asChild>
<div className='flex cursor-pointer items-center gap-[4px]'>
<Button className='h-[28px] w-[28px] rounded-[6px] p-0' variant='active'>
{mode === 'hand' ? (
<Hand className='h-[14px] w-[14px]' />
) : (
<Cursor className='h-[14px] w-[14px]' />
)}
</Button>
<Button className='!p-[2px] group' variant='ghost'>
<ChevronDown className='h-[8px] w-[10px] text-[var(--text-muted)] group-hover:text-[var(--text-secondary)]' />
</Button>
</div>
</PopoverTrigger>
<PopoverContent align='center' side='top' sideOffset={8} maxWidth={100} minWidth={100}>
<PopoverItem
onClick={() => {
setMode('cursor')
setIsCanvasModeOpen(false)
}}
>
<Cursor className='h-3 w-3' />
<span>Pointer</span>
</PopoverItem>
<PopoverItem
onClick={() => {
setMode('hand')
setIsCanvasModeOpen(false)
}}
>
<Hand className='h-3 w-3' />
<span>Mover</span>
</PopoverItem>
</PopoverContent>
</Popover>
<div className='mx-[4px] h-[20px] w-[1px] bg-[var(--border)]' />
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[28px] w-[28px] rounded-[6px] p-0 hover:bg-[var(--surface-5)]'
onClick={undo}
disabled={!canUndo}
>
<Undo className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<Tooltip.Shortcut keys='⌘Z'>Undo</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[28px] w-[28px] rounded-[6px] p-0 hover:bg-[var(--surface-5)]'
onClick={redo}
disabled={!canRedo}
>
<Redo className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<Tooltip.Shortcut keys='⌘⇧Z'>Redo</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
<div className='mx-[4px] h-[20px] w-[1px] bg-[var(--border)]' />
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[28px] w-[28px] rounded-[6px] p-0 hover:bg-[var(--surface-5)]'
onClick={() => zoomOut()}
>
<ZoomOut className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Zoom out</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[28px] w-[28px] rounded-[6px] p-0 hover:bg-[var(--surface-5)]'
onClick={() => zoomIn()}
>
<ZoomIn className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Zoom in</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[28px] w-[28px] rounded-[6px] p-0 hover:bg-[var(--surface-5)]'
onClick={() => fitViewToBounds({ padding: 0.1, duration: 300 })}
>
<Expand className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Zoom to fit</Tooltip.Content>
</Tooltip.Root>
</div>
<Popover
open={contextMenu !== null}
onOpenChange={(open) => !open && setContextMenu(null)}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenu?.x ?? 0}px`,
top: `${contextMenu?.y ?? 0}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={handleHide}>Hide canvas controls</PopoverItem>
</PopoverContent>
</Popover>
</>
)
}

View File

@@ -0,0 +1 @@
export { ActionBar } from './action-bar'

View File

@@ -20,6 +20,7 @@ import {
PopoverItem,
PopoverScrollArea,
PopoverTrigger,
Tooltip,
Trash,
} from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
@@ -896,7 +897,7 @@ export function Chat() {
<div className='flex flex-shrink-0 items-center gap-[8px]'>
{/* More menu with actions */}
<Popover variant='default' open={moreMenuOpen} onOpenChange={setMoreMenuOpen}>
<Popover variant='default' size='sm' open={moreMenuOpen} onOpenChange={setMoreMenuOpen}>
<PopoverTrigger asChild>
<Button
variant='ghost'
@@ -1069,17 +1070,21 @@ export function Chat() {
{/* Buttons positioned absolutely on the right */}
<div className='-translate-y-1/2 absolute top-1/2 right-[2px] flex items-center gap-[10px]'>
<Badge
onClick={() => document.getElementById('floating-chat-file-input')?.click()}
title='Attach file'
className={cn(
'!bg-transparent !border-0 cursor-pointer rounded-[6px] p-[0px]',
(!activeWorkflowId || isExecuting || chatFiles.length >= 15) &&
'cursor-not-allowed opacity-50'
)}
>
<Paperclip className='!h-3.5 !w-3.5' />
</Badge>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Badge
onClick={() => document.getElementById('floating-chat-file-input')?.click()}
className={cn(
'!bg-transparent !border-0 cursor-pointer rounded-[6px] p-[0px]',
(!activeWorkflowId || isExecuting || chatFiles.length >= 15) &&
'cursor-not-allowed opacity-50'
)}
>
<Paperclip className='!h-3.5 !w-3.5' />
</Badge>
</Tooltip.Trigger>
<Tooltip.Content>Attach file</Tooltip.Content>
</Tooltip.Root>
{isStreaming ? (
<Button

View File

@@ -27,6 +27,9 @@ export function PaneContextMenu({
onToggleVariables,
onToggleChat,
onInvite,
onZoomIn,
onZoomOut,
onFitView,
isVariablesOpen = false,
isChatOpen = false,
hasClipboard = false,
@@ -113,6 +116,33 @@ export function PaneContextMenu({
<span className='ml-auto opacity-70 group-hover:opacity-100'>L</span>
</PopoverItem>
{/* View actions */}
<PopoverDivider />
<PopoverItem
onClick={() => {
onZoomIn()
onClose()
}}
>
Zoom In
</PopoverItem>
<PopoverItem
onClick={() => {
onZoomOut()
onClose()
}}
>
Zoom Out
</PopoverItem>
<PopoverItem
onClick={() => {
onFitView()
onClose()
}}
>
Fit to View
</PopoverItem>
{/* Navigation actions */}
<PopoverDivider />
<PopoverItem

View File

@@ -80,6 +80,9 @@ export interface PaneContextMenuProps {
onToggleVariables: () => void
onToggleChat: () => void
onInvite: () => void
onZoomIn: () => void
onZoomOut: () => void
onFitView: () => void
/** Whether the variables panel is currently open */
isVariablesOpen?: boolean
/** Whether the chat panel is currently open */

View File

@@ -303,8 +303,8 @@ export const DiffControls = memo(function DiffControls() {
!isResizing && 'transition-[bottom,right] duration-100 ease-out'
)}
style={{
bottom: 'calc(var(--terminal-height) + 16px)',
right: 'calc(var(--panel-width) + 16px)',
bottom: 'calc(var(--terminal-height) + 8px)',
right: 'calc(var(--panel-width) + 8px)',
}}
>
<div

View File

@@ -1,3 +1,4 @@
export { ActionBar } from './action-bar'
export { CommandList } from './command-list/command-list'
export { Cursors } from './cursors/cursors'
export { DiffControls } from './diff-controls/diff-controls'

View File

@@ -470,8 +470,17 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
{/* Content blocks in chronological order */}
{memoizedContentBlocks}
{/* Always show streaming indicator at the end while streaming */}
{isStreaming && <StreamingIndicator />}
{/* Show streaming indicator if streaming but no text content yet after tool calls */}
{isStreaming &&
!message.content &&
message.contentBlocks?.every((block) => block.type === 'tool_call') && (
<StreamingIndicator />
)}
{/* Streaming indicator when no content yet */}
{!cleanTextContent && !message.contentBlocks?.length && isStreaming && (
<StreamingIndicator />
)}
{message.errorType === 'usage_limit' && (
<div className='flex gap-1.5'>

View File

@@ -3,8 +3,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { ChevronUp, LayoutList } from 'lucide-react'
import Editor from 'react-simple-code-editor'
import { Button, Code, getCodeEditorProps, highlight, languages } from '@/components/emcn'
import { Button, Code } from '@/components/emcn'
import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool'
import { getClientTool } from '@/lib/copilot/tools/client/manager'
import { getRegisteredTools } from '@/lib/copilot/tools/client/registry'
@@ -414,8 +413,6 @@ const ACTION_VERBS = [
'Listed',
'Editing',
'Edited',
'Executing',
'Executed',
'Running',
'Ran',
'Designing',
@@ -754,70 +751,36 @@ function SubAgentToolCall({ toolCall: toolCallProp }: { toolCall: CopilotToolCal
const safeInputs = inputs && typeof inputs === 'object' ? inputs : {}
const inputEntries = Object.entries(safeInputs)
if (inputEntries.length === 0) return null
/**
* Format a value for display - handles objects, arrays, and primitives
*/
const formatValue = (value: unknown): string => {
if (value === null || value === undefined) return '-'
if (typeof value === 'string') return value || '-'
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
/**
* Check if a value is a complex type (object or array)
*/
const isComplex = (value: unknown): boolean => {
return typeof value === 'object' && value !== null
}
return (
<div className='mt-1.5 w-full overflow-hidden rounded-md border border-[var(--border-1)] bg-[var(--surface-1)]'>
{/* Header */}
<div className='flex items-center gap-[8px] border-[var(--border-1)] border-b bg-[var(--surface-2)] p-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-primary)]'>Input</span>
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
{inputEntries.length}
</span>
</div>
{/* Input entries */}
<div className='flex flex-col'>
{inputEntries.map(([key, value], index) => {
const formattedValue = formatValue(value)
const needsCodeViewer = isComplex(value)
return (
<div
key={key}
className={clsx(
'flex flex-col gap-1 px-[10px] py-[6px]',
index > 0 && 'border-[var(--border-1)] border-t'
)}
>
{/* Input key */}
<span className='font-medium text-[11px] text-[var(--text-primary)]'>{key}</span>
{/* Value display */}
{needsCodeViewer ? (
<Code.Viewer
code={formattedValue}
language='json'
showGutter={false}
className='max-h-[80px] min-h-0'
/>
) : (
<span className='font-mono text-[11px] text-[var(--text-muted)] leading-[1.3]'>
{formattedValue}
<div className='mt-1.5 w-full overflow-hidden rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-1)]'>
<table className='w-full table-fixed bg-transparent'>
<thead className='bg-transparent'>
<tr className='border-[var(--border-1)] border-b bg-transparent'>
<th className='w-[36%] border-[var(--border-1)] border-r bg-transparent px-[10px] py-[5px] text-left font-medium text-[12px] text-[var(--text-tertiary)]'>
Input
</th>
<th className='w-[64%] bg-transparent px-[10px] py-[5px] text-left font-medium text-[12px] text-[var(--text-tertiary)]'>
Value
</th>
</tr>
</thead>
<tbody className='bg-transparent'>
{inputEntries.map(([key, value]) => (
<tr key={key} className='border-[var(--border-1)] border-t bg-transparent'>
<td className='w-[36%] border-[var(--border-1)] border-r bg-transparent px-[10px] py-[6px]'>
<span className='truncate font-medium text-[var(--text-primary)] text-xs'>
{key}
</span>
)}
</div>
)
})}
</div>
</td>
<td className='w-[64%] bg-transparent px-[10px] py-[6px]'>
<span className='font-mono text-[var(--text-muted)] text-xs'>
{String(value)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
@@ -2327,136 +2290,74 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
const safeInputs = inputs && typeof inputs === 'object' ? inputs : {}
const inputEntries = Object.entries(safeInputs)
// Don't show the section if there are no inputs
// Don't show the table if there are no inputs
if (inputEntries.length === 0) {
return null
}
/**
* Format a value for display - handles objects, arrays, and primitives
*/
const formatValueForDisplay = (value: unknown): string => {
if (value === null || value === undefined) return ''
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
// For objects and arrays, use JSON.stringify with formatting
try {
return JSON.stringify(value, null, 2)
} catch {
return String(value)
}
}
/**
* Parse a string value back to its original type if possible
*/
const parseInputValue = (value: string, originalValue: unknown): unknown => {
// If original was a primitive, keep as string
if (typeof originalValue !== 'object' || originalValue === null) {
return value
}
// Try to parse as JSON for objects/arrays
try {
return JSON.parse(value)
} catch {
return value
}
}
/**
* Check if a value is a complex type (object or array)
*/
const isComplexValue = (value: unknown): boolean => {
return typeof value === 'object' && value !== null
}
return (
<div className='w-full overflow-hidden rounded-md border border-[var(--border-1)] bg-[var(--surface-1)]'>
{/* Header */}
<div className='flex items-center gap-[8px] border-[var(--border-1)] border-b bg-[var(--surface-2)] p-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-primary)]'>Edit Input</span>
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
{inputEntries.length}
</span>
</div>
{/* Input entries */}
<div className='flex flex-col'>
{inputEntries.map(([key, value], index) => {
const isComplex = isComplexValue(value)
const displayValue = formatValueForDisplay(value)
return (
<div
<div className='w-full overflow-hidden rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-1)]'>
<table className='w-full table-fixed bg-transparent'>
<thead className='bg-transparent'>
<tr className='border-[var(--border-1)] border-b bg-transparent'>
<th className='w-[36%] border-[var(--border-1)] border-r bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
Input
</th>
<th className='w-[64%] bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
Value
</th>
</tr>
</thead>
<tbody className='bg-transparent'>
{inputEntries.map(([key, value]) => (
<tr
key={key}
className={clsx(
'flex flex-col gap-1.5 px-[10px] py-[8px]',
index > 0 && 'border-[var(--border-1)] border-t'
)}
className='group relative border-[var(--border-1)] border-t bg-transparent'
>
{/* Input key */}
<span className='font-medium text-[11px] text-[var(--text-primary)]'>{key}</span>
{/* Value editor */}
{isComplex ? (
<Code.Container className='max-h-[168px] min-h-[60px]'>
<Code.Content>
<Editor
value={displayValue}
onValueChange={(newCode) => {
const parsedValue = parseInputValue(newCode, value)
const newInputs = { ...safeInputs, [key]: parsedValue }
<td className='relative w-[36%] border-[var(--border-1)] border-r bg-transparent p-0'>
<div className='px-[10px] py-[8px]'>
<span className='truncate font-medium text-[var(--text-primary)] text-xs'>
{key}
</span>
</div>
</td>
<td className='relative w-[64%] bg-transparent p-0'>
<div className='min-w-0 px-[10px] py-[8px]'>
<input
type='text'
value={String(value)}
onChange={(e) => {
const newInputs = { ...safeInputs, [key]: e.target.value }
if (isNestedInWorkflowInput) {
setEditedParams({ ...editedParams, workflow_input: newInputs })
} else if (typeof editedParams.input === 'string') {
setEditedParams({ ...editedParams, input: JSON.stringify(newInputs) })
} else if (
editedParams.input &&
typeof editedParams.input === 'object'
) {
setEditedParams({ ...editedParams, input: newInputs })
} else if (
editedParams.inputs &&
typeof editedParams.inputs === 'object'
) {
setEditedParams({ ...editedParams, inputs: newInputs })
} else {
setEditedParams({ ...editedParams, [key]: parsedValue })
}
}}
highlight={(code) => highlight(code, languages.json, 'json')}
{...getCodeEditorProps()}
className={clsx(getCodeEditorProps().className, 'min-h-[40px]')}
style={{ minHeight: '40px' }}
/>
</Code.Content>
</Code.Container>
) : (
<input
type='text'
value={displayValue}
onChange={(e) => {
const parsedValue = parseInputValue(e.target.value, value)
const newInputs = { ...safeInputs, [key]: parsedValue }
if (isNestedInWorkflowInput) {
setEditedParams({ ...editedParams, workflow_input: newInputs })
} else if (typeof editedParams.input === 'string') {
setEditedParams({ ...editedParams, input: JSON.stringify(newInputs) })
} else if (editedParams.input && typeof editedParams.input === 'object') {
setEditedParams({ ...editedParams, input: newInputs })
} else if (editedParams.inputs && typeof editedParams.inputs === 'object') {
setEditedParams({ ...editedParams, inputs: newInputs })
} else {
setEditedParams({ ...editedParams, [key]: parsedValue })
}
}}
className='w-full rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-1)] px-[8px] py-[6px] font-medium font-mono text-[13px] text-[var(--text-primary)] outline-none transition-colors placeholder:text-[var(--text-muted)] focus:outline-none'
/>
)}
</div>
)
})}
</div>
// Determine how to update based on original structure
if (isNestedInWorkflowInput) {
// Update workflow_input
setEditedParams({ ...editedParams, workflow_input: newInputs })
} else if (typeof editedParams.input === 'string') {
// Input was a JSON string, serialize back
setEditedParams({ ...editedParams, input: JSON.stringify(newInputs) })
} else if (editedParams.input && typeof editedParams.input === 'object') {
// Input is an object
setEditedParams({ ...editedParams, input: newInputs })
} else if (
editedParams.inputs &&
typeof editedParams.inputs === 'object'
) {
// Inputs is an object
setEditedParams({ ...editedParams, inputs: newInputs })
} else {
// Flat structure - update at base level
setEditedParams({ ...editedParams, [key]: e.target.value })
}
}}
className='w-full bg-transparent font-mono text-[var(--text-muted)] text-xs outline-none focus:text-[var(--text-primary)]'
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
@@ -2542,8 +2443,8 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
<ShimmerOverlayText
text={displayName}
active={isLoadingState}
isSpecial={isSpecial}
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
isSpecial={false}
className='font-[470] font-season text-[var(--text-muted)] text-sm'
/>
</div>
{code && (

View File

@@ -124,10 +124,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
isSendingMessage,
})
// Handle scroll management (80px stickiness for copilot)
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage, {
stickinessThreshold: 80,
})
// Handle scroll management
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage)
// Handle chat history grouping
const { groupedChats, handleHistoryDropdownOpen: handleHistoryDropdownOpenHook } = useChatHistory(

View File

@@ -2,11 +2,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Maximize2 } from 'lucide-react'
import {
Button,
ButtonGroup,
ButtonGroupItem,
Expand,
Label,
Modal,
ModalBody,
@@ -222,7 +222,7 @@ export function GeneralDeploy({
onClick={() => setShowExpandedPreview(true)}
className='absolute right-[8px] bottom-[8px] z-10 h-[28px] w-[28px] cursor-pointer border border-[var(--border)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
>
<Maximize2 className='h-[14px] w-[14px]' />
<Expand className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>See preview</Tooltip.Content>

View File

@@ -2,4 +2,3 @@ export { Copilot } from './copilot/copilot'
export { Deploy } from './deploy/deploy'
export { Editor } from './editor/editor'
export { Toolbar } from './toolbar/toolbar'
export { WorkflowControls } from './workflow-controls/workflow-controls'

View File

@@ -327,12 +327,14 @@ export const Toolbar = forwardRef<ToolbarRef, ToolbarProps>(function Toolbar(
/**
* Handle search input blur.
*
* We intentionally keep search mode active after blur so that ArrowUp/Down
* navigation continues to work after the first move from the search input
* into the triggers/blocks list (e.g. when initiated via Mod+F).
* If the search query is empty, deactivate search mode to show the search icon again.
* If there's a query, keep search mode active so ArrowUp/Down navigation continues
* to work after focus moves into the triggers/blocks list (e.g. when initiated via Mod+F).
*/
const handleSearchBlur = () => {
// No-op by design
if (!searchQuery.trim()) {
setIsSearchActive(false)
}
}
/**

View File

@@ -1,51 +0,0 @@
'use client'
import { Button, Redo, Undo } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useUndoRedoStore } from '@/stores/undo-redo'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
/**
* Workflow controls component that provides undo/redo functionality.
* Styled to align with the panel tab buttons.
*/
export function WorkflowControls() {
const { undo, redo } = useCollaborativeWorkflow()
const { activeWorkflowId } = useWorkflowRegistry()
const { data: session } = useSession()
const userId = session?.user?.id || 'unknown'
const stacks = useUndoRedoStore((s) => s.stacks)
const undoRedoSizes = (() => {
const key = activeWorkflowId && userId ? `${activeWorkflowId}:${userId}` : ''
const stack = (key && stacks[key]) || { undo: [], redo: [] }
return { undoSize: stack.undo.length, redoSize: stack.redo.length }
})()
const canUndo = undoRedoSizes.undoSize > 0
const canRedo = undoRedoSizes.redoSize > 0
return (
<div className='flex gap-[2px]'>
<Button
className='h-[28px] rounded-[6px] rounded-r-none border border-transparent px-[6px] py-[5px] hover:border-[var(--border-1)] hover:bg-[var(--surface-5)]'
onClick={undo}
variant={canUndo ? 'active' : 'ghost'}
disabled={!canUndo}
title='Undo (Cmd+Z)'
>
<Undo className='h-[12px] w-[12px]' />
</Button>
<Button
className='h-[28px] rounded-[6px] rounded-l-none border border-transparent px-[6px] py-[5px] hover:border-[var(--border-1)] hover:bg-[var(--surface-5)]'
onClick={redo}
variant={canRedo ? 'active' : 'ghost'}
disabled={!canRedo}
title='Redo (Cmd+Shift+Z)'
>
<Redo className='h-[12px] w-[12px]' />
</Button>
</div>
)
}

View File

@@ -8,7 +8,10 @@ import {
BubbleChatClose,
BubbleChatPreview,
Button,
ChevronDown,
Copy,
Cursor,
Hand,
Layout,
Modal,
ModalBody,
@@ -43,6 +46,7 @@ import { useAutoLayout } from '@/app/workspace/[workspaceId]/w/[workflowId]/hook
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useCanvasModeStore } from '@/stores/canvas-mode'
import { useChatStore } from '@/stores/chat/store'
import type { PanelTab } from '@/stores/panel'
import { usePanelStore, useVariablesStore as usePanelVariablesStore } from '@/stores/panel'
@@ -92,6 +96,7 @@ export function Panel() {
const [isExporting, setIsExporting] = useState(false)
const [isDuplicating, setIsDuplicating] = useState(false)
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [isCanvasModeOpen, setIsCanvasModeOpen] = useState(false)
// Hooks
const userPermissions = useUserPermissionsContext()
@@ -160,6 +165,9 @@ export function Panel() {
const { isChatOpen, setIsChatOpen } = useChatStore()
const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore()
// Canvas mode
const { mode: canvasMode, setMode: setCanvasMode } = useCanvasModeStore()
const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null
/**
@@ -496,8 +504,58 @@ export function Panel() {
</Button>
</div>
{/* Workflow Controls (Undo/Redo) */}
{/* <WorkflowControls /> */}
{/* Canvas Mode Selector */}
<Popover
open={isCanvasModeOpen}
onOpenChange={setIsCanvasModeOpen}
variant='secondary'
size='sm'
>
<PopoverTrigger asChild>
<div className='flex cursor-pointer items-center gap-[4px]'>
<Button className='h-[28px] w-[28px] rounded-[6px] p-0' variant='active'>
{canvasMode === 'hand' ? (
<Hand className='h-[14px] w-[14px]' />
) : (
<Cursor className='h-[14px] w-[14px]' />
)}
</Button>
<Button className='!p-[2px] group' variant='ghost'>
<ChevronDown
className={`h-[8px] w-[10px] text-[var(--text-muted)] transition-transform duration-100 group-hover:text-[var(--text-secondary)] ${
isCanvasModeOpen ? 'rotate-180' : ''
}`}
/>
</Button>
</div>
</PopoverTrigger>
<PopoverContent
align='end'
side='bottom'
sideOffset={8}
maxWidth={100}
minWidth={100}
>
<PopoverItem
onClick={() => {
setCanvasMode('cursor')
setIsCanvasModeOpen(false)
}}
>
<Cursor className='h-3 w-3' />
<span>Pointer</span>
</PopoverItem>
<PopoverItem
onClick={() => {
setCanvasMode('hand')
setIsCanvasModeOpen(false)
}}
>
<Hand className='h-3 w-3' />
<span>Mover</span>
</PopoverItem>
</PopoverContent>
</Popover>
</div>
{/* Tab Content - Keep all tabs mounted but hidden to preserve state */}

View File

@@ -148,7 +148,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
ref={blockRef}
onClick={() => setCurrentBlockId(id)}
className={cn(
'relative cursor-pointer select-none rounded-[8px] border border-[var(--border-1)]',
'workflow-drag-handle relative cursor-grab select-none rounded-[8px] border border-[var(--border)] [&:active]:cursor-grabbing',
'transition-block-bg transition-ring',
'z-[20]'
)}
@@ -166,11 +166,8 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
{/* Header Section */}
<div
className={cn(
'workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing'
'flex items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px]'
)}
onMouseDown={(e) => {
e.stopPropagation()
}}
>
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
<div

View File

@@ -11,6 +11,16 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 }
const ACTION_BUTTON_STYLES = [
'h-[23px] w-[23px] rounded-[8px] p-0',
'border border-[var(--border)] bg-[var(--surface-5)]',
'text-[var(--text-secondary)]',
'hover:border-transparent hover:bg-[var(--brand-secondary)] hover:!text-[var(--text-inverse)]',
'dark:border-transparent dark:bg-[var(--surface-7)] dark:hover:bg-[var(--brand-secondary)]',
].join(' ')
const ICON_SIZE = 'h-[11px] w-[11px]'
/**
* Props for the ActionBar component
*/
@@ -110,7 +120,9 @@ export const ActionBar = memo(
'-top-[46px] absolute right-0',
'flex flex-row items-center',
'opacity-0 transition-opacity duration-200 group-hover:opacity-100',
'gap-[5px] rounded-[10px] bg-[var(--surface-4)] p-[5px]'
'gap-[5px] rounded-[10px] p-[5px]',
'border border-[var(--border)] bg-[var(--surface-2)]',
'dark:border-transparent dark:bg-[var(--surface-4)]'
)}
>
{!isNoteBlock && (
@@ -124,14 +136,10 @@ export const ActionBar = memo(
collaborativeBatchToggleBlockEnabled([blockId])
}
}}
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
className={ACTION_BUTTON_STYLES}
disabled={disabled}
>
{isEnabled ? (
<Circle className='h-[11px] w-[11px]' />
) : (
<CircleOff className='h-[11px] w-[11px]' />
)}
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
@@ -151,10 +159,10 @@ export const ActionBar = memo(
handleDuplicateBlock()
}
}}
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
className={ACTION_BUTTON_STYLES}
disabled={disabled}
>
<Copy className='h-[11px] w-[11px]' />
<Copy className={ICON_SIZE} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{getTooltipMessage('Duplicate Block')}</Tooltip.Content>
@@ -172,13 +180,13 @@ export const ActionBar = memo(
collaborativeBatchToggleBlockHandles([blockId])
}
}}
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
className={ACTION_BUTTON_STYLES}
disabled={disabled}
>
{horizontalHandles ? (
<ArrowLeftRight className='h-[11px] w-[11px]' />
<ArrowLeftRight className={ICON_SIZE} />
) : (
<ArrowUpDown className='h-[11px] w-[11px]' />
<ArrowUpDown className={ICON_SIZE} />
)}
</Button>
</Tooltip.Trigger>
@@ -201,10 +209,10 @@ export const ActionBar = memo(
)
}
}}
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
className={ACTION_BUTTON_STYLES}
disabled={disabled || !userPermissions.canEdit}
>
<LogOut className='h-[11px] w-[11px]' />
<LogOut className={ICON_SIZE} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{getTooltipMessage('Remove from Subflow')}</Tooltip.Content>
@@ -221,10 +229,10 @@ export const ActionBar = memo(
collaborativeBatchRemoveBlocks([blockId])
}
}}
className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]'
className={ACTION_BUTTON_STYLES}
disabled={disabled}
>
<Trash2 className='h-[11px] w-[11px]' />
<Trash2 className={ICON_SIZE} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{getTooltipMessage('Delete Block')}</Tooltip.Content>

View File

@@ -950,7 +950,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
ref={contentRef}
onClick={handleClick}
className={cn(
'relative z-[20] w-[250px] cursor-default select-none rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)]'
'workflow-drag-handle relative z-[20] w-[250px] cursor-grab select-none rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)] [&:active]:cursor-grabbing'
)}
>
{isPending && (
@@ -986,12 +986,9 @@ export const WorkflowBlock = memo(function WorkflowBlock({
<div
className={cn(
'workflow-drag-handle flex cursor-grab items-center justify-between p-[8px] [&:active]:cursor-grabbing',
'flex items-center justify-between p-[8px]',
hasContentBelowHeader && 'border-[var(--border-1)] border-b'
)}
onMouseDown={(e) => {
e.stopPropagation()
}}
>
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-[10px]'>
<div

View File

@@ -4,7 +4,7 @@ export {
computeParentUpdateEntries,
getClampedPositionForNode,
isInEditableElement,
resolveParentChildSelectionConflicts,
selectNodesDeferred,
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 { calculateContainerDimensions, useNodeUtilities } from './use-node-utilities'
export { useNodeUtilities } from './use-node-utilities'
export { usePreventZoom } from './use-prevent-zoom'
export { useScrollManagement } from './use-scroll-management'
export { useWorkflowExecution } from './use-workflow-execution'

View File

@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import { useReactFlow } from 'reactflow'
import type { AutoLayoutOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils'
import { applyAutoLayoutAndUpdateStore as applyAutoLayoutStandalone } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils'
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
export type { AutoLayoutOptions }
@@ -16,7 +17,8 @@ const logger = createLogger('useAutoLayout')
* Note: This hook requires a ReactFlowProvider ancestor.
*/
export function useAutoLayout(workflowId: string | null) {
const { fitView } = useReactFlow()
const reactFlowInstance = useReactFlow()
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
const applyAutoLayoutAndUpdateStore = useCallback(
async (options: AutoLayoutOptions = {}) => {
@@ -38,7 +40,7 @@ export function useAutoLayout(workflowId: string | null) {
if (result.success) {
logger.info('Auto layout completed successfully')
requestAnimationFrame(() => {
fitView({ padding: 0.8, duration: 600 })
fitViewToBounds({ padding: 0.15, duration: 600 })
})
} else {
logger.error('Auto layout failed:', result.error)
@@ -52,7 +54,7 @@ export function useAutoLayout(workflowId: string | null) {
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}, [applyAutoLayoutAndUpdateStore, fitView])
}, [applyAutoLayoutAndUpdateStore, fitViewToBounds])
return {
applyAutoLayoutAndUpdateStore,

View File

@@ -62,47 +62,6 @@ export function clampPositionToContainer(
}
}
/**
* Calculates container dimensions based on child block positions.
* Single source of truth for container sizing - ensures consistency between
* live drag updates and final dimension calculations.
*
* @param childPositions - Array of child positions with their dimensions
* @returns Calculated width and height for the container
*/
export function calculateContainerDimensions(
childPositions: Array<{ x: number; y: number; width: number; height: number }>
): { width: number; height: number } {
if (childPositions.length === 0) {
return {
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
}
let maxRight = 0
let maxBottom = 0
for (const child of childPositions) {
maxRight = Math.max(maxRight, child.x + child.width)
maxBottom = Math.max(maxBottom, child.y + child.height)
}
const width = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
)
const height = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
CONTAINER_DIMENSIONS.HEADER_HEIGHT +
CONTAINER_DIMENSIONS.TOP_PADDING +
maxBottom +
CONTAINER_DIMENSIONS.BOTTOM_PADDING
)
return { width, height }
}
/**
* Hook providing utilities for node position, hierarchy, and dimension calculations
*/
@@ -347,16 +306,36 @@ export function useNodeUtilities(blocks: Record<string, any>) {
(id) => currentBlocks[id]?.data?.parentId === nodeId
)
const childPositions = childBlockIds
.map((childId) => {
const child = currentBlocks[childId]
if (!child?.position) return null
const { width, height } = getBlockDimensions(childId)
return { x: child.position.x, y: child.position.y, width, height }
})
.filter((p): p is NonNullable<typeof p> => p !== null)
if (childBlockIds.length === 0) {
return {
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
}
return calculateContainerDimensions(childPositions)
let maxRight = 0
let maxBottom = 0
for (const childId of childBlockIds) {
const child = currentBlocks[childId]
if (!child?.position) continue
const { width: childWidth, height: childHeight } = getBlockDimensions(childId)
maxRight = Math.max(maxRight, child.position.x + childWidth)
maxBottom = Math.max(maxBottom, child.position.y + childHeight)
}
const width = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
)
const height = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING
)
return { width, height }
},
[getBlockDimensions]
)

View File

@@ -12,12 +12,6 @@ interface UseScrollManagementOptions {
* - `auto`: immediate scroll to bottom (used by floating chat to avoid jitter).
*/
behavior?: 'auto' | 'smooth'
/**
* Distance from bottom (in pixels) within which auto-scroll stays active.
* Lower values = less sticky (user can scroll away easier).
* Default is 100px.
*/
stickinessThreshold?: number
}
/**
@@ -40,7 +34,6 @@ export function useScrollManagement(
const programmaticScrollInProgressRef = useRef(false)
const lastScrollTopRef = useRef(0)
const scrollBehavior: 'auto' | 'smooth' = options?.behavior ?? 'smooth'
const stickinessThreshold = options?.stickinessThreshold ?? 100
/**
* Scrolls the container to the bottom with smooth animation
@@ -81,7 +74,7 @@ export function useScrollManagement(
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const nearBottom = distanceFromBottom <= stickinessThreshold
const nearBottom = distanceFromBottom <= 100
setIsNearBottom(nearBottom)
if (isSendingMessage) {
@@ -102,7 +95,7 @@ export function useScrollManagement(
// Track last scrollTop for direction detection
lastScrollTopRef.current = scrollTop
}, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream, stickinessThreshold])
}, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream])
// Attach scroll listener
useEffect(() => {
@@ -181,20 +174,14 @@ export function useScrollManagement(
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const nearBottom = distanceFromBottom <= stickinessThreshold
const nearBottom = distanceFromBottom <= 120
if (nearBottom) {
scrollToBottom()
}
}, 100)
return () => window.clearInterval(intervalId)
}, [
isSendingMessage,
userHasScrolledDuringStream,
getScrollContainer,
scrollToBottom,
stickinessThreshold,
])
}, [isSendingMessage, userHasScrolledDuringStream, getScrollContainer, scrollToBottom])
return {
scrollAreaRef,

View File

@@ -65,6 +65,27 @@ 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?: {
@@ -165,26 +186,3 @@ export function computeParentUpdateEntries(
}
})
}
/**
* Resolves parent-child selection conflicts by deselecting children whose parent is also selected.
*/
export function resolveParentChildSelectionConflicts(
nodes: Node[],
blocks: Record<string, { data?: { parentId?: string } }>
): Node[] {
const selectedIds = new Set(nodes.filter((n) => n.selected).map((n) => n.id))
let hasConflict = false
const resolved = nodes.map((n) => {
if (!n.selected) return n
const parentId = n.parentId || blocks[n.id]?.data?.parentId
if (parentId && selectedIds.has(parentId)) {
hasConflict = true
return { ...n, selected: false }
}
return n
})
return hasConflict ? resolved : nodes
}

View File

@@ -24,6 +24,7 @@ import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/b
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
ActionBar,
CommandList,
DiffControls,
Notifications,
@@ -47,7 +48,7 @@ import {
computeClampedPositionUpdates,
getClampedPositionForNode,
isInEditableElement,
resolveParentChildSelectionConflicts,
selectNodesDeferred,
useAutoLayout,
useCurrentWorkflow,
useNodeUtilities,
@@ -55,7 +56,6 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useCanvasContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu'
import {
calculateContainerDimensions,
clampPositionToContainer,
estimateBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities'
@@ -63,8 +63,10 @@ import { useSocket } from '@/app/workspace/providers/socket-provider'
import { getBlock } from '@/blocks'
import { isAnnotationOnlyBlock } from '@/executor/constants'
import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
import { useCanvasModeStore } from '@/stores/canvas-mode'
import { useChatStore } from '@/stores/chat/store'
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
import { useExecutionStore } from '@/stores/execution'
@@ -209,9 +211,9 @@ const WorkflowContent = React.memo(() => {
const [isCanvasReady, setIsCanvasReady] = useState(false)
const [potentialParentId, setPotentialParentId] = useState<string | null>(null)
const [selectedEdges, setSelectedEdges] = useState<SelectedEdgesMap>(new Map())
const [isShiftPressed, setIsShiftPressed] = useState(false)
const [isSelectionDragActive, setIsSelectionDragActive] = useState(false)
const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false)
const canvasMode = useCanvasModeStore((state) => state.mode)
const isHandMode = canvasMode === 'hand'
const [oauthModal, setOauthModal] = useState<{
provider: OAuthProvider
serviceId: string
@@ -222,7 +224,9 @@ const WorkflowContent = React.memo(() => {
const params = useParams()
const router = useRouter()
const { screenToFlowPosition, getNodes, setNodes, fitView, getIntersectingNodes } = useReactFlow()
const reactFlowInstance = useReactFlow()
const { screenToFlowPosition, getNodes, setNodes, getIntersectingNodes } = reactFlowInstance
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
const { emitCursorUpdate } = useSocket()
const workspaceId = params.workspaceId as string
@@ -357,9 +361,6 @@ 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(() => {
@@ -691,12 +692,6 @@ 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,
@@ -704,6 +699,11 @@ const WorkflowContent = React.memo(() => {
pastedParallels,
pastedSubBlockValues
)
selectNodesDeferred(
pastedBlocksArray.map((b) => b.id),
setDisplayNodes
)
}, [
hasClipboard,
clipboard,
@@ -740,12 +740,6 @@ 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,
@@ -753,6 +747,11 @@ const WorkflowContent = React.memo(() => {
pastedParallels,
pastedSubBlockValues
)
selectNodesDeferred(
pastedBlocksArray.map((b) => b.id),
setDisplayNodes
)
}, [
contextMenuBlocks,
copyBlocks,
@@ -886,12 +885,6 @@ 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,
@@ -899,6 +892,11 @@ const WorkflowContent = React.memo(() => {
pasteData.parallels,
pasteData.subBlockValues
)
selectNodesDeferred(
pastedBlocks.map((b) => b.id),
setDisplayNodes
)
}
}
}
@@ -1505,10 +1503,10 @@ const WorkflowContent = React.memo(() => {
foundNodes: changedNodes.length,
})
requestAnimationFrame(() => {
fitView({
fitViewToBounds({
nodes: changedNodes,
duration: 600,
padding: 0.3,
padding: 0.1,
minZoom: 0.5,
maxZoom: 1.0,
})
@@ -1516,18 +1514,18 @@ const WorkflowContent = React.memo(() => {
} else {
logger.info('Diff ready - no changed nodes found, fitting all')
requestAnimationFrame(() => {
fitView({ padding: 0.3, duration: 600 })
fitViewToBounds({ padding: 0.1, duration: 600 })
})
}
} else {
logger.info('Diff ready - no changed blocks, fitting all')
requestAnimationFrame(() => {
fitView({ padding: 0.3, duration: 600 })
fitViewToBounds({ padding: 0.1, duration: 600 })
})
}
}
prevDiffReadyRef.current = isDiffReady
}, [isDiffReady, diffAnalysis, fitView, getNodes])
}, [isDiffReady, diffAnalysis, fitViewToBounds, getNodes])
/** Displays trigger warning notifications. */
useEffect(() => {
@@ -1920,68 +1918,15 @@ const WorkflowContent = React.memo(() => {
const [displayNodes, setDisplayNodes] = useState<Node[]>([])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') setIsShiftPressed(true)
}
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') setIsShiftPressed(false)
}
const handleFocusLoss = () => {
setIsShiftPressed(false)
setIsSelectionDragActive(false)
}
const handleVisibilityChange = () => {
if (document.hidden) {
handleFocusLoss()
}
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
window.addEventListener('blur', handleFocusLoss)
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
window.removeEventListener('blur', handleFocusLoss)
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [])
useEffect(() => {
if (isShiftPressed) {
document.body.style.userSelect = 'none'
} else {
document.body.style.userSelect = ''
}
return () => {
document.body.style.userSelect = ''
}
}, [isShiftPressed])
useEffect(() => {
// Check for pending selection (from paste/duplicate), otherwise preserve existing selection
const pendingSelection = pendingSelectionRef.current
pendingSelectionRef.current = null
// Preserve selection state when syncing from derivedNodes
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, blocks])
}, [derivedNodes])
/** Handles ActionBar remove-from-subflow events. */
useEffect(() => {
@@ -2056,17 +2001,10 @@ const WorkflowContent = React.memo(() => {
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
}, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent])
/** Handles node changes - applies changes and resolves parent-child selection conflicts. */
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
setDisplayNodes((nds) => {
const updated = applyNodeChanges(changes, nds)
const hasSelectionChange = changes.some((c) => c.type === 'select')
return hasSelectionChange ? resolveParentChildSelectionConflicts(updated, blocks) : updated
})
},
[blocks]
)
/** Handles node position changes - updates local state for smooth drag, syncs to store only on drag end. */
const onNodesChange = useCallback((changes: NodeChange[]) => {
setDisplayNodes((nds) => applyNodeChanges(changes, nds))
}, [])
/**
* Updates container dimensions in displayNodes during drag.
@@ -2081,13 +2019,28 @@ const WorkflowContent = React.memo(() => {
const childNodes = currentNodes.filter((n) => n.parentId === parentId)
if (childNodes.length === 0) return currentNodes
const childPositions = childNodes.map((node) => {
let maxRight = 0
let maxBottom = 0
childNodes.forEach((node) => {
const nodePosition = node.id === draggedNodeId ? draggedNodePosition : node.position
const { width, height } = getBlockDimensions(node.id)
return { x: nodePosition.x, y: nodePosition.y, width, height }
const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id)
maxRight = Math.max(maxRight, nodePosition.x + nodeWidth)
maxBottom = Math.max(maxBottom, nodePosition.y + nodeHeight)
})
const { width: newWidth, height: newHeight } = calculateContainerDimensions(childPositions)
const newWidth = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
)
const newHeight = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
CONTAINER_DIMENSIONS.HEADER_HEIGHT +
CONTAINER_DIMENSIONS.TOP_PADDING +
maxBottom +
CONTAINER_DIMENSIONS.BOTTOM_PADDING
)
return currentNodes.map((node) => {
if (node.id === parentId) {
@@ -2847,50 +2800,27 @@ const WorkflowContent = React.memo(() => {
]
)
// Lock selection mode when selection drag starts (captures Shift state at drag start)
const onSelectionStart = useCallback(() => {
if (isShiftPressed) {
setIsSelectionDragActive(true)
}
}, [isShiftPressed])
const onSelectionEnd = useCallback(() => {
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)
}
// 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
// Capture all selected nodes' positions for undo/redo
multiNodeDragStartRef.current.clear()
effectiveNodes.forEach((n) => {
const blk = blocks[n.id]
if (blk) {
nodes.forEach((n) => {
const block = blocks[n.id]
if (block) {
multiNodeDragStartRef.current.set(n.id, {
x: n.position.x,
y: n.position.y,
parentId: blk.data?.parentId,
parentId: block.data?.parentId,
})
}
})
// Apply visual deselection of children
setDisplayNodes((allNodes) => resolveParentChildSelectionConflicts(allNodes, blocks))
},
[blocks]
)
@@ -2926,6 +2856,7 @@ const WorkflowContent = React.memo(() => {
eligibleNodes.forEach((node) => {
const absolutePos = getNodeAbsolutePosition(node.id)
const block = blocks[node.id]
const width = BLOCK_DIMENSIONS.FIXED_WIDTH
const height = Math.max(
node.height || BLOCK_DIMENSIONS.MIN_HEIGHT,
@@ -3018,7 +2949,6 @@ const WorkflowContent = React.memo(() => {
const onSelectionDragStop = useCallback(
(_event: React.MouseEvent, nodes: any[]) => {
requestAnimationFrame(() => setIsSelectionDragActive(false))
clearDragHighlights()
if (nodes.length === 0) return
@@ -3151,11 +3081,13 @@ const WorkflowContent = React.memo(() => {
/**
* Handles node click to select the node in ReactFlow.
* Parent-child conflict resolution happens automatically in onNodesChange.
* This ensures clicking anywhere on a block (not just the drag handle)
* selects it for delete/backspace and multi-select operations.
*/
const handleNodeClick = useCallback(
(event: React.MouseEvent, node: Node) => {
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
setNodes((nodes) =>
nodes.map((n) => ({
...n,
@@ -3347,11 +3279,9 @@ const WorkflowContent = React.memo(() => {
onPointerMove={handleCanvasPointerMove}
onPointerLeave={handleCanvasPointerLeave}
elementsSelectable={true}
selectionOnDrag={isShiftPressed || isSelectionDragActive}
selectionOnDrag={!isHandMode}
selectionMode={SelectionMode.Partial}
panOnDrag={isShiftPressed || isSelectionDragActive ? false : [0, 1]}
onSelectionStart={onSelectionStart}
onSelectionEnd={onSelectionEnd}
panOnDrag={isHandMode ? [0, 1] : false}
multiSelectionKeyCode={['Meta', 'Control', 'Shift']}
nodesConnectable={effectivePermissions.canEdit}
nodesDraggable={effectivePermissions.canEdit}
@@ -3359,7 +3289,7 @@ const WorkflowContent = React.memo(() => {
noWheelClassName='allow-scroll'
edgesFocusable={true}
edgesUpdatable={effectivePermissions.canEdit}
className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'}`}
className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'} ${isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'}`}
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
onSelectionDragStart={effectivePermissions.canEdit ? onSelectionDragStart : undefined}
@@ -3378,6 +3308,8 @@ const WorkflowContent = React.memo(() => {
<Cursors />
<ActionBar />
<Suspense fallback={null}>
<LazyChat />
</Suspense>
@@ -3419,6 +3351,9 @@ const WorkflowContent = React.memo(() => {
onToggleVariables={handleContextToggleVariables}
onToggleChat={handleContextToggleChat}
onInvite={handleContextInvite}
onZoomIn={() => reactFlowInstance.zoomIn()}
onZoomOut={() => reactFlowInstance.zoomOut()}
onFitView={() => fitViewToBounds({ padding: 0.1, duration: 300 })}
isVariablesOpen={isVariablesOpen}
isChatOpen={isChatOpen}
hasClipboard={hasClipboard()}

View File

@@ -38,7 +38,7 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowD
return (
<div
className='relative select-none rounded-[8px] border border-[var(--border-1)]'
className='relative select-none rounded-[8px] border border-[var(--border)]'
style={{
width,
height,

View File

@@ -87,6 +87,12 @@ function GeneralSkeleton() {
<Skeleton className='h-8 w-[100px] rounded-[4px]' />
</div>
{/* Show canvas controls row */}
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-32' />
<Skeleton className='h-[17px] w-[30px] rounded-full' />
</div>
{/* Telemetry row */}
<div className='flex items-center justify-between border-t pt-[16px]'>
<Skeleton className='h-4 w-44' />
@@ -310,6 +316,12 @@ export function General({ onOpenChange }: GeneralProps) {
}
}
const handleShowActionBarChange = async (checked: boolean) => {
if (checked !== settings?.showActionBar && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'showActionBar', value: checked })
}
}
const handleTrainingControlsChange = async (checked: boolean) => {
if (checked !== settings?.showTrainingControls && !updateSetting.isPending) {
await updateSetting.mutateAsync({ key: 'showTrainingControls', value: checked })
@@ -519,6 +531,15 @@ export function General({ onOpenChange }: GeneralProps) {
</div>
</div>
<div className='flex items-center justify-between'>
<Label htmlFor='show-action-bar'>Show canvas controls</Label>
<Switch
id='show-action-bar'
checked={settings?.showActionBar ?? true}
onCheckedChange={handleShowActionBarChange}
/>
</div>
<div className='flex items-center justify-between border-t pt-[16px]'>
<Label htmlFor='telemetry'>Allow anonymous telemetry</Label>
<Switch

View File

@@ -147,12 +147,6 @@ export function ContextMenu({
disableCreate = false,
disableCreateFolder = false,
}: ContextMenuProps) {
// Section visibility for divider logic
const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
const hasEditSection =
(showRename && onRename) || (showCreate && onCreate) || (showCreateFolder && onCreateFolder)
const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport)
return (
<Popover
open={isOpen}
@@ -182,7 +176,7 @@ export function ContextMenu({
Open in new tab
</PopoverItem>
)}
{hasNavigationSection && (hasEditSection || hasCopySection) && <PopoverDivider />}
{showOpenInNewTab && onOpenInNewTab && <PopoverDivider />}
{/* Edit and create actions */}
{showRename && onRename && (
@@ -220,7 +214,7 @@ export function ContextMenu({
)}
{/* Copy and export actions */}
{hasEditSection && hasCopySection && <PopoverDivider />}
{(showDuplicate || showExport) && <PopoverDivider />}
{showDuplicate && onDuplicate && (
<PopoverItem
disabled={disableDuplicate}
@@ -245,7 +239,7 @@ export function ContextMenu({
)}
{/* Destructive action */}
{(hasNavigationSection || hasEditSection || hasCopySection) && <PopoverDivider />}
<PopoverDivider />
<PopoverItem
disabled={disableDelete}
onClick={() => {

View File

@@ -11,9 +11,9 @@ export const PermissionsTableSkeleton = React.memo(() => (
</div>
<div className='flex flex-shrink-0 items-center'>
<div className='inline-flex gap-[2px]'>
<Skeleton className='h-[26px] w-[44px] rounded-[4px]' />
<Skeleton className='h-[26px] w-[44px] rounded-[4px]' />
<Skeleton className='h-[26px] w-[44px] rounded-[4px]' />
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
</div>
</div>
</div>

View File

@@ -194,6 +194,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
const matches = text.match(emailRegex) || []
return [...new Set(matches.map((e) => e.toLowerCase()))]
},
tooltip: 'Upload emails',
}),
[userPerms.canAdmin]
)
@@ -657,7 +658,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
items={emailItems}
onAdd={(value) => addEmail(value)}
onRemove={removeEmailItem}
onInputChange={() => setErrorMessage(null)}
placeholder={
!userPerms.canAdmin
? 'Only administrators can invite new members'

View File

@@ -290,7 +290,7 @@ export function WorkspaceHeader({
<button
type='button'
aria-label='Switch workspace'
className={`flex cursor-pointer items-center gap-[8px] rounded-[6px] bg-transparent px-[6px] py-[4px] transition-colors hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)] ${
className={`group flex cursor-pointer items-center gap-[8px] rounded-[6px] bg-transparent px-[6px] py-[4px] transition-colors hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)] ${
isCollapsed ? '' : '-mx-[6px] min-w-0 max-w-full'
}`}
title={activeWorkspace?.name || 'Loading...'}
@@ -303,7 +303,7 @@ export function WorkspaceHeader({
{activeWorkspace?.name || 'Loading...'}
</span>
<ChevronDown
className={`h-[8px] w-[12px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-100 ${
className={`h-[8px] w-[10px] flex-shrink-0 text-[var(--text-muted)] transition-transform duration-100 group-hover:text-[var(--text-secondary)] ${
isWorkspaceMenuOpen ? 'rotate-180' : ''
}`}
/>
@@ -452,7 +452,7 @@ export function WorkspaceHeader({
>
{activeWorkspace?.name || 'Loading...'}
</span>
<ChevronDown className='h-[8px] w-[12px] flex-shrink-0 text-[var(--text-muted)]' />
<ChevronDown className='h-[8px] w-[10px] flex-shrink-0 text-[var(--text-muted)]' />
</button>
)}
</div>

View File

@@ -16,6 +16,7 @@ const avatarVariants = cva('relative flex shrink-0 overflow-hidden rounded-full'
sm: 'h-8 w-8',
md: 'h-10 w-10',
lg: 'h-12 w-12',
xl: 'h-16 w-16',
},
},
defaultVariants: {
@@ -41,6 +42,7 @@ const avatarStatusVariants = cva(
sm: 'h-2.5 w-2.5',
md: 'h-3 w-3',
lg: 'h-3.5 w-3.5',
xl: 'h-4 w-4',
},
},
defaultVariants: {

View File

@@ -40,6 +40,7 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { Paperclip, Plus, X } from 'lucide-react'
import { Tooltip } from '@/components/emcn/components/tooltip/tooltip'
import { cn } from '@/lib/core/utils/cn'
/**
@@ -151,6 +152,8 @@ export interface FileInputOptions {
icon?: React.ComponentType<{ className?: string; strokeWidth?: number }>
/** Extract values from file content. Each extracted value will be passed to onAdd. */
extractValues?: (text: string) => string[]
/** Tooltip text for the file input button */
tooltip?: string
}
/**
@@ -166,8 +169,6 @@ export interface TagInputProps extends VariantProps<typeof tagInputVariants> {
onAdd: (value: string) => boolean
/** Callback when a tag is removed (receives value, index, and isValid) */
onRemove: (value: string, index: number, isValid: boolean) => void
/** Callback when the input value changes (useful for clearing errors) */
onInputChange?: (value: string) => void
/** Placeholder text for the input */
placeholder?: string
/** Placeholder text when there are existing tags */
@@ -209,7 +210,6 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
items,
onAdd,
onRemove,
onInputChange,
placeholder = 'Enter values',
placeholderWithTags = 'Add another',
disabled = false,
@@ -347,12 +347,10 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
})
if (addedCount === 0 && pastedValues.length === 1) {
const newValue = inputValue + pastedValues[0]
setInputValue(newValue)
onInputChange?.(newValue)
setInputValue(inputValue + pastedValues[0])
}
},
[onAdd, inputValue, onInputChange]
[onAdd, inputValue]
)
const handleBlur = React.useCallback(() => {
@@ -427,10 +425,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
name={name}
type='text'
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
onInputChange?.(e.target.value)
}}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onBlur={handleBlur}
@@ -473,17 +468,24 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
)}
</div>
{fileInputEnabled && !disabled && (
<button
type='button'
onClick={(e) => {
e.stopPropagation()
fileInputRef.current?.click()
}}
className='absolute right-[8px] bottom-[9px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-secondary)]'
aria-label='Upload file'
>
<FileIcon className='h-[14px] w-[14px]' strokeWidth={2} />
</button>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
onClick={(e) => {
e.stopPropagation()
fileInputRef.current?.click()
}}
className='absolute right-[8px] bottom-[9px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-secondary)]'
aria-label={fileInputOptions?.tooltip ?? 'Upload file'}
>
<FileIcon className='h-[14px] w-[14px]' strokeWidth={2} />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{fileInputOptions?.tooltip ?? 'Upload file'}
</Tooltip.Content>
</Tooltip.Root>
)}
</div>
)

View File

@@ -7,7 +7,12 @@ import { cn } from '@/lib/core/utils/cn'
/**
* Tooltip provider component that must wrap your app or tooltip usage area.
*/
const Provider = TooltipPrimitive.Provider
const Provider = ({
delayDuration = 400,
...props
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Provider>) => (
<TooltipPrimitive.Provider delayDuration={delayDuration} {...props} />
)
/**
* Root tooltip component that wraps trigger and content.

View File

@@ -0,0 +1,22 @@
import type { SVGProps } from 'react'
export function Cursor(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
d='M20.5056 10.7754C21.1225 10.5355 21.431 10.4155 21.5176 10.2459C21.5926 10.099 21.5903 9.92446 21.5115 9.77954C21.4205 9.61226 21.109 9.50044 20.486 9.2768L4.59629 3.5728C4.0866 3.38983 3.83175 3.29835 3.66514 3.35605C3.52029 3.40621 3.40645 3.52004 3.35629 3.6649C3.29859 3.8315 3.39008 4.08635 3.57304 4.59605L9.277 20.4858C9.50064 21.1088 9.61246 21.4203 9.77973 21.5113C9.92465 21.5901 10.0991 21.5924 10.2461 21.5174C10.4157 21.4308 10.5356 21.1223 10.7756 20.5054L13.3724 13.8278C13.4194 13.707 13.4429 13.6466 13.4792 13.5957C13.5114 13.5506 13.5508 13.5112 13.5959 13.479C13.6468 13.4427 13.7072 13.4192 13.828 13.3722L20.5056 10.7754Z'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}

View File

@@ -0,0 +1,43 @@
import type { SVGProps } from 'react'
export function Expand(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
d='M15 3H21V9'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M9 21H3V15'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M21 3L14 10'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M3 21L10 14'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}

View File

@@ -0,0 +1,43 @@
import type { SVGProps } from 'react'
export function Hand(props: SVGProps<SVGSVGElement>) {
return (
<svg
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
d='M6.5 11V6.5C6.5 5.67157 7.17157 5 8 5C8.82843 5 9.5 5.67157 9.5 6.5V11'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M9.5 10.5V5.5C9.5 4.67157 10.1716 4 11 4C11.8284 4 12.5 4.67157 12.5 5.5V10.5'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M12.5 10.5V6.5C12.5 5.67157 13.1716 5 14 5C14.8284 5 15.5 5.67157 15.5 6.5V10.5'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M15.5 10.5V8.5C15.5 7.67157 16.1716 7 17 7C17.8284 7 18.5 7.67157 18.5 8.5V15.5C18.5 18.8137 15.8137 21.5 12.5 21.5H11.5C8.18629 21.5 5.5 18.8137 5.5 15.5V13C5.5 12.1716 6.17157 11.5 7 11.5C7.82843 11.5 8.5 12.1716 8.5 13'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
/>
</svg>
)
}

View File

@@ -4,11 +4,14 @@ export { Card } from './card'
export { ChevronDown } from './chevron-down'
export { Connections } from './connections'
export { Copy } from './copy'
export { Cursor } from './cursor'
export { DocumentAttachment } from './document-attachment'
export { Duplicate } from './duplicate'
export { Expand } from './expand'
export { Eye } from './eye'
export { FolderCode } from './folder-code'
export { FolderPlus } from './folder-plus'
export { Hand } from './hand'
export { HexSimple } from './hex-simple'
export { Key } from './key'
export { Layout } from './layout'

View File

@@ -17,14 +17,14 @@ export function Redo(props: SVGProps<SVGSVGElement>) {
<path
d='M9.5 4.5H4C2.61929 4.5 1.5 5.61929 1.5 7C1.5 8.38071 2.61929 9.5 4 9.5H7'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M8 2.5L10 4.5L8 6.5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>

View File

@@ -17,14 +17,14 @@ export function Undo(props: SVGProps<SVGSVGElement>) {
<path
d='M2.5 4.5H8C9.38071 4.5 10.5 5.61929 10.5 7C10.5 8.38071 9.38071 9.5 8 9.5H5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M4 2.5L2 4.5L4 6.5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>

View File

@@ -19,28 +19,28 @@ export function ZoomIn(props: SVGProps<SVGSVGElement>) {
cy='5'
r='3.5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M5 3.5V6.5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M3.5 5H6.5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M7.5 7.5L10.5 10.5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>

View File

@@ -19,21 +19,21 @@ export function ZoomOut(props: SVGProps<SVGSVGElement>) {
cy='5'
r='3.5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M3.5 5H6.5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>
<path
d='M7.5 7.5L10.5 10.5'
stroke='currentColor'
strokeWidth='1.5'
strokeWidth='1'
strokeLinecap='round'
strokeLinejoin='round'
/>

View File

@@ -25,6 +25,7 @@ export interface GeneralSettings {
billingUsageNotificationsEnabled: boolean
errorNotificationsEnabled: boolean
snapToGridSize: number
showActionBar: boolean
}
/**
@@ -48,6 +49,7 @@ async function fetchGeneralSettings(): Promise<GeneralSettings> {
billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
errorNotificationsEnabled: data.errorNotificationsEnabled ?? true,
snapToGridSize: data.snapToGridSize ?? 0,
showActionBar: data.showActionBar ?? true,
}
}
@@ -69,6 +71,7 @@ function syncSettingsToZustand(settings: GeneralSettings) {
isBillingUsageNotificationsEnabled: settings.billingUsageNotificationsEnabled,
isErrorNotificationsEnabled: settings.errorNotificationsEnabled,
snapToGridSize: settings.snapToGridSize,
showActionBar: settings.showActionBar,
}
const hasChanges = Object.entries(newSettings).some(

View File

@@ -0,0 +1,185 @@
import { useCallback } from 'react'
import type { Node, ReactFlowInstance } from 'reactflow'
interface VisibleBounds {
width: number
height: number
offsetLeft: number
offsetRight: number
offsetBottom: number
}
/**
* Gets the visible canvas bounds accounting for sidebar, terminal, and panel overlays.
* Works correctly regardless of whether the ReactFlow container extends under the sidebar or not.
*/
function getVisibleCanvasBounds(): VisibleBounds {
const style = getComputedStyle(document.documentElement)
const sidebarWidth = Number.parseInt(style.getPropertyValue('--sidebar-width') || '0', 10)
const terminalHeight = Number.parseInt(style.getPropertyValue('--terminal-height') || '0', 10)
const panelWidth = Number.parseInt(style.getPropertyValue('--panel-width') || '0', 10)
const flowContainer = document.querySelector('.react-flow')
if (!flowContainer) {
return {
width: window.innerWidth - sidebarWidth - panelWidth,
height: window.innerHeight - terminalHeight,
offsetLeft: sidebarWidth,
offsetRight: panelWidth,
offsetBottom: terminalHeight,
}
}
const rect = flowContainer.getBoundingClientRect()
// Calculate actual visible area in screen coordinates
// This works regardless of whether the container extends under overlays
const visibleLeft = Math.max(rect.left, sidebarWidth)
const visibleRight = Math.min(rect.right, window.innerWidth - panelWidth)
const visibleBottom = Math.min(rect.bottom, window.innerHeight - terminalHeight)
// Calculate visible dimensions and offsets relative to the container
const visibleWidth = Math.max(0, visibleRight - visibleLeft)
const visibleHeight = Math.max(0, visibleBottom - rect.top)
return {
width: visibleWidth,
height: visibleHeight,
offsetLeft: visibleLeft - rect.left,
offsetRight: rect.right - visibleRight,
offsetBottom: rect.bottom - visibleBottom,
}
}
/**
* Gets the center of the visible canvas in screen coordinates.
*/
function getVisibleCanvasCenter(): { x: number; y: number } {
const style = getComputedStyle(document.documentElement)
const sidebarWidth = Number.parseInt(style.getPropertyValue('--sidebar-width') || '0', 10)
const panelWidth = Number.parseInt(style.getPropertyValue('--panel-width') || '0', 10)
const terminalHeight = Number.parseInt(style.getPropertyValue('--terminal-height') || '0', 10)
const flowContainer = document.querySelector('.react-flow')
if (!flowContainer) {
const visibleWidth = window.innerWidth - sidebarWidth - panelWidth
const visibleHeight = window.innerHeight - terminalHeight
return {
x: sidebarWidth + visibleWidth / 2,
y: visibleHeight / 2,
}
}
const rect = flowContainer.getBoundingClientRect()
// Calculate actual visible area in screen coordinates
const visibleLeft = Math.max(rect.left, sidebarWidth)
const visibleRight = Math.min(rect.right, window.innerWidth - panelWidth)
const visibleBottom = Math.min(rect.bottom, window.innerHeight - terminalHeight)
return {
x: (visibleLeft + visibleRight) / 2,
y: (rect.top + visibleBottom) / 2,
}
}
interface FitViewToBoundsOptions {
padding?: number
maxZoom?: number
minZoom?: number
duration?: number
nodes?: Node[]
}
/**
* Hook providing canvas viewport utilities that account for sidebar, panel, and terminal overlays.
*/
export function useCanvasViewport(reactFlowInstance: ReactFlowInstance | null) {
/**
* Gets the center of the visible canvas in flow coordinates.
*/
const getViewportCenter = useCallback(() => {
if (!reactFlowInstance) {
return { x: 0, y: 0 }
}
const center = getVisibleCanvasCenter()
return reactFlowInstance.screenToFlowPosition(center)
}, [reactFlowInstance])
/**
* Fits the view to show all nodes within the visible canvas bounds,
* accounting for sidebar, panel, and terminal overlays.
* @param padding - Fraction of viewport to leave as margin (0.1 = 10% on each side)
*/
const fitViewToBounds = useCallback(
(options: FitViewToBoundsOptions = {}) => {
if (!reactFlowInstance) return
const {
padding = 0.1,
maxZoom = 1,
minZoom = 0.1,
duration = 300,
nodes: targetNodes,
} = options
const nodes = targetNodes ?? reactFlowInstance.getNodes()
if (nodes.length === 0) {
return
}
const bounds = getVisibleCanvasBounds()
// Calculate node bounds
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
nodes.forEach((node) => {
const nodeWidth = node.width ?? 200
const nodeHeight = node.height ?? 100
minX = Math.min(minX, node.position.x)
minY = Math.min(minY, node.position.y)
maxX = Math.max(maxX, node.position.x + nodeWidth)
maxY = Math.max(maxY, node.position.y + nodeHeight)
})
const contentWidth = maxX - minX
const contentHeight = maxY - minY
// Apply padding as fraction of viewport (matches ReactFlow's fitView behavior)
const availableWidth = bounds.width * (1 - padding * 2)
const availableHeight = bounds.height * (1 - padding * 2)
// Calculate zoom to fit content in available area
const zoomX = availableWidth / contentWidth
const zoomY = availableHeight / contentHeight
const zoom = Math.max(minZoom, Math.min(maxZoom, Math.min(zoomX, zoomY)))
// Calculate center of content in flow coordinates
const contentCenterX = minX + contentWidth / 2
const contentCenterY = minY + contentHeight / 2
// Calculate viewport position to center content in visible area
// Account for sidebar offset on the left
const visibleCenterX = bounds.offsetLeft + bounds.width / 2
const visibleCenterY = bounds.height / 2
const x = visibleCenterX - contentCenterX * zoom
const y = visibleCenterY - contentCenterY * zoom
reactFlowInstance.setViewport({ x, y, zoom }, { duration })
},
[reactFlowInstance]
)
return {
getViewportCenter,
fitViewToBounds,
getVisibleCanvasBounds,
}
}

View File

@@ -0,0 +1,2 @@
export type { CanvasMode } from './store'
export { useCanvasModeStore } from './store'

View File

@@ -0,0 +1,22 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
export type CanvasMode = 'cursor' | 'hand'
interface CanvasModeState {
mode: CanvasMode
setMode: (mode: CanvasMode) => void
}
export const useCanvasModeStore = create<CanvasModeState>()(
devtools(
persist(
(set) => ({
mode: 'hand',
setMode: (mode) => set({ mode }),
}),
{ name: 'canvas-mode' }
),
{ name: 'canvas-mode-store' }
)
)

View File

@@ -271,31 +271,11 @@ function resolveToolDisplay(
if (cand?.text || cand?.icon) return { text: cand.text, icon: cand.icon }
}
} catch {}
// Humanized fallback as last resort - include state verb for proper verb-noun styling
// Humanized fallback as last resort
try {
if (toolName) {
const formattedName = toolName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
// Add state verb prefix for verb-noun rendering in tool-call component
let stateVerb: string
switch (state) {
case ClientToolCallState.pending:
case ClientToolCallState.executing:
stateVerb = 'Executing'
break
case ClientToolCallState.success:
stateVerb = 'Executed'
break
case ClientToolCallState.error:
stateVerb = 'Failed'
break
case ClientToolCallState.rejected:
case ClientToolCallState.aborted:
stateVerb = 'Skipped'
break
default:
stateVerb = 'Executing'
}
return { text: `${stateVerb} ${formattedName}`, icon: undefined as any }
const text = toolName.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
return { text, icon: undefined as any }
}
} catch {}
return undefined
@@ -592,30 +572,8 @@ function stripTodoTags(text: string): string {
*/
function deepClone<T>(obj: T): T {
try {
const json = JSON.stringify(obj)
if (!json || json === 'undefined') {
logger.warn('[deepClone] JSON.stringify returned empty for object', {
type: typeof obj,
isArray: Array.isArray(obj),
length: Array.isArray(obj) ? obj.length : undefined,
})
return obj
}
const parsed = JSON.parse(json)
// Verify the clone worked
if (Array.isArray(obj) && (!Array.isArray(parsed) || parsed.length !== obj.length)) {
logger.warn('[deepClone] Array clone mismatch', {
originalLength: obj.length,
clonedLength: Array.isArray(parsed) ? parsed.length : 'not array',
})
}
return parsed
} catch (err) {
logger.error('[deepClone] Failed to clone object', {
error: String(err),
type: typeof obj,
isArray: Array.isArray(obj),
})
return JSON.parse(JSON.stringify(obj))
} catch {
return obj
}
}
@@ -629,18 +587,11 @@ function serializeMessagesForDB(messages: CopilotMessage[]): any[] {
const result = messages
.map((msg) => {
// Deep clone the entire message to ensure all nested data is serializable
// Ensure timestamp is always a string (Zod schema requires it)
let timestamp: string = msg.timestamp
if (typeof timestamp !== 'string') {
const ts = timestamp as any
timestamp = ts instanceof Date ? ts.toISOString() : new Date().toISOString()
}
const serialized: any = {
id: msg.id,
role: msg.role,
content: msg.content || '',
timestamp,
timestamp: msg.timestamp,
}
// Deep clone contentBlocks (the main rendering data)
@@ -3200,7 +3151,7 @@ export const useCopilotStore = create<CopilotStore>()(
model: selectedModel,
}
const saveResponse = await fetch('/api/copilot/chat/update-messages', {
await fetch('/api/copilot/chat/update-messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -3211,18 +3162,6 @@ export const useCopilotStore = create<CopilotStore>()(
}),
})
if (!saveResponse.ok) {
const errorText = await saveResponse.text().catch(() => '')
logger.error('[Stream Done] Failed to save messages to DB', {
status: saveResponse.status,
error: errorText,
})
} else {
logger.info('[Stream Done] Successfully saved messages to DB', {
messageCount: dbMessages.length,
})
}
// Update local chat object with plan artifact and config
set({
currentChat: {
@@ -3231,9 +3170,7 @@ export const useCopilotStore = create<CopilotStore>()(
config,
},
})
} catch (err) {
logger.error('[Stream Done] Exception saving messages', { error: String(err) })
}
} catch {}
}
// Post copilot_stats record (input/output tokens can be null for now)

View File

@@ -14,6 +14,7 @@ const initialState: General = {
isBillingUsageNotificationsEnabled: true,
isErrorNotificationsEnabled: true,
snapToGridSize: 0,
showActionBar: true,
}
export const useGeneralStore = create<GeneralStore>()(

View File

@@ -7,6 +7,7 @@ export interface General {
isBillingUsageNotificationsEnabled: boolean
isErrorNotificationsEnabled: boolean
snapToGridSize: number
showActionBar: boolean
}
export interface GeneralStore extends General {
@@ -23,4 +24,5 @@ export type UserSettings = {
isBillingUsageNotificationsEnabled: boolean
errorNotificationsEnabled: boolean
snapToGridSize: number
showActionBar: boolean
}

View File

@@ -0,0 +1 @@
ALTER TABLE "settings" ADD COLUMN "show_action_bar" boolean DEFAULT true NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -967,6 +967,13 @@
"when": 1768027253808,
"tag": "0138_faulty_gamma_corps",
"breakpoints": true
},
{
"idx": 139,
"version": "7",
"when": 1768083992922,
"tag": "0139_mushy_bishop",
"breakpoints": true
}
]
}

View File

@@ -473,6 +473,7 @@ export const settings = pgTable('settings', {
// Canvas preferences
snapToGridSize: integer('snap_to_grid_size').notNull().default(0), // 0 = off, 10-50 = grid size
showActionBar: boolean('show_action_bar').notNull().default(true),
// Copilot preferences - maps model_id to enabled/disabled boolean
copilotEnabledModels: jsonb('copilot_enabled_models').notNull().default('{}'),