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
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:
+1
@@ -0,0 +1 @@
|
||||
export { PreviewContextMenu } from './preview-context-menu'
|
||||
+91
@@ -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
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { PreviewEditor } from './preview-editor'
|
||||
+1590
File diff suppressed because it is too large
Load Diff
+592
@@ -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)
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { PreviewBlock } from './block'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { PreviewSubflow } from './subflow'
|
||||
+152
@@ -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)
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { getLeftmostBlockId, PreviewWorkflow } from './preview-workflow'
|
||||
+655
@@ -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>
|
||||
)
|
||||
}
|
||||
+494
@@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
CollapsedChatFlyoutItem,
|
||||
CollapsedFileFolderItems,
|
||||
CollapsedFolderItems,
|
||||
CollapsedSidebarMenu,
|
||||
CollapsedWorkflowFlyoutItem,
|
||||
} from './collapsed-sidebar-menu'
|
||||
+214
@@ -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>
|
||||
)
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { FileList } from './file-list'
|
||||
+364
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
+1
@@ -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'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { NavItemContextMenu } from './nav-item-context-menu'
|
||||
+65
@@ -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>
|
||||
)
|
||||
}
|
||||
+360
@@ -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
|
||||
)
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
HighlightedText,
|
||||
MemoizedActionItem,
|
||||
MemoizedCommandItem,
|
||||
MemoizedFileItem,
|
||||
MemoizedIconItem,
|
||||
MemoizedPageItem,
|
||||
MemoizedTaskItem,
|
||||
MemoizedWorkflowItem,
|
||||
MemoizedWorkspaceItem,
|
||||
} from './command-items'
|
||||
+25
@@ -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'
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
export {
|
||||
ActionsGroup,
|
||||
BlocksGroup,
|
||||
ChatsGroup,
|
||||
ConnectedAccountsGroup,
|
||||
DocsGroup,
|
||||
FilesGroup,
|
||||
IntegrationsGroup,
|
||||
KnowledgeBasesGroup,
|
||||
PagesGroup,
|
||||
TablesGroup,
|
||||
ToolOpsGroup,
|
||||
ToolsGroup,
|
||||
TriggersGroup,
|
||||
WorkflowsGroup,
|
||||
WorkspacesGroup,
|
||||
} from './search-groups'
|
||||
+403
@@ -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>
|
||||
)
|
||||
})
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export type { SearchModalProps } from './search-modal'
|
||||
export { SearchModal } from './search-modal'
|
||||
+96
@@ -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}`,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
+819
@@ -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
|
||||
)
|
||||
}
|
||||
+317
@@ -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 item’s 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))
|
||||
})
|
||||
})
|
||||
+307
@@ -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
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { SettingsSidebar } from './settings-sidebar'
|
||||
+383
@@ -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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+356
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ContextMenu } from './context-menu'
|
||||
+234
@@ -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
|
||||
<span className='font-medium text-[var(--text-primary)]'>{workspaceName}</span>
|
||||
to confirm
|
||||
</span>
|
||||
}
|
||||
value={confirmationText}
|
||||
onChange={setConfirmationText}
|
||||
placeholder={workspaceName}
|
||||
/>
|
||||
)}
|
||||
</ChipConfirmModal>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { DeleteModal } from './delete-modal'
|
||||
+93
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { EmptyAreaContextMenu } from './empty-area-context-menu'
|
||||
+618
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { FolderItem } from './folder-item'
|
||||
+5
@@ -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'
|
||||
+152
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { Avatars } from './avatars'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { WorkflowItem } from './workflow-item'
|
||||
+519
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { WorkflowList } from './workflow-list'
|
||||
+619
@@ -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>
|
||||
)
|
||||
})
|
||||
+92
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { InviteModal } from './invite-modal'
|
||||
+215
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { WorkspaceHeader } from './workspace-header'
|
||||
+708
@@ -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,
|
||||
}
|
||||
}
|
||||
+101
@@ -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,
|
||||
}
|
||||
}
|
||||
+51
@@ -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,
|
||||
}
|
||||
}
|
||||
+162
@@ -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,
|
||||
}
|
||||
}
|
||||
+93
@@ -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 }
|
||||
}
|
||||
+52
@@ -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,
|
||||
}
|
||||
}
|
||||
+100
@@ -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,
|
||||
}
|
||||
}
|
||||
+166
@@ -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,
|
||||
}
|
||||
}
|
||||
+273
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user