feat(workflow-controls): added action bar for workflow controls (#2767)

* feat(workflow-controls): added action bar for picker/hand/undo/redo/zoom workflow controls, added general setting to disable

* added util for fit to zoom that accounts for sidebar, terminal, and panel

* ack PR comments

* remove dead state variable, add logs

* improvement(ui/ux): action bar, panel, tooltip, dragging, invite modal

* added fit to view in canvas context menu

* fix(theme): dark mode flash

* fix: duplicate fit to view

* refactor: popovers; improvement: notifications, diff controls, action bar

* improvement(action-bar): ui/ux

* refactor(action-bar): renamed to workflow controls

* ran migrations

* fix: deleted migration

---------

Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
This commit is contained in:
Waleed
2026-01-15 13:25:00 -08:00
committed by GitHub
co-authored by Emir Karabeg
parent e499cc4f82
commit 1cc489e544
57 changed files with 11198 additions and 397 deletions
@@ -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>
}
+33
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)
@@ -657,6 +676,20 @@ input[type="search"]::-ms-clear {
}
}
/**
* Notification toast enter animation
*/
@keyframes notification-enter {
from {
opacity: 0;
transform: translateX(-16px);
}
to {
opacity: 1;
transform: translateX(var(--stack-offset, 0px));
}
}
/**
* @depricated
* Legacy globals (light/dark) kept for backward-compat with old classes.
+4 -1
View File
@@ -27,10 +27,11 @@ 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 = {
theme: 'system',
theme: 'dark',
autoConnect: true,
telemetryEnabled: true,
emailPreferences: {},
@@ -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 }
+4 -1
View File
@@ -12,6 +12,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 = {
@@ -208,7 +209,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<ThemeProvider>
<QueryProvider>
<SessionProvider>
<BrandedLayout>{children}</BrandedLayout>
<TooltipProvider>
<BrandedLayout>{children}</BrandedLayout>
</TooltipProvider>
</SessionProvider>
</QueryProvider>
</ThemeProvider>
+6
View File
@@ -21,12 +21,15 @@ import {
Combobox,
Connections,
Copy,
Cursor,
DatePicker,
DocumentAttachment,
Duplicate,
Expand,
Eye,
FolderCode,
FolderPlus,
Hand,
HexSimple,
Input,
Key as KeyIcon,
@@ -991,11 +994,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' },
+4 -7
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>
)
}
@@ -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>
</>
)
@@ -19,6 +19,7 @@ export type CommandId =
| 'clear-terminal-console'
| 'focus-toolbar-search'
| 'clear-notifications'
| 'fit-to-view'
/**
* Static metadata for a global command.
@@ -104,6 +105,11 @@ export const COMMAND_DEFINITIONS: Record<CommandId, CommandDefinition> = {
shortcut: 'Mod+E',
allowInEditable: false,
},
'fit-to-view': {
id: 'fit-to-view',
shortcut: 'Mod+Shift+F',
allowInEditable: false,
},
}
/**
@@ -1,5 +1,6 @@
'use client'
import type { RefObject } from 'react'
import {
Popover,
PopoverAnchor,
@@ -7,14 +8,48 @@ import {
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import type { BlockContextMenuProps } from './types'
/**
* Block information for context menu actions
*/
export interface BlockInfo {
id: string
type: string
enabled: boolean
horizontalHandles: boolean
parentId?: string
parentType?: string
}
/**
* Props for BlockMenu component
*/
export interface BlockMenuProps {
isOpen: boolean
position: { x: number; y: number }
menuRef: RefObject<HTMLDivElement | null>
onClose: () => void
selectedBlocks: BlockInfo[]
onCopy: () => void
onPaste: () => void
onDuplicate: () => void
onDelete: () => void
onToggleEnabled: () => void
onToggleHandles: () => void
onRemoveFromSubflow: () => void
onOpenEditor: () => void
onRename: () => void
hasClipboard?: boolean
showRemoveFromSubflow?: boolean
disableEdit?: boolean
}
/**
* Context menu for workflow block(s).
* Displays block-specific actions in a popover at right-click position.
* Supports multi-selection - actions apply to all selected blocks.
*/
export function BlockContextMenu({
export function BlockMenu({
isOpen,
position,
menuRef,
@@ -32,7 +67,7 @@ export function BlockContextMenu({
hasClipboard = false,
showRemoveFromSubflow = false,
disableEdit = false,
}: BlockContextMenuProps) {
}: BlockMenuProps) {
const isSingleBlock = selectedBlocks.length === 1
const allEnabled = selectedBlocks.every((b) => b.enabled)
@@ -0,0 +1,2 @@
export type { BlockInfo, BlockMenuProps } from './block-menu'
export { BlockMenu } from './block-menu'
@@ -1,5 +1,6 @@
'use client'
import type { RefObject } from 'react'
import {
Popover,
PopoverAnchor,
@@ -7,13 +8,40 @@ import {
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import type { PaneContextMenuProps } from './types'
/**
* Context menu for workflow canvas pane.
* Props for CanvasMenu component
*/
export interface CanvasMenuProps {
isOpen: boolean
position: { x: number; y: number }
menuRef: RefObject<HTMLDivElement | null>
onClose: () => void
onUndo: () => void
onRedo: () => void
onPaste: () => void
onAddBlock: () => void
onAutoLayout: () => void
onFitToView: () => void
onOpenLogs: () => void
onToggleVariables: () => void
onToggleChat: () => void
onInvite: () => void
isVariablesOpen?: boolean
isChatOpen?: boolean
hasClipboard?: boolean
disableEdit?: boolean
disableAdmin?: boolean
canUndo?: boolean
canRedo?: boolean
isInvitationsDisabled?: boolean
}
/**
* Context menu for workflow canvas.
* Displays canvas-level actions when right-clicking empty space.
*/
export function PaneContextMenu({
export function CanvasMenu({
isOpen,
position,
menuRef,
@@ -23,6 +51,7 @@ export function PaneContextMenu({
onPaste,
onAddBlock,
onAutoLayout,
onFitToView,
onOpenLogs,
onToggleVariables,
onToggleChat,
@@ -35,7 +64,7 @@ export function PaneContextMenu({
canUndo = false,
canRedo = false,
isInvitationsDisabled = false,
}: PaneContextMenuProps) {
}: CanvasMenuProps) {
return (
<Popover
open={isOpen}
@@ -113,6 +142,14 @@ export function PaneContextMenu({
<span>Auto-layout</span>
<span className='ml-auto opacity-70 group-hover:opacity-100'>⇧L</span>
</PopoverItem>
<PopoverItem
onClick={() => {
onFitToView()
onClose()
}}
>
Fit to View
</PopoverItem>
{/* Navigation actions */}
<PopoverDivider />
@@ -0,0 +1,2 @@
export type { CanvasMenuProps } from './canvas-menu'
export { CanvasMenu } from './canvas-menu'
@@ -20,6 +20,7 @@ import {
PopoverItem,
PopoverScrollArea,
PopoverTrigger,
Tooltip,
Trash,
} from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
@@ -869,7 +870,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'
@@ -1042,17 +1043,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
@@ -1,8 +0,0 @@
export { BlockContextMenu } from './block-context-menu'
export { PaneContextMenu } from './pane-context-menu'
export type {
BlockContextMenuProps,
ContextMenuBlockInfo,
ContextMenuPosition,
PaneContextMenuProps,
} from './types'
@@ -1,99 +0,0 @@
import type { RefObject } from 'react'
/**
* Position for context menu placement
*/
export interface ContextMenuPosition {
x: number
y: number
}
/**
* Block information passed to context menu for action handling
*/
export interface ContextMenuBlockInfo {
/** Block ID */
id: string
/** Block type (e.g., 'agent', 'function', 'loop') */
type: string
/** Whether block is enabled */
enabled: boolean
/** Whether block uses horizontal handles */
horizontalHandles: boolean
/** Parent subflow ID if nested in loop/parallel */
parentId?: string
/** Parent type ('loop' | 'parallel') if nested */
parentType?: string
}
/**
* Props for BlockContextMenu component
*/
export interface BlockContextMenuProps {
/** Whether the context menu is open */
isOpen: boolean
/** Position of the context menu */
position: ContextMenuPosition
/** Ref for the menu element (for click-outside detection) */
menuRef: RefObject<HTMLDivElement | null>
/** Callback when menu should close */
onClose: () => void
/** Selected block(s) info */
selectedBlocks: ContextMenuBlockInfo[]
/** Callbacks for menu actions */
onCopy: () => void
onPaste: () => void
onDuplicate: () => void
onDelete: () => void
onToggleEnabled: () => void
onToggleHandles: () => void
onRemoveFromSubflow: () => void
onOpenEditor: () => void
onRename: () => void
/** Whether clipboard has content for pasting */
hasClipboard?: boolean
/** Whether remove from subflow option should be shown */
showRemoveFromSubflow?: boolean
/** Whether edit actions are disabled (no permission) */
disableEdit?: boolean
}
/**
* Props for PaneContextMenu component
*/
export interface PaneContextMenuProps {
/** Whether the context menu is open */
isOpen: boolean
/** Position of the context menu */
position: ContextMenuPosition
/** Ref for the menu element */
menuRef: RefObject<HTMLDivElement | null>
/** Callback when menu should close */
onClose: () => void
/** Callbacks for menu actions */
onUndo: () => void
onRedo: () => void
onPaste: () => void
onAddBlock: () => void
onAutoLayout: () => void
onOpenLogs: () => void
onToggleVariables: () => void
onToggleChat: () => void
onInvite: () => void
/** Whether the variables panel is currently open */
isVariablesOpen?: boolean
/** Whether the chat panel is currently open */
isChatOpen?: boolean
/** Whether clipboard has content for pasting */
hasClipboard?: boolean
/** Whether edit actions are disabled (no permission) */
disableEdit?: boolean
/** Whether admin actions are disabled (no admin permission) */
disableAdmin?: boolean
/** Whether undo is available */
canUndo?: boolean
/** Whether redo is available */
canRedo?: boolean
/** Whether invitations are disabled (feature flag or permission group) */
isInvitationsDisabled?: boolean
}
@@ -4,6 +4,7 @@ import clsx from 'clsx'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { useNotificationStore } from '@/stores/notifications'
import { useCopilotStore, usePanelStore } from '@/stores/panel'
import { useTerminalStore } from '@/stores/terminal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
@@ -12,6 +13,8 @@ import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('DiffControls')
const NOTIFICATION_WIDTH = 240
const NOTIFICATION_GAP = 16
export const DiffControls = memo(function DiffControls() {
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
@@ -45,6 +48,12 @@ export const DiffControls = memo(function DiffControls() {
useCallback((state) => ({ activeWorkflowId: state.activeWorkflowId }), [])
)
const allNotifications = useNotificationStore((state) => state.notifications)
const hasVisibleNotifications = useMemo(() => {
if (!activeWorkflowId) return false
return allNotifications.some((n) => !n.workflowId || n.workflowId === activeWorkflowId)
}, [allNotifications, activeWorkflowId])
const createCheckpoint = useCallback(async () => {
if (!activeWorkflowId || !currentChat?.id) {
logger.warn('Cannot create checkpoint: missing workflowId or chatId', {
@@ -295,16 +304,15 @@ export const DiffControls = memo(function DiffControls() {
const isResizing = isTerminalResizing || isPanelResizing
const notificationOffset = hasVisibleNotifications ? NOTIFICATION_WIDTH + NOTIFICATION_GAP : 0
return (
<div
ref={preventZoomRef}
className={clsx(
'fixed z-30',
!isResizing && 'transition-[bottom,right] duration-100 ease-out'
)}
className={clsx('fixed z-30', !isResizing && 'transition-[bottom] duration-100 ease-out')}
style={{
bottom: 'calc(var(--terminal-height) + 16px)',
right: 'calc(var(--panel-width) + 16px)',
right: `calc(var(--panel-width) + 16px + ${notificationOffset}px)`,
}}
>
<div
@@ -1,3 +1,5 @@
export { BlockMenu } from './block-menu'
export { CanvasMenu } from './canvas-menu'
export { CommandList } from './command-list/command-list'
export { Cursors } from './cursors/cursors'
export { DiffControls } from './diff-controls/diff-controls'
@@ -8,4 +10,5 @@ export { SubflowNodeComponent } from './subflows/subflow-node'
export { Terminal } from './terminal/terminal'
export { WandPromptBar } from './wand-prompt-bar/wand-prompt-bar'
export { WorkflowBlock } from './workflow-block/workflow-block'
export { WorkflowControls } from './workflow-controls'
export { WorkflowEdge } from './workflow-edge/workflow-edge'
@@ -11,7 +11,7 @@ import {
openCopilotWithMessage,
useNotificationStore,
} from '@/stores/notifications'
import { useSidebarStore } from '@/stores/sidebar/store'
import { usePanelStore } from '@/stores/panel'
import { useTerminalStore } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -19,9 +19,9 @@ const logger = createLogger('Notifications')
const MAX_VISIBLE_NOTIFICATIONS = 4
/**
* Notifications display component
* Positioned in the bottom-left workspace area, reactive to sidebar width and terminal height
* Shows both global notifications and workflow-specific notifications
* Notifications display component.
* Positioned in the bottom-right workspace area, reactive to panel width and terminal height.
* Shows both global notifications and workflow-specific notifications.
*/
export const Notifications = memo(function Notifications() {
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
@@ -37,7 +37,7 @@ export const Notifications = memo(function Notifications() {
.slice(0, MAX_VISIBLE_NOTIFICATIONS)
}, [allNotifications, activeWorkflowId])
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
const isSidebarResizing = useSidebarStore((state) => state.isResizing)
const isPanelResizing = usePanelStore((state) => state.isResizing)
/**
* Executes a notification action and handles side effects.
@@ -105,15 +105,19 @@ export const Notifications = memo(function Notifications() {
return null
}
const isResizing = isTerminalResizing || isSidebarResizing
const isResizing = isTerminalResizing || isPanelResizing
return (
<div
ref={preventZoomRef}
className={clsx(
'fixed bottom-[calc(var(--terminal-height)+16px)] left-[calc(var(--sidebar-width)+16px)] z-30 flex flex-col items-start',
!isResizing && 'transition-[bottom,left] duration-100 ease-out'
'fixed z-30 flex flex-col items-start',
!isResizing && 'transition-[bottom,right] duration-100 ease-out'
)}
style={{
bottom: 'calc(var(--terminal-height) + 16px)',
right: 'calc(var(--panel-width) + 16px)',
}}
>
{[...visibleNotifications].reverse().map((notification, index, stacked) => {
const depth = stacked.length - index - 1
@@ -123,8 +127,13 @@ export const Notifications = memo(function Notifications() {
return (
<div
key={notification.id}
style={{ transform: `translateX(${xOffset}px)` }}
className={`relative h-[80px] w-[240px] overflow-hidden rounded-[4px] border bg-[var(--surface-2)] transition-transform duration-200 ${
style={
{
'--stack-offset': `${xOffset}px`,
animation: 'notification-enter 200ms ease-out forwards',
} as React.CSSProperties
}
className={`relative h-[80px] w-[240px] overflow-hidden rounded-[4px] border bg-[var(--surface-2)] ${
index > 0 ? '-mt-[80px]' : ''
}`}
>
@@ -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>
@@ -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'
@@ -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)
}
}
/**
@@ -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>
)
}
@@ -495,9 +495,6 @@ export function Panel() {
Editor
</Button>
</div>
{/* Workflow Controls (Undo/Redo) */}
{/* <WorkflowControls /> */}
</div>
{/* Tab Content - Keep all tabs mounted but hidden to preserve state */}
@@ -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-1)] [&: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
@@ -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>
@@ -921,7 +921,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 && (
@@ -957,12 +957,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
@@ -0,0 +1 @@
export { WorkflowControls } from './workflow-controls'
@@ -0,0 +1,225 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import clsx from 'clsx'
import { Scan } from 'lucide-react'
import { useReactFlow } from 'reactflow'
import {
Button,
ChevronDown,
Cursor,
Hand,
Popover,
PopoverAnchor,
PopoverContent,
PopoverItem,
PopoverTrigger,
Redo,
Tooltip,
Undo,
} from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommand } from '@/app/workspace/[workspaceId]/utils/commands-utils'
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 { useTerminalStore } from '@/stores/terminal'
import { useUndoRedoStore } from '@/stores/undo-redo'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('WorkflowControls')
export function WorkflowControls() {
const reactFlowInstance = useReactFlow()
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
const { mode, setMode } = useCanvasModeStore()
const { undo, redo } = useCollaborativeWorkflow()
const showWorkflowControls = useGeneralStore((s) => s.showActionBar)
const updateSetting = useUpdateGeneralSetting()
const isTerminalResizing = useTerminalStore((state) => state.isResizing)
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 handleFitToView = useCallback(() => {
fitViewToBounds({ padding: 0.1, duration: 300 })
}, [fitViewToBounds])
useRegisterGlobalCommands([
createCommand({
id: 'fit-to-view',
handler: handleFitToView,
}),
])
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 workflow controls', error)
} finally {
setContextMenu(null)
}
}
if (!showWorkflowControls) {
return null
}
return (
<>
<div
className={clsx(
'fixed z-10 flex h-[36px] items-center gap-[2px] rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] p-[4px]',
!isTerminalResizing && 'transition-[bottom] duration-100 ease-out'
)}
style={{
bottom: 'calc(var(--terminal-height) + 16px)',
left: 'calc(var(--sidebar-width) + 16px)',
}}
onContextMenu={handleContextMenu}
>
{/* Canvas Mode Selector */}
<Popover
open={isCanvasModeOpen}
onOpenChange={setIsCanvasModeOpen}
variant='secondary'
size='sm'
>
<Tooltip.Root>
<PopoverTrigger asChild>
<div className='flex cursor-pointer items-center gap-[4px]'>
<Tooltip.Trigger asChild>
<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>
</Tooltip.Trigger>
<Button className='-m-[4px] !p-[6px] 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>
<Tooltip.Content side='top'>{mode === 'hand' ? 'Mover' : 'Pointer'}</Tooltip.Content>
</Tooltip.Root>
<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={handleFitToView}
>
<Scan className='h-[16px] w-[16px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<Tooltip.Shortcut keys='⌘⇧F'>Fit to View</Tooltip.Shortcut>
</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>
</>
)
}
@@ -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,
@@ -1,36 +1,28 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { Node } from 'reactflow'
import type { BlockState } from '@/stores/workflows/workflow/types'
import type { ContextMenuBlockInfo, ContextMenuPosition } from '../components/context-menu/types'
import type { BlockInfo } from '../components/block-menu'
type MenuType = 'block' | 'pane' | null
interface UseCanvasContextMenuProps {
/** Current blocks from workflow store */
blocks: Record<string, BlockState>
/** Function to get nodes from ReactFlow */
getNodes: () => Node[]
}
/**
* Hook for managing workflow canvas context menus.
*
* Handles:
* - Right-click event handling for blocks and pane
* - Menu open/close state for both menu types
* - Click-outside detection to close menus
* - Selected block info extraction for multi-selection support
* Handles right-click events, menu state, click-outside detection, and block info extraction.
*/
export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuProps) {
const [activeMenu, setActiveMenu] = useState<MenuType>(null)
const [position, setPosition] = useState<ContextMenuPosition>({ x: 0, y: 0 })
const [selectedBlocks, setSelectedBlocks] = useState<ContextMenuBlockInfo[]>([])
const [position, setPosition] = useState({ x: 0, y: 0 })
const [selectedBlocks, setSelectedBlocks] = useState<BlockInfo[]>([])
const menuRef = useRef<HTMLDivElement>(null)
/** Converts nodes to block info for context menu */
const nodesToBlockInfos = useCallback(
(nodes: Node[]): ContextMenuBlockInfo[] =>
(nodes: Node[]): BlockInfo[] =>
nodes.map((n) => {
const block = blocks[n.id]
const parentId = block?.data?.parentId
@@ -47,9 +39,6 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
[blocks]
)
/**
* Handle right-click on a node (block)
*/
const handleNodeContextMenu = useCallback(
(event: React.MouseEvent, node: Node) => {
event.preventDefault()
@@ -65,9 +54,6 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
[getNodes, nodesToBlockInfos]
)
/**
* Handle right-click on the pane (empty canvas area)
*/
const handlePaneContextMenu = useCallback((event: React.MouseEvent) => {
event.preventDefault()
event.stopPropagation()
@@ -77,9 +63,6 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
setActiveMenu('pane')
}, [])
/**
* Handle right-click on a selection (multiple selected nodes)
*/
const handleSelectionContextMenu = useCallback(
(event: React.MouseEvent) => {
event.preventDefault()
@@ -94,16 +77,10 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
[getNodes, nodesToBlockInfos]
)
/**
* Close the active context menu
*/
const closeMenu = useCallback(() => {
setActiveMenu(null)
}, [])
/**
* Handle clicks outside the menu to close it
*/
useEffect(() => {
if (!activeMenu) return
@@ -123,9 +100,6 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
}
}, [activeMenu, closeMenu])
/**
* Close menu on scroll or zoom to prevent menu from being positioned incorrectly
*/
useEffect(() => {
if (!activeMenu) return
@@ -139,23 +113,14 @@ export function useCanvasContextMenu({ blocks, getNodes }: UseCanvasContextMenuP
}, [activeMenu, closeMenu])
return {
/** Whether the block context menu is open */
isBlockMenuOpen: activeMenu === 'block',
/** Whether the pane context menu is open */
isPaneMenuOpen: activeMenu === 'pane',
/** Position for the context menu */
position,
/** Ref for the menu element */
menuRef,
/** Selected blocks info for multi-selection actions */
selectedBlocks,
/** Handler for ReactFlow onNodeContextMenu */
handleNodeContextMenu,
/** Handler for ReactFlow onPaneContextMenu */
handlePaneContextMenu,
/** Handler for ReactFlow onSelectionContextMenu */
handleSelectionContextMenu,
/** Close the active context menu */
closeMenu,
}
}
@@ -31,16 +31,15 @@ import {
SubflowNodeComponent,
Terminal,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import {
BlockContextMenu,
PaneContextMenu,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu'
import { BlockMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu'
import { CanvasMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu'
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
import type { SubflowNodeData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
import { TrainingModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import {
clearDragHighlights,
@@ -63,9 +62,11 @@ 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 { usePermissionConfig } from '@/hooks/use-permission-config'
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'
@@ -210,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
@@ -223,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
@@ -1512,10 +1515,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,
})
@@ -1523,18 +1526,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(() => {
@@ -1926,47 +1929,6 @@ const WorkflowContent = React.memo(() => {
// Local state for nodes - allows smooth drag without store updates on every frame
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
@@ -2867,19 +2829,19 @@ 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])
// // 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])
// 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(
@@ -3038,7 +3000,6 @@ const WorkflowContent = React.memo(() => {
const onSelectionDragStop = useCallback(
(_event: React.MouseEvent, nodes: any[]) => {
requestAnimationFrame(() => setIsSelectionDragActive(false))
clearDragHighlights()
if (nodes.length === 0) return
@@ -3367,11 +3328,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}
@@ -3379,7 +3338,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}
@@ -3398,12 +3357,14 @@ const WorkflowContent = React.memo(() => {
<Cursors />
<WorkflowControls />
<Suspense fallback={null}>
<LazyChat />
</Suspense>
{/* Context Menus */}
<BlockContextMenu
<BlockMenu
isOpen={isBlockMenuOpen}
position={contextMenuPosition}
menuRef={contextMenuRef}
@@ -3425,7 +3386,7 @@ const WorkflowContent = React.memo(() => {
disableEdit={!effectivePermissions.canEdit}
/>
<PaneContextMenu
<CanvasMenu
isOpen={isPaneMenuOpen}
position={contextMenuPosition}
menuRef={contextMenuRef}
@@ -3435,6 +3396,7 @@ const WorkflowContent = React.memo(() => {
onPaste={handleContextPaste}
onAddBlock={handleContextAddBlock}
onAutoLayout={handleAutoLayout}
onFitToView={() => fitViewToBounds({ padding: 0.1, duration: 300 })}
onOpenLogs={handleContextOpenLogs}
onToggleVariables={handleContextToggleVariables}
onToggleChat={handleContextToggleChat}
@@ -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
@@ -369,7 +369,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
autoConnect: data.autoConnect ?? true,
showTrainingControls: data.showTrainingControls ?? false,
superUserModeEnabled: data.superUserModeEnabled ?? true,
theme: data.theme || 'system',
theme: data.theme || 'dark',
telemetryEnabled: data.telemetryEnabled ?? true,
billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
}
@@ -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>
@@ -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]
)
@@ -298,7 +298,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...'}
@@ -311,7 +311,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' : ''
}`}
/>
@@ -460,7 +460,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>
@@ -489,7 +489,7 @@ export function Sidebar() {
<>
{isCollapsed ? (
/* Floating collapsed header - minimal pill showing workspace name and expand toggle */
<div className='fixed top-[14px] left-[10px] z-10 w-fit rounded-[10px] border border-[var(--border)] bg-[var(--surface-1)] px-[10px] py-[6px]'>
<div className='fixed top-[14px] left-[10px] z-10 w-fit rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] py-[4px] pr-[10px] pl-[6px]'>
<WorkspaceHeader
activeWorkspace={activeWorkspace}
workspaceId={workspaceId}
@@ -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'
/**
@@ -155,6 +156,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
}
/**
@@ -480,17 +483,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>
)
@@ -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.
+22
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>
)
}
+43
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>
)
}
+43
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>
)
}
+3
View File
@@ -4,12 +4,15 @@ 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 { Download } from './download'
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'
+2 -2
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'
/>
+2 -2
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'
/>
+4 -4
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'
/>
+3 -3
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'
/>
@@ -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(
+185
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,
}
}
+2
View File
@@ -0,0 +1,2 @@
export type { CanvasMode } from './store'
export { useCanvasModeStore } from './store'
+22
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' }
)
)
+2 -1
View File
@@ -9,11 +9,12 @@ const initialState: General = {
isAutoConnectEnabled: true,
showTrainingControls: false,
superUserModeEnabled: true,
theme: 'system',
theme: 'dark',
telemetryEnabled: true,
isBillingUsageNotificationsEnabled: true,
isErrorNotificationsEnabled: true,
snapToGridSize: 0,
showActionBar: true,
}
export const useGeneralStore = create<GeneralStore>()(
@@ -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
}
@@ -0,0 +1,2 @@
ALTER TABLE "settings" ALTER COLUMN "theme" SET DEFAULT 'dark';--> statement-breakpoint
ALTER TABLE "settings" ADD COLUMN "show_action_bar" boolean DEFAULT true NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -988,6 +988,13 @@
"when": 1768421319400,
"tag": "0141_daffy_marten_broadcloak",
"breakpoints": true
},
{
"idx": 142,
"version": "7",
"when": 1768511699652,
"tag": "0142_short_nicolaos",
"breakpoints": true
}
]
}
+2 -1
View File
@@ -451,7 +451,7 @@ export const settings = pgTable('settings', {
.unique(), // One settings record per user
// General settings
theme: text('theme').notNull().default('system'),
theme: text('theme').notNull().default('dark'),
autoConnect: boolean('auto_connect').notNull().default(true),
// Privacy settings
@@ -474,6 +474,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('{}'),