mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(workflow): workflow overhaul (#1906)
* feat: action-bar side-effects; refactor: removed unused styles from globals and tailwind config * feat(terminal): filters/sorting; fix(workflow): zoom bug * feat(sidebar): toggle; feat(terminal): show/hide timestamp * feat(toolbar): triggers ordering * feat: commands, cursors, room-presence, command-list, rename blocks, workspace controls, invite, search modal * removed old imports * ack PR comments * fix tag dropdown * feat: variables UI; fix: terminal keys --------- Co-authored-by: waleed <waleed>
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
--panel-width: 244px;
|
||||
--toolbar-triggers-height: 300px;
|
||||
--editor-connections-height: 200px;
|
||||
--terminal-height: 100px;
|
||||
--terminal-height: 145px;
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
@@ -260,11 +260,6 @@
|
||||
/**
|
||||
* Dark mode specific overrides
|
||||
*/
|
||||
.dark .error-badge {
|
||||
background-color: hsl(0, 70%, 20%) !important;
|
||||
color: hsl(0, 0%, 100%) !important;
|
||||
}
|
||||
|
||||
.dark .bg-red-500 {
|
||||
@apply bg-red-700;
|
||||
}
|
||||
@@ -285,23 +280,11 @@ input[type="search"]::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout utilities
|
||||
*/
|
||||
.main-content-overlay {
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilities and special effects
|
||||
* Animation keyframes are defined in tailwind.config.ts
|
||||
*/
|
||||
@layer utilities {
|
||||
.animation-container {
|
||||
contain: paint layout style;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
@@ -348,46 +331,6 @@ input[type="search"]::-ms-clear {
|
||||
background-color: hsl(var(--input-background));
|
||||
}
|
||||
|
||||
.bg-brand-primary {
|
||||
background-color: var(--brand-primary-hex);
|
||||
}
|
||||
|
||||
.bg-brand-primary-hover {
|
||||
background-color: var(--brand-primary-hover-hex);
|
||||
}
|
||||
|
||||
.hover\:bg-brand-primary-hover:hover {
|
||||
background-color: var(--brand-primary-hover-hex);
|
||||
}
|
||||
|
||||
.hover\:text-brand-accent-hover:hover {
|
||||
color: var(--brand-accent-hover-hex);
|
||||
}
|
||||
|
||||
.bg-brand-gradient {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in srgb, var(--brand-primary-hex) 85%, white),
|
||||
var(--brand-primary-hex)
|
||||
);
|
||||
}
|
||||
|
||||
.border-brand-gradient {
|
||||
border-color: var(--brand-primary-hex);
|
||||
}
|
||||
|
||||
.shadow-brand-gradient {
|
||||
box-shadow: inset 0 2px 4px 0 color-mix(in srgb, var(--brand-primary-hex) 60%, transparent);
|
||||
}
|
||||
|
||||
.hover\:bg-brand-gradient-hover:hover {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
var(--brand-primary-hover-hex),
|
||||
color-mix(in srgb, var(--brand-primary-hex) 90%, black)
|
||||
);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background-color: rgba(255, 255, 255, 0.9) !important;
|
||||
border-color: #e5e5e5 !important;
|
||||
@@ -419,10 +362,6 @@ input[type="search"]::-ms-clear {
|
||||
color: #737373 !important;
|
||||
}
|
||||
|
||||
.bg-surface-elevated {
|
||||
background-color: var(--surface-elevated);
|
||||
}
|
||||
|
||||
.transition-ring {
|
||||
transition-property: box-shadow, transform;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('GlobalCommands')
|
||||
|
||||
/**
|
||||
* Detects if the current platform is macOS.
|
||||
*
|
||||
* @returns True if running on macOS, false otherwise
|
||||
*/
|
||||
function isMacPlatform(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
return (
|
||||
/Mac|iPhone|iPod|iPad/i.test(navigator.platform) ||
|
||||
/Mac|iPhone|iPod|iPad/i.test(navigator.userAgent)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a parsed keyboard shortcut.
|
||||
*
|
||||
* We support the following modifiers:
|
||||
* - Mod: maps to Meta on macOS, Ctrl on other platforms
|
||||
* - Ctrl, Meta, Shift, Alt
|
||||
*
|
||||
* Examples:
|
||||
* - "Mod+A"
|
||||
* - "Mod+Shift+T"
|
||||
* - "Meta+K"
|
||||
*/
|
||||
export interface ParsedShortcut {
|
||||
key: string
|
||||
mod?: boolean
|
||||
ctrl?: boolean
|
||||
meta?: boolean
|
||||
shift?: boolean
|
||||
alt?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Declarative command registration.
|
||||
*/
|
||||
export interface GlobalCommand {
|
||||
/** Unique id for the command. If omitted, one is generated. */
|
||||
id?: string
|
||||
/** Shortcut string in the form "Mod+Shift+T", "Mod+A", "Meta+K", etc. */
|
||||
shortcut: string
|
||||
/**
|
||||
* Whether to allow the command to run inside editable elements like inputs,
|
||||
* textareas or contenteditable. Defaults to true to ensure browser defaults
|
||||
* are overridden when desired.
|
||||
*/
|
||||
allowInEditable?: boolean
|
||||
/**
|
||||
* Handler invoked when the shortcut is matched. Use this to trigger actions
|
||||
* like navigation or dispatching application events.
|
||||
*/
|
||||
handler: (event: KeyboardEvent) => void
|
||||
}
|
||||
|
||||
interface RegistryCommand extends GlobalCommand {
|
||||
id: string
|
||||
parsed: ParsedShortcut
|
||||
}
|
||||
|
||||
interface GlobalCommandsContextValue {
|
||||
register: (commands: GlobalCommand[]) => () => void
|
||||
}
|
||||
|
||||
const GlobalCommandsContext = createContext<GlobalCommandsContextValue | null>(null)
|
||||
|
||||
/**
|
||||
* Parses a human-readable shortcut into a structured representation.
|
||||
*/
|
||||
function parseShortcut(shortcut: string): ParsedShortcut {
|
||||
const parts = shortcut.split('+').map((p) => p.trim())
|
||||
const modifiers = new Set(parts.slice(0, -1).map((p) => p.toLowerCase()))
|
||||
const last = parts[parts.length - 1]
|
||||
|
||||
return {
|
||||
key: last.length === 1 ? last.toLowerCase() : last, // keep non-letter keys verbatim
|
||||
mod: modifiers.has('mod'),
|
||||
ctrl: modifiers.has('ctrl'),
|
||||
meta: modifiers.has('meta') || modifiers.has('cmd') || modifiers.has('command'),
|
||||
shift: modifiers.has('shift'),
|
||||
alt: modifiers.has('alt') || modifiers.has('option'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a KeyboardEvent matches a parsed shortcut, honoring platform-specific
|
||||
* interpretation of "Mod" (Meta on macOS, Ctrl elsewhere).
|
||||
*/
|
||||
function matchesShortcut(e: KeyboardEvent, parsed: ParsedShortcut): boolean {
|
||||
const isMac = isMacPlatform()
|
||||
const expectedCtrl = parsed.ctrl || (parsed.mod ? !isMac : false)
|
||||
const expectedMeta = parsed.meta || (parsed.mod ? isMac : false)
|
||||
|
||||
// Normalize key for comparison: for letters compare lowercase
|
||||
const eventKey = e.key.length === 1 ? e.key.toLowerCase() : e.key
|
||||
|
||||
return (
|
||||
eventKey === parsed.key &&
|
||||
!!e.ctrlKey === !!expectedCtrl &&
|
||||
!!e.metaKey === !!expectedMeta &&
|
||||
!!e.shiftKey === !!parsed.shift &&
|
||||
!!e.altKey === !!parsed.alt
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider that captures global keyboard shortcuts and routes them to
|
||||
* registered commands. Commands can be registered from any descendant component.
|
||||
*/
|
||||
export function GlobalCommandsProvider({ children }: { children: ReactNode }) {
|
||||
const registryRef = useRef<Map<string, RegistryCommand>>(new Map())
|
||||
const isMac = useMemo(() => isMacPlatform(), [])
|
||||
const router = useRouter()
|
||||
|
||||
const register = useCallback((commands: GlobalCommand[]) => {
|
||||
const createdIds: string[] = []
|
||||
for (const cmd of commands) {
|
||||
const id = cmd.id ?? crypto.randomUUID()
|
||||
const parsed = parseShortcut(cmd.shortcut)
|
||||
registryRef.current.set(id, {
|
||||
...cmd,
|
||||
id,
|
||||
parsed,
|
||||
allowInEditable: cmd.allowInEditable ?? true,
|
||||
})
|
||||
createdIds.push(id)
|
||||
logger.info('Registered global command', { id, shortcut: cmd.shortcut })
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const id of createdIds) {
|
||||
registryRef.current.delete(id)
|
||||
logger.info('Unregistered global command', { id })
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.isComposing) return
|
||||
|
||||
// Evaluate matches in registration order (latest registration wins naturally
|
||||
// due to replacement on same id). Break on first match.
|
||||
for (const [, cmd] of registryRef.current) {
|
||||
if (!cmd.allowInEditable) {
|
||||
const ae = document.activeElement
|
||||
const isEditable =
|
||||
ae instanceof HTMLInputElement ||
|
||||
ae instanceof HTMLTextAreaElement ||
|
||||
ae?.hasAttribute('contenteditable')
|
||||
if (isEditable) continue
|
||||
}
|
||||
|
||||
if (matchesShortcut(e, cmd.parsed)) {
|
||||
// Always override default browser behavior for matched commands.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
logger.info('Executing global command', {
|
||||
id: cmd.id,
|
||||
shortcut: cmd.shortcut,
|
||||
key: e.key,
|
||||
isMac,
|
||||
path: typeof window !== 'undefined' ? window.location.pathname : undefined,
|
||||
})
|
||||
try {
|
||||
cmd.handler(e)
|
||||
} catch (err) {
|
||||
logger.error('Global command handler threw', { id: cmd.id, err })
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
}, [isMac, router])
|
||||
|
||||
const value = useMemo<GlobalCommandsContextValue>(() => ({ register }), [register])
|
||||
|
||||
return <GlobalCommandsContext.Provider value={value}>{children}</GlobalCommandsContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a set of global commands for the lifetime of the component.
|
||||
*
|
||||
* Returns nothing; cleanup is automatic on unmount.
|
||||
*/
|
||||
export function useRegisterGlobalCommands(commands: GlobalCommand[] | (() => GlobalCommand[])) {
|
||||
const ctx = useContext(GlobalCommandsContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useRegisterGlobalCommands must be used within GlobalCommandsProvider')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const list = typeof commands === 'function' ? commands() : commands
|
||||
const unregister = ctx.register(list)
|
||||
return unregister
|
||||
// We intentionally want to register once for the given commands
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React from 'react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { SettingsLoader } from './settings-loader'
|
||||
|
||||
@@ -13,9 +14,11 @@ const Providers = React.memo<ProvidersProps>(({ children }) => {
|
||||
return (
|
||||
<>
|
||||
<SettingsLoader />
|
||||
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
|
||||
<WorkspacePermissionsProvider>{children}</WorkspacePermissionsProvider>
|
||||
</Tooltip.Provider>
|
||||
<GlobalCommandsProvider>
|
||||
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
|
||||
<WorkspacePermissionsProvider>{children}</WorkspacePermissionsProvider>
|
||||
</Tooltip.Provider>
|
||||
</GlobalCommandsProvider>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
import { Layout, LibraryBig, Search } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { AgentIcon } from '@/components/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Command item data structure
|
||||
*/
|
||||
interface CommandItem {
|
||||
/** Display label for the command */
|
||||
label: string
|
||||
/** Icon component from lucide-react */
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
/** Keyboard shortcut keys (can be single or array for multiple keys) */
|
||||
shortcut: string | string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Available commands list
|
||||
*/
|
||||
const commands: CommandItem[] = [
|
||||
{
|
||||
label: 'Templates',
|
||||
icon: Layout,
|
||||
shortcut: 'Y',
|
||||
},
|
||||
{
|
||||
label: 'New Agent',
|
||||
icon: AgentIcon,
|
||||
shortcut: ['⇧', 'A'],
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
icon: LibraryBig,
|
||||
shortcut: 'L',
|
||||
},
|
||||
{
|
||||
label: 'Search Blocks',
|
||||
icon: Search,
|
||||
shortcut: 'K',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* CommandList component that displays available commands with keyboard shortcuts
|
||||
* Centered on the screen for empty workflows
|
||||
*/
|
||||
export function CommandList() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 mb-[50px] flex items-center justify-center'
|
||||
)}
|
||||
>
|
||||
<div className='pointer-events-none flex flex-col gap-[8px]'>
|
||||
{/* Logo */}
|
||||
<div className='mb-[20px] flex justify-center'>
|
||||
<Image
|
||||
src='/logo/b&w/text/b&w.svg'
|
||||
alt='Sim'
|
||||
width={99.56}
|
||||
height={48.56}
|
||||
className='opacity-70'
|
||||
style={{
|
||||
filter:
|
||||
'brightness(0) saturate(100%) invert(69%) sepia(0%) saturate(0%) hue-rotate(202deg) brightness(94%) contrast(89%)',
|
||||
}}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{commands.map((command) => {
|
||||
const Icon = command.icon
|
||||
const shortcuts = Array.isArray(command.shortcut) ? command.shortcut : [command.shortcut]
|
||||
return (
|
||||
<div
|
||||
key={command.label}
|
||||
className='group flex cursor-pointer items-center justify-between gap-[60px]'
|
||||
>
|
||||
{/* Left side: Icon and Label */}
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Icon className='h-[14px] w-[14px] text-[#AEAEAE] group-hover:text-[#E6E6E6] dark:group-hover:text-[#E6E6E6]' />
|
||||
<span className='font-medium text-[#AEAEAE] text-[14px] group-hover:text-[#E6E6E6] dark:group-hover:text-[#E6E6E6]'>
|
||||
{command.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right side: Keyboard Shortcut */}
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<Button
|
||||
className='group-hover:-translate-y-0.5 w-[26px] py-[3px] text-[12px] hover:translate-y-0 hover:text-[#AEAEAE] hover:shadow-[0_2px_0_0] group-hover:text-[#E6E6E6] group-hover:shadow-[0_4px_0_0] group-hover:dark:text-[#E6E6E6] group-hover:dark:shadow-[#303030] hover:dark:text-[#AEAEAE] hover:dark:shadow-[#303030]'
|
||||
variant='3d'
|
||||
>
|
||||
<span>⌘</span>
|
||||
</Button>
|
||||
{shortcuts.map((key, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
className='group-hover:-translate-y-0.5 w-[26px] py-[3px] text-[12px] hover:translate-y-0 hover:text-[#AEAEAE] hover:shadow-[0_2px_0_0] group-hover:text-[#E6E6E6] group-hover:shadow-[0_4px_0_0] group-hover:dark:text-[#E6E6E6] group-hover:dark:shadow-[#303030] hover:dark:text-[#AEAEAE] hover:dark:shadow-[#303030]'
|
||||
variant='3d'
|
||||
>
|
||||
{key}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -39,10 +39,6 @@ import {
|
||||
WebhookSettings,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components'
|
||||
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
|
||||
import {
|
||||
getKeyboardShortcutText,
|
||||
useKeyboardShortcuts,
|
||||
} from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useOperationQueueStore } from '@/stores/operation-queue/store'
|
||||
@@ -147,14 +143,6 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
// Shared condition for keyboard shortcut and button disabled state
|
||||
const isWorkflowBlocked = isExecuting || hasValidationErrors
|
||||
|
||||
// Register keyboard shortcut for running workflow
|
||||
useKeyboardShortcuts(() => {
|
||||
if (!isWorkflowBlocked) {
|
||||
openConsolePanel()
|
||||
handleRunWorkflow()
|
||||
}
|
||||
}, isWorkflowBlocked)
|
||||
|
||||
// // Check if the current user is the owner of the published workflow
|
||||
// const isWorkflowOwner = () => {
|
||||
// const marketplaceData = getMarketplaceData()
|
||||
@@ -1125,12 +1113,6 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
<Play className={cn('h-3.5 w-3.5', 'fill-current stroke-current')} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
{getTooltipContent()}
|
||||
<span className='ml-1 text-xs opacity-75'>
|
||||
({getKeyboardShortcutText('Enter', true)})
|
||||
</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import { useViewport } from 'reactflow'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
|
||||
import { useSocket } from '@/contexts/socket-context'
|
||||
|
||||
interface CursorPoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface CursorRenderData {
|
||||
id: string
|
||||
name: string
|
||||
cursor: CursorPoint
|
||||
color: string
|
||||
}
|
||||
|
||||
const POINTER_OFFSET = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
}
|
||||
|
||||
const CursorsComponent = () => {
|
||||
const { presenceUsers } = useSocket()
|
||||
const viewport = useViewport()
|
||||
const session = useSession()
|
||||
const currentUserId = session.data?.user?.id
|
||||
|
||||
const cursors = useMemo<CursorRenderData[]>(() => {
|
||||
return presenceUsers
|
||||
.filter((user): user is typeof user & { cursor: CursorPoint } => Boolean(user.cursor))
|
||||
.filter((user) => user.userId !== currentUserId)
|
||||
.map((user) => ({
|
||||
id: user.socketId,
|
||||
name: user.userName?.trim() || 'Collaborator',
|
||||
cursor: user.cursor,
|
||||
color: getUserColor(user.userId),
|
||||
}))
|
||||
}, [currentUserId, presenceUsers])
|
||||
|
||||
if (!cursors.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='pointer-events-none absolute inset-0 z-30 select-none'>
|
||||
{cursors.map(({ id, name, cursor, color }) => {
|
||||
const x = cursor.x * viewport.zoom + viewport.x
|
||||
const y = cursor.y * viewport.zoom + viewport.y
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className='pointer-events-none absolute'
|
||||
style={{
|
||||
transform: `translate3d(${x}px, ${y}px, 0)`,
|
||||
transition: 'transform 0.12s ease-out',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='relative'
|
||||
style={{ transform: `translate(${-POINTER_OFFSET.x}px, ${-POINTER_OFFSET.y}px)` }}
|
||||
>
|
||||
{/* Simple cursor pointer */}
|
||||
<svg width={16} height={18} viewBox='0 0 16 18' fill='none'>
|
||||
<path
|
||||
d='M0.5 0.5L0.5 12L4 9L6.5 15L8.5 14L6 8L12 8L0.5 0.5Z'
|
||||
fill={color}
|
||||
stroke='rgba(0,0,0,0.3)'
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Name tag underneath and to the right */}
|
||||
<div
|
||||
className='absolute top-[18px] left-[4px] h-[21px] w-[140px] truncate whitespace-nowrap rounded-[2px] p-[6px] font-medium text-[#1E1E1E] text-[11px]'
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Cursors = memo(CursorsComponent)
|
||||
Cursors.displayName = 'Cursors'
|
||||
@@ -1,9 +1,13 @@
|
||||
export { CommandList } from './command-list/command-list'
|
||||
export { ControlBar } from './control-bar/control-bar'
|
||||
export { Cursors } from './cursors/cursors'
|
||||
export { DiffControls } from './diff-controls/diff-controls'
|
||||
export { ErrorBoundary } from './error/index'
|
||||
export { Panel } from './panel/panel'
|
||||
export { Panel } from './panel-new/panel-new'
|
||||
export { SkeletonLoading } from './skeleton-loading/skeleton-loading'
|
||||
export { SubflowNodeComponent } from './subflows/subflow-node'
|
||||
export { Terminal } from './terminal/terminal'
|
||||
export { TrainingControls } from './training-controls/training-controls'
|
||||
export { WandPromptBar } from './wand-prompt-bar/wand-prompt-bar'
|
||||
export { WorkflowBlock } from './workflow-block/workflow-block'
|
||||
export { WorkflowEdge } from './workflow-edge/workflow-edge'
|
||||
|
||||
@@ -95,29 +95,18 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
|
||||
useEffect(() => {
|
||||
if (!visible || !flatTagList.length) return
|
||||
|
||||
// Helper to open a folder with proper selection callback and parent selection
|
||||
const openFolderWithSelection = (
|
||||
folderId: string,
|
||||
folderTitle: string,
|
||||
parentTag: string,
|
||||
group: BlockTagGroup
|
||||
) => {
|
||||
const parentIdx = flatTagList.findIndex((item) => item.tag === parentTag)
|
||||
const selectionCallback = () => handleTagSelect(parentTag, group)
|
||||
|
||||
// Find parent tag index (which is first in visible items when in folder)
|
||||
let parentIndex = 0
|
||||
for (const g of nestedBlockTagGroups) {
|
||||
for (const nestedTag of g.nestedTags) {
|
||||
if (nestedTag.parentTag === parentTag) {
|
||||
const idx = flatTagList.findIndex((item) => item.tag === nestedTag.parentTag)
|
||||
parentIndex = idx >= 0 ? idx : 0
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
openFolder(folderId, folderTitle, undefined, selectionCallback)
|
||||
setSelectedIndex(parentIndex)
|
||||
if (parentIdx >= 0) {
|
||||
setSelectedIndex(parentIdx)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyboardEvent = (e: KeyboardEvent) => {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { BookOpen, ChevronUp, Crosshair, RepeatIcon, Settings, SplitIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronUp,
|
||||
Crosshair,
|
||||
Pencil,
|
||||
RepeatIcon,
|
||||
Settings,
|
||||
SplitIcon,
|
||||
} from 'lucide-react'
|
||||
import { Button, Tooltip } from '@/components/emcn'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
|
||||
@@ -98,7 +107,13 @@ export function Editor() {
|
||||
})
|
||||
|
||||
// Collaborative actions
|
||||
const { collaborativeToggleBlockAdvancedMode } = useCollaborativeWorkflow()
|
||||
const { collaborativeToggleBlockAdvancedMode, collaborativeUpdateBlockName } =
|
||||
useCollaborativeWorkflow()
|
||||
|
||||
// Rename state
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const [editedName, setEditedName] = useState('')
|
||||
const nameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Mode toggle handlers
|
||||
const handleToggleAdvancedMode = useCallback(() => {
|
||||
@@ -107,6 +122,43 @@ export function Editor() {
|
||||
}
|
||||
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
|
||||
|
||||
/**
|
||||
* Handles starting the rename process.
|
||||
*/
|
||||
const handleStartRename = useCallback(() => {
|
||||
if (!userPermissions.canEdit || !currentBlock) return
|
||||
setEditedName(currentBlock.name || '')
|
||||
setIsRenaming(true)
|
||||
}, [userPermissions.canEdit, currentBlock])
|
||||
|
||||
/**
|
||||
* Handles saving the renamed block.
|
||||
*/
|
||||
const handleSaveRename = useCallback(() => {
|
||||
if (!currentBlockId || !isRenaming) return
|
||||
|
||||
const trimmedName = editedName.trim()
|
||||
if (trimmedName && trimmedName !== currentBlock?.name) {
|
||||
collaborativeUpdateBlockName(currentBlockId, trimmedName)
|
||||
}
|
||||
setIsRenaming(false)
|
||||
}, [currentBlockId, isRenaming, editedName, currentBlock?.name, collaborativeUpdateBlockName])
|
||||
|
||||
/**
|
||||
* Handles canceling the rename process.
|
||||
*/
|
||||
const handleCancelRename = useCallback(() => {
|
||||
setIsRenaming(false)
|
||||
setEditedName('')
|
||||
}, [])
|
||||
|
||||
// Focus input when entering rename mode
|
||||
useEffect(() => {
|
||||
if (isRenaming && nameInputRef.current) {
|
||||
nameInputRef.current.select()
|
||||
}
|
||||
}, [isRenaming])
|
||||
|
||||
// Focus on block handler
|
||||
const handleFocusOnBlock = useCallback(() => {
|
||||
if (currentBlockId) {
|
||||
@@ -145,14 +197,55 @@ export function Editor() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h2
|
||||
className='min-w-0 flex-1 truncate pr-[8px] font-medium text-[#FFFFFF] text-[14px] dark:text-[#FFFFFF]'
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type='text'
|
||||
value={editedName}
|
||||
onChange={(e) => setEditedName(e.target.value)}
|
||||
onBlur={handleSaveRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSaveRename()
|
||||
} else if (e.key === 'Escape') {
|
||||
handleCancelRename()
|
||||
}
|
||||
}}
|
||||
className='min-w-0 flex-1 truncate bg-transparent pr-[8px] font-medium text-[#FFFFFF] text-[14px] outline-none dark:text-[#FFFFFF]'
|
||||
/>
|
||||
) : (
|
||||
<h2
|
||||
className='min-w-0 flex-1 truncate pr-[8px] font-medium text-[#FFFFFF] text-[14px] dark:text-[#FFFFFF]'
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-[8px]'>
|
||||
{/* Rename button */}
|
||||
{currentBlock && !isSubflow && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='p-0'
|
||||
onClick={isRenaming ? handleSaveRename : handleStartRename}
|
||||
disabled={!userPermissions.canEdit}
|
||||
aria-label={isRenaming ? 'Save name' : 'Rename block'}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<Check className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<Pencil className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>{isRenaming ? 'Save name' : 'Rename block'}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{/* Focus on block button */}
|
||||
{currentBlock && (
|
||||
<Tooltip.Root>
|
||||
|
||||
@@ -30,12 +30,26 @@ interface BlockItem {
|
||||
let cachedTriggers: ReturnType<typeof getTriggersForSidebar> | null = null
|
||||
|
||||
/**
|
||||
* Gets triggers data, computing it once and caching for subsequent calls
|
||||
* Gets triggers data, computing it once and caching for subsequent calls.
|
||||
* Non-integration triggers (Start, Schedule, Webhook) are prioritized first,
|
||||
* followed by all other triggers sorted alphabetically.
|
||||
*/
|
||||
function getTriggers() {
|
||||
if (cachedTriggers === null) {
|
||||
const allTriggers = getTriggersForSidebar()
|
||||
cachedTriggers = allTriggers.sort((a, b) => a.name.localeCompare(b.name))
|
||||
const priorityOrder = ['Start', 'Schedule', 'Webhook']
|
||||
|
||||
cachedTriggers = allTriggers.sort((a, b) => {
|
||||
const aIndex = priorityOrder.indexOf(a.name)
|
||||
const bIndex = priorityOrder.indexOf(b.name)
|
||||
const aHasPriority = aIndex !== -1
|
||||
const bHasPriority = bIndex !== -1
|
||||
|
||||
if (aHasPriority && bHasPriority) return aIndex - bIndex
|
||||
if (aHasPriority) return -1
|
||||
if (bHasPriority) return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
return cachedTriggers
|
||||
}
|
||||
|
||||
@@ -23,14 +23,17 @@ import {
|
||||
Trash,
|
||||
} from '@/components/emcn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useDeleteWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { useChatStore } from '@/stores/chat/store'
|
||||
import { usePanelStore } from '@/stores/panel-new/store'
|
||||
import type { PanelTab } from '@/stores/panel-new/types'
|
||||
// import { useVariablesStore } from '@/stores/variables/store'
|
||||
import { useWorkflowJsonStore } from '@/stores/workflows/json/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
// import { Variables } from '../variables/variables'
|
||||
import { Copilot, Deploy, Editor, Toolbar } from './components'
|
||||
import { usePanelResize, useRunWorkflow, useUsageLimits } from './hooks'
|
||||
|
||||
@@ -104,6 +107,7 @@ export function Panel() {
|
||||
|
||||
// Chat state
|
||||
const { isChatOpen, setIsChatOpen } = useChatStore()
|
||||
// const { isOpen: isVariablesOpen, setIsOpen: setVariablesOpen } = useVariablesStore()
|
||||
|
||||
const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null
|
||||
|
||||
@@ -233,6 +237,28 @@ export function Panel() {
|
||||
const isWorkflowBlocked = isExecuting || hasValidationErrors
|
||||
const isButtonDisabled = !isExecuting && (isWorkflowBlocked || (!canRun && !isLoadingPermissions))
|
||||
|
||||
// Register global keyboard shortcuts
|
||||
useRegisterGlobalCommands(() => [
|
||||
{
|
||||
id: 'run-workflow',
|
||||
shortcut: 'Mod+Enter',
|
||||
allowInEditable: false,
|
||||
handler: () => {
|
||||
try {
|
||||
if (isExecuting) {
|
||||
cancelWorkflow()
|
||||
} else if (!isButtonDisabled) {
|
||||
runWorkflow()
|
||||
} else {
|
||||
logger.warn('Cannot run workflow: button is disabled')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to execute Cmd+Enter command', { err })
|
||||
}
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
@@ -259,6 +285,10 @@ export function Panel() {
|
||||
<Layout className='h-3 w-3' animate={isAutoLayouting} variant='clockwise' />
|
||||
<span>Auto layout</span>
|
||||
</PopoverItem>
|
||||
{/* <PopoverItem onClick={() => setVariablesOpen(!isVariablesOpen)}>
|
||||
<Braces className='h-3 w-3' />
|
||||
<span>Variables</span>
|
||||
</PopoverItem> */}
|
||||
{/* <PopoverItem>
|
||||
<Bug className='h-3 w-3' />
|
||||
<span>Debug</span>
|
||||
@@ -437,6 +467,9 @@ export function Panel() {
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Floating Variables Modal */}
|
||||
{/* <Variables /> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps<SubflowNodeDat
|
||||
ref={blockRef}
|
||||
onClick={() => setCurrentBlockId(id)}
|
||||
className={cn(
|
||||
'relative cursor-default select-none rounded-[8px] border border-[#393939]',
|
||||
'relative cursor-pointer select-none rounded-[8px] border border-[#393939]',
|
||||
'transition-block-bg transition-ring',
|
||||
'z-[20]'
|
||||
)}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { useOutputPanelResize } from './use-output-panel-resize'
|
||||
export { useTerminalFilters } from './use-terminal-filters'
|
||||
export { useTerminalResize } from './use-terminal-resize'
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { ConsoleEntry } from '@/stores/terminal'
|
||||
|
||||
/**
|
||||
* Sort configuration
|
||||
*/
|
||||
export type SortField = 'timestamp'
|
||||
export type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export interface SortConfig {
|
||||
field: SortField
|
||||
direction: SortDirection
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter configuration state
|
||||
*/
|
||||
export interface TerminalFilters {
|
||||
blockIds: Set<string>
|
||||
statuses: Set<'error' | 'info'>
|
||||
runIds: Set<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to manage terminal filters and sorting.
|
||||
* Provides filter state, sort state, and filtering/sorting logic for console entries.
|
||||
*
|
||||
* @returns Filter state, sort state, and handlers
|
||||
*/
|
||||
export function useTerminalFilters() {
|
||||
const [filters, setFilters] = useState<TerminalFilters>({
|
||||
blockIds: new Set(),
|
||||
statuses: new Set(),
|
||||
runIds: new Set(),
|
||||
})
|
||||
|
||||
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
||||
field: 'timestamp',
|
||||
direction: 'desc',
|
||||
})
|
||||
|
||||
/**
|
||||
* Toggles a block filter by block ID
|
||||
*/
|
||||
const toggleBlock = useCallback((blockId: string) => {
|
||||
setFilters((prev) => {
|
||||
const newBlockIds = new Set(prev.blockIds)
|
||||
if (newBlockIds.has(blockId)) {
|
||||
newBlockIds.delete(blockId)
|
||||
} else {
|
||||
newBlockIds.add(blockId)
|
||||
}
|
||||
return { ...prev, blockIds: newBlockIds }
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Toggles a status filter
|
||||
*/
|
||||
const toggleStatus = useCallback((status: 'error' | 'info') => {
|
||||
setFilters((prev) => {
|
||||
const newStatuses = new Set(prev.statuses)
|
||||
if (newStatuses.has(status)) {
|
||||
newStatuses.delete(status)
|
||||
} else {
|
||||
newStatuses.add(status)
|
||||
}
|
||||
return { ...prev, statuses: newStatuses }
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Toggles a run ID filter
|
||||
*/
|
||||
const toggleRunId = useCallback((runId: string) => {
|
||||
setFilters((prev) => {
|
||||
const newRunIds = new Set(prev.runIds)
|
||||
if (newRunIds.has(runId)) {
|
||||
newRunIds.delete(runId)
|
||||
} else {
|
||||
newRunIds.add(runId)
|
||||
}
|
||||
return { ...prev, runIds: newRunIds }
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Toggles sort direction between ascending and descending
|
||||
*/
|
||||
const toggleSort = useCallback(() => {
|
||||
setSortConfig((prev) => ({
|
||||
field: prev.field,
|
||||
direction: prev.direction === 'desc' ? 'asc' : 'desc',
|
||||
}))
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Clears all filters
|
||||
*/
|
||||
const clearFilters = useCallback(() => {
|
||||
setFilters({
|
||||
blockIds: new Set(),
|
||||
statuses: new Set(),
|
||||
runIds: new Set(),
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Checks if any filters are active
|
||||
*/
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
return filters.blockIds.size > 0 || filters.statuses.size > 0 || filters.runIds.size > 0
|
||||
}, [filters])
|
||||
|
||||
/**
|
||||
* Filters and sorts console entries based on current filter and sort state
|
||||
*/
|
||||
const filterEntries = useCallback(
|
||||
(entries: ConsoleEntry[]): ConsoleEntry[] => {
|
||||
// Apply filters first
|
||||
let result = entries
|
||||
|
||||
if (hasActiveFilters) {
|
||||
result = entries.filter((entry) => {
|
||||
// Block ID filter
|
||||
if (filters.blockIds.size > 0 && !filters.blockIds.has(entry.blockId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (filters.statuses.size > 0) {
|
||||
const isError = !!entry.error
|
||||
const hasStatus = isError ? filters.statuses.has('error') : filters.statuses.has('info')
|
||||
if (!hasStatus) return false
|
||||
}
|
||||
|
||||
// Run ID filter
|
||||
if (
|
||||
filters.runIds.size > 0 &&
|
||||
(!entry.executionId || !filters.runIds.has(entry.executionId))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// Apply sorting by timestamp
|
||||
result = [...result].sort((a, b) => {
|
||||
const timeA = new Date(a.timestamp).getTime()
|
||||
const timeB = new Date(b.timestamp).getTime()
|
||||
const comparison = timeA - timeB
|
||||
return sortConfig.direction === 'asc' ? comparison : -comparison
|
||||
})
|
||||
|
||||
return result
|
||||
},
|
||||
[filters, hasActiveFilters, sortConfig]
|
||||
)
|
||||
|
||||
return {
|
||||
filters,
|
||||
sortConfig,
|
||||
toggleBlock,
|
||||
toggleStatus,
|
||||
toggleRunId,
|
||||
toggleSort,
|
||||
clearFilters,
|
||||
hasActiveFilters,
|
||||
filterEntries,
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clipboard,
|
||||
MoreHorizontal,
|
||||
Filter,
|
||||
FilterX,
|
||||
RepeatIcon,
|
||||
SplitIcon,
|
||||
Trash2,
|
||||
@@ -18,17 +21,21 @@ import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverSection,
|
||||
PopoverScrollArea,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
Wrap,
|
||||
} from '@/components/emcn'
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { ConsoleEntry } from '@/stores/terminal'
|
||||
import { useTerminalConsoleStore, useTerminalStore } from '@/stores/terminal'
|
||||
import {
|
||||
DEFAULT_TERMINAL_HEIGHT,
|
||||
useTerminalConsoleStore,
|
||||
useTerminalStore,
|
||||
} from '@/stores/terminal'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { PrettierOutput } from './components'
|
||||
import { useOutputPanelResize, useTerminalResize } from './hooks'
|
||||
// import { PrettierOutput } from './components'
|
||||
import { useOutputPanelResize, useTerminalFilters, useTerminalResize } from './hooks'
|
||||
|
||||
/**
|
||||
* Terminal height configuration constants
|
||||
@@ -119,10 +126,21 @@ const getStatusInfo = (
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable column header component
|
||||
* Reusable column header component with optional filter button
|
||||
*/
|
||||
const ColumnHeader = ({ label, width }: { label: string; width: string }) => (
|
||||
<span className={clsx(width, COLUMN_BASE_CLASS, HEADER_TEXT_CLASS)}>{label}</span>
|
||||
const ColumnHeader = ({
|
||||
label,
|
||||
width,
|
||||
filterButton,
|
||||
}: {
|
||||
label: string
|
||||
width: string
|
||||
filterButton?: React.ReactNode
|
||||
}) => (
|
||||
<div className={clsx(width, COLUMN_BASE_CLASS, 'flex items-center')}>
|
||||
<span className={HEADER_TEXT_CLASS}>{label}</span>
|
||||
{filterButton && <div className='-mt-[0.75px] ml-[8px] flex items-center'>{filterButton}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -182,6 +200,39 @@ const getRunIdColor = (
|
||||
return RUN_ID_COLORS[colorIndex % RUN_ID_COLORS.length]
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a keyboard event originated from a text-editable element.
|
||||
*
|
||||
* Treats native inputs, textareas, contenteditable elements, and elements with
|
||||
* textbox-like roles as editable. If the event target or any of its ancestors
|
||||
* match these criteria, we consider it editable and skip global key handlers.
|
||||
*
|
||||
* @param e - Keyboard event to inspect
|
||||
* @returns True if the event is from an editable context, false otherwise
|
||||
*/
|
||||
const isEventFromEditableElement = (e: KeyboardEvent): boolean => {
|
||||
const target = e.target as HTMLElement | null
|
||||
if (!target) return false
|
||||
|
||||
const isEditable = (el: HTMLElement | null): boolean => {
|
||||
if (!el) return false
|
||||
if (el instanceof HTMLInputElement) return true
|
||||
if (el instanceof HTMLTextAreaElement) return true
|
||||
if ((el as HTMLElement).isContentEditable) return true
|
||||
const role = el.getAttribute('role')
|
||||
if (role === 'textbox' || role === 'combobox') return true
|
||||
return false
|
||||
}
|
||||
|
||||
// Check target and walk up ancestors in case editors render nested elements
|
||||
let el: HTMLElement | null = target
|
||||
while (el) {
|
||||
if (isEditable(el)) return true
|
||||
el = el.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal component with resizable height that persists across page refreshes.
|
||||
*
|
||||
@@ -196,13 +247,14 @@ const getRunIdColor = (
|
||||
*/
|
||||
export function Terminal() {
|
||||
const terminalRef = useRef<HTMLElement>(null)
|
||||
const prevEntriesLengthRef = useRef(0)
|
||||
const {
|
||||
terminalHeight,
|
||||
setTerminalHeight,
|
||||
outputPanelWidth,
|
||||
setOutputPanelWidth,
|
||||
displayMode,
|
||||
setDisplayMode,
|
||||
// displayMode,
|
||||
// setDisplayMode,
|
||||
setHasHydrated,
|
||||
} = useTerminalStore()
|
||||
const entries = useTerminalConsoleStore((state) => state.entries)
|
||||
@@ -210,36 +262,99 @@ export function Terminal() {
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const [selectedEntry, setSelectedEntry] = useState<ConsoleEntry | null>(null)
|
||||
const [isToggling, setIsToggling] = useState(false)
|
||||
const [displayPopoverOpen, setDisplayPopoverOpen] = useState(false)
|
||||
// const [displayPopoverOpen, setDisplayPopoverOpen] = useState(false)
|
||||
const [wrapText, setWrapText] = useState(true)
|
||||
const [showCopySuccess, setShowCopySuccess] = useState(false)
|
||||
const [showInput, setShowInput] = useState(false)
|
||||
const [autoSelectEnabled, setAutoSelectEnabled] = useState(true)
|
||||
const [blockFilterOpen, setBlockFilterOpen] = useState(false)
|
||||
const [statusFilterOpen, setStatusFilterOpen] = useState(false)
|
||||
const [runIdFilterOpen, setRunIdFilterOpen] = useState(false)
|
||||
|
||||
// Terminal resize hooks
|
||||
const { handleMouseDown } = useTerminalResize()
|
||||
const { handleMouseDown: handleOutputPanelResizeMouseDown } = useOutputPanelResize()
|
||||
|
||||
// Terminal filters hook
|
||||
const {
|
||||
filters,
|
||||
sortConfig,
|
||||
toggleBlock,
|
||||
toggleStatus,
|
||||
toggleRunId,
|
||||
toggleSort,
|
||||
clearFilters,
|
||||
filterEntries,
|
||||
hasActiveFilters,
|
||||
} = useTerminalFilters()
|
||||
|
||||
const isExpanded = terminalHeight > NEAR_MIN_THRESHOLD
|
||||
|
||||
/**
|
||||
* Filter entries for current workflow
|
||||
* Get all entries for current workflow (before filtering) for filter options
|
||||
*/
|
||||
const filteredEntries = useMemo(() => {
|
||||
const allWorkflowEntries = useMemo(() => {
|
||||
if (!activeWorkflowId) return []
|
||||
return entries.filter((entry) => entry.workflowId === activeWorkflowId)
|
||||
}, [entries, activeWorkflowId])
|
||||
|
||||
/**
|
||||
* Filter entries for current workflow and apply filters
|
||||
*/
|
||||
const filteredEntries = useMemo(() => {
|
||||
return filterEntries(allWorkflowEntries)
|
||||
}, [allWorkflowEntries, filterEntries])
|
||||
|
||||
/**
|
||||
* Get unique blocks (by ID) from all workflow entries
|
||||
*/
|
||||
const uniqueBlocks = useMemo(() => {
|
||||
const blocksMap = new Map<string, { blockId: string; blockName: string; blockType: string }>()
|
||||
allWorkflowEntries.forEach((entry) => {
|
||||
if (!blocksMap.has(entry.blockId)) {
|
||||
blocksMap.set(entry.blockId, {
|
||||
blockId: entry.blockId,
|
||||
blockName: entry.blockName,
|
||||
blockType: entry.blockType,
|
||||
})
|
||||
}
|
||||
})
|
||||
return Array.from(blocksMap.values()).sort((a, b) => a.blockName.localeCompare(b.blockName))
|
||||
}, [allWorkflowEntries])
|
||||
|
||||
/**
|
||||
* Get unique run IDs from all workflow entries
|
||||
*/
|
||||
const uniqueRunIds = useMemo(() => {
|
||||
const runIdsSet = new Set<string>()
|
||||
allWorkflowEntries.forEach((entry) => {
|
||||
if (entry.executionId) {
|
||||
runIdsSet.add(entry.executionId)
|
||||
}
|
||||
})
|
||||
return Array.from(runIdsSet).sort()
|
||||
}, [allWorkflowEntries])
|
||||
|
||||
/**
|
||||
* Check if there are any entries with status information (error or success)
|
||||
*/
|
||||
const hasStatusEntries = useMemo(() => {
|
||||
return allWorkflowEntries.some((entry) => entry.error || entry.success !== undefined)
|
||||
}, [allWorkflowEntries])
|
||||
|
||||
/**
|
||||
* Create stable execution ID to color index mapping based on order of first appearance.
|
||||
* Once an execution ID is assigned a color index, it keeps that index.
|
||||
* Uses all workflow entries to maintain consistent colors regardless of active filters.
|
||||
*/
|
||||
const executionIdOrderMap = useMemo(() => {
|
||||
const orderMap = new Map<string, number>()
|
||||
let colorIndex = 0
|
||||
|
||||
// Process entries in reverse order (oldest first) since entries array is newest-first
|
||||
for (let i = filteredEntries.length - 1; i >= 0; i--) {
|
||||
const entry = filteredEntries[i]
|
||||
// Use allWorkflowEntries to ensure colors remain consistent when filters change
|
||||
for (let i = allWorkflowEntries.length - 1; i >= 0; i--) {
|
||||
const entry = allWorkflowEntries[i]
|
||||
if (entry.executionId && !orderMap.has(entry.executionId)) {
|
||||
orderMap.set(entry.executionId, colorIndex)
|
||||
colorIndex++
|
||||
@@ -247,7 +362,7 @@ export function Terminal() {
|
||||
}
|
||||
|
||||
return orderMap
|
||||
}, [filteredEntries])
|
||||
}, [allWorkflowEntries])
|
||||
|
||||
/**
|
||||
* Check if input data exists for selected entry
|
||||
@@ -273,15 +388,22 @@ export function Terminal() {
|
||||
*/
|
||||
const outputData = useMemo(() => {
|
||||
if (!selectedEntry) return null
|
||||
if (showInput) return selectedEntry.input
|
||||
if (selectedEntry.error) return selectedEntry.error
|
||||
return showInput ? selectedEntry.input : selectedEntry.output
|
||||
return selectedEntry.output
|
||||
}, [selectedEntry, showInput])
|
||||
|
||||
/**
|
||||
* Handle row click - toggle if clicking same entry
|
||||
* Disables auto-selection when user manually selects, re-enables when deselecting
|
||||
*/
|
||||
const handleRowClick = useCallback((entry: ConsoleEntry) => {
|
||||
setSelectedEntry((prev) => (prev?.id === entry.id ? null : entry))
|
||||
setSelectedEntry((prev) => {
|
||||
const isDeselecting = prev?.id === entry.id
|
||||
// Re-enable auto-select when deselecting, disable when selecting
|
||||
setAutoSelectEnabled(isDeselecting)
|
||||
return isDeselecting ? null : entry
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
@@ -293,9 +415,7 @@ export function Terminal() {
|
||||
if (isExpanded) {
|
||||
setTerminalHeight(MIN_HEIGHT)
|
||||
} else {
|
||||
const maxHeight = window.innerHeight * 0.7
|
||||
const targetHeight = Math.min(DEFAULT_EXPANDED_HEIGHT, maxHeight)
|
||||
setTerminalHeight(targetHeight)
|
||||
setTerminalHeight(DEFAULT_TERMINAL_HEIGHT)
|
||||
}
|
||||
}, [isExpanded, setTerminalHeight])
|
||||
|
||||
@@ -377,11 +497,38 @@ export function Terminal() {
|
||||
}
|
||||
}, [showCopySuccess])
|
||||
|
||||
/**
|
||||
* Auto-select the latest entry when new logs arrive
|
||||
* Re-enables auto-selection when all entries are cleared
|
||||
* Only auto-selects when NEW entries are added (length increases)
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (filteredEntries.length === 0) {
|
||||
// Re-enable auto-selection when console is cleared
|
||||
setAutoSelectEnabled(true)
|
||||
setSelectedEntry(null)
|
||||
prevEntriesLengthRef.current = 0
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-select the latest entry only when a NEW entry is added (length increased)
|
||||
if (autoSelectEnabled && filteredEntries.length > prevEntriesLengthRef.current) {
|
||||
const latestEntry = filteredEntries[0]
|
||||
setSelectedEntry(latestEntry)
|
||||
}
|
||||
|
||||
prevEntriesLengthRef.current = filteredEntries.length
|
||||
}, [filteredEntries, autoSelectEnabled])
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation through logs
|
||||
* Disables auto-selection when user manually navigates
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore when typing/navigating inside editable inputs/editors
|
||||
if (isEventFromEditableElement(e)) return
|
||||
|
||||
if (!selectedEntry || filteredEntries.length === 0) return
|
||||
|
||||
// Only handle arrow keys
|
||||
@@ -394,8 +541,10 @@ export function Terminal() {
|
||||
if (currentIndex === -1) return
|
||||
|
||||
if (e.key === 'ArrowUp' && currentIndex > 0) {
|
||||
setAutoSelectEnabled(false)
|
||||
setSelectedEntry(filteredEntries[currentIndex - 1])
|
||||
} else if (e.key === 'ArrowDown' && currentIndex < filteredEntries.length - 1) {
|
||||
setAutoSelectEnabled(false)
|
||||
setSelectedEntry(filteredEntries[currentIndex + 1])
|
||||
}
|
||||
}
|
||||
@@ -404,6 +553,65 @@ export function Terminal() {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [selectedEntry, filteredEntries])
|
||||
|
||||
/**
|
||||
* Handle keyboard navigation for input/output toggle
|
||||
* Left arrow shows output, right arrow shows input
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore when typing/navigating inside editable inputs/editors
|
||||
if (isEventFromEditableElement(e)) return
|
||||
|
||||
if (!selectedEntry) return
|
||||
|
||||
// Only handle left/right arrow keys
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||
|
||||
// Prevent default scrolling behavior
|
||||
e.preventDefault()
|
||||
|
||||
// Expand terminal if collapsed
|
||||
if (!isExpanded) {
|
||||
setIsToggling(true)
|
||||
const maxHeight = window.innerHeight * 0.7
|
||||
const targetHeight = Math.min(DEFAULT_EXPANDED_HEIGHT, maxHeight)
|
||||
setTerminalHeight(targetHeight)
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowLeft') {
|
||||
// Show output
|
||||
if (showInput) {
|
||||
setShowInput(false)
|
||||
}
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
// Show input (only if input data exists)
|
||||
if (!showInput && hasInputData) {
|
||||
setShowInput(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [selectedEntry, showInput, hasInputData, isExpanded])
|
||||
|
||||
/**
|
||||
* Handle Escape to unselect and Enter to re-enable auto-selection
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && selectedEntry) {
|
||||
// Escape unselects the current entry and re-enables auto-selection
|
||||
e.preventDefault()
|
||||
setSelectedEntry(null)
|
||||
setAutoSelectEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [selectedEntry])
|
||||
|
||||
/**
|
||||
* Adjust output panel width when sidebar or panel width changes.
|
||||
* Ensures output panel doesn't exceed maximum allowed width.
|
||||
@@ -483,13 +691,224 @@ export function Terminal() {
|
||||
className='group flex h-[30px] flex-shrink-0 cursor-pointer items-center bg-[#1E1E1E] pr-[16px] pl-[24px]'
|
||||
onClick={handleHeaderClick}
|
||||
>
|
||||
<ColumnHeader label='Block' width={COLUMN_WIDTHS.BLOCK} />
|
||||
<ColumnHeader label='Status' width={COLUMN_WIDTHS.STATUS} />
|
||||
<ColumnHeader label='Run ID' width={COLUMN_WIDTHS.RUN_ID} />
|
||||
{uniqueBlocks.length > 0 ? (
|
||||
<div className={clsx(COLUMN_WIDTHS.BLOCK, COLUMN_BASE_CLASS, 'flex items-center')}>
|
||||
<Popover open={blockFilterOpen} onOpenChange={setBlockFilterOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!h-auto !p-0 flex items-center'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label='Filter by block'
|
||||
>
|
||||
<span className={HEADER_TEXT_CLASS}>Block</span>
|
||||
<div className='-mt-[0.75px] ml-[8px] flex items-center'>
|
||||
<Filter
|
||||
className={clsx(
|
||||
'h-[11px] w-[11px]',
|
||||
filters.blockIds.size > 0 && 'text-[#33B4FF]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='bottom'
|
||||
align='start'
|
||||
sideOffset={4}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ minWidth: '120px', maxWidth: '120px' }}
|
||||
>
|
||||
<PopoverScrollArea style={{ maxHeight: '140px' }}>
|
||||
{uniqueBlocks.map((block, index) => {
|
||||
const BlockIcon = getBlockIcon(block.blockType)
|
||||
const isSelected = filters.blockIds.has(block.blockId)
|
||||
|
||||
return (
|
||||
<PopoverItem
|
||||
key={block.blockId}
|
||||
active={isSelected}
|
||||
onClick={() => toggleBlock(block.blockId)}
|
||||
className={index > 0 ? 'mt-[2px]' : ''}
|
||||
>
|
||||
{BlockIcon && <BlockIcon className='h-3 w-3' />}
|
||||
<span className='flex-1'>{block.blockName}</span>
|
||||
{isSelected && <Check className='h-3 w-3' />}
|
||||
</PopoverItem>
|
||||
)
|
||||
})}
|
||||
</PopoverScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
<ColumnHeader label='Block' width={COLUMN_WIDTHS.BLOCK} />
|
||||
)}
|
||||
{hasStatusEntries ? (
|
||||
<div className={clsx(COLUMN_WIDTHS.STATUS, COLUMN_BASE_CLASS, 'flex items-center')}>
|
||||
<Popover open={statusFilterOpen} onOpenChange={setStatusFilterOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!h-auto !p-0 flex items-center'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label='Filter by status'
|
||||
>
|
||||
<span className={HEADER_TEXT_CLASS}>Status</span>
|
||||
<div className='-mt-[0.75px] ml-[8px] flex items-center'>
|
||||
<Filter
|
||||
className={clsx(
|
||||
'h-[11px] w-[11px]',
|
||||
filters.statuses.size > 0 && 'text-[#33B4FF]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='bottom'
|
||||
align='start'
|
||||
sideOffset={4}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ minWidth: '120px', maxWidth: '120px' }}
|
||||
>
|
||||
<PopoverScrollArea style={{ maxHeight: '140px' }}>
|
||||
<PopoverItem
|
||||
active={filters.statuses.has('error')}
|
||||
onClick={() => toggleStatus('error')}
|
||||
>
|
||||
<div
|
||||
className='h-[6px] w-[6px] rounded-[2px]'
|
||||
style={{ backgroundColor: '#EF4444' }}
|
||||
/>
|
||||
<span className='flex-1'>Error</span>
|
||||
{filters.statuses.has('error') && <Check className='h-3 w-3' />}
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
active={filters.statuses.has('info')}
|
||||
onClick={() => toggleStatus('info')}
|
||||
className='mt-[2px]'
|
||||
>
|
||||
<div
|
||||
className='h-[6px] w-[6px] rounded-[2px]'
|
||||
style={{ backgroundColor: '#B7B7B7' }}
|
||||
/>
|
||||
<span className='flex-1'>Info</span>
|
||||
{filters.statuses.has('info') && <Check className='h-3 w-3' />}
|
||||
</PopoverItem>
|
||||
</PopoverScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
<ColumnHeader label='Status' width={COLUMN_WIDTHS.STATUS} />
|
||||
)}
|
||||
{uniqueRunIds.length > 0 ? (
|
||||
<div className={clsx(COLUMN_WIDTHS.RUN_ID, COLUMN_BASE_CLASS, 'flex items-center')}>
|
||||
<Popover open={runIdFilterOpen} onOpenChange={setRunIdFilterOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!h-auto !p-0 flex items-center'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label='Filter by run ID'
|
||||
>
|
||||
<span className={HEADER_TEXT_CLASS}>Run ID</span>
|
||||
<div className='-mt-[0.75px] ml-[8px] flex items-center'>
|
||||
<Filter
|
||||
className={clsx(
|
||||
'h-[11px] w-[11px]',
|
||||
filters.runIds.size > 0 && 'text-[#33B4FF]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='bottom'
|
||||
align='start'
|
||||
sideOffset={4}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ minWidth: '90px', maxWidth: '90px' }}
|
||||
>
|
||||
<PopoverScrollArea style={{ maxHeight: '140px' }}>
|
||||
{uniqueRunIds.map((runId, index) => {
|
||||
const isSelected = filters.runIds.has(runId)
|
||||
const runIdColor = getRunIdColor(runId, executionIdOrderMap)
|
||||
|
||||
return (
|
||||
<PopoverItem
|
||||
key={runId}
|
||||
active={isSelected}
|
||||
onClick={() => toggleRunId(runId)}
|
||||
className={index > 0 ? 'mt-[2px]' : ''}
|
||||
>
|
||||
<span
|
||||
className='flex-1 font-mono text-[12px]'
|
||||
style={{ color: runIdColor?.text || '#D2D2D2' }}
|
||||
>
|
||||
{formatRunId(runId)}
|
||||
</span>
|
||||
{isSelected && <Check className='h-3 w-3' />}
|
||||
</PopoverItem>
|
||||
)
|
||||
})}
|
||||
</PopoverScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
<ColumnHeader label='Run ID' width={COLUMN_WIDTHS.RUN_ID} />
|
||||
)}
|
||||
<ColumnHeader label='Duration' width={COLUMN_WIDTHS.DURATION} />
|
||||
<ColumnHeader label='Timestamp' width={COLUMN_WIDTHS.TIMESTAMP} />
|
||||
{allWorkflowEntries.length > 0 ? (
|
||||
<div
|
||||
className={clsx(COLUMN_WIDTHS.TIMESTAMP, COLUMN_BASE_CLASS, 'flex items-center')}
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!h-auto !p-0 flex items-center'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleSort()
|
||||
}}
|
||||
aria-label='Sort by timestamp'
|
||||
>
|
||||
<span className={HEADER_TEXT_CLASS}>Timestamp</span>
|
||||
<div className='-mt-[0.75px] ml-[8px] flex items-center'>
|
||||
{sortConfig.direction === 'desc' ? (
|
||||
<ArrowDown className='h-[13px] w-[13px]' />
|
||||
) : (
|
||||
<ArrowUp className='h-[13px] w-[13px]' />
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ColumnHeader label='Timestamp' width={COLUMN_WIDTHS.TIMESTAMP} />
|
||||
)}
|
||||
{!selectedEntry && (
|
||||
<div className='ml-auto flex items-center gap-[8px]'>
|
||||
{hasActiveFilters && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
clearFilters()
|
||||
}}
|
||||
aria-label='Clear filters'
|
||||
className='!p-1.5 -m-1.5'
|
||||
>
|
||||
<FilterX className='h-3 w-3' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>Clear filters</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{filteredEntries.length > 0 && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
@@ -743,7 +1162,7 @@ export function Terminal() {
|
||||
<span>{wrapText ? 'Wrap text' : 'No wrap'}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Popover open={displayPopoverOpen} onOpenChange={setDisplayPopoverOpen}>
|
||||
{/* <Popover open={displayPopoverOpen} onOpenChange={setDisplayPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -756,7 +1175,13 @@ export function Terminal() {
|
||||
<MoreHorizontal className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side='bottom' align='end' sideOffset={4} collisionPadding={0}>
|
||||
<PopoverContent
|
||||
side='bottom'
|
||||
align='end'
|
||||
sideOffset={4}
|
||||
collisionPadding={0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PopoverSection>Display</PopoverSection>
|
||||
<PopoverItem
|
||||
active={displayMode === 'prettier'}
|
||||
@@ -780,7 +1205,27 @@ export function Terminal() {
|
||||
<span>Raw</span>
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Popover> */}
|
||||
{hasActiveFilters && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
clearFilters()
|
||||
}}
|
||||
aria-label='Clear filters'
|
||||
className='!p-1.5 -m-1.5'
|
||||
>
|
||||
<FilterX className='h-3 w-3' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>Clear filters</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{filteredEntries.length > 0 && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
@@ -810,10 +1255,11 @@ export function Terminal() {
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex-1 overflow-x-auto overflow-y-auto',
|
||||
displayMode === 'prettier' && 'px-[8px] pb-[8px]'
|
||||
)}
|
||||
className='flex-1 overflow-x-auto overflow-y-auto'
|
||||
// className={clsx(
|
||||
// 'flex-1 overflow-x-auto overflow-y-auto',
|
||||
// displayMode === 'prettier' && 'px-[8px] pb-[8px]'
|
||||
// )}
|
||||
>
|
||||
{shouldShowCodeDisplay ? (
|
||||
<Code.Viewer
|
||||
@@ -827,7 +1273,18 @@ export function Terminal() {
|
||||
gutterStyle={{ backgroundColor: 'transparent' }}
|
||||
wrapText={wrapText}
|
||||
/>
|
||||
) : displayMode === 'raw' ? (
|
||||
) : (
|
||||
<Code.Viewer
|
||||
code={JSON.stringify(outputData, null, 2)}
|
||||
showGutter
|
||||
language='json'
|
||||
className='m-0 min-h-full rounded-none border-0 bg-[#1E1E1E]'
|
||||
paddingLeft={8}
|
||||
gutterStyle={{ backgroundColor: 'transparent' }}
|
||||
wrapText={wrapText}
|
||||
/>
|
||||
)}
|
||||
{/* ) : displayMode === 'raw' ? (
|
||||
<Code.Viewer
|
||||
code={JSON.stringify(outputData, null, 2)}
|
||||
showGutter
|
||||
@@ -839,7 +1296,7 @@ export function Terminal() {
|
||||
/>
|
||||
) : (
|
||||
<PrettierOutput output={outputData} wrapText={wrapText} />
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Info, Plus, Search, X } from 'lucide-react'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getAllTriggerBlocks, getTriggerDisplayName } from '@/lib/workflows/trigger-utils'
|
||||
|
||||
const logger = createLogger('TriggerList')
|
||||
|
||||
interface TriggerListProps {
|
||||
onSelect: (triggerId: string, enableTriggerMode?: boolean) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TriggerList({ onSelect, className }: TriggerListProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [showList, setShowList] = useState(false)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Get all trigger options from the centralized source
|
||||
const triggerOptions = useMemo(() => getAllTriggerBlocks(), [])
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
if (!showList) return
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
logger.info('Closing trigger list via escape key')
|
||||
setShowList(false)
|
||||
setSearchQuery('')
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [showList])
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!searchQuery.trim()) return triggerOptions
|
||||
|
||||
const query = searchQuery.toLowerCase()
|
||||
return triggerOptions.filter(
|
||||
(option) =>
|
||||
option.name.toLowerCase().includes(query) ||
|
||||
option.description.toLowerCase().includes(query)
|
||||
)
|
||||
}, [searchQuery, triggerOptions])
|
||||
|
||||
const coreOptions = useMemo(
|
||||
() => filteredOptions.filter((opt) => opt.category === 'core'),
|
||||
[filteredOptions]
|
||||
)
|
||||
|
||||
const integrationOptions = useMemo(
|
||||
() => filteredOptions.filter((opt) => opt.category === 'integration'),
|
||||
[filteredOptions]
|
||||
)
|
||||
|
||||
const handleTriggerClick = (triggerId: string, enableTriggerMode?: boolean) => {
|
||||
logger.info('Trigger selected', { triggerId, enableTriggerMode })
|
||||
onSelect(triggerId, enableTriggerMode)
|
||||
// Reset state after selection
|
||||
setShowList(false)
|
||||
setSearchQuery('')
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
logger.info('Closing trigger list via X button')
|
||||
setShowList(false)
|
||||
setSearchQuery('')
|
||||
}
|
||||
|
||||
const TriggerItem = ({ trigger }: { trigger: (typeof triggerOptions)[0] }) => {
|
||||
const Icon = trigger.icon
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-10 w-[200px] flex-shrink-0 cursor-pointer items-center gap-[10px] rounded-[8px] border px-1.5 transition-all duration-200',
|
||||
'border-border/40 bg-background/60 hover:border-border hover:bg-secondary/80'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
onClick={() => handleTriggerClick(trigger.id, trigger.enableTriggerMode)}
|
||||
className='flex flex-1 items-center gap-[10px]'
|
||||
>
|
||||
<div
|
||||
className='relative flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded-[6px]'
|
||||
style={{ backgroundColor: trigger.color }}
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon className='!h-4 !w-4 text-white' />
|
||||
) : (
|
||||
<div className='h-4 w-4 rounded bg-white/20' />
|
||||
)}
|
||||
</div>
|
||||
<span className='flex-1 truncate font-medium text-sm leading-none'>
|
||||
{getTriggerDisplayName(trigger.id)}
|
||||
</span>
|
||||
</div>
|
||||
<Tooltip.Root delayDuration={300}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='flex h-6 w-6 items-center justify-center rounded-md'
|
||||
>
|
||||
<Info className='h-3.5 w-3.5 text-muted-foreground' />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
side='top'
|
||||
sideOffset={5}
|
||||
className='z-[9999] max-w-[200px]'
|
||||
align='center'
|
||||
avoidCollisions={false}
|
||||
>
|
||||
<p className='text-xs'>{trigger.description}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 flex items-center justify-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!showList ? (
|
||||
/* Initial Button State */
|
||||
<button
|
||||
onClick={() => {
|
||||
logger.info('Opening trigger list')
|
||||
setShowList(true)
|
||||
}}
|
||||
className={cn(
|
||||
'pointer-events-auto',
|
||||
'flex items-center gap-2',
|
||||
'px-4 py-2',
|
||||
'rounded-lg border border-muted-foreground/50 border-dashed',
|
||||
'bg-background/95 backdrop-blur-sm',
|
||||
'hover:border-muted-foreground hover:bg-muted',
|
||||
'transition-all duration-200',
|
||||
'font-medium text-muted-foreground text-sm'
|
||||
)}
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
Click to Add Trigger
|
||||
</button>
|
||||
) : (
|
||||
/* Trigger List View */
|
||||
<div
|
||||
ref={listRef}
|
||||
className={cn(
|
||||
'pointer-events-auto',
|
||||
'max-h-[400px] w-[650px]',
|
||||
'rounded-xl border border-border',
|
||||
'bg-background/95 backdrop-blur-sm',
|
||||
'shadow-lg',
|
||||
'flex flex-col',
|
||||
'relative'
|
||||
)}
|
||||
>
|
||||
{/* Search - matching search modal exactly */}
|
||||
<div className='flex items-center border-b px-4 py-1'>
|
||||
<Search className='h-4 w-4 font-sans text-muted-foreground text-xl' />
|
||||
<Input
|
||||
placeholder='Search triggers'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className='!font-[350] border-0 bg-transparent font-sans text-muted-foreground leading-10 tracking-normal placeholder:text-muted-foreground focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className='absolute top-4 right-4 h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground focus:outline-none disabled:pointer-events-none'
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</button>
|
||||
|
||||
{/* Trigger List */}
|
||||
<div
|
||||
className='flex-1 overflow-y-auto'
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
<div className='space-y-4 pt-4 pb-4'>
|
||||
{/* Core Triggers Section */}
|
||||
{coreOptions.length > 0 && (
|
||||
<div>
|
||||
<h3 className='mb-2 ml-4 font-normal font-sans text-[13px] text-muted-foreground leading-none tracking-normal'>
|
||||
Core Triggers
|
||||
</h3>
|
||||
<div className='px-4 pb-1'>
|
||||
{/* Display triggers in a 3-column grid */}
|
||||
<div className='grid grid-cols-3 gap-2'>
|
||||
{coreOptions.map((trigger) => (
|
||||
<TriggerItem key={trigger.id} trigger={trigger} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Integration Triggers Section */}
|
||||
{integrationOptions.length > 0 && (
|
||||
<div>
|
||||
<h3 className='mb-2 ml-4 font-normal font-sans text-[13px] text-muted-foreground leading-none tracking-normal'>
|
||||
Integration Triggers
|
||||
</h3>
|
||||
<div
|
||||
className='max-h-[200px] overflow-y-auto px-4 pb-1'
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{/* Display triggers in a 3-column grid */}
|
||||
<div className='grid grid-cols-3 gap-2'>
|
||||
{integrationOptions.map((trigger) => (
|
||||
<TriggerItem key={trigger.id} trigger={trigger} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredOptions.length === 0 && (
|
||||
<div className='ml-6 py-12 text-center'>
|
||||
<p className='text-muted-foreground'>No results found for "{searchQuery}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Plus, Trash, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
import type { ComboboxOption } from '@/components/emcn'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Code,
|
||||
Combobox,
|
||||
calculateGutterWidth,
|
||||
getCodeEditorProps,
|
||||
highlight,
|
||||
Input,
|
||||
languages,
|
||||
} from '@/components/emcn'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn, validateName } from '@/lib/utils'
|
||||
import { getVariablesPosition, useVariablesStore } from '@/stores/variables/store'
|
||||
import type { Variable } from '@/stores/variables/types'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useChatBoundarySync, useChatDrag, useChatResize } from '../chat/hooks'
|
||||
|
||||
const logger = createLogger('FloatingVariables')
|
||||
|
||||
/**
|
||||
* Type options for variable type selection
|
||||
*/
|
||||
const TYPE_OPTIONS: ComboboxOption[] = [
|
||||
{ label: 'Plain', value: 'plain' },
|
||||
{ label: 'Number', value: 'number' },
|
||||
{ label: 'Boolean', value: 'boolean' },
|
||||
{ label: 'Object', value: 'object' },
|
||||
{ label: 'Array', value: 'array' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Floating Variables modal component
|
||||
*
|
||||
* Matches the visual and interaction style of the Chat modal:
|
||||
* - Draggable and resizable within the canvas bounds
|
||||
* - Persists position and size
|
||||
* - Uses emcn Input/Code/Combobox components for a consistent UI
|
||||
*/
|
||||
export function Variables() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
|
||||
// UI store
|
||||
const {
|
||||
isOpen,
|
||||
position,
|
||||
width,
|
||||
height,
|
||||
setIsOpen,
|
||||
setPosition,
|
||||
setDimensions,
|
||||
// Data
|
||||
variables,
|
||||
loadForWorkflow,
|
||||
addVariable,
|
||||
updateVariable,
|
||||
deleteVariable,
|
||||
getVariablesByWorkflowId,
|
||||
} = useVariablesStore()
|
||||
|
||||
// Local UI helpers
|
||||
const actualPosition = useMemo(
|
||||
() => getVariablesPosition(position, width, height),
|
||||
[position, width, height]
|
||||
)
|
||||
|
||||
const { handleMouseDown } = useChatDrag({
|
||||
position: actualPosition,
|
||||
width,
|
||||
height,
|
||||
onPositionChange: setPosition,
|
||||
})
|
||||
|
||||
useChatBoundarySync({
|
||||
isOpen,
|
||||
position: actualPosition,
|
||||
width,
|
||||
height,
|
||||
onPositionChange: setPosition,
|
||||
})
|
||||
|
||||
const {
|
||||
cursor: resizeCursor,
|
||||
handleMouseMove: handleResizeMouseMove,
|
||||
handleMouseLeave: handleResizeMouseLeave,
|
||||
handleMouseDown: handleResizeMouseDown,
|
||||
} = useChatResize({
|
||||
position: actualPosition,
|
||||
width,
|
||||
height,
|
||||
onPositionChange: setPosition,
|
||||
onDimensionsChange: setDimensions,
|
||||
})
|
||||
|
||||
// Data for current workflow
|
||||
const workflowVariables = useMemo(
|
||||
() => (activeWorkflowId ? getVariablesByWorkflowId(activeWorkflowId) : []),
|
||||
[activeWorkflowId, getVariablesByWorkflowId, variables]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (activeWorkflowId) {
|
||||
loadForWorkflow(activeWorkflowId).catch((e) => logger.error('loadForWorkflow failed', e))
|
||||
}
|
||||
}, [activeWorkflowId, loadForWorkflow])
|
||||
|
||||
// Ensure variables are loaded when the modal is opened
|
||||
useEffect(() => {
|
||||
if (isOpen && activeWorkflowId) {
|
||||
loadForWorkflow(activeWorkflowId).catch((e) => logger.error('loadForWorkflow failed', e))
|
||||
}
|
||||
}, [isOpen, activeWorkflowId, loadForWorkflow])
|
||||
|
||||
// Local per-variable UI state
|
||||
const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>({})
|
||||
const [localNames, setLocalNames] = useState<Record<string, string>>({})
|
||||
const [nameErrors, setNameErrors] = useState<Record<string, string>>({})
|
||||
|
||||
/**
|
||||
* Toggles the collapsed state of a variable
|
||||
*/
|
||||
const toggleCollapsed = (variableId: string) => {
|
||||
setCollapsedById((prev) => ({
|
||||
...prev,
|
||||
[variableId]: !prev[variableId],
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear local name/error state for a variable
|
||||
*/
|
||||
const clearLocalState = (variableId: string) => {
|
||||
setLocalNames((prev) => {
|
||||
const updated = { ...prev }
|
||||
delete updated[variableId]
|
||||
return updated
|
||||
})
|
||||
setNameErrors((prev) => {
|
||||
const updated = { ...prev }
|
||||
delete updated[variableId]
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error for a specific variable if present
|
||||
*/
|
||||
const clearError = (variableId: string) => {
|
||||
setNameErrors((prev) => {
|
||||
if (!prev[variableId]) return prev
|
||||
const updated = { ...prev }
|
||||
delete updated[variableId]
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new variable to the list
|
||||
*/
|
||||
const handleAddVariable = () => {
|
||||
if (!activeWorkflowId) return
|
||||
addVariable({
|
||||
name: '',
|
||||
type: 'plain',
|
||||
value: '',
|
||||
workflowId: activeWorkflowId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a variable by ID
|
||||
*/
|
||||
const handleRemoveVariable = (variableId: string) => {
|
||||
deleteVariable(variableId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a specific variable property
|
||||
*/
|
||||
const handleUpdateVariable = (variableId: string, field: keyof Variable, value: any) => {
|
||||
const validatedValue =
|
||||
field === 'name' && typeof value === 'string' ? validateName(value) : value
|
||||
updateVariable(variableId, { [field]: validatedValue })
|
||||
}
|
||||
|
||||
/**
|
||||
* Local handlers for name editing with validation akin to panel behavior
|
||||
*/
|
||||
const isDuplicateName = (variableId: string, name: string): boolean => {
|
||||
if (!name.trim()) return false
|
||||
return workflowVariables.some((v) => v.id !== variableId && v.name === name.trim())
|
||||
}
|
||||
|
||||
const handleVariableNameChange = (variableId: string, newName: string) => {
|
||||
const validatedName = validateName(newName)
|
||||
setLocalNames((prev) => ({
|
||||
...prev,
|
||||
[variableId]: validatedName,
|
||||
}))
|
||||
clearError(variableId)
|
||||
}
|
||||
|
||||
const handleVariableNameBlur = (variableId: string) => {
|
||||
const localName = localNames[variableId]
|
||||
if (localName === undefined) return
|
||||
|
||||
const trimmedName = localName.trim()
|
||||
if (!trimmedName) {
|
||||
setNameErrors((prev) => ({
|
||||
...prev,
|
||||
[variableId]: 'Variable name cannot be empty',
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (isDuplicateName(variableId, trimmedName)) {
|
||||
setNameErrors((prev) => ({
|
||||
...prev,
|
||||
[variableId]: 'Two variables cannot have the same name',
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
updateVariable(variableId, { name: trimmedName })
|
||||
clearLocalState(variableId)
|
||||
}
|
||||
|
||||
const handleVariableNameKeyDown = (
|
||||
variableId: string,
|
||||
e: React.KeyboardEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats variable value for display in editor
|
||||
*/
|
||||
const formatValue = (variable: Variable) => {
|
||||
if (variable.value === '') return ''
|
||||
return typeof variable.value === 'string' ? variable.value : JSON.stringify(variable.value)
|
||||
}
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
}, [setIsOpen])
|
||||
|
||||
/**
|
||||
* Renders the variable header with name, type badge, and action buttons
|
||||
*/
|
||||
const renderVariableHeader = (variable: Variable, index: number) => (
|
||||
<div
|
||||
className='flex cursor-pointer items-center justify-between bg-transparent px-[10px] py-[5px]'
|
||||
onClick={() => toggleCollapsed(variable.id)}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
<span className='block truncate font-medium text-[#AEAEAE] text-[14px]'>
|
||||
{variable.name || `Variable ${index + 1}`}
|
||||
</span>
|
||||
{variable.name && <Badge className='h-[20px] text-[13px]'>{variable.type}</Badge>}
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant='ghost' onClick={handleAddVariable} className='h-auto p-0'>
|
||||
<Plus className='h-[14px] w-[14px]' />
|
||||
<span className='sr-only'>Add Variable</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleRemoveVariable(variable.id)}
|
||||
className='h-auto p-0 text-[#EF4444] hover:text-[#EF4444]'
|
||||
>
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
<span className='sr-only'>Delete Variable</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* Renders the value input based on variable type
|
||||
*/
|
||||
const renderValueInput = (variable: Variable) => {
|
||||
const variableValue = formatValue(variable)
|
||||
|
||||
if (variable.type === 'object' || variable.type === 'array') {
|
||||
const lineCount = variableValue.split('\n').length
|
||||
const gutterWidth = calculateGutterWidth(lineCount)
|
||||
const placeholder = variable.type === 'object' ? '{\n "key": "value"\n}' : '[\n 1, 2, 3\n]'
|
||||
|
||||
const renderLineNumbers = () => {
|
||||
return Array.from({ length: lineCount }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='font-medium font-mono text-[#787878] text-xs'
|
||||
style={{ height: `${21}px`, lineHeight: `${21}px` }}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<Code.Container className='min-h-[120px]'>
|
||||
<Code.Gutter width={gutterWidth}>{renderLineNumbers()}</Code.Gutter>
|
||||
<Code.Content paddingLeft={`${gutterWidth}px`}>
|
||||
<Code.Placeholder gutterWidth={gutterWidth} show={variableValue.length === 0}>
|
||||
{placeholder}
|
||||
</Code.Placeholder>
|
||||
<Editor
|
||||
value={variableValue}
|
||||
onValueChange={(newValue) => handleUpdateVariable(variable.id, 'value', newValue)}
|
||||
highlight={(code) => highlight(code, languages.json, 'json')}
|
||||
{...getCodeEditorProps()}
|
||||
/>
|
||||
</Code.Content>
|
||||
</Code.Container>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
name='value'
|
||||
value={variableValue}
|
||||
onChange={(e) => handleUpdateVariable(variable.id, 'value', e.target.value)}
|
||||
placeholder={
|
||||
variable.type === 'number'
|
||||
? '42'
|
||||
: variable.type === 'boolean'
|
||||
? 'true'
|
||||
: 'Plain text value'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className='fixed z-30 flex flex-col overflow-hidden rounded-[6px] bg-[#1E1E1E] px-[10px] pt-[2px] pb-[8px]'
|
||||
style={{
|
||||
left: `${actualPosition.x}px`,
|
||||
top: `${actualPosition.y}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
cursor: resizeCursor || undefined,
|
||||
}}
|
||||
onMouseMove={handleResizeMouseMove}
|
||||
onMouseLeave={handleResizeMouseLeave}
|
||||
onMouseDown={handleResizeMouseDown}
|
||||
>
|
||||
{/* Header (drag handle) */}
|
||||
<div
|
||||
className='flex h-[32px] flex-shrink-0 cursor-grab items-center justify-between bg-[#1E1E1E] p-0 active:cursor-grabbing'
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<span className='flex-shrink-0 font-medium text-[#E6E6E6] text-[14px]'>Variables</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!p-1.5 -m-1.5'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddVariable()
|
||||
}}
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
<Button variant='ghost' className='!p-1.5 -m-1.5' onClick={handleClose}>
|
||||
<X className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className='flex flex-1 flex-col overflow-hidden pt-[8px]'>
|
||||
{workflowVariables.length === 0 ? (
|
||||
<div className='flex flex-1 items-center justify-center text-[#8D8D8D] text-[13px]'>
|
||||
No variables yet
|
||||
</div>
|
||||
) : (
|
||||
<div className='h-full overflow-y-auto overflow-x-hidden'>
|
||||
<div className='w-full max-w-full space-y-[8px] overflow-hidden'>
|
||||
{workflowVariables.map((variable, index) => (
|
||||
<div
|
||||
key={variable.id}
|
||||
className={cn(
|
||||
'rounded-[4px] border border-[#303030] bg-[#1F1F1F]',
|
||||
(collapsedById[variable.id] ?? false) ? 'overflow-hidden' : 'overflow-visible'
|
||||
)}
|
||||
>
|
||||
{renderVariableHeader(variable, index)}
|
||||
|
||||
{!(collapsedById[variable.id] ?? false) && (
|
||||
<div className='flex flex-col gap-[6px] border-[#303030] border-t px-[10px] pt-[6px] pb-[10px]'>
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<Label className='text-[13px]'>Name</Label>
|
||||
<Input
|
||||
name='name'
|
||||
value={localNames[variable.id] ?? variable.name}
|
||||
onChange={(e) => handleVariableNameChange(variable.id, e.target.value)}
|
||||
onBlur={() => handleVariableNameBlur(variable.id)}
|
||||
onKeyDown={(e) => handleVariableNameKeyDown(variable.id, e)}
|
||||
placeholder='variableName'
|
||||
/>
|
||||
{nameErrors[variable.id] && (
|
||||
<div className='mt-1 text-red-400 text-xs'>{nameErrors[variable.id]}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-[4px]'>
|
||||
<Label className='text-[13px]'>Type</Label>
|
||||
<Combobox
|
||||
options={TYPE_OPTIONS}
|
||||
value={variable.type}
|
||||
onChange={(value) => handleUpdateVariable(variable.id, 'type', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-[4px]'>
|
||||
<Label className='text-[13px]'>Value</Label>
|
||||
<div className='relative'>{renderValueInput(variable)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-r
|
||||
import { Button, Duplicate, Tooltip, Trash2 } from '@/components/emcn'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { supportsHandles } from '@/executor/consts'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
@@ -83,7 +82,8 @@ export const ActionBar = memo(
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
collaborativeToggleBlockEnabled(blockId)
|
||||
}
|
||||
@@ -108,7 +108,8 @@ export const ActionBar = memo(
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
collaborativeDuplicateBlock(blockId)
|
||||
}
|
||||
@@ -128,7 +129,8 @@ export const ActionBar = memo(
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled && userPermissions.canEdit) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('remove-from-subflow', { detail: { blockId } })
|
||||
@@ -147,38 +149,38 @@ export const ActionBar = memo(
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{supportsHandles(blockType) && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
collaborativeToggleBlockHandles(blockId)
|
||||
}
|
||||
}}
|
||||
className='h-[30px] w-[30px] rounded-[8px] bg-[#363636] p-0 text-[#868686] hover:bg-[#33B4FF] hover:text-[#1B1B1B] dark:text-[#868686] dark:hover:bg-[#33B4FF] dark:hover:text-[#1B1B1B]'
|
||||
disabled={disabled}
|
||||
>
|
||||
{horizontalHandles ? (
|
||||
<ArrowLeftRight className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<ArrowUpDown className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='right'>
|
||||
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
collaborativeToggleBlockHandles(blockId)
|
||||
}
|
||||
}}
|
||||
className='h-[30px] w-[30px] rounded-[8px] bg-[#363636] p-0 text-[#868686] hover:bg-[#33B4FF] hover:text-[#1B1B1B] dark:text-[#868686] dark:hover:bg-[#33B4FF] dark:hover:text-[#1B1B1B]'
|
||||
disabled={disabled}
|
||||
>
|
||||
{horizontalHandles ? (
|
||||
<ArrowLeftRight className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<ArrowUpDown className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='right'>
|
||||
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
{!isStarterBlock && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
collaborativeRemoveBlock(blockId)
|
||||
}
|
||||
|
||||
@@ -687,7 +687,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
<config.icon className='h-[16px] w-[16px] text-white' />
|
||||
</div>
|
||||
<span
|
||||
className={cn('font-medium text-[16px]', !isEnabled && 'truncate text-[#808080]')}
|
||||
className={cn('truncate font-medium text-[16px]', !isEnabled && 'text-[#808080]')}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import { useViewport } from 'reactflow'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { getPresenceColors } from '@/lib/collaboration/presence-colors'
|
||||
import { useSocket } from '@/contexts/socket-context'
|
||||
|
||||
interface CursorPoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface CursorRenderData {
|
||||
id: string
|
||||
name: string
|
||||
cursor: CursorPoint
|
||||
gradient: string
|
||||
accentColor: string
|
||||
}
|
||||
|
||||
const POINTER_OFFSET = {
|
||||
x: 2,
|
||||
y: 18,
|
||||
}
|
||||
|
||||
const LABEL_BACKGROUND = 'rgba(15, 23, 42, 0.88)'
|
||||
|
||||
const CollaboratorCursorLayerComponent = () => {
|
||||
const { presenceUsers } = useSocket()
|
||||
const viewport = useViewport()
|
||||
const session = useSession()
|
||||
const currentUserId = session.data?.user?.id
|
||||
|
||||
const cursors = useMemo<CursorRenderData[]>(() => {
|
||||
if (!presenceUsers.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return presenceUsers
|
||||
.filter((user): user is typeof user & { cursor: CursorPoint } => Boolean(user.cursor))
|
||||
.filter((user) => user.userId !== currentUserId)
|
||||
.map((user) => {
|
||||
const cursor = user.cursor
|
||||
const name = user.userName?.trim() || 'Collaborator'
|
||||
const { gradient, accentColor } = getPresenceColors(user.userId)
|
||||
|
||||
return {
|
||||
id: user.socketId,
|
||||
name,
|
||||
cursor,
|
||||
gradient,
|
||||
accentColor,
|
||||
}
|
||||
})
|
||||
}, [currentUserId, presenceUsers])
|
||||
|
||||
if (!cursors.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='pointer-events-none absolute inset-0 z-30 select-none'>
|
||||
{cursors.map(({ id, name, cursor, gradient, accentColor }) => {
|
||||
const x = cursor.x * viewport.zoom + viewport.x
|
||||
const y = cursor.y * viewport.zoom + viewport.y
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className='pointer-events-none absolute'
|
||||
style={{
|
||||
transform: `translate3d(${x}px, ${y}px, 0)`,
|
||||
transition: 'transform 0.12s ease-out',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='relative'
|
||||
style={{ transform: `translate(${-POINTER_OFFSET.x}px, ${-POINTER_OFFSET.y}px)` }}
|
||||
>
|
||||
<svg
|
||||
width={20}
|
||||
height={22}
|
||||
viewBox='0 0 20 22'
|
||||
className='drop-shadow-md'
|
||||
style={{ fill: accentColor, stroke: 'white', strokeWidth: 1.25 }}
|
||||
>
|
||||
<path d='M1 0L1 17L6.2 12.5L10.5 21.5L13.7 19.8L9.4 10.7L18.5 10.7L1 0Z' />
|
||||
</svg>
|
||||
|
||||
<div
|
||||
className='absolute top-[-28px] left-4 flex items-center gap-2 rounded-full px-2 py-1 font-medium text-white text-xs shadow-lg'
|
||||
style={{
|
||||
background: LABEL_BACKGROUND,
|
||||
border: `1px solid ${accentColor}`,
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className='h-2.5 w-2.5 rounded-full border border-white/60'
|
||||
style={{ background: gradient }}
|
||||
/>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CollaboratorCursorLayer = memo(CollaboratorCursorLayerComponent)
|
||||
CollaboratorCursorLayer.displayName = 'CollaboratorCursorLayer'
|
||||
@@ -14,23 +14,25 @@ import 'reactflow/dist/style.css'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { DiffControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
|
||||
import {
|
||||
CommandList,
|
||||
DiffControls,
|
||||
Panel,
|
||||
SubflowNodeComponent,
|
||||
Terminal,
|
||||
TrainingControls,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
|
||||
import { Chat } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat'
|
||||
import { UserAvatarStack } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/user-avatar-stack'
|
||||
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 { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new'
|
||||
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
|
||||
import { Terminal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal'
|
||||
import { TrainingControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/training-controls/training-controls'
|
||||
import { TriggerList } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-list/trigger-list'
|
||||
import {
|
||||
TriggerWarningDialog,
|
||||
TriggerWarningType,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/trigger-warning-dialog/trigger-warning-dialog'
|
||||
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||
import { CollaboratorCursorLayer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-presence/collaborator-cursor-layer'
|
||||
import {
|
||||
useAutoLayout,
|
||||
useCurrentWorkflow,
|
||||
@@ -2034,6 +2036,13 @@ const WorkflowContent = React.memo(() => {
|
||||
onDragOver={effectivePermissions.canEdit ? onDragOver : undefined}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.6 }}
|
||||
onInit={(instance) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
instance.fitView({ padding: 0.3 })
|
||||
})
|
||||
})
|
||||
}}
|
||||
minZoom={0.1}
|
||||
maxZoom={1.3}
|
||||
panOnScroll
|
||||
@@ -2071,7 +2080,7 @@ const WorkflowContent = React.memo(() => {
|
||||
autoPanOnNodeDrag={effectivePermissions.canEdit}
|
||||
/>
|
||||
|
||||
<CollaboratorCursorLayer />
|
||||
<Cursors />
|
||||
|
||||
{/* Floating chat modal */}
|
||||
<Chat />
|
||||
@@ -2088,9 +2097,7 @@ const WorkflowContent = React.memo(() => {
|
||||
/>
|
||||
|
||||
{/* Trigger list for empty workflows - only show after workflow has loaded and hydrated */}
|
||||
{isWorkflowReady && isWorkflowEmpty && effectivePermissions.canEdit && (
|
||||
<TriggerList onSelect={handleTriggerSelect} />
|
||||
)}
|
||||
{isWorkflowReady && isWorkflowEmpty && effectivePermissions.canEdit && <CommandList />}
|
||||
|
||||
<Panel />
|
||||
</div>
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
export interface NavigationSection {
|
||||
id: string
|
||||
name: string
|
||||
type: 'grid' | 'list'
|
||||
items: any[]
|
||||
gridCols?: number // How many columns per row for grid sections
|
||||
}
|
||||
|
||||
export interface NavigationPosition {
|
||||
sectionIndex: number
|
||||
itemIndex: number
|
||||
}
|
||||
|
||||
export function useSearchNavigation(sections: NavigationSection[], isOpen: boolean) {
|
||||
const [position, setPosition] = useState<NavigationPosition>({ sectionIndex: 0, itemIndex: 0 })
|
||||
const scrollRefs = useRef<Map<string, HTMLElement>>(new Map())
|
||||
const lastPositionInSection = useRef<Map<string, number>>(new Map())
|
||||
|
||||
// Reset position when sections change or modal opens
|
||||
useEffect(() => {
|
||||
if (sections.length > 0) {
|
||||
setPosition({ sectionIndex: 0, itemIndex: 0 })
|
||||
}
|
||||
}, [sections, isOpen])
|
||||
|
||||
const getCurrentItem = useCallback(() => {
|
||||
if (sections.length === 0 || position.sectionIndex >= sections.length) return null
|
||||
|
||||
const section = sections[position.sectionIndex]
|
||||
if (position.itemIndex >= section.items.length) return null
|
||||
|
||||
return {
|
||||
section,
|
||||
item: section.items[position.itemIndex],
|
||||
position,
|
||||
}
|
||||
}, [sections, position])
|
||||
|
||||
const navigate = useCallback(
|
||||
(direction: 'up' | 'down' | 'left' | 'right') => {
|
||||
if (sections.length === 0) return
|
||||
|
||||
const currentSection = sections[position.sectionIndex]
|
||||
if (!currentSection) return
|
||||
|
||||
const isGridSection = currentSection.type === 'grid'
|
||||
const gridCols = currentSection.gridCols || 1
|
||||
|
||||
setPosition((prevPosition) => {
|
||||
let newSectionIndex = prevPosition.sectionIndex
|
||||
let newItemIndex = prevPosition.itemIndex
|
||||
|
||||
if (direction === 'up') {
|
||||
if (isGridSection) {
|
||||
// In grid: up moves to previous row in same section, or previous section
|
||||
if (newItemIndex >= gridCols) {
|
||||
newItemIndex -= gridCols
|
||||
} else if (newSectionIndex > 0) {
|
||||
// Save current position before moving to previous section
|
||||
lastPositionInSection.current.set(currentSection.id, newItemIndex)
|
||||
|
||||
// Move to previous section
|
||||
newSectionIndex -= 1
|
||||
const prevSection = sections[newSectionIndex]
|
||||
|
||||
// Restore last position in that section, or go to end
|
||||
const lastPos = lastPositionInSection.current.get(prevSection.id)
|
||||
if (lastPos !== undefined && lastPos < prevSection.items.length) {
|
||||
newItemIndex = lastPos
|
||||
} else {
|
||||
newItemIndex = Math.max(0, prevSection.items.length - 1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// In list: up moves to previous item, or previous section
|
||||
if (newItemIndex > 0) {
|
||||
newItemIndex -= 1
|
||||
} else if (newSectionIndex > 0) {
|
||||
// Save current position before moving to previous section
|
||||
lastPositionInSection.current.set(currentSection.id, newItemIndex)
|
||||
|
||||
newSectionIndex -= 1
|
||||
const prevSection = sections[newSectionIndex]
|
||||
|
||||
// Restore last position in that section, or go to end
|
||||
const lastPos = lastPositionInSection.current.get(prevSection.id)
|
||||
if (lastPos !== undefined && lastPos < prevSection.items.length) {
|
||||
newItemIndex = lastPos
|
||||
} else {
|
||||
newItemIndex = Math.max(0, prevSection.items.length - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (direction === 'down') {
|
||||
if (isGridSection) {
|
||||
// In grid: down moves to next row in same section, or next section
|
||||
const maxIndexInCurrentRow = Math.min(
|
||||
newItemIndex + gridCols,
|
||||
currentSection.items.length - 1
|
||||
)
|
||||
|
||||
if (newItemIndex + gridCols < currentSection.items.length) {
|
||||
newItemIndex += gridCols
|
||||
} else if (newSectionIndex < sections.length - 1) {
|
||||
// Save current position before moving to next section
|
||||
lastPositionInSection.current.set(currentSection.id, newItemIndex)
|
||||
|
||||
// Move to next section
|
||||
newSectionIndex += 1
|
||||
const nextSection = sections[newSectionIndex]
|
||||
|
||||
// Restore last position in next section, or start at beginning
|
||||
const lastPos = lastPositionInSection.current.get(nextSection.id)
|
||||
if (lastPos !== undefined && lastPos < nextSection.items.length) {
|
||||
newItemIndex = lastPos
|
||||
} else {
|
||||
newItemIndex = 0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// In list: down moves to next item, or next section
|
||||
if (newItemIndex < currentSection.items.length - 1) {
|
||||
newItemIndex += 1
|
||||
} else if (newSectionIndex < sections.length - 1) {
|
||||
// Save current position before moving to next section
|
||||
lastPositionInSection.current.set(currentSection.id, newItemIndex)
|
||||
|
||||
newSectionIndex += 1
|
||||
const nextSection = sections[newSectionIndex]
|
||||
|
||||
// Restore last position in next section, or start at beginning
|
||||
const lastPos = lastPositionInSection.current.get(nextSection.id)
|
||||
if (lastPos !== undefined && lastPos < nextSection.items.length) {
|
||||
newItemIndex = lastPos
|
||||
} else {
|
||||
newItemIndex = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (direction === 'left' && isGridSection) {
|
||||
// In grid: left moves to previous item in same row
|
||||
if (newItemIndex > 0) {
|
||||
const currentRow = Math.floor(newItemIndex / gridCols)
|
||||
const newIndex = newItemIndex - 1
|
||||
const newRow = Math.floor(newIndex / gridCols)
|
||||
|
||||
// Only move if we stay in the same row
|
||||
if (currentRow === newRow) {
|
||||
newItemIndex = newIndex
|
||||
}
|
||||
}
|
||||
} else if (direction === 'right' && isGridSection) {
|
||||
// In grid: right moves to next item in same row
|
||||
if (newItemIndex < currentSection.items.length - 1) {
|
||||
const currentRow = Math.floor(newItemIndex / gridCols)
|
||||
const newIndex = newItemIndex + 1
|
||||
const newRow = Math.floor(newIndex / gridCols)
|
||||
|
||||
// Only move if we stay in the same row
|
||||
if (currentRow === newRow) {
|
||||
newItemIndex = newIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { sectionIndex: newSectionIndex, itemIndex: newItemIndex }
|
||||
})
|
||||
},
|
||||
[sections, position]
|
||||
)
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
const current = getCurrentItem()
|
||||
if (!current) return
|
||||
|
||||
const { section, position: currentPos } = current
|
||||
const scrollContainer = scrollRefs.current.get(section.id)
|
||||
|
||||
if (scrollContainer) {
|
||||
const itemElement = scrollContainer.querySelector(
|
||||
`[data-nav-item="${section.id}-${currentPos.itemIndex}"]`
|
||||
) as HTMLElement
|
||||
|
||||
if (itemElement) {
|
||||
// For horizontal scrolling sections (blocks/tools)
|
||||
if (section.type === 'grid') {
|
||||
const containerRect = scrollContainer.getBoundingClientRect()
|
||||
const itemRect = itemElement.getBoundingClientRect()
|
||||
|
||||
// Check if item is outside the visible area horizontally
|
||||
if (itemRect.left < containerRect.left) {
|
||||
scrollContainer.scrollTo({
|
||||
left: scrollContainer.scrollLeft - (containerRect.left - itemRect.left + 20),
|
||||
behavior: 'smooth',
|
||||
})
|
||||
} else if (itemRect.right > containerRect.right) {
|
||||
scrollContainer.scrollTo({
|
||||
left: scrollContainer.scrollLeft + (itemRect.right - containerRect.right + 20),
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Always ensure vertical visibility
|
||||
itemElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
}, [getCurrentItem, position])
|
||||
|
||||
return {
|
||||
navigate,
|
||||
getCurrentItem,
|
||||
scrollRefs,
|
||||
position,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
||||
export { FooterNavigation } from './footer-navigation'
|
||||
export { HelpModal } from './help-modal'
|
||||
export { SearchModal } from './search-modal'
|
||||
export { SettingsModal } from './settings-modal'
|
||||
export { WorkflowList } from './workflow-list/workflow-list'
|
||||
export { WorkspaceHeader } from './workspace-header'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { SearchModal } from './search-modal'
|
||||
@@ -0,0 +1,599 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
|
||||
import { BookOpen, Layout, RepeatIcon, ScrollText, Search, SplitIcon } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Dialog, DialogOverlay, DialogPortal, DialogTitle } from '@/components/ui/dialog'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/trigger-utils'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
|
||||
interface SearchModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
workflows?: WorkflowItem[]
|
||||
workspaces?: WorkspaceItem[]
|
||||
isOnWorkflowPage?: boolean
|
||||
}
|
||||
|
||||
interface WorkflowItem {
|
||||
id: string
|
||||
name: string
|
||||
href: string
|
||||
color: string
|
||||
isCurrent?: boolean
|
||||
}
|
||||
|
||||
interface WorkspaceItem {
|
||||
id: string
|
||||
name: string
|
||||
href: string
|
||||
isCurrent?: boolean
|
||||
}
|
||||
|
||||
interface BlockItem {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: React.ComponentType<any>
|
||||
bgColor: string
|
||||
type: string
|
||||
config?: any
|
||||
}
|
||||
|
||||
interface ToolItem {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: React.ComponentType<any>
|
||||
bgColor: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface PageItem {
|
||||
id: string
|
||||
name: string
|
||||
icon: React.ComponentType<any>
|
||||
href: string
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
interface DocItem {
|
||||
id: string
|
||||
name: string
|
||||
icon: React.ComponentType<any>
|
||||
href: string
|
||||
type: 'main' | 'block' | 'tool'
|
||||
}
|
||||
|
||||
type SearchItem = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
icon?: React.ComponentType<any>
|
||||
bgColor?: string
|
||||
color?: string
|
||||
href?: string
|
||||
shortcut?: string
|
||||
type: 'block' | 'trigger' | 'tool' | 'workflow' | 'workspace' | 'page' | 'doc'
|
||||
isCurrent?: boolean
|
||||
blockType?: string
|
||||
config?: any
|
||||
}
|
||||
|
||||
export function SearchModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
workflows = [],
|
||||
workspaces = [],
|
||||
isOnWorkflowPage = false,
|
||||
}: SearchModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const brand = useBrandConfig()
|
||||
|
||||
// Get all available blocks - only when on workflow page
|
||||
const blocks = useMemo(() => {
|
||||
if (!isOnWorkflowPage) return []
|
||||
|
||||
const allBlocks = getAllBlocks()
|
||||
const regularBlocks = allBlocks
|
||||
.filter(
|
||||
(block) => block.type !== 'starter' && !block.hideFromToolbar && block.category === 'blocks'
|
||||
)
|
||||
.map(
|
||||
(block): BlockItem => ({
|
||||
id: block.type,
|
||||
name: block.name,
|
||||
description: block.description || '',
|
||||
icon: block.icon,
|
||||
bgColor: block.bgColor || '#6B7280',
|
||||
type: block.type,
|
||||
})
|
||||
)
|
||||
|
||||
// Add special blocks (loop and parallel)
|
||||
const specialBlocks: BlockItem[] = [
|
||||
{
|
||||
id: 'loop',
|
||||
name: 'Loop',
|
||||
description: 'Create a Loop',
|
||||
icon: RepeatIcon,
|
||||
bgColor: '#2FB3FF',
|
||||
type: 'loop',
|
||||
},
|
||||
{
|
||||
id: 'parallel',
|
||||
name: 'Parallel',
|
||||
description: 'Parallel Execution',
|
||||
icon: SplitIcon,
|
||||
bgColor: '#FEE12B',
|
||||
type: 'parallel',
|
||||
},
|
||||
]
|
||||
|
||||
return [...regularBlocks, ...specialBlocks]
|
||||
}, [isOnWorkflowPage])
|
||||
|
||||
const triggers = useMemo(() => {
|
||||
if (!isOnWorkflowPage) return []
|
||||
|
||||
const allTriggers = getTriggersForSidebar()
|
||||
const priorityOrder = ['Start', 'Schedule', 'Webhook']
|
||||
|
||||
// Sort triggers with priority order matching toolbar
|
||||
const sortedTriggers = allTriggers.sort((a, b) => {
|
||||
const aIndex = priorityOrder.indexOf(a.name)
|
||||
const bIndex = priorityOrder.indexOf(b.name)
|
||||
const aHasPriority = aIndex !== -1
|
||||
const bHasPriority = bIndex !== -1
|
||||
|
||||
if (aHasPriority && bHasPriority) return aIndex - bIndex
|
||||
if (aHasPriority) return -1
|
||||
if (bHasPriority) return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return sortedTriggers.map(
|
||||
(block): BlockItem => ({
|
||||
id: block.type,
|
||||
name: block.name,
|
||||
description: block.description || '',
|
||||
icon: block.icon,
|
||||
bgColor: block.bgColor || '#6B7280',
|
||||
type: block.type,
|
||||
config: block,
|
||||
})
|
||||
)
|
||||
}, [isOnWorkflowPage])
|
||||
|
||||
// Get all available tools - only when on workflow page
|
||||
const tools = useMemo(() => {
|
||||
if (!isOnWorkflowPage) return []
|
||||
|
||||
const allBlocks = getAllBlocks()
|
||||
return allBlocks
|
||||
.filter((block) => block.category === 'tools')
|
||||
.map(
|
||||
(block): ToolItem => ({
|
||||
id: block.type,
|
||||
name: block.name,
|
||||
description: block.description || '',
|
||||
icon: block.icon,
|
||||
bgColor: block.bgColor || '#6B7280',
|
||||
type: block.type,
|
||||
})
|
||||
)
|
||||
}, [isOnWorkflowPage])
|
||||
|
||||
// Define pages
|
||||
const pages = useMemo(
|
||||
(): PageItem[] => [
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Logs',
|
||||
icon: ScrollText,
|
||||
href: `/workspace/${workspaceId}/logs`,
|
||||
shortcut: '⌘⇧L',
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
name: 'Templates',
|
||||
icon: Layout,
|
||||
href: `/workspace/${workspaceId}/templates`,
|
||||
},
|
||||
{
|
||||
id: 'docs',
|
||||
name: 'Docs',
|
||||
icon: BookOpen,
|
||||
href: brand.documentationUrl || 'https://docs.sim.ai/',
|
||||
},
|
||||
],
|
||||
[workspaceId, brand.documentationUrl]
|
||||
)
|
||||
|
||||
// Define docs
|
||||
const docs = useMemo((): DocItem[] => {
|
||||
const allBlocks = getAllBlocks()
|
||||
const docsItems: DocItem[] = []
|
||||
|
||||
allBlocks.forEach((block) => {
|
||||
if (block.docsLink) {
|
||||
docsItems.push({
|
||||
id: `docs-${block.type}`,
|
||||
name: block.name,
|
||||
icon: block.icon,
|
||||
href: block.docsLink,
|
||||
type: block.category === 'blocks' || block.category === 'triggers' ? 'block' : 'tool',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return docsItems
|
||||
}, [])
|
||||
|
||||
// Combine all items into a single flattened list
|
||||
const allItems = useMemo((): SearchItem[] => {
|
||||
const items: SearchItem[] = []
|
||||
|
||||
// Add workspaces
|
||||
workspaces.forEach((workspace) => {
|
||||
items.push({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
href: workspace.href,
|
||||
type: 'workspace',
|
||||
isCurrent: workspace.isCurrent,
|
||||
})
|
||||
})
|
||||
|
||||
// Add workflows
|
||||
workflows.forEach((workflow) => {
|
||||
items.push({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
href: workflow.href,
|
||||
type: 'workflow',
|
||||
color: workflow.color,
|
||||
isCurrent: workflow.isCurrent,
|
||||
})
|
||||
})
|
||||
|
||||
// Add pages
|
||||
pages.forEach((page) => {
|
||||
items.push({
|
||||
id: page.id,
|
||||
name: page.name,
|
||||
icon: page.icon,
|
||||
href: page.href,
|
||||
shortcut: page.shortcut,
|
||||
type: 'page',
|
||||
})
|
||||
})
|
||||
|
||||
// Add blocks
|
||||
blocks.forEach((block) => {
|
||||
items.push({
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
description: block.description,
|
||||
icon: block.icon,
|
||||
bgColor: block.bgColor,
|
||||
type: 'block',
|
||||
blockType: block.type,
|
||||
})
|
||||
})
|
||||
|
||||
// Add triggers
|
||||
triggers.forEach((trigger) => {
|
||||
items.push({
|
||||
id: trigger.id,
|
||||
name: trigger.name,
|
||||
description: trigger.description,
|
||||
icon: trigger.icon,
|
||||
bgColor: trigger.bgColor,
|
||||
type: 'trigger',
|
||||
blockType: trigger.type,
|
||||
config: trigger.config,
|
||||
})
|
||||
})
|
||||
|
||||
// Add tools
|
||||
tools.forEach((tool) => {
|
||||
items.push({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
icon: tool.icon,
|
||||
bgColor: tool.bgColor,
|
||||
type: 'tool',
|
||||
blockType: tool.type,
|
||||
})
|
||||
})
|
||||
|
||||
// Add docs
|
||||
docs.forEach((doc) => {
|
||||
items.push({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
icon: doc.icon,
|
||||
href: doc.href,
|
||||
type: 'doc',
|
||||
})
|
||||
})
|
||||
|
||||
return items
|
||||
}, [workspaces, workflows, pages, blocks, triggers, tools, docs])
|
||||
|
||||
// Filter items based on search query
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!searchQuery.trim()) return allItems
|
||||
|
||||
const query = searchQuery.toLowerCase()
|
||||
return allItems.filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(query) || item.description?.toLowerCase().includes(query)
|
||||
)
|
||||
}, [allItems, searchQuery])
|
||||
|
||||
// Reset selected index when filtered items change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [filteredItems])
|
||||
|
||||
// Clear search when modal closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearchQuery('')
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Handle item selection
|
||||
const handleItemClick = useCallback(
|
||||
(item: SearchItem) => {
|
||||
switch (item.type) {
|
||||
case 'block':
|
||||
case 'trigger':
|
||||
case 'tool':
|
||||
if (item.blockType) {
|
||||
const enableTriggerMode =
|
||||
item.type === 'trigger' && item.config ? hasTriggerCapability(item.config) : false
|
||||
const event = new CustomEvent('add-block-from-toolbar', {
|
||||
detail: {
|
||||
type: item.blockType,
|
||||
enableTriggerMode,
|
||||
},
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
break
|
||||
case 'workspace':
|
||||
case 'workflow':
|
||||
case 'page':
|
||||
case 'doc':
|
||||
if (item.href) {
|
||||
if (item.href.startsWith('http')) {
|
||||
window.open(item.href, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
router.push(item.href)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
onOpenChange(false)
|
||||
},
|
||||
[router, onOpenChange]
|
||||
)
|
||||
|
||||
// Handle keyboard navigation
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, filteredItems.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (filteredItems[selectedIndex]) {
|
||||
handleItemClick(filteredItems[selectedIndex])
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
onOpenChange(false)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, selectedIndex, filteredItems, handleItemClick, onOpenChange])
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (open && selectedIndex >= 0) {
|
||||
const element = document.querySelector(`[data-search-item-index="${selectedIndex}"]`)
|
||||
if (element) {
|
||||
element.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
}
|
||||
}, [selectedIndex, open])
|
||||
|
||||
// Group items by type for sectioned display
|
||||
const groupedItems = useMemo(() => {
|
||||
const groups: Record<string, SearchItem[]> = {
|
||||
workspace: [],
|
||||
workflow: [],
|
||||
page: [],
|
||||
trigger: [],
|
||||
block: [],
|
||||
tool: [],
|
||||
doc: [],
|
||||
}
|
||||
|
||||
filteredItems.forEach((item) => {
|
||||
if (groups[item.type]) {
|
||||
groups[item.type].push(item)
|
||||
}
|
||||
})
|
||||
|
||||
return groups
|
||||
}, [filteredItems])
|
||||
|
||||
// Section titles mapping
|
||||
const sectionTitles: Record<string, string> = {
|
||||
workspace: 'Workspaces',
|
||||
workflow: 'Workflows',
|
||||
page: 'Pages',
|
||||
trigger: 'Triggers',
|
||||
block: 'Blocks',
|
||||
tool: 'Tools',
|
||||
doc: 'Documentation',
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
className='bg-white/80 dark:bg-transparent'
|
||||
style={{ backdropFilter: 'blur(3px)' }}
|
||||
/>
|
||||
<DialogPrimitive.Content className='fixed top-[15%] left-[50%] z-50 flex w-[500px] translate-x-[-50%] flex-col gap-[12px] p-0 focus:outline-none focus-visible:outline-none'>
|
||||
<VisuallyHidden.Root>
|
||||
<DialogTitle>Search</DialogTitle>
|
||||
</VisuallyHidden.Root>
|
||||
|
||||
{/* Search input container */}
|
||||
<div className='flex items-center gap-[6px] rounded-[10px] border border-[#2C2C2C] bg-[#272727] px-[10px] py-[8px] shadow-sm dark:border-[#2C2C2C] dark:bg-[#272727]'>
|
||||
<Search className='h-[16px] w-[16px] flex-shrink-0 text-[#7D7D7D] dark:text-[#7D7D7D]' />
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Search anything...'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className='w-full border-0 bg-transparent font-base text-[#E6E6E6] text-[16px] placeholder:text-[#B1B1B1] focus:outline-none dark:text-[#E6E6E6] dark:placeholder:text-[#B1B1B1]'
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Floating results container */}
|
||||
{filteredItems.length > 0 ? (
|
||||
<div className='scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent max-h-[400px] overflow-y-auto rounded-[10px] py-[8px] shadow-sm backdrop-blur-lg'>
|
||||
{Object.entries(groupedItems).map(([type, items]) => {
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<div key={type} className='mb-[10px] last:mb-0'>
|
||||
{/* Section header */}
|
||||
<div className='pt-[2px] pb-[4px] font-medium text-[#7D7D7D] text-[11px] uppercase tracking-wide dark:text-[#7D7D7D]'>
|
||||
{sectionTitles[type]}
|
||||
</div>
|
||||
|
||||
{/* Section items */}
|
||||
<div className='space-y-[2px]'>
|
||||
{items.map((item, itemIndex) => {
|
||||
const Icon = item.icon
|
||||
const globalIndex = filteredItems.indexOf(item)
|
||||
const isSelected = globalIndex === selectedIndex
|
||||
const showColoredIcon =
|
||||
item.type === 'block' || item.type === 'trigger' || item.type === 'tool'
|
||||
const isWorkflow = item.type === 'workflow'
|
||||
const isWorkspace = item.type === 'workspace'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${item.type}-${item.id}`}
|
||||
data-search-item-index={globalIndex}
|
||||
onClick={() => handleItemClick(item)}
|
||||
className={cn(
|
||||
'group flex h-[28px] w-full items-center gap-[8px] rounded-[6px] bg-[#252525]/60 px-[8px] text-left text-[13px] transition-all focus:outline-none dark:bg-[#252525]/60',
|
||||
isSelected
|
||||
? 'bg-[#2C2C2C] shadow-sm dark:bg-[#2C2C2C]'
|
||||
: 'hover:bg-[#2C2C2C] dark:hover:bg-[#2C2C2C]'
|
||||
)}
|
||||
>
|
||||
{/* Icon - different rendering for workflows vs others */}
|
||||
{!isWorkspace && (
|
||||
<>
|
||||
{isWorkflow ? (
|
||||
<div
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px]'
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
) : (
|
||||
Icon && (
|
||||
<div
|
||||
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
|
||||
style={{
|
||||
backgroundColor: showColoredIcon
|
||||
? item.bgColor
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'transition-transform duration-100 group-hover:scale-110',
|
||||
showColoredIcon
|
||||
? '!h-[10px] !w-[10px] text-white'
|
||||
: 'h-[14px] w-[14px] text-[#AEAEAE] group-hover:text-[#E6E6E6] dark:text-[#AEAEAE] dark:group-hover:text-[#E6E6E6]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<span
|
||||
className={cn(
|
||||
'truncate font-medium',
|
||||
isSelected
|
||||
? 'text-[#E6E6E6] dark:text-[#E6E6E6]'
|
||||
: 'text-[#AEAEAE] group-hover:text-[#E6E6E6] dark:text-[#AEAEAE] dark:group-hover:text-[#E6E6E6]'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
{item.isCurrent && ' (current)'}
|
||||
</span>
|
||||
|
||||
{/* Shortcut */}
|
||||
{item.shortcut && (
|
||||
<span className='ml-auto flex-shrink-0 font-medium text-[#7D7D7D] text-[11px] dark:text-[#7D7D7D]'>
|
||||
{item.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : searchQuery ? (
|
||||
<div className='flex items-center justify-center rounded-[10px] bg-[#272727] px-[16px] py-[24px] shadow-sm dark:bg-[#272727]'>
|
||||
<p className='text-[#7D7D7D] text-[13px] dark:text-[#7D7D7D]'>
|
||||
No results found for "{searchQuery}"
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -30,7 +30,7 @@ interface DeleteModalProps {
|
||||
/**
|
||||
* Type of item being deleted
|
||||
*/
|
||||
itemType: 'workflow' | 'folder'
|
||||
itemType: 'workflow' | 'folder' | 'workspace'
|
||||
/**
|
||||
* Name(s) of the item(s) being deleted (optional, for display)
|
||||
* Can be a single name or an array of names for multiple items
|
||||
@@ -39,7 +39,7 @@ interface DeleteModalProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable delete confirmation modal for workflow and folder items.
|
||||
* Reusable delete confirmation modal for workflow, folder, and workspace items.
|
||||
* Displays a warning message and confirmation buttons.
|
||||
*
|
||||
* @param props - Component props
|
||||
@@ -61,8 +61,10 @@ export function DeleteModal({
|
||||
let title = ''
|
||||
if (itemType === 'workflow') {
|
||||
title = isMultiple ? 'Delete workflows?' : 'Delete workflow?'
|
||||
} else {
|
||||
} else if (itemType === 'folder') {
|
||||
title = 'Delete folder?'
|
||||
} else {
|
||||
title = 'Delete workspace?'
|
||||
}
|
||||
|
||||
let description = ''
|
||||
@@ -76,13 +78,16 @@ export function DeleteModal({
|
||||
description =
|
||||
'Deleting this workflow will permanently remove all associated blocks, executions, and configuration.'
|
||||
}
|
||||
} else {
|
||||
} else if (itemType === 'folder') {
|
||||
if (isSingle && displayNames.length > 0) {
|
||||
description = `Deleting ${displayNames[0]} will permanently remove all associated workflows, logs, and knowledge bases.`
|
||||
} else {
|
||||
description =
|
||||
'Deleting this folder will permanently remove all associated workflows, logs, and knowledge bases.'
|
||||
}
|
||||
} else {
|
||||
description =
|
||||
'Deleting this workspace will permanently remove all associated workflows, folders, logs, and knowledge bases.'
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { type CSSProperties, useEffect, useMemo } from 'react'
|
||||
import Image from 'next/image'
|
||||
import { Tooltip } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color'
|
||||
import { useSocket } from '@/contexts/socket-context'
|
||||
|
||||
interface AvatarsProps {
|
||||
workflowId: string
|
||||
maxVisible?: number
|
||||
/**
|
||||
* Callback fired when the presence visibility changes.
|
||||
* Used by parent components to adjust layout (e.g., text truncation spacing).
|
||||
*/
|
||||
onPresenceChange?: (hasAvatars: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays user avatars for presence in a workflow item.
|
||||
* Consolidated logic from user-avatar-stack and user-avatar components.
|
||||
* Only shows avatars for the currently active workflow.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns Avatar stack for workflow presence
|
||||
*/
|
||||
export function Avatars({ workflowId, maxVisible = 3, onPresenceChange }: AvatarsProps) {
|
||||
const { presenceUsers, currentWorkflowId } = useSocket()
|
||||
const { data: session } = useSession()
|
||||
const currentUserId = session?.user?.id
|
||||
|
||||
/**
|
||||
* Only show presence for the currently active workflow
|
||||
* Filter out the current user from the list
|
||||
*/
|
||||
const workflowUsers = useMemo(() => {
|
||||
if (currentWorkflowId !== workflowId) {
|
||||
return []
|
||||
}
|
||||
return presenceUsers.filter((user) => user.userId !== currentUserId)
|
||||
}, [presenceUsers, currentWorkflowId, workflowId, currentUserId])
|
||||
|
||||
/**
|
||||
* Calculate visible users and overflow count
|
||||
*/
|
||||
const { visibleUsers, overflowCount } = useMemo(() => {
|
||||
if (workflowUsers.length === 0) {
|
||||
return { visibleUsers: [], overflowCount: 0 }
|
||||
}
|
||||
|
||||
const visible = workflowUsers.slice(0, maxVisible)
|
||||
const overflow = Math.max(0, workflowUsers.length - maxVisible)
|
||||
|
||||
return { visibleUsers: visible, overflowCount: overflow }
|
||||
}, [workflowUsers, maxVisible])
|
||||
|
||||
// Notify parent when avatars are present or not
|
||||
useEffect(() => {
|
||||
const hasAnyAvatars = visibleUsers.length > 0
|
||||
if (typeof onPresenceChange === 'function') {
|
||||
onPresenceChange(hasAnyAvatars)
|
||||
}
|
||||
}, [visibleUsers, onPresenceChange])
|
||||
|
||||
if (visibleUsers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='-space-x-1 ml-[-8px] flex items-center'>
|
||||
{visibleUsers.map((user, index) => {
|
||||
const color = getUserColor(user.userId)
|
||||
const initials = user.userName ? user.userName.charAt(0).toUpperCase() : '?'
|
||||
const hasAvatar = Boolean(user.avatarUrl)
|
||||
|
||||
const avatarElement = (
|
||||
<div
|
||||
key={user.socketId}
|
||||
className='relative flex h-[14px] w-[14px] flex-shrink-0 cursor-default items-center justify-center overflow-hidden rounded-full font-semibold text-[7px] text-white'
|
||||
style={
|
||||
{
|
||||
background: hasAvatar ? undefined : color,
|
||||
zIndex: 10 - index,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{hasAvatar && user.avatarUrl ? (
|
||||
<Image
|
||||
src={user.avatarUrl}
|
||||
alt={user.userName ? `${user.userName}'s avatar` : 'User avatar'}
|
||||
fill
|
||||
sizes='14px'
|
||||
className='object-cover'
|
||||
referrerPolicy='no-referrer'
|
||||
unoptimized={user.avatarUrl.startsWith('http')}
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (user.userName) {
|
||||
return (
|
||||
<Tooltip.Root key={user.socketId}>
|
||||
<Tooltip.Trigger asChild>{avatarElement}</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom'>
|
||||
<span>{user.userName}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
return avatarElement
|
||||
})}
|
||||
|
||||
{overflowCount > 0 && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div
|
||||
className='relative flex h-[14px] w-[14px] flex-shrink-0 cursor-default items-center justify-center overflow-hidden rounded-full bg-[#404040] font-semibold text-[7px] text-white'
|
||||
style={{ zIndex: 10 - visibleUsers.length } as CSSProperties}
|
||||
>
|
||||
+{overflowCount}
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='bottom'>
|
||||
{overflowCount} more user{overflowCount > 1 ? 's' : ''}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/context-menu/context-menu'
|
||||
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/delete-modal/delete-modal'
|
||||
import { Avatars } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workflow-list/components/workflow-item/avatars/avatars'
|
||||
import {
|
||||
useContextMenu,
|
||||
useItemDrag,
|
||||
@@ -42,6 +43,9 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
const [workflowIdsToDelete, setWorkflowIdsToDelete] = useState<string[]>([])
|
||||
const [deleteModalNames, setDeleteModalNames] = useState<string | string[]>('')
|
||||
|
||||
// Presence avatars state
|
||||
const [hasAvatars, setHasAvatars] = useState(false)
|
||||
|
||||
// Capture selection at right-click time (using ref to persist across renders)
|
||||
const capturedSelectionRef = useRef<{
|
||||
workflowIds: string[]
|
||||
@@ -221,41 +225,46 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px]'
|
||||
style={{ backgroundColor: workflow.color }}
|
||||
/>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
className={clsx(
|
||||
'min-w-0 flex-1 border-0 bg-transparent p-0 font-medium text-[14px] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
active
|
||||
? 'text-[#E6E6E6] dark:text-[#E6E6E6]'
|
||||
: 'text-[#AEAEAE] group-hover:text-[#E6E6E6] dark:text-[#AEAEAE] dark:group-hover:text-[#E6E6E6]'
|
||||
)}
|
||||
maxLength={100}
|
||||
disabled={isRenaming}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={clsx(
|
||||
'truncate font-medium',
|
||||
active
|
||||
? 'text-[#E6E6E6] dark:text-[#E6E6E6]'
|
||||
: 'text-[#AEAEAE] group-hover:text-[#E6E6E6] dark:text-[#AEAEAE] dark:group-hover:text-[#E6E6E6]'
|
||||
)}
|
||||
>
|
||||
{workflow.name}
|
||||
</span>
|
||||
<div className={clsx('min-w-0 flex-1', hasAvatars && 'pr-[8px]')}>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
className={clsx(
|
||||
'w-full border-0 bg-transparent p-0 font-medium text-[14px] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
active
|
||||
? 'text-[#E6E6E6] dark:text-[#E6E6E6]'
|
||||
: 'text-[#AEAEAE] group-hover:text-[#E6E6E6] dark:text-[#AEAEAE] dark:group-hover:text-[#E6E6E6]'
|
||||
)}
|
||||
maxLength={100}
|
||||
disabled={isRenaming}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={clsx(
|
||||
'truncate font-medium',
|
||||
active
|
||||
? 'text-[#E6E6E6] dark:text-[#E6E6E6]'
|
||||
: 'text-[#AEAEAE] group-hover:text-[#E6E6E6] dark:text-[#AEAEAE] dark:group-hover:text-[#E6E6E6]'
|
||||
)}
|
||||
>
|
||||
{workflow.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<Avatars workflowId={workflow.id} maxVisible={3} onPresenceChange={setHasAvatars} />
|
||||
)}
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { InviteModal } from './invite-modal/invite-modal'
|
||||
@@ -1068,7 +1068,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
<label htmlFor='emails' className='font-medium text-sm'>
|
||||
Email Addresses
|
||||
</label>
|
||||
<div className='scrollbar-hide flex max-h-32 min-h-9 flex-wrap items-center gap-x-2 gap-y-1 overflow-y-auto rounded-[8px] border px-2 py-1 focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background'>
|
||||
<div className='scrollbar-hide flex max-h-32 min-h-9 flex-wrap items-center gap-x-2 gap-y-1 overflow-y-auto rounded-[8px] border border-input bg-background px-2 py-1 focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2'>
|
||||
{invalidEmails.map((email, index) => (
|
||||
<EmailTag
|
||||
key={`invalid-${index}`}
|
||||
@@ -1102,7 +1102,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
: 'Enter emails'
|
||||
}
|
||||
className={cn(
|
||||
'h-6 min-w-[180px] flex-1 border-none focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
'h-6 min-w-[180px] flex-1 border-none bg-transparent p-0 focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
emails.length > 0 || invalidEmails.length > 0 ? 'pl-1' : 'pl-1'
|
||||
)}
|
||||
autoFocus={userPerms.canAdmin}
|
||||
@@ -0,0 +1 @@
|
||||
export { WorkspaceHeader } from './workspace-header'
|
||||
@@ -0,0 +1,393 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Pencil, Plus, RefreshCw, Settings } from 'lucide-react'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
ChevronDown,
|
||||
PanelLeft,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverSection,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
Trash,
|
||||
} from '@/components/emcn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { DeleteModal } from '../workflow-list/components/delete-modal/delete-modal'
|
||||
import { InviteModal } from './components'
|
||||
|
||||
const logger = createLogger('WorkspaceHeader')
|
||||
|
||||
interface Workspace {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
interface WorkspaceHeaderProps {
|
||||
/**
|
||||
* The active workspace object
|
||||
*/
|
||||
activeWorkspace?: { name: string } | null
|
||||
/**
|
||||
* Current workspace ID
|
||||
*/
|
||||
workspaceId: string
|
||||
/**
|
||||
* List of available workspaces
|
||||
*/
|
||||
workspaces: Workspace[]
|
||||
/**
|
||||
* Whether workspaces are loading
|
||||
*/
|
||||
isWorkspacesLoading: boolean
|
||||
/**
|
||||
* Whether workspace creation is in progress
|
||||
*/
|
||||
isCreatingWorkspace: boolean
|
||||
/**
|
||||
* Whether the workspace menu popover is open
|
||||
*/
|
||||
isWorkspaceMenuOpen: boolean
|
||||
/**
|
||||
* Callback to set workspace menu open state
|
||||
*/
|
||||
setIsWorkspaceMenuOpen: (isOpen: boolean) => void
|
||||
/**
|
||||
* Callback when workspace is switched
|
||||
*/
|
||||
onWorkspaceSwitch: (workspace: Workspace) => void
|
||||
/**
|
||||
* Callback when create workspace is clicked
|
||||
*/
|
||||
onCreateWorkspace: () => Promise<void>
|
||||
/**
|
||||
* Callback when toggle collapse is clicked
|
||||
*/
|
||||
onToggleCollapse: () => void
|
||||
/**
|
||||
* Whether the sidebar is collapsed
|
||||
*/
|
||||
isCollapsed: boolean
|
||||
/**
|
||||
* Callback to rename the workspace
|
||||
*/
|
||||
onRenameWorkspace: (workspaceId: string, newName: string) => Promise<void>
|
||||
/**
|
||||
* Callback to delete the workspace
|
||||
*/
|
||||
onDeleteWorkspace: (workspaceId: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace header component that displays workspace name, switcher, and collapse toggle.
|
||||
* Used in both the full sidebar and floating collapsed state.
|
||||
*/
|
||||
export function WorkspaceHeader({
|
||||
activeWorkspace,
|
||||
workspaceId,
|
||||
workspaces,
|
||||
isWorkspacesLoading,
|
||||
isCreatingWorkspace,
|
||||
isWorkspaceMenuOpen,
|
||||
setIsWorkspaceMenuOpen,
|
||||
onWorkspaceSwitch,
|
||||
onCreateWorkspace,
|
||||
onToggleCollapse,
|
||||
isCollapsed,
|
||||
onRenameWorkspace,
|
||||
onDeleteWorkspace,
|
||||
}: WorkspaceHeaderProps) {
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Workspace | null>(null)
|
||||
const [settingsWorkspaceId, setSettingsWorkspaceId] = useState<string | null>(null)
|
||||
const [editingWorkspaceId, setEditingWorkspaceId] = useState<string | null>(null)
|
||||
const [editingName, setEditingName] = useState('')
|
||||
const [isListRenaming, setIsListRenaming] = useState(false)
|
||||
const listRenameInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
/**
|
||||
* Focus the inline list rename input when it becomes active
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (editingWorkspaceId && listRenameInputRef.current) {
|
||||
try {
|
||||
listRenameInputRef.current.focus()
|
||||
listRenameInputRef.current.select()
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
}, [editingWorkspaceId])
|
||||
|
||||
/**
|
||||
* Save and exit edit mode when popover closes
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isWorkspaceMenuOpen && editingWorkspaceId) {
|
||||
const workspace = workspaces.find((w) => w.id === editingWorkspaceId)
|
||||
if (workspace && editingName.trim() && editingName.trim() !== workspace.name) {
|
||||
void onRenameWorkspace(editingWorkspaceId, editingName.trim())
|
||||
}
|
||||
setEditingWorkspaceId(null)
|
||||
}
|
||||
}, [isWorkspaceMenuOpen, editingWorkspaceId, editingName, workspaces, onRenameWorkspace])
|
||||
|
||||
const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null
|
||||
|
||||
/**
|
||||
* Handles page refresh when disconnected
|
||||
*/
|
||||
const handleRefresh = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles rename action from settings menu
|
||||
*/
|
||||
const handleRenameAction = (workspace: Workspace) => {
|
||||
setSettingsWorkspaceId(null)
|
||||
setEditingWorkspaceId(workspace.id)
|
||||
setEditingName(workspace.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles delete action from settings menu
|
||||
*/
|
||||
const handleDeleteAction = (workspace: Workspace) => {
|
||||
setSettingsWorkspaceId(null)
|
||||
setDeleteTarget(workspace)
|
||||
setIsDeleteModalOpen(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle delete workspace
|
||||
*/
|
||||
const handleDeleteWorkspace = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const targetId = deleteTarget?.id || workspaceId
|
||||
await onDeleteWorkspace(targetId)
|
||||
setIsDeleteModalOpen(false)
|
||||
setDeleteTarget(null)
|
||||
} catch (error) {
|
||||
logger.error('Error deleting workspace:', error)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 items-center justify-between gap-[8px]'>
|
||||
{/* Workspace Name */}
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||
<h2
|
||||
className='max-w-full truncate font-base text-[14px] dark:text-[#FFFFFF]'
|
||||
title={activeWorkspace?.name || 'Loading...'}
|
||||
>
|
||||
{activeWorkspace?.name || 'Loading...'}
|
||||
</h2>
|
||||
</div>
|
||||
{/* Workspace Actions */}
|
||||
<div className='flex items-center gap-[10px]'>
|
||||
{/* Disconnection Indicator */}
|
||||
{userPermissions.isOfflineMode && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
type='button'
|
||||
aria-label='Connection lost - click to refresh'
|
||||
className='group !p-[3px] -m-[3px]'
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw className='h-[14px] w-[14px] text-[#EF4444] dark:text-[#EF4444]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Connection lost - refresh</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{/* Invite */}
|
||||
<Badge className='cursor-pointer' onClick={() => setIsInviteModalOpen(true)}>
|
||||
Invite
|
||||
</Badge>
|
||||
{/* Workspace Switcher Popover */}
|
||||
<Popover open={isWorkspaceMenuOpen} onOpenChange={setIsWorkspaceMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost-secondary'
|
||||
type='button'
|
||||
aria-label='Switch workspace'
|
||||
className='group !p-[3px] -m-[3px]'
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-[8px] w-[12px] transition-transform duration-100 ${
|
||||
isWorkspaceMenuOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align='end'
|
||||
side='bottom'
|
||||
sideOffset={8}
|
||||
style={{ maxWidth: '160px', minWidth: '160px' }}
|
||||
>
|
||||
{isWorkspacesLoading ? (
|
||||
<PopoverItem disabled>
|
||||
<span>Loading workspaces...</span>
|
||||
</PopoverItem>
|
||||
) : (
|
||||
<>
|
||||
<div className='relative flex items-center justify-between'>
|
||||
<PopoverSection>Workspaces</PopoverSection>
|
||||
<Button
|
||||
variant='ghost'
|
||||
type='button'
|
||||
aria-label='Create workspace'
|
||||
className='!p-[3px] absolute top-[3px] right-[5.5px]'
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
await onCreateWorkspace()
|
||||
setIsWorkspaceMenuOpen(false)
|
||||
}}
|
||||
disabled={isCreatingWorkspace}
|
||||
>
|
||||
<Plus className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='max-h-[200px] overflow-y-auto'>
|
||||
{workspaces.map((workspace, index) => (
|
||||
<div key={workspace.id} className={index > 0 ? 'mt-[2px]' : ''}>
|
||||
{editingWorkspaceId === workspace.id ? (
|
||||
<div className='flex h-[25px] items-center gap-[8px] rounded-[6px] bg-[#363636] px-[6px] dark:bg-[#363636]'>
|
||||
<input
|
||||
ref={listRenameInputRef}
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
setIsListRenaming(true)
|
||||
try {
|
||||
await onRenameWorkspace(workspace.id, editingName.trim())
|
||||
setEditingWorkspaceId(null)
|
||||
} finally {
|
||||
setIsListRenaming(false)
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setEditingWorkspaceId(null)
|
||||
}
|
||||
}}
|
||||
onBlur={async () => {
|
||||
if (!editingWorkspaceId) return
|
||||
setIsListRenaming(true)
|
||||
try {
|
||||
await onRenameWorkspace(workspace.id, editingName.trim())
|
||||
setEditingWorkspaceId(null)
|
||||
} finally {
|
||||
setIsListRenaming(false)
|
||||
}
|
||||
}}
|
||||
className='w-full border-0 bg-transparent p-0 font-base text-[#E6E6E6] text-[12px] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:text-[#E6E6E6]'
|
||||
maxLength={100}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
disabled={isListRenaming}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='group relative flex items-center'>
|
||||
<PopoverItem
|
||||
active={workspace.id === workspaceId}
|
||||
onClick={() => onWorkspaceSwitch(workspace)}
|
||||
className='flex-1 pr-[28px]'
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate'>{workspace.name}</span>
|
||||
</PopoverItem>
|
||||
<Popover
|
||||
open={settingsWorkspaceId === workspace.id}
|
||||
onOpenChange={(open) =>
|
||||
setSettingsWorkspaceId(open ? workspace.id : null)
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
type='button'
|
||||
aria-label='Workspace settings'
|
||||
className='!p-[4px] absolute right-[4px]'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<Settings className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align='end' side='right' sideOffset={10}>
|
||||
<PopoverItem onClick={() => handleRenameAction(workspace)}>
|
||||
<Pencil className='h-3 w-3' />
|
||||
<span>Rename</span>
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
onClick={() => handleDeleteAction(workspace)}
|
||||
className='mt-[2px]'
|
||||
>
|
||||
<Trash className='h-3 w-3' />
|
||||
<span>Delete</span>
|
||||
</PopoverItem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{/* Sidebar Collapse Toggle */}
|
||||
<Button
|
||||
variant='ghost-secondary'
|
||||
type='button'
|
||||
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
className='group !p-[3px] -m-[3px]'
|
||||
onClick={onToggleCollapse}
|
||||
>
|
||||
<PanelLeft className='h-[17.5px] w-[17.5px]' />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Invite Modal */}
|
||||
<InviteModal
|
||||
open={isInviteModalOpen}
|
||||
onOpenChange={setIsInviteModalOpen}
|
||||
workspaceName={activeWorkspace?.name || 'Workspace'}
|
||||
/>
|
||||
{/* Delete Confirmation Modal */}
|
||||
<DeleteModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
onConfirm={handleDeleteWorkspace}
|
||||
isDeleting={isDeleting}
|
||||
itemType='workspace'
|
||||
itemName={deleteTarget?.name || activeWorkspaceFull?.name || activeWorkspace?.name}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { HelpCircle, LibraryBig, ScrollText, Settings, Shapes } from 'lucide-react'
|
||||
import { NavigationItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/navigation-item/navigation-item'
|
||||
import { getKeyboardShortcutText } from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts'
|
||||
|
||||
interface FloatingNavigationProps {
|
||||
workspaceId: string
|
||||
@@ -36,7 +35,6 @@ export const FloatingNavigation = ({
|
||||
icon: ScrollText,
|
||||
href: `/workspace/${workspaceId}/logs`,
|
||||
tooltip: 'Logs',
|
||||
shortcut: getKeyboardShortcutText('L', true, true),
|
||||
active: pathname === `/workspace/${workspaceId}/logs`,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -21,7 +21,7 @@ import { isDev } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal'
|
||||
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workspace-header/components/invite-modal/invite-modal'
|
||||
|
||||
const logger = createLogger('WorkspaceSelector')
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ interface UseItemRenameProps {
|
||||
/**
|
||||
* Item type for logging
|
||||
*/
|
||||
itemType: 'workflow' | 'folder'
|
||||
itemType: 'workflow' | 'folder' | 'workspace'
|
||||
/**
|
||||
* Item ID for logging
|
||||
*/
|
||||
@@ -23,7 +23,7 @@ interface UseItemRenameProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing inline rename functionality for workflows and folders.
|
||||
* Hook for managing inline rename functionality for workflows, folders, and workspaces.
|
||||
*
|
||||
* Handles:
|
||||
* - Edit state management
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowDown, Plus, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
ChevronDown,
|
||||
FolderPlus,
|
||||
PanelLeft,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverSection,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button, FolderPlus, Tooltip } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { FooterNavigation, WorkflowList } from './components-new'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import {
|
||||
FooterNavigation,
|
||||
SearchModal,
|
||||
WorkflowList,
|
||||
WorkspaceHeader,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new'
|
||||
import {
|
||||
useFolderOperations,
|
||||
useSidebarResize,
|
||||
useWorkflowOperations,
|
||||
useWorkspaceManagement,
|
||||
} from './hooks'
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
|
||||
const logger = createLogger('SidebarNew')
|
||||
|
||||
/**
|
||||
* Sidebar component with resizable width that persists across page refreshes.
|
||||
@@ -41,6 +40,7 @@ export function SidebarNew() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const workflowId = params.workflowId as string | undefined
|
||||
const router = useRouter()
|
||||
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -49,12 +49,19 @@ export function SidebarNew() {
|
||||
// Session data
|
||||
const { data: sessionData, isPending: sessionLoading } = useSession()
|
||||
|
||||
// Sidebar state
|
||||
const isCollapsed = useSidebarStore((state) => state.isCollapsed)
|
||||
const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed)
|
||||
|
||||
// Import state
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
|
||||
// Workspace popover state
|
||||
const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false)
|
||||
|
||||
// Search modal state
|
||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false)
|
||||
|
||||
// Workspace management hook
|
||||
const {
|
||||
workspaces,
|
||||
@@ -65,6 +72,8 @@ export function SidebarNew() {
|
||||
switchWorkspace,
|
||||
handleCreateWorkspace,
|
||||
isCreatingWorkspace,
|
||||
updateWorkspaceName,
|
||||
confirmDeleteWorkspace,
|
||||
} = useWorkspaceManagement({
|
||||
workspaceId,
|
||||
sessionUserId: sessionData?.user?.id,
|
||||
@@ -90,6 +99,30 @@ export function SidebarNew() {
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
// Prepare data for search modal
|
||||
const searchModalWorkflows = useMemo(
|
||||
() =>
|
||||
regularWorkflows.map((workflow) => ({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
href: `/workspace/${workspaceId}/w/${workflow.id}`,
|
||||
color: workflow.color,
|
||||
isCurrent: workflow.id === workflowId,
|
||||
})),
|
||||
[regularWorkflows, workspaceId, workflowId]
|
||||
)
|
||||
|
||||
const searchModalWorkspaces = useMemo(
|
||||
() =>
|
||||
workspaces.map((workspace) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
href: `/workspace/${workspace.id}/w`,
|
||||
isCurrent: workspace.id === workspaceId,
|
||||
})),
|
||||
[workspaces, workspaceId]
|
||||
)
|
||||
|
||||
// Combined loading state
|
||||
const isLoading = workflowsLoading || sessionLoading
|
||||
|
||||
@@ -198,6 +231,13 @@ export function SidebarNew() {
|
||||
[workspaceId, switchWorkspace]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle sidebar collapse toggle
|
||||
*/
|
||||
const handleToggleCollapse = useCallback(() => {
|
||||
setIsCollapsed(!isCollapsed)
|
||||
}, [isCollapsed, setIsCollapsed])
|
||||
|
||||
/**
|
||||
* Handle click on sidebar elements to revert to active workflow selection
|
||||
*/
|
||||
@@ -216,192 +256,278 @@ export function SidebarNew() {
|
||||
[workflowId]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle workspace rename
|
||||
*/
|
||||
const handleRenameWorkspace = useCallback(
|
||||
async (workspaceIdToRename: string, newName: string) => {
|
||||
await updateWorkspaceName(workspaceIdToRename, newName)
|
||||
},
|
||||
[updateWorkspaceName]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle workspace delete
|
||||
*/
|
||||
const handleDeleteWorkspace = useCallback(
|
||||
async (workspaceIdToDelete: string) => {
|
||||
const workspaceToDelete = workspaces.find((w) => w.id === workspaceIdToDelete)
|
||||
if (workspaceToDelete) {
|
||||
await confirmDeleteWorkspace(workspaceToDelete, 'keep')
|
||||
}
|
||||
},
|
||||
[workspaces, confirmDeleteWorkspace]
|
||||
)
|
||||
|
||||
/**
|
||||
* Register global commands:
|
||||
* - Mod+Shift+A: Add an Agent block to the canvas
|
||||
* - Mod+Y: Navigate to Templates (attempts to override browser history)
|
||||
* - Mod+L: Navigate to Logs (attempts to override browser location bar)
|
||||
* - Mod+K: Search (placeholder; no-op for now)
|
||||
*/
|
||||
useRegisterGlobalCommands(() => [
|
||||
{
|
||||
id: 'add-agent',
|
||||
shortcut: 'Mod+Shift+A',
|
||||
allowInEditable: true,
|
||||
handler: () => {
|
||||
try {
|
||||
const event = new CustomEvent('add-block-from-toolbar', {
|
||||
detail: { type: 'agent', enableTriggerMode: false },
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
logger.info('Dispatched add-agent command')
|
||||
} catch (err) {
|
||||
logger.error('Failed to dispatch add-agent command', { err })
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'goto-templates',
|
||||
shortcut: 'Mod+Y',
|
||||
allowInEditable: true,
|
||||
handler: () => {
|
||||
try {
|
||||
const pathWorkspaceId =
|
||||
workspaceId ||
|
||||
(typeof window !== 'undefined'
|
||||
? (() => {
|
||||
const parts = window.location.pathname.split('/')
|
||||
const idx = parts.indexOf('workspace')
|
||||
return idx !== -1 ? parts[idx + 1] : undefined
|
||||
})()
|
||||
: undefined)
|
||||
if (pathWorkspaceId) {
|
||||
router.push(`/workspace/${pathWorkspaceId}/templates`)
|
||||
logger.info('Navigated to templates', { workspaceId: pathWorkspaceId })
|
||||
} else {
|
||||
router.push('/templates')
|
||||
logger.info('Navigated to global templates (no workspace in path)')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to navigate to templates', { err })
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'goto-logs',
|
||||
shortcut: 'Mod+L',
|
||||
allowInEditable: true,
|
||||
handler: () => {
|
||||
try {
|
||||
const pathWorkspaceId =
|
||||
workspaceId ||
|
||||
(typeof window !== 'undefined'
|
||||
? (() => {
|
||||
const parts = window.location.pathname.split('/')
|
||||
const idx = parts.indexOf('workspace')
|
||||
return idx !== -1 ? parts[idx + 1] : undefined
|
||||
})()
|
||||
: undefined)
|
||||
if (pathWorkspaceId) {
|
||||
router.push(`/workspace/${pathWorkspaceId}/logs`)
|
||||
logger.info('Navigated to logs', { workspaceId: pathWorkspaceId })
|
||||
} else {
|
||||
logger.warn('No workspace ID found, cannot navigate to logs')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to navigate to logs', { err })
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'open-search',
|
||||
shortcut: 'Mod+K',
|
||||
allowInEditable: true,
|
||||
handler: () => {
|
||||
setIsSearchModalOpen(true)
|
||||
logger.info('Search modal opened')
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
className='sidebar-container fixed inset-y-0 left-0 z-10 overflow-hidden dark:bg-[#1E1E1E]'
|
||||
aria-label='Workspace sidebar'
|
||||
onClick={handleSidebarClick}
|
||||
>
|
||||
<div className='flex h-full flex-col border-r pt-[14px] dark:border-[#2C2C2C]'>
|
||||
{/* Header */}
|
||||
<div className='flex flex-shrink-0 items-center justify-between gap-[8px] px-[14px]'>
|
||||
{/* Workspace Name */}
|
||||
<div className='flex min-w-0 items-center gap-[8px]'>
|
||||
<h2
|
||||
className='truncate font-medium text-base dark:text-white'
|
||||
title={activeWorkspace?.name || 'Loading...'}
|
||||
>
|
||||
{activeWorkspace?.name || 'Loading...'}
|
||||
</h2>
|
||||
{/* TODO: Solo/Team based on workspace members */}
|
||||
{/* <Badge className='flex-shrink-0 translate-y-[1px] whitespace-nowrap'>Solo</Badge> */}
|
||||
</div>
|
||||
{/* Workspace Actions */}
|
||||
<div className='flex items-center gap-[14px]'>
|
||||
{/* Workspace Switcher Popover */}
|
||||
<Popover open={isWorkspaceMenuOpen} onOpenChange={setIsWorkspaceMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost-secondary'
|
||||
type='button'
|
||||
aria-label='Switch workspace'
|
||||
className='group -m-1 p-0 p-1'
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-[8px] w-[12px] transition-transform duration-100 ${
|
||||
isWorkspaceMenuOpen ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align='end' side='bottom' sideOffset={8}>
|
||||
{isWorkspacesLoading ? (
|
||||
<PopoverItem disabled>
|
||||
<span>Loading workspaces...</span>
|
||||
</PopoverItem>
|
||||
) : (
|
||||
<>
|
||||
{workspaces.length > 0 && (
|
||||
<>
|
||||
<PopoverSection>Workspaces</PopoverSection>
|
||||
{workspaces.map((workspace, index) => (
|
||||
<PopoverItem
|
||||
key={workspace.id}
|
||||
active={workspace.id === workspaceId}
|
||||
onClick={() => handleWorkspaceSwitch(workspace)}
|
||||
className={index > 0 ? 'mt-[2px]' : ''}
|
||||
>
|
||||
<span>{workspace.name}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<PopoverItem
|
||||
onClick={async () => {
|
||||
await handleCreateWorkspace()
|
||||
setIsWorkspaceMenuOpen(false)
|
||||
}}
|
||||
disabled={isCreatingWorkspace}
|
||||
className={workspaces.length > 0 ? 'mt-[2px]' : ''}
|
||||
>
|
||||
<Plus className='h-3 w-3' />
|
||||
<span>Create a workspace</span>
|
||||
</PopoverItem>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{/* TODO: Add panel toggle */}
|
||||
<Button
|
||||
variant='ghost-secondary'
|
||||
type='button'
|
||||
aria-label='Toggle panel'
|
||||
className='group p-0'
|
||||
>
|
||||
<PanelLeft className='h-[17.5px] w-[17.5px]' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
/* Floating collapsed header */
|
||||
<div className='fixed top-[14px] left-[10px] z-10 max-w-[232px] rounded-[8px] border bg-white px-[12px] py-[8px] dark:border-[#2C2C2C] dark:bg-[#1E1E1E]'>
|
||||
<WorkspaceHeader
|
||||
activeWorkspace={activeWorkspace}
|
||||
workspaceId={workspaceId}
|
||||
workspaces={workspaces}
|
||||
isWorkspacesLoading={isWorkspacesLoading}
|
||||
isCreatingWorkspace={isCreatingWorkspace}
|
||||
isWorkspaceMenuOpen={isWorkspaceMenuOpen}
|
||||
setIsWorkspaceMenuOpen={setIsWorkspaceMenuOpen}
|
||||
onWorkspaceSwitch={handleWorkspaceSwitch}
|
||||
onCreateWorkspace={handleCreateWorkspace}
|
||||
onToggleCollapse={handleToggleCollapse}
|
||||
isCollapsed={isCollapsed}
|
||||
onRenameWorkspace={handleRenameWorkspace}
|
||||
onDeleteWorkspace={handleDeleteWorkspace}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Full sidebar */
|
||||
<>
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
className='sidebar-container fixed inset-y-0 left-0 z-10 overflow-hidden dark:bg-[#1E1E1E]'
|
||||
aria-label='Workspace sidebar'
|
||||
onClick={handleSidebarClick}
|
||||
>
|
||||
<div className='flex h-full flex-col border-r pt-[14px] dark:border-[#2C2C2C]'>
|
||||
{/* Header */}
|
||||
<div className='flex-shrink-0 px-[14px]'>
|
||||
<WorkspaceHeader
|
||||
activeWorkspace={activeWorkspace}
|
||||
workspaceId={workspaceId}
|
||||
workspaces={workspaces}
|
||||
isWorkspacesLoading={isWorkspacesLoading}
|
||||
isCreatingWorkspace={isCreatingWorkspace}
|
||||
isWorkspaceMenuOpen={isWorkspaceMenuOpen}
|
||||
setIsWorkspaceMenuOpen={setIsWorkspaceMenuOpen}
|
||||
onWorkspaceSwitch={handleWorkspaceSwitch}
|
||||
onCreateWorkspace={handleCreateWorkspace}
|
||||
onToggleCollapse={handleToggleCollapse}
|
||||
isCollapsed={isCollapsed}
|
||||
onRenameWorkspace={handleRenameWorkspace}
|
||||
onDeleteWorkspace={handleDeleteWorkspace}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className='mx-[8px] mt-[14px] flex flex-shrink-0 cursor-pointer items-center justify-between rounded-[8px] bg-[#272727] px-[6px] py-[7px] dark:bg-[#272727]'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Search className='h-[16px] w-[16px] text-[#7D7D7D] dark:text-[#7D7D7D]' />
|
||||
<p className='translate-y-[0.25px] font-medium text-[#B1B1B1] text-small dark:text-[#B1B1B1]'>
|
||||
Search
|
||||
</p>
|
||||
</div>
|
||||
<p className='font-medium text-[#7D7D7D] text-small dark:text-[#7D7D7D]'>⌘K</p>
|
||||
</div>
|
||||
|
||||
{/* Workflows */}
|
||||
<div className='workflows-section relative mt-[14px] flex flex-1 flex-col overflow-hidden'>
|
||||
{/* Header - Always visible */}
|
||||
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[14px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='font-medium text-[#AEAEAE] text-small dark:text-[#AEAEAE]'>
|
||||
Workflows
|
||||
{/* Search */}
|
||||
<div
|
||||
className='mx-[8px] mt-[12px] flex flex-shrink-0 cursor-pointer items-center justify-between rounded-[8px] bg-[#272727] px-[8px] py-[7px] dark:bg-[#272727]'
|
||||
onClick={() => setIsSearchModalOpen(true)}
|
||||
>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Search className='h-[14px] w-[14px] text-[#7D7D7D] dark:text-[#7D7D7D]' />
|
||||
<p className='translate-y-[0.25px] font-medium text-[#B1B1B1] text-small dark:text-[#B1B1B1]'>
|
||||
Search
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center justify-center gap-[10px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='translate-y-[-0.25px] p-[1px]'
|
||||
onClick={handleImportWorkflow}
|
||||
disabled={isImporting}
|
||||
>
|
||||
<ArrowDown className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content className='py-[2.5px]'>
|
||||
<p>{isImporting ? 'Importing workflow...' : 'Import from JSON'}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='mr-[1px] translate-y-[-0.25px] p-[1px]'
|
||||
onClick={handleCreateFolder}
|
||||
disabled={isCreatingFolder}
|
||||
>
|
||||
<FolderPlus className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content className='py-[2.5px]'>
|
||||
<p>{isCreatingFolder ? 'Creating folder...' : 'Create folder'}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='translate-y-[-0.25px] p-[1px]'
|
||||
onClick={handleCreateWorkflow}
|
||||
disabled={isCreatingWorkflow}
|
||||
>
|
||||
<Plus className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content className='py-[2.5px]'>
|
||||
<p>{isCreatingWorkflow ? 'Creating workflow...' : 'Create workflow'}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<p className='font-medium text-[#7D7D7D] text-small dark:text-[#7D7D7D]'>⌘K</p>
|
||||
</div>
|
||||
|
||||
{/* Workflows */}
|
||||
<div className='workflows-section relative mt-[14px] flex flex-1 flex-col overflow-hidden'>
|
||||
{/* Header - Always visible */}
|
||||
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[14px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='font-medium text-[#AEAEAE] text-small dark:text-[#AEAEAE]'>
|
||||
Workflows
|
||||
</div>
|
||||
<div className='flex items-center justify-center gap-[10px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='translate-y-[-0.25px] p-[1px]'
|
||||
onClick={handleImportWorkflow}
|
||||
disabled={isImporting}
|
||||
>
|
||||
<ArrowDown className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content className='py-[2.5px]'>
|
||||
<p>{isImporting ? 'Importing workflow...' : 'Import from JSON'}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='mr-[1px] translate-y-[-0.25px] p-[1px]'
|
||||
onClick={handleCreateFolder}
|
||||
disabled={isCreatingFolder}
|
||||
>
|
||||
<FolderPlus className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content className='py-[2.5px]'>
|
||||
<p>{isCreatingFolder ? 'Creating folder...' : 'Create folder'}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='translate-y-[-0.25px] p-[1px]'
|
||||
onClick={handleCreateWorkflow}
|
||||
disabled={isCreatingWorkflow}
|
||||
>
|
||||
<Plus className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content className='py-[2.5px]'>
|
||||
<p>{isCreatingWorkflow ? 'Creating workflow...' : 'Create workflow'}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable workflow list */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className='mt-[6px] flex-1 overflow-y-auto overflow-x-hidden px-[8px]'
|
||||
>
|
||||
<WorkflowList
|
||||
regularWorkflows={regularWorkflows}
|
||||
isLoading={isLoading}
|
||||
isImporting={isImporting}
|
||||
setIsImporting={setIsImporting}
|
||||
fileInputRef={fileInputRef}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<FooterNavigation />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Scrollable workflow list */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className='mt-[6px] flex-1 overflow-y-auto overflow-x-hidden px-[8px]'
|
||||
>
|
||||
<WorkflowList
|
||||
regularWorkflows={regularWorkflows}
|
||||
isLoading={isLoading}
|
||||
isImporting={isImporting}
|
||||
setIsImporting={setIsImporting}
|
||||
fileInputRef={fileInputRef}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
className='fixed top-0 bottom-0 left-[calc(var(--sidebar-width)-4px)] z-20 w-[8px] cursor-ew-resize'
|
||||
onMouseDown={handleMouseDown}
|
||||
role='separator'
|
||||
aria-orientation='vertical'
|
||||
aria-label='Resize sidebar'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Footer Navigation */}
|
||||
<FooterNavigation />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
className='fixed top-0 bottom-0 left-[calc(var(--sidebar-width)-4px)] z-20 w-[8px] cursor-ew-resize'
|
||||
onMouseDown={handleMouseDown}
|
||||
role='separator'
|
||||
aria-orientation='vertical'
|
||||
aria-label='Resize sidebar'
|
||||
{/* Universal Search Modal */}
|
||||
<SearchModal
|
||||
open={isSearchModalOpen}
|
||||
onOpenChange={setIsSearchModalOpen}
|
||||
workflows={searchModalWorkflows}
|
||||
workspaces={searchModalWorkspaces}
|
||||
isOnWorkflowPage={!!workflowId}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -9,13 +9,11 @@ import { getEnv, isTruthy } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateWorkspaceName } from '@/lib/naming'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { SearchModal } from '@/app/workspace/[workspaceId]/w/components/search-modal/search-modal'
|
||||
import {
|
||||
CreateMenu,
|
||||
FloatingNavigation,
|
||||
FolderTree,
|
||||
HelpModal,
|
||||
KeyboardShortcut,
|
||||
KnowledgeBaseTags,
|
||||
KnowledgeTags,
|
||||
LogsFilters,
|
||||
@@ -25,12 +23,8 @@ import {
|
||||
WorkspaceHeader,
|
||||
WorkspaceSelector,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
|
||||
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-selector/components/invite-modal/invite-modal'
|
||||
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/workspace-header/components/invite-modal/invite-modal'
|
||||
import { useAutoScroll } from '@/app/workspace/[workspaceId]/w/hooks/use-auto-scroll'
|
||||
import {
|
||||
getKeyboardShortcutText,
|
||||
useGlobalShortcuts,
|
||||
} from '@/app/workspace/[workspaceId]/w/hooks/use-keyboard-shortcuts'
|
||||
import { useKnowledgeBasesList } from '@/hooks/use-knowledge'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
@@ -85,7 +79,7 @@ interface TemplateData {
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
useGlobalShortcuts()
|
||||
// useGlobalShortcuts()
|
||||
|
||||
const {
|
||||
workflows,
|
||||
@@ -914,7 +908,7 @@ export function Sidebar() {
|
||||
<span className='flex h-8 flex-1 items-center px-0 text-muted-foreground text-sm leading-none'>
|
||||
Search anything
|
||||
</span>
|
||||
<KeyboardShortcut shortcut={getKeyboardShortcutText('K', true)} />
|
||||
{/* <KeyboardShortcut shortcut={getKeyboardShortcutText('K', true)} /> */}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1027,15 +1021,6 @@ export function Sidebar() {
|
||||
<HelpModal open={showHelp} onOpenChange={setShowHelp} />
|
||||
<InviteModal open={showInviteMembers} onOpenChange={setShowInviteMembers} />
|
||||
<SubscriptionModal open={showSubscriptionModal} onOpenChange={setShowSubscriptionModal} />
|
||||
|
||||
<SearchModal
|
||||
open={showSearchModal}
|
||||
onOpenChange={setShowSearchModal}
|
||||
workflows={searchWorkflows}
|
||||
workspaces={searchWorkspaces}
|
||||
knowledgeBases={searchKnowledgeBases}
|
||||
isOnWorkflowPage={isOnWorkflowPage}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,4 +3,3 @@ export { useDeleteFolder } from './use-delete-folder'
|
||||
export { useDeleteWorkflow } from './use-delete-workflow'
|
||||
export { useDuplicateFolder } from './use-duplicate-folder'
|
||||
export { useDuplicateWorkflow } from './use-duplicate-workflow'
|
||||
export { useKeyboardShortcuts } from './use-keyboard-shortcuts'
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
/**
|
||||
* Detect if the current platform is Mac
|
||||
*/
|
||||
export function isMacPlatform() {
|
||||
if (typeof navigator === 'undefined') return false
|
||||
return navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a formatted keyboard shortcut string for display
|
||||
* @param key The key part of the shortcut (e.g., "Enter")
|
||||
* @param requiresCmd Whether the shortcut requires Cmd/Ctrl
|
||||
* @param requiresShift Whether the shortcut requires Shift
|
||||
* @param requiresAlt Whether the shortcut requires Alt/Option
|
||||
*/
|
||||
export function getKeyboardShortcutText(
|
||||
key: string,
|
||||
requiresCmd = false,
|
||||
requiresShift = false,
|
||||
requiresAlt = false
|
||||
) {
|
||||
const isMac = isMacPlatform()
|
||||
const cmdKey = isMac ? '⌘' : 'Ctrl'
|
||||
const altKey = isMac ? '⌥' : 'Alt'
|
||||
const shiftKey = '⇧'
|
||||
|
||||
const parts: string[] = []
|
||||
if (requiresCmd) parts.push(cmdKey)
|
||||
if (requiresShift) parts.push(shiftKey)
|
||||
if (requiresAlt) parts.push(altKey)
|
||||
parts.push(key)
|
||||
|
||||
return parts.join('+')
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage keyboard shortcuts
|
||||
* @param onRunWorkflow - Function to run when Cmd/Ctrl+Enter is pressed
|
||||
* @param isDisabled - Whether shortcuts should be disabled
|
||||
*/
|
||||
export function useKeyboardShortcuts(onRunWorkflow: () => void, isDisabled = false) {
|
||||
// Memoize the platform detection
|
||||
const isMac = useMemo(() => isMacPlatform(), [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Run workflow with Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux)
|
||||
if (event.key === 'Enter' && ((isMac && event.metaKey) || (!isMac && event.ctrlKey))) {
|
||||
// Don't trigger if user is typing in an input, textarea, or contenteditable element
|
||||
const activeElement = document.activeElement
|
||||
const isEditableElement =
|
||||
activeElement instanceof HTMLInputElement ||
|
||||
activeElement instanceof HTMLTextAreaElement ||
|
||||
activeElement?.hasAttribute('contenteditable')
|
||||
|
||||
if (!isEditableElement && !isDisabled) {
|
||||
event.preventDefault()
|
||||
onRunWorkflow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onRunWorkflow, isDisabled, isMac])
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage global navigation shortcuts
|
||||
*/
|
||||
export function useGlobalShortcuts() {
|
||||
const router = useRouter()
|
||||
const isMac = useMemo(() => isMacPlatform(), [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Don't trigger if user is typing in an input, textarea, or contenteditable element
|
||||
const activeElement = document.activeElement
|
||||
const isEditableElement =
|
||||
activeElement instanceof HTMLInputElement ||
|
||||
activeElement instanceof HTMLTextAreaElement ||
|
||||
activeElement?.hasAttribute('contenteditable')
|
||||
|
||||
if (isEditableElement) return
|
||||
|
||||
// Cmd/Ctrl + Shift + L - Navigate to Logs
|
||||
if (
|
||||
event.key.toLowerCase() === 'l' &&
|
||||
event.shiftKey &&
|
||||
((isMac && event.metaKey) || (!isMac && event.ctrlKey))
|
||||
) {
|
||||
event.preventDefault()
|
||||
|
||||
const pathParts = window.location.pathname.split('/')
|
||||
const workspaceIndex = pathParts.indexOf('workspace')
|
||||
|
||||
if (workspaceIndex !== -1 && pathParts[workspaceIndex + 1]) {
|
||||
const workspaceId = pathParts[workspaceIndex + 1]
|
||||
router.push(`/workspace/${workspaceId}/logs`)
|
||||
} else {
|
||||
router.push('/workspace')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [router, isMac])
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* User color palette matching terminal.tsx RUN_ID_COLORS
|
||||
* These colors are used consistently across cursors, avatars, and terminal run IDs
|
||||
*/
|
||||
export const USER_COLORS = [
|
||||
'#4ADE80', // Green
|
||||
'#F472B6', // Pink
|
||||
'#60C5FF', // Blue
|
||||
'#FF8533', // Orange
|
||||
'#C084FC', // Purple
|
||||
'#FCD34D', // Yellow
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Hash a user ID to generate a consistent numeric index
|
||||
*
|
||||
* @param userId - The user ID to hash
|
||||
* @returns A positive integer
|
||||
*/
|
||||
function hashUserId(userId: string): number {
|
||||
return Math.abs(Array.from(userId).reduce((acc, char) => acc + char.charCodeAt(0), 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a consistent color for a user based on their ID.
|
||||
* The same user will always get the same color across cursors, avatars, and terminal.
|
||||
*
|
||||
* @param userId - The unique user identifier
|
||||
* @returns A hex color string
|
||||
*/
|
||||
export function getUserColor(userId: string): string {
|
||||
const hash = hashUserId(userId)
|
||||
return USER_COLORS[hash % USER_COLORS.length]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a stable mapping of user IDs to color indices for a list of users.
|
||||
* Useful when you need to maintain consistent color assignments across renders.
|
||||
*
|
||||
* @param userIds - Array of user IDs to map
|
||||
* @returns Map of user ID to color index
|
||||
*/
|
||||
export function createUserColorMap(userIds: string[]): Map<string, number> {
|
||||
const colorMap = new Map<string, number>()
|
||||
let colorIndex = 0
|
||||
|
||||
for (const userId of userIds) {
|
||||
if (!colorMap.has(userId)) {
|
||||
colorMap.set(userId, colorIndex++)
|
||||
}
|
||||
}
|
||||
|
||||
return colorMap
|
||||
}
|
||||
@@ -59,7 +59,7 @@ import { cn } from '@/lib/utils'
|
||||
* Ensures consistent height and styling across items, folders, and back button.
|
||||
*/
|
||||
const POPOVER_ITEM_BASE_CLASSES =
|
||||
'flex h-[25px] cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[#E6E6E6] text-[12px] transition-colors dark:text-[#E6E6E6] [&_svg]:transition-colors disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
'flex h-[25px] min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[#E6E6E6] text-[12px] transition-colors dark:text-[#E6E6E6] [&_svg]:transition-colors disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
|
||||
/**
|
||||
* Variant-specific active state styles for popover items.
|
||||
@@ -139,7 +139,7 @@ const Popover: React.FC<PopoverProps> = ({ children, variant = 'default', ...pro
|
||||
(id: string, title: string, onLoad?: () => void | Promise<void>, onSelect?: () => void) => {
|
||||
setCurrentFolder(id)
|
||||
setFolderTitle(title)
|
||||
setOnFolderSelect(onSelect ?? null)
|
||||
setOnFolderSelect(() => onSelect ?? null)
|
||||
if (onLoad) {
|
||||
void Promise.resolve(onLoad())
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ export function TagInput({
|
||||
onBlur={handleBlur}
|
||||
placeholder={value.length === 0 ? placeholder : ''}
|
||||
disabled={disabled}
|
||||
className='h-7 min-w-[120px] flex-1 border-0 p-0 px-1 text-sm shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
className='h-7 min-w-[120px] flex-1 border-0 bg-transparent p-0 px-1 text-sm shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -882,6 +882,12 @@ export function useCollaborativeWorkflow() {
|
||||
}
|
||||
findAllDescendants(id)
|
||||
|
||||
// If the currently edited block is among the blocks being removed, clear selection to restore the last tab
|
||||
const currentEditedBlockId = usePanelEditorStore.getState().currentBlockId
|
||||
if (currentEditedBlockId && blocksToRemove.has(currentEditedBlockId)) {
|
||||
usePanelEditorStore.getState().clearCurrentBlock()
|
||||
}
|
||||
|
||||
// Capture state before removal, including all nested blocks with subblock values
|
||||
const allBlocks = mergeSubblockState(workflowStore.blocks, activeWorkflowId || undefined)
|
||||
const capturedBlocks: Record<string, BlockState> = {}
|
||||
@@ -1262,6 +1268,9 @@ export function useCollaborativeWorkflow() {
|
||||
}
|
||||
)
|
||||
|
||||
// Focus the newly duplicated block in the editor
|
||||
usePanelEditorStore.getState().setCurrentBlockId(newId)
|
||||
|
||||
executeQueuedOperation('duplicate', 'block', duplicatedBlockData, () => {
|
||||
workflowStore.addBlock(
|
||||
newId,
|
||||
|
||||
@@ -7,9 +7,9 @@ import { usePanelStore } from '../store'
|
||||
/**
|
||||
* Connections height constraints
|
||||
*/
|
||||
const DEFAULT_CONNECTIONS_HEIGHT = 200
|
||||
const DEFAULT_CONNECTIONS_HEIGHT = 115
|
||||
const MIN_CONNECTIONS_HEIGHT = 30
|
||||
const MAX_CONNECTIONS_HEIGHT = DEFAULT_CONNECTIONS_HEIGHT
|
||||
const MAX_CONNECTIONS_HEIGHT = 200
|
||||
|
||||
/**
|
||||
* State for the Editor panel.
|
||||
|
||||
@@ -7,8 +7,10 @@ import { persist } from 'zustand/middleware'
|
||||
interface SidebarState {
|
||||
workspaceDropdownOpen: boolean
|
||||
sidebarWidth: number
|
||||
isCollapsed: boolean
|
||||
setWorkspaceDropdownOpen: (isOpen: boolean) => void
|
||||
setSidebarWidth: (width: number) => void
|
||||
setIsCollapsed: (isCollapsed: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,6 +25,7 @@ export const useSidebarStore = create<SidebarState>()(
|
||||
(set, get) => ({
|
||||
workspaceDropdownOpen: false,
|
||||
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
|
||||
isCollapsed: false,
|
||||
setWorkspaceDropdownOpen: (isOpen) => set({ workspaceDropdownOpen: isOpen }),
|
||||
setSidebarWidth: (width) => {
|
||||
// Only enforce minimum - maximum is enforced dynamically by the resize hook
|
||||
@@ -33,14 +36,26 @@ export const useSidebarStore = create<SidebarState>()(
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${clampedWidth}px`)
|
||||
}
|
||||
},
|
||||
setIsCollapsed: (isCollapsed) => {
|
||||
set({ isCollapsed })
|
||||
// Set width to 0 when collapsed (floating UI doesn't need sidebar space)
|
||||
if (isCollapsed && typeof window !== 'undefined') {
|
||||
document.documentElement.style.setProperty('--sidebar-width', '0px')
|
||||
} else if (!isCollapsed && typeof window !== 'undefined') {
|
||||
// Restore to stored width when expanding
|
||||
const currentWidth = get().sidebarWidth
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${currentWidth}px`)
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'sidebar-state',
|
||||
onRehydrateStorage: () => (state) => {
|
||||
// Validate and enforce constraints after rehydration
|
||||
if (state && typeof window !== 'undefined') {
|
||||
// Sync CSS variables with validated state
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${state.sidebarWidth}px`)
|
||||
// Use 0 width if collapsed (floating UI), otherwise use stored width
|
||||
const width = state.isCollapsed ? 0 : state.sidebarWidth
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${width}px`)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from './console'
|
||||
export { useTerminalConsoleStore } from './console'
|
||||
export { useTerminalStore } from './store'
|
||||
export { DEFAULT_TERMINAL_HEIGHT, useTerminalStore } from './store'
|
||||
|
||||
@@ -4,7 +4,7 @@ import { persist } from 'zustand/middleware'
|
||||
/**
|
||||
* Display mode type for terminal output
|
||||
*/
|
||||
export type DisplayMode = 'raw' | 'prettier'
|
||||
// export type DisplayMode = 'raw' | 'prettier'
|
||||
|
||||
/**
|
||||
* Terminal state interface
|
||||
@@ -14,8 +14,8 @@ interface TerminalState {
|
||||
setTerminalHeight: (height: number) => void
|
||||
outputPanelWidth: number
|
||||
setOutputPanelWidth: (width: number) => void
|
||||
displayMode: DisplayMode
|
||||
setDisplayMode: (mode: DisplayMode) => void
|
||||
// displayMode: DisplayMode
|
||||
// setDisplayMode: (mode: DisplayMode) => void
|
||||
_hasHydrated: boolean
|
||||
setHasHydrated: (hasHydrated: boolean) => void
|
||||
}
|
||||
@@ -25,7 +25,7 @@ interface TerminalState {
|
||||
* Note: Maximum height is enforced dynamically at 70% of viewport height in the resize hook
|
||||
*/
|
||||
const MIN_TERMINAL_HEIGHT = 30
|
||||
const DEFAULT_TERMINAL_HEIGHT = 100
|
||||
export const DEFAULT_TERMINAL_HEIGHT = 145
|
||||
|
||||
/**
|
||||
* Output panel width constraints
|
||||
@@ -36,7 +36,7 @@ const DEFAULT_OUTPUT_PANEL_WIDTH = 400
|
||||
/**
|
||||
* Default display mode
|
||||
*/
|
||||
const DEFAULT_DISPLAY_MODE: DisplayMode = 'prettier'
|
||||
// const DEFAULT_DISPLAY_MODE: DisplayMode = 'prettier'
|
||||
|
||||
export const useTerminalStore = create<TerminalState>()(
|
||||
persist(
|
||||
@@ -56,10 +56,10 @@ export const useTerminalStore = create<TerminalState>()(
|
||||
const clampedWidth = Math.max(MIN_OUTPUT_PANEL_WIDTH, width)
|
||||
set({ outputPanelWidth: clampedWidth })
|
||||
},
|
||||
displayMode: DEFAULT_DISPLAY_MODE,
|
||||
setDisplayMode: (mode) => {
|
||||
set({ displayMode: mode })
|
||||
},
|
||||
// displayMode: DEFAULT_DISPLAY_MODE,
|
||||
// setDisplayMode: (mode) => {
|
||||
// set({ displayMode: mode })
|
||||
// },
|
||||
_hasHydrated: false,
|
||||
setHasHydrated: (hasHydrated) => {
|
||||
set({ _hasHydrated: hasHydrated })
|
||||
|
||||
431
apps/sim/stores/variables/store.ts
Normal file
431
apps/sim/stores/variables/store.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { create } from 'zustand'
|
||||
import { devtools, persist } from 'zustand/middleware'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import type {
|
||||
Variable,
|
||||
VariablesDimensions,
|
||||
VariablesPosition,
|
||||
VariablesStore,
|
||||
VariableType,
|
||||
} from './types'
|
||||
|
||||
const logger = createLogger('VariablesModalStore')
|
||||
|
||||
/**
|
||||
* Floating variables modal default dimensions.
|
||||
* Matches the chat modal baseline for visual consistency.
|
||||
*/
|
||||
const DEFAULT_WIDTH = 250
|
||||
const DEFAULT_HEIGHT = 286
|
||||
|
||||
/**
|
||||
* Minimum and maximum modal dimensions.
|
||||
* Kept in sync with the chat modal experience.
|
||||
*/
|
||||
export const MIN_VARIABLES_WIDTH = DEFAULT_WIDTH
|
||||
export const MIN_VARIABLES_HEIGHT = DEFAULT_HEIGHT
|
||||
export const MAX_VARIABLES_WIDTH = 500
|
||||
export const MAX_VARIABLES_HEIGHT = 600
|
||||
|
||||
/**
|
||||
* Compute a center-biased default position, factoring in current layout chrome
|
||||
* (sidebar, right panel, and terminal), mirroring the chat modal behavior.
|
||||
*/
|
||||
const calculateDefaultPosition = (): VariablesPosition => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { x: 100, y: 100 }
|
||||
}
|
||||
|
||||
const sidebarWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
|
||||
)
|
||||
const panelWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
|
||||
)
|
||||
const terminalHeight = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
|
||||
)
|
||||
|
||||
const availableWidth = window.innerWidth - sidebarWidth - panelWidth
|
||||
const availableHeight = window.innerHeight - terminalHeight
|
||||
const x = sidebarWidth + (availableWidth - DEFAULT_WIDTH) / 2
|
||||
const y = (availableHeight - DEFAULT_HEIGHT) / 2
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrain a position to the visible canvas, considering layout chrome.
|
||||
*/
|
||||
const constrainPosition = (
|
||||
position: VariablesPosition,
|
||||
width: number = DEFAULT_WIDTH,
|
||||
height: number = DEFAULT_HEIGHT
|
||||
): VariablesPosition => {
|
||||
if (typeof window === 'undefined') return position
|
||||
|
||||
const sidebarWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
|
||||
)
|
||||
const panelWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
|
||||
)
|
||||
const terminalHeight = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
|
||||
)
|
||||
|
||||
const minX = sidebarWidth
|
||||
const maxX = window.innerWidth - panelWidth - width
|
||||
const minY = 0
|
||||
const maxY = window.innerHeight - terminalHeight - height
|
||||
|
||||
return {
|
||||
x: Math.max(minX, Math.min(maxX, position.x)),
|
||||
y: Math.max(minY, Math.min(maxY, position.y)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a valid, constrained position. If the stored one is off-bounds due to
|
||||
* layout changes, prefer a fresh default center position.
|
||||
*/
|
||||
export const getVariablesPosition = (
|
||||
stored: VariablesPosition | null,
|
||||
width: number = DEFAULT_WIDTH,
|
||||
height: number = DEFAULT_HEIGHT
|
||||
): VariablesPosition => {
|
||||
if (!stored) return calculateDefaultPosition()
|
||||
const constrained = constrainPosition(stored, width, height)
|
||||
const deltaX = Math.abs(constrained.x - stored.x)
|
||||
const deltaY = Math.abs(constrained.y - stored.y)
|
||||
if (deltaX > 100 || deltaY > 100) return calculateDefaultPosition()
|
||||
return constrained
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a variable's value given its type. Returns an error message or undefined.
|
||||
*/
|
||||
function validateVariable(variable: Variable): string | undefined {
|
||||
try {
|
||||
switch (variable.type) {
|
||||
case 'number': {
|
||||
return Number.isNaN(Number(variable.value)) ? 'Not a valid number' : undefined
|
||||
}
|
||||
case 'boolean': {
|
||||
return !/^(true|false)$/i.test(String(variable.value).trim())
|
||||
? 'Expected "true" or "false"'
|
||||
: undefined
|
||||
}
|
||||
case 'object': {
|
||||
try {
|
||||
const valueToEvaluate = String(variable.value).trim()
|
||||
if (!valueToEvaluate.startsWith('{') || !valueToEvaluate.endsWith('}')) {
|
||||
return 'Not a valid object format'
|
||||
}
|
||||
// eslint-disable-next-line no-new-func
|
||||
const parsed = new Function(`return ${valueToEvaluate}`)()
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return 'Not a valid object'
|
||||
}
|
||||
return undefined
|
||||
} catch (e) {
|
||||
logger.error('Object parsing error:', e)
|
||||
return 'Invalid object syntax'
|
||||
}
|
||||
}
|
||||
case 'array': {
|
||||
try {
|
||||
const parsed = JSON.parse(String(variable.value))
|
||||
if (!Array.isArray(parsed)) {
|
||||
return 'Not a valid JSON array'
|
||||
}
|
||||
} catch {
|
||||
return 'Invalid JSON array syntax'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
} catch (e) {
|
||||
return e instanceof Error ? e.message : 'Invalid format'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate deprecated type 'string' -> 'plain'.
|
||||
*/
|
||||
function migrateStringToPlain(variable: Variable): Variable {
|
||||
if (variable.type !== 'string') return variable
|
||||
return { ...variable, type: 'plain' as const }
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating Variables modal + Variables data store.
|
||||
*/
|
||||
export const useVariablesStore = create<VariablesStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// UI
|
||||
isOpen: false,
|
||||
position: null,
|
||||
width: DEFAULT_WIDTH,
|
||||
height: DEFAULT_HEIGHT,
|
||||
|
||||
setIsOpen: (open) => set({ isOpen: open }),
|
||||
setPosition: (position) => set({ position }),
|
||||
setDimensions: (dimensions) =>
|
||||
set({
|
||||
width: Math.max(MIN_VARIABLES_WIDTH, Math.min(MAX_VARIABLES_WIDTH, dimensions.width)),
|
||||
height: Math.max(
|
||||
MIN_VARIABLES_HEIGHT,
|
||||
Math.min(MAX_VARIABLES_HEIGHT, dimensions.height)
|
||||
),
|
||||
}),
|
||||
resetPosition: () => set({ position: null }),
|
||||
|
||||
// Data
|
||||
variables: {},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
async loadForWorkflow(workflowId) {
|
||||
try {
|
||||
set({ isLoading: true, error: null })
|
||||
const res = await fetch(`/api/workflows/${workflowId}/variables`, { method: 'GET' })
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(text || `Failed to load variables: ${res.statusText}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const variables = (data?.data as Record<string, Variable>) || {}
|
||||
// Migrate any deprecated types and merge into store (remove other workflow entries)
|
||||
const migrated: Record<string, Variable> = Object.fromEntries(
|
||||
Object.entries(variables).map(([id, v]) => [id, migrateStringToPlain(v)])
|
||||
)
|
||||
set((state) => {
|
||||
const withoutThisWorkflow = Object.fromEntries(
|
||||
Object.entries(state.variables).filter(
|
||||
(entry): entry is [string, Variable] => entry[1].workflowId !== workflowId
|
||||
)
|
||||
)
|
||||
return {
|
||||
variables: { ...withoutThisWorkflow, ...migrated },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'Unknown error'
|
||||
set({ isLoading: false, error: message })
|
||||
}
|
||||
},
|
||||
|
||||
addVariable: (variable, providedId) => {
|
||||
const id = providedId || uuidv4()
|
||||
const state = get()
|
||||
|
||||
const workflowVariables = state
|
||||
.getVariablesByWorkflowId(variable.workflowId)
|
||||
.map((v) => ({ id: v.id, name: v.name }))
|
||||
|
||||
// Default naming: variableN
|
||||
if (!variable.name || /^variable\d+$/.test(variable.name)) {
|
||||
const existingNumbers = workflowVariables
|
||||
.map((v) => {
|
||||
const match = v.name.match(/^variable(\d+)$/)
|
||||
return match ? Number.parseInt(match[1]) : 0
|
||||
})
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1
|
||||
variable.name = `variable${nextNumber}`
|
||||
}
|
||||
|
||||
// Ensure uniqueness
|
||||
let uniqueName = variable.name
|
||||
let nameIndex = 1
|
||||
while (workflowVariables.some((v) => v.name === uniqueName)) {
|
||||
uniqueName = `${variable.name} (${nameIndex})`
|
||||
nameIndex++
|
||||
}
|
||||
|
||||
if (variable.type === 'string') {
|
||||
variable.type = 'plain'
|
||||
}
|
||||
|
||||
const newVariable: Variable = {
|
||||
id,
|
||||
workflowId: variable.workflowId,
|
||||
name: uniqueName,
|
||||
type: variable.type,
|
||||
value: variable.value ?? '',
|
||||
validationError: undefined,
|
||||
}
|
||||
|
||||
const validationError = validateVariable(newVariable)
|
||||
if (validationError) {
|
||||
newVariable.validationError = validationError
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
variables: {
|
||||
...state.variables,
|
||||
[id]: newVariable,
|
||||
},
|
||||
}))
|
||||
|
||||
return id
|
||||
},
|
||||
|
||||
updateVariable: (id, update) => {
|
||||
set((state) => {
|
||||
const existing = state.variables[id]
|
||||
if (!existing) return state
|
||||
|
||||
// Handle name changes: keep references in sync across workflow values
|
||||
if (update.name !== undefined) {
|
||||
const oldVariableName = existing.name
|
||||
const newName = String(update.name).trim()
|
||||
|
||||
if (!newName) {
|
||||
update = { ...update, name: undefined }
|
||||
} else if (newName !== oldVariableName) {
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
|
||||
if (activeWorkflowId) {
|
||||
const workflowValues = subBlockStore.workflowValues[activeWorkflowId] || {}
|
||||
const updatedWorkflowValues = { ...workflowValues }
|
||||
|
||||
Object.entries(workflowValues).forEach(([blockId, blockValues]) => {
|
||||
Object.entries(blockValues as Record<string, any>).forEach(
|
||||
([subBlockId, value]) => {
|
||||
const oldVarName = oldVariableName.replace(/\s+/g, '').toLowerCase()
|
||||
const newVarName = newName.replace(/\s+/g, '').toLowerCase()
|
||||
const regex = new RegExp(`<variable\\.${oldVarName}>`, 'gi')
|
||||
|
||||
updatedWorkflowValues[blockId][subBlockId] = updateReferences(
|
||||
value,
|
||||
regex,
|
||||
`<variable.${newVarName}>`
|
||||
)
|
||||
|
||||
function updateReferences(
|
||||
val: any,
|
||||
refRegex: RegExp,
|
||||
replacement: string
|
||||
): any {
|
||||
if (typeof val === 'string') {
|
||||
return refRegex.test(val) ? val.replace(refRegex, replacement) : val
|
||||
}
|
||||
if (Array.isArray(val)) {
|
||||
return val.map((item) => updateReferences(item, refRegex, replacement))
|
||||
}
|
||||
if (val !== null && typeof val === 'object') {
|
||||
const result: Record<string, any> = { ...val }
|
||||
for (const key in result) {
|
||||
result[key] = updateReferences(result[key], refRegex, replacement)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return val
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
useSubBlockStore.setState({
|
||||
workflowValues: {
|
||||
...subBlockStore.workflowValues,
|
||||
[activeWorkflowId]: updatedWorkflowValues,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deprecated -> new type migration
|
||||
if (update.type === 'string') {
|
||||
update = { ...update, type: 'plain' as VariableType }
|
||||
}
|
||||
|
||||
const updated: Variable = {
|
||||
...existing,
|
||||
...update,
|
||||
validationError: undefined,
|
||||
}
|
||||
|
||||
// Validate only when type or value changed
|
||||
if (update.type || update.value !== undefined) {
|
||||
updated.validationError = validateVariable(updated)
|
||||
}
|
||||
|
||||
return {
|
||||
variables: {
|
||||
...state.variables,
|
||||
[id]: updated,
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
deleteVariable: (id) => {
|
||||
set((state) => {
|
||||
if (!state.variables[id]) return state
|
||||
const { [id]: _deleted, ...rest } = state.variables
|
||||
return { variables: rest }
|
||||
})
|
||||
},
|
||||
|
||||
duplicateVariable: (id, providedId) => {
|
||||
const state = get()
|
||||
const existing = state.variables[id]
|
||||
if (!existing) return ''
|
||||
const newId = providedId || uuidv4()
|
||||
|
||||
const workflowVariables = state.getVariablesByWorkflowId(existing.workflowId)
|
||||
const baseName = `${existing.name} (copy)`
|
||||
let uniqueName = baseName
|
||||
let nameIndex = 1
|
||||
while (workflowVariables.some((v) => v.name === uniqueName)) {
|
||||
uniqueName = `${baseName} (${nameIndex})`
|
||||
nameIndex++
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
variables: {
|
||||
...state.variables,
|
||||
[newId]: {
|
||||
id: newId,
|
||||
workflowId: existing.workflowId,
|
||||
name: uniqueName,
|
||||
type: existing.type,
|
||||
value: existing.value,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
return newId
|
||||
},
|
||||
|
||||
getVariablesByWorkflowId: (workflowId) => {
|
||||
return Object.values(get().variables).filter((v) => v.workflowId === workflowId)
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'variables-modal-store',
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* Get default floating variables modal dimensions.
|
||||
*/
|
||||
export const getDefaultVariablesDimensions = (): VariablesDimensions => ({
|
||||
width: DEFAULT_WIDTH,
|
||||
height: DEFAULT_HEIGHT,
|
||||
})
|
||||
62
apps/sim/stores/variables/types.ts
Normal file
62
apps/sim/stores/variables/types.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Variable types supported by the variables modal/editor.
|
||||
* Note: 'string' is deprecated. Use 'plain' for freeform text values instead.
|
||||
*/
|
||||
export type VariableType = 'plain' | 'number' | 'boolean' | 'object' | 'array' | 'string'
|
||||
|
||||
/**
|
||||
* Workflow-scoped variable model.
|
||||
*/
|
||||
export interface Variable {
|
||||
id: string
|
||||
workflowId: string
|
||||
name: string
|
||||
type: VariableType
|
||||
value: unknown
|
||||
validationError?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 2D position used by the floating variables modal.
|
||||
*/
|
||||
export interface VariablesPosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Dimensions for the floating variables modal.
|
||||
*/
|
||||
export interface VariablesDimensions {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Public store interface for variables editor/modal.
|
||||
* Combines UI state of the floating modal and the variables data/actions.
|
||||
*/
|
||||
export interface VariablesStore {
|
||||
// UI State
|
||||
isOpen: boolean
|
||||
position: VariablesPosition | null
|
||||
width: number
|
||||
height: number
|
||||
setIsOpen: (open: boolean) => void
|
||||
setPosition: (position: VariablesPosition) => void
|
||||
setDimensions: (dimensions: VariablesDimensions) => void
|
||||
resetPosition: () => void
|
||||
|
||||
// Data
|
||||
variables: Record<string, Variable>
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
loadForWorkflow: (workflowId: string) => Promise<void>
|
||||
addVariable: (variable: Omit<Variable, 'id'>, providedId?: string) => string
|
||||
updateVariable: (id: string, update: Partial<Omit<Variable, 'id' | 'workflowId'>>) => void
|
||||
deleteVariable: (id: string) => void
|
||||
duplicateVariable: (id: string, providedId?: string) => string
|
||||
getVariablesByWorkflowId: (workflowId: string) => Variable[]
|
||||
}
|
||||
@@ -94,62 +94,6 @@ export default {
|
||||
padding: 'padding',
|
||||
},
|
||||
keyframes: {
|
||||
'slide-down': {
|
||||
'0%': {
|
||||
transform: 'translate(-50%, -100%)',
|
||||
opacity: '0',
|
||||
},
|
||||
'100%': {
|
||||
transform: 'translate(-50%, 0)',
|
||||
opacity: '1',
|
||||
},
|
||||
},
|
||||
'notification-slide': {
|
||||
'0%': {
|
||||
opacity: '0',
|
||||
transform: 'translateY(-100%)',
|
||||
},
|
||||
'100%': {
|
||||
opacity: '1',
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
},
|
||||
'notification-fade-out': {
|
||||
'0%': {
|
||||
opacity: '1',
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
'100%': {
|
||||
opacity: '0',
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
},
|
||||
'fade-up': {
|
||||
'0%': {
|
||||
opacity: '0',
|
||||
transform: 'translateY(10px)',
|
||||
},
|
||||
'100%': {
|
||||
opacity: '1',
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
},
|
||||
'rocket-pulse': {
|
||||
'0%, 100%': {
|
||||
opacity: '1',
|
||||
},
|
||||
'50%': {
|
||||
opacity: '0.7',
|
||||
},
|
||||
},
|
||||
'run-glow': {
|
||||
'0%, 100%': {
|
||||
filter: 'opacity(1)',
|
||||
},
|
||||
'50%': {
|
||||
filter: 'opacity(0.7)',
|
||||
},
|
||||
},
|
||||
'caret-blink': {
|
||||
'0%,70%,100%': {
|
||||
opacity: '1',
|
||||
@@ -158,30 +102,6 @@ export default {
|
||||
opacity: '0',
|
||||
},
|
||||
},
|
||||
'pulse-slow': {
|
||||
'0%, 100%': {
|
||||
opacity: '1',
|
||||
},
|
||||
'50%': {
|
||||
opacity: '0.7',
|
||||
},
|
||||
},
|
||||
'accordion-down': {
|
||||
from: {
|
||||
height: '0',
|
||||
},
|
||||
to: {
|
||||
height: 'var(--radix-accordion-content-height)',
|
||||
},
|
||||
},
|
||||
'accordion-up': {
|
||||
from: {
|
||||
height: 'var(--radix-accordion-content-height)',
|
||||
},
|
||||
to: {
|
||||
height: '0',
|
||||
},
|
||||
},
|
||||
'slide-left': {
|
||||
'0%': {
|
||||
transform: 'translateX(0)',
|
||||
@@ -225,40 +145,6 @@ export default {
|
||||
transform: 'translateX(100%)',
|
||||
},
|
||||
},
|
||||
orbit: {
|
||||
'0%': {
|
||||
transform:
|
||||
'rotate(calc(var(--angle) * 1deg)) translateY(calc(var(--radius) * 1px)) rotate(calc(var(--angle) * -1deg))',
|
||||
},
|
||||
'100%': {
|
||||
transform:
|
||||
'rotate(calc(var(--angle) * 1deg + 360deg)) translateY(calc(var(--radius) * 1px)) rotate(calc((var(--angle) * -1deg) - 360deg))',
|
||||
},
|
||||
},
|
||||
marquee: {
|
||||
from: {
|
||||
transform: 'translateX(0)',
|
||||
},
|
||||
to: {
|
||||
transform: 'translateX(calc(-100% - var(--gap)))',
|
||||
},
|
||||
},
|
||||
'marquee-vertical': {
|
||||
from: {
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
to: {
|
||||
transform: 'translateY(calc(-100% - var(--gap)))',
|
||||
},
|
||||
},
|
||||
'fade-in': {
|
||||
from: {
|
||||
opacity: '0',
|
||||
},
|
||||
to: {
|
||||
opacity: '1',
|
||||
},
|
||||
},
|
||||
'placeholder-pulse': {
|
||||
'0%, 100%': {
|
||||
opacity: '0.5',
|
||||
@@ -269,25 +155,12 @@ export default {
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'slide-down': 'slide-down 0.3s ease-out',
|
||||
'notification-slide': 'notification-slide 0.3s ease-out forwards',
|
||||
'notification-fade-out': 'notification-fade-out 0.2s ease-out forwards',
|
||||
'fade-up': 'fade-up 0.5s ease-out forwards',
|
||||
'rocket-pulse': 'rocket-pulse 1.5s ease-in-out infinite',
|
||||
'run-glow': 'run-glow 2s ease-in-out infinite',
|
||||
'caret-blink': 'caret-blink 1.25s ease-out infinite',
|
||||
'pulse-slow': 'pulse-slow 3s ease-in-out infinite',
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
'slide-left': 'slide-left 80s linear infinite',
|
||||
'slide-right': 'slide-right 80s linear infinite',
|
||||
'dash-animation': 'dash-animation 1.5s linear infinite',
|
||||
'pulse-ring': 'pulse-ring 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'code-shimmer': 'code-shimmer 1.5s infinite',
|
||||
orbit: 'orbit calc(var(--duration, 2) * 1s) linear infinite',
|
||||
marquee: 'marquee var(--duration) infinite linear',
|
||||
'marquee-vertical': 'marquee-vertical var(--duration) linear infinite',
|
||||
'fade-in': 'fade-in 0.3s ease-in-out forwards',
|
||||
'placeholder-pulse': 'placeholder-pulse 1.5s ease-in-out infinite',
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user