chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1 @@
export { PreviewContextMenu } from './preview-context-menu'
@@ -0,0 +1,91 @@
'use client'
import type { RefObject } from 'react'
import { Popover, PopoverAnchor, PopoverContent, PopoverDivider, PopoverItem } from '@sim/emcn'
import { createPortal } from 'react-dom'
interface PreviewContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
menuRef: RefObject<HTMLDivElement | null>
onClose: () => void
onCopy: () => void
onSearch?: () => void
wrapText?: boolean
onToggleWrap?: () => void
/** When true, only shows Copy option (for subblock values) */
copyOnly?: boolean
}
/**
* Context menu for preview editor sidebar.
* Provides copy, search, and display options.
* Uses createPortal to render outside any transformed containers (like modals).
*/
export function PreviewContextMenu({
isOpen,
position,
menuRef,
onClose,
onCopy,
onSearch,
wrapText,
onToggleWrap,
copyOnly = false,
}: PreviewContextMenuProps) {
if (typeof document === 'undefined') return null
return createPortal(
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
<PopoverItem
onClick={() => {
onCopy()
onClose()
}}
>
Copy
</PopoverItem>
{!copyOnly && onSearch && (
<>
<PopoverDivider />
<PopoverItem
onClick={() => {
onSearch()
onClose()
}}
>
Search
</PopoverItem>
</>
)}
{!copyOnly && onToggleWrap && (
<>
<PopoverDivider />
<PopoverItem showCheck={wrapText} onClick={onToggleWrap}>
Wrap Text
</PopoverItem>
</>
)}
</PopoverContent>
</Popover>,
document.body
)
}
@@ -0,0 +1 @@
export { PreviewEditor } from './preview-editor'
@@ -0,0 +1,592 @@
'use client'
import { type CSSProperties, memo, useMemo } from 'react'
import { HANDLE_POSITIONS } from '@sim/workflow-renderer'
import { Handle, type NodeProps, Position } from 'reactflow'
import {
getDisplayValue,
resolveDropdownLabel,
resolveSkillsLabel,
resolveToolsLabel,
resolveVariablesLabel,
resolveWorkflowMultiSelectLabel,
resolveWorkflowSelectionLabel,
} from '@/lib/workflows/subblocks/display'
import {
buildCanonicalIndex,
evaluateSubBlockCondition,
isSubBlockFeatureEnabled,
isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
import { useVariablesStore } from '@/stores/variables/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
/** Execution status for blocks in preview mode */
type ExecutionStatus = 'success' | 'error' | 'not-executed'
/** Subblock value structure matching workflow state */
interface SubBlockValueEntry {
value: unknown
}
/**
* Handle style constants for preview blocks.
* Extracted to avoid recreating style objects on each render.
*/
const HANDLE_STYLES = {
horizontal: '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-xs',
vertical: '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-xs',
right:
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none',
error:
'!z-[10] !border-none !bg-[var(--text-error)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none',
} as const
/** Reusable style object for error handles positioned at bottom-right */
const ERROR_HANDLE_STYLE: CSSProperties = {
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}
interface WorkflowPreviewBlockData {
type: string
name: string
workflowMap?: Record<string, WorkflowMetadata>
workflowLabelsReady?: boolean
isTrigger?: boolean
horizontalHandles?: boolean
enabled?: boolean
/** Whether this block is selected in preview mode */
isPreviewSelected?: boolean
/** Execution status for highlighting error/success states */
executionStatus?: ExecutionStatus
/** Subblock values from the workflow state */
subBlockValues?: Record<string, SubBlockValueEntry | unknown>
/** Skips expensive subblock computations for thumbnails/template previews */
lightweight?: boolean
}
/**
* Extracts the raw value from a subblock value entry.
* Handles both wrapped ({ value: ... }) and unwrapped formats.
*/
function extractValue(entry: SubBlockValueEntry | unknown): unknown {
if (entry && typeof entry === 'object' && 'value' in entry) {
return (entry as SubBlockValueEntry).value
}
return entry
}
interface SubBlockRowProps {
title: string
value?: string
subBlock?: SubBlockConfig
rawValue?: unknown
workflowMap: Record<string, WorkflowMetadata>
workflowLabelsReady: boolean
}
/**
* Renders a single subblock row with title and optional value.
* Matches the SubBlockRow component in WorkflowBlock.
* - Masks password fields with bullets
* - Resolves dropdown/combobox labels
* - Resolves workflow names from registry
* - Resolves variable names from store
* - Resolves tool and skill names (registry + stored names; no API access)
* - Shows '-' for other selector types that need hydration
*/
const SubBlockRow = memo(function SubBlockRow({
title,
value,
subBlock,
rawValue,
workflowMap,
workflowLabelsReady,
}: SubBlockRowProps) {
const isPasswordField = subBlock?.password === true
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
const workflowLookup = { workflowMap, ready: workflowLabelsReady }
const dropdownLabel = resolveDropdownLabel(subBlock, rawValue)
// Materialize the variables store only for variables-input rows.
const variablesDisplay =
subBlock?.type === 'variables-input'
? resolveVariablesLabel(
subBlock,
rawValue,
Object.values(useVariablesStore.getState().variables)
)
: null
// The preview is hook-free, so custom tools referenced only by id resolve
// through their inline schema/registry fallbacks rather than the API.
const toolsDisplay = resolveToolsLabel(subBlock, rawValue, [])
const skillsDisplay = resolveSkillsLabel(subBlock, rawValue, [])
const workflowName = resolveWorkflowSelectionLabel(subBlock, rawValue, workflowLookup)
const workflowMultiSelectionNames = resolveWorkflowMultiSelectLabel(
subBlock,
rawValue,
workflowLookup
)
const isSelectorType = subBlock?.type && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlock.type)
const hydratedName =
dropdownLabel ||
variablesDisplay ||
toolsDisplay ||
skillsDisplay ||
workflowName ||
workflowMultiSelectionNames
const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
return (
<div className='flex items-center gap-2'>
<span
className='min-w-0 truncate text-[var(--text-tertiary)] text-sm capitalize'
title={title}
>
{title}
</span>
{displayValue !== undefined && (
<span
className='flex-1 truncate text-right text-[var(--text-primary)] text-sm'
title={displayValue}
>
{displayValue}
</span>
)}
</div>
)
})
/**
* Preview block component for workflow visualization.
* Renders block header, subblock values, and handles without
* hooks, store subscriptions, or interactive features.
* Matches the visual structure of WorkflowBlock exactly.
*/
function WorkflowPreviewBlockInner({ data }: NodeProps<WorkflowPreviewBlockData>) {
const {
type,
name,
workflowMap = {},
workflowLabelsReady = false,
isTrigger = false,
horizontalHandles = false,
enabled = true,
isPreviewSelected = false,
executionStatus,
subBlockValues,
lightweight = false,
} = data
const blockConfig = getBlock(type)
const canonicalIndex = useMemo(
() => buildCanonicalIndex(blockConfig?.subBlocks || []),
[blockConfig?.subBlocks]
)
const rawValues = useMemo(() => {
if (lightweight || !subBlockValues) return {}
return Object.entries(subBlockValues).reduce<Record<string, unknown>>((acc, [key, entry]) => {
acc[key] = extractValue(entry)
return acc
}, {})
}, [subBlockValues, lightweight])
const visibleSubBlocks = useMemo(() => {
if (!blockConfig?.subBlocks) return []
const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers'
const effectiveTrigger = isTrigger || type === 'starter'
return blockConfig.subBlocks.filter((subBlock) => {
if (subBlock.hidden) return false
if (subBlock.hideFromPreview) return false
if (!isSubBlockFeatureEnabled(subBlock)) return false
if (effectiveTrigger) {
const isValidTriggerSubblock = isPureTriggerBlock
? subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced' || !subBlock.mode
: subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced'
if (!isValidTriggerSubblock) return false
} else {
if (subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') return false
}
/** Skip value-dependent visibility checks in lightweight mode */
if (lightweight) return !subBlock.condition
if (!isSubBlockVisibleForMode(subBlock, false, canonicalIndex, rawValues, undefined)) {
return false
}
if (!subBlock.condition) return true
return evaluateSubBlockCondition(subBlock.condition, rawValues)
})
}, [
lightweight,
blockConfig?.subBlocks,
blockConfig?.triggers?.enabled,
blockConfig?.category,
type,
isTrigger,
canonicalIndex,
rawValues,
])
/**
* Compute condition rows for condition blocks.
* In lightweight mode, returns default structure without parsing values.
*/
const conditionRows = useMemo(() => {
if (type !== 'condition') return []
/** Default structure for lightweight mode or when no values */
const defaultRows = [
{ id: 'if', title: 'if', value: '' },
{ id: 'else', title: 'else', value: '' },
]
if (lightweight) return defaultRows
const conditionsValue = rawValues.conditions
const raw = typeof conditionsValue === 'string' ? conditionsValue : undefined
try {
if (raw) {
const parsed = JSON.parse(raw) as unknown
if (Array.isArray(parsed)) {
return parsed.map((item: unknown, index: number) => {
const conditionItem = item as { id?: string; value?: unknown }
const title = index === 0 ? 'if' : index === parsed.length - 1 ? 'else' : 'else if'
return {
id: conditionItem?.id ?? `cond-${index}`,
title,
value: typeof conditionItem?.value === 'string' ? conditionItem.value : '',
}
})
}
}
} catch {
/* empty */
}
return defaultRows
}, [type, rawValues, lightweight])
/**
* Compute router rows for router_v2 blocks.
* In lightweight mode, returns default structure without parsing values.
*/
const routerRows = useMemo(() => {
if (type !== 'router_v2') return []
/** Default structure for lightweight mode or when no values */
const defaultRows = [{ id: 'route1', value: '' }]
if (lightweight) return defaultRows
const routesValue = rawValues.routes
const raw = typeof routesValue === 'string' ? routesValue : undefined
try {
if (raw) {
const parsed = JSON.parse(raw) as unknown
if (Array.isArray(parsed)) {
return parsed.map((item: unknown, index: number) => {
const routeItem = item as { id?: string; value?: string }
return {
id: routeItem?.id ?? `route${index + 1}`,
value: routeItem?.value ?? '',
}
})
}
}
} catch {
/* empty */
}
return defaultRows
}, [type, rawValues, lightweight])
if (!blockConfig) {
return null
}
const IconComponent = blockConfig.icon
const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger
const isNoteBlock = type === 'note'
const shouldShowDefaultHandles = !isStarterOrTrigger && !isNoteBlock
const hasSubBlocks = visibleSubBlocks.length > 0
const hasContentBelowHeader =
type === 'condition'
? conditionRows.length > 0 || shouldShowDefaultHandles
: type === 'router_v2'
? routerRows.length > 0 || shouldShowDefaultHandles
: hasSubBlocks || shouldShowDefaultHandles
const hasError = executionStatus === 'error'
const hasSuccess = executionStatus === 'success'
return (
<div className='relative w-[250px] select-none rounded-lg border border-[var(--border-1)] bg-[var(--surface-2)]'>
{/* Selection ring overlay (takes priority over execution rings) */}
{isPreviewSelected && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-lg ring-[1.75px] ring-[var(--brand-secondary)]' />
)}
{/* Success ring overlay (only shown if not selected) */}
{!isPreviewSelected && hasSuccess && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-lg ring-[1.75px] ring-[var(--brand-accent)]' />
)}
{/* Error ring overlay (only shown if not selected) */}
{!isPreviewSelected && hasError && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-lg ring-[1.75px] ring-[var(--text-error)]' />
)}
{/* Target handle - not shown for triggers/starters */}
{shouldShowDefaultHandles && (
<Handle
type='target'
position={horizontalHandles ? Position.Left : Position.Top}
id='target'
className={horizontalHandles ? HANDLE_STYLES.horizontal : HANDLE_STYLES.vertical}
style={
horizontalHandles
? { left: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
: { top: '-7px', left: '50%', transform: 'translateX(-50%)' }
}
/>
)}
{/* Header - matches WorkflowBlock structure */}
<div
className={`flex items-center justify-between p-2 ${hasContentBelowHeader ? 'border-[var(--border-1)] border-b' : ''}`}
>
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-2.5'>
{!isNoteBlock && (
<div
className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-md'
style={{ background: enabled ? blockConfig.bgColor : 'gray' }}
>
<IconComponent
className={`size-[16px] ${enabled ? getTileIconColorClass(blockConfig.bgColor) : 'text-[var(--text-icon)]'}`}
/>
</div>
)}
<span
className={`truncate font-medium text-md ${!enabled ? 'text-[var(--text-muted)]' : ''}`}
title={name}
>
{name}
</span>
</div>
</div>
{/* Content area with subblocks */}
{hasContentBelowHeader && (
<div className='flex flex-col gap-2 p-2'>
{type === 'condition' ? (
conditionRows.map((cond) => (
<SubBlockRow
key={cond.id}
title={cond.title}
value={lightweight ? undefined : getDisplayValue(cond.value)}
workflowMap={workflowMap}
workflowLabelsReady={workflowLabelsReady}
/>
))
) : type === 'router_v2' ? (
<>
<SubBlockRow
key='context'
title='Context'
value={lightweight ? undefined : getDisplayValue(rawValues.context)}
workflowMap={workflowMap}
workflowLabelsReady={workflowLabelsReady}
/>
{routerRows.map((route, index) => (
<SubBlockRow
key={route.id}
title={`Route ${index + 1}`}
value={lightweight ? undefined : getDisplayValue(route.value)}
workflowMap={workflowMap}
workflowLabelsReady={workflowLabelsReady}
/>
))}
</>
) : (
visibleSubBlocks.map((subBlock) => {
const rawValue = lightweight ? undefined : rawValues[subBlock.id]
return (
<SubBlockRow
key={subBlock.id}
title={subBlock.title ?? subBlock.id}
value={lightweight ? undefined : getDisplayValue(rawValue)}
subBlock={lightweight ? undefined : subBlock}
rawValue={rawValue}
workflowMap={workflowMap}
workflowLabelsReady={workflowLabelsReady}
/>
)
})
)}
{/* Error row for non-trigger blocks */}
{shouldShowDefaultHandles && (
<SubBlockRow
title='error'
workflowMap={workflowMap}
workflowLabelsReady={workflowLabelsReady}
/>
)}
</div>
)}
{/* Condition block handles */}
{type === 'condition' && (
<>
{conditionRows.map((cond, condIndex) => {
const topOffset =
HANDLE_POSITIONS.CONDITION_START_Y + condIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
return (
<Handle
key={`handle-${cond.id}`}
type='source'
position={Position.Right}
id={`condition-${cond.id}`}
className={HANDLE_STYLES.right}
style={{ top: `${topOffset}px`, right: '-7px', transform: 'translateY(-50%)' }}
/>
)
})}
<Handle
type='source'
position={Position.Right}
id='error'
className={HANDLE_STYLES.error}
style={ERROR_HANDLE_STYLE}
/>
</>
)}
{/* Router block handles */}
{type === 'router_v2' && (
<>
{routerRows.map((route, routeIndex) => {
const topOffset =
HANDLE_POSITIONS.CONDITION_START_Y +
(routeIndex + 1) * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
return (
<Handle
key={`handle-${route.id}`}
type='source'
position={Position.Right}
id={`router-${route.id}`}
className={HANDLE_STYLES.right}
style={{ top: `${topOffset}px`, right: '-7px', transform: 'translateY(-50%)' }}
/>
)
})}
<Handle
type='source'
position={Position.Right}
id='error'
className={HANDLE_STYLES.error}
style={ERROR_HANDLE_STYLE}
/>
</>
)}
{/* Source and error handles for non-condition/router/note blocks */}
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && !isNoteBlock && (
<>
<Handle
type='source'
position={horizontalHandles ? Position.Right : Position.Bottom}
id='source'
className={horizontalHandles ? HANDLE_STYLES.right : HANDLE_STYLES.vertical}
style={
horizontalHandles
? { right: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
: { bottom: '-7px', left: '50%', transform: 'translateX(-50%)' }
}
/>
{shouldShowDefaultHandles && (
<Handle
type='source'
position={Position.Right}
id='error'
className={HANDLE_STYLES.error}
style={ERROR_HANDLE_STYLE}
/>
)}
</>
)}
</div>
)
}
/**
* Custom comparison function for React.memo optimization.
* Uses fast-path primitive comparison before shallow comparing subBlockValues.
* @param prevProps - Previous render props
* @param nextProps - Next render props
* @returns True if render should be skipped (props are equal)
*/
function shouldSkipPreviewBlockRender(
prevProps: NodeProps<WorkflowPreviewBlockData>,
nextProps: NodeProps<WorkflowPreviewBlockData>
): boolean {
if (
prevProps.id !== nextProps.id ||
prevProps.data.type !== nextProps.data.type ||
prevProps.data.name !== nextProps.data.name ||
prevProps.data.isTrigger !== nextProps.data.isTrigger ||
prevProps.data.horizontalHandles !== nextProps.data.horizontalHandles ||
prevProps.data.enabled !== nextProps.data.enabled ||
prevProps.data.isPreviewSelected !== nextProps.data.isPreviewSelected ||
prevProps.data.executionStatus !== nextProps.data.executionStatus ||
prevProps.data.lightweight !== nextProps.data.lightweight
) {
return false
}
/** Skip subBlockValues comparison in lightweight mode */
if (nextProps.data.lightweight) return true
const prevValues = prevProps.data.subBlockValues
const nextValues = nextProps.data.subBlockValues
if (prevValues === nextValues) return true
if (!prevValues || !nextValues) return false
const prevKeys = Object.keys(prevValues)
const nextKeys = Object.keys(nextValues)
if (prevKeys.length !== nextKeys.length) return false
for (const key of prevKeys) {
if (prevValues[key] !== nextValues[key]) return false
}
return true
}
/**
* Preview block component for workflow visualization in readonly contexts.
* Optimized for rendering without hooks or store subscriptions.
*
* @remarks
* - Renders block header, subblock values, and connection handles
* - Supports condition, router, and standard block types
* - Shows error handles for non-trigger blocks
* - Displays execution status via colored ring overlays
*/
export const PreviewBlock = memo(WorkflowPreviewBlockInner, shouldSkipPreviewBlockRender)
@@ -0,0 +1 @@
export { PreviewBlock } from './block'
@@ -0,0 +1 @@
export { PreviewSubflow } from './subflow'
@@ -0,0 +1,152 @@
'use client'
import { memo } from 'react'
import { Badge, cn } from '@sim/emcn'
import { HANDLE_POSITIONS } from '@sim/workflow-renderer'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Handle, type NodeProps, Position } from 'reactflow'
import { getTileIconColorClass } from '@/blocks/icon-color'
/** Execution status for subflows in preview mode */
type ExecutionStatus = 'success' | 'error' | 'not-executed'
interface WorkflowPreviewSubflowData {
name: string
width?: number
height?: number
kind: 'loop' | 'parallel'
/** Whether this subflow is enabled */
enabled?: boolean
/** Whether this subflow is selected in preview mode */
isPreviewSelected?: boolean
/** Execution status for highlighting the subflow container */
executionStatus?: ExecutionStatus
/** Skips expensive computations for thumbnails/template previews (unused in subflow, for consistency) */
lightweight?: boolean
}
/**
* Preview subflow component for workflow visualization.
* Renders loop/parallel containers without hooks, store subscriptions,
* or interactive features.
*/
function WorkflowPreviewSubflowInner({ data }: NodeProps<WorkflowPreviewSubflowData>) {
const {
name,
width = 500,
height = 300,
kind,
enabled = true,
isPreviewSelected = false,
executionStatus,
} = data
const isLoop = kind === 'loop'
const BlockIcon = isLoop ? RepeatIcon : SplitIcon
const blockIconBg = isLoop ? '#2FB3FF' : '#FEE12B'
const blockName = name || (isLoop ? 'Loop' : 'Parallel')
const startHandleId = isLoop ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = isLoop ? 'loop-end-source' : 'parallel-end-source'
const leftHandleClass =
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none'
const rightHandleClass =
'!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none'
const hasError = executionStatus === 'error'
const hasSuccess = executionStatus === 'success'
return (
<div
className='relative select-none rounded-lg border border-[var(--border-1)]'
style={{
width,
height,
}}
>
{/* Selection ring overlay (takes priority over execution rings) */}
{isPreviewSelected && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-lg ring-[1.75px] ring-[var(--brand-secondary)]' />
)}
{/* Success ring overlay (only shown if not selected) */}
{!isPreviewSelected && hasSuccess && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-lg ring-[1.75px] ring-[var(--brand-accent)]' />
)}
{/* Error ring overlay (only shown if not selected) */}
{!isPreviewSelected && hasError && (
<div className='pointer-events-none absolute inset-0 z-40 rounded-lg ring-[1.75px] ring-[var(--text-error)]' />
)}
{/* Target handle on left (input to the subflow) */}
<Handle
type='target'
position={Position.Left}
id='target'
className={leftHandleClass}
style={{
left: '-8px',
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
transform: 'translateY(-50%)',
}}
/>
{/* Header - matches actual subflow header structure */}
<div className='flex items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-2 pr-3 pl-2'>
<div className='flex min-w-0 flex-1 items-center gap-2.5'>
<div
className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-md'
style={{ backgroundColor: enabled ? blockIconBg : 'var(--surface-4)' }}
>
<BlockIcon
className={cn(
'size-[16px]',
enabled ? getTileIconColorClass(blockIconBg) : 'text-[var(--text-icon)]'
)}
/>
</div>
<span
className={cn('truncate font-medium text-md', !enabled && 'text-[var(--text-muted)]')}
title={blockName}
>
{blockName}
</span>
</div>
{!enabled && <Badge variant='gray-secondary'>disabled</Badge>}
</div>
{/* Content area - matches workflow structure */}
<div
className='h-[calc(100%-50px)] pt-4 pr-[80px] pb-4 pl-4'
style={{ position: 'relative' }}
>
{/* Subflow Start - connects to first block in subflow */}
<div className='absolute top-4 left-[16px] flex items-center justify-center rounded-lg border border-[var(--border-1)] bg-[var(--surface-2)] px-3 py-1.5'>
<span className='font-medium text-[var(--text-primary)] text-sm'>Start</span>
<Handle
type='source'
position={Position.Right}
id={startHandleId}
className={rightHandleClass}
style={{ right: '-8px', top: '50%', transform: 'translateY(-50%)' }}
/>
</div>
</div>
{/* End source handle on right (output from the subflow) */}
<Handle
type='source'
position={Position.Right}
id={endHandleId}
className={rightHandleClass}
style={{
right: '-8px',
top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
transform: 'translateY(-50%)',
}}
/>
</div>
)
}
export const PreviewSubflow = memo(WorkflowPreviewSubflowInner)
@@ -0,0 +1 @@
export { getLeftmostBlockId, PreviewWorkflow } from './preview-workflow'
@@ -0,0 +1,655 @@
'use client'
import { useEffect, useMemo, useRef } from 'react'
import { useParams } from 'next/navigation'
import ReactFlow, {
ConnectionLineType,
type Edge,
type EdgeTypes,
type Node,
type NodeTypes,
ReactFlowProvider,
useReactFlow,
} from 'reactflow'
import 'reactflow/dist/style.css'
import { cn } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { PreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block'
import { PreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('PreviewWorkflow')
/** Gets block dimensions, using stored values or defaults. */
function getPreviewBlockDimensions(block: BlockState): { width: number; height: number } {
if (block.type === 'loop' || block.type === 'parallel') {
return {
width: block.data?.width
? Math.max(block.data.width, CONTAINER_DIMENSIONS.MIN_WIDTH)
: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: block.data?.height
? Math.max(block.data.height, CONTAINER_DIMENSIONS.MIN_HEIGHT)
: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
}
if (block.height) {
return {
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT),
}
}
return estimateBlockDimensions(block.type)
}
/** Calculates container dimensions from child block positions. */
function calculateContainerDimensions(
containerId: string,
blocks: Record<string, BlockState>
): { width: number; height: number } {
const childBlocks = Object.values(blocks).filter((block) => block?.data?.parentId === containerId)
if (childBlocks.length === 0) {
return {
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
}
}
let maxRight = 0
let maxBottom = 0
for (const child of childBlocks) {
if (!child?.position) continue
const { width: childWidth, height: childHeight } = getPreviewBlockDimensions(child)
maxRight = Math.max(maxRight, child.position.x + childWidth)
maxBottom = Math.max(maxBottom, child.position.y + childHeight)
}
const width = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
)
const height = Math.max(
CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING
)
return { width, height }
}
/** Finds the leftmost block ID, excluding subflow containers. */
export function getLeftmostBlockId(workflowState: WorkflowState | null | undefined): string | null {
if (!workflowState?.blocks) return null
let leftmostId: string | null = null
let minX = Number.POSITIVE_INFINITY
for (const [blockId, block] of Object.entries(workflowState.blocks)) {
if (!block || block.type === 'loop' || block.type === 'parallel') continue
const x = block.position?.x ?? Number.POSITIVE_INFINITY
if (x < minX) {
minX = x
leftmostId = blockId
}
}
return leftmostId
}
/** Execution status for edges/nodes in the preview */
type ExecutionStatus = 'success' | 'error' | 'not-executed'
/** Calculates nesting depth, handling nested subflows. */
function calculateNestingDepth(
block: BlockState,
blocks: Record<string, BlockState>,
visited: Set<string> = new Set()
): number {
const parentId = block.data?.parentId
if (!parentId) return 0
if (visited.has(parentId)) return 0
const parentBlock = blocks[parentId]
if (!parentBlock) {
logger.warn('Parent block not found for child block')
return 0
}
visited.add(parentId)
return 1 + calculateNestingDepth(parentBlock, blocks, visited)
}
interface PreviewWorkflowProps {
workflowState: WorkflowState
workspaceId?: string
className?: string
height?: string | number
width?: string | number
isPannable?: boolean
defaultPosition?: { x: number; y: number }
defaultZoom?: number
fitPadding?: number
onNodeClick?: (blockId: string, mousePosition: { x: number; y: number }) => void
/** Callback when a node is right-clicked */
onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
/** Callback when the canvas (empty area) is clicked */
onPaneClick?: () => void
/** Cursor style to show when hovering the canvas */
cursorStyle?: 'default' | 'pointer' | 'grab'
/** Map of executed block IDs to their status for highlighting the execution path */
executedBlocks?: Record<string, { status: string; output?: unknown }>
/** Currently selected block ID for highlighting */
selectedBlockId?: string | null
/** Skips expensive subblock computations for thumbnails/template previews */
lightweight?: boolean
}
/** Preview node types using minimal, hook-free components. */
const previewNodeTypes: NodeTypes = {
workflowBlock: PreviewBlock,
noteBlock: PreviewBlock,
subflowNode: PreviewSubflow,
}
const edgeTypes: EdgeTypes = {
default: WorkflowEdge,
workflowEdge: WorkflowEdge,
}
interface FitViewOnChangeProps {
nodeIds: string
fitPadding: number
containerRef: React.RefObject<HTMLDivElement | null>
}
/** Calls fitView on node changes or container resize. */
function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeProps) {
const { fitView } = useReactFlow()
const lastNodeIdsRef = useRef<string | null>(null)
useEffect(() => {
if (!nodeIds.length) return
const shouldFit = lastNodeIdsRef.current !== nodeIds
if (!shouldFit) return
lastNodeIdsRef.current = nodeIds
const timeoutId = setTimeout(() => {
fitView({ padding: fitPadding, duration: 200 })
}, 50)
return () => clearTimeout(timeoutId)
}, [nodeIds, fitPadding, fitView])
useEffect(() => {
const container = containerRef.current
if (!container) return
let timeoutId: ReturnType<typeof setTimeout> | null = null
const resizeObserver = new ResizeObserver(() => {
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
fitView({ padding: fitPadding, duration: 150 })
}, 100)
})
resizeObserver.observe(container)
return () => {
if (timeoutId) clearTimeout(timeoutId)
resizeObserver.disconnect()
}
}, [containerRef, fitPadding, fitView])
return null
}
/** Readonly workflow visualization with execution status highlighting. */
export function PreviewWorkflow({
workflowState,
workspaceId: propWorkspaceId,
className,
height = '100%',
width = '100%',
isPannable = true,
defaultPosition,
defaultZoom = 0.8,
fitPadding = 0.25,
onNodeClick,
onNodeContextMenu,
onPaneClick,
cursorStyle = 'grab',
executedBlocks,
selectedBlockId,
lightweight = false,
}: PreviewWorkflowProps) {
const params = useParams<{ workspaceId: string }>()
const workspaceId = propWorkspaceId ?? params.workspaceId
const {
data: workflowMap = {},
isSuccess: isWorkflowMapLoaded,
isPlaceholderData: isWorkflowMapPlaceholderData,
} = useWorkflowMap(workspaceId)
// Ready only on a successful, non-placeholder load — an errored or stale
// placeholder map must not mislabel valid workflows as deleted.
const workflowLabelsReady = isWorkflowMapLoaded && !isWorkflowMapPlaceholderData
const containerRef = useRef<HTMLDivElement>(null)
const nodeTypes = previewNodeTypes
const isValidWorkflowState = workflowState?.blocks && workflowState.edges
const blocksStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.blocks || {}).length,
ids: Object.keys(workflowState.blocks || {}).join(','),
}
}, [workflowState.blocks, isValidWorkflowState])
const loopsStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.loops || {}).length,
ids: Object.keys(workflowState.loops || {}).join(','),
}
}, [workflowState.loops, isValidWorkflowState])
const parallelsStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: Object.keys(workflowState.parallels || {}).length,
ids: Object.keys(workflowState.parallels || {}).join(','),
}
}, [workflowState.parallels, isValidWorkflowState])
/** Map of subflow ID to child block IDs */
const subflowChildrenMap = useMemo(() => {
if (!isValidWorkflowState) return new Map<string, string[]>()
const map = new Map<string, string[]>()
for (const [blockId, block] of Object.entries(workflowState.blocks || {})) {
const parentId = block?.data?.parentId
if (parentId) {
const children = map.get(parentId) || []
children.push(blockId)
map.set(parentId, children)
}
}
return map
}, [workflowState.blocks, isValidWorkflowState])
/** Maps base block IDs to execution data, handling parallel iteration variants (blockId₍n₎). */
const blockExecutionMap = useMemo(() => {
if (!executedBlocks) return new Map<string, { status: string; output?: unknown }>()
const map = new Map<string, { status: string; output?: unknown }>()
for (const [key, value] of Object.entries(executedBlocks)) {
// Extract base ID (remove iteration suffix like ₍0₎)
const baseId = key.includes('₍') ? key.split('₍')[0] : key
// Keep first match or error status (error takes precedence)
const existing = map.get(baseId)
if (!existing || value.status === 'error') {
map.set(baseId, value)
}
}
return map
}, [executedBlocks])
/** Derives subflow status from children. Recursively checks nested subflows. Error takes precedence. */
const getSubflowExecutionStatus = useMemo(() => {
const derive = (
subflowId: string,
visited: Set<string> = new Set()
): ExecutionStatus | undefined => {
if (visited.has(subflowId)) return undefined
visited.add(subflowId)
const childIds = subflowChildrenMap.get(subflowId)
if (!childIds?.length) return undefined
const childStatuses: string[] = []
for (const childId of childIds) {
const direct = blockExecutionMap.get(childId)
if (direct) {
childStatuses.push(direct.status)
} else {
const childBlock = workflowState.blocks?.[childId]
if (childBlock?.type === 'loop' || childBlock?.type === 'parallel') {
const nested = derive(childId, visited)
if (nested) childStatuses.push(nested)
}
}
}
if (childStatuses.length === 0) return undefined
if (childStatuses.some((s) => s === 'error')) return 'error'
if (childStatuses.every((s) => s === 'success')) return 'success'
return undefined
}
return derive
}, [subflowChildrenMap, blockExecutionMap, workflowState.blocks])
/** Gets block status. Subflows derive status from children. */
const getBlockExecutionStatus = useMemo(() => {
return (blockId: string): { status: string; executed: boolean } | undefined => {
const directStatus = blockExecutionMap.get(blockId)
if (directStatus) {
return { status: directStatus.status, executed: true }
}
const block = workflowState.blocks?.[blockId]
if (block?.type === 'loop' || block?.type === 'parallel') {
const subflowStatus = getSubflowExecutionStatus(blockId)
if (subflowStatus) {
return { status: subflowStatus, executed: true }
}
}
return undefined
}
}, [workflowState.blocks, getSubflowExecutionStatus, blockExecutionMap])
const edgesStructure = useMemo(() => {
if (!isValidWorkflowState) return { count: 0, ids: '' }
return {
count: workflowState.edges?.length || 0,
ids: workflowState.edges?.map((e) => e.id).join(',') || '',
}
}, [workflowState.edges, isValidWorkflowState])
const nodes: Node[] = useMemo(() => {
if (!isValidWorkflowState) return []
const nodeArray: Node[] = []
const sortedBlocks = Object.entries(workflowState.blocks || {}).sort(
([, left], [, right]) =>
calculateNestingDepth(left, workflowState.blocks) -
calculateNestingDepth(right, workflowState.blocks)
)
sortedBlocks.forEach(([blockId, block]) => {
if (!block || !block.type) {
logger.warn(`Skipping invalid block: ${blockId}`)
return
}
const parentId = block.data?.parentId
const nestingDepth = calculateNestingDepth(block, workflowState.blocks)
if (block.type === 'loop' || block.type === 'parallel') {
const isSelected = selectedBlockId === blockId
const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
// Check for direct error on the subflow block itself (e.g., loop resolution errors)
// before falling back to children-derived status
const directExecution = blockExecutionMap.get(blockId)
const subflowExecutionStatus: ExecutionStatus | undefined =
directExecution?.status === 'error'
? 'error'
: (getSubflowExecutionStatus(blockId) ??
(directExecution ? (directExecution.status as ExecutionStatus) : undefined))
nodeArray.push({
id: blockId,
type: 'subflowNode',
position: block.position,
parentId,
extent: block.data?.extent || undefined,
draggable: false,
zIndex: nestingDepth,
className: parentId ? 'nested-subflow-node' : undefined,
data: {
...block.data,
name: block.name,
width: dimensions.width,
height: dimensions.height,
kind: block.type as 'loop' | 'parallel',
enabled: block.enabled ?? true,
isPreviewSelected: isSelected,
executionStatus: subflowExecutionStatus,
lightweight,
},
})
return
}
const isSelected = selectedBlockId === blockId
let executionStatus: ExecutionStatus | undefined
if (executedBlocks) {
const blockExecution = executedBlocks[blockId]
if (blockExecution) {
if (blockExecution.status === 'error') {
executionStatus = 'error'
} else if (blockExecution.status === 'success') {
executionStatus = 'success'
} else {
executionStatus = 'not-executed'
}
} else {
executionStatus = 'not-executed'
}
}
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
nodeArray.push({
id: blockId,
type: nodeType,
position: block.position,
parentId,
extent: block.data?.extent || undefined,
draggable: false,
zIndex: parentId ? 1000 : undefined,
data: {
type: block.type,
name: block.name,
workflowMap,
workflowLabelsReady,
isTrigger: block.triggerMode === true,
horizontalHandles: block.horizontalHandles ?? false,
enabled: block.enabled ?? true,
isPreviewSelected: isSelected,
executionStatus,
subBlockValues: block.subBlocks,
lightweight,
},
})
})
return nodeArray
}, [
blocksStructure,
loopsStructure,
parallelsStructure,
workflowState.blocks,
isValidWorkflowState,
executedBlocks,
selectedBlockId,
getSubflowExecutionStatus,
workflowMap,
workflowLabelsReady,
lightweight,
])
const edges: Edge[] = useMemo(() => {
if (!isValidWorkflowState) return []
const getEdgeExecutionStatus = (edge: {
source: string
target: string
sourceHandle?: string | null
}): ExecutionStatus | undefined => {
if (blockExecutionMap.size === 0) return undefined
const targetStatus = getBlockExecutionStatus(edge.target)
if (!targetStatus?.executed) return 'not-executed'
const sourceStatus = getBlockExecutionStatus(edge.source)
if (!sourceStatus?.executed) return 'not-executed'
const handle = edge.sourceHandle
if (!handle) {
return sourceStatus.status === 'success' ? 'success' : 'not-executed'
}
const sourceOutput = blockExecutionMap.get(edge.source)?.output as
| Record<string, any>
| undefined
if (handle.startsWith('condition-')) {
const conditionValue = handle.substring('condition-'.length)
return sourceOutput?.selectedOption === conditionValue ? 'success' : 'not-executed'
}
if (handle.startsWith('router-')) {
const routeId = handle.substring('router-'.length)
return sourceOutput?.selectedRoute === routeId ? 'success' : 'not-executed'
}
switch (handle) {
case 'error':
return sourceStatus.status === 'error' ? 'error' : 'not-executed'
case 'source':
return sourceStatus.status === 'success' ? 'success' : 'not-executed'
case 'loop-start-source':
case 'loop-end-source':
case 'parallel-start-source':
case 'parallel-end-source':
return 'success'
default:
return sourceStatus.status === 'success' ? 'success' : 'not-executed'
}
}
return (workflowState.edges || []).map((edge) => {
const status = getEdgeExecutionStatus(edge)
const isErrorEdge = edge.sourceHandle === 'error'
return {
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
data: {
...(status ? { executionStatus: status } : {}),
sourceHandle: edge.sourceHandle,
},
zIndex: status === 'success' ? 10 : isErrorEdge ? 5 : 0,
}
})
}, [
edgesStructure,
workflowState.edges,
isValidWorkflowState,
blockExecutionMap,
getBlockExecutionStatus,
])
if (!isValidWorkflowState) {
return (
<div
style={{ height, width }}
className='flex items-center justify-center rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900'
>
<div className='text-center text-gray-500 dark:text-gray-400'>
<div className='mb-2 font-medium text-lg'> Logged State Not Found</div>
<div className='text-sm'>
This log was migrated from the old system and doesn't contain workflow state data.
</div>
</div>
</div>
)
}
return (
<ReactFlowProvider>
<div
ref={containerRef}
style={{ height, width, backgroundColor: 'var(--bg)' }}
className={cn('preview-mode', onNodeClick && 'interactive-nodes', className)}
>
<style>{`
/* Canvas cursor - grab on the flow container and pane */
.preview-mode .react-flow { cursor: ${cursorStyle}; }
.preview-mode .react-flow__pane { cursor: ${cursorStyle} !important; }
.preview-mode .react-flow__selectionpane { cursor: ${cursorStyle} !important; }
.preview-mode .react-flow__renderer { cursor: ${cursorStyle}; }
/* Active/grabbing cursor when dragging */
${
cursorStyle === 'grab'
? `
.preview-mode .react-flow:active { cursor: grabbing; }
.preview-mode .react-flow__pane:active { cursor: grabbing !important; }
.preview-mode .react-flow__selectionpane:active { cursor: grabbing !important; }
.preview-mode .react-flow__renderer:active { cursor: grabbing; }
.preview-mode .react-flow__node:active { cursor: grabbing !important; }
.preview-mode .react-flow__node:active * { cursor: grabbing !important; }
`
: ''
}
/* Node cursor - pointer on nodes when onNodeClick is provided */
.preview-mode.interactive-nodes .react-flow__node { cursor: pointer !important; }
.preview-mode.interactive-nodes .react-flow__node > div { cursor: pointer !important; }
.preview-mode.interactive-nodes .react-flow__node * { cursor: pointer !important; }
`}</style>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionLineType={ConnectionLineType.SmoothStep}
fitView
fitViewOptions={{ padding: fitPadding }}
panOnScroll={isPannable}
panOnDrag={isPannable}
zoomOnScroll={false}
draggable={false}
defaultViewport={{
x: defaultPosition?.x ?? 0,
y: defaultPosition?.y ?? 0,
zoom: defaultZoom ?? 1,
}}
minZoom={0.1}
maxZoom={2}
proOptions={{ hideAttribution: true }}
elementsSelectable={false}
nodesDraggable={false}
nodesConnectable={false}
onNodeClick={
onNodeClick
? (event, node) => {
logger.debug('Node clicked:', { nodeId: node.id, event })
onNodeClick(node.id, { x: event.clientX, y: event.clientY })
}
: undefined
}
onNodeContextMenu={
onNodeContextMenu
? (event, node) => {
event.preventDefault()
event.stopPropagation()
onNodeContextMenu(node.id, { x: event.clientX, y: event.clientY })
}
: undefined
}
onPaneClick={onPaneClick}
/>
<FitViewOnChange
nodeIds={blocksStructure.ids}
fitPadding={fitPadding}
containerRef={containerRef}
/>
</div>
</ReactFlowProvider>
)
}
@@ -0,0 +1,6 @@
export { PreviewContextMenu } from './components/preview-context-menu'
export { PreviewEditor } from './components/preview-editor'
export { getLeftmostBlockId, PreviewWorkflow } from './components/preview-workflow'
export { PreviewBlock } from './components/preview-workflow/components/block'
export { PreviewSubflow } from './components/preview-workflow/components/subflow'
export { buildBlockExecutions, Preview } from './preview'
@@ -0,0 +1,360 @@
'use client'
import type React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button, cn, Tooltip } from '@sim/emcn'
import { ArrowLeft } from 'lucide-react'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { PreviewEditor } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-editor'
import {
getLeftmostBlockId,
PreviewWorkflow,
} from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
interface TraceSpan {
blockId?: string
input?: unknown
output?: unknown
status?: string
duration?: number
children?: TraceSpan[]
childWorkflowSnapshotId?: string
childWorkflowId?: string
}
interface BlockExecutionData {
input: unknown
output: unknown
status: string
durationMs: number
/** Child trace spans for nested workflow blocks */
children?: TraceSpan[]
childWorkflowSnapshotId?: string
}
function isPauseOutput(output: unknown): boolean {
return (
output !== null &&
typeof output === 'object' &&
'_pauseMetadata' in output &&
(output as Record<string, unknown>)._pauseMetadata !== undefined
)
}
/** Represents a level in the workflow navigation stack */
interface WorkflowStackEntry {
workflowState: WorkflowState
traceSpans: TraceSpan[]
blockExecutions: Record<string, BlockExecutionData>
workflowName: string
}
/**
* Extracts child trace spans from a workflow block's execution data.
* Checks `children` property (where trace-spans processing puts them),
* with fallback to `output.childTraceSpans` for old stored logs.
*/
function extractChildTraceSpans(blockExecution: BlockExecutionData | undefined): TraceSpan[] {
if (!blockExecution) return []
if (Array.isArray(blockExecution.children) && blockExecution.children.length > 0) {
return blockExecution.children
}
// Backward compat: old stored logs may have childTraceSpans in output
if (blockExecution.output && typeof blockExecution.output === 'object') {
const output = blockExecution.output as Record<string, unknown>
if (Array.isArray(output.childTraceSpans)) {
return output.childTraceSpans as TraceSpan[]
}
}
return []
}
/**
* Builds block execution data from trace spans
*/
export function buildBlockExecutions(spans: TraceSpan[]): Record<string, BlockExecutionData> {
const blockExecutionMap: Record<string, BlockExecutionData> = {}
const collectBlockSpans = (traceSpans: TraceSpan[]): TraceSpan[] => {
const blockSpans: TraceSpan[] = []
for (const span of traceSpans) {
if (span.blockId) {
blockSpans.push(span)
}
if (span.children && Array.isArray(span.children)) {
blockSpans.push(...collectBlockSpans(span.children))
}
}
return blockSpans
}
const allBlockSpans = collectBlockSpans(spans)
for (const span of allBlockSpans) {
if (span.blockId && !blockExecutionMap[span.blockId]) {
blockExecutionMap[span.blockId] = {
input: redactApiKeys(span.input || {}),
output: redactApiKeys(span.output || {}),
status: isPauseOutput(span.output) ? 'pending' : span.status || 'unknown',
durationMs: span.duration || 0,
children: span.children,
childWorkflowSnapshotId: span.childWorkflowSnapshotId,
}
}
}
return blockExecutionMap
}
interface PreviewProps {
/** The workflow state to display */
workflowState: WorkflowState
/** Trace spans for the execution (optional - enables execution mode features) */
traceSpans?: TraceSpan[]
/** Pre-computed block executions (optional - will be built from traceSpans if not provided) */
blockExecutions?: Record<string, BlockExecutionData>
/** Child workflow snapshots keyed by snapshot ID (execution mode only) */
childWorkflowSnapshots?: Record<string, WorkflowState>
/** Additional CSS class names */
className?: string
/** Height of the component */
height?: string | number
/** Width of the component */
width?: string | number
/** Callback when canvas context menu is opened */
onCanvasContextMenu?: (e: React.MouseEvent) => void
/** Callback when a node context menu is opened */
onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
/** Whether to show border around the component */
showBorder?: boolean
/** Initial block to select (defaults to leftmost block) */
initialSelectedBlockId?: string | null
/** Whether to auto-select the leftmost block on mount */
autoSelectLeftmost?: boolean
/** Whether to show the close (X) button on the block detail panel */
showBlockCloseButton?: boolean
}
const MIN_PANEL_WIDTH = 280
const MAX_PANEL_WIDTH = 600
const DEFAULT_PANEL_WIDTH = 320
/**
* Main preview component that combines PreviewCanvas with PreviewEditor
* and handles nested workflow navigation via a stack.
*
* @remarks
* - Manages navigation stack for drilling into nested workflow blocks
* - Displays back button when viewing nested workflows
* - Properly passes execution data through to nested levels
* - Can be used anywhere a workflow preview with editor is needed
*/
export function Preview({
workflowState: rootWorkflowState,
traceSpans: rootTraceSpans,
blockExecutions: providedBlockExecutions,
childWorkflowSnapshots,
className,
height = '100%',
width = '100%',
onCanvasContextMenu,
onNodeContextMenu,
showBorder = false,
initialSelectedBlockId,
autoSelectLeftmost = true,
showBlockCloseButton = true,
}: PreviewProps) {
const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH)
const panelWidthRef = useRef(DEFAULT_PANEL_WIDTH)
panelWidthRef.current = panelWidth
const isResizingRef = useRef(false)
const startXRef = useRef(0)
const startWidthRef = useRef(0)
function handleResizeMouseDown(e: React.MouseEvent) {
isResizingRef.current = true
startXRef.current = e.clientX
startWidthRef.current = panelWidthRef.current
document.body.style.cursor = 'ew-resize'
document.body.style.userSelect = 'none'
}
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizingRef.current) return
const delta = startXRef.current - e.clientX
setPanelWidth(
Math.max(MIN_PANEL_WIDTH, Math.min(MAX_PANEL_WIDTH, startWidthRef.current + delta))
)
}
const handleMouseUp = () => {
if (!isResizingRef.current) return
isResizingRef.current = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
}, [])
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(() => {
if (initialSelectedBlockId) return initialSelectedBlockId
if (autoSelectLeftmost) {
return getLeftmostBlockId(rootWorkflowState)
}
return null
})
const [workflowStack, setWorkflowStack] = useState<WorkflowStackEntry[]>([])
const [prevRootState, setPrevRootState] = useState(rootWorkflowState)
if (rootWorkflowState !== prevRootState) {
setPrevRootState(rootWorkflowState)
setWorkflowStack([])
}
const rootBlockExecutions = useMemo(() => {
if (providedBlockExecutions) return providedBlockExecutions
if (!rootTraceSpans || !Array.isArray(rootTraceSpans)) return {}
return buildBlockExecutions(rootTraceSpans)
}, [providedBlockExecutions, rootTraceSpans])
const currentStackEntry =
workflowStack.length > 0 ? workflowStack[workflowStack.length - 1] : null
const blockExecutions = currentStackEntry
? currentStackEntry.blockExecutions
: rootBlockExecutions
const workflowState = currentStackEntry ? currentStackEntry.workflowState : rootWorkflowState
const isExecutionMode = Object.keys(blockExecutions).length > 0
function handleDrillDown(blockId: string, childWorkflowState: WorkflowState) {
const blockExecution = blockExecutions[blockId]
const childTraceSpans = extractChildTraceSpans(blockExecution)
const childBlockExecutions = buildBlockExecutions(childTraceSpans)
const workflowName =
childWorkflowState.metadata?.name ||
(blockExecution?.output as { childWorkflowName?: string } | undefined)?.childWorkflowName ||
'Nested Workflow'
setWorkflowStack((prev) => [
...prev,
{
workflowState: childWorkflowState,
traceSpans: childTraceSpans,
blockExecutions: childBlockExecutions,
workflowName,
},
])
const leftmostId = getLeftmostBlockId(childWorkflowState)
setPinnedBlockId(leftmostId)
}
function handleGoBack() {
setWorkflowStack((prev) => prev.slice(0, -1))
setPinnedBlockId(null)
}
function handleNodeClick(blockId: string) {
setPinnedBlockId(blockId)
}
function handlePaneClick() {
setPinnedBlockId(null)
}
function handleEditorClose() {
setPinnedBlockId(null)
}
const isNested = workflowStack.length > 0
const currentWorkflowName = isNested ? workflowStack[workflowStack.length - 1].workflowName : null
return (
<div
style={{ height, width }}
className={cn(
'relative flex overflow-hidden',
showBorder && 'rounded-sm border border-[var(--border)]',
className
)}
>
{isNested && (
<div className='absolute top-3 left-[12px] z-20 flex items-center gap-1.5'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleGoBack}
className='flex h-[28px] items-center gap-[5px] rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2.5 text-[var(--text-secondary)] shadow-sm hover-hover:bg-[var(--surface-4)] hover-hover:text-[var(--text-primary)]'
>
<ArrowLeft className='size-[12px]' />
<span className='font-medium text-caption'>Back</span>
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>Go back to parent workflow</Tooltip.Content>
</Tooltip.Root>
{currentWorkflowName && (
<div className='flex h-[28px] max-w-[200px] items-center rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2.5 shadow-sm'>
<span className='truncate font-medium text-[var(--text-secondary)] text-caption'>
{currentWorkflowName}
</span>
</div>
)}
</div>
)}
<div role='presentation' className='h-full flex-1' onContextMenu={onCanvasContextMenu}>
<PreviewWorkflow
workflowState={workflowState}
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={0.8}
onNodeClick={handleNodeClick}
onNodeContextMenu={onNodeContextMenu}
onPaneClick={handlePaneClick}
cursorStyle='pointer'
executedBlocks={blockExecutions}
selectedBlockId={pinnedBlockId}
/>
</div>
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
<div style={{ width: panelWidth }} className='relative h-full flex-shrink-0'>
{/* Left-edge resize handle */}
<div
role='separator'
aria-orientation='vertical'
className='absolute top-0 bottom-0 left-0 z-10 w-1 cursor-ew-resize transition-colors hover-hover:bg-[var(--border-1)]'
onMouseDown={handleResizeMouseDown}
/>
<PreviewEditor
block={workflowState.blocks[pinnedBlockId]}
executionData={blockExecutions[pinnedBlockId]}
allBlockExecutions={blockExecutions}
workflowBlocks={workflowState.blocks}
workflowVariables={workflowState.variables}
loops={workflowState.loops}
parallels={workflowState.parallels}
isExecutionMode={isExecutionMode}
childWorkflowSnapshots={childWorkflowSnapshots}
onClose={showBlockCloseButton ? handleEditorClose : undefined}
onDrillDown={handleDrillDown}
/>
</div>
)}
</div>
)
}
@@ -0,0 +1,494 @@
import { type MouseEvent as ReactMouseEvent, useState } from 'react'
import {
chipVariants,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuItemAction,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@sim/emcn'
import { Pencil, SquareArrowUpRight } from '@sim/emcn/icons'
import { Folder, MoreHorizontal, Plus } from 'lucide-react'
import Link from 'next/link'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import type { WorkspaceFileFolderApi } from '@/hooks/queries/workspace-file-folders'
import type { FolderTreeNode } from '@/stores/folders/types'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
interface FileFolderFlyoutNode extends WorkspaceFileFolderApi {
children: FileFolderFlyoutNode[]
files: WorkspaceFileRecord[]
}
export function CollapsedFileFolderItems({
nodes,
rootFiles,
workspaceId,
currentFileId,
}: {
nodes: FileFolderFlyoutNode[]
rootFiles?: WorkspaceFileRecord[]
workspaceId: string
currentFileId?: string
}) {
return (
<>
{nodes.map((folder) => {
const hasChildren = folder.children.length > 0 || folder.files.length > 0
if (!hasChildren) {
return (
<DropdownMenuItem key={folder.id} disabled>
<Folder className='size-[14px]' />
<span className='truncate'>{folder.name}</span>
</DropdownMenuItem>
)
}
return (
<DropdownMenuSub key={folder.id}>
<DropdownMenuSubTrigger className='focus:bg-[var(--surface-hover)] data-[state=open]:bg-[var(--surface-hover)]'>
<Folder className='size-[14px]' />
<span className='truncate'>{folder.name}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<CollapsedFileFolderItems
nodes={folder.children}
workspaceId={workspaceId}
currentFileId={currentFileId}
/>
{folder.files.map((file) => (
<DropdownMenuItem key={file.id} asChild>
<Link
href={`/workspace/${workspaceId}/files/${file.id}`}
className={cn(currentFileId === file.id && 'bg-[var(--surface-active)]')}
>
<svg
className='size-[14px] flex-shrink-0 text-[var(--text-icon)]'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
aria-hidden='true'
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
</svg>
<span className='truncate'>{file.name}</span>
</Link>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
})}
{rootFiles?.map((file) => (
<DropdownMenuItem key={file.id} asChild>
<Link
href={`/workspace/${workspaceId}/files/${file.id}`}
className={cn(currentFileId === file.id && 'bg-[var(--surface-active)]')}
>
<svg
className='size-[14px] flex-shrink-0 text-[var(--text-icon)]'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
aria-hidden='true'
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
</svg>
<span className='truncate'>{file.name}</span>
</Link>
</DropdownMenuItem>
))}
</>
)
}
interface CollapsedSidebarMenuProps {
icon: React.ReactNode
hover: ReturnType<typeof useHoverMenu>
ariaLabel?: string
children: React.ReactNode
className?: string
primaryAction?: {
label: string
onSelect: () => void
}
}
interface CollapsedChatFlyoutItemProps {
chat: { id: string; href: string; name: string; isActive?: boolean; isUnread?: boolean }
isCurrentRoute: boolean
isMenuOpen?: boolean
isEditing?: boolean
editValue?: string
inputRef?: React.RefObject<HTMLInputElement | null>
isRenaming?: boolean
onEditValueChange?: (value: string) => void
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
onEditBlur?: () => void
onContextMenu?: (e: ReactMouseEvent, chatId: string) => void
onMorePointerDown?: () => void
onMoreClick?: (e: ReactMouseEvent<HTMLButtonElement>, chatId: string) => void
}
interface CollapsedWorkflowFlyoutItemProps {
workflow: WorkflowMetadata
href: string
isCurrentRoute?: boolean
isEditing?: boolean
editValue?: string
inputRef?: React.RefObject<HTMLInputElement | null>
isRenaming?: boolean
onEditValueChange?: (value: string) => void
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
onEditBlur?: () => void
onOpenInNewTab?: () => void
onRename?: () => void
canRename?: boolean
}
const EDIT_ROW_CLASS = cn(
chipVariants({ active: true, fullWidth: true }),
'mx-0 min-w-0 cursor-default select-none text-small'
)
export function CollapsedSidebarMenu({
icon,
hover,
ariaLabel,
children,
className,
primaryAction,
}: CollapsedSidebarMenuProps) {
return (
<div className={cn('flex flex-col px-2', className)}>
<DropdownMenu
open={hover.isOpen}
onOpenChange={(open) => {
if (open) hover.open()
else hover.close()
}}
modal={false}
>
<div {...hover.triggerProps}>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label={ariaLabel}
className={chipVariants({ fullWidth: true })}
>
{icon}
</button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent side='right' align='start' sideOffset={8} {...hover.contentProps}>
{primaryAction && (
<>
<DropdownMenuItem onSelect={primaryAction.onSelect}>
<Plus className='size-[14px]' />
{primaryAction.label}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{children}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export function CollapsedChatFlyoutItem({
chat,
isCurrentRoute,
isMenuOpen = false,
isEditing = false,
editValue,
inputRef,
isRenaming = false,
onEditValueChange,
onEditKeyDown,
onEditBlur,
onContextMenu,
onMorePointerDown,
onMoreClick,
}: CollapsedChatFlyoutItemProps) {
const showActions = chat.id !== 'new' && onMoreClick
if (isEditing) {
return (
<div className={EDIT_ROW_CLASS}>
<input
aria-label={`Rename chat ${chat.name}`}
ref={inputRef}
value={editValue ?? chat.name}
onChange={(e) => onEditValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={onEditBlur}
className='w-full min-w-0 border-0 bg-transparent p-0 text-[var(--text-body)] text-small outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
maxLength={100}
disabled={isRenaming}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
</div>
)
}
return (
<DropdownMenuItem
asChild
className={cn((isCurrentRoute || isMenuOpen) && 'bg-[var(--surface-active)]')}
action={
showActions ? (
<DropdownMenuItemAction
aria-label='Chat options'
onPointerDown={onMorePointerDown}
onClick={(e) => onMoreClick?.(e, chat.id)}
className={cn(isMenuOpen && 'opacity-100')}
>
<MoreHorizontal />
</DropdownMenuItemAction>
) : undefined
}
>
<Link
href={chat.href}
onContextMenu={
chat.id !== 'new' && onContextMenu ? (e) => onContextMenu(e, chat.id) : undefined
}
>
<ConversationListItem
title={chat.name}
isActive={!!chat.isActive}
isUnread={!!chat.isUnread}
/>
</Link>
</DropdownMenuItem>
)
}
export function CollapsedWorkflowFlyoutItem({
workflow,
href,
isCurrentRoute = false,
isEditing = false,
editValue,
inputRef,
isRenaming = false,
onEditValueChange,
onEditKeyDown,
onEditBlur,
onOpenInNewTab,
onRename,
canRename = true,
}: CollapsedWorkflowFlyoutItemProps) {
const hasActions = !!onOpenInNewTab || !!onRename
const [actionsOpen, setActionsOpen] = useState(false)
if (isEditing) {
return (
<div className={EDIT_ROW_CLASS}>
<input
aria-label={`Rename workflow ${workflow.name}`}
ref={inputRef}
value={editValue ?? workflow.name}
onChange={(e) => onEditValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={onEditBlur}
className='w-full min-w-0 border-0 bg-transparent p-0 text-[var(--text-body)] text-small outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
maxLength={100}
disabled={isRenaming}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
</div>
)
}
return (
<DropdownMenuItem
asChild
className={cn((isCurrentRoute || actionsOpen) && 'bg-[var(--surface-active)]')}
action={
hasActions ? (
<DropdownMenuSub
open={actionsOpen}
onOpenChange={(open) => {
if (!open) setActionsOpen(false)
}}
>
<DropdownMenuSubTrigger asChild>
<DropdownMenuItemAction
aria-label='Workflow options'
onClick={() => setActionsOpen((prev) => !prev)}
className={cn(actionsOpen && 'opacity-100')}
>
<MoreHorizontal />
</DropdownMenuItemAction>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{onOpenInNewTab && (
<DropdownMenuItem onSelect={onOpenInNewTab}>
<SquareArrowUpRight className='size-[14px]' />
Open in new tab
</DropdownMenuItem>
)}
{onRename && (
<DropdownMenuItem
disabled={!canRename}
onSelect={(e) => {
e.preventDefault()
setActionsOpen(false)
onRename()
}}
>
<Pencil className='size-[14px]' />
Rename
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : undefined
}
>
<Link
href={href}
onContextMenu={
hasActions
? (e) => {
e.preventDefault()
setActionsOpen(true)
}
: undefined
}
>
<span className='min-w-0 flex-1 truncate'>{workflow.name}</span>
</Link>
</DropdownMenuItem>
)
}
export function CollapsedFolderItems({
nodes,
workflowsByFolder,
workspaceId,
currentWorkflowId,
editingWorkflowId,
editingValue,
editInputRef,
isRenamingWorkflow,
onEditValueChange,
onEditKeyDown,
onEditBlur,
onWorkflowOpenInNewTab,
onWorkflowRename,
canRenameWorkflow,
}: {
nodes: FolderTreeNode[]
workflowsByFolder: Record<string, WorkflowMetadata[]>
workspaceId: string
currentWorkflowId?: string
editingWorkflowId?: string | null
editingValue?: string
editInputRef?: React.RefObject<HTMLInputElement | null>
isRenamingWorkflow?: boolean
onEditValueChange?: (value: string) => void
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
onEditBlur?: () => void
onWorkflowOpenInNewTab?: (workflow: WorkflowMetadata) => void
onWorkflowRename?: (workflow: WorkflowMetadata) => void
canRenameWorkflow?: boolean
}) {
return (
<>
{nodes.map((folder) => {
const folderWorkflows = workflowsByFolder[folder.id] || []
const hasChildren = folder.children.length > 0 || folderWorkflows.length > 0
if (!hasChildren) {
return (
<DropdownMenuItem key={folder.id} disabled>
<Folder className='size-[14px]' />
<span className='truncate'>{folder.name}</span>
</DropdownMenuItem>
)
}
return (
<DropdownMenuSub key={folder.id}>
<DropdownMenuSubTrigger className='focus:bg-[var(--surface-active)] data-[state=open]:bg-[var(--surface-active)]'>
<Folder className='size-[14px]' />
<span className='truncate'>{folder.name}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<CollapsedFolderItems
nodes={folder.children}
workflowsByFolder={workflowsByFolder}
workspaceId={workspaceId}
currentWorkflowId={currentWorkflowId}
editingWorkflowId={editingWorkflowId}
editingValue={editingValue}
editInputRef={editInputRef}
isRenamingWorkflow={isRenamingWorkflow}
onEditValueChange={onEditValueChange}
onEditKeyDown={onEditKeyDown}
onEditBlur={onEditBlur}
onWorkflowOpenInNewTab={onWorkflowOpenInNewTab}
onWorkflowRename={onWorkflowRename}
canRenameWorkflow={canRenameWorkflow}
/>
{folderWorkflows.map((workflow) => (
<CollapsedWorkflowFlyoutItem
key={workflow.id}
workflow={workflow}
href={`/workspace/${workspaceId}/w/${workflow.id}`}
isCurrentRoute={workflow.id === currentWorkflowId}
isEditing={workflow.id === editingWorkflowId}
editValue={editingValue}
inputRef={editInputRef}
isRenaming={isRenamingWorkflow}
onEditValueChange={onEditValueChange}
onEditKeyDown={onEditKeyDown}
onEditBlur={onEditBlur}
onOpenInNewTab={
onWorkflowOpenInNewTab ? () => onWorkflowOpenInNewTab(workflow) : undefined
}
onRename={onWorkflowRename ? () => onWorkflowRename(workflow) : undefined}
canRename={canRenameWorkflow}
/>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
})}
</>
)
}
@@ -0,0 +1,7 @@
export {
CollapsedChatFlyoutItem,
CollapsedFileFolderItems,
CollapsedFolderItems,
CollapsedSidebarMenu,
CollapsedWorkflowFlyoutItem,
} from './collapsed-sidebar-menu'
@@ -0,0 +1,214 @@
'use client'
import { memo, useMemo, useState } from 'react'
import { cn } from '@sim/emcn'
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import type { WorkspaceFileFolderApi } from '@/hooks/queries/workspace-file-folders'
interface FileFolderNode extends WorkspaceFileFolderApi {
children: FileFolderNode[]
files: WorkspaceFileRecord[]
}
function buildFileFolderTree(
folders: WorkspaceFileFolderApi[],
files: WorkspaceFileRecord[],
parentId: string | null = null
): FileFolderNode[] {
return folders
.filter((f) => (f.parentId ?? null) === parentId)
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
.map((folder) => ({
...folder,
children: buildFileFolderTree(folders, files, folder.id),
files: files
.filter((file) => (file.folderId ?? null) === folder.id)
.sort((a, b) => a.name.localeCompare(b.name)),
}))
}
const INDENT_PER_LEVEL = 16
interface FileFolderNodeItemProps {
node: FileFolderNode
workspaceId: string
currentFileId: string | undefined
pathname: string | null
level: number
}
const FileFolderNodeItem = memo(function FileFolderNodeItem({
node,
workspaceId,
currentFileId,
pathname,
level,
}: FileFolderNodeItemProps) {
const [isExpanded, setIsExpanded] = useState(true)
const hasChildren = node.children.length > 0 || node.files.length > 0
return (
<div>
<button
type='button'
className='group mx-0.5 flex h-[30px] w-[calc(100%-4px)] items-center gap-1 rounded-lg px-2 text-sm hover-hover:bg-[var(--surface-hover)]'
style={{ paddingLeft: `${8 + level * INDENT_PER_LEVEL}px` }}
onClick={() => setIsExpanded((prev) => !prev)}
>
<ChevronRight
className={cn(
'size-[14px] flex-shrink-0 text-[var(--text-icon)] transition-transform duration-150',
isExpanded && hasChildren && 'rotate-90',
!hasChildren && 'opacity-0'
)}
/>
<svg
className='size-[16px] flex-shrink-0 text-[var(--text-icon)]'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
aria-hidden='true'
>
{isExpanded && hasChildren ? (
<>
<path d='m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2' />
</>
) : (
<>
<path d='M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z' />
</>
)}
</svg>
<span className='min-w-0 flex-1 truncate text-left font-base text-[var(--text-body)]'>
{node.name}
</span>
</button>
{isExpanded && (
<>
{node.children.map((child) => (
<FileFolderNodeItem
key={child.id}
node={child}
workspaceId={workspaceId}
currentFileId={currentFileId}
pathname={pathname}
level={level + 1}
/>
))}
{node.files.map((file) => {
const href = `/workspace/${workspaceId}/files/${file.id}`
const isActive = currentFileId === file.id || pathname === href
return (
<Link
key={file.id}
href={href}
className={cn(
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg text-sm',
!isActive && 'hover-hover:bg-[var(--surface-hover)]',
isActive && 'bg-[var(--surface-active)]'
)}
style={{ paddingLeft: `${8 + (level + 1) * INDENT_PER_LEVEL + 14}px` }}
>
<svg
className='size-[14px] flex-shrink-0 text-[var(--text-icon)]'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
aria-hidden='true'
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
</svg>
<span className='min-w-0 flex-1 truncate font-base text-[var(--text-body)]'>
{file.name}
</span>
</Link>
)
})}
</>
)}
</div>
)
})
interface FileListProps {
workspaceId: string
currentFileId?: string
pathname: string | null
folders: WorkspaceFileFolderApi[]
files: WorkspaceFileRecord[]
}
export const FileList = memo(function FileList({
workspaceId,
currentFileId,
pathname,
folders,
files,
}: FileListProps) {
const rootFolderNodes = useMemo(() => buildFileFolderTree(folders, files, null), [folders, files])
const rootFiles = useMemo(
() =>
files
.filter((f) => (f.folderId ?? null) === null)
.sort((a, b) => a.name.localeCompare(b.name)),
[files]
)
return (
<div className='flex flex-col'>
{rootFolderNodes.map((node) => (
<FileFolderNodeItem
key={node.id}
node={node}
workspaceId={workspaceId}
currentFileId={currentFileId}
pathname={pathname}
level={0}
/>
))}
{rootFiles.map((file) => {
const href = `/workspace/${workspaceId}/files/${file.id}`
const isActive = currentFileId === file.id || pathname === href
return (
<Link
key={file.id}
href={href}
className={cn(
'group mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-sm',
!isActive && 'hover-hover:bg-[var(--surface-hover)]',
isActive && 'bg-[var(--surface-active)]'
)}
>
<svg
className='size-[14px] flex-shrink-0 text-[var(--text-icon)]'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
aria-hidden='true'
>
<path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' />
<path d='M14 2v4a2 2 0 0 0 2 2h4' />
</svg>
<span className='min-w-0 flex-1 truncate font-base text-[var(--text-body)]'>
{file.name}
</span>
</Link>
)
})}
</div>
)
})
@@ -0,0 +1 @@
export { FileList } from './file-list'
@@ -0,0 +1,364 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import {
ChipModal,
ChipModalBody,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { useMutation } from '@tanstack/react-query'
import imageCompression from 'browser-image-compression'
import { X } from 'lucide-react'
import Image from 'next/image'
import { Controller, useForm } from 'react-hook-form'
import { z } from 'zod'
const logger = createLogger('HelpModal')
const MAX_FILE_SIZE = 20 * 1024 * 1024
const TARGET_SIZE_MB = 2
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']
const SCROLL_DELAY_MS = 100
const SUCCESS_RESET_DELAY_MS = 2000
const DEFAULT_REQUEST_TYPE = 'bug'
const REQUEST_TYPE_OPTIONS = [
{ label: 'Bug report', value: 'bug' },
{ label: 'Feedback', value: 'feedback' },
{ label: 'Feature request', value: 'feature_request' },
{ label: 'Other', value: 'other' },
]
const formSchema = z.object({
subject: z.string().min(1, 'Subject is required'),
message: z.string().min(1, 'Message is required'),
type: z.enum(['bug', 'feedback', 'feature_request', 'other'], {
error: 'Please select a request type',
}),
})
type FormValues = z.infer<typeof formSchema>
interface ImageWithPreview extends File {
preview: string
}
interface HelpModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workflowId?: string
workspaceId: string
}
interface SubmitHelpVariables {
data: FormValues
images: ImageWithPreview[]
workflowId?: string
workspaceId: string
}
async function compressImage(file: File): Promise<File> {
if (file.size < TARGET_SIZE_MB * 1024 * 1024 || file.type === 'image/gif') {
return file
}
try {
const compressedFile = await imageCompression(file, {
maxSizeMB: TARGET_SIZE_MB,
maxWidthOrHeight: 1920,
useWebWorker: true,
fileType: file.type,
initialQuality: 0.8,
alwaysKeepResolution: true,
})
return new File([compressedFile], file.name, {
type: file.type,
lastModified: Date.now(),
})
} catch (error) {
logger.warn('Image compression failed, using original file:', { error })
return file
}
}
async function submitHelpRequest({ data, images, workflowId, workspaceId }: SubmitHelpVariables) {
const formData = new FormData()
formData.append('subject', data.subject)
formData.append('message', data.message)
formData.append('type', data.type)
formData.append('workspaceId', workspaceId)
formData.append('userAgent', navigator.userAgent)
if (workflowId) {
formData.append('workflowId', workflowId)
}
images.forEach((image, index) => {
formData.append(`image_${index}`, image)
})
// boundary-raw-fetch: multipart/form-data submission with image attachments, requestJson only supports JSON bodies
const response = await fetch('/api/help', {
method: 'POST',
body: formData,
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
throw new Error(errorData?.error || 'Failed to submit help request')
}
}
export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpModalProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null)
const imagesRef = useRef<ImageWithPreview[]>([])
const [submitStatus, setSubmitStatus] = useState<'success' | 'error' | null>(null)
const [images, setImages] = useState<ImageWithPreview[]>([])
const [isProcessing, setIsProcessing] = useState(false)
const { control, handleSubmit, reset } = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
subject: '',
message: '',
type: DEFAULT_REQUEST_TYPE,
},
mode: 'onSubmit',
})
const helpMutation = useMutation({
mutationFn: submitHelpRequest,
onSuccess: (_data, variables) => {
setSubmitStatus('success')
reset()
variables.images.forEach((image) => URL.revokeObjectURL(image.preview))
setImages([])
},
onError: (error) => {
logger.error('Error submitting help request:', { error })
setSubmitStatus('error')
},
})
useEffect(() => {
imagesRef.current = images
}, [images])
useEffect(() => {
if (open) {
setSubmitStatus(null)
setIsProcessing(false)
helpMutation.reset()
reset({
subject: '',
message: '',
type: DEFAULT_REQUEST_TYPE,
})
} else {
const previewsToRevoke = imagesRef.current
if (previewsToRevoke.length > 0) {
previewsToRevoke.forEach((image) => URL.revokeObjectURL(image.preview))
setImages([])
}
}
}, [open, reset])
useEffect(() => {
return () => {
imagesRef.current.forEach((image) => URL.revokeObjectURL(image.preview))
}
}, [])
useEffect(() => {
if (submitStatus === 'success') {
const timer = setTimeout(() => {
setSubmitStatus(null)
}, SUCCESS_RESET_DELAY_MS)
return () => clearTimeout(timer)
}
}, [submitStatus])
useEffect(() => {
if (images.length > 0 && scrollContainerRef.current) {
const scrollContainer = scrollContainerRef.current
const timer = setTimeout(() => {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: 'smooth',
})
}, SCROLL_DELAY_MS)
return () => clearTimeout(timer)
}
}, [images.length])
async function processFiles(files: FileList | File[]) {
if (!files || files.length === 0) return
setIsProcessing(true)
try {
const newImages: ImageWithPreview[] = []
let hasError = false
for (const file of Array.from(files)) {
if (file.size > MAX_FILE_SIZE) {
hasError = true
continue
}
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
hasError = true
continue
}
const compressedFile = await compressImage(file)
const imageWithPreview = Object.assign(compressedFile, {
preview: URL.createObjectURL(compressedFile),
}) as ImageWithPreview
newImages.push(imageWithPreview)
}
if (!hasError && newImages.length > 0) {
setImages((prev) => [...prev, ...newImages])
}
} catch (error) {
logger.error('Error processing images:', { error })
} finally {
setIsProcessing(false)
}
}
function removeImage(index: number) {
setImages((prev) => {
URL.revokeObjectURL(prev[index].preview)
return prev.filter((_, i) => i !== index)
})
}
function onSubmit(data: FormValues) {
if (helpMutation.isPending) return
setSubmitStatus(null)
helpMutation.mutate({ data, images, workflowId, workspaceId })
}
return (
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Help & support' size='md'>
<ChipModalHeader onClose={() => onOpenChange(false)}>Help &amp; support</ChipModalHeader>
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
<button type='submit' hidden disabled={helpMutation.isPending || isProcessing} />
<ChipModalBody ref={scrollContainerRef} className='max-h-[60vh]'>
<Controller
name='type'
control={control}
render={({ field, fieldState }) => (
<ChipModalField
type='dropdown'
title='Request'
value={field.value}
onChange={field.onChange}
options={REQUEST_TYPE_OPTIONS}
placeholder='Select a request type'
error={fieldState.error?.message}
/>
)}
/>
<Controller
name='subject'
control={control}
render={({ field, fieldState }) => (
<ChipModalField
type='input'
title='Subject'
value={field.value}
onChange={field.onChange}
placeholder='Brief description of your request'
error={fieldState.error?.message}
/>
)}
/>
<Controller
name='message'
control={control}
render={({ field, fieldState }) => (
<ChipModalField
type='textarea'
title='Message'
value={field.value}
onChange={field.onChange}
placeholder='Please provide details about your request...'
rows={6}
error={fieldState.error?.message}
/>
)}
/>
<ChipModalField
type='file'
title='Attach images (optional)'
label='Drop images here or click to browse'
description='PNG, JPEG, WebP, GIF (max 20MB each)'
accept={ACCEPTED_IMAGE_TYPES.join(',')}
multiple
onChange={processFiles}
/>
{images.length > 0 && (
<ChipModalField type='custom' title='Uploaded images'>
<div className='grid grid-cols-2 gap-3'>
{images.map((image, index) => (
<div
className='group relative overflow-hidden rounded-sm border'
key={image.preview}
>
<div className='relative flex max-h-[120px] min-h-[80px] w-full items-center justify-center'>
<Image
src={image.preview}
alt={`Preview ${index + 1}`}
fill
unoptimized
sizes='(max-width: 768px) 100vw, 50vw'
className='object-contain'
/>
<button
type='button'
className='absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100'
onClick={() => removeImage(index)}
>
<X className='size-[18px] text-white' />
</button>
</div>
<div className='truncate p-1.5 text-caption'>{image.name}</div>
</div>
))}
</div>
</ChipModalField>
)}
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
cancelDisabled={helpMutation.isPending}
primaryAction={{
label: helpMutation.isPending
? 'Submitting...'
: submitStatus === 'error'
? 'Error'
: submitStatus === 'success'
? 'Success'
: 'Submit',
onClick: () => void handleSubmit(onSubmit)(),
disabled: helpMutation.isPending || isProcessing,
}}
/>
</form>
</ChipModal>
)
}
@@ -0,0 +1 @@
export { HelpModal } from './help-modal'
@@ -0,0 +1,14 @@
export {
CollapsedChatFlyoutItem,
CollapsedFileFolderItems,
CollapsedFolderItems,
CollapsedSidebarMenu,
CollapsedWorkflowFlyoutItem,
} from './collapsed-sidebar-menu'
export { FileList } from './file-list'
export { HelpModal } from './help-modal'
export { NavItemContextMenu } from './nav-item-context-menu'
export { SearchModal } from './search-modal'
export { SettingsSidebar } from './settings-sidebar'
export { WorkflowList } from './workflow-list'
export { WorkspaceHeader } from './workspace-header'
@@ -0,0 +1 @@
export { NavItemContextMenu } from './nav-item-context-menu'
@@ -0,0 +1,65 @@
'use client'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@sim/emcn'
import { Duplicate, SquareArrowUpRight } from '@sim/emcn/icons'
interface NavItemContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
menuRef: React.RefObject<HTMLDivElement | null>
onClose: () => void
onOpenInNewTab: () => void
onCopyLink: () => void
}
export function NavItemContextMenu({
isOpen,
position,
menuRef,
onClose,
onOpenInNewTab,
onCopyLink,
}: NavItemContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
ref={menuRef}
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem
onSelect={() => {
onOpenInNewTab()
onClose()
}}
>
<SquareArrowUpRight />
Open in new tab
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
onCopyLink()
onClose()
}}
>
<Duplicate />
Copy link
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,360 @@
'use client'
import type { ComponentType } from 'react'
import { memo } from 'react'
import { cn } from '@sim/emcn'
import { File, Workflow } from '@sim/emcn/icons'
import { Command } from 'cmdk'
import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import {
COMMAND_ITEM_CLASSNAME,
fuzzyMatch,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import { getTileIconColorClass } from '@/blocks/icon-color'
interface Segment {
text: string
hit: boolean
}
function buildSegments(text: string, positions: readonly number[]): Segment[] {
const hits = new Set(positions)
const segments: Segment[] = []
for (let i = 0; i < text.length; i++) {
const hit = hits.has(i)
const last = segments[segments.length - 1]
if (last && last.hit === hit) last.text += text[i]
else segments.push({ text: text[i], hit })
}
return segments
}
/**
* Renders `text` with the characters that match `query` emphasized. Falls back
* to plain text when there is no query or no positional match against the
* display text (e.g. the row matched on a hidden id rather than its label).
*/
export const HighlightedText = memo(
function HighlightedText({ text, query }: { text: string; query?: string }) {
if (!query) return <>{text}</>
const { positions } = fuzzyMatch(text, query)
if (positions.length === 0) return <>{text}</>
return (
<>
{buildSegments(text, positions).map((segment, index) =>
segment.hit ? (
<span key={index} className='font-medium'>
{segment.text}
</span>
) : (
<span key={index}>{segment.text}</span>
)
)}
</>
)
},
(prev, next) => prev.text === next.text && prev.query === next.query
)
export const MemoizedCommandItem = memo(
function CommandItem({
value,
onSelect,
icon: Icon,
bgColor,
showColoredIcon,
label,
query,
}: CommandItemProps) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<div
className='relative flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm'
style={{ background: showColoredIcon ? bgColor : 'transparent' }}
>
<Icon
className={cn(
'transition-transform duration-100 group-hover:scale-110',
showColoredIcon
? `size-[10px] ${getTileIconColorClass(bgColor)}`
: 'size-[16px] text-[var(--text-icon)]'
)}
/>
</div>
<span className='truncate text-[var(--text-body)]'>
<HighlightedText text={label} query={query} />
</span>
</Command.Item>
)
},
(prev, next) =>
prev.value === next.value &&
prev.icon === next.icon &&
prev.bgColor === next.bgColor &&
prev.showColoredIcon === next.showColoredIcon &&
prev.label === next.label &&
prev.query === next.query
)
export const MemoizedActionItem = memo(
function ActionItem({
value,
onSelect,
icon: Icon,
name,
shortcut,
query,
}: {
value: string
onSelect: () => void
icon: ComponentType<{ className?: string }>
name: string
shortcut?: string
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[var(--text-body)]'>
<HighlightedText text={name} query={query} />
</span>
{shortcut && (
<span className='ml-auto flex-shrink-0 text-[var(--text-subtle)] text-small'>
{shortcut}
</span>
)}
</Command.Item>
)
},
(prev, next) =>
prev.value === next.value &&
prev.icon === next.icon &&
prev.name === next.name &&
prev.shortcut === next.shortcut &&
prev.query === next.query
)
export const MemoizedWorkflowItem = memo(
function WorkflowItem({
value,
onSelect,
name,
folderPath,
isCurrent,
query,
}: {
value: string
onSelect: () => void
name: string
folderPath?: string[]
isCurrent?: boolean
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<div className='relative flex size-[16px] flex-shrink-0 items-center justify-center'>
<Workflow className='size-[14px] text-[var(--text-icon)]' />
</div>
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 text-[var(--text-body)]'>
<span className='truncate'>
<HighlightedText text={name} query={query} />
</span>
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
</span>
{folderPath && folderPath.length > 0 && (
<span className='ml-auto flex min-w-0 pl-2 text-[var(--text-subtle)] text-small'>
{folderPath.length > 1 && (
<>
<span className='min-w-0 truncate [flex-shrink:9999]'>
{folderPath.slice(0, -1).join(' / ')}
</span>
<span className='flex-shrink-0 whitespace-pre'> / </span>
</>
)}
<span className='min-w-0 truncate'>{folderPath[folderPath.length - 1]}</span>
</span>
)}
</Command.Item>
)
},
(prev, next) =>
prev.value === next.value &&
prev.name === next.name &&
prev.isCurrent === next.isCurrent &&
prev.query === next.query &&
(prev.folderPath === next.folderPath ||
(prev.folderPath?.length === next.folderPath?.length &&
(prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i])))
)
export const MemoizedFileItem = memo(
function FileItem({
value,
onSelect,
name,
folderPath,
query,
}: {
value: string
onSelect: () => void
name: string
folderPath?: string[]
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<div className='relative flex size-[16px] flex-shrink-0 items-center justify-center'>
<File className='size-[14px] text-[var(--text-icon)]' />
</div>
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 font-base text-[var(--text-body)]'>
<span className='truncate'>
<HighlightedText text={name} query={query} />
</span>
</span>
{folderPath && folderPath.length > 0 && (
<span className='ml-auto flex min-w-0 pl-2 font-base text-[var(--text-subtle)] text-small'>
{folderPath.length > 1 && (
<>
<span className='min-w-0 truncate [flex-shrink:9999]'>
{folderPath.slice(0, -1).join(' / ')}
</span>
<span className='flex-shrink-0 whitespace-pre'> / </span>
</>
)}
<span className='min-w-0 truncate'>{folderPath[folderPath.length - 1]}</span>
</span>
)}
</Command.Item>
)
},
(prev, next) =>
prev.value === next.value &&
prev.name === next.name &&
prev.query === next.query &&
(prev.folderPath === next.folderPath ||
(prev.folderPath?.length === next.folderPath?.length &&
(prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i])))
)
export const MemoizedTaskItem = memo(
function TaskItem({
value,
onSelect,
name,
query,
}: {
value: string
onSelect: () => void
name: string
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<span className='truncate text-[var(--text-body)]'>
<HighlightedText text={name} query={query} />
</span>
</Command.Item>
)
},
(prev, next) => prev.value === next.value && prev.name === next.name && prev.query === next.query
)
export const MemoizedWorkspaceItem = memo(
function WorkspaceItem({
value,
onSelect,
name,
isCurrent,
query,
}: {
value: string
onSelect: () => void
name: string
isCurrent?: boolean
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<span className='flex min-w-0 text-[var(--text-body)]'>
<span className='truncate'>
<HighlightedText text={name} query={query} />
</span>
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
</span>
</Command.Item>
)
},
(prev, next) =>
prev.value === next.value &&
prev.name === next.name &&
prev.isCurrent === next.isCurrent &&
prev.query === next.query
)
export const MemoizedPageItem = memo(
function PageItem({
value,
onSelect,
icon: Icon,
name,
shortcut,
query,
}: {
value: string
onSelect: () => void
icon: ComponentType<{ className?: string }>
name: string
shortcut?: string
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[var(--text-body)]'>
<HighlightedText text={name} query={query} />
</span>
{shortcut && (
<span className='ml-auto flex-shrink-0 text-[var(--text-subtle)] text-small'>
{shortcut}
</span>
)}
</Command.Item>
)
},
(prev, next) =>
prev.value === next.value &&
prev.icon === next.icon &&
prev.name === next.name &&
prev.shortcut === next.shortcut &&
prev.query === next.query
)
export const MemoizedIconItem = memo(
function IconItem({
value,
onSelect,
name,
icon: Icon,
query,
}: {
value: string
onSelect: () => void
name: string
icon: ComponentType<{ className?: string }>
query?: string
}) {
return (
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='truncate text-[var(--text-body)]'>
<HighlightedText text={name} query={query} />
</span>
</Command.Item>
)
},
(prev, next) =>
prev.value === next.value &&
prev.name === next.name &&
prev.icon === next.icon &&
prev.query === next.query
)
@@ -0,0 +1,11 @@
export {
HighlightedText,
MemoizedActionItem,
MemoizedCommandItem,
MemoizedFileItem,
MemoizedIconItem,
MemoizedPageItem,
MemoizedTaskItem,
MemoizedWorkflowItem,
MemoizedWorkspaceItem,
} from './command-items'
@@ -0,0 +1,25 @@
export {
MemoizedCommandItem,
MemoizedFileItem,
MemoizedIconItem,
MemoizedPageItem,
MemoizedTaskItem,
MemoizedWorkflowItem,
MemoizedWorkspaceItem,
} from './command-items'
export {
BlocksGroup,
ChatsGroup,
ConnectedAccountsGroup,
DocsGroup,
FilesGroup,
IntegrationsGroup,
KnowledgeBasesGroup,
PagesGroup,
TablesGroup,
ToolOpsGroup,
ToolsGroup,
TriggersGroup,
WorkflowsGroup,
WorkspacesGroup,
} from './search-groups'
@@ -0,0 +1,17 @@
export {
ActionsGroup,
BlocksGroup,
ChatsGroup,
ConnectedAccountsGroup,
DocsGroup,
FilesGroup,
IntegrationsGroup,
KnowledgeBasesGroup,
PagesGroup,
TablesGroup,
ToolOpsGroup,
ToolsGroup,
TriggersGroup,
WorkflowsGroup,
WorkspacesGroup,
} from './search-groups'
@@ -0,0 +1,403 @@
'use client'
import type { ComponentType } from 'react'
import { memo } from 'react'
import { Database, Table } from '@sim/emcn/icons'
import { Command } from 'cmdk'
import {
MemoizedActionItem,
MemoizedCommandItem,
MemoizedFileItem,
MemoizedIconItem,
MemoizedPageItem,
MemoizedTaskItem,
MemoizedWorkflowItem,
MemoizedWorkspaceItem,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items'
import type {
ActionItem,
FileItem,
IntegrationSearchItem,
PageItem,
TaskItem,
WorkflowItem,
WorkspaceItem,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import { GROUP_HEADING_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import type {
SearchBlockItem,
SearchDocItem,
SearchToolOperationItem,
} from '@/stores/modals/search/types'
export const ActionsGroup = memo(function ActionsGroup({
items,
onSelect,
query,
}: {
items: ActionItem[]
onSelect: (action: ActionItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Actions' className={GROUP_HEADING_CLASSNAME}>
{items.map((action) => (
<MemoizedActionItem
key={action.id}
value={`${action.name} ${action.keywords ?? ''} action-${action.id}`}
onSelect={() => onSelect(action)}
icon={action.icon}
name={action.name}
shortcut={action.shortcut}
query={query}
/>
))}
</Command.Group>
)
})
export const BlocksGroup = memo(function BlocksGroup({
items,
onSelect,
query,
}: {
items: SearchBlockItem[]
onSelect: (block: SearchBlockItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Blocks' className={GROUP_HEADING_CLASSNAME}>
{items.map((block) => (
<MemoizedCommandItem
key={block.id}
value={`${block.name} block-${block.id}`}
onSelect={() => onSelect(block)}
icon={block.icon}
bgColor={block.bgColor}
showColoredIcon
label={block.name}
query={query}
/>
))}
</Command.Group>
)
})
export const ToolsGroup = memo(function ToolsGroup({
items,
onSelect,
query,
}: {
items: SearchBlockItem[]
onSelect: (tool: SearchBlockItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Tools' className={GROUP_HEADING_CLASSNAME}>
{items.map((tool) => (
<MemoizedCommandItem
key={tool.id}
value={`${tool.name} tool-${tool.id}`}
onSelect={() => onSelect(tool)}
icon={tool.icon}
bgColor={tool.bgColor}
showColoredIcon
label={tool.name}
query={query}
/>
))}
</Command.Group>
)
})
export const TriggersGroup = memo(function TriggersGroup({
items,
onSelect,
query,
}: {
items: SearchBlockItem[]
onSelect: (trigger: SearchBlockItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Triggers' className={GROUP_HEADING_CLASSNAME}>
{items.map((trigger) => (
<MemoizedCommandItem
key={trigger.id}
value={`${trigger.name} trigger-${trigger.id}`}
onSelect={() => onSelect(trigger)}
icon={trigger.icon}
bgColor={trigger.bgColor}
showColoredIcon
label={trigger.name}
query={query}
/>
))}
</Command.Group>
)
})
export const ToolOpsGroup = memo(function ToolOpsGroup({
items,
onSelect,
query,
}: {
items: SearchToolOperationItem[]
onSelect: (op: SearchToolOperationItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Tool operations' className={GROUP_HEADING_CLASSNAME}>
{items.map((op) => (
<MemoizedCommandItem
key={op.id}
value={`${op.searchValue} operation-${op.id}`}
onSelect={() => onSelect(op)}
icon={op.icon}
bgColor={op.bgColor}
showColoredIcon
label={op.name}
query={query}
/>
))}
</Command.Group>
)
})
export const DocsGroup = memo(function DocsGroup({
items,
onSelect,
query,
}: {
items: SearchDocItem[]
onSelect: (doc: SearchDocItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Docs' className={GROUP_HEADING_CLASSNAME}>
{items.map((doc) => (
<MemoizedCommandItem
key={doc.id}
value={`${doc.name} docs documentation doc-${doc.id}`}
onSelect={() => onSelect(doc)}
icon={doc.icon}
bgColor='#6B7280'
showColoredIcon
label={doc.name}
query={query}
/>
))}
</Command.Group>
)
})
export const WorkflowsGroup = memo(function WorkflowsGroup({
items,
onSelect,
query,
}: {
items: WorkflowItem[]
onSelect: (workflow: WorkflowItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Workflows' className={GROUP_HEADING_CLASSNAME}>
{items.map((workflow) => (
<MemoizedWorkflowItem
key={workflow.id}
value={`${workflow.name} ${workflow.folderPath?.join(' / ') ?? ''} workflow-${workflow.id}`}
onSelect={() => onSelect(workflow)}
name={workflow.name}
folderPath={workflow.folderPath}
isCurrent={workflow.isCurrent}
query={query}
/>
))}
</Command.Group>
)
})
export const ChatsGroup = memo(function ChatsGroup({
items,
onSelect,
query,
}: {
items: TaskItem[]
onSelect: (task: TaskItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Chats' className={GROUP_HEADING_CLASSNAME}>
{items.map((task) => (
<MemoizedTaskItem
key={task.id}
value={`${task.name} task-${task.id}`}
onSelect={() => onSelect(task)}
name={task.name}
query={query}
/>
))}
</Command.Group>
)
})
export const WorkspacesGroup = memo(function WorkspacesGroup({
items,
onSelect,
query,
}: {
items: WorkspaceItem[]
onSelect: (workspace: WorkspaceItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Workspaces' className={GROUP_HEADING_CLASSNAME}>
{items.map((workspace) => (
<MemoizedWorkspaceItem
key={workspace.id}
value={`${workspace.name} workspace-${workspace.id}`}
onSelect={() => onSelect(workspace)}
name={workspace.name}
isCurrent={workspace.isCurrent}
query={query}
/>
))}
</Command.Group>
)
})
export const PagesGroup = memo(function PagesGroup({
items,
onSelect,
query,
}: {
items: PageItem[]
onSelect: (page: PageItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Pages' className={GROUP_HEADING_CLASSNAME}>
{items.map((page) => (
<MemoizedPageItem
key={page.id}
value={`${page.name} page-${page.id}`}
onSelect={() => onSelect(page)}
icon={page.icon}
name={page.name}
shortcut={page.shortcut}
query={query}
/>
))}
</Command.Group>
)
})
export const TablesGroup = createIconGroup('Tables', 'table', Table)
export const KnowledgeBasesGroup = createIconGroup('Knowledge bases', 'knowledge-base', Database)
export const ConnectedAccountsGroup = createColoredIconGroup('Connected', 'connected-account')
export const IntegrationsGroup = createColoredIconGroup('Integrations', 'integration')
export const FilesGroup = memo(function FilesGroup({
items,
onSelect,
query,
}: {
items: FileItem[]
onSelect: (file: FileItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
<Command.Group heading='Files' className={GROUP_HEADING_CLASSNAME}>
{items.map((file) => (
<MemoizedFileItem
key={file.id}
value={`${file.name} ${file.folderPath?.join(' / ') ?? ''} file-${file.id}`}
onSelect={() => onSelect(file)}
name={file.name}
folderPath={file.folderPath}
query={query}
/>
))}
</Command.Group>
)
})
/**
* Factory for groups that render each item with its own brand icon on a
* brand-colored tile (the same `showColoredIcon` pattern used by
* `BlocksGroup` / `ToolsGroup`). Used for integrations and connected accounts
* where every row has a distinct per-item icon and brand color.
*/
function createColoredIconGroup(heading: string, prefix: string) {
return memo(function ColoredIconGroup({
items,
onSelect,
query,
}: {
items: IntegrationSearchItem[]
onSelect: (item: IntegrationSearchItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
<Command.Group heading={heading} className={GROUP_HEADING_CLASSNAME}>
{items.map((item) => (
<MemoizedCommandItem
key={item.id}
value={`${item.name} ${prefix}-${item.id}`}
onSelect={() => onSelect(item)}
icon={item.icon}
bgColor={item.bgColor}
showColoredIcon
label={item.name}
query={query}
/>
))}
</Command.Group>
)
})
}
function createIconGroup(
heading: string,
prefix: string,
icon: ComponentType<{ className?: string }>
) {
return memo(function IconGroup({
items,
onSelect,
query,
}: {
items: TaskItem[]
onSelect: (item: TaskItem) => void
query?: string
}) {
if (items.length === 0) return null
return (
<Command.Group heading={heading} className={GROUP_HEADING_CLASSNAME}>
{items.map((item) => (
<MemoizedIconItem
key={item.id}
value={`${item.name} ${prefix}-${item.id}`}
onSelect={() => onSelect(item)}
name={item.name}
icon={icon}
query={query}
/>
))}
</Command.Group>
)
})
}
@@ -0,0 +1,2 @@
export type { SearchModalProps } from './search-modal'
export { SearchModal } from './search-modal'
@@ -0,0 +1,96 @@
import type { ComponentType } from 'react'
import { blockTypeToIconMap, INTEGRATIONS } from '@/lib/integrations'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import {
CONNECT_MODE,
CONNECT_QUERY_PARAM,
} from '@/app/workspace/[workspaceId]/integrations/connect-route'
import type { IntegrationSearchItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
import type { WorkspaceCredential } from '@/hooks/queries/credentials'
/** Fallback brand color for credentials whose integration metadata cannot be resolved. */
const FALLBACK_BG_COLOR = '#6B7280'
/**
* Module-level lookup of integration metadata by OAuth service display name
* (case-insensitive). Mirrors the same map in `integrations.tsx`.
*/
const INTEGRATION_BY_LOWER_NAME = new Map(INTEGRATIONS.map((i) => [i.name.toLowerCase(), i]))
/**
* Module-level base array of resolvable integrations (entries without a
* registered icon are dropped, matching the catalog's `if (!Icon) return null`
* guard). Workspace-independent; `href` is injected per call.
*/
const INTEGRATION_BASES: readonly {
id: string
name: string
icon: ComponentType<{ className?: string }>
bgColor: string
slug: string
authType: string
}[] = INTEGRATIONS.flatMap((integration) => {
const icon = blockTypeToIconMap[integration.type]
if (!icon) return []
return [
{
id: integration.slug,
name: integration.name,
icon,
bgColor: integration.bgColor,
slug: integration.slug,
authType: integration.authType,
},
]
})
/**
* Builds the full integration catalog as search items for a given workspace.
* OAuth integrations link directly to the detail page with `?connect=oauth` so
* the connect modal auto-opens (via the detail page's `useEffect` on
* `CONNECT_QUERY_PARAM`). Non-OAuth integrations link to the plain detail page.
*/
export function buildIntegrationSearchItems(workspaceId: string): IntegrationSearchItem[] {
return INTEGRATION_BASES.map((base) => {
const connectSuffix =
base.authType === 'oauth' ? `?${CONNECT_QUERY_PARAM}=${CONNECT_MODE.oauth}` : ''
return {
id: base.id,
name: base.name,
icon: base.icon,
bgColor: base.bgColor,
href: `/workspace/${workspaceId}/integrations/${base.slug}${connectSuffix}`,
}
})
}
/**
* Builds search items for the user's connected OAuth / service-account
* credentials. Each item links to its credential detail page. Credentials
* without a resolvable OAuth service are silently dropped (same guard as
* `integrations.tsx`'s `connectedItems` memo).
*/
export function buildConnectedAccountSearchItems(
credentials: readonly WorkspaceCredential[],
workspaceId: string
): IntegrationSearchItem[] {
return credentials.flatMap((credential) => {
if (credential.type !== 'oauth' && credential.type !== 'service_account') return []
if (!credential.providerId) return []
const service = getServiceConfigByProviderId(credential.providerId)
if (!service) return []
const integration = INTEGRATION_BY_LOWER_NAME.get(service.name.toLowerCase())
return [
{
id: credential.id,
name: credential.displayName,
icon: service.icon as ComponentType<{ className?: string }>,
bgColor: integration?.bgColor ?? FALLBACK_BG_COLOR,
href: `/workspace/${workspaceId}/integrations/connected/${credential.id}`,
},
]
})
}
@@ -0,0 +1,819 @@
'use client'
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { cn, Library } from '@sim/emcn'
import {
Calendar,
Database,
Duplicate,
File,
FolderPlus,
HelpCircle,
Home,
Integration,
Key,
Play,
Plus,
Search,
Send,
Settings,
Table,
Upload,
} from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { Command } from 'cmdk'
import { Scan } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { createPortal } from 'react-dom'
import { captureEvent } from '@/lib/posthog/client'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { useInvokeGlobalCommand } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import {
CMDK_ITEM_GAP_CLASS,
CMDK_SECTION_GAP_CLASS,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useSearchModalStore } from '@/stores/modals/search/store'
import type {
SearchBlockItem,
SearchDocItem,
SearchSection,
SearchToolOperationItem,
} from '@/stores/modals/search/types'
import {
ActionsGroup,
BlocksGroup,
ChatsGroup,
ConnectedAccountsGroup,
DocsGroup,
FilesGroup,
IntegrationsGroup,
KnowledgeBasesGroup,
PagesGroup,
TablesGroup,
ToolOpsGroup,
ToolsGroup,
TriggersGroup,
WorkflowsGroup,
WorkspacesGroup,
} from './components/search-groups'
import type {
ActionItem,
FileItem,
IntegrationSearchItem,
PageItem,
SearchModalProps,
TaskItem,
WorkflowItem,
WorkspaceItem,
} from './utils'
import { filterAndCap, filterAndSort } from './utils'
const logger = createLogger('SearchModal')
export type { SearchModalProps } from './utils'
export function SearchModal({
open,
onOpenChange,
workflows = [],
workspaces = [],
chats = [],
tables = [],
files = [],
knowledgeBases = [],
integrations = [],
connectedAccounts = [],
isOnWorkflowPage = false,
isOnIntegrationsPage = false,
canEdit = false,
onCreateWorkflow,
onCreateFolder,
onImportWorkflow,
}: SearchModalProps) {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const inputRef = useRef<HTMLInputElement>(null)
const [mounted, setMounted] = useState(false)
const { navigateToSettings } = useSettingsNavigation()
const { config: permissionConfig } = usePermissionConfig()
const invokeCommand = useInvokeGlobalCommand()
const posthog = usePostHog()
const routerRef = useRef(router)
routerRef.current = router
const onOpenChangeRef = useRef(onOpenChange)
onOpenChangeRef.current = onOpenChange
const posthogRef = useRef(posthog)
posthogRef.current = posthog
useEffect(() => {
setMounted(true)
}, [])
const { blocks, tools, triggers, toolOperations, docs } = useSearchModalStore(
(state) => state.data
)
const sections = useSearchModalStore((state) => state.sections)
const showSection = (key: SearchSection) => !sections || sections.includes(key)
const openHelpModal = useCallback(() => {
window.dispatchEvent(new CustomEvent('open-help-modal'))
}, [])
const pages = useMemo(
(): PageItem[] =>
[
{
id: 'integrations',
name: 'Integrations',
icon: Integration,
href: `/workspace/${workspaceId}/integrations`,
hidden: permissionConfig.hideIntegrationsTab,
},
{
id: 'tables',
name: 'Tables',
icon: Table,
href: `/workspace/${workspaceId}/tables`,
hidden: permissionConfig.hideTablesTab,
},
{
id: 'files',
name: 'Files',
icon: File,
href: `/workspace/${workspaceId}/files`,
hidden: permissionConfig.hideFilesTab,
},
{
id: 'knowledge-base',
name: 'Knowledge base',
icon: Database,
href: `/workspace/${workspaceId}/knowledge`,
hidden: permissionConfig.hideKnowledgeBaseTab,
},
{
id: 'scheduled-tasks',
name: 'Scheduled tasks',
icon: Calendar,
href: `/workspace/${workspaceId}/scheduled-tasks`,
},
{
id: 'logs',
name: 'Logs',
icon: Library,
href: `/workspace/${workspaceId}/logs`,
shortcut: '⌘⇧L',
},
{
id: 'secrets',
name: 'Secrets',
icon: Key,
href: `/workspace/${workspaceId}/settings/secrets`,
},
{
id: 'help',
name: 'Help',
icon: HelpCircle,
onClick: openHelpModal,
},
{
id: 'settings',
name: 'Settings',
icon: Settings,
onClick: navigateToSettings,
},
].filter((page) => !page.hidden),
[
workspaceId,
openHelpModal,
navigateToSettings,
permissionConfig.hideKnowledgeBaseTab,
permissionConfig.hideTablesTab,
permissionConfig.hideFilesTab,
permissionConfig.hideIntegrationsTab,
]
)
/**
* Verbs the palette can run directly. Entity navigation lives in the groups
* below; this list is for "do something" intents (run, create, import, copy,
* invite).
*/
const actions = useMemo((): ActionItem[] => {
const list: ActionItem[] = []
list.push({
id: 'run-workflow',
name: 'Run workflow',
keywords: 'execute start play test',
icon: Play,
shortcut: '⌘↵',
context: 'workflow',
run: () => invokeCommand('run-workflow'),
})
list.push({
id: 'new-chat',
name: 'New chat',
keywords: 'chat message ask sim assistant home',
icon: Home,
context: 'global',
run: () => routerRef.current.push(`/workspace/${workspaceId}/home`),
})
if (canEdit && onCreateWorkflow) {
list.push({
id: 'create-workflow',
name: 'Create workflow',
keywords: 'new add build',
icon: Plus,
context: 'global',
run: onCreateWorkflow,
})
}
if (canEdit && onCreateFolder) {
list.push({
id: 'create-folder',
name: 'Create folder',
keywords: 'new add group',
icon: FolderPlus,
context: 'global',
run: onCreateFolder,
})
}
if (canEdit && onImportWorkflow) {
list.push({
id: 'import-workflow',
name: 'Import workflow',
keywords: 'upload add',
icon: Upload,
context: 'global',
run: onImportWorkflow,
})
}
list.push({
id: 'fit-to-view',
name: 'Fit workflow to view',
keywords: 'zoom center recenter canvas reset',
icon: Scan,
shortcut: '⌘⇧F',
context: 'workflow',
run: () => invokeCommand('fit-to-view'),
})
list.push({
id: 'copy-workflow-url',
name: 'Copy workflow link',
keywords: 'url share clipboard',
icon: Duplicate,
context: 'workflow',
run: () => {
navigator.clipboard.writeText(window.location.href).catch((error) => {
logger.error('Failed to copy workflow link to clipboard', { error })
})
},
})
list.push({
id: 'invite-teammates',
name: 'Invite teammates',
keywords: 'members people add user organization',
icon: Send,
context: 'global',
run: () => navigateToSettings({ section: 'teammates' }),
})
return list
}, [
workspaceId,
canEdit,
onCreateWorkflow,
onCreateFolder,
onImportWorkflow,
invokeCommand,
navigateToSettings,
])
const [search, setSearch] = useState('')
const [prevOpen, setPrevOpen] = useState(open)
if (open !== prevOpen) {
setPrevOpen(open)
if (open) setSearch('')
}
useEffect(() => {
if (!open || !inputRef.current) return
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
)?.set
if (nativeInputValueSetter) {
nativeInputValueSetter.call(inputRef.current, '')
inputRef.current.dispatchEvent(new Event('input', { bubbles: true }))
}
inputRef.current.focus()
}, [open])
const deferredSearch = useDeferredValue(search)
const deferredSearchRef = useRef(deferredSearch)
deferredSearchRef.current = deferredSearch
const handleSearchChange = useCallback((value: string) => {
setSearch(value)
requestAnimationFrame(() => {
const list = document.querySelector('[cmdk-list]')
if (list) {
list.scrollTop = 0
}
})
}, [])
useEffect(() => {
if (!open) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onOpenChangeRef.current(false)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open])
const handleBlockSelect = useCallback(
(block: SearchBlockItem, type: 'block' | 'trigger' | 'tool') => {
const enableTriggerMode =
type === 'trigger' && block.config ? hasTriggerCapability(block.config) : false
window.dispatchEvent(
new CustomEvent('add-block-from-toolbar', {
detail: {
type: block.type,
enableTriggerMode,
pendingConnect: useSearchModalStore.getState().pendingConnect,
},
})
)
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: type,
query_length: deferredSearchRef.current.length,
workspace_id: workspaceId,
})
onOpenChangeRef.current(false)
},
[workspaceId]
)
const handleToolOperationSelect = useCallback(
(op: SearchToolOperationItem) => {
window.dispatchEvent(
new CustomEvent('add-block-from-toolbar', {
detail: {
type: op.blockType,
presetOperation: op.operationId,
pendingConnect: useSearchModalStore.getState().pendingConnect,
},
})
)
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'tool_operation',
query_length: deferredSearchRef.current.length,
workspace_id: workspaceId,
})
onOpenChangeRef.current(false)
},
[workspaceId]
)
const handleWorkflowSelect = useCallback(
(workflow: WorkflowItem) => {
if (!workflow.isCurrent && workflow.href) {
routerRef.current.push(workflow.href)
window.dispatchEvent(
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflow.id } })
)
}
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'workflow',
query_length: deferredSearchRef.current.length,
workspace_id: workspaceId,
})
onOpenChangeRef.current(false)
},
[workspaceId]
)
const handleWorkspaceSelect = useCallback(
(workspace: WorkspaceItem) => {
if (!workspace.isCurrent && workspace.href) {
routerRef.current.push(workspace.href)
}
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'workspace',
query_length: deferredSearchRef.current.length,
workspace_id: workspaceId,
})
onOpenChangeRef.current(false)
},
[workspaceId]
)
const handleChatSelect = useCallback(
(chat: TaskItem) => {
routerRef.current.push(chat.href)
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'task',
query_length: deferredSearchRef.current.length,
workspace_id: workspaceId,
})
onOpenChangeRef.current(false)
},
[workspaceId]
)
const handleTableSelect = useCallback(
(item: TaskItem) => {
routerRef.current.push(item.href)
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'table',
query_length: deferredSearchRef.current.length,
workspace_id: workspaceId,
})
onOpenChangeRef.current(false)
},
[workspaceId]
)
const handleFileSelect = useCallback(
(item: FileItem) => {
routerRef.current.push(item.href)
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'file',
query_length: deferredSearchRef.current.length,
workspace_id: workspaceId,
})
onOpenChangeRef.current(false)
},
[workspaceId]
)
const handleKbSelect = useCallback(
(item: TaskItem) => {
routerRef.current.push(item.href)
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'knowledge_base',
query_length: deferredSearchRef.current.length,
workspace_id: workspaceId,
})
onOpenChangeRef.current(false)
},
[workspaceId]
)
const handlePageSelect = useCallback(
(page: PageItem) => {
if (page.onClick) {
page.onClick()
} else if (page.href) {
if (page.href.startsWith('http')) {
window.open(page.href, '_blank', 'noopener,noreferrer')
} else {
routerRef.current.push(page.href)
}
}
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'page',
query_length: deferredSearchRef.current.length,
workspace_id: workspaceId,
})
onOpenChangeRef.current(false)
},
[workspaceId]
)
const handleDocSelect = useCallback(
(doc: SearchDocItem) => {
window.open(doc.href, '_blank', 'noopener,noreferrer')
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'docs',
query_length: deferredSearchRef.current.length,
workspace_id: workspaceId,
})
onOpenChangeRef.current(false)
},
[workspaceId]
)
const handleConnectedAccountSelect = useCallback(
(item: IntegrationSearchItem) => {
routerRef.current.push(item.href)
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'connected_account',
query_length: deferredSearchRef.current.length,
workspace_id: workspaceId,
})
onOpenChangeRef.current(false)
},
[workspaceId]
)
const handleIntegrationSelect = useCallback(
(item: IntegrationSearchItem) => {
routerRef.current.push(item.href)
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'integration',
query_length: deferredSearchRef.current.length,
workspace_id: workspaceId,
})
onOpenChangeRef.current(false)
},
[workspaceId]
)
const handleActionSelect = useCallback(
(item: ActionItem) => {
onOpenChangeRef.current(false)
item.run()
captureEvent(posthogRef.current, 'search_result_selected', {
result_type: 'action',
action_id: item.id,
query_length: deferredSearchRef.current.length,
workspace_id: workspaceId,
})
},
[workspaceId]
)
const handleBlockSelectAsBlock = useCallback(
(block: SearchBlockItem) => handleBlockSelect(block, 'block'),
[handleBlockSelect]
)
const handleBlockSelectAsTool = useCallback(
(tool: SearchBlockItem) => handleBlockSelect(tool, 'tool'),
[handleBlockSelect]
)
const handleBlockSelectAsTrigger = useCallback(
(trigger: SearchBlockItem) => handleBlockSelect(trigger, 'trigger'),
[handleBlockSelect]
)
const handleOverlayClick = useCallback(() => {
onOpenChangeRef.current(false)
}, [])
const filteredActions = useMemo(() => {
const available = actions.filter(
(a) =>
a.context === 'global' ||
(a.context === 'workflow' && isOnWorkflowPage) ||
(a.context === 'integrations' && isOnIntegrationsPage)
)
return filterAndSort(available, (a) => `${a.name} ${a.keywords ?? ''}`, deferredSearch)
}, [actions, isOnWorkflowPage, isOnIntegrationsPage, deferredSearch])
/**
* Blocks and tools rank by name first, with `searchValue` (type + option
* labels) as a lower-tier fallback, so an exact name match wins while a block
* stays findable by an option label.
*/
const filteredBlocks = useMemo(() => {
if (!isOnWorkflowPage) return []
return filterAndCap(
blocks,
(b) => b.name,
deferredSearch,
(b) => b.searchValue
)
}, [isOnWorkflowPage, blocks, deferredSearch])
const filteredTools = useMemo(() => {
if (!isOnWorkflowPage) return []
return filterAndCap(
tools,
(t) => t.name,
deferredSearch,
(t) => t.searchValue
)
}, [isOnWorkflowPage, tools, deferredSearch])
const filteredTriggers = useMemo(() => {
if (!isOnWorkflowPage) return []
return filterAndCap(triggers, (t) => `${t.name} ${t.id}`, deferredSearch)
}, [isOnWorkflowPage, triggers, deferredSearch])
const filteredToolOps = useMemo(() => {
if (!isOnWorkflowPage) return []
return filterAndCap(toolOperations, (op) => op.searchValue, deferredSearch)
}, [isOnWorkflowPage, toolOperations, deferredSearch])
const filteredDocs = useMemo(() => {
if (!isOnWorkflowPage) return []
return filterAndCap(docs, (d) => `${d.name} docs documentation`, deferredSearch)
}, [isOnWorkflowPage, docs, deferredSearch])
const filteredTables = useMemo(
() => filterAndCap(tables, (t) => t.name, deferredSearch),
[tables, deferredSearch]
)
const filteredFiles = useMemo(
() => filterAndCap(files, (f) => `${f.name} ${f.folderPath?.join(' ') ?? ''}`, deferredSearch),
[files, deferredSearch]
)
const filteredKnowledgeBases = useMemo(
() => filterAndCap(knowledgeBases, (kb) => kb.name, deferredSearch),
[knowledgeBases, deferredSearch]
)
const filteredWorkflows = useMemo(
() =>
filterAndCap(workflows, (w) => `${w.name} ${w.folderPath?.join(' ') ?? ''}`, deferredSearch),
[workflows, deferredSearch]
)
const filteredChats = useMemo(
() => filterAndCap(chats, (t) => t.name, deferredSearch),
[chats, deferredSearch]
)
const filteredWorkspaces = useMemo(
() => filterAndCap(workspaces, (w) => w.name, deferredSearch),
[workspaces, deferredSearch]
)
const filteredPages = useMemo(
() => filterAndSort(pages, (p) => p.name, deferredSearch),
[pages, deferredSearch]
)
/** Connected accounts: visible on the integrations page even with empty input. */
const filteredConnectedAccounts = useMemo(() => {
if (!isOnIntegrationsPage) return []
return filterAndCap(connectedAccounts, (a) => a.name, deferredSearch)
}, [isOnIntegrationsPage, connectedAccounts, deferredSearch])
/** Catalog integrations: only shown once the user has typed something. */
const filteredIntegrations = useMemo(() => {
if (!isOnIntegrationsPage || !deferredSearch.trim()) return []
return filterAndCap(integrations, (i) => i.name, deferredSearch)
}, [isOnIntegrationsPage, deferredSearch, integrations])
if (!mounted) return null
return createPortal(
<>
<div
className={cn(
'fixed inset-0 z-40 transition-opacity duration-100',
open ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
onClick={handleOverlayClick}
aria-hidden={!open}
/>
<div
role='dialog'
aria-modal={open}
aria-hidden={!open}
aria-label='Search'
className={cn(
'-translate-x-1/2 fixed top-[15%] z-50 w-[500px] rounded-xl border border-[var(--border-muted)] bg-[var(--surface-4)] p-[3px] shadow-[var(--shadow-overlay)] dark:bg-[var(--surface-5)]',
open ? 'visible opacity-100' : 'invisible opacity-0'
)}
style={{
left: isOnWorkflowPage
? 'calc(50% + (var(--sidebar-width) - var(--panel-width)) / 2)'
: 'calc(var(--sidebar-width) / 2 + 50%)',
}}
>
<div className='overflow-hidden rounded-lg border border-[var(--border-1)] bg-[var(--bg)]'>
<Command label='Search' shouldFilter={false}>
<div className='mx-2 mt-2 flex h-[30px] items-center gap-1.5 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 dark:bg-[var(--surface-4)]'>
<Search className='size-[14px] flex-shrink-0 text-[var(--text-muted)]' />
<Command.Input
ref={inputRef}
autoFocus
onValueChange={handleSearchChange}
placeholder='Search anything...'
className='h-full w-full bg-transparent text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)] focus:outline-none'
/>
</div>
<Command.List
className={cn(
'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent max-h-[400px] overflow-y-auto overflow-x-hidden px-2 pt-3 pb-2 [&_[cmdk-group-items]]:flex [&_[cmdk-group-items]]:flex-col',
CMDK_ITEM_GAP_CLASS,
CMDK_SECTION_GAP_CLASS
)}
>
<Command.Empty className='flex items-center justify-center px-4 py-6 text-[var(--text-subtle)] text-sm'>
No results found.
</Command.Empty>
{showSection('actions') && (
<ActionsGroup
items={filteredActions}
onSelect={handleActionSelect}
query={deferredSearch}
/>
)}
{showSection('connectedAccounts') && (
<ConnectedAccountsGroup
items={filteredConnectedAccounts}
onSelect={handleConnectedAccountSelect}
query={deferredSearch}
/>
)}
{showSection('integrations') && (
<IntegrationsGroup
items={filteredIntegrations}
onSelect={handleIntegrationSelect}
query={deferredSearch}
/>
)}
{showSection('blocks') && (
<BlocksGroup
items={filteredBlocks}
onSelect={handleBlockSelectAsBlock}
query={deferredSearch}
/>
)}
{showSection('tools') && (
<ToolsGroup
items={filteredTools}
onSelect={handleBlockSelectAsTool}
query={deferredSearch}
/>
)}
{showSection('triggers') && (
<TriggersGroup
items={filteredTriggers}
onSelect={handleBlockSelectAsTrigger}
query={deferredSearch}
/>
)}
{showSection('chats') && (
<ChatsGroup
items={filteredChats}
onSelect={handleChatSelect}
query={deferredSearch}
/>
)}
{showSection('workflows') && (
<WorkflowsGroup
items={filteredWorkflows}
onSelect={handleWorkflowSelect}
query={deferredSearch}
/>
)}
{showSection('tables') && (
<TablesGroup
items={filteredTables}
onSelect={handleTableSelect}
query={deferredSearch}
/>
)}
{showSection('files') && (
<FilesGroup
items={filteredFiles}
onSelect={handleFileSelect}
query={deferredSearch}
/>
)}
{showSection('knowledgeBases') && (
<KnowledgeBasesGroup
items={filteredKnowledgeBases}
onSelect={handleKbSelect}
query={deferredSearch}
/>
)}
{showSection('toolOperations') && (
<ToolOpsGroup
items={filteredToolOps}
onSelect={handleToolOperationSelect}
query={deferredSearch}
/>
)}
{showSection('workspaces') && (
<WorkspacesGroup
items={filteredWorkspaces}
onSelect={handleWorkspaceSelect}
query={deferredSearch}
/>
)}
{showSection('docs') && (
<DocsGroup items={filteredDocs} onSelect={handleDocSelect} query={deferredSearch} />
)}
{showSection('pages') && (
<PagesGroup
items={filteredPages}
onSelect={handlePageSelect}
query={deferredSearch}
/>
)}
</Command.List>
</Command>
</div>
</div>
</>,
document.body
)
}
@@ -0,0 +1,317 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { filterAndCap, filterAndSort, fuzzyMatch, MAX_RESULTS_PER_GROUP } from './utils'
/**
* The matcher that shipped before fuzzy matching was introduced. Re-implemented
* here verbatim so the new matcher can be proven a strict superset: anything the
* old matcher returned, the new one must still return. This is the core
* no-regression guarantee.
*/
function oldScoreMatch(value: string, search: string): number {
if (!search) return 1
const v = value.toLowerCase()
const s = search.toLowerCase()
if (v === s) return 1
if (v.startsWith(s)) return 0.9
if (v.includes(s)) return 0.7
const words = s.split(/\s+/).filter(Boolean)
if (words.length > 1 && words.every((w) => v.includes(w))) return 0.5
return 0
}
function oldFilterAndSort<T>(items: T[], toValue: (item: T) => string, search: string): T[] {
if (!search) return items
const scored: [T, number][] = []
for (const item of items) {
const score = oldScoreMatch(toValue(item), search)
if (score > 0) scored.push([item, score])
}
scored.sort((a, b) => b[1] - a[1])
return scored.map(([item]) => item)
}
interface Entry {
label: string
/** The string the modal actually searches against (name + type/id junk). */
value: string
}
/** Mirrors how groups build their `value` strings (name + slug/id suffix). */
function block(label: string, slug: string): Entry {
return { label, value: `${label} ${slug} block-${slug}` }
}
function workflow(label: string, folder: string): Entry {
return { label, value: `${label} ${folder} workflow-${slugUuid(label)}` }
}
function action(label: string, keywords: string): Entry {
const id = label.toLowerCase().replace(/\s+/g, '-')
return { label, value: `${label} ${keywords} action-${id}` }
}
function slugUuid(label: string): string {
return `${label.toLowerCase().replace(/\s+/g, '')}-9f2a3b4c5d6e`
}
const CORPUS: Entry[] = [
block('Slack', 'slack'),
block('Gmail', 'gmail'),
block('Google Sheets', 'google_sheets'),
block('Google PageSpeed', 'google_pagespeed'),
block('GitHub', 'github'),
block('Notion', 'notion'),
block('Postgres', 'postgresql'),
block('OpenAI', 'openai'),
block('Airtable', 'airtable'),
block('HubSpot', 'hubspot'),
block('Linear', 'linear'),
block('Discord', 'discord'),
block('Microsoft Teams', 'microsoft_teams'),
block('Webhook', 'webhook'),
block('Schedule', 'schedule'),
block('Agent', 'agent'),
block('Function', 'function'),
block('Condition', 'condition'),
block('Router', 'router'),
block('Knowledge Base', 'knowledge'),
workflow('Customer Onboarding Flow', 'Sales'),
workflow('Daily Report', 'Ops'),
workflow('Lead Enrichment', 'Sales'),
action('Create workflow', 'new add build'),
action('Create folder', 'new add group'),
action('Import workflow', 'upload add'),
action('Toggle theme', 'dark light mode appearance color'),
]
const toValue = (e: Entry) => e.value
/**
* A broad sweep of realistic query shapes, grouped by intent: single chars,
* exact-ish names, prefixes, contains/mid-word, multi-word, initialisms and
* scattered (the new wins), typos, and genuine non-matches.
*/
const QUERIES = [
's',
'g',
'a',
'w',
'slack',
'gmail',
'github',
'notion',
'postgres',
'openai',
'agent',
'goog',
'micro',
'know',
'cond',
'rout',
'sched',
'hook',
'table',
'spot',
'mail',
'google sheets',
'sheets google',
'create workflow',
'workflow create',
'customer onboarding',
'slk',
'gps',
'msteams',
'crwf',
'cwf',
'kb',
'githb',
'postgrs',
'zzz',
'qqqq',
]
describe('fuzzyMatch / filterAndSort — no regression vs. old matcher', () => {
it('returns a strict superset of the old matcher for every query (never loses a result)', () => {
for (const query of QUERIES) {
const oldLabels = new Set(oldFilterAndSort(CORPUS, toValue, query).map((e) => e.label))
const newLabels = new Set(filterAndSort(CORPUS, toValue, query).map((e) => e.label))
for (const label of oldLabels) {
expect(
newLabels.has(label),
`query "${query}": new matcher dropped "${label}" that the old matcher returned`
).toBe(true)
}
}
})
it('preserves the old #1 result for exact/prefix/contains queries (no top-rank regression)', () => {
const exactish = [
'slack',
'gmail',
'github',
'notion',
'postgres',
'openai',
'agent',
'goog',
'micro',
'know',
'cond',
'rout',
'sched',
]
for (const query of exactish) {
const oldTop = oldFilterAndSort(CORPUS, toValue, query)[0]
const newTop = filterAndSort(CORPUS, toValue, query)[0]
if (oldTop) {
expect(newTop?.label, `query "${query}" top result changed`).toBe(oldTop.label)
}
}
})
})
describe('fuzzyMatch — new wins (initialisms & scattered)', () => {
const wins: Array<[string, string]> = [
['slk', 'Slack'],
['gps', 'Google PageSpeed'],
['crwf', 'Create workflow'],
['msteams', 'Microsoft Teams'],
]
for (const [query, expectedTop] of wins) {
it(`"${query}" surfaces "${expectedTop}" as the top result`, () => {
const results = filterAndSort(CORPUS, toValue, query)
expect(results[0]?.label).toBe(expectedTop)
})
}
it('finds initialisms the old matcher missed entirely (old returns 0 for "slk")', () => {
expect(oldFilterAndSort(CORPUS, toValue, 'slk')).toHaveLength(0)
expect(filterAndSort(CORPUS, toValue, 'slk').map((e) => e.label)).toContain('Slack')
})
})
describe('fuzzyMatch — noise control', () => {
it('rejects a mid-word scattered subsequence ("oge" in P-o-st-g-r-e-s is not a substring)', () => {
expect(fuzzyMatch('Postgres', 'oge').matched).toBe(false)
})
it('ranks every "g"-prefixed result above results that only contain "g" deeper', () => {
const results = filterAndSort(CORPUS, toValue, 'g')
const labels = results.map((e) => e.label)
const firstNonPrefix = labels.findIndex((l) => !l.toLowerCase().startsWith('g'))
const lastPrefix = labels.reduce((acc, l, i) => (l.toLowerCase().startsWith('g') ? i : acc), -1)
if (firstNonPrefix !== -1 && lastPrefix !== -1) {
expect(lastPrefix).toBeLessThan(firstNonPrefix)
}
})
it('returns no matches for genuine non-matches', () => {
expect(filterAndSort(CORPUS, toValue, 'zzz')).toHaveLength(0)
expect(filterAndSort(CORPUS, toValue, 'qqqq')).toHaveLength(0)
})
})
describe('fuzzyMatch — positions for highlighting', () => {
it('reports prefix match positions', () => {
expect(fuzzyMatch('Slack', 'sla').positions).toEqual([0, 1, 2])
})
it('reports scattered match positions for "slk" against "Slack" (S, l, k)', () => {
expect(fuzzyMatch('Slack', 'slk').positions).toEqual([0, 1, 4])
})
it('highlights the substring itself, not an earlier scattered occurrence', () => {
const result = fuzzyMatch('a_apple', 'apple')
expect(result.matched).toBe(true)
expect(result.positions).toEqual([2, 3, 4, 5, 6])
})
it('highlights a mid-string substring at its real position', () => {
expect(fuzzyMatch('Webhook', 'hook').positions).toEqual([3, 4, 5, 6])
})
it('reports empty positions for empty query', () => {
const result = fuzzyMatch('Slack', '')
expect(result.matched).toBe(true)
expect(result.positions).toEqual([])
})
it('matches multi-word tokens order-independently', () => {
const result = fuzzyMatch('Slack Send Message', 'message slack')
expect(result.matched).toBe(true)
})
})
describe('filterAndSort — name ranked above secondary text', () => {
interface Item {
name: string
searchValue: string
}
const toName = (i: Item) => i.name
const toExtra = (i: Item) => i.searchValue
it('ranks an exact name match above a substring buried in another items option text', () => {
const items: Item[] = [
// Matches "agent" only inside a long secondary string (its model catalog).
{ name: 'Pi Coding Agent', searchValue: `Pi Coding Agent pi ${'model-x '.repeat(60)}` },
// Exact name match, but an even longer secondary string.
{ name: 'Agent', searchValue: `Agent agent ${'claude-sonnet gpt-4o '.repeat(60)}` },
]
const sorted = filterAndSort(items, toName, 'agent', toExtra)
expect(sorted[0].name).toBe('Agent')
})
it('keeps every name match above every secondary-only match', () => {
const items: Item[] = [
{ name: 'Zeta', searchValue: 'Zeta agent agent agent' }, // strong secondary hit, no name hit
{ name: 'Agent', searchValue: 'Agent agent' }, // name hit
]
const sorted = filterAndSort(items, toName, 'agent', toExtra)
expect(sorted[0].name).toBe('Agent')
})
it('still surfaces an item matched only by its secondary text', () => {
const items: Item[] = [{ name: 'Agent', searchValue: 'Agent agent claude-sonnet gpt-4o' }]
expect(filterAndSort(items, toName, 'gpt-4o', toExtra)).toHaveLength(1)
})
it('is byte-identical to single-field ranking when no secondary accessor is given', () => {
const items = ['Slack message', 'Send message to Slack']
expect(filterAndSort(items, (s) => s, 'slack')).toEqual(
filterAndSort(items, (s) => s, 'slack', undefined)
)
})
})
describe('filterAndCap', () => {
const id = (s: string) => s
it('caps an active search to MAX_RESULTS_PER_GROUP', () => {
const items = Array.from({ length: MAX_RESULTS_PER_GROUP + 25 }, (_, i) => `item ${i}`)
expect(filterAndCap(items, id, 'item')).toHaveLength(MAX_RESULTS_PER_GROUP)
})
it('never caps the empty (browse) state, even above the cap', () => {
const items = Array.from({ length: MAX_RESULTS_PER_GROUP + 25 }, (_, i) => `item ${i}`)
const result = filterAndCap(items, id, '')
expect(result).toHaveLength(items.length)
expect(result).toBe(items)
})
it('treats whitespace-only input as browse: unfiltered and uncapped', () => {
const items = Array.from({ length: MAX_RESULTS_PER_GROUP + 25 }, (_, i) => `item ${i}`)
const result = filterAndCap(items, id, ' ')
expect(result).toBe(items)
})
it('returns every match untrimmed when under the cap', () => {
const items = ['Slack', 'Slate', 'Slalom']
expect(filterAndCap(items, id, 'sl')).toHaveLength(3)
})
it('caps to the top-ranked matches, preserving filterAndSort order', () => {
const items = Array.from({ length: MAX_RESULTS_PER_GROUP + 5 }, (_, i) => `item ${i}`)
const capped = filterAndCap(items, id, 'item')
expect(capped).toEqual(filterAndSort(items, id, 'item').slice(0, MAX_RESULTS_PER_GROUP))
})
})
@@ -0,0 +1,307 @@
import type { ComponentType } from 'react'
export interface IntegrationSearchItem {
id: string
name: string
href: string
icon: ComponentType<{ className?: string }>
bgColor: string
}
export interface TaskItem {
id: string
name: string
href: string
}
export interface WorkflowItem {
id: string
name: string
href: string
folderPath?: string[]
isCurrent?: boolean
}
export interface WorkspaceItem {
id: string
name: string
href: string
isCurrent?: boolean
}
export interface PageItem {
id: string
name: string
icon: ComponentType<{ className?: string }>
href?: string
onClick?: () => void
shortcut?: string
hidden?: boolean
}
export interface FileItem {
id: string
name: string
href: string
folderPath?: string[]
}
/** Where an {@link ActionItem} (a verb) is available. */
export type ActionContext = 'global' | 'workflow' | 'integrations'
/**
* An action is a verb the palette can run directly (create, import, toggle),
* as opposed to an entity the user navigates to. Actions render at the top of
* the result list so the most common "do something" intents are one keystroke
* away.
*/
export interface ActionItem {
id: string
name: string
/** Extra terms folded into the search value (e.g. "new add"). */
keywords?: string
icon: ComponentType<{ className?: string }>
shortcut?: string
context: ActionContext
run: () => void
}
export interface SearchModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workflows?: WorkflowItem[]
workspaces?: WorkspaceItem[]
chats?: TaskItem[]
tables?: TaskItem[]
files?: FileItem[]
knowledgeBases?: TaskItem[]
integrations?: IntegrationSearchItem[]
connectedAccounts?: IntegrationSearchItem[]
isOnWorkflowPage?: boolean
isOnIntegrationsPage?: boolean
canEdit?: boolean
onCreateWorkflow?: () => void
onCreateFolder?: () => void
onImportWorkflow?: () => void
}
export interface CommandItemProps {
value: string
onSelect: () => void
icon: ComponentType<{ className?: string }>
bgColor: string
showColoredIcon?: boolean
/** Primary text. Matched characters are highlighted against {@link query}. */
label: string
/** Active search query, used to bold matched characters. */
query?: string
}
export const GROUP_HEADING_CLASSNAME =
'[&_[cmdk-group-heading]]:flex [&_[cmdk-group-heading]]:h-[18px] [&_[cmdk-group-heading]]:items-center [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:mb-2 [&_[cmdk-group-heading]]:text-small [&_[cmdk-group-heading]]:text-[var(--text-muted)]'
export const COMMAND_ITEM_CLASSNAME =
'group mx-0.5 flex h-[30px] w-full cursor-pointer items-center gap-2 rounded-lg border border-transparent px-2 text-left text-sm aria-selected:border-[var(--border-1)] aria-selected:bg-[var(--surface-active)] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50'
/** Characters that begin a new word — a match here scores higher. */
const SEPARATORS = new Set([' ', '-', '_', '/', '.', ':', '(', ')'])
/** Result of matching a query against a single candidate string. */
export interface FuzzyResult {
/** Whether every query character was found, in order. */
matched: boolean
/** Relative ranking score; higher sorts first. Only meaningful when matched. */
score: number
/** Indices into the candidate string that matched, ascending. Read-only. */
positions: readonly number[]
}
/**
* Shared singleton for the no-match case. The frozen empty array makes the
* read-only contract explicit and guarantees the shared instance can never be
* mutated by a caller.
*/
const NO_MATCH: FuzzyResult = { matched: false, score: 0, positions: Object.freeze([]) }
function isCamelBoundary(text: string, index: number): boolean {
if (index === 0) return false
const prev = text[index - 1]
const curr = text[index]
return prev === prev.toLowerCase() && curr !== curr.toLowerCase() && curr === curr.toUpperCase()
}
/**
* A "hard" boundary: the start of the string or immediately after a separator.
* Used to anchor scattered matches. Deliberately excludes camelCase so a fuzzy
* match cannot *start* in the middle of a word (e.g. the `S` in "PageSpeed"),
* which would let short queries scatter-match unrelated items. Interior
* camelCase still earns a scoring bonus — it just cannot anchor a match.
*/
function isHardBoundary(lowerText: string, index: number): boolean {
return index === 0 || SEPARATORS.has(lowerText[index - 1])
}
/**
* Order-independent fallback: a multi-word query matches when every token
* appears somewhere in the text. Preserves the original matcher's multi-word
* behavior (`message slack` → "Slack Send Message"). Single-word queries that
* reach here did not match as exact/prefix/contains and are rejected, so this
* never broadens single-token matching beyond the original behavior.
*/
function tokenFallback(lowerText: string, lowerQuery: string): FuzzyResult {
const tokens = lowerQuery.split(/\s+/).filter(Boolean)
if (tokens.length <= 1 || !tokens.every((token) => lowerText.includes(token))) return NO_MATCH
const tokenPositions = new Set<number>()
for (const token of tokens) {
const start = lowerText.indexOf(token)
for (let k = 0; k < token.length; k++) tokenPositions.add(start + k)
}
return {
matched: true,
score: 10 - lowerText.length * 0.1,
positions: Array.from(tokenPositions).sort((a, b) => a - b),
}
}
/**
* Subsequence fuzzy match with positional scoring. Rewards matches at word
* boundaries (`slk` → **S**lack), consecutive runs, and prefix/exact hits,
* while still matching scattered characters so typos and partial recall work.
*
* Exact, prefix, contains, and multi-word token matches all reproduce the
* original substring matcher's behavior, making this a strict superset: any
* result the old matcher returned, this one returns too. The only additions are
* scattered subsequences, and those are accepted only when the match STARTS at a
* hard word boundary — so initialisms match (`slk` → **S**la**c**k) but loose
* noise does not (`slack` will not scatter-match "Page**S**peed", and `se` will
* not match every item containing s…e).
*
* Falls back to order-independent token matching for multi-word queries
* (`message slack` matches "Slack Send Message") which a strict left-to-right
* subsequence would miss.
*
* Contiguous substring matches report the indices of the substring itself, so
* highlighting always bolds the run the user actually matched rather than an
* earlier scattered occurrence of the same characters.
*/
export function fuzzyMatch(text: string, query: string): FuzzyResult {
if (!query) return { matched: true, score: 1, positions: [] }
if (!text) return NO_MATCH
const lowerText = text.toLowerCase()
const lowerQuery = query.toLowerCase()
const substringIndex = lowerText.indexOf(lowerQuery)
if (substringIndex !== -1) {
const length = lowerQuery.length
const positions = Array.from({ length }, (_, k) => substringIndex + k)
let score = 1
if (substringIndex === 0) score += 10
else if (SEPARATORS.has(lowerText[substringIndex - 1])) score += 8
else if (isCamelBoundary(text, substringIndex)) score += 6
score += (length - 1) * 6
if (lowerText === lowerQuery) score += 120
else if (substringIndex === 0) score += 50
else score += 25
score -= substringIndex * 0.5
score -= (length - 1) * 0.15
score -= lowerText.length * 0.1
return { matched: true, score, positions }
}
const positions: number[] = []
let queryIndex = 0
let score = 0
let prevMatch = -2
for (let i = 0; i < lowerText.length && queryIndex < lowerQuery.length; i++) {
if (lowerText[i] !== lowerQuery[queryIndex]) continue
let charScore = 1
if (i === 0) charScore += 10
else if (SEPARATORS.has(lowerText[i - 1])) charScore += 8
else if (isCamelBoundary(text, i)) charScore += 6
if (prevMatch === i - 1) charScore += 5
score += charScore
positions.push(i)
prevMatch = i
queryIndex++
}
if (queryIndex === lowerQuery.length && isHardBoundary(lowerText, positions[0])) {
score -= positions[0] * 0.5
score -= (positions[positions.length - 1] - positions[0]) * 0.15
score -= lowerText.length * 0.1
return { matched: true, score, positions }
}
return tokenFallback(lowerText, lowerQuery)
}
/** Rank offset that lifts every name match above any secondary-text match. */
const NAME_MATCH_TIER = 1_000_000
/**
* Ranks an item by its name first, falling back to secondary text (ids, aliases,
* option labels) only when the name doesn't match — a name match always wins, so
* an exact name hit isn't diluted by a long secondary string ("Agent" beats
* "Pi Coding Agent" for the query "agent").
*/
function scoreItem(name: string, extra: string | undefined, search: string): FuzzyResult {
const byName = fuzzyMatch(name, search)
if (!extra) return byName
if (byName.matched) {
return { matched: true, score: byName.score + NAME_MATCH_TIER, positions: byName.positions }
}
const byExtra = fuzzyMatch(extra, search)
return byExtra.matched ? byExtra : NO_MATCH
}
/**
* Filters and ranks items by fuzzy match, highest score first; returns the input
* unchanged when the search is empty or whitespace-only. Pass `toExtra` to rank
* the name first and fall back to secondary text.
*/
export function filterAndSort<T>(
items: T[],
toValue: (item: T) => string,
search: string,
toExtra?: (item: T) => string | undefined
): T[] {
const query = search.trim()
if (!query) return items
const scored: Array<{ item: T; score: number }> = []
for (const item of items) {
const { matched, score } = scoreItem(toValue(item), toExtra?.(item), query)
if (matched) scored.push({ item, score })
}
scored.sort((a, b) => b.score - a.score)
return scored.map((entry) => entry.item)
}
/**
* Max rows rendered per group while searching. Re-rendering an unbounded,
* reshuffling match set every keystroke is what stalls typing; results are
* score-sorted, so the cap only drops the low-relevance tail.
*/
export const MAX_RESULTS_PER_GROUP = 50
/**
* {@link filterAndSort} bounded to {@link MAX_RESULTS_PER_GROUP} while searching,
* so the per-keystroke render can't block typing. The empty browse state is
* returned in full.
*/
export function filterAndCap<T>(
items: T[],
toValue: (item: T) => string,
search: string,
toExtra?: (item: T) => string | undefined
): T[] {
const results = filterAndSort(items, toValue, search, toExtra)
return search.trim() ? results.slice(0, MAX_RESULTS_PER_GROUP) : results
}
@@ -0,0 +1 @@
export { SettingsSidebar } from './settings-sidebar'
@@ -0,0 +1,383 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChipConfirmModal, chipVariants, cn } from '@sim/emcn'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { useSession } from '@/lib/auth/auth-client'
import { getSubscriptionAccessState } from '@/lib/billing/client'
import { isEnterprise } from '@/lib/billing/plan-helpers'
import { isHosted } from '@/lib/core/config/env-flags'
import { getUserRole } from '@/lib/workspaces/organization'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
import {
allNavigationItems,
isBillingEnabled,
sectionConfig,
} from '@/app/workspace/[workspaceId]/settings/navigation'
import {
SIDEBAR_ITEM_GAP_CLASS,
SIDEBAR_SECTION_GAP_CLASS,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import { useSSOProviders } from '@/ee/sso/hooks/sso'
import { useForkingAvailable } from '@/ee/workspace-forking/hooks/use-forking-available'
import { prefetchWorkspaceCredentials } from '@/hooks/queries/credentials'
import { prefetchGeneralSettings, useGeneralSettings } from '@/hooks/queries/general-settings'
import { useInboxConfig } from '@/hooks/queries/inbox'
import { useOrganizations } from '@/hooks/queries/organization'
import { prefetchSubscriptionData, useSubscriptionData } from '@/hooks/queries/subscription'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
interface SettingsSidebarProps {
isCollapsed?: boolean
showCollapsedTooltips?: boolean
}
export function SettingsSidebar({
isCollapsed = false,
showCollapsedTooltips = false,
}: SettingsSidebarProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null)
const scrollContentRef = useRef<HTMLDivElement>(null)
const params = useParams()
const workspaceId = params.workspaceId as string
const pathname = usePathname()
const router = useRouter()
const queryClient = useQueryClient()
const requestLeave = useSettingsDirtyStore((s) => s.requestLeave)
const confirmLeave = useSettingsDirtyStore((s) => s.confirmLeave)
const cancelLeave = useSettingsDirtyStore((s) => s.cancelLeave)
const pendingLeave = useSettingsDirtyStore((s) => s.pendingLeave)
const showDiscardDialog = pendingLeave !== null
const [hasOverflowTop, setHasOverflowTop] = useState(false)
const { data: session } = useSession()
const { data: organizationsData } = useOrganizations()
const { data: generalSettings } = useGeneralSettings()
const { data: subscriptionData } = useSubscriptionData({
enabled: isBillingEnabled,
staleTime: 5 * 60 * 1000,
})
const { data: inboxConfig } = useInboxConfig(workspaceId)
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders({
enabled: !isHosted,
})
const activeOrganization = organizationsData?.activeOrganization
const { config: permissionConfig } = usePermissionConfig()
// Mirrors the fork EE gate: the WORKSPACE's plan (not the viewer's) plus
// workspace admin - matching the Forks page's own gate and the server check.
const forkingAvailable = useForkingAvailable(workspaceId)
const { canAdmin: canAdminWorkspace } = useUserPermissionsContext()
const userEmail = session?.user?.email
const userId = session?.user?.id
const userRole = getUserRole(activeOrganization, userEmail)
const isOwner = userRole === 'owner'
const isAdmin = userRole === 'admin'
const isOrgAdminOrOwner = isOwner || isAdmin
const subscriptionAccess = getSubscriptionAccessState(subscriptionData?.data)
const inboxEntitled = inboxConfig?.entitled ?? false
const hasTeamPlan = subscriptionAccess.hasUsableTeamAccess
const hasEnterprisePlan = subscriptionAccess.hasUsableEnterpriseAccess
const isEnterprisePlan = isEnterprise(subscriptionData?.data?.plan)
const isSuperUser = session?.user?.role === 'admin'
const isSSOProviderOwner = useMemo(() => {
if (isHosted) return null
if (!userId || isLoadingSSO) return null
return ssoProvidersData?.providers?.some((p) => p.userId === userId) || false
}, [userId, ssoProvidersData?.providers, isLoadingSSO])
const navigationItems = useMemo(() => {
return allNavigationItems.filter((item) => {
if (item.hideWhenBillingDisabled && !isBillingEnabled) {
return false
}
if (item.hideForEnterprise && isEnterprisePlan) {
return false
}
if (item.id === 'secrets' && permissionConfig.hideSecretsTab) {
return false
}
if (item.id === 'apikeys' && permissionConfig.hideApiKeysTab) {
return false
}
if (item.id === 'inbox' && permissionConfig.hideInboxTab) {
return false
}
if (item.id === 'mcp' && permissionConfig.disableMcpTools) {
return false
}
if (item.id === 'custom-tools' && permissionConfig.disableCustomTools) {
return false
}
if (item.id === 'forks' && !(forkingAvailable && canAdminWorkspace)) {
return false
}
if (item.selfHostedOverride && !isHosted) {
if (item.id === 'sso') {
const hasProviders = (ssoProvidersData?.providers?.length ?? 0) > 0
return !hasProviders || isSSOProviderOwner === true
}
return true
}
const orgAdminSatisfied = isOrgAdminOrOwner || item.allowNonOrgAdmin
if (item.requiresTeam && (!hasTeamPlan || !orgAdminSatisfied)) {
return false
}
if (
item.requiresEnterprise &&
(!hasEnterprisePlan || !orgAdminSatisfied) &&
!item.showWhenLocked
) {
return false
}
if (item.requiresMax && !subscriptionAccess.hasUsableMaxAccess && !item.showWhenLocked) {
return false
}
if (item.requiresHosted && !isHosted) {
return false
}
const superUserModeEnabled = generalSettings?.superUserModeEnabled ?? false
const effectiveSuperUser = isSuperUser && superUserModeEnabled
if (item.requiresSuperUser && !effectiveSuperUser) {
return false
}
if (item.requiresAdminRole && !isSuperUser) {
return false
}
return true
})
}, [
hasTeamPlan,
hasEnterprisePlan,
isEnterprisePlan,
subscriptionAccess.hasUsableMaxAccess,
isOrgAdminOrOwner,
isSSOProviderOwner,
ssoProvidersData?.providers?.length,
permissionConfig,
isSuperUser,
generalSettings?.superUserModeEnabled,
forkingAvailable,
canAdminWorkspace,
])
const activeSection = useMemo(() => {
const segments = pathname?.split('/') ?? []
const settingsIdx = segments.indexOf('settings')
if (settingsIdx !== -1 && segments[settingsIdx + 1]) {
return segments[settingsIdx + 1] as SettingsSection
}
return 'general'
}, [pathname])
const handlePrefetch = useCallback(
(itemId: string) => {
switch (itemId) {
case 'general':
prefetchGeneralSettings(queryClient)
void import('@/app/workspace/[workspaceId]/settings/components/general/general')
break
case 'secrets':
prefetchWorkspaceCredentials(queryClient, workspaceId)
void import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets')
break
case 'billing':
prefetchSubscriptionData(queryClient)
void import('@/app/workspace/[workspaceId]/settings/components/billing/billing')
break
}
},
[queryClient, workspaceId]
)
const { popSettingsReturnUrl, getSettingsHref } = useSettingsNavigation()
const handleBack = useCallback(() => {
requestLeave(() => {
router.push(popSettingsReturnUrl(`/workspace/${workspaceId}/home`))
})
}, [requestLeave, router, popSettingsReturnUrl, workspaceId])
const handleConfirmDiscard = useCallback(() => {
confirmLeave()
}, [confirmLeave])
const handleCancelDiscard = useCallback(() => {
cancelLeave()
}, [cancelLeave])
useEffect(() => {
const container = scrollContainerRef.current
if (!container) return
const updateScrollState = () => {
setHasOverflowTop(container.scrollTop > 1)
}
updateScrollState()
container.addEventListener('scroll', updateScrollState, { passive: true })
const observer = new ResizeObserver(updateScrollState)
observer.observe(container)
if (scrollContentRef.current) {
observer.observe(scrollContentRef.current)
}
return () => {
container.removeEventListener('scroll', updateScrollState)
observer.disconnect()
}
}, [isCollapsed])
return (
<>
{/* Back button */}
<div
className={cn(
SIDEBAR_SECTION_GAP_CLASS,
SIDEBAR_ITEM_GAP_CLASS,
'flex flex-shrink-0 flex-col px-2 pb-1.5'
)}
>
<SidebarTooltip label='Back' enabled={showCollapsedTooltips}>
<button type='button' onClick={handleBack} className={chipVariants({ fullWidth: true })}>
<div className='flex size-[16px] flex-shrink-0 items-center justify-center text-[var(--text-icon)]'>
<ChevronDown className='size-[10px] rotate-90' />
</div>
<span className='sidebar-collapse-hide truncate text-[var(--text-body)]'>Back</span>
</button>
</SidebarTooltip>
</div>
{/* Settings sections */}
<div
ref={isCollapsed ? undefined : scrollContainerRef}
className={cn(
'flex flex-1 flex-col overflow-y-auto overflow-x-hidden border-t pt-1.5 transition-colors duration-150',
!hasOverflowTop && 'border-transparent'
)}
>
<div ref={scrollContentRef} className='flex flex-col'>
{sectionConfig
.map(({ key, title }) => ({
key,
title,
items: navigationItems.filter((item) => item.section === key),
}))
.filter(({ items }) => items.length > 0)
.map(({ key, title, items: sectionItems }, index) => (
<div
key={key}
className={cn(
index > 0 && SIDEBAR_SECTION_GAP_CLASS,
'flex flex-shrink-0 flex-col'
)}
>
<div className='px-4 pb-2'>
<div className='text-[var(--text-muted)] text-small'>{title}</div>
</div>
<div className={cn(SIDEBAR_ITEM_GAP_CLASS, 'flex flex-col px-2')}>
{sectionItems.map((item) => {
const Icon = item.icon
const active = activeSection === item.id
const isLocked =
item.requiresMax &&
(item.id === 'inbox'
? !inboxEntitled
: !subscriptionAccess.hasUsableMaxAccess)
const itemClassName = chipVariants({ active, fullWidth: true })
const content = (
<>
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
<span className='sidebar-collapse-hide min-w-0 truncate text-[var(--text-body)]'>
{item.label}
</span>
{isLocked && (
<span className='sidebar-collapse-hide ml-auto shrink-0 rounded-[3px] bg-[var(--surface-5)] px-1 py-[1px] font-medium text-[9px] text-[var(--text-icon)] uppercase tracking-wide'>
Max
</span>
)}
</>
)
const element = item.externalUrl ? (
<a
href={item.externalUrl}
target='_blank'
rel='noopener noreferrer'
className={itemClassName}
>
{content}
</a>
) : (
<button
type='button'
className={itemClassName}
onMouseEnter={() => handlePrefetch(item.id)}
onFocus={() => handlePrefetch(item.id)}
onClick={() => {
const section = item.id as SettingsSection
if (section === activeSection) return
requestLeave(() => {
router.replace(getSettingsHref({ section }), { scroll: false })
})
}}
>
{content}
</button>
)
return (
<SidebarTooltip
key={item.id}
label={item.label}
enabled={showCollapsedTooltips}
>
{element}
</SidebarTooltip>
)
})}
</div>
</div>
))}
</div>
</div>
<ChipConfirmModal
open={showDiscardDialog}
onOpenChange={(open) => !open && handleCancelDiscard()}
srTitle='Unsaved changes'
title='Unsaved changes'
text='You have unsaved changes. Are you sure you want to discard them?'
dismissLabel='Keep editing'
confirm={{
label: 'Discard changes',
onClick: handleConfirmDiscard,
}}
/>
</>
)
}
@@ -0,0 +1,356 @@
'use client'
import { useRef } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@sim/emcn'
import {
Download,
Duplicate,
Eye,
FolderPlus,
ImageUp,
Lock,
LogOut,
Mail,
Pencil,
Plus,
SquareArrowUpRight,
Trash,
Unlock,
} from '@sim/emcn/icons'
import { Pin, PinOff } from 'lucide-react'
interface ContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
menuRef: React.RefObject<HTMLDivElement | null>
onClose: () => void
onOpenInNewTab?: () => void
onMarkAsRead?: () => void
onMarkAsUnread?: () => void
onTogglePin?: () => void
onRename?: () => void
/**
* Ref to the rename input rendered by the "Rename" action, if any. Radix's
* FocusScope defers its close-time focus teardown to a `setTimeout(0)`, which
* can run after the rename input's own mount-time `focus()`/`select()` and
* clobber the selection (the "rename deselects the text" bug). Focusing from
* `onCloseAutoFocus` runs synchronously inside that same deferred teardown, so
* it always wins the race regardless of scheduler timing. Only applied when
* this specific close was caused by selecting "Rename" (see
* `justSelectedRenameRef`) — an unrelated action closing the menu while an
* earlier rename is still live must not steal focus back into it.
*/
renameInputRef?: React.RefObject<HTMLInputElement | null>
onCreate?: () => void
onCreateFolder?: () => void
onDuplicate?: () => void
onExport?: () => void
onDelete: () => void
showOpenInNewTab?: boolean
showMarkAsRead?: boolean
showMarkAsUnread?: boolean
showPin?: boolean
isPinned?: boolean
showRename?: boolean
showCreate?: boolean
showCreateFolder?: boolean
showDuplicate?: boolean
showExport?: boolean
disableExport?: boolean
disableMarkAsRead?: boolean
disableMarkAsUnread?: boolean
disableRename?: boolean
disableDuplicate?: boolean
disableDelete?: boolean
disableCreate?: boolean
disableCreateFolder?: boolean
onLeave?: () => void
showLeave?: boolean
disableLeave?: boolean
onToggleLock?: () => void
showLock?: boolean
disableLock?: boolean
isLocked?: boolean
showDelete?: boolean
onUploadLogo?: () => void
showUploadLogo?: boolean
disableUploadLogo?: boolean
}
/**
* Context menu component for workflow, folder, and workspace items.
* Uses DropdownMenu for accessible, hover-expandable submenus.
*/
export function ContextMenu({
isOpen,
position,
menuRef,
onClose,
onOpenInNewTab,
onMarkAsRead,
onMarkAsUnread,
onTogglePin,
onRename,
renameInputRef,
onCreate,
onCreateFolder,
onDuplicate,
onExport,
onDelete,
showOpenInNewTab = false,
showMarkAsRead = false,
showMarkAsUnread = false,
showPin = false,
isPinned = false,
showRename = true,
showCreate = false,
showCreateFolder = false,
showDuplicate = true,
showExport = false,
disableExport = false,
disableMarkAsRead = false,
disableMarkAsUnread = false,
disableRename = false,
disableDuplicate = false,
disableDelete = false,
disableCreate = false,
disableCreateFolder = false,
onLeave,
showLeave = false,
disableLeave = false,
onToggleLock,
showLock = false,
disableLock = false,
isLocked = false,
showDelete = true,
onUploadLogo,
showUploadLogo = false,
disableUploadLogo = false,
}: ContextMenuProps) {
const hasNavigationSection = showOpenInNewTab && onOpenInNewTab
const hasStatusSection =
(showMarkAsRead && onMarkAsRead) ||
(showMarkAsUnread && onMarkAsUnread) ||
(showPin && onTogglePin)
const hasEditSection =
(showRename && onRename) ||
(showCreate && onCreate) ||
(showCreateFolder && onCreateFolder) ||
(showLock && onToggleLock) ||
(showUploadLogo && onUploadLogo)
const hasCopySection = (showDuplicate && onDuplicate) || (showExport && onExport)
/**
* Only the "Rename" item should trigger the `onCloseAutoFocus` refocus below —
* an unrelated action (Delete, Duplicate, ...) closing this menu while a rename
* from an earlier interaction is still live must not steal focus back into it.
*/
const justSelectedRenameRef = useRef(false)
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
ref={menuRef}
align='start'
side='bottom'
sideOffset={4}
className='max-h-[var(--radix-dropdown-menu-content-available-height,400px)]'
onCloseAutoFocus={(e) => {
e.preventDefault()
const shouldFocusRenameInput = justSelectedRenameRef.current
justSelectedRenameRef.current = false
const input = shouldFocusRenameInput ? renameInputRef?.current : null
if (input) {
input.focus()
input.select()
}
}}
>
{showOpenInNewTab && onOpenInNewTab && (
<DropdownMenuItem
onSelect={() => {
onOpenInNewTab()
onClose()
}}
>
<SquareArrowUpRight />
Open in new tab
</DropdownMenuItem>
)}
{hasNavigationSection && (hasStatusSection || hasEditSection || hasCopySection) && (
<DropdownMenuSeparator />
)}
{showMarkAsRead && onMarkAsRead && (
<DropdownMenuItem
disabled={disableMarkAsRead}
onSelect={() => {
onMarkAsRead()
onClose()
}}
>
<Eye />
Mark as read
</DropdownMenuItem>
)}
{showMarkAsUnread && onMarkAsUnread && (
<DropdownMenuItem
disabled={disableMarkAsUnread}
onSelect={() => {
onMarkAsUnread()
onClose()
}}
>
<Mail />
Mark as unread
</DropdownMenuItem>
)}
{showPin && onTogglePin && (
<DropdownMenuItem
onSelect={() => {
onTogglePin()
onClose()
}}
>
{isPinned ? <PinOff className='size-[14px]' /> : <Pin className='size-[14px]' />}
{isPinned ? 'Unpin' : 'Pin'}
</DropdownMenuItem>
)}
{hasStatusSection && (hasEditSection || hasCopySection) && <DropdownMenuSeparator />}
{showRename && onRename && (
<DropdownMenuItem
disabled={disableRename}
onSelect={() => {
justSelectedRenameRef.current = true
onRename()
onClose()
}}
>
<Pencil />
Rename
</DropdownMenuItem>
)}
{showCreate && onCreate && (
<DropdownMenuItem
disabled={disableCreate}
onSelect={() => {
onCreate()
onClose()
}}
>
<Plus />
Create workflow
</DropdownMenuItem>
)}
{showCreateFolder && onCreateFolder && (
<DropdownMenuItem
disabled={disableCreateFolder}
onSelect={() => {
onCreateFolder()
onClose()
}}
>
<FolderPlus />
Create folder
</DropdownMenuItem>
)}
{showUploadLogo && onUploadLogo && (
<DropdownMenuItem
disabled={disableUploadLogo}
onSelect={() => {
onUploadLogo()
onClose()
}}
>
<ImageUp />
Upload logo
</DropdownMenuItem>
)}
{showLock && onToggleLock && (
<DropdownMenuItem
disabled={disableLock}
onSelect={() => {
onToggleLock()
onClose()
}}
>
{isLocked ? <Unlock /> : <Lock />}
{isLocked ? 'Unlock' : 'Lock'}
</DropdownMenuItem>
)}
{hasEditSection && hasCopySection && <DropdownMenuSeparator />}
{showDuplicate && onDuplicate && (
<DropdownMenuItem
disabled={disableDuplicate}
onSelect={() => {
onDuplicate()
onClose()
}}
>
<Duplicate />
Duplicate
</DropdownMenuItem>
)}
{showExport && onExport && (
<DropdownMenuItem
disabled={disableExport}
onSelect={() => {
onExport()
onClose()
}}
>
<Download />
Export
</DropdownMenuItem>
)}
{(hasNavigationSection || hasStatusSection || hasEditSection || hasCopySection) &&
(showLeave || showDelete) && <DropdownMenuSeparator />}
{showLeave && onLeave && (
<DropdownMenuItem
disabled={disableLeave}
onSelect={() => {
onLeave()
onClose()
}}
>
<LogOut />
Leave
</DropdownMenuItem>
)}
{showDelete && (
<DropdownMenuItem
disabled={disableDelete}
onSelect={() => {
onDelete()
onClose()
}}
>
<Trash />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1 @@
export { ContextMenu } from './context-menu'
@@ -0,0 +1,234 @@
'use client'
import { useState } from 'react'
import { ChipConfirmModal, type ChipConfirmTextSegment, ChipModalField } from '@sim/emcn'
interface DeleteModalProps {
/**
* Whether the modal is open
*/
isOpen: boolean
/**
* Callback when modal should close
*/
onClose: () => void
/**
* Callback when delete is confirmed
*/
onConfirm: () => void
/**
* Whether the delete operation is in progress
*/
isDeleting: boolean
/**
* Type of item being deleted
* - 'mixed' is used when both workflows and folders are selected
*/
itemType: 'workflow' | 'folder' | 'workspace' | 'mixed' | 'task'
/**
* Name(s) of the item(s) being deleted (optional, for display)
* Can be a single name or an array of names for multiple items
*/
itemName?: string | string[]
}
/**
* Reusable delete confirmation modal for workflow, folder, and workspace items.
* Displays a warning message and confirmation buttons.
*
* @param props - Component props
* @returns Delete confirmation modal
*/
export function DeleteModal({
isOpen,
onClose,
onConfirm,
isDeleting,
itemType,
itemName,
}: DeleteModalProps) {
const [confirmationText, setConfirmationText] = useState('')
const [prevIsOpen, setPrevIsOpen] = useState(false)
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen)
if (isOpen) {
setConfirmationText('')
}
}
const isMultiple = Array.isArray(itemName) && itemName.length > 1
const isSingle = !isMultiple
const displayNames = Array.isArray(itemName) ? itemName : itemName ? [itemName] : []
const isWorkspace = itemType === 'workspace'
const workspaceName = isWorkspace && displayNames.length > 0 ? displayNames[0] : ''
const isConfirmed = !isWorkspace || confirmationText === workspaceName
let title = ''
if (itemType === 'workflow') {
title = isMultiple ? 'Delete workflows' : 'Delete workflow'
} else if (itemType === 'folder') {
title = isMultiple ? 'Delete folders' : 'Delete folder'
} else if (itemType === 'task') {
title = isMultiple ? 'Delete chats' : 'Delete chat'
} else if (itemType === 'mixed') {
title = 'Delete items'
} else {
title = 'Delete workspace'
}
const restorableTypes = new Set<string>(['workflow', 'folder', 'mixed'])
const buildDescriptionSegments = (): ChipConfirmTextSegment[] => {
if (itemType === 'workflow') {
const warning = {
text: 'All associated blocks, executions, and configuration will be removed.',
error: true,
}
if (isMultiple) {
return [
'Are you sure you want to delete ',
{ text: displayNames.join(', '), bold: true },
'? ',
warning,
]
}
if (isSingle && displayNames.length > 0) {
return [
'Are you sure you want to delete ',
{ text: displayNames[0], bold: true },
'? ',
warning,
]
}
return ['Are you sure you want to delete this workflow? ', warning]
}
if (itemType === 'folder') {
if (isMultiple) {
return [
'Are you sure you want to delete ',
{ text: displayNames.join(', '), bold: true },
'? ',
{
text: 'All workflows and contents within these folders will be archived.',
error: true,
},
]
}
const warning = {
text: 'All associated workflows and contents will be archived.',
error: true,
}
if (isSingle && displayNames.length > 0) {
return [
'Are you sure you want to delete ',
{ text: displayNames[0], bold: true },
'? ',
warning,
]
}
return ['Are you sure you want to delete this folder? ', warning]
}
if (itemType === 'task') {
const warning = {
text: 'This will permanently remove all conversation history.',
error: true,
}
if (isMultiple) {
return [
'Are you sure you want to delete ',
{ text: `${displayNames.length} chats`, bold: true },
'? ',
warning,
]
}
if (isSingle && displayNames.length > 0) {
return [
'Are you sure you want to delete ',
{ text: displayNames[0], bold: true },
'? ',
warning,
]
}
return ['Are you sure you want to delete this chat? ', warning]
}
if (itemType === 'mixed') {
const warning = {
text: 'All selected workflows and folders, including their contents, will be archived.',
error: true,
}
if (displayNames.length > 0) {
return [
'Are you sure you want to delete ',
{ text: displayNames.join(', '), bold: true },
'? ',
warning,
]
}
return ['Are you sure you want to delete the selected items? ', warning]
}
const workspaceWarning = {
text: 'This will permanently remove all associated workflows, tables, files, logs, and knowledge bases.',
error: true,
}
if (isSingle && displayNames.length > 0) {
return [
'Are you sure you want to delete ',
{ text: displayNames[0], bold: true },
'? ',
workspaceWarning,
]
}
return ['Are you sure you want to delete this workspace? ', workspaceWarning]
}
const handleClose = () => {
setConfirmationText('')
onClose()
}
return (
<ChipConfirmModal
open={isOpen}
onOpenChange={handleClose}
srTitle={title}
title={title}
text={[
...buildDescriptionSegments(),
' ',
restorableTypes.has(itemType)
? 'You can restore it from Recently deleted in Settings.'
: 'This action cannot be undone.',
]}
confirm={{
label: 'Delete',
onClick: onConfirm,
pending: isDeleting,
pendingLabel: 'Deleting...',
disabled: !isConfirmed,
}}
>
{isWorkspace && workspaceName && (
<ChipModalField
type='input'
title={
<span>
Type&nbsp;
<span className='font-medium text-[var(--text-primary)]'>{workspaceName}</span>
&nbsp;to confirm
</span>
}
value={confirmationText}
onChange={setConfirmationText}
placeholder={workspaceName}
/>
)}
</ChipConfirmModal>
)
}
@@ -0,0 +1 @@
export { DeleteModal } from './delete-modal'
@@ -0,0 +1,93 @@
'use client'
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@sim/emcn'
interface EmptyAreaContextMenuProps {
/**
* Whether the context menu is open
*/
isOpen: boolean
/**
* Position of the context menu
*/
position: { x: number; y: number }
/**
* Ref for the menu element
*/
menuRef: React.RefObject<HTMLDivElement | null>
/**
* Callback when menu should close
*/
onClose: () => void
/**
* Callback when create workflow is clicked
*/
onCreateWorkflow: () => void
/**
* Callback when create folder is clicked
*/
onCreateFolder: () => void
/**
* Whether create workflow is disabled
*/
disableCreateWorkflow?: boolean
/**
* Whether create folder is disabled
*/
disableCreateFolder?: boolean
}
/**
* Context menu component for sidebar empty area.
* Displays options to create a workflow or folder when right-clicking on empty space.
*/
export function EmptyAreaContextMenu({
isOpen,
position,
menuRef,
onClose,
onCreateWorkflow,
onCreateFolder,
disableCreateWorkflow = false,
disableCreateFolder = false,
}: EmptyAreaContextMenuProps) {
return (
<Popover
open={isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
<PopoverItem
disabled={disableCreateWorkflow}
onClick={() => {
onCreateWorkflow()
onClose()
}}
>
Create workflow
</PopoverItem>
<PopoverItem
disabled={disableCreateFolder}
onClick={() => {
onCreateFolder()
onClose()
}}
>
Create folder
</PopoverItem>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1 @@
export { EmptyAreaContextMenu } from './empty-area-context-menu'
@@ -0,0 +1,618 @@
'use client'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { chipVariants, cn } from '@sim/emcn'
import { Lock } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import clsx from 'clsx'
import { ChevronRight, Folder, FolderOpen, MoreHorizontal } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
import {
useContextMenu,
useFolderExpand,
useItemDrag,
useItemRename,
useSidebarListContext,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import {
buildDragResources,
createSidebarDragGhost,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
import {
useCanDelete,
useDeleteFolder,
useDeleteSelection,
useDuplicateFolder,
useDuplicateSelection,
useExportFolder,
useExportSelection,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { useCreateFolder, useFolderMap, useUpdateFolder } from '@/hooks/queries/folders'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import {
isFolderEffectivelyLocked,
isFolderOrAncestorLocked,
} from '@/hooks/queries/utils/folder-tree'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useCreateWorkflow } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import type { FolderTreeNode } from '@/stores/folders/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
const logger = createLogger('FolderItem')
interface FolderItemProps {
workspaceId: string
folder: FolderTreeNode
}
export const FolderItem = memo(function FolderItem({ workspaceId, folder }: FolderItemProps) {
const {
isAnyDragActive,
dragDisabled,
activeWorkflowIdRef,
onFolderClick,
onItemDragStart,
onItemDragEnd,
} = useSidebarListContext()
const router = useRouter()
const updateFolderMutation = useUpdateFolder()
const createWorkflowMutation = useCreateWorkflow()
const createFolderMutation = useCreateFolder()
const userPermissions = useUserPermissionsContext()
const selectedFolders = useFolderStore((state) => state.selectedFolders)
const isSelected = selectedFolders.has(folder.id)
const { data: foldersById = {} } = useFolderMap(workspaceId)
const inheritedFolderLocked = isFolderOrAncestorLocked(folder.parentId, foldersById)
const effectiveLocked = isFolderEffectivelyLocked(folder, foldersById)
const { canDeleteFolder, canDeleteWorkflows } = useCanDelete({ workspaceId })
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [deleteItemType, setDeleteItemType] = useState<'folder' | 'mixed'>('folder')
const [deleteItemNames, setDeleteItemNames] = useState<string | string[]>(folder.name)
const capturedSelectionRef = useRef<{
workflowIds: string[]
folderIds: string[]
isMixed: boolean
names: string[]
} | null>(null)
const [canDeleteSelection, setCanDeleteSelection] = useState(true)
const { isDeleting: isDeletingThisFolder, handleDeleteFolder: handleDeleteThisFolder } =
useDeleteFolder({
workspaceId,
folderIds: folder.id,
onSuccess: () => setIsDeleteModalOpen(false),
})
const { isDeleting: isDeletingSelection, handleDeleteSelection } = useDeleteSelection({
workspaceId,
workflowIds: capturedSelectionRef.current?.workflowIds || [],
folderIds: capturedSelectionRef.current?.folderIds || [],
isActiveWorkflow: (id) => id === activeWorkflowIdRef.current,
onSuccess: () => setIsDeleteModalOpen(false),
})
const isDeleting = isDeletingThisFolder || isDeletingSelection
const { handleDuplicateFolder: handleDuplicateThisFolder } = useDuplicateFolder({
workspaceId,
folderIds: folder.id,
})
const { isDuplicating: isDuplicatingSelection, handleDuplicateSelection } = useDuplicateSelection(
{
workspaceId,
}
)
const {
isExporting: isExportingThisFolder,
hasWorkflows,
handleExportFolder: handleExportThisFolder,
} = useExportFolder({
workspaceId,
folderId: folder.id,
})
const { isExporting: isExportingSelection, handleExportSelection } = useExportSelection({
workspaceId,
})
const isExporting = isExportingThisFolder || isExportingSelection
const {
isExpanded,
handleToggleExpanded,
expandFolder,
handleKeyDown: handleExpandKeyDown,
} = useFolderExpand({
folderId: folder.id,
})
const isEditingRef = useRef(false)
const dragGhostRef = useRef<HTMLElement | null>(null)
const handleCreateWorkflowInFolder = useCallback(() => {
if (effectiveLocked) return
const name = generateCreativeWorkflowName()
const id = generateId()
createWorkflowMutation.mutate({
workspaceId,
folderId: folder.id,
name,
id,
})
useWorkflowRegistry.getState().markWorkflowCreating(id)
expandFolder()
router.push(`/workspace/${workspaceId}/w/${id}`)
window.dispatchEvent(new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: id } }))
}, [createWorkflowMutation, workspaceId, folder.id, effectiveLocked, router, expandFolder])
const handleCreateFolderInFolder = useCallback(async () => {
if (effectiveLocked) return
try {
const result = await createFolderMutation.mutateAsync({
workspaceId,
name: 'New folder',
parentId: folder.id,
id: generateId(),
})
if (result.id) {
expandFolder()
window.dispatchEvent(
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: result.id } })
)
}
} catch (error) {
logger.error('Failed to create folder:', error)
}
}, [createFolderMutation, workspaceId, folder.id, effectiveLocked, expandFolder])
const handleToggleLock = useCallback(() => {
if (inheritedFolderLocked) return
updateFolderMutation.mutate({
workspaceId,
id: folder.id,
updates: { locked: !folder.locked },
})
}, [folder.id, folder.locked, inheritedFolderLocked, updateFolderMutation, workspaceId])
const onDragStart = useCallback(
(e: React.DragEvent) => {
if (isEditingRef.current) {
e.preventDefault()
return
}
const { selectedWorkflows, selectedFolders } = useFolderStore.getState()
const isCurrentlySelected = selectedFolders.has(folder.id)
const selection = isCurrentlySelected
? {
workflowIds: Array.from(selectedWorkflows),
folderIds: Array.from(selectedFolders),
}
: {
workflowIds: [],
folderIds: [folder.id],
}
e.dataTransfer.setData('sidebar-selection', JSON.stringify(selection))
e.dataTransfer.effectAllowed = 'copyMove'
const resources = buildDragResources(selection, workspaceId)
if (resources.length > 0) {
e.dataTransfer.setData(SIM_RESOURCES_DRAG_TYPE, JSON.stringify(resources))
}
const total = selection.folderIds.length + selection.workflowIds.length
const ghostLabel = total > 1 ? `${folder.name} +${total - 1} more` : folder.name
const icon = total === 1 ? { kind: 'folder' as const } : undefined
const ghost = createSidebarDragGhost(ghostLabel, icon)
void ghost.offsetHeight
e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2)
dragGhostRef.current = ghost
onItemDragStart(folder.parentId)
},
[folder.id, folder.name, folder.parentId, workspaceId, onItemDragStart]
)
const {
isDragging,
shouldPreventClickRef,
handleDragStart,
handleDragEnd: handleDragEndBase,
} = useItemDrag({
onDragStart,
})
const handleDragEnd = useCallback(() => {
if (dragGhostRef.current) {
dragGhostRef.current.remove()
dragGhostRef.current = null
}
handleDragEndBase()
onItemDragEnd()
}, [handleDragEndBase, onItemDragEnd])
const {
isOpen: isContextMenuOpen,
position,
menuRef,
handleContextMenu: handleContextMenuBase,
closeMenu,
preventDismiss,
} = useContextMenu()
const captureSelectionState = useCallback(() => {
const store = useFolderStore.getState()
const isFolderSelected = store.selectedFolders.has(folder.id)
if (!isFolderSelected) {
// Replace selection with just this folder (Finder/Explorer pattern)
store.clearAllSelection()
store.selectFolder(folder.id)
}
const finalFolderSelection = useFolderStore.getState().selectedFolders
const finalWorkflowSelection = useFolderStore.getState().selectedWorkflows
const folderIds = Array.from(finalFolderSelection)
const workflowIds = Array.from(finalWorkflowSelection)
const isMixed = folderIds.length > 0 && workflowIds.length > 0
const folderMap = getFolderMap(workspaceId)
const workflows = getWorkflows(workspaceId)
const names: string[] = []
for (const id of folderIds) {
const f = folderMap[id]
if (f) names.push(f.name)
}
for (const id of workflowIds) {
const w = workflows.find((wf) => wf.id === id)
if (w) names.push(w.name)
}
capturedSelectionRef.current = {
workflowIds,
folderIds,
isMixed,
names,
}
const canDeleteAllFolders = folderIds.every((id) => canDeleteFolder(id))
const canDeleteAllWorkflows = workflowIds.length === 0 || canDeleteWorkflows(workflowIds)
setCanDeleteSelection(canDeleteAllFolders && canDeleteAllWorkflows)
}, [folder.id, canDeleteFolder, canDeleteWorkflows])
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
captureSelectionState()
handleContextMenuBase(e)
},
[captureSelectionState, handleContextMenuBase]
)
const {
isEditing,
editValue,
isRenaming,
inputRef,
setEditValue,
handleStartEdit,
handleKeyDown: handleRenameKeyDown,
handleInputBlur,
} = useItemRename({
initialName: folder.name,
onSave: async (newName) => {
await updateFolderMutation.mutateAsync({
workspaceId,
id: folder.id,
updates: { name: newName },
})
},
itemType: 'folder',
itemId: folder.id,
})
isEditingRef.current = isEditing
const handleDoubleClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!effectiveLocked) {
handleStartEdit()
}
},
[effectiveLocked, handleStartEdit]
)
const handleFolderSelect = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
if (shouldPreventClickRef.current || isEditing) {
e.preventDefault()
return
}
const isModifierClick = e.shiftKey || e.metaKey || e.ctrlKey
if (isModifierClick) {
e.preventDefault()
onFolderClick(folder.id, e.shiftKey, e.metaKey || e.ctrlKey)
return
}
useFolderStore.getState().clearFolderSelection()
handleToggleExpanded()
},
[handleToggleExpanded, shouldPreventClickRef, isEditing, onFolderClick, folder.id]
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (isEditing) {
handleRenameKeyDown(e)
} else {
handleExpandKeyDown(e)
}
},
[isEditing, handleRenameKeyDown, handleExpandKeyDown]
)
const handleMorePointerDown = useCallback(() => {
if (isContextMenuOpen) {
preventDismiss()
}
}, [isContextMenuOpen, preventDismiss])
const handleMoreClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
e.stopPropagation()
if (isContextMenuOpen) {
closeMenu()
return
}
captureSelectionState()
const rect = e.currentTarget.getBoundingClientRect()
handleContextMenuBase({
preventDefault: () => {},
stopPropagation: () => {},
clientX: rect.right,
clientY: rect.top,
} as React.MouseEvent)
},
[isContextMenuOpen, closeMenu, captureSelectionState, handleContextMenuBase]
)
const handleOpenDeleteModal = useCallback(() => {
if (!capturedSelectionRef.current) return
const { isMixed, names, folderIds } = capturedSelectionRef.current
if (isMixed) {
setDeleteItemType('mixed')
setDeleteItemNames(names)
} else if (folderIds.length > 1) {
setDeleteItemType('folder')
setDeleteItemNames(names)
} else {
setDeleteItemType('folder')
setDeleteItemNames(folder.name)
}
setIsDeleteModalOpen(true)
}, [folder.name])
const handleConfirmDelete = useCallback(async () => {
if (!capturedSelectionRef.current) return
const { isMixed, folderIds } = capturedSelectionRef.current
if (isMixed || folderIds.length > 1) {
await handleDeleteSelection()
} else {
await handleDeleteThisFolder()
}
}, [handleDeleteSelection, handleDeleteThisFolder])
const handleExport = useCallback(async () => {
if (!capturedSelectionRef.current) return
const { isMixed, workflowIds, folderIds } = capturedSelectionRef.current
if (isMixed || folderIds.length > 1) {
await handleExportSelection(workflowIds, folderIds)
} else {
await handleExportThisFolder()
}
}, [handleExportSelection, handleExportThisFolder])
const handleDuplicate = useCallback(async () => {
if (!capturedSelectionRef.current) return
const { isMixed, workflowIds, folderIds } = capturedSelectionRef.current
if (isMixed || folderIds.length > 1) {
await handleDuplicateSelection(workflowIds, folderIds)
} else {
await handleDuplicateThisFolder()
}
}, [handleDuplicateSelection, handleDuplicateThisFolder])
const isMixedSelection = useMemo(() => {
return capturedSelectionRef.current?.isMixed ?? false
}, [isContextMenuOpen])
const hasExportableContent = useMemo(() => {
if (!capturedSelectionRef.current) return hasWorkflows
const { workflowIds } = capturedSelectionRef.current
return workflowIds.length > 0 || hasWorkflows
}, [isContextMenuOpen, hasWorkflows])
return (
<>
<div
role='button'
tabIndex={0}
data-item-id={folder.id}
aria-expanded={isExpanded}
aria-label={`${folder.name} folder, ${isExpanded ? 'expanded' : 'collapsed'}`}
className={cn(
chipVariants({ active: isSelected || isContextMenuOpen, fullWidth: true }),
(isDragging || (isAnyDragActive && isSelected)) && 'opacity-50'
)}
onClick={handleFolderSelect}
onKeyDown={handleKeyDown}
onContextMenu={handleContextMenu}
draggable={!isEditing && !dragDisabled && !effectiveLocked}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<ChevronRight
className={clsx(
'size-[16px] flex-shrink-0 text-[var(--text-icon)] transition-transform duration-100',
isExpanded && 'rotate-90'
)}
aria-hidden='true'
/>
{isExpanded ? (
<FolderOpen
className='size-[16px] flex-shrink-0 text-[var(--text-icon)]'
aria-hidden='true'
/>
) : (
<Folder
className='size-[16px] flex-shrink-0 text-[var(--text-icon)]'
aria-hidden='true'
/>
)}
{isEditing ? (
<input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={handleInputBlur}
className='min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
maxLength={50}
disabled={isRenaming}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
) : (
<div className='flex min-w-0 flex-1 items-center gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-1'>
<span
className='min-w-0 truncate text-[var(--text-body)]'
onDoubleClick={handleDoubleClick}
>
{folder.name}
</span>
</div>
<div className='relative size-[18px] flex-shrink-0'>
{folder.locked && (
<span
role='img'
aria-label='Folder is locked'
className={clsx(
'pointer-events-none absolute inset-0 flex items-center justify-center transition-opacity',
!isAnyDragActive && 'group-hover:opacity-0',
isContextMenuOpen && 'opacity-0'
)}
>
<Lock className='size-[14px] text-[var(--text-icon)]' aria-hidden='true' />
</span>
)}
<button
type='button'
aria-label='Folder options'
onPointerDown={handleMorePointerDown}
onClick={handleMoreClick}
className={clsx(
'pointer-events-none absolute inset-0 flex items-center justify-center rounded-sm opacity-0 transition-opacity',
!isAnyDragActive && 'group-hover:pointer-events-auto group-hover:opacity-100',
isContextMenuOpen && 'pointer-events-auto opacity-100'
)}
>
<MoreHorizontal className='size-[16px] text-[var(--text-icon)]' />
</button>
</div>
</div>
)}
</div>
<ContextMenu
isOpen={isContextMenuOpen}
position={position}
menuRef={menuRef}
onClose={closeMenu}
onRename={handleStartEdit}
renameInputRef={inputRef}
onCreate={handleCreateWorkflowInFolder}
onCreateFolder={handleCreateFolderInFolder}
onDuplicate={handleDuplicate}
onExport={handleExport}
onDelete={handleOpenDeleteModal}
showCreate={!isMixedSelection}
showCreateFolder={!isMixedSelection}
showRename={!isMixedSelection && selectedFolders.size <= 1}
showDuplicate={true}
showExport={true}
disableRename={!userPermissions.canEdit || effectiveLocked}
disableCreate={
!userPermissions.canEdit || effectiveLocked || createWorkflowMutation.isPending
}
disableCreateFolder={
!userPermissions.canEdit || effectiveLocked || createFolderMutation.isPending
}
disableDuplicate={
!userPermissions.canEdit || isDuplicatingSelection || !hasExportableContent
}
disableExport={!userPermissions.canEdit || isExporting || !hasExportableContent}
showDelete={userPermissions.canEdit}
disableDelete={effectiveLocked || !canDeleteSelection}
onToggleLock={handleToggleLock}
showLock={!isMixedSelection && selectedFolders.size <= 1}
disableLock={!userPermissions.canAdmin || inheritedFolderLocked}
isLocked={effectiveLocked}
/>
<DeleteModal
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
onConfirm={handleConfirmDelete}
isDeleting={isDeleting}
itemType={deleteItemType}
itemName={deleteItemNames}
/>
</>
)
})
@@ -0,0 +1 @@
export { FolderItem } from './folder-item'
@@ -0,0 +1,5 @@
export { ContextMenu } from './context-menu'
export { DeleteModal } from './delete-modal'
export { EmptyAreaContextMenu } from './empty-area-context-menu'
export { FolderItem } from './folder-item'
export { WorkflowItem } from './workflow-item'
@@ -0,0 +1,152 @@
'use client'
import { type CSSProperties, useMemo } from 'react'
import { Avatar, AvatarFallback, AvatarImage, Tooltip } from '@sim/emcn'
import { getUserColor } from '@/lib/workspaces/colors'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import { SIDEBAR_WIDTH } from '@/stores/constants'
import { usePresenceStore } from '@/stores/presence/store'
import { useSidebarStore } from '@/stores/sidebar/store'
/**
* Avatar display configuration for responsive layout.
*/
const AVATAR_CONFIG = {
MIN_COUNT: 4,
MAX_COUNT: 12,
WIDTH_PER_AVATAR: 20,
} as const
interface AvatarsProps {
workflowId: string
}
interface PresenceUser {
socketId: string
userId: string
userName?: string
avatarUrl?: string | null
}
interface UserAvatarProps {
user: PresenceUser
index: number
}
/**
* Individual user avatar using emcn Avatar component.
* Falls back to colored circle with initials if image fails to load.
*/
function UserAvatar({ user, index }: UserAvatarProps) {
const color = getUserColor(user.userId)
const initials = user.userName ? user.userName.charAt(0).toUpperCase() : '?'
const avatarElement = (
<Avatar size='xs' style={{ zIndex: index + 1 } as CSSProperties}>
{user.avatarUrl && (
<AvatarImage
src={user.avatarUrl}
alt={user.userName ? `${user.userName}'s avatar` : 'User avatar'}
referrerPolicy='no-referrer'
/>
)}
<AvatarFallback
style={{ background: color }}
className='border-0 font-semibold text-[7px] text-white leading-none'
>
{initials}
</AvatarFallback>
</Avatar>
)
if (user.userName) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>{avatarElement}</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
<span>{user.userName}</span>
</Tooltip.Content>
</Tooltip.Root>
)
}
return avatarElement
}
/**
* Displays user avatars for presence in a workflow item.
* Only shows avatars for the currently active workflow.
*
* @param props - Component props
* @returns Avatar stack for workflow presence
*/
export function Avatars({ workflowId }: AvatarsProps) {
const { currentWorkflowId, currentSocketId } = useSocket()
const presenceUsers = usePresenceStore((state) => state.presenceUsers)
const sidebarWidth = useSidebarStore((state) => state.sidebarWidth)
/**
* Calculate max visible avatars based on sidebar width.
* Scales between MIN_COUNT and MAX_COUNT as sidebar expands.
*/
const maxVisible = useMemo(() => {
const widthDelta = sidebarWidth - SIDEBAR_WIDTH.MIN
const additionalAvatars = Math.floor(widthDelta / AVATAR_CONFIG.WIDTH_PER_AVATAR)
const calculated = AVATAR_CONFIG.MIN_COUNT + additionalAvatars
return Math.max(AVATAR_CONFIG.MIN_COUNT, Math.min(AVATAR_CONFIG.MAX_COUNT, calculated))
}, [sidebarWidth])
/**
* Only show presence for the currently active workflow.
* Filter out the current socket connection (allows same user's other tabs to appear).
*/
const workflowUsers = useMemo(() => {
if (currentWorkflowId !== workflowId) {
return []
}
return presenceUsers.filter((user) => user.socketId !== currentSocketId)
}, [presenceUsers, currentWorkflowId, workflowId, currentSocketId])
/**
* Calculate visible users and overflow count.
* Shows up to maxVisible avatars, with overflow indicator for any remaining.
* Users are reversed so new avatars appear on the left (keeping right side stable).
*/
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)
// Reverse so rightmost avatars stay stable as new ones are revealed on the left
return { visibleUsers: [...visible].reverse(), overflowCount: overflow }
}, [workflowUsers, maxVisible])
if (visibleUsers.length === 0) {
return null
}
return (
<div className='-space-x-1 flex flex-shrink-0 items-center'>
{overflowCount > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Avatar size='xs' style={{ zIndex: 0 } as CSSProperties}>
<AvatarFallback className='border-0 bg-[#404040] font-semibold text-[7px] text-white leading-none'>
+{overflowCount}
</AvatarFallback>
</Avatar>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>
{overflowCount} more user{overflowCount > 1 ? 's' : ''}
</Tooltip.Content>
</Tooltip.Root>
)}
{visibleUsers.map((user, index) => (
<UserAvatar key={user.socketId} user={user} index={index} />
))}
</div>
)
}
@@ -0,0 +1 @@
export { WorkflowItem } from './workflow-item'
@@ -0,0 +1,519 @@
'use client'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { chipVariants, cn } from '@sim/emcn'
import { Lock } from '@sim/emcn/icons'
import clsx from 'clsx'
import { MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
import { Avatars } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars'
import {
useContextMenu,
useItemDrag,
useItemRename,
useSidebarListContext,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import {
buildDragResources,
createSidebarDragGhost,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
import {
useCanDelete,
useDeleteSelection,
useDeleteWorkflow,
useDuplicateSelection,
useDuplicateWorkflow,
useExportSelection,
useExportWorkflow,
} from '@/app/workspace/[workspaceId]/w/hooks'
import { useFolderMap } from '@/hooks/queries/folders'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import {
isFolderOrAncestorLocked,
isWorkflowEffectivelyLocked,
} from '@/hooks/queries/utils/folder-tree'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useUpdateWorkflow } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
interface WorkflowItemProps {
workspaceId: string
workflow: WorkflowMetadata
active: boolean
}
/**
* WorkflowItem component displaying a single workflow with drag and selection support.
* Selection and drag callbacks come from the sidebar list context; uses the item drag
* hook for unified drag behavior.
*
* @param props - Component props
* @returns Workflow item with drag and selection support
*/
export const WorkflowItem = memo(function WorkflowItem({
workspaceId,
workflow,
active,
}: WorkflowItemProps) {
const {
isAnyDragActive,
dragDisabled,
activeWorkflowIdRef,
onWorkflowClick,
onItemDragStart,
onItemDragEnd,
} = useSidebarListContext()
const selectedWorkflows = useFolderStore((state) => state.selectedWorkflows)
const updateWorkflowMutation = useUpdateWorkflow()
const userPermissions = useUserPermissionsContext()
const isSelected = selectedWorkflows.has(workflow.id)
const { data: foldersById = {} } = useFolderMap(workspaceId)
const inheritedFolderLocked = isFolderOrAncestorLocked(workflow.folderId, foldersById)
const effectiveLocked = isWorkflowEffectivelyLocked(workflow, foldersById)
const { canDeleteWorkflows, canDeleteFolder } = useCanDelete({ workspaceId })
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [deleteItemType, setDeleteItemType] = useState<'workflow' | 'mixed'>('workflow')
const [deleteModalNames, setDeleteModalNames] = useState<string | string[]>('')
const [canDeleteSelection, setCanDeleteSelection] = useState(true)
const capturedSelectionRef = useRef<{
workflowIds: string[]
folderIds: string[]
isMixed: boolean
names: string[]
} | null>(null)
/**
* Handle opening the delete modal - uses pre-captured selection state
*/
const handleOpenDeleteModal = useCallback(() => {
if (!capturedSelectionRef.current) return
const { isMixed, names } = capturedSelectionRef.current
if (isMixed) {
setDeleteItemType('mixed')
} else {
setDeleteItemType('workflow')
}
setDeleteModalNames(names.length > 1 ? names : names[0] || '')
setIsDeleteModalOpen(true)
}, [])
const { isDeleting: isDeletingWorkflows, handleDeleteWorkflow: handleDeleteWorkflows } =
useDeleteWorkflow({
workspaceId,
workflowIds: capturedSelectionRef.current?.workflowIds || [],
isActive: (workflowIds) => workflowIds.includes(activeWorkflowIdRef.current ?? ''),
onSuccess: () => setIsDeleteModalOpen(false),
})
const { isDeleting: isDeletingSelection, handleDeleteSelection } = useDeleteSelection({
workspaceId,
workflowIds: capturedSelectionRef.current?.workflowIds || [],
folderIds: capturedSelectionRef.current?.folderIds || [],
isActiveWorkflow: (id) => id === activeWorkflowIdRef.current,
onSuccess: () => setIsDeleteModalOpen(false),
})
const isDeleting = isDeletingWorkflows || isDeletingSelection
const handleConfirmDelete = useCallback(async () => {
if (!capturedSelectionRef.current) return
const { isMixed } = capturedSelectionRef.current
if (isMixed) {
await handleDeleteSelection()
} else {
await handleDeleteWorkflows()
}
}, [handleDeleteSelection, handleDeleteWorkflows])
const { handleDuplicateWorkflow: duplicateWorkflows } = useDuplicateWorkflow({ workspaceId })
const { isDuplicating: isDuplicatingSelection, handleDuplicateSelection } = useDuplicateSelection(
{ workspaceId }
)
const { handleExportWorkflow: handleExportWorkflows } = useExportWorkflow({ workspaceId })
const { handleExportSelection } = useExportSelection({ workspaceId })
const handleDuplicate = useCallback(() => {
if (!capturedSelectionRef.current) return
const { isMixed, workflowIds, folderIds } = capturedSelectionRef.current
if (isMixed) {
handleDuplicateSelection(workflowIds, folderIds)
} else {
if (workflowIds.length === 0) return
duplicateWorkflows(workflowIds)
}
}, [duplicateWorkflows, handleDuplicateSelection])
const handleExport = useCallback(() => {
if (!capturedSelectionRef.current) return
const { isMixed, workflowIds, folderIds } = capturedSelectionRef.current
if (isMixed) {
handleExportSelection(workflowIds, folderIds)
} else {
if (workflowIds.length === 0) return
handleExportWorkflows(workflowIds)
}
}, [handleExportWorkflows, handleExportSelection])
const handleOpenInNewTab = useCallback(() => {
window.open(`/workspace/${workspaceId}/w/${workflow.id}`, '_blank')
}, [workspaceId, workflow.id])
const handleToggleLock = useCallback(() => {
if (inheritedFolderLocked) return
updateWorkflowMutation.mutate({
workspaceId,
workflowId: workflow.id,
metadata: { locked: !workflow.locked },
})
}, [updateWorkflowMutation, workflow.id, workflow.locked, inheritedFolderLocked, workspaceId])
const isEditingRef = useRef(false)
const dragGhostRef = useRef<HTMLElement | null>(null)
const {
isOpen: isContextMenuOpen,
position,
menuRef,
handleContextMenu: handleContextMenuBase,
closeMenu,
preventDismiss,
} = useContextMenu()
const isMixedSelection = useMemo(() => {
return capturedSelectionRef.current?.isMixed ?? false
}, [isContextMenuOpen])
const captureSelectionState = useCallback(() => {
const store = useFolderStore.getState()
const isCurrentlySelected = store.selectedWorkflows.has(workflow.id)
if (!isCurrentlySelected) {
// Replace selection with just this item (Finder/Explorer pattern)
// This clears both workflow and folder selections
store.clearAllSelection()
store.selectWorkflow(workflow.id)
}
const finalWorkflowSelection = useFolderStore.getState().selectedWorkflows
const finalFolderSelection = useFolderStore.getState().selectedFolders
const workflowIds = Array.from(finalWorkflowSelection)
const folderIds = Array.from(finalFolderSelection)
const isMixed = workflowIds.length > 0 && folderIds.length > 0
const workflows = getWorkflows(workspaceId)
const folderMap = getFolderMap(workspaceId)
const names: string[] = []
for (const id of workflowIds) {
const w = workflows.find((wf) => wf.id === id)
if (w) names.push(w.name)
}
for (const id of folderIds) {
const f = folderMap[id]
if (f) names.push(f.name)
}
capturedSelectionRef.current = {
workflowIds,
folderIds,
isMixed,
names,
}
const canDeleteAllWorkflows = canDeleteWorkflows(workflowIds)
const canDeleteAllFolders =
folderIds.length === 0 || folderIds.every((id) => canDeleteFolder(id))
setCanDeleteSelection(canDeleteAllWorkflows && canDeleteAllFolders)
}, [workflow.id, canDeleteWorkflows, canDeleteFolder])
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
captureSelectionState()
handleContextMenuBase(e)
},
[captureSelectionState, handleContextMenuBase]
)
const handleMorePointerDown = useCallback(() => {
if (isContextMenuOpen) {
preventDismiss()
}
}, [isContextMenuOpen, preventDismiss])
const handleMoreClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
e.stopPropagation()
if (isContextMenuOpen) {
closeMenu()
return
}
captureSelectionState()
const rect = e.currentTarget.getBoundingClientRect()
handleContextMenuBase({
preventDefault: () => {},
stopPropagation: () => {},
clientX: rect.right,
clientY: rect.top,
} as React.MouseEvent)
},
[isContextMenuOpen, closeMenu, captureSelectionState, handleContextMenuBase]
)
const {
isEditing,
editValue,
isRenaming,
inputRef,
setEditValue,
handleStartEdit,
handleKeyDown,
handleInputBlur,
} = useItemRename({
initialName: workflow.name,
onSave: async (newName) => {
await updateWorkflowMutation.mutateAsync({
workspaceId,
workflowId: workflow.id,
metadata: { name: newName },
})
},
itemType: 'workflow',
itemId: workflow.id,
})
isEditingRef.current = isEditing
const onDragStart = useCallback(
(e: React.DragEvent) => {
if (isEditingRef.current) {
e.preventDefault()
return
}
const { selectedWorkflows, selectedFolders } = useFolderStore.getState()
const isCurrentlySelected = selectedWorkflows.has(workflow.id)
const selection = isCurrentlySelected
? {
workflowIds: Array.from(selectedWorkflows),
folderIds: Array.from(selectedFolders),
}
: {
workflowIds: [workflow.id],
folderIds: [],
}
e.dataTransfer.setData('sidebar-selection', JSON.stringify(selection))
e.dataTransfer.effectAllowed = 'copyMove'
const resources = buildDragResources(selection, workspaceId)
if (resources.length > 0) {
e.dataTransfer.setData(SIM_RESOURCES_DRAG_TYPE, JSON.stringify(resources))
}
const total = selection.workflowIds.length + selection.folderIds.length
const ghostLabel = total > 1 ? `${workflow.name} +${total - 1} more` : workflow.name
const icon = total === 1 ? { kind: 'workflow' as const } : undefined
const ghost = createSidebarDragGhost(ghostLabel, icon)
// Force reflow so the browser can capture the rendered element
void ghost.offsetHeight
e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2)
dragGhostRef.current = ghost
onItemDragStart(workflow.folderId || null)
},
[workflow.id, workflow.name, workflow.folderId, workspaceId, onItemDragStart]
)
const {
isDragging,
shouldPreventClickRef,
handleDragStart,
handleDragEnd: handleDragEndBase,
} = useItemDrag({
onDragStart,
})
const handleDragEnd = useCallback(() => {
if (dragGhostRef.current) {
dragGhostRef.current.remove()
dragGhostRef.current = null
}
handleDragEndBase()
onItemDragEnd()
}, [handleDragEndBase, onItemDragEnd])
const handleDoubleClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (effectiveLocked) return
handleStartEdit()
},
[handleStartEdit, effectiveLocked]
)
const handleWorkflowSelect = useCallback(
(e: React.MouseEvent<HTMLAnchorElement>) => {
e.stopPropagation()
if (shouldPreventClickRef.current || isEditing) {
e.preventDefault()
return
}
if (e.metaKey || e.ctrlKey) {
return
}
if (e.shiftKey) {
e.preventDefault()
}
onWorkflowClick(workflow.id, e.shiftKey)
},
[shouldPreventClickRef, workflow.id, onWorkflowClick, isEditing]
)
return (
<>
<Link
href={`/workspace/${workspaceId}/w/${workflow.id}`}
data-item-id={workflow.id}
className={cn(
chipVariants({
active: active || isContextMenuOpen || (isSelected && selectedWorkflows.size > 1),
fullWidth: true,
}),
(isDragging || (isAnyDragActive && isSelected)) && 'opacity-50'
)}
draggable={!isEditing && !dragDisabled && !effectiveLocked}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onClick={handleWorkflowSelect}
onContextMenu={handleContextMenu}
>
<div className='min-w-0 flex-1'>
<div className='flex min-w-0 items-center gap-2'>
{isEditing ? (
<input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
className='w-full min-w-0 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
maxLength={100}
disabled={isRenaming}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
) : (
<div
className='min-w-0 truncate text-[var(--text-body)]'
onDoubleClick={handleDoubleClick}
>
{workflow.name}
</div>
)}
{!isEditing && <Avatars workflowId={workflow.id} />}
</div>
</div>
{!isEditing && (
<div className='relative size-[18px] flex-shrink-0'>
{workflow.locked && (
<span
role='img'
aria-label='Workflow is locked'
className={clsx(
'pointer-events-none absolute inset-0 flex items-center justify-center transition-opacity',
!isAnyDragActive && 'group-hover:opacity-0',
isContextMenuOpen && 'opacity-0'
)}
>
<Lock className='size-[14px] text-[var(--text-icon)]' aria-hidden='true' />
</span>
)}
<button
type='button'
aria-label='Workflow options'
onPointerDown={handleMorePointerDown}
onClick={handleMoreClick}
className={clsx(
'pointer-events-none absolute inset-0 flex items-center justify-center rounded-sm opacity-0 transition-opacity',
!isAnyDragActive && 'group-hover:pointer-events-auto group-hover:opacity-100',
isContextMenuOpen && 'pointer-events-auto opacity-100'
)}
>
<MoreHorizontal className='size-[16px] text-[var(--text-icon)]' />
</button>
</div>
)}
</Link>
<ContextMenu
isOpen={isContextMenuOpen}
position={position}
menuRef={menuRef}
onClose={closeMenu}
onOpenInNewTab={handleOpenInNewTab}
onRename={handleStartEdit}
renameInputRef={inputRef}
onDuplicate={handleDuplicate}
onExport={handleExport}
onDelete={handleOpenDeleteModal}
showOpenInNewTab={!isMixedSelection && selectedWorkflows.size <= 1}
showRename={!isMixedSelection && selectedWorkflows.size <= 1}
showDuplicate={true}
showExport={true}
disableRename={!userPermissions.canEdit || effectiveLocked}
disableDuplicate={!userPermissions.canEdit || isDuplicatingSelection}
disableExport={!userPermissions.canEdit}
showDelete={userPermissions.canEdit}
disableDelete={!canDeleteSelection || effectiveLocked}
onToggleLock={handleToggleLock}
showLock={!isMixedSelection && selectedWorkflows.size <= 1}
disableLock={!userPermissions.canAdmin || inheritedFolderLocked}
isLocked={effectiveLocked}
/>
<DeleteModal
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
onConfirm={handleConfirmDelete}
isDeleting={isDeleting}
itemType={deleteItemType}
itemName={deleteModalNames}
/>
</>
)
})
@@ -0,0 +1 @@
export { WorkflowList } from './workflow-list'
@@ -0,0 +1,619 @@
'use client'
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import clsx from 'clsx'
import { useShallow } from 'zustand/react/shallow'
import { buildFolderTree, getFolderPath } from '@/lib/folders/tree'
import { EmptyAreaContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/empty-area-context-menu'
import { FolderItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item'
import { WorkflowItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item'
import {
SidebarListContext,
useContextMenu,
useDragDrop,
useFolderSelection,
useSidebarListContextValue,
useWorkflowSelection,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import {
compareByOrder,
groupWorkflowsByFolder,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
import { useFolderStore } from '@/stores/folders/store'
import type { FolderTreeNode } from '@/stores/folders/types'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
const TREE_SPACING = {
INDENT_PER_LEVEL: 20,
} as const
interface WorkflowListProps {
workspaceId: string
workflowId: string | undefined
regularWorkflows: WorkflowMetadata[]
isLoading?: boolean
canReorder?: boolean
scrollContainerRef: React.RefObject<HTMLDivElement | null>
onCreateWorkflow?: () => void
onCreateFolder?: () => void
disableCreate?: boolean
}
const DropIndicatorLine = memo(function DropIndicatorLine({
show,
level = 0,
position = 'before',
}: {
show: boolean
level?: number
position?: 'before' | 'after'
}) {
if (!show) return null
const positionStyle = position === 'before' ? { top: '-2px' } : { bottom: '-2px' }
return (
<div
className='pointer-events-none absolute right-0 left-0 z-20'
style={{ ...positionStyle, paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
>
<div className='h-[2px] rounded-full bg-[var(--text-subtle)]' />
</div>
)
})
export const WorkflowList = memo(function WorkflowList({
workspaceId,
workflowId,
regularWorkflows,
isLoading = false,
canReorder = true,
scrollContainerRef,
onCreateWorkflow,
onCreateFolder,
disableCreate = false,
}: WorkflowListProps) {
const { isLoading: foldersLoading } = useFolders(workspaceId)
const { data: folderMap = {} } = useFolderMap(workspaceId)
const { expandedFolders, setExpanded } = useFolderStore(
useShallow((s) => ({
expandedFolders: s.expandedFolders,
setExpanded: s.setExpanded,
}))
)
const {
isOpen: isEmptyAreaMenuOpen,
position: emptyAreaMenuPosition,
menuRef: emptyAreaMenuRef,
handleContextMenu: handleEmptyAreaContextMenu,
closeMenu: closeEmptyAreaMenu,
} = useContextMenu()
const {
dropIndicator,
isDragging,
disabled: dragDisabled,
setScrollContainer,
createWorkflowDragHandlers,
createFolderDragHandlers,
createEmptyFolderDropZone,
createFolderContentDropZone,
createRootDropZone,
createEdgeDropZone,
handleDragStart,
handleDragEnd,
} = useDragDrop({ disabled: !canReorder })
useEffect(() => {
if (scrollContainerRef.current) {
setScrollContainer(scrollContainerRef.current)
}
}, [scrollContainerRef, setScrollContainer])
const folderTree = useMemo(
() => (workspaceId ? buildFolderTree(folderMap, workspaceId) : []),
[workspaceId, folderMap]
)
const activeWorkflowFolderId = useMemo(() => {
if (!workflowId || isLoading || foldersLoading) return null
const activeWorkflow = regularWorkflows.find((workflow) => workflow.id === workflowId)
return activeWorkflow?.folderId || null
}, [workflowId, regularWorkflows, isLoading, foldersLoading])
const workflowsByFolder = useMemo(
() => groupWorkflowsByFolder(regularWorkflows),
[regularWorkflows]
)
const orderedWorkflowIds = useMemo(() => {
const ids: string[] = []
const collectFromFolder = (folder: FolderTreeNode) => {
const workflowsInFolder = workflowsByFolder[folder.id] || []
const childItems: Array<{
type: 'folder' | 'workflow'
id: string
sortOrder: number
createdAt?: Date
data: FolderTreeNode | WorkflowMetadata
}> = []
for (const child of folder.children) {
childItems.push({
type: 'folder',
id: child.id,
sortOrder: child.sortOrder,
createdAt: child.createdAt,
data: child,
})
}
for (const wf of workflowsInFolder) {
childItems.push({
type: 'workflow',
id: wf.id,
sortOrder: wf.sortOrder,
createdAt: wf.createdAt,
data: wf,
})
}
childItems.sort(compareByOrder)
for (const item of childItems) {
if (item.type === 'workflow') {
ids.push(item.id)
} else {
collectFromFolder(item.data as FolderTreeNode)
}
}
}
const rootLevelItems: Array<{
type: 'folder' | 'workflow'
id: string
sortOrder: number
createdAt?: Date
data: FolderTreeNode | WorkflowMetadata
}> = []
for (const folder of folderTree) {
rootLevelItems.push({
type: 'folder',
id: folder.id,
sortOrder: folder.sortOrder,
createdAt: folder.createdAt,
data: folder,
})
}
const rootWfs = workflowsByFolder.root || []
for (const wf of rootWfs) {
rootLevelItems.push({
type: 'workflow',
id: wf.id,
sortOrder: wf.sortOrder,
createdAt: wf.createdAt,
data: wf,
})
}
rootLevelItems.sort(compareByOrder)
for (const item of rootLevelItems) {
if (item.type === 'workflow') {
ids.push(item.id)
} else {
collectFromFolder(item.data as FolderTreeNode)
}
}
return ids
}, [folderTree, workflowsByFolder])
const orderedFolderIds = useMemo(() => {
const ids: string[] = []
const collectFromFolder = (folder: FolderTreeNode) => {
ids.push(folder.id)
const workflowsInFolder = workflowsByFolder[folder.id] || []
const childItems: Array<{
type: 'folder' | 'workflow'
id: string
sortOrder: number
createdAt?: Date
data: FolderTreeNode | WorkflowMetadata
}> = []
for (const child of folder.children) {
childItems.push({
type: 'folder',
id: child.id,
sortOrder: child.sortOrder,
createdAt: child.createdAt,
data: child,
})
}
for (const wf of workflowsInFolder) {
childItems.push({
type: 'workflow',
id: wf.id,
sortOrder: wf.sortOrder,
createdAt: wf.createdAt,
data: wf,
})
}
childItems.sort(compareByOrder)
for (const item of childItems) {
if (item.type === 'folder') {
collectFromFolder(item.data as FolderTreeNode)
}
}
}
const rootLevelItems: Array<{
type: 'folder' | 'workflow'
id: string
sortOrder: number
createdAt?: Date
data: FolderTreeNode | WorkflowMetadata
}> = []
for (const folder of folderTree) {
rootLevelItems.push({
type: 'folder',
id: folder.id,
sortOrder: folder.sortOrder,
createdAt: folder.createdAt,
data: folder,
})
}
const rootWfs = workflowsByFolder.root || []
for (const wf of rootWfs) {
rootLevelItems.push({
type: 'workflow',
id: wf.id,
sortOrder: wf.sortOrder,
createdAt: wf.createdAt,
data: wf,
})
}
rootLevelItems.sort(compareByOrder)
for (const item of rootLevelItems) {
if (item.type === 'folder') {
collectFromFolder(item.data as FolderTreeNode)
}
}
return ids
}, [folderTree, workflowsByFolder])
const {
workflowAncestorFolderIds,
folderDescendantWorkflowIds,
folderAncestorIds,
folderDescendantIds,
} = useMemo(() => {
const wfAncestors: Record<string, string[]> = {}
const fDescWfs: Record<string, string[]> = {}
const fAncestors: Record<string, string[]> = {}
const fDescendants: Record<string, string[]> = {}
const buildMaps = (folder: FolderTreeNode, ancestors: string[]) => {
fAncestors[folder.id] = ancestors
const wfsInFolder = (workflowsByFolder[folder.id] || []).map((w) => w.id)
const allDescWfs = [...wfsInFolder]
const allDescFolders: string[] = []
for (const child of folder.children) {
buildMaps(child, [...ancestors, folder.id])
allDescFolders.push(child.id, ...(fDescendants[child.id] || []))
allDescWfs.push(...(fDescWfs[child.id] || []))
}
fDescendants[folder.id] = allDescFolders
fDescWfs[folder.id] = allDescWfs
}
for (const folder of folderTree) {
buildMaps(folder, [])
}
for (const wf of regularWorkflows) {
if (wf.folderId && fAncestors[wf.folderId] !== undefined) {
wfAncestors[wf.id] = [wf.folderId, ...fAncestors[wf.folderId]]
}
}
return {
workflowAncestorFolderIds: wfAncestors,
folderDescendantWorkflowIds: fDescWfs,
folderAncestorIds: fAncestors,
folderDescendantIds: fDescendants,
}
}, [folderTree, workflowsByFolder, regularWorkflows])
const { handleWorkflowClick } = useWorkflowSelection({
workflowIds: orderedWorkflowIds,
activeWorkflowId: workflowId,
workflowAncestorFolderIds,
})
const { handleFolderClick } = useFolderSelection({
folderIds: orderedFolderIds,
folderDescendantWorkflowIds,
folderAncestorIds,
folderDescendantIds,
})
/** Mirror `workflowId` into a stable ref so the list context stays referentially stable across navigation. */
const activeWorkflowIdRef = useRef(workflowId)
activeWorkflowIdRef.current = workflowId
const listContextValue = useSidebarListContextValue({
isAnyDragActive: isDragging,
dragDisabled,
activeWorkflowIdRef,
onWorkflowClick: handleWorkflowClick,
onFolderClick: handleFolderClick,
onItemDragStart: handleDragStart,
onItemDragEnd: handleDragEnd,
})
const isWorkflowActive = useCallback((wfId: string) => wfId === workflowId, [workflowId])
useEffect(() => {
if (!workflowId || isLoading || foldersLoading) return
if (activeWorkflowFolderId) {
const folderPath = getFolderPath(folderMap, activeWorkflowFolderId)
folderPath.forEach((folder) => setExpanded(folder.id, true))
}
const { selectedWorkflows, selectOnly } = useFolderStore.getState()
if (!selectedWorkflows.has(workflowId)) {
selectOnly(workflowId)
}
}, [workflowId, activeWorkflowFolderId, isLoading, foldersLoading, folderMap, setExpanded])
const renderWorkflowItem = useCallback(
(workflow: WorkflowMetadata, level: number, folderId: string | null = null) => {
const showBefore =
dropIndicator?.targetId === workflow.id && dropIndicator?.position === 'before'
const showAfter =
dropIndicator?.targetId === workflow.id && dropIndicator?.position === 'after'
return (
<div key={workflow.id} className='relative'>
<DropIndicatorLine show={showBefore} level={level} position='before' />
<div
style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
{...createWorkflowDragHandlers(workflow.id, folderId)}
>
<WorkflowItem
workspaceId={workspaceId}
workflow={workflow}
active={isWorkflowActive(workflow.id)}
/>
</div>
<DropIndicatorLine show={showAfter} level={level} position='after' />
</div>
)
},
[workspaceId, dropIndicator, isWorkflowActive, createWorkflowDragHandlers]
)
const renderFolderSection = useCallback(
(
folder: FolderTreeNode,
level: number,
parentFolderId: string | null = null
): React.ReactNode => {
const workflowsInFolder = workflowsByFolder[folder.id] || []
const isExpanded = expandedFolders.has(folder.id)
const hasChildren = workflowsInFolder.length > 0 || folder.children.length > 0
const showBefore =
dropIndicator?.targetId === folder.id && dropIndicator?.position === 'before'
const showAfter = dropIndicator?.targetId === folder.id && dropIndicator?.position === 'after'
const showInside =
dropIndicator?.targetId === folder.id && dropIndicator?.position === 'inside'
const childItems: Array<{
type: 'folder' | 'workflow'
id: string
sortOrder: number
createdAt?: Date
data: FolderTreeNode | WorkflowMetadata
}> = []
for (const childFolder of folder.children) {
childItems.push({
type: 'folder',
id: childFolder.id,
sortOrder: childFolder.sortOrder,
createdAt: childFolder.createdAt,
data: childFolder,
})
}
for (const workflow of workflowsInFolder) {
childItems.push({
type: 'workflow',
id: workflow.id,
sortOrder: workflow.sortOrder,
createdAt: workflow.createdAt,
data: workflow,
})
}
childItems.sort(compareByOrder)
return (
<div key={folder.id} className='relative'>
<DropIndicatorLine show={showBefore} level={level} position='before' />
<div
className={clsx(
'pointer-events-none absolute inset-0 z-10 rounded-sm',
showInside && isDragging ? 'bg-[var(--text-subtle)] opacity-10' : 'hidden'
)}
/>
<div
style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
{...createFolderDragHandlers(folder.id, parentFolderId)}
>
<FolderItem workspaceId={workspaceId} folder={folder} />
</div>
<DropIndicatorLine show={showAfter} level={level} position='after' />
{isExpanded && (hasChildren || isDragging) && (
<div className='relative' {...createFolderContentDropZone(folder.id)}>
<div
className='pointer-events-none absolute top-0 bottom-0 w-px bg-[var(--border)]'
style={{ left: `${level * TREE_SPACING.INDENT_PER_LEVEL + 12}px` }}
/>
<div className='mt-0.5 space-y-0.5 pl-0.5'>
{childItems.map((item) =>
item.type === 'folder'
? renderFolderSection(item.data as FolderTreeNode, level + 1, folder.id)
: renderWorkflowItem(item.data as WorkflowMetadata, level + 1, folder.id)
)}
{!hasChildren && isDragging && (
<div className='h-[24px]' {...createEmptyFolderDropZone(folder.id)} />
)}
</div>
</div>
)}
</div>
)
},
[
workspaceId,
workflowsByFolder,
expandedFolders,
dropIndicator,
isDragging,
createFolderDragHandlers,
createEmptyFolderDropZone,
createFolderContentDropZone,
renderWorkflowItem,
]
)
const rootDropZoneHandlers = createRootDropZone()
const rootWorkflows = workflowsByFolder.root || []
const rootItems = useMemo(() => {
const items: Array<{
type: 'folder' | 'workflow'
id: string
sortOrder: number
createdAt?: Date
data: FolderTreeNode | WorkflowMetadata
}> = []
for (const folder of folderTree) {
items.push({
type: 'folder',
id: folder.id,
sortOrder: folder.sortOrder,
createdAt: folder.createdAt,
data: folder,
})
}
for (const workflow of rootWorkflows) {
items.push({
type: 'workflow',
id: workflow.id,
sortOrder: workflow.sortOrder,
createdAt: workflow.createdAt,
data: workflow,
})
}
return items.sort(compareByOrder)
}, [folderTree, rootWorkflows])
const hasRootItems = rootItems.length > 0
const firstItemId = rootItems[0]?.id ?? null
const lastItemId = rootItems[rootItems.length - 1]?.id ?? null
const showRootInside = dropIndicator?.targetId === 'root' && dropIndicator?.position === 'inside'
const handleContainerClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return
const { selectOnly, clearAllSelection } = useFolderStore.getState()
workflowId ? selectOnly(workflowId) : clearAllSelection()
},
[workflowId]
)
const handleContainerContextMenu = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement
const isOnEmptyArea =
target === e.currentTarget ||
target.classList.contains('space-y-0.5') ||
target.closest('[data-empty-area]')
if (!isOnEmptyArea) return
if (!onCreateWorkflow && !onCreateFolder) return
handleEmptyAreaContextMenu(e)
},
[handleEmptyAreaContextMenu, onCreateWorkflow, onCreateFolder]
)
return (
<SidebarListContext.Provider value={listContextValue}>
<div
role='tree'
aria-label='Workflows'
className='flex min-h-full flex-col pb-2'
onClick={handleContainerClick}
onContextMenu={handleContainerContextMenu}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (e.target !== e.currentTarget) return
const { selectOnly, clearAllSelection } = useFolderStore.getState()
workflowId ? selectOnly(workflowId) : clearAllSelection()
}
}}
data-empty-area
>
<div
className={clsx('relative flex-1 rounded-sm', !hasRootItems && 'min-h-[26px]')}
{...rootDropZoneHandlers}
data-empty-area
>
<div
className={clsx(
'pointer-events-none absolute inset-0 z-10 rounded-sm',
showRootInside && isDragging ? 'bg-[var(--text-subtle)] opacity-10' : 'hidden'
)}
/>
{isDragging && hasRootItems && (
<div
className='absolute top-0 right-0 left-0 z-30 h-[12px]'
{...createEdgeDropZone(firstItemId, 'before')}
/>
)}
<div className='space-y-0.5' data-empty-area>
{rootItems.map((item) =>
item.type === 'folder'
? renderFolderSection(item.data as FolderTreeNode, 0, null)
: renderWorkflowItem(item.data as WorkflowMetadata, 0, null)
)}
</div>
{isDragging && hasRootItems && (
<div
className='absolute right-0 bottom-0 left-0 z-30 h-[12px]'
{...createEdgeDropZone(lastItemId, 'after')}
/>
)}
</div>
</div>
{onCreateWorkflow && onCreateFolder && (
<EmptyAreaContextMenu
isOpen={isEmptyAreaMenuOpen}
position={emptyAreaMenuPosition}
menuRef={emptyAreaMenuRef}
onClose={closeEmptyAreaMenu}
onCreateWorkflow={onCreateWorkflow}
onCreateFolder={onCreateFolder}
disableCreateWorkflow={disableCreate}
disableCreateFolder={disableCreate}
/>
)}
</SidebarListContext.Provider>
)
})
@@ -0,0 +1,92 @@
'use client'
import { useState } from 'react'
import {
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
} from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
interface CreateWorkspaceModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: (name: string) => Promise<void>
isCreating: boolean
}
/**
* Modal for naming a new workspace before creation.
*/
export function CreateWorkspaceModal({
open,
onOpenChange,
onConfirm,
isCreating,
}: CreateWorkspaceModalProps) {
const [name, setName] = useState('')
const [error, setError] = useState<string | null>(null)
const [prevOpen, setPrevOpen] = useState(open)
if (prevOpen !== open) {
setPrevOpen(open)
if (open) {
setName('')
setError(null)
}
}
const handleSubmit = async () => {
const trimmed = name.trim()
if (!trimmed || isCreating) return
try {
await onConfirm(trimmed)
} catch (err) {
setError(getErrorMessage(err, 'Failed to create workspace'))
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
void handleSubmit()
}
}
const handleNameChange = (value: string) => {
setName(value)
setError(null)
}
return (
<ChipModal open={open} onOpenChange={onOpenChange} srTitle='Create workspace'>
<ChipModalHeader onClose={() => onOpenChange(false)}>Create workspace</ChipModalHeader>
<ChipModalBody onKeyDown={handleKeyDown}>
<ChipModalField
type='input'
title='Name'
value={name}
onChange={handleNameChange}
placeholder='Workspace name'
maxLength={100}
autoComplete='off'
disabled={isCreating}
required
/>
<ChipModalError>{error ?? undefined}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
cancelDisabled={isCreating}
primaryAction={{
label: isCreating ? 'Creating...' : 'Create',
onClick: () => void handleSubmit(),
disabled: !name.trim() || isCreating,
}}
/>
</ChipModal>
)
}
@@ -0,0 +1 @@
export { InviteModal } from './invite-modal'
@@ -0,0 +1,215 @@
'use client'
import { useCallback, useState } from 'react'
import {
ChipModal,
ChipModalBody,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
toast,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { useSession } from '@/lib/auth/auth-client'
import { isEnterprise } from '@/lib/billing/plan-helpers'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
import { useBatchSendWorkspaceInvitations } from '@/hooks/queries/invitations'
import { useOrganizationBilling } from '@/hooks/queries/organization'
const logger = createLogger('InviteModal')
const ROLE_OPTIONS = [
{ value: 'admin', label: 'Admin' },
{ value: 'write', label: 'Write' },
{ value: 'read', label: 'Read' },
] as const
interface InviteModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workspaceName?: string
inviteDisabledReason?: string | null
organizationId?: string | null
}
export function InviteModal({
open,
onOpenChange,
workspaceName,
inviteDisabledReason = null,
organizationId = null,
}: InviteModalProps) {
const [emails, setEmails] = useState<string[]>([])
const [inviteRole, setInviteRole] = useState<PermissionType>('admin')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const params = useParams()
const workspaceId = params.workspaceId as string
const { data: session } = useSession()
const { workspacePermissions, userPermissions: userPerms } = useWorkspacePermissionsContext()
const { data: organizationBillingData } = useOrganizationBilling(organizationId ?? '', {
enabled: open && isBillingEnabled,
})
const batchSendInvitations = useBatchSendWorkspaceInvitations()
const canInviteMembers = userPerms.canAdmin && !inviteDisabledReason
const isSubmitting = batchSendInvitations.isPending
const totalSeats = organizationBillingData?.data?.totalSeats ?? 0
const usedSeats = organizationBillingData?.data?.usedSeats ?? 0
const availableSeats = Math.max(0, totalSeats - usedSeats)
// Only Enterprise plans have a fixed seat cap that gates invites. Team/Pro
// seats are provisioned automatically when an invitee accepts.
const isEnterpriseOrg = isEnterprise(organizationBillingData?.data?.subscriptionPlan)
const hasSeatData = !!organizationId && isEnterpriseOrg && totalSeats > 0
const exceedsSeatCapacity = hasSeatData && userPerms.canAdmin && emails.length > availableSeats
const seatLimitReason = exceedsSeatCapacity
? `Only ${availableSeats} internal seat${availableSeats === 1 ? '' : 's'} available. External workspace invites do not require seats.`
: null
const validateEmail = useCallback(
(email: string): string | null => {
const formatResult = quickValidateEmail(email)
if (!formatResult.isValid) {
return formatResult.reason ?? 'Invalid email'
}
if (workspacePermissions?.users?.some((user) => user.email === email)) {
return `${email} is already a teammate in this workspace`
}
if (session?.user?.email && session.user.email.toLowerCase() === email) {
return 'You cannot invite yourself'
}
return null
},
[workspacePermissions?.users, session?.user?.email]
)
const handleEmailsChange = useCallback((next: string[]) => {
setEmails(next)
setErrorMessage(null)
}, [])
const handleSendInvites = useCallback(() => {
setErrorMessage(null)
if (!canInviteMembers || emails.length === 0 || !workspaceId) return
const invitations = emails.map((email) => ({ email, permission: inviteRole }))
batchSendInvitations.mutate(
{ workspaceId, organizationId, invitations },
{
onSuccess: (result) => {
const parts: string[] = []
if (result.added.length > 0) {
parts.push(`${result.added.length} member${result.added.length === 1 ? '' : 's'} added`)
}
if (result.successful.length > 0) {
parts.push(
`${result.successful.length} invite${result.successful.length === 1 ? '' : 's'} sent`
)
}
if (parts.length > 0) {
toast.success(parts.join(' · '))
}
if (result.failed.length > 0) {
// Keep the failed addresses in the field with the error for retry.
setEmails(result.failed.map((f) => f.email))
setErrorMessage(
result.failed.length === 1
? result.failed[0].error
: `${result.failed.length} invitations failed. ${result.failed[0].error}`
)
return
}
setEmails([])
onOpenChange(false)
},
onError: (error) => {
logger.error('Error inviting teammates:', error)
setErrorMessage(error.message || 'An unexpected error occurred. Please try again.')
},
}
)
}, [
canInviteMembers,
emails,
workspaceId,
organizationId,
inviteRole,
batchSendInvitations,
onOpenChange,
])
const resetState = useCallback(() => {
setEmails([])
setInviteRole('admin')
setErrorMessage(null)
}, [])
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) resetState()
onOpenChange(next)
},
[onOpenChange, resetState]
)
const isSendDisabled =
!canInviteMembers || isSubmitting || !workspaceId || emails.length === 0 || exceedsSeatCapacity
const fieldHint = inviteDisabledReason ?? seatLimitReason
return (
<ChipModal
open={open}
onOpenChange={handleOpenChange}
srTitle={`Invite teammates to ${workspaceName || 'workspace'}`}
>
<ChipModalHeader onClose={() => handleOpenChange(false)}>Invite teammates</ChipModalHeader>
<ChipModalBody>
<ChipModalField
type='emails'
title='Emails'
value={emails}
onChange={handleEmailsChange}
validate={validateEmail}
error={errorMessage}
hint={fieldHint}
placeholder={
!canInviteMembers
? inviteDisabledReason || 'Only administrators can invite new teammates'
: 'Enter emails'
}
disabled={isSubmitting || !canInviteMembers}
/>
<ChipModalField
type='dropdown'
title='Invite as'
options={ROLE_OPTIONS}
value={inviteRole}
placeholder='Select role'
align='start'
onChange={(role) => setInviteRole(role as PermissionType)}
/>
</ChipModalBody>
<ChipModalFooter
onCancel={() => handleOpenChange(false)}
cancelDisabled={isSubmitting}
primaryAction={{
label: isSubmitting ? 'Sending...' : 'Send invites',
onClick: handleSendInvites,
disabled: isSendDisabled,
}}
/>
</ChipModal>
)
}
@@ -0,0 +1 @@
export { WorkspaceHeader } from './workspace-header'
@@ -0,0 +1,708 @@
'use client'
import { memo, useEffect, useRef, useState } from 'react'
import {
ChevronDown,
Chip,
ChipConfirmModal,
chipVariants,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
Plus,
Send,
Skeleton,
} from '@sim/emcn'
import { ManageWorkspace, PanelLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { MoreHorizontal } from 'lucide-react'
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
import { CreateWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal'
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
import type { Workspace, WorkspaceCreationPolicy } from '@/hooks/queries/workspace'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
const logger = createLogger('WorkspaceHeader')
interface WorkspaceHeaderProps {
/** The active workspace object */
activeWorkspace?: { name: string } | null
/** Current workspace ID */
workspaceId: string
/** List of available workspaces */
workspaces: Workspace[]
/** Server-derived workspace creation policy for the current user context */
workspaceCreationPolicy?: WorkspaceCreationPolicy | null
/** 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 confirmed with a name */
onCreateWorkspace: (name: string) => Promise<void>
/** Callback to rename the workspace */
onRenameWorkspace: (workspaceId: string, newName: string) => Promise<void>
/** Callback to delete the workspace */
onDeleteWorkspace: (workspaceId: string) => Promise<void>
/** Whether workspace deletion is in progress */
isDeletingWorkspace: boolean
/** Callback to upload a workspace logo */
onUploadLogo: (workspaceId: string) => void
/** Callback to leave the workspace */
onLeaveWorkspace: (workspaceId: string) => Promise<void>
/** Whether workspace leave is in progress */
isLeavingWorkspace: boolean
/** Current user's session ID for owner check */
sessionUserId?: string
/** Whether the sidebar is collapsed */
isCollapsed?: boolean
/** Callback to expand the sidebar from collapsed state */
onExpandSidebar?: () => void
}
/**
* Workspace header component that displays workspace name and switcher.
*/
function WorkspaceHeaderImpl({
activeWorkspace,
workspaceId,
workspaces,
workspaceCreationPolicy,
isWorkspacesLoading,
isCreatingWorkspace,
isWorkspaceMenuOpen,
setIsWorkspaceMenuOpen,
onWorkspaceSwitch,
onCreateWorkspace,
onRenameWorkspace,
onDeleteWorkspace,
isDeletingWorkspace,
onUploadLogo,
onLeaveWorkspace,
isLeavingWorkspace,
sessionUserId,
isCollapsed = false,
onExpandSidebar,
}: WorkspaceHeaderProps) {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<Workspace | null>(null)
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false)
const [leaveTarget, setLeaveTarget] = useState<Workspace | null>(null)
const [editingWorkspaceId, setEditingWorkspaceId] = useState<string | null>(null)
const [editingName, setEditingName] = useState('')
const [isListRenaming, setIsListRenaming] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const [menuOpenWorkspaceId, setMenuOpenWorkspaceId] = useState<string | null>(null)
const contextMenuRef = useRef<HTMLDivElement | null>(null)
const capturedWorkspaceRef = useRef<Workspace | null>(null)
const isRenamingRef = useRef(false)
const isContextMenuOpeningRef = useRef(false)
const contextMenuClosedRef = useRef(true)
const hasInputFocusedRef = useRef(false)
const renameInputRef = useRef<HTMLInputElement | null>(null)
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true)
}, [])
const { navigateToSettings } = useSettingsNavigation()
const activeWorkspaceFull = workspaces.find((w) => w.id === workspaceId) || null
const isWorkspaceReady = !isWorkspacesLoading && activeWorkspaceFull !== null
const canCreateWorkspace = workspaceCreationPolicy?.canCreate ?? true
const createWorkspaceDisabledReason =
workspaceCreationPolicy?.canCreate === false ? workspaceCreationPolicy.reason : null
const { isInvitationsDisabled: isInvitationsDisabledByConfig } = usePermissionConfig()
const inviteDisabledReason = activeWorkspaceFull?.inviteDisabledReason ?? null
const isInvitationsDisabled = isInvitationsDisabledByConfig || inviteDisabledReason !== null
/**
* 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 workspaceInitial = (() => {
const name = activeWorkspace?.name || ''
const stripped = name.replace(/workspace/gi, '').trim()
return (stripped[0] || name[0] || 'W').toUpperCase()
})()
/**
* Opens the context menu for a workspace at the specified position
*/
const openContextMenuAt = (workspace: Workspace, x: number, y: number) => {
isContextMenuOpeningRef.current = true
contextMenuClosedRef.current = false
capturedWorkspaceRef.current = workspace
setMenuOpenWorkspaceId(workspace.id)
setContextMenuPosition({ x, y })
setIsContextMenuOpen(true)
}
/**
* Handle right-click context menu
*/
const handleContextMenu = (e: React.MouseEvent, workspace: Workspace) => {
e.preventDefault()
e.stopPropagation()
openContextMenuAt(workspace, e.clientX, e.clientY)
}
/**
* Close context menu and optionally the workspace dropdown
* When renaming, we keep the workspace menu open so the input is visible
* This function is idempotent - duplicate calls are ignored
*/
const closeContextMenu = () => {
if (contextMenuClosedRef.current) {
return
}
contextMenuClosedRef.current = true
setIsContextMenuOpen(false)
setMenuOpenWorkspaceId(null)
const isOpeningAnother = isContextMenuOpeningRef.current
isContextMenuOpeningRef.current = false
if (!isRenamingRef.current && !isOpeningAnother) {
setIsWorkspaceMenuOpen(false)
}
isRenamingRef.current = false
}
/**
* Handles rename action from context menu
*/
const handleRenameAction = () => {
if (!capturedWorkspaceRef.current) return
isRenamingRef.current = true
hasInputFocusedRef.current = false
setEditingWorkspaceId(capturedWorkspaceRef.current.id)
setEditingName(capturedWorkspaceRef.current.name)
setIsWorkspaceMenuOpen(true)
}
/**
* Handles delete action from context menu
*/
const handleDeleteAction = () => {
if (!capturedWorkspaceRef.current) return
const workspace = workspaces.find((w) => w.id === capturedWorkspaceRef.current?.id)
if (workspace) {
setDeleteTarget(workspace)
setIsDeleteModalOpen(true)
setIsWorkspaceMenuOpen(false)
}
}
/**
* Handles leave action from context menu - shows confirmation modal
*/
const handleLeaveAction = () => {
if (!capturedWorkspaceRef.current) return
const workspace = workspaces.find((w) => w.id === capturedWorkspaceRef.current?.id)
if (workspace) {
setLeaveTarget(workspace)
setIsLeaveModalOpen(true)
setIsWorkspaceMenuOpen(false)
}
}
const handleUploadLogoAction = () => {
if (!capturedWorkspaceRef.current) return
onUploadLogo(capturedWorkspaceRef.current.id)
}
/**
* Handle leave workspace after confirmation
*/
const handleLeaveWorkspace = async () => {
if (!leaveTarget) return
try {
await onLeaveWorkspace(leaveTarget.id)
setIsLeaveModalOpen(false)
setLeaveTarget(null)
} catch (error) {
logger.error('Error leaving workspace:', error)
}
}
/**
* Handle delete workspace after confirmation
*/
const handleDeleteWorkspace = async () => {
try {
const targetId = deleteTarget?.id || workspaceId
await onDeleteWorkspace(targetId)
setIsDeleteModalOpen(false)
setDeleteTarget(null)
} catch (error) {
logger.error('Error deleting workspace:', error)
}
}
return (
<div className='min-w-0 flex-1'>
{isMounted && isCollapsed ? (
<button
type='button'
aria-label='Expand sidebar'
onClick={onExpandSidebar}
className={chipVariants({ fullWidth: true })}
>
<div className='relative flex size-[16px] flex-shrink-0 items-center justify-center'>
{!activeWorkspaceFull ? (
<Skeleton className='size-[16px] rounded-sm' />
) : (
<>
{activeWorkspaceFull.logoUrl ? (
<img
src={activeWorkspaceFull.logoUrl}
alt={activeWorkspaceFull.name || 'Workspace logo'}
className='size-[16px] rounded-sm object-cover group-hover:invisible'
/>
) : (
<div
className='flex size-[16px] items-center justify-center rounded-sm font-medium text-[9px] text-white leading-none group-hover:invisible'
style={{
backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)',
}}
>
{workspaceInitial}
</div>
)}
<PanelLeft
aria-hidden
className='pointer-events-none invisible absolute inset-0 m-auto size-[16px] rotate-180 text-[var(--text-icon)] group-hover:visible'
/>
</>
)}
</div>
</button>
) : isMounted && isWorkspaceReady ? (
<DropdownMenu
open={isWorkspaceMenuOpen}
onOpenChange={(open) => {
if (
!open &&
(isContextMenuOpen || isContextMenuOpeningRef.current || editingWorkspaceId)
) {
return
}
setIsWorkspaceMenuOpen(open)
}}
>
<DropdownMenuTrigger asChild>
<button
type='button'
aria-label='Switch workspace'
className={cn(chipVariants(), 'min-w-0 max-w-full')}
title={activeWorkspace?.name}
onContextMenu={(e) => {
if (activeWorkspaceFull) {
handleContextMenu(e, activeWorkspaceFull)
}
}}
>
{activeWorkspaceFull ? (
activeWorkspaceFull.logoUrl ? (
<img
src={activeWorkspaceFull.logoUrl}
alt={activeWorkspaceFull.name || 'Workspace logo'}
className='size-[16px] flex-shrink-0 rounded-sm object-cover'
/>
) : (
<div
className='flex size-[16px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-[9px] text-white leading-none'
style={{
backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)',
}}
>
{workspaceInitial}
</div>
)
) : (
<Skeleton className='size-[16px] flex-shrink-0 rounded-sm' />
)}
{!isCollapsed && activeWorkspace?.name && (
<>
<span className='min-w-0 truncate text-[var(--text-body)] text-sm'>
{activeWorkspace.name}
</span>
<ChevronDown className='h-[6px] w-[10px] flex-shrink-0 text-[var(--text-icon)]' />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side={isCollapsed ? 'right' : 'bottom'}
sideOffset={isCollapsed ? 16 : 8}
className='flex max-h-none flex-col overflow-hidden'
style={{
width: '248px',
maxWidth: 'calc(100vw - 24px)',
}}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{isWorkspacesLoading ? (
<div className='px-2 py-[5px] font-medium text-[var(--text-secondary)] text-caption'>
Loading workspaces...
</div>
) : (
<>
<div className='-mx-1.5 flex max-h-[94px] flex-col gap-0.5 overflow-y-auto px-1.5'>
{workspaces.map((workspace) => {
const stripped = workspace.name.replace(/workspace/gi, '').trim()
const initial = (stripped[0] || workspace.name[0] || 'W').toUpperCase()
const isActive = workspace.id === workspaceId
const isMenuOpen = menuOpenWorkspaceId === workspace.id
return (
<div key={workspace.id}>
{editingWorkspaceId === workspace.id ? (
<div
className={chipVariants({ active: true, fullWidth: true, flush: true })}
>
{workspace.logoUrl ? (
<img
src={workspace.logoUrl}
alt={workspace.name || 'Workspace logo'}
className='size-[16px] flex-shrink-0 rounded-sm object-cover'
/>
) : (
<div
className='flex size-[16px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-[9px] text-white leading-none'
style={{
backgroundColor: workspace.color ?? 'var(--brand-accent)',
}}
>
{initial}
</div>
)}
<input
ref={(el) => {
renameInputRef.current = el
if (el && !hasInputFocusedRef.current) {
hasInputFocusedRef.current = true
el.focus()
el.select()
}
}}
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={async (e) => {
e.stopPropagation()
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
const trimmedName = editingName.trim()
if (trimmedName && trimmedName !== workspace.name) {
setIsListRenaming(true)
try {
await onRenameWorkspace(workspace.id, trimmedName)
} finally {
setIsListRenaming(false)
}
}
setEditingWorkspaceId(null)
}}
className='w-full min-w-0 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
maxLength={100}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
disabled={isListRenaming}
onClick={(e) => {
e.stopPropagation()
}}
/>
</div>
) : (
<div
className={cn(
chipVariants({
active: isActive || isMenuOpen,
fullWidth: true,
flush: true,
}),
'select-none'
)}
onClick={(e) => {
if (e.metaKey || e.ctrlKey) {
window.open(`/workspace/${workspace.id}/home`, '_blank')
return
}
onWorkspaceSwitch(workspace)
}}
onAuxClick={(e) => {
if (e.button === 1) {
e.preventDefault()
window.open(`/workspace/${workspace.id}/home`, '_blank')
}
}}
onContextMenu={(e) => handleContextMenu(e, workspace)}
>
{workspace.logoUrl ? (
<img
src={workspace.logoUrl}
alt={workspace.name || 'Workspace logo'}
className='size-[16px] flex-shrink-0 rounded-sm object-cover'
/>
) : (
<div
className='flex size-[16px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-[9px] text-white leading-none'
style={{
backgroundColor: workspace.color ?? 'var(--brand-accent)',
}}
>
{initial}
</div>
)}
<span className='min-w-0 flex-1 truncate text-[var(--text-body)] text-sm'>
{workspace.name}
</span>
<button
type='button'
aria-label='Workspace options'
onMouseDown={() => {
isContextMenuOpeningRef.current = true
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
openContextMenuAt(workspace, rect.right, rect.top)
}}
className={cn(
'flex size-[18px] flex-shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity group-hover:opacity-100',
isMenuOpen && 'opacity-100'
)}
>
<MoreHorizontal className='size-[14px] text-[var(--text-tertiary)]' />
</button>
</div>
)}
</div>
)
})}
</div>
<DropdownMenuSeparator className='mx-0' />
<div className='flex flex-col gap-0.5'>
<Chip
leftIcon={Plus}
onClick={(e) => {
e.stopPropagation()
setIsWorkspaceMenuOpen(false)
if (!canCreateWorkspace) {
if (isBillingEnabled) navigateToSettings({ section: 'billing' })
return
}
setIsCreateModalOpen(true)
}}
disabled={isCreatingWorkspace}
title={createWorkspaceDisabledReason ?? undefined}
fullWidth
flush
className='w-full select-none disabled:pointer-events-none disabled:opacity-50'
>
New workspace
</Chip>
</div>
<DropdownMenuSeparator className='mx-0' />
<Chip
leftIcon={Send}
onClick={() => {
setIsWorkspaceMenuOpen(false)
if (isInvitationsDisabled) {
if (isBillingEnabled) navigateToSettings({ section: 'billing' })
return
}
setIsInviteModalOpen(true)
}}
title={inviteDisabledReason ?? undefined}
fullWidth
flush
className='w-full select-none'
>
Invite teammates
</Chip>
<Chip
leftIcon={ManageWorkspace}
onClick={() => {
setIsWorkspaceMenuOpen(false)
if (isInvitationsDisabled) {
if (isBillingEnabled) navigateToSettings({ section: 'billing' })
return
}
navigateToSettings({ section: 'teammates' })
}}
title={inviteDisabledReason ?? undefined}
fullWidth
flush
className='w-full select-none'
>
Manage workspace
</Chip>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type='button'
aria-label='Switch workspace'
className={cn(
'mx-0.5 h-[30px] items-center gap-2 rounded-lg px-2',
isCollapsed ? 'flex' : 'inline-flex min-w-0 max-w-full'
)}
title={activeWorkspace?.name}
disabled
>
{activeWorkspaceFull ? (
activeWorkspaceFull.logoUrl ? (
<img
src={activeWorkspaceFull.logoUrl}
alt={activeWorkspaceFull.name || 'Workspace logo'}
className='size-[16px] flex-shrink-0 rounded-sm object-cover'
/>
) : (
<div
className='flex size-[16px] flex-shrink-0 items-center justify-center rounded-sm font-medium text-[9px] text-white leading-none'
style={{ backgroundColor: activeWorkspaceFull.color ?? 'var(--brand-accent)' }}
>
{workspaceInitial}
</div>
)
) : (
<Skeleton className='size-[16px] flex-shrink-0 rounded-sm' />
)}
{!isCollapsed && activeWorkspace?.name && (
<>
<span className='min-w-0 truncate text-[var(--text-body)] text-sm'>
{activeWorkspace.name}
</span>
<ChevronDown className='h-[6px] w-[10px] flex-shrink-0 text-[var(--text-icon)]' />
</>
)}
</button>
)}
{(() => {
const capturedPermissions = capturedWorkspaceRef.current?.permissions
const contextCanAdmin = capturedPermissions === 'admin'
const capturedWorkspace = workspaces.find((w) => w.id === capturedWorkspaceRef.current?.id)
const isOwner = capturedWorkspace && sessionUserId === capturedWorkspace.ownerId
return (
<ContextMenu
isOpen={isContextMenuOpen}
position={contextMenuPosition}
menuRef={contextMenuRef}
onClose={closeContextMenu}
onRename={handleRenameAction}
renameInputRef={renameInputRef}
onDelete={handleDeleteAction}
onLeave={handleLeaveAction}
onUploadLogo={handleUploadLogoAction}
showRename={true}
showUploadLogo={!!onUploadLogo}
showLeave={!isOwner && !!onLeaveWorkspace}
disableRename={!contextCanAdmin}
disableDelete={!contextCanAdmin || workspaces.length <= 1}
disableUploadLogo={!contextCanAdmin}
/>
)
})()}
<CreateWorkspaceModal
open={isCreateModalOpen}
onOpenChange={setIsCreateModalOpen}
onConfirm={async (name) => {
await onCreateWorkspace(name)
setIsCreateModalOpen(false)
}}
isCreating={isCreatingWorkspace}
/>
<InviteModal
open={isInviteModalOpen}
onOpenChange={setIsInviteModalOpen}
workspaceName={activeWorkspace?.name || 'Workspace'}
inviteDisabledReason={inviteDisabledReason}
organizationId={activeWorkspaceFull?.organizationId ?? null}
/>
<DeleteModal
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
onConfirm={handleDeleteWorkspace}
isDeleting={isDeletingWorkspace}
itemType='workspace'
itemName={deleteTarget?.name}
/>
<ChipConfirmModal
open={isLeaveModalOpen}
onOpenChange={() => setIsLeaveModalOpen(false)}
srTitle='Leave workspace'
title='Leave workspace'
text={[
'Are you sure you want to leave ',
{ text: leaveTarget?.name ?? 'this workspace', bold: true },
'? You will lose access to all workflows and data in this workspace. This action cannot be undone.',
]}
confirm={{
label: 'Leave workspace',
onClick: handleLeaveWorkspace,
pending: isLeavingWorkspace,
pendingLabel: 'Leaving...',
}}
/>
</div>
)
}
export const WorkspaceHeader = memo(WorkspaceHeaderImpl)
@@ -0,0 +1,24 @@
/**
* Shared sidebar spacing tokens.
*
* Apply these Tailwind class names so the sidebar and any related surfaces
* (e.g. the workspace search modal) stay visually aligned. When the sidebar
* rhythm changes, update these values and every consumer follows.
*/
/** Vertical gap between sibling sidebar sections (12px). */
export const SIDEBAR_SECTION_GAP_CLASS = 'mt-3'
/** Vertical gap between items within a sidebar section (2px). */
export const SIDEBAR_ITEM_GAP_CLASS = 'gap-0.5'
/**
* Nested-selector variants for cmdk-based surfaces (e.g. the search modal).
* Written as complete literal strings so Tailwind's JIT can detect them.
*/
/** Matches {@link SIDEBAR_SECTION_GAP_CLASS} applied to adjacent cmdk groups. */
export const CMDK_SECTION_GAP_CLASS = '[&_[cmdk-group]+[cmdk-group]]:mt-3'
/** Matches {@link SIDEBAR_ITEM_GAP_CLASS} applied to cmdk item containers. */
export const CMDK_ITEM_GAP_CLASS = '[&_[cmdk-group-items]]:gap-0.5'
@@ -0,0 +1,21 @@
export { useAutoScroll } from './use-auto-scroll'
export { useChatSelection } from './use-chat-selection'
export { useContextMenu } from './use-context-menu'
export { type DropIndicator, useDragDrop } from './use-drag-drop'
export { useFlyoutInlineRename } from './use-flyout-inline-rename'
export { useFolderExpand } from './use-folder-expand'
export { useFolderOperations } from './use-folder-operations'
export { useFolderSelection } from './use-folder-selection'
export { useHoverMenu } from './use-hover-menu'
export { useItemDrag } from './use-item-drag'
export { useItemRename } from './use-item-rename'
export {
SidebarListContext,
useSidebarListContext,
useSidebarListContextValue,
} from './use-sidebar-list-context'
export { useSidebarResize } from './use-sidebar-resize'
export { useWorkflowOperations } from './use-workflow-operations'
export { useWorkflowSelection } from './use-workflow-selection'
export { useWorkspaceLogoUpload } from './use-workspace-logo-upload'
export { useWorkspaceManagement } from './use-workspace-management'
@@ -0,0 +1,103 @@
import { useCallback, useRef } from 'react'
/**
* Optimized auto-scroll hook for smooth drag operations
*/
export const useAutoScroll = (containerRef: React.RefObject<HTMLDivElement | null>) => {
const animationRef = useRef<number | null>(null)
const speedRef = useRef<number>(0)
const lastUpdateRef = useRef<number>(0)
const animateScroll = useCallback(() => {
const scrollContainer = containerRef.current?.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLElement
if (!scrollContainer || speedRef.current === 0) {
animationRef.current = null
return
}
const currentScrollTop = scrollContainer.scrollTop
const maxScrollTop = scrollContainer.scrollHeight - scrollContainer.clientHeight
// Check bounds and stop if needed
if (
(speedRef.current < 0 && currentScrollTop <= 0) ||
(speedRef.current > 0 && currentScrollTop >= maxScrollTop)
) {
speedRef.current = 0
animationRef.current = null
return
}
// Apply smooth scroll
scrollContainer.scrollTop = Math.max(
0,
Math.min(maxScrollTop, currentScrollTop + speedRef.current)
)
animationRef.current = requestAnimationFrame(animateScroll)
}, [containerRef])
const startScroll = useCallback(
(speed: number) => {
speedRef.current = speed
if (!animationRef.current) {
animationRef.current = requestAnimationFrame(animateScroll)
}
},
[animateScroll]
)
const stopScroll = useCallback(() => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
speedRef.current = 0
}, [])
const handleDragOver = useCallback(
(e: DragEvent) => {
const now = performance.now()
// Throttle to ~16ms for 60fps
if (now - lastUpdateRef.current < 16) return
lastUpdateRef.current = now
const scrollContainer = containerRef.current
if (!scrollContainer) return
const rect = scrollContainer.getBoundingClientRect()
const mouseY = e.clientY
// Early exit if mouse is outside container
if (mouseY < rect.top || mouseY > rect.bottom) {
stopScroll()
return
}
const scrollZone = 50
const maxSpeed = 4
const distanceFromTop = mouseY - rect.top
const distanceFromBottom = rect.bottom - mouseY
let scrollSpeed = 0
if (distanceFromTop < scrollZone) {
const intensity = (scrollZone - distanceFromTop) / scrollZone
scrollSpeed = -maxSpeed * intensity ** 2
} else if (distanceFromBottom < scrollZone) {
const intensity = (scrollZone - distanceFromBottom) / scrollZone
scrollSpeed = maxSpeed * intensity ** 2
}
if (Math.abs(scrollSpeed) > 0.1) {
startScroll(scrollSpeed)
} else {
stopScroll()
}
},
[containerRef, startScroll, stopScroll]
)
return { handleDragOver, stopScroll }
}
@@ -0,0 +1,44 @@
import { useCallback } from 'react'
import { useFolderStore } from '@/stores/folders/store'
interface UseChatSelectionProps {
/**
* Flat array of all chat IDs in display order
*/
chatIds: string[]
}
/**
* Hook for managing chat selection with support for single and range selection.
* Handles shift-click for range selection.
* cmd/ctrl+click is handled by the browser (opens in new tab) and never reaches this handler.
* Uses the last selected chat as the anchor point for range selections.
* Selecting chats clears workflow/folder selections and vice versa.
*/
export function useChatSelection({ chatIds }: UseChatSelectionProps) {
const selectedChats = useFolderStore((s) => s.selectedChats)
const handleChatClick = useCallback(
(chatId: string, shiftKey: boolean) => {
const {
selectChatOnly,
selectChatRange,
toggleChatSelection,
lastSelectedChatId: anchor,
} = useFolderStore.getState()
if (shiftKey && anchor && anchor !== chatId) {
selectChatRange(chatIds, anchor, chatId)
} else if (shiftKey) {
toggleChatSelection(chatId)
} else {
selectChatOnly(chatId)
}
},
[chatIds]
)
return {
selectedChats,
handleChatClick,
}
}
@@ -0,0 +1,90 @@
import { useCallback, useEffect, useRef, useState } from 'react'
interface UseContextMenuProps {
/**
* Callback when context menu should open
*/
onContextMenu?: (e: React.MouseEvent) => void
}
interface ContextMenuPosition {
x: number
y: number
}
/**
* Hook for managing context menu (right-click) state and positioning.
*
* Handles:
* - Right-click event prevention and positioning
* - Menu open/close state
* - Click-outside detection to close menu
*
* @param props - Hook configuration
* @returns Context menu state and handlers
*/
export function useContextMenu({ onContextMenu }: UseContextMenuProps = {}) {
const [isOpen, setIsOpen] = useState(false)
const [position, setPosition] = useState<ContextMenuPosition>({ x: 0, y: 0 })
const menuRef = useRef<HTMLDivElement>(null)
const dismissPreventedRef = useRef(false)
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
const x = e.clientX
const y = e.clientY
setPosition({ x, y })
setIsOpen(true)
onContextMenu?.(e)
},
[onContextMenu]
)
const closeMenu = useCallback(() => {
setIsOpen(false)
}, [])
const preventDismiss = useCallback(() => {
dismissPreventedRef.current = true
}, [])
/**
* Handle clicks outside the menu to close it
*/
useEffect(() => {
if (!isOpen) return
const handleClickOutside = (e: MouseEvent) => {
if (dismissPreventedRef.current) {
dismissPreventedRef.current = false
return
}
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
closeMenu()
}
}
const timeoutId = setTimeout(() => {
document.addEventListener('click', handleClickOutside)
}, 0)
return () => {
clearTimeout(timeoutId)
document.removeEventListener('click', handleClickOutside)
}
}, [isOpen, closeMenu])
return {
isOpen,
position,
menuRef,
handleContextMenu,
closeMenu,
preventDismiss,
}
}
@@ -0,0 +1,695 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { noop } from '@sim/utils/helpers'
import { useParams } from 'next/navigation'
import { getFolderPath } from '@/lib/folders/tree'
import { useReorderFolders } from '@/hooks/queries/folders'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useReorderWorkflows } from '@/hooks/queries/workflows'
import { useFolderStore } from '@/stores/folders/store'
const logger = createLogger('WorkflowList:DragDrop')
const SCROLL_THRESHOLD = 60
const SCROLL_SPEED = 8
const HOVER_EXPAND_DELAY = 400
export interface DropIndicator {
targetId: string
position: 'before' | 'after' | 'inside'
folderId: string | null
}
interface UseDragDropOptions {
disabled?: boolean
}
type SiblingItem = {
type: 'folder' | 'workflow'
id: string
sortOrder: number
createdAt: Date
}
/** Stable no-op drop-zone handlers returned when drag-and-drop is disabled. */
const NOOP_DRAG_HANDLERS = {
onDragOver: (e: React.DragEvent<HTMLElement>) => e.preventDefault(),
onDrop: (e: React.DragEvent<HTMLElement>) => e.preventDefault(),
onDragLeave: () => {},
}
const createNoopDragHandlers = () => NOOP_DRAG_HANDLERS
/** Root folder vs root workflow scope: API/cache may use null or undefined for "no parent". */
function isSameFolderScope(
parentOrFolderId: string | null | undefined,
scope: string | null
): boolean {
return (parentOrFolderId ?? null) === (scope ?? null)
}
export function useDragDrop(options: UseDragDropOptions = {}) {
const { disabled = false } = options
const [dropIndicator, setDropIndicator] = useState<DropIndicator | null>(null)
/**
* Mirrors `dropIndicator` synchronously. `drop` can fire before React commits the last
* `dragOver` state update, so `handleDrop` must read this ref instead of state.
*/
const dropIndicatorRef = useRef<DropIndicator | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [hoverFolderId, setHoverFolderId] = useState<string | null>(null)
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const scrollAnimationRef = useRef<number | null>(null)
const hoverExpandTimerRef = useRef<number | null>(null)
const lastDragYRef = useRef<number>(0)
const draggedSourceFolderRef = useRef<string | null>(null)
const siblingsCacheRef = useRef<Map<string, SiblingItem[]> | null>(null)
const isDraggingRef = useRef(false)
const params = useParams()
const workspaceId = params.workspaceId as string | undefined
const reorderWorkflowsMutation = useReorderWorkflows()
const reorderFoldersMutation = useReorderFolders()
const setExpanded = useFolderStore((s) => s.setExpanded)
const expandedFolders = useFolderStore((s) => s.expandedFolders)
const handleAutoScroll = useCallback(() => {
if (!scrollContainerRef.current) {
scrollAnimationRef.current = null
return
}
const container = scrollContainerRef.current
const rect = container.getBoundingClientRect()
const mouseY = lastDragYRef.current
if (mouseY < rect.top || mouseY > rect.bottom) {
scrollAnimationRef.current = requestAnimationFrame(handleAutoScroll)
return
}
const distanceFromTop = mouseY - rect.top
const distanceFromBottom = rect.bottom - mouseY
let scrollDelta = 0
if (distanceFromTop < SCROLL_THRESHOLD && container.scrollTop > 0) {
const intensity = Math.max(0, Math.min(1, 1 - distanceFromTop / SCROLL_THRESHOLD))
scrollDelta = -SCROLL_SPEED * intensity
} else if (distanceFromBottom < SCROLL_THRESHOLD) {
const maxScroll = container.scrollHeight - container.clientHeight
if (container.scrollTop < maxScroll) {
const intensity = Math.max(0, Math.min(1, 1 - distanceFromBottom / SCROLL_THRESHOLD))
scrollDelta = SCROLL_SPEED * intensity
}
}
if (scrollDelta !== 0) {
container.scrollTop += scrollDelta
}
scrollAnimationRef.current = requestAnimationFrame(handleAutoScroll)
}, [])
useEffect(() => {
if (isDragging) {
scrollAnimationRef.current = requestAnimationFrame(handleAutoScroll)
} else if (scrollAnimationRef.current) {
cancelAnimationFrame(scrollAnimationRef.current)
scrollAnimationRef.current = null
}
return () => {
if (scrollAnimationRef.current) {
cancelAnimationFrame(scrollAnimationRef.current)
scrollAnimationRef.current = null
}
}
}, [isDragging, handleAutoScroll])
useEffect(() => {
if (hoverExpandTimerRef.current) {
clearTimeout(hoverExpandTimerRef.current)
hoverExpandTimerRef.current = null
}
if (!isDragging || !hoverFolderId) return
if (expandedFolders.has(hoverFolderId)) return
hoverExpandTimerRef.current = window.setTimeout(() => {
setExpanded(hoverFolderId, true)
}, HOVER_EXPAND_DELAY)
return () => {
if (hoverExpandTimerRef.current) {
clearTimeout(hoverExpandTimerRef.current)
hoverExpandTimerRef.current = null
}
}
}, [hoverFolderId, isDragging, expandedFolders, setExpanded])
useEffect(() => {
siblingsCacheRef.current?.clear()
}, [workspaceId])
const calculateDropPosition = useCallback(
(e: React.DragEvent, element: HTMLElement): 'before' | 'after' => {
const rect = element.getBoundingClientRect()
const midY = rect.top + rect.height / 2
return e.clientY < midY ? 'before' : 'after'
},
[]
)
const calculateFolderDropPosition = useCallback(
(e: React.DragEvent, element: HTMLElement): 'before' | 'inside' | 'after' => {
const rect = element.getBoundingClientRect()
const relativeY = e.clientY - rect.top
const height = rect.height
if (relativeY < height * 0.25) return 'before'
if (relativeY > height * 0.75) return 'after'
return 'inside'
},
[]
)
const compareSiblingItems = (a: SiblingItem, b: SiblingItem): number => {
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
const timeA = a.createdAt.getTime()
const timeB = b.createdAt.getTime()
if (timeA !== timeB) return timeA - timeB
return a.id.localeCompare(b.id)
}
const getDestinationFolderId = useCallback((indicator: DropIndicator): string | null => {
return indicator.position === 'inside'
? indicator.targetId === 'root'
? null
: indicator.targetId
: indicator.folderId
}, [])
/**
* Insert index into the list of siblings **excluding** moving items. Must use the full
* `siblingItems` list for lookup: when the drop line targets the dragged row,
* `indicator.targetId` is not present in `remaining`, so indexing `remaining` alone
* returns -1 and corrupts the splice.
*/
const getInsertIndexInRemaining = useCallback(
(siblingItems: SiblingItem[], movingIds: Set<string>, indicator: DropIndicator): number => {
if (indicator.position === 'inside') {
return siblingItems.filter((s) => !movingIds.has(s.id)).length
}
const targetIdx = siblingItems.findIndex((s) => s.id === indicator.targetId)
if (targetIdx === -1) {
return siblingItems.filter((s) => !movingIds.has(s.id)).length
}
if (indicator.position === 'before') {
return siblingItems.slice(0, targetIdx).filter((s) => !movingIds.has(s.id)).length
}
return siblingItems.slice(0, targetIdx + 1).filter((s) => !movingIds.has(s.id)).length
},
[]
)
const buildAndSubmitUpdates = useCallback(
async (newOrder: SiblingItem[], destinationFolderId: string | null) => {
const indexed = newOrder.map((item, i) => ({ ...item, sortOrder: i }))
const folderUpdates = indexed
.filter((item) => item.type === 'folder')
.map((item) => ({ id: item.id, sortOrder: item.sortOrder, parentId: destinationFolderId }))
const workflowUpdates = indexed
.filter((item) => item.type === 'workflow')
.map((item) => ({ id: item.id, sortOrder: item.sortOrder, folderId: destinationFolderId }))
await Promise.all(
[
folderUpdates.length > 0 &&
reorderFoldersMutation.mutateAsync({
workspaceId: workspaceId!,
updates: folderUpdates,
}),
workflowUpdates.length > 0 &&
reorderWorkflowsMutation.mutateAsync({
workspaceId: workspaceId!,
updates: workflowUpdates,
}),
].filter(Boolean)
)
},
[workspaceId, reorderFoldersMutation, reorderWorkflowsMutation]
)
const isLeavingElement = useCallback((e: React.DragEvent<HTMLElement>): boolean => {
const relatedTarget = e.relatedTarget as HTMLElement | null
const currentTarget = e.currentTarget as HTMLElement
return !relatedTarget || !currentTarget.contains(relatedTarget)
}, [])
const initDragOver = useCallback(
(e: React.DragEvent<HTMLElement>, stopPropagation = true): boolean => {
e.preventDefault()
if (stopPropagation) e.stopPropagation()
lastDragYRef.current = e.clientY
if (!isDragging) {
isDraggingRef.current = true
setIsDragging(true)
} else if (scrollAnimationRef.current === null) {
scrollAnimationRef.current = requestAnimationFrame(handleAutoScroll)
}
return true
},
[isDragging, handleAutoScroll]
)
const getSiblingItems = useCallback(
(folderId: string | null): SiblingItem[] => {
const cacheKey = folderId ?? 'root'
if (!isDraggingRef.current) {
const cached = siblingsCacheRef.current?.get(cacheKey)
if (cached) return cached
}
const currentFolders = workspaceId ? getFolderMap(workspaceId) : {}
const currentWorkflows = workspaceId ? getWorkflows(workspaceId) : []
const siblings = [
...Object.values(currentFolders)
.filter((f) => isSameFolderScope(f.parentId, folderId))
.map((f) => ({
type: 'folder' as const,
id: f.id,
sortOrder: f.sortOrder,
createdAt: f.createdAt,
})),
...currentWorkflows
.filter((w) => isSameFolderScope(w.folderId, folderId))
.map((w) => ({
type: 'workflow' as const,
id: w.id,
sortOrder: w.sortOrder,
createdAt: w.createdAt,
})),
].sort(compareSiblingItems)
if (!isDraggingRef.current) {
const cache = (siblingsCacheRef.current ??= new Map())
cache.set(cacheKey, siblings)
}
return siblings
},
[workspaceId]
)
const setNormalizedDropIndicator = useCallback(
(indicator: DropIndicator | null) => {
if (indicator === null) {
dropIndicatorRef.current = null
setDropIndicator(null)
return
}
let next: DropIndicator = indicator
if (indicator.position === 'after' && indicator.targetId !== 'root') {
const siblings = getSiblingItems(indicator.folderId)
const currentIdx = siblings.findIndex((s) => s.id === indicator.targetId)
if (currentIdx !== -1) {
const nextSibling = siblings[currentIdx + 1]
if (nextSibling) {
next = {
targetId: nextSibling.id,
position: 'before',
folderId: indicator.folderId,
}
}
}
}
setDropIndicator((prev) => {
if (
prev?.targetId === next.targetId &&
prev?.position === next.position &&
prev?.folderId === next.folderId
) {
dropIndicatorRef.current = prev
return prev
}
dropIndicatorRef.current = next
return next
})
},
[getSiblingItems]
)
const canMoveFolderTo = useCallback(
(folderId: string, destinationFolderId: string | null): boolean => {
if (folderId === destinationFolderId) return false
if (!destinationFolderId) return true
if (!workspaceId) return false
const targetPath = getFolderPath(getFolderMap(workspaceId), destinationFolderId)
return !targetPath.some((f) => f.id === folderId)
},
[workspaceId]
)
const collectMovingItems = useCallback(
(
workflowIds: string[],
folderIds: string[],
destinationFolderId: string | null
): { fromDestination: SiblingItem[]; fromOther: SiblingItem[] } => {
const folders = workspaceId ? getFolderMap(workspaceId) : {}
const workflows = workspaceId ? getWorkflows(workspaceId) : []
const fromDestination: SiblingItem[] = []
const fromOther: SiblingItem[] = []
for (const id of workflowIds) {
const workflow = workflows.find((w) => w.id === id)
if (!workflow) continue
const item: SiblingItem = {
type: 'workflow',
id,
sortOrder: workflow.sortOrder,
createdAt: workflow.createdAt,
}
if (isSameFolderScope(workflow.folderId, destinationFolderId)) {
fromDestination.push(item)
} else {
fromOther.push(item)
}
}
for (const id of folderIds) {
const folder = folders[id]
if (!folder) continue
const item: SiblingItem = {
type: 'folder',
id,
sortOrder: folder.sortOrder,
createdAt: folder.createdAt,
}
if (isSameFolderScope(folder.parentId, destinationFolderId)) {
fromDestination.push(item)
} else {
fromOther.push(item)
}
}
fromDestination.sort(compareSiblingItems)
fromOther.sort(compareSiblingItems)
return { fromDestination, fromOther }
},
[workspaceId]
)
const handleSelectionDrop = useCallback(
async (selection: { workflowIds: string[]; folderIds: string[] }, indicator: DropIndicator) => {
if (!workspaceId) return
const { workflowIds, folderIds } = selection
if (workflowIds.length === 0 && folderIds.length === 0) return
try {
const destinationFolderId = getDestinationFolderId(indicator)
const validFolderIds = folderIds.filter((id) => canMoveFolderTo(id, destinationFolderId))
if (workflowIds.length === 0 && validFolderIds.length === 0) {
return
}
const siblingItems = getSiblingItems(destinationFolderId)
const movingIds = new Set([...workflowIds, ...validFolderIds])
const remaining = siblingItems.filter((item) => !movingIds.has(item.id))
const { fromDestination, fromOther } = collectMovingItems(
workflowIds,
validFolderIds,
destinationFolderId
)
const insertAt = getInsertIndexInRemaining(siblingItems, movingIds, indicator)
const newOrder = [
...remaining.slice(0, insertAt),
...fromDestination,
...fromOther,
...remaining.slice(insertAt),
]
await buildAndSubmitUpdates(newOrder, destinationFolderId)
const { clearSelection, clearFolderSelection } = useFolderStore.getState()
clearSelection()
clearFolderSelection()
} catch (error) {
logger.error('Failed to drop selection:', error)
}
},
[
workspaceId,
getDestinationFolderId,
canMoveFolderTo,
getSiblingItems,
collectMovingItems,
getInsertIndexInRemaining,
buildAndSubmitUpdates,
]
)
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
const indicator = dropIndicatorRef.current
dropIndicatorRef.current = null
setDropIndicator(null)
isDraggingRef.current = false
setIsDragging(false)
siblingsCacheRef.current?.clear()
if (!indicator) return
try {
const selectionData = e.dataTransfer.getData('sidebar-selection')
if (!selectionData) return
const selection = JSON.parse(selectionData) as {
workflowIds: string[]
folderIds: string[]
}
await handleSelectionDrop(selection, indicator)
} catch (error) {
logger.error('Failed to handle drop:', error)
}
},
[handleSelectionDrop]
)
const createWorkflowDragHandlers = useCallback(
(workflowId: string, folderId: string | null) => ({
onDragOver: (e: React.DragEvent<HTMLElement>) => {
if (!initDragOver(e)) return
const isSameFolder = draggedSourceFolderRef.current === folderId
if (isSameFolder) {
const position = calculateDropPosition(e, e.currentTarget)
setNormalizedDropIndicator({ targetId: workflowId, position, folderId })
} else {
setNormalizedDropIndicator({
targetId: folderId || 'root',
position: 'inside',
folderId: null,
})
}
},
onDragLeave: () => {},
onDrop: handleDrop,
}),
[initDragOver, calculateDropPosition, setNormalizedDropIndicator, handleDrop]
)
const createFolderDragHandlers = useCallback(
(folderId: string, parentFolderId: string | null) => ({
onDragOver: (e: React.DragEvent<HTMLElement>) => {
if (!initDragOver(e)) return
const isSameParent = draggedSourceFolderRef.current === parentFolderId
if (isSameParent) {
const position = calculateFolderDropPosition(e, e.currentTarget)
setNormalizedDropIndicator({ targetId: folderId, position, folderId: parentFolderId })
if (position === 'inside') {
setHoverFolderId(folderId)
} else {
setHoverFolderId(null)
}
} else {
setNormalizedDropIndicator({
targetId: folderId,
position: 'inside',
folderId: parentFolderId,
})
setHoverFolderId(folderId)
}
},
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
if (isLeavingElement(e)) setHoverFolderId(null)
},
onDrop: handleDrop,
}),
[
initDragOver,
calculateFolderDropPosition,
setNormalizedDropIndicator,
isLeavingElement,
handleDrop,
]
)
const createEmptyFolderDropZone = useCallback(
(folderId: string) => ({
onDragOver: (e: React.DragEvent<HTMLElement>) => {
if (!initDragOver(e)) return
setNormalizedDropIndicator({ targetId: folderId, position: 'inside', folderId })
},
onDragLeave: () => {},
onDrop: handleDrop,
}),
[initDragOver, setNormalizedDropIndicator, handleDrop]
)
const createFolderContentDropZone = useCallback(
(folderId: string) => ({
onDragOver: (e: React.DragEvent<HTMLElement>) => {
if (!initDragOver(e)) return
if (e.target === e.currentTarget && draggedSourceFolderRef.current !== folderId) {
setNormalizedDropIndicator({ targetId: folderId, position: 'inside', folderId: null })
}
},
onDragLeave: () => {},
onDrop: handleDrop,
}),
[initDragOver, setNormalizedDropIndicator, handleDrop]
)
const createRootDropZone = useCallback(
() => ({
onDragOver: (e: React.DragEvent<HTMLElement>) => {
if (!initDragOver(e, false)) return
if (e.target === e.currentTarget) {
setNormalizedDropIndicator({ targetId: 'root', position: 'inside', folderId: null })
}
},
onDragLeave: (e: React.DragEvent<HTMLElement>) => {
if (isLeavingElement(e)) setNormalizedDropIndicator(null)
},
onDrop: handleDrop,
}),
[initDragOver, setNormalizedDropIndicator, isLeavingElement, handleDrop]
)
const createEdgeDropZone = useCallback(
(itemId: string | null, position: 'before' | 'after') => ({
onDragOver: (e: React.DragEvent<HTMLElement>) => {
if (!initDragOver(e)) return
if (itemId) {
const edge: DropIndicator = { targetId: itemId, position, folderId: null }
dropIndicatorRef.current = edge
setDropIndicator(edge)
} else {
setNormalizedDropIndicator({ targetId: 'root', position: 'inside', folderId: null })
}
},
onDragLeave: () => {},
onDrop: handleDrop,
}),
[initDragOver, setNormalizedDropIndicator, handleDrop]
)
const handleDragStart = useCallback((sourceFolderId: string | null) => {
draggedSourceFolderRef.current = sourceFolderId
siblingsCacheRef.current?.clear()
isDraggingRef.current = true
setIsDragging(true)
}, [])
const handleDragEnd = useCallback(() => {
isDraggingRef.current = false
setIsDragging(false)
dropIndicatorRef.current = null
setDropIndicator(null)
draggedSourceFolderRef.current = null
setHoverFolderId(null)
siblingsCacheRef.current?.clear()
}, [])
useEffect(() => {
if (!isDragging) return
const container = scrollContainerRef.current
if (!container) return
const onLeave = (e: DragEvent) => {
const related = e.relatedTarget as Node | null
if (related && container.contains(related)) return
if (scrollAnimationRef.current !== null) {
cancelAnimationFrame(scrollAnimationRef.current)
scrollAnimationRef.current = null
}
dropIndicatorRef.current = null
setDropIndicator(null)
setHoverFolderId(null)
}
const onWindowDrop = (e: DragEvent) => {
const target = e.target as Node | null
if (target && container.contains(target)) return
handleDragEnd()
}
container.addEventListener('dragleave', onLeave)
window.addEventListener('drop', onWindowDrop, true)
return () => {
container.removeEventListener('dragleave', onLeave)
window.removeEventListener('drop', onWindowDrop, true)
}
}, [isDragging, handleDragEnd])
const setScrollContainer = useCallback((element: HTMLDivElement | null) => {
scrollContainerRef.current = element
}, [])
if (disabled) {
return {
dropIndicator: null,
isDragging: false,
disabled: true,
setScrollContainer,
createWorkflowDragHandlers: createNoopDragHandlers,
createFolderDragHandlers: createNoopDragHandlers,
createEmptyFolderDropZone: createNoopDragHandlers,
createFolderContentDropZone: createNoopDragHandlers,
createRootDropZone: createNoopDragHandlers,
createEdgeDropZone: createNoopDragHandlers,
handleDragStart: noop,
handleDragEnd: noop,
}
}
return {
dropIndicator,
isDragging,
disabled: false,
setScrollContainer,
createWorkflowDragHandlers,
createFolderDragHandlers,
createEmptyFolderDropZone,
createFolderContentDropZone,
createRootDropZone,
createEdgeDropZone,
handleDragStart,
handleDragEnd,
}
}
@@ -0,0 +1,101 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
const logger = createLogger('useFlyoutInlineRename')
interface RenameTarget {
id: string
name: string
}
interface UseFlyoutInlineRenameProps {
itemType: string
onSave: (id: string, name: string) => Promise<void>
}
export function useFlyoutInlineRename({ itemType, onSave }: UseFlyoutInlineRenameProps) {
const [editingTarget, setEditingTarget] = useState<RenameTarget | null>(null)
const [value, setValue] = useState('')
const [isSaving, setIsSaving] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const cancelRequestedRef = useRef(false)
const isSavingRef = useRef(false)
useEffect(() => {
if (editingTarget && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [editingTarget])
const startRename = useCallback((target: RenameTarget) => {
cancelRequestedRef.current = false
setEditingTarget(target)
setValue(target.name)
}, [])
const cancelRename = useCallback(() => {
cancelRequestedRef.current = true
setEditingTarget(null)
}, [])
const saveRename = useCallback(async () => {
if (cancelRequestedRef.current) {
cancelRequestedRef.current = false
return
}
if (!editingTarget || isSavingRef.current) {
return
}
const trimmedValue = value.trim()
if (!trimmedValue || trimmedValue === editingTarget.name) {
setEditingTarget(null)
return
}
isSavingRef.current = true
setIsSaving(true)
try {
await onSave(editingTarget.id, trimmedValue)
setEditingTarget(null)
} catch (error) {
logger.error(`Failed to rename ${itemType}:`, {
error,
itemId: editingTarget.id,
oldName: editingTarget.name,
newName: trimmedValue,
})
setValue(editingTarget.name)
} finally {
isSavingRef.current = false
setIsSaving(false)
}
}, [editingTarget, itemType, onSave, value])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
void saveRename()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelRename()
}
},
[cancelRename, saveRename]
)
return {
editingId: editingTarget?.id ?? null,
value,
setValue,
isSaving,
inputRef,
startRename,
cancelRename,
saveRename,
handleKeyDown,
}
}
@@ -0,0 +1,55 @@
import { useCallback } from 'react'
import { useFolderStore } from '@/stores/folders/store'
const toggleFolderExpanded = useFolderStore.getState().toggleExpanded
const setFolderExpanded = useFolderStore.getState().setExpanded
interface UseFolderExpandProps {
folderId: string
}
/**
* Custom hook to handle folder expand/collapse functionality.
* Provides handlers for mouse clicks and keyboard navigation.
*
* @param props - Configuration object containing folderId
* @returns Expansion state and event handlers
*/
export function useFolderExpand({ folderId }: UseFolderExpandProps) {
const expandedFolders = useFolderStore((state) => state.expandedFolders)
const isExpanded = expandedFolders.has(folderId)
/**
* Toggle folder expansion state
*/
const handleToggleExpanded = useCallback(() => {
toggleFolderExpanded(folderId)
}, [folderId])
/**
* Expand the folder (useful when creating items inside)
*/
const expandFolder = useCallback(() => {
setFolderExpanded(folderId, true)
}, [folderId])
/**
* Handle keyboard navigation (Enter/Space)
*/
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleToggleExpanded()
}
},
[handleToggleExpanded]
)
return {
isExpanded,
handleToggleExpanded,
expandFolder,
handleKeyDown,
}
}
@@ -0,0 +1,51 @@
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { generateFolderName } from '@/lib/workspaces/naming'
import { useCreateFolder } from '@/hooks/queries/folders'
const logger = createLogger('useFolderOperations')
interface UseFolderOperationsProps {
workspaceId: string
}
/**
* Custom hook to manage folder operations including creating folders.
* Handles folder name generation and state management.
* Uses React Query mutation's isPending state for immediate loading feedback.
*
* @param props - Configuration object containing workspaceId
* @returns Folder operations state and handlers
*/
export function useFolderOperations({ workspaceId }: UseFolderOperationsProps) {
const createFolderMutation = useCreateFolder()
const handleCreateFolder = useCallback(async (): Promise<string | null> => {
if (!workspaceId) {
return null
}
try {
const folderName = await generateFolderName(workspaceId)
const folder = await createFolderMutation.mutateAsync({
name: folderName,
workspaceId,
id: generateId(),
})
logger.info(`Created folder: ${folderName}`)
return folder.id
} catch (error) {
logger.error('Failed to create folder:', { error })
return null
}
}, [createFolderMutation, workspaceId])
return {
// State
isCreatingFolder: createFolderMutation.isPending,
// Operations
handleCreateFolder,
}
}
@@ -0,0 +1,162 @@
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useFolderStore } from '@/stores/folders/store'
interface UseFolderSelectionProps {
/**
* Flat array of all folder IDs in display order
*/
folderIds: string[]
/**
* Map from folder ID to ALL descendant workflow IDs (recursively, not just direct children)
*/
folderDescendantWorkflowIds: Record<string, string[]>
/**
* Map from folder ID to all its ancestor folder IDs
*/
folderAncestorIds: Record<string, string[]>
/**
* Map from folder ID to all its descendant folder IDs
*/
folderDescendantIds: Record<string, string[]>
}
/**
* Hook for managing folder selection with support for single, range, and toggle selection.
* Handles shift-click for range selection and cmd/ctrl-click for toggle selection.
* Uses the last selected folder ID (tracked in store) as the anchor point for range selections.
* Enforces three constraints:
* - Selecting a folder deselects any workflows in its entire subtree
* - Cmd+click on a folder deselects its ancestors and descendants (clicked folder wins)
* - Range selection deduplicates ancestor-descendant pairs (keeps the ancestor)
*
* @param props - Hook props
* @returns Selection handlers
*/
export function useFolderSelection({
folderIds,
folderDescendantWorkflowIds,
folderAncestorIds,
folderDescendantIds,
}: UseFolderSelectionProps) {
const {
selectedFolders,
lastSelectedFolderId,
selectFolderOnly,
selectFolderRange,
toggleFolderSelection,
} = useFolderStore(
useShallow((s) => ({
selectedFolders: s.selectedFolders,
lastSelectedFolderId: s.lastSelectedFolderId,
selectFolderOnly: s.selectFolderOnly,
selectFolderRange: s.selectFolderRange,
toggleFolderSelection: s.toggleFolderSelection,
}))
)
/**
* Deselect any workflows whose folder (or any ancestor folder) is currently selected.
*/
const deselectConflictingWorkflows = useCallback(() => {
const { selectedWorkflows: workflows, selectedFolders: folders } = useFolderStore.getState()
if (workflows.size === 0) return
for (const folderId of folders) {
const wfIds = folderDescendantWorkflowIds[folderId]
if (!wfIds) continue
for (const wfId of wfIds) {
if (workflows.has(wfId)) {
useFolderStore.getState().deselectWorkflow(wfId)
}
}
}
}, [folderDescendantWorkflowIds])
/**
* For Cmd+click: the clicked folder wins. Deselect any selected folders that are
* ancestors or descendants of the clicked folder.
*/
const deselectRelatedFolders = useCallback(
(clickedFolderId: string) => {
const { selectedFolders: folders } = useFolderStore.getState()
if (!folders.has(clickedFolderId) || folders.size <= 1) return
const ancestors = folderAncestorIds[clickedFolderId] || []
const descendants = folderDescendantIds[clickedFolderId] || []
for (const id of ancestors) {
if (folders.has(id)) {
useFolderStore.getState().deselectFolder(id)
}
}
for (const id of descendants) {
if (folders.has(id)) {
useFolderStore.getState().deselectFolder(id)
}
}
},
[folderAncestorIds, folderDescendantIds]
)
/**
* For range selection: if both a folder and a nested subfolder end up in the range,
* keep the ancestor and deselect the descendant (ancestor already covers it).
*/
const deduplicateSelectedFolders = useCallback(() => {
const { selectedFolders: folders } = useFolderStore.getState()
if (folders.size <= 1) return
for (const folderId of folders) {
const ancestors = folderAncestorIds[folderId] || []
for (const ancestorId of ancestors) {
if (folders.has(ancestorId)) {
useFolderStore.getState().deselectFolder(folderId)
break
}
}
}
}, [folderAncestorIds])
/**
* Handle folder click with support for shift-click range selection and cmd/ctrl-click toggle
*
* @param folderId - ID of clicked folder
* @param shiftKey - Whether shift key was pressed
* @param metaKey - Whether cmd (Mac) or ctrl (Windows) key was pressed
*/
const handleFolderClick = useCallback(
(folderId: string, shiftKey: boolean, metaKey: boolean) => {
if (metaKey) {
toggleFolderSelection(folderId)
deselectRelatedFolders(folderId)
deselectConflictingWorkflows()
} else if (shiftKey && lastSelectedFolderId && lastSelectedFolderId !== folderId) {
selectFolderRange(folderIds, lastSelectedFolderId, folderId)
deduplicateSelectedFolders()
deselectConflictingWorkflows()
} else if (shiftKey) {
selectFolderOnly(folderId)
deselectConflictingWorkflows()
} else {
selectFolderOnly(folderId)
deselectConflictingWorkflows()
}
},
[
folderIds,
lastSelectedFolderId,
selectFolderOnly,
selectFolderRange,
toggleFolderSelection,
deselectRelatedFolders,
deduplicateSelectedFolders,
deselectConflictingWorkflows,
]
)
return {
selectedFolders,
handleFolderClick,
}
}
@@ -0,0 +1,111 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
const CLOSE_DELAY_MS = 150
const preventAutoFocus = (e: Event) => e.preventDefault()
/**
* Manages hover-triggered dropdown menu state.
* Provides handlers for trigger and content mouse events with a delay
* to prevent flickering when moving between trigger and content.
*/
export function useHoverMenu() {
const [isOpen, setIsOpen] = useState(false)
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isLockedRef = useRef(false)
const hoverRegionCountRef = useRef(0)
const cancelClose = useCallback(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current)
closeTimerRef.current = null
}
}, [])
useEffect(() => {
return () => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current)
}
}
}, [])
const scheduleClose = useCallback(() => {
if (isLockedRef.current) {
return
}
cancelClose()
closeTimerRef.current = setTimeout(() => {
if (!isLockedRef.current && hoverRegionCountRef.current === 0) {
setIsOpen(false)
}
}, CLOSE_DELAY_MS)
}, [cancelClose])
const open = useCallback(() => {
cancelClose()
setIsOpen(true)
}, [cancelClose])
const close = useCallback(() => {
if (isLockedRef.current) {
return
}
cancelClose()
setIsOpen(false)
}, [cancelClose])
const setLocked = useCallback(
(locked: boolean) => {
isLockedRef.current = locked
cancelClose()
if (locked) {
setIsOpen(true)
} else if (hoverRegionCountRef.current === 0) {
setIsOpen(false)
}
},
[cancelClose]
)
const handleTriggerMouseEnter = useCallback(() => {
hoverRegionCountRef.current += 1
open()
}, [open])
const handleTriggerMouseLeave = useCallback(() => {
hoverRegionCountRef.current = Math.max(0, hoverRegionCountRef.current - 1)
scheduleClose()
}, [scheduleClose])
const handleContentMouseEnter = useCallback(() => {
hoverRegionCountRef.current += 1
cancelClose()
}, [cancelClose])
const handleContentMouseLeave = useCallback(() => {
hoverRegionCountRef.current = Math.max(0, hoverRegionCountRef.current - 1)
scheduleClose()
}, [scheduleClose])
const triggerProps = useMemo(
() =>
({
onMouseEnter: handleTriggerMouseEnter,
onMouseLeave: handleTriggerMouseLeave,
}) as const,
[handleTriggerMouseEnter, handleTriggerMouseLeave]
)
const contentProps = useMemo(
() =>
({
onMouseEnter: handleContentMouseEnter,
onMouseLeave: handleContentMouseLeave,
onCloseAutoFocus: preventAutoFocus,
}) as const,
[handleContentMouseEnter, handleContentMouseLeave]
)
return { isOpen, open, close, setLocked, triggerProps, contentProps }
}
@@ -0,0 +1,58 @@
import { useCallback, useRef, useState } from 'react'
interface UseItemDragProps {
onDragStart: (e: React.DragEvent) => void
}
let invisibleDragImage: HTMLImageElement | null = null
if (typeof Image !== 'undefined') {
invisibleDragImage = new Image()
invisibleDragImage.src =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
}
/**
* Custom hook to handle drag operations for workflow and folder items.
* Manages drag state and provides unified drag event handlers.
*
* @param props - Configuration object containing drag start callback
* @returns Drag state and event handlers
*/
export function useItemDrag({ onDragStart }: UseItemDragProps) {
const [isDragging, setIsDragging] = useState(false)
const shouldPreventClickRef = useRef(false)
/**
* Handle drag start - sets dragging state, hides default drag ghost, and prevents click
*/
const handleDragStart = useCallback(
(e: React.DragEvent) => {
shouldPreventClickRef.current = true
setIsDragging(true)
if (invisibleDragImage) {
e.dataTransfer.setDragImage(invisibleDragImage, 0, 0)
}
onDragStart(e)
},
[onDragStart]
)
/**
* Handle drag end - resets dragging state and re-enables clicks
*/
const handleDragEnd = useCallback(() => {
setIsDragging(false)
requestAnimationFrame(() => {
shouldPreventClickRef.current = false
})
}, [])
return {
isDragging,
shouldPreventClickRef,
handleDragStart,
handleDragEnd,
}
}
@@ -0,0 +1,138 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
const logger = createLogger('useItemRename')
interface UseItemRenameProps {
/**
* Current item name
*/
initialName: string
/**
* Callback to save the new name
*/
onSave: (newName: string) => Promise<void>
/**
* Item type for logging
*/
itemType: 'workflow' | 'folder' | 'workspace'
/**
* Item ID for logging
*/
itemId: string
}
/**
* Hook for managing inline rename functionality for workflows, folders, and workspaces.
*
* Handles:
* - Edit state management
* - Input value tracking
* - Save/cancel operations
* - Keyboard shortcuts (Enter to save, Escape to cancel)
* - Auto-focus and selection
* - Loading state during save
*
* @param props - Hook configuration
* @returns Rename state and handlers
*/
export function useItemRename({ initialName, onSave, itemType, itemId }: UseItemRenameProps) {
const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState(initialName)
const [isRenaming, setIsRenaming] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
/**
* Focus and select input when entering edit mode
*/
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [isEditing])
/**
* Start editing mode
*/
const handleStartEdit = useCallback(() => {
setIsEditing(true)
setEditValue(initialName)
}, [initialName])
/**
* Save the new name
*/
const handleSaveEdit = useCallback(async () => {
const trimmedValue = editValue.trim()
// If empty or unchanged, just cancel
if (!trimmedValue || trimmedValue === initialName) {
setIsEditing(false)
setEditValue(initialName)
return
}
setIsRenaming(true)
try {
await onSave(trimmedValue)
logger.info(`Successfully renamed ${itemType} from "${initialName}" to "${trimmedValue}"`)
setIsEditing(false)
} catch (error) {
logger.error(`Failed to rename ${itemType}:`, {
error,
itemId,
oldName: initialName,
newName: trimmedValue,
})
// Reset to original name on error
setEditValue(initialName)
} finally {
setIsRenaming(false)
}
}, [editValue, initialName, onSave, itemType, itemId])
/**
* Cancel editing and restore original name
*/
const handleCancelEdit = useCallback(() => {
setIsEditing(false)
setEditValue(initialName)
}, [initialName])
/**
* Handle keyboard shortcuts
*/
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSaveEdit()
} else if (e.key === 'Escape') {
e.preventDefault()
handleCancelEdit()
}
},
[handleSaveEdit, handleCancelEdit]
)
/**
* Handle input blur (unfocus)
*/
const handleInputBlur = useCallback(() => {
handleSaveEdit()
}, [handleSaveEdit])
return {
isEditing,
editValue,
isRenaming,
inputRef,
setEditValue,
handleStartEdit,
handleSaveEdit,
handleCancelEdit,
handleKeyDown,
handleInputBlur,
}
}
@@ -0,0 +1,93 @@
'use client'
import { createContext, type RefObject, useContext, useMemo } from 'react'
import { noop } from '@sim/utils/helpers'
interface SidebarListContextValue {
/** Whether any drag operation is currently in progress */
isAnyDragActive: boolean
/** Whether item dragging is disabled (e.g. viewer permissions) */
dragDisabled: boolean
/**
* Live id of the workflow open in the URL, held in a ref so rows read it at
* click/delete time without subscribing to `useParams` — which would re-render
* every row on each tab navigation and defeat their memoization.
*/
activeWorkflowIdRef: RefObject<string | undefined>
/** Selects a workflow on click (single or shift-range selection) */
onWorkflowClick: (workflowId: string, shiftKey: boolean) => void
/** Selects a folder on modifier-click (shift-range or cmd/ctrl-toggle selection) */
onFolderClick: (folderId: string, shiftKey: boolean, metaKey: boolean) => void
/** Notifies the list that an item drag started from the given parent folder */
onItemDragStart: (parentFolderId: string | null) => void
/** Notifies the list that an item drag ended */
onItemDragEnd: () => void
}
const noopActiveWorkflowIdRef: RefObject<string | undefined> = { current: undefined }
/**
* Context for sharing list-item interaction handlers and drag state across
* sidebar workflow-list components. Eliminates prop drilling of selection
* and drag callbacks into WorkflowItem/FolderItem.
*/
export const SidebarListContext = createContext<SidebarListContextValue>({
isAnyDragActive: false,
dragDisabled: false,
activeWorkflowIdRef: noopActiveWorkflowIdRef,
onWorkflowClick: noop,
onFolderClick: noop,
onItemDragStart: noop,
onItemDragEnd: noop,
})
/**
* Hook to access the sidebar list context.
* Use this in WorkflowItem, FolderItem, etc. for selection/drag callbacks and drag state.
*
* @returns The current sidebar list context value
*/
export function useSidebarListContext(): SidebarListContextValue {
return useContext(SidebarListContext)
}
/**
* Hook to create a memoized sidebar list context value.
*
* @param value - The handlers and drag state to expose to list items
* @returns Memoized context value to provide to SidebarListContext.Provider
*/
export function useSidebarListContextValue(
value: SidebarListContextValue
): SidebarListContextValue {
const {
isAnyDragActive,
dragDisabled,
activeWorkflowIdRef,
onWorkflowClick,
onFolderClick,
onItemDragStart,
onItemDragEnd,
} = value
return useMemo(
() => ({
isAnyDragActive,
dragDisabled,
activeWorkflowIdRef,
onWorkflowClick,
onFolderClick,
onItemDragStart,
onItemDragEnd,
}),
[
isAnyDragActive,
dragDisabled,
activeWorkflowIdRef,
onWorkflowClick,
onFolderClick,
onItemDragStart,
onItemDragEnd,
]
)
}
@@ -0,0 +1,93 @@
import { useCallback, useEffect, useRef } from 'react'
import { SIDEBAR_WIDTH } from '@/stores/constants'
import { useSidebarStore } from '@/stores/sidebar/store'
/**
* Handles sidebar drag-resize with zero React renders during the drag.
*
* Architecture (confirmed industry best-practice for resize handles):
*
* pointerdown → capture the pointer on the handle (so move/up keep arriving
* even when the cursor leaves the window or crosses an iframe),
* add `is-resizing` class directly to the DOM (no React
* round-trip, so the CSS width transition is suppressed from the
* very first frame)
* pointermove → write to --sidebar-width inside a requestAnimationFrame
* callback (aligns work with the browser paint cycle)
* pointerup → cancel any pending RAF, tear down, persist final width to
* Zustand once (one React re-render to save to localStorage)
*
* The drag is torn down by `pointerup`, `pointercancel`, or window `blur`, so an
* interrupted gesture (release outside the window, alt-tab, context menu, the OS
* stealing focus) can never leave the `is-resizing` / `sidebar-resizing` classes
* stuck — which would otherwise freeze the sidebar at a tiny width with the
* collapse transition permanently disabled. A single-flight guard prevents
* stacking listeners across rapid presses, and an unmount cleanup tears down a
* drag still in flight when the sidebar unmounts (e.g. route change).
*/
export function useSidebarResize() {
const setSidebarWidth = useSidebarStore((s) => s.setSidebarWidth)
const cleanupRef = useRef<(() => void) | null>(null)
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLElement>) => {
if (cleanupRef.current) return
const handle = e.currentTarget
const pointerId = e.pointerId
const sidebar = document.querySelector<HTMLElement>('.sidebar-container')
sidebar?.classList.add('is-resizing')
document.documentElement.classList.add('sidebar-resizing')
document.body.style.cursor = 'ew-resize'
document.body.style.userSelect = 'none'
handle.setPointerCapture?.(pointerId)
let rafId: number | null = null
const onPointerMove = (ev: PointerEvent) => {
if (rafId !== null) cancelAnimationFrame(rafId)
rafId = requestAnimationFrame(() => {
const max = Math.max(SIDEBAR_WIDTH.MIN, window.innerWidth * SIDEBAR_WIDTH.MAX_PERCENTAGE)
const clamped = Math.min(Math.max(ev.clientX, SIDEBAR_WIDTH.MIN), max)
document.documentElement.style.setProperty('--sidebar-width', `${clamped}px`)
rafId = null
})
}
const cleanup = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
sidebar?.classList.remove('is-resizing')
document.documentElement.classList.remove('sidebar-resizing')
document.body.style.cursor = ''
document.body.style.userSelect = ''
if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId)
document.removeEventListener('pointermove', onPointerMove)
document.removeEventListener('pointerup', endDrag)
document.removeEventListener('pointercancel', endDrag)
window.removeEventListener('blur', endDrag)
cleanupRef.current = null
}
function endDrag() {
cleanup()
const raw = document.documentElement.style.getPropertyValue('--sidebar-width')
const finalWidth = Number.parseFloat(raw)
if (!Number.isNaN(finalWidth)) setSidebarWidth(finalWidth)
}
cleanupRef.current = cleanup
document.addEventListener('pointermove', onPointerMove)
document.addEventListener('pointerup', endDrag)
document.addEventListener('pointercancel', endDrag)
window.addEventListener('blur', endDrag)
},
[setSidebarWidth]
)
useEffect(() => () => cleanupRef.current?.(), [])
return { handlePointerDown }
}
@@ -0,0 +1,52 @@
import { useCallback, useMemo } from 'react'
import { generateId } from '@sim/utils/id'
import { useRouter } from 'next/navigation'
import { useCreateWorkflow, useWorkflowMap } from '@/hooks/queries/workflows'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
interface UseWorkflowOperationsProps {
workspaceId: string
}
export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProps) {
const router = useRouter()
const { data: workflows = {}, isLoading: workflowsLoading } = useWorkflowMap(workspaceId)
const createWorkflowMutation = useCreateWorkflow()
const regularWorkflows = useMemo(
() =>
Object.values(workflows)
.filter((workflow) => workflow.workspaceId === workspaceId)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()),
[workflows, workspaceId]
)
const handleCreateWorkflow = useCallback((): Promise<string | null> => {
const { clearDiff } = useWorkflowDiffStore.getState()
clearDiff()
const name = generateCreativeWorkflowName()
const id = generateId()
createWorkflowMutation.mutate({
workspaceId,
name,
id,
})
useWorkflowRegistry.getState().markWorkflowCreating(id)
router.push(`/workspace/${workspaceId}/w/${id}`)
return Promise.resolve(id)
}, [createWorkflowMutation, workspaceId, router])
return {
workflows,
regularWorkflows,
workflowsLoading,
isCreatingWorkflow: createWorkflowMutation.isPending,
handleCreateWorkflow,
}
}
@@ -0,0 +1,100 @@
import { useCallback, useRef } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useFolderStore } from '@/stores/folders/store'
interface UseWorkflowSelectionProps {
/**
* Flat array of all workflow IDs in display order
*/
workflowIds: string[]
/**
* Active workflow ID (from URL) - used as anchor for range selection
*/
activeWorkflowId: string | undefined
/**
* Map from workflow ID to all its ancestor folder IDs (direct parent first, then up)
*/
workflowAncestorFolderIds: Record<string, string[]>
}
/**
* Hook for managing workflow selection with support for single, range, and toggle selection.
* Handles shift-click for range selection and regular click for single selection.
* Uses the active workflow ID as the anchor point for range selections.
* Enforces ancestor constraint: selecting a workflow deselects any ancestor folder.
*
* @param props - Hook props
* @returns Selection handlers
*/
export function useWorkflowSelection({
workflowIds,
activeWorkflowId,
workflowAncestorFolderIds,
}: UseWorkflowSelectionProps) {
const { selectedWorkflows, selectOnly, selectRange, toggleWorkflowSelection } = useFolderStore(
useShallow((s) => ({
selectedWorkflows: s.selectedWorkflows,
selectOnly: s.selectOnly,
selectRange: s.selectRange,
toggleWorkflowSelection: s.toggleWorkflowSelection,
}))
)
/**
* Read the click-time anchor values through refs so `handleWorkflowClick` keeps a stable
* identity across tab navigation. `activeWorkflowId` changes on every route change; without
* refs it would churn the shared sidebar list context on each tab switch and defeat the
* memoization of the WorkflowItem/FolderItem rows that consume it.
*/
const workflowIdsRef = useRef(workflowIds)
workflowIdsRef.current = workflowIds
const activeWorkflowIdRef = useRef(activeWorkflowId)
activeWorkflowIdRef.current = activeWorkflowId
/**
* After a workflow selection change, deselect any folder that is an ancestor of a selected
* workflow to prevent ancestor-descendant co-selection.
*/
const deselectConflictingFolders = useCallback(() => {
const { selectedWorkflows: workflows, selectedFolders: folders } = useFolderStore.getState()
if (folders.size === 0) return
for (const wfId of workflows) {
const ancestorIds = workflowAncestorFolderIds[wfId]
if (!ancestorIds) continue
for (const folderId of ancestorIds) {
if (folders.has(folderId)) {
useFolderStore.getState().deselectFolder(folderId)
}
}
}
}, [workflowAncestorFolderIds])
/**
* Handle workflow click with support for shift-click range selection.
* cmd/ctrl+click is handled by the browser (opens in new tab) and never reaches this handler.
*
* @param workflowId - ID of clicked workflow
* @param shiftKey - Whether shift key was pressed
*/
const handleWorkflowClick = useCallback(
(workflowId: string, shiftKey: boolean) => {
const activeWorkflowId = activeWorkflowIdRef.current
if (shiftKey && activeWorkflowId && activeWorkflowId !== workflowId) {
selectRange(workflowIdsRef.current, activeWorkflowId, workflowId)
deselectConflictingFolders()
} else if (shiftKey) {
toggleWorkflowSelection(workflowId)
deselectConflictingFolders()
} else {
selectOnly(workflowId)
}
},
[selectOnly, selectRange, toggleWorkflowSelection, deselectConflictingFolders]
)
return {
selectedWorkflows,
handleWorkflowClick,
}
}
@@ -0,0 +1,166 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { uploadViaApiFallback } from '@/lib/uploads/client/api-fallback'
import { DirectUploadError, runUploadStrategy } from '@/lib/uploads/client/direct-upload'
const logger = createLogger('WorkspaceLogoUpload')
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml', 'image/webp']
interface UseWorkspaceLogoUploadProps {
workspaceId?: string
currentLogoUrl?: string | null
onUpload?: (url: string | null) => void
onError?: (error: string) => void
}
/**
* Hook for handling workspace logo upload functionality.
* Manages file validation, preview generation, and server upload.
*/
export function useWorkspaceLogoUpload({
workspaceId,
currentLogoUrl,
onUpload,
onError,
}: UseWorkspaceLogoUploadProps = {}) {
const previewRef = useRef<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const onUploadRef = useRef(onUpload)
const onErrorRef = useRef(onError)
const currentLogoUrlRef = useRef(currentLogoUrl)
const workspaceIdRef = useRef(workspaceId)
const [previewUrl, setPreviewUrl] = useState<string | null>(currentLogoUrl || null)
const [isUploading, setIsUploading] = useState(false)
useEffect(() => {
onUploadRef.current = onUpload
onErrorRef.current = onError
currentLogoUrlRef.current = currentLogoUrl
}, [onUpload, onError, currentLogoUrl])
useEffect(() => {
workspaceIdRef.current = workspaceId
}, [workspaceId])
useEffect(() => {
if (previewRef.current && previewRef.current !== currentLogoUrl) {
URL.revokeObjectURL(previewRef.current)
previewRef.current = null
}
setPreviewUrl(currentLogoUrl || null)
}, [currentLogoUrl])
const validateFile = useCallback((file: File): string | null => {
if (file.size > MAX_FILE_SIZE) {
return `File "${file.name}" is too large. Maximum size is 5MB.`
}
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
return `File "${file.name}" is not a supported image format. Please use PNG, JPEG, SVG, or WebP.`
}
return null
}, [])
const uploadFileToServer = useCallback(async (file: File): Promise<string> => {
const targetWorkspaceId = workspaceIdRef.current
if (!targetWorkspaceId) {
throw new Error('workspaceId is required for workspace logo upload')
}
const presignedEndpoint = `/api/files/presigned?type=workspace-logos&workspaceId=${encodeURIComponent(targetWorkspaceId)}`
try {
const result = await runUploadStrategy({
file,
workspaceId: targetWorkspaceId,
context: 'workspace-logos',
presignedEndpoint,
})
logger.info(`Workspace logo uploaded successfully: ${result.path}`)
return result.path
} catch (error) {
if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') {
const { path } = await uploadViaApiFallback(file, 'workspace-logos', targetWorkspaceId)
logger.info(`Workspace logo uploaded via API fallback: ${path}`)
return path
}
throw error
}
}, [])
const processFile = useCallback(
async (file: File) => {
const validationError = validateFile(file)
if (validationError) {
onErrorRef.current?.(validationError)
return
}
const newPreviewUrl = URL.createObjectURL(file)
if (previewRef.current) URL.revokeObjectURL(previewRef.current)
setPreviewUrl(newPreviewUrl)
previewRef.current = newPreviewUrl
setIsUploading(true)
try {
const serverUrl = await uploadFileToServer(file)
URL.revokeObjectURL(newPreviewUrl)
previewRef.current = null
setPreviewUrl(serverUrl)
onUploadRef.current?.(serverUrl)
} catch (error) {
const errorMessage = getErrorMessage(error, 'Failed to upload workspace logo')
onErrorRef.current?.(errorMessage)
URL.revokeObjectURL(newPreviewUrl)
previewRef.current = null
setPreviewUrl(currentLogoUrlRef.current || null)
} finally {
setIsUploading(false)
}
},
[uploadFileToServer, validateFile]
)
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) processFile(file)
if (event.target) event.target.value = ''
},
[processFile]
)
const setTargetWorkspaceId = useCallback((id: string) => {
workspaceIdRef.current = id
}, [])
const handleRemove = useCallback(() => {
if (previewRef.current) {
URL.revokeObjectURL(previewRef.current)
previewRef.current = null
}
setPreviewUrl(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
onUploadRef.current?.(null)
}, [])
useEffect(() => {
return () => {
if (previewRef.current) {
URL.revokeObjectURL(previewRef.current)
}
}
}, [])
return {
previewUrl,
fileInputRef,
handleFileChange,
handleRemove,
setTargetWorkspaceId,
isUploading,
}
}
@@ -0,0 +1,273 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useRouter } from 'next/navigation'
import { requestJson } from '@/lib/api/client/request'
import { updateUserSettingsContract } from '@/lib/api/contracts'
import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage'
import { useLeaveWorkspace } from '@/hooks/queries/invitations'
import {
useCreateWorkspace,
useDeleteWorkspace,
useUpdateWorkspace,
useWorkspaceCreationPolicy,
useWorkspacesQuery,
type Workspace,
} from '@/hooks/queries/workspace'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('useWorkspaceManagement')
interface UseWorkspaceManagementProps {
workspaceId: string
sessionUserId?: string
}
/**
* Manages workspace operations including fetching, switching, creating, deleting, and leaving workspaces.
* Handles workspace validation, URL synchronization, and recency-based ordering.
*
* @param props.workspaceId - The current workspace ID from the URL
* @param props.sessionUserId - The current user's session ID
* @returns Workspace state and operations
*/
export function useWorkspaceManagement({
workspaceId,
sessionUserId,
}: UseWorkspaceManagementProps) {
const router = useRouter()
const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace)
const {
data: workspaces = [],
isLoading: isWorkspacesLoading,
isFetching: isWorkspacesFetching,
} = useWorkspacesQuery(Boolean(sessionUserId))
const { data: workspaceCreationPolicy = null } = useWorkspaceCreationPolicy(
Boolean(sessionUserId)
)
const leaveWorkspaceMutation = useLeaveWorkspace()
const createWorkspaceMutation = useCreateWorkspace()
const deleteWorkspaceMutation = useDeleteWorkspace()
const updateWorkspaceMutation = useUpdateWorkspace()
const workspaceIdRef = useRef<string>(workspaceId)
const workspacesRef = useRef<Workspace[]>(workspaces)
const routerRef = useRef<ReturnType<typeof useRouter>>(router)
const hasValidatedRef = useRef<boolean>(false)
const lastTouchedRef = useRef<string | null>(null)
const syncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
workspaceIdRef.current = workspaceId
workspacesRef.current = workspaces
routerRef.current = router
const [recencySortKey, setRecencySortKey] = useState(0)
useEffect(() => {
return () => {
if (syncTimerRef.current) clearTimeout(syncTimerRef.current)
}
}, [])
const touchRecency = useCallback((id: string) => {
if (lastTouchedRef.current === id) return
lastTouchedRef.current = id
WorkspaceRecencyStorage.touch(id)
const validIds = workspacesRef.current.map((w) => w.id)
if (validIds.length > 0) {
WorkspaceRecencyStorage.prune(new Set(validIds))
}
setRecencySortKey((k) => k + 1)
if (syncTimerRef.current) clearTimeout(syncTimerRef.current)
syncTimerRef.current = setTimeout(() => {
requestJson(updateUserSettingsContract, {
body: { lastActiveWorkspaceId: id },
}).catch(() => {})
}, 1000)
}, [])
const sortedWorkspaces = useMemo(
() => WorkspaceRecencyStorage.sortByRecency(workspaces),
// eslint-disable-next-line react-hooks/exhaustive-deps
[workspaces, recencySortKey]
)
const activeWorkspace = useMemo(() => {
if (!workspaces.length) return null
return workspaces.find((w) => w.id === workspaceId) ?? null
}, [workspaces, workspaceId])
useEffect(() => {
if (workspaceId) {
touchRecency(workspaceId)
}
}, [workspaceId, touchRecency])
const activeWorkspaceRef = useRef<Workspace | null>(activeWorkspace)
activeWorkspaceRef.current = activeWorkspace
useEffect(() => {
if (isWorkspacesLoading || hasValidatedRef.current || !workspaces.length) {
return
}
const currentWorkspaceId = workspaceIdRef.current
const matchingWorkspace = workspaces.find((w) => w.id === currentWorkspaceId)
if (!matchingWorkspace) {
if (isWorkspacesFetching) {
return
}
logger.warn(`Workspace ${currentWorkspaceId} not found in user's workspaces`)
const sorted = WorkspaceRecencyStorage.sortByRecency(workspaces)
const fallbackWorkspace = sorted[0]
logger.info(`Redirecting to fallback workspace: ${fallbackWorkspace.id}`)
routerRef.current?.push(`/workspace/${fallbackWorkspace.id}/home`)
}
hasValidatedRef.current = true
}, [workspaces, isWorkspacesLoading, isWorkspacesFetching])
const updateWorkspace = useCallback(
async (
workspaceId: string,
updates: { name?: string; logoUrl?: string | null; color?: string }
): Promise<boolean> => {
try {
await updateWorkspaceMutation.mutateAsync({ workspaceId, ...updates })
logger.info('Successfully updated workspace:', updates)
return true
} catch (error) {
logger.error('Error updating workspace:', error)
return false
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)
const switchWorkspace = useCallback(
async (workspace: Workspace) => {
if (activeWorkspaceRef.current?.id === workspace.id) {
return
}
try {
switchToWorkspace(workspace.id)
routerRef.current?.push(`/workspace/${workspace.id}/home`)
logger.info(`Switched to workspace: ${workspace.name} (${workspace.id})`)
} catch (error) {
logger.error('Error switching workspace:', error)
}
},
[switchToWorkspace]
)
const handleCreateWorkspace = useCallback(
async (name: string) => {
try {
logger.info(`Creating new workspace: ${name}`)
const newWorkspace = await createWorkspaceMutation.mutateAsync({ name })
logger.info('Created new workspace:', newWorkspace)
await switchWorkspace(newWorkspace)
} catch (error) {
logger.error('Error creating workspace:', error)
throw error
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[switchWorkspace]
)
const confirmDeleteWorkspace = useCallback(
async (workspaceToDelete: Workspace) => {
try {
logger.info('Deleting workspace:', workspaceToDelete.id)
await deleteWorkspaceMutation.mutateAsync({
workspaceId: workspaceToDelete.id,
})
WorkspaceRecencyStorage.remove(workspaceToDelete.id)
logger.info('Workspace deleted successfully:', workspaceToDelete.id)
const isDeletingCurrentWorkspace =
workspaceIdRef.current === workspaceToDelete.id ||
activeWorkspaceRef.current?.id === workspaceToDelete.id
if (isDeletingCurrentWorkspace) {
hasValidatedRef.current = false
const remainingWorkspaces = WorkspaceRecencyStorage.sortByRecency(
workspacesRef.current.filter((w) => w.id !== workspaceToDelete.id)
)
if (remainingWorkspaces.length > 0) {
await switchWorkspace(remainingWorkspaces[0])
}
}
} catch (error) {
logger.error('Error deleting workspace:', error)
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[switchWorkspace]
)
const handleLeaveWorkspace = useCallback(
async (workspaceToLeave: Workspace) => {
if (!sessionUserId) {
logger.error('Cannot leave workspace: no session user ID')
return
}
logger.info('Leaving workspace:', workspaceToLeave.id)
try {
await leaveWorkspaceMutation.mutateAsync({
userId: sessionUserId,
workspaceId: workspaceToLeave.id,
})
WorkspaceRecencyStorage.remove(workspaceToLeave.id)
logger.info('Left workspace successfully:', workspaceToLeave.id)
const isLeavingCurrentWorkspace =
workspaceIdRef.current === workspaceToLeave.id ||
activeWorkspaceRef.current?.id === workspaceToLeave.id
if (isLeavingCurrentWorkspace) {
hasValidatedRef.current = false
const remainingWorkspaces = WorkspaceRecencyStorage.sortByRecency(
workspacesRef.current.filter((w) => w.id !== workspaceToLeave.id)
)
if (remainingWorkspaces.length > 0) {
await switchWorkspace(remainingWorkspaces[0])
}
}
} catch (error) {
logger.error('Error leaving workspace:', error)
throw error
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[switchWorkspace, sessionUserId]
)
return {
workspaces: sortedWorkspaces,
workspaceCreationPolicy,
activeWorkspace,
isWorkspacesLoading,
isCreatingWorkspace: createWorkspaceMutation.isPending,
isDeletingWorkspace: deleteWorkspaceMutation.isPending,
isLeavingWorkspace: leaveWorkspaceMutation.isPending,
updateWorkspace,
switchWorkspace,
handleCreateWorkspace,
confirmDeleteWorkspace,
handleLeaveWorkspace,
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,124 @@
import type { MothershipResource } from '@/lib/copilot/resource-types'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
/**
* Builds a `MothershipResource` array from a sidebar drag selection so it can
* be set as `application/x-sim-resources` drag data and dropped into the chat.
*/
export function buildDragResources(
selection: { workflowIds: string[]; folderIds: string[] },
workspaceId: string
): MothershipResource[] {
const allWorkflows = getWorkflows(workspaceId)
const workflowMap = Object.fromEntries(allWorkflows.map((w) => [w.id, w]))
const folderMap = getFolderMap(workspaceId)
return [
...selection.workflowIds.map((id) => ({
type: 'workflow' as const,
id,
title: workflowMap[id]?.name ?? id,
})),
...selection.folderIds.map((id) => ({
type: 'folder' as const,
id,
title: folderMap[id]?.name ?? id,
})),
]
}
export type SidebarDragGhostIcon = { kind: 'workflow' } | { kind: 'folder' } | { kind: 'task' }
const FOLDER_SVG = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>`
const WORKFLOW_SVG = `<svg width="14" height="14" viewBox="-1 -2 24 24" fill="none" stroke="currentColor" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="1.25" y="0.75" width="18" height="18" rx="4"/><rect x="6.25" y="5.75" width="8" height="8" rx="2"/></svg>`
/**
* Creates a lightweight drag ghost pill showing an icon and label for the item(s) being dragged.
* Append to `document.body`, pass to `e.dataTransfer.setDragImage`, then remove on dragend.
*/
export function createSidebarDragGhost(label: string, icon?: SidebarDragGhostIcon): HTMLElement {
const ghost = document.createElement('div')
ghost.style.cssText = `
position: fixed;
top: -500px;
left: 0;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: var(--surface-active);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
font-family: system-ui, -apple-system, sans-serif;
font-size: 13px;
color: var(--text-body);
white-space: nowrap;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
z-index: 9999;
`
if (icon) {
if (icon.kind === 'workflow') {
const iconWrapper = document.createElement('div')
iconWrapper.style.cssText =
'display: flex; align-items: center; flex-shrink: 0; color: var(--text-icon);'
iconWrapper.innerHTML = WORKFLOW_SVG
ghost.appendChild(iconWrapper)
} else if (icon.kind === 'task') {
const circle = document.createElement('div')
circle.style.cssText = `
width: 14px; height: 14px; flex-shrink: 0;
border-radius: 9999px;
border: 2px solid color-mix(in srgb, var(--text-icon) 38%, transparent);
background: var(--text-icon); background-clip: padding-box;
`
ghost.appendChild(circle)
} else {
const iconWrapper = document.createElement('div')
iconWrapper.style.cssText =
'display: flex; align-items: center; flex-shrink: 0; color: var(--text-icon);'
iconWrapper.innerHTML = FOLDER_SVG
ghost.appendChild(iconWrapper)
}
}
const text = document.createElement('span')
text.style.cssText = 'max-width: 200px; overflow: hidden; text-overflow: ellipsis;'
text.textContent = label
ghost.appendChild(text)
document.body.appendChild(ghost)
return ghost
}
export function compareByOrder<T extends { sortOrder: number; createdAt?: Date; id: string }>(
a: T,
b: T
): number {
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
const timeA = a.createdAt?.getTime() ?? 0
const timeB = b.createdAt?.getTime() ?? 0
if (timeA !== timeB) return timeA - timeB
return a.id.localeCompare(b.id)
}
export function groupWorkflowsByFolder(
workflows: WorkflowMetadata[]
): Record<string, WorkflowMetadata[]> {
const grouped = workflows.reduce(
(acc, workflow) => {
const folderId = workflow.folderId || 'root'
if (!acc[folderId]) acc[folderId] = []
acc[folderId].push(workflow)
return acc
},
{} as Record<string, WorkflowMetadata[]>
)
for (const key of Object.keys(grouped)) {
grouped[key].sort(compareByOrder)
}
return grouped
}