mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Improvement(ui/ux): signup, command-list, cursors, search modal, workflow runs, usage indicator (#1998)
* improvement: signup loading, command-list, cursors, search modal ordering * improvement: workflow runs, search modal * improvement(usage-indicator): ui/ux
This commit is contained in:
@@ -513,7 +513,7 @@ function SignupFormContent({
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span className='flex items-center gap-1'>
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
{isLoading ? 'Creating account' : 'Create account'}
|
||||
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
|
||||
{isButtonHovered ? (
|
||||
<ArrowRight className='h-4 w-4' aria-hidden='true' />
|
||||
|
||||
@@ -74,6 +74,30 @@
|
||||
animation: dash-animation 1.5s linear infinite !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active block ring animation - cycles through gray tones using box-shadow
|
||||
*/
|
||||
@keyframes ring-pulse-colors {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 4px var(--surface-14);
|
||||
}
|
||||
33% {
|
||||
box-shadow: 0 0 0 4px var(--surface-12);
|
||||
}
|
||||
66% {
|
||||
box-shadow: 0 0 0 4px var(--surface-15);
|
||||
}
|
||||
}
|
||||
|
||||
.dark .animate-ring-pulse {
|
||||
animation: ring-pulse-colors 2s ease-in-out infinite !important;
|
||||
}
|
||||
|
||||
.light .animate-ring-pulse {
|
||||
animation: ring-pulse-colors 2s ease-in-out infinite !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dark color tokens - single source of truth for all colors (dark-only)
|
||||
*/
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { Layout, LibraryBig, Search } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { AgentIcon } from '@/components/icons'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearchModalStore } from '@/stores/search-modal/store'
|
||||
|
||||
const logger = createLogger('WorkflowCommandList')
|
||||
|
||||
/**
|
||||
* Command item data structure
|
||||
@@ -49,13 +55,131 @@ const commands: CommandItem[] = [
|
||||
* Centered on the screen for empty workflows
|
||||
*/
|
||||
export function CommandList() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { open: openSearchModal } = useSearchModalStore()
|
||||
|
||||
const workspaceId = params.workspaceId as string | undefined
|
||||
|
||||
/**
|
||||
* Handle click on a command row.
|
||||
*
|
||||
* Mirrors the behavior of the corresponding global keyboard shortcuts:
|
||||
* - Templates: navigate to workspace templates
|
||||
* - New Agent: add an agent block to the canvas
|
||||
* - Logs: navigate to workspace logs
|
||||
* - Search Blocks: open the universal search modal
|
||||
*
|
||||
* @param label - Command label that was clicked.
|
||||
*/
|
||||
const handleCommandClick = useCallback(
|
||||
(label: string) => {
|
||||
try {
|
||||
switch (label) {
|
||||
case 'Templates': {
|
||||
if (!workspaceId) {
|
||||
logger.warn('No workspace ID found, cannot navigate to templates from command list')
|
||||
return
|
||||
}
|
||||
router.push(`/workspace/${workspaceId}/templates`)
|
||||
return
|
||||
}
|
||||
case 'New Agent': {
|
||||
const event = new CustomEvent('add-block-from-toolbar', {
|
||||
detail: { type: 'agent', enableTriggerMode: false },
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
return
|
||||
}
|
||||
case 'Logs': {
|
||||
if (!workspaceId) {
|
||||
logger.warn('No workspace ID found, cannot navigate to logs from command list')
|
||||
return
|
||||
}
|
||||
router.push(`/workspace/${workspaceId}/logs`)
|
||||
return
|
||||
}
|
||||
case 'Search Blocks': {
|
||||
openSearchModal()
|
||||
return
|
||||
}
|
||||
default:
|
||||
logger.warn('Unknown command label clicked in command list', { label })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle command click in command list', { error, label })
|
||||
}
|
||||
},
|
||||
[router, workspaceId, openSearchModal]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle drag-over events from the toolbar.
|
||||
*
|
||||
* When a toolbar item is dragged over the command list, mark the drop as valid
|
||||
* so the browser shows the appropriate drop cursor. Only reacts to toolbar
|
||||
* drags that carry the expected JSON payload.
|
||||
*
|
||||
* @param event - Drag event from the browser.
|
||||
*/
|
||||
const handleDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!event.dataTransfer?.types.includes('application/json')) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle drops of toolbar items onto the command list.
|
||||
*
|
||||
* This forwards the drop information (block type and cursor position)
|
||||
* to the workflow canvas via a custom event. The workflow component
|
||||
* then reuses its existing drop logic to place the block precisely
|
||||
* under the cursor, including container/subflow handling.
|
||||
*
|
||||
* @param event - Drop event from the browser.
|
||||
*/
|
||||
const handleDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!event.dataTransfer?.types.includes('application/json')) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
try {
|
||||
const raw = event.dataTransfer.getData('application/json')
|
||||
if (!raw) return
|
||||
|
||||
const data = JSON.parse(raw) as { type?: string; enableTriggerMode?: boolean }
|
||||
if (!data?.type || data.type === 'connectionBlock') return
|
||||
|
||||
const overlayDropEvent = new CustomEvent('toolbar-drop-on-empty-workflow-overlay', {
|
||||
detail: {
|
||||
type: data.type,
|
||||
enableTriggerMode: data.enableTriggerMode ?? false,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
},
|
||||
})
|
||||
|
||||
window.dispatchEvent(overlayDropEvent)
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle drop on command list', { error })
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 mb-[50px] flex items-center justify-center'
|
||||
)}
|
||||
>
|
||||
<div className='pointer-events-none flex flex-col gap-[8px]'>
|
||||
<div
|
||||
className='pointer-events-auto flex flex-col gap-[8px]'
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className='mb-[20px] flex justify-center'>
|
||||
<Image
|
||||
@@ -79,6 +203,7 @@ export function CommandList() {
|
||||
<div
|
||||
key={command.label}
|
||||
className='group flex cursor-pointer items-center justify-between gap-[60px]'
|
||||
onClick={() => handleCommandClick(command.label)}
|
||||
>
|
||||
{/* Left side: Icon and Label */}
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
@@ -91,7 +216,7 @@ export function CommandList() {
|
||||
{/* Right side: Keyboard Shortcut */}
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<Button
|
||||
className='group-hover:-translate-y-0.5 w-[26px] py-[3px] text-[12px] hover:translate-y-0 hover:text-[var(--text-tertiary)] hover:shadow-[0_2px_0_0] group-hover:text-[var(--text-primary)] group-hover:shadow-[0_4px_0_0]'
|
||||
className='group-hover:-translate-y-0.5 w-[26px] py-[3px] text-[12px] hover:translate-y-0 hover:text-[var(--text-tertiary)] hover:shadow-[0_2px_0_0_rgba(48,48,48,1)] group-hover:text-[var(--text-primary)] group-hover:shadow-[0_4px_0_0_rgba(48,48,48,1)]'
|
||||
variant='3d'
|
||||
>
|
||||
<span>⌘</span>
|
||||
@@ -99,7 +224,7 @@ export function CommandList() {
|
||||
{shortcuts.map((key, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
className='group-hover:-translate-y-0.5 w-[26px] py-[3px] text-[12px] hover:translate-y-0 hover:text-[var(--text-tertiary)] hover:shadow-[0_2px_0_0] group-hover:text-[var(--text-primary)] group-hover:shadow-[0_4px_0_0]'
|
||||
className='group-hover:-translate-y-0.5 w-[26px] py-[3px] text-[12px] hover:translate-y-0 hover:text-[var(--text-tertiary)] hover:shadow-[0_2px_0_0_rgba(48,48,48,1)] group-hover:text-[var(--text-primary)] group-hover:shadow-[0_4px_0_0_rgba(48,48,48,1)]'
|
||||
variant='3d'
|
||||
>
|
||||
{key}
|
||||
|
||||
@@ -18,11 +18,6 @@ interface CursorRenderData {
|
||||
color: string
|
||||
}
|
||||
|
||||
const POINTER_OFFSET = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
}
|
||||
|
||||
const CursorsComponent = () => {
|
||||
const { presenceUsers } = useSocket()
|
||||
const viewport = useViewport()
|
||||
@@ -60,23 +55,15 @@ const CursorsComponent = () => {
|
||||
transition: 'transform 0.12s ease-out',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='relative'
|
||||
style={{ transform: `translate(${-POINTER_OFFSET.x}px, ${-POINTER_OFFSET.y}px)` }}
|
||||
>
|
||||
{/* Simple cursor pointer */}
|
||||
<svg width={16} height={18} viewBox='0 0 16 18' fill='none'>
|
||||
<path
|
||||
d='M0.5 0.5L0.5 12L4 9L6.5 15L8.5 14L6 8L12 8L0.5 0.5Z'
|
||||
fill={color}
|
||||
stroke='rgba(0,0,0,0.3)'
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<div className='relative flex items-start'>
|
||||
{/* Filled mouse pointer cursor */}
|
||||
<svg className='-mt-[18px]' width={24} height={24} viewBox='0 0 24 24' fill={color}>
|
||||
<path d='M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z' />
|
||||
</svg>
|
||||
|
||||
{/* Name tag underneath and to the right */}
|
||||
{/* Name tag to the right, background tightly wrapping text */}
|
||||
<div
|
||||
className='absolute top-[18px] left-[4px] h-[21px] w-[140px] truncate whitespace-nowrap rounded-[2px] p-[6px] font-medium text-[11px] text-[var(--surface-1)]'
|
||||
className='ml-[-4px] inline-flex max-w-[160px] truncate whitespace-nowrap rounded-[2px] px-1.5 py-[2px] font-medium text-[11px] text-[var(--surface-1)]'
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{name}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useExecutionStore } from '@/stores/execution/store'
|
||||
import { usePanelEditorStore } from '@/stores/panel-new/editor/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useBlockState } from '../components/workflow-block/hooks'
|
||||
@@ -28,6 +29,10 @@ export function useBlockCore({ blockId, data, isPending = false }: UseBlockCoreO
|
||||
data
|
||||
)
|
||||
|
||||
// Run path state (from last execution)
|
||||
const lastRunPath = useExecutionStore((state) => state.lastRunPath)
|
||||
const runPathStatus = lastRunPath.get(blockId)
|
||||
|
||||
// Focus management
|
||||
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
|
||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||
@@ -38,6 +43,7 @@ export function useBlockCore({ blockId, data, isPending = false }: UseBlockCoreO
|
||||
}, [blockId, setCurrentBlockId])
|
||||
|
||||
// Ring styling based on all states
|
||||
// Priority: active (animated) > pending > focused > deleted > diff > run path
|
||||
const { hasRing, ringStyles } = useMemo(() => {
|
||||
const hasRing =
|
||||
isActive ||
|
||||
@@ -45,20 +51,52 @@ export function useBlockCore({ blockId, data, isPending = false }: UseBlockCoreO
|
||||
isFocused ||
|
||||
diffStatus === 'new' ||
|
||||
diffStatus === 'edited' ||
|
||||
isDeletedBlock
|
||||
isDeletedBlock ||
|
||||
!!runPathStatus
|
||||
|
||||
const ringStyles = cn(
|
||||
hasRing && 'ring-[1.75px]',
|
||||
isActive && 'ring-[#8C10FF] animate-pulse-ring',
|
||||
isPending && 'ring-[var(--warning)]',
|
||||
isFocused && 'ring-[var(--brand-secondary)]',
|
||||
diffStatus === 'new' && 'ring-[#22C55F]',
|
||||
diffStatus === 'edited' && 'ring-[var(--warning)]',
|
||||
isDeletedBlock && 'ring-[var(--text-error)]'
|
||||
// Executing block: animated ring cycling through gray tones (animation handles all styling)
|
||||
isActive && 'animate-ring-pulse',
|
||||
// Non-active states use standard ring utilities
|
||||
!isActive && hasRing && 'ring-[1.75px]',
|
||||
// Pending state: warning ring
|
||||
!isActive && isPending && 'ring-[var(--warning)]',
|
||||
// Focused (selected) state: brand ring
|
||||
!isActive && !isPending && isFocused && 'ring-[var(--brand-secondary)]',
|
||||
// Deleted state (highest priority after active/pending/focused)
|
||||
!isActive && !isPending && !isFocused && isDeletedBlock && 'ring-[var(--text-error)]',
|
||||
// Diff states
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
diffStatus === 'new' &&
|
||||
'ring-[#22C55E]',
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
diffStatus === 'edited' &&
|
||||
'ring-[var(--warning)]',
|
||||
// Run path states (lowest priority - only show if no other states active)
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
!diffStatus &&
|
||||
runPathStatus === 'success' &&
|
||||
'ring-[var(--surface-14)]',
|
||||
!isActive &&
|
||||
!isPending &&
|
||||
!isFocused &&
|
||||
!isDeletedBlock &&
|
||||
!diffStatus &&
|
||||
runPathStatus === 'error' &&
|
||||
'ring-[var(--text-error)]'
|
||||
)
|
||||
|
||||
return { hasRing, ringStyles }
|
||||
}, [isActive, isPending, isFocused, diffStatus, isDeletedBlock])
|
||||
}, [isActive, isPending, isFocused, diffStatus, isDeletedBlock, runPathStatus])
|
||||
|
||||
return {
|
||||
// Workflow context
|
||||
|
||||
@@ -99,6 +99,7 @@ export function useWorkflowExecution() {
|
||||
setExecutor,
|
||||
setDebugContext,
|
||||
setActiveBlocks,
|
||||
setBlockRunStatus,
|
||||
} = useExecutionStore()
|
||||
const [executionResult, setExecutionResult] = useState<ExecutionResult | null>(null)
|
||||
const executionStream = useExecutionStream()
|
||||
@@ -900,6 +901,9 @@ export function useWorkflowExecution() {
|
||||
// Create a new Set to trigger React re-render
|
||||
setActiveBlocks(new Set(activeBlocksSet))
|
||||
|
||||
// Track successful block execution in run path
|
||||
setBlockRunStatus(data.blockId, 'success')
|
||||
|
||||
// Add to console
|
||||
addConsole({
|
||||
input: data.input || {},
|
||||
@@ -932,6 +936,9 @@ export function useWorkflowExecution() {
|
||||
// Create a new Set to trigger React re-render
|
||||
setActiveBlocks(new Set(activeBlocksSet))
|
||||
|
||||
// Track failed block execution in run path
|
||||
setBlockRunStatus(data.blockId, 'error')
|
||||
|
||||
// Add error to console
|
||||
addConsole({
|
||||
input: data.input || {},
|
||||
|
||||
@@ -553,246 +553,21 @@ const WorkflowContent = React.memo(() => {
|
||||
return sourceHandle
|
||||
}, [])
|
||||
|
||||
// Listen for toolbar block click events
|
||||
useEffect(() => {
|
||||
const handleAddBlockFromToolbar = (event: CustomEvent) => {
|
||||
// Check if user has permission to interact with blocks
|
||||
if (!effectivePermissions.canEdit) {
|
||||
return
|
||||
}
|
||||
|
||||
const { type, enableTriggerMode } = event.detail
|
||||
|
||||
if (!type) return
|
||||
if (type === 'connectionBlock') return
|
||||
|
||||
// Special handling for container nodes (loop or parallel)
|
||||
if (type === 'loop' || type === 'parallel') {
|
||||
const id = crypto.randomUUID()
|
||||
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
const centerPosition = project({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
})
|
||||
|
||||
// Auto-connect logic for container nodes
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (isAutoConnectEnabled) {
|
||||
const closestBlock = findClosestOutput(centerPosition)
|
||||
if (closestBlock) {
|
||||
const sourceHandle = determineSourceHandle(closestBlock)
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the container node with default dimensions and auto-connect edge
|
||||
addBlock(
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
centerPosition,
|
||||
{
|
||||
width: 500,
|
||||
height: 300,
|
||||
type: 'subflowNode',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
autoConnectEdge
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const blockConfig = getBlock(type)
|
||||
if (!blockConfig) {
|
||||
logger.error('Invalid block type:', { type })
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate the center position of the viewport
|
||||
const centerPosition = project({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
})
|
||||
|
||||
// Create a new block with a unique ID
|
||||
const id = crypto.randomUUID()
|
||||
// Prefer semantic default names for triggers; then ensure unique numbering centrally
|
||||
const defaultTriggerName = TriggerUtils.getDefaultTriggerName(type)
|
||||
const baseName = defaultTriggerName || blockConfig.name
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
// Auto-connect logic
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (isAutoConnectEnabled && type !== 'starter') {
|
||||
const closestBlock = findClosestOutput(centerPosition)
|
||||
logger.info('Closest block found:', closestBlock)
|
||||
if (closestBlock) {
|
||||
// Don't create edges into trigger blocks
|
||||
const targetBlockConfig = blockConfig
|
||||
const isTargetTrigger = enableTriggerMode || targetBlockConfig?.category === 'triggers'
|
||||
|
||||
if (!isTargetTrigger) {
|
||||
const sourceHandle = determineSourceHandle(closestBlock)
|
||||
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
logger.info('Auto-connect edge created:', autoConnectEdge)
|
||||
} else {
|
||||
logger.info('Skipping auto-connect into trigger block', {
|
||||
target: type,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Centralized trigger constraints
|
||||
const additionIssue = TriggerUtils.getTriggerAdditionIssue(blocks, type)
|
||||
if (additionIssue) {
|
||||
if (additionIssue.issue === 'legacy') {
|
||||
setTriggerWarning({
|
||||
open: true,
|
||||
triggerName: additionIssue.triggerName,
|
||||
type: TriggerWarningType.LEGACY_INCOMPATIBILITY,
|
||||
})
|
||||
} else {
|
||||
setTriggerWarning({
|
||||
open: true,
|
||||
triggerName: additionIssue.triggerName,
|
||||
type: TriggerWarningType.DUPLICATE_TRIGGER,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Add the block to the workflow with auto-connect edge
|
||||
// Enable trigger mode if this is a trigger-capable block from the triggers tab
|
||||
addBlock(
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
centerPosition,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
autoConnectEdge,
|
||||
enableTriggerMode
|
||||
)
|
||||
}
|
||||
|
||||
window.addEventListener('add-block-from-toolbar', handleAddBlockFromToolbar as EventListener)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'add-block-from-toolbar',
|
||||
handleAddBlockFromToolbar as EventListener
|
||||
)
|
||||
}
|
||||
}, [
|
||||
project,
|
||||
blocks,
|
||||
addBlock,
|
||||
addEdge,
|
||||
findClosestOutput,
|
||||
determineSourceHandle,
|
||||
effectivePermissions.canEdit,
|
||||
setTriggerWarning,
|
||||
])
|
||||
|
||||
/**
|
||||
* Recenter canvas when diff appears
|
||||
* Tracks when diff becomes ready to automatically fit the view with smooth animation
|
||||
* Shared handler for drops of toolbar items onto the workflow canvas.
|
||||
*
|
||||
* This encapsulates the full drop behavior (container handling, auto-connect,
|
||||
* trigger constraints, etc.) so it can be reused both for direct ReactFlow
|
||||
* drops and for drops forwarded from the empty-workflow command list overlay.
|
||||
*
|
||||
* @param data - Drag data from the toolbar (type + optional trigger mode).
|
||||
* @param position - Drop position in ReactFlow coordinates.
|
||||
*/
|
||||
const prevDiffReadyRef = useRef(false)
|
||||
useEffect(() => {
|
||||
// Only recenter when diff transitions from not ready to ready
|
||||
if (isDiffReady && !prevDiffReadyRef.current && diffAnalysis) {
|
||||
logger.info('Diff ready - recentering canvas to show changes')
|
||||
// Use a small delay to ensure the diff has fully rendered
|
||||
setTimeout(() => {
|
||||
fitView({ padding: 0.3, duration: 600 })
|
||||
}, 100)
|
||||
}
|
||||
prevDiffReadyRef.current = isDiffReady
|
||||
}, [isDiffReady, diffAnalysis, fitView])
|
||||
|
||||
// Listen for trigger warning events
|
||||
useEffect(() => {
|
||||
const handleShowTriggerWarning = (event: CustomEvent) => {
|
||||
const { type, triggerName } = event.detail
|
||||
setTriggerWarning({
|
||||
open: true,
|
||||
triggerName: triggerName || 'trigger',
|
||||
type: type === 'trigger_in_subflow' ? TriggerWarningType.TRIGGER_IN_SUBFLOW : type,
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('show-trigger-warning', handleShowTriggerWarning as EventListener)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('show-trigger-warning', handleShowTriggerWarning as EventListener)
|
||||
}
|
||||
}, [setTriggerWarning])
|
||||
|
||||
// Handler for trigger selection from list
|
||||
const handleTriggerSelect = useCallback(
|
||||
(triggerId: string, enableTriggerMode?: boolean) => {
|
||||
// Get the trigger name
|
||||
const triggerName = TriggerUtils.getDefaultTriggerName(triggerId) || triggerId
|
||||
|
||||
// Create the trigger block at the center of the viewport
|
||||
const centerPosition = project({ x: window.innerWidth / 2, y: window.innerHeight / 2 })
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
// Add the trigger block with trigger mode if specified
|
||||
addBlock(
|
||||
id,
|
||||
triggerId,
|
||||
triggerName,
|
||||
centerPosition,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
enableTriggerMode || false
|
||||
)
|
||||
},
|
||||
[project, addBlock]
|
||||
)
|
||||
|
||||
// Update the onDrop handler
|
||||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault()
|
||||
const handleToolbarDrop = useCallback(
|
||||
(data: { type: string; enableTriggerMode?: boolean }, position: { x: number; y: number }) => {
|
||||
if (!data.type || data.type === 'connectionBlock') return
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.dataTransfer.getData('application/json'))
|
||||
if (data.type === 'connectionBlock') return
|
||||
|
||||
const reactFlowBounds = event.currentTarget.getBoundingClientRect()
|
||||
const position = project({
|
||||
x: event.clientX - reactFlowBounds.left,
|
||||
y: event.clientY - reactFlowBounds.top,
|
||||
})
|
||||
|
||||
// Check if dropping inside a container node (loop or parallel)
|
||||
const containerInfo = isPointInLoopNode(position)
|
||||
|
||||
@@ -806,7 +581,7 @@ const WorkflowContent = React.memo(() => {
|
||||
// Ensure any toolbar drag flags are cleared on drop
|
||||
document.body.classList.remove('sim-drag-subflow')
|
||||
|
||||
// Special handling for container nodes (loop or parallel)
|
||||
// Special handling for container nodes (loop or parallel) dragged from toolbar
|
||||
if (data.type === 'loop' || data.type === 'parallel') {
|
||||
// Create a unique ID and name for the container
|
||||
const id = crypto.randomUUID()
|
||||
@@ -1033,22 +808,307 @@ const WorkflowContent = React.memo(() => {
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error dropping block:', { err })
|
||||
logger.error('Error handling toolbar drop on workflow canvas', { err })
|
||||
}
|
||||
},
|
||||
[
|
||||
project,
|
||||
blocks,
|
||||
addBlock,
|
||||
addEdge,
|
||||
getNodes,
|
||||
findClosestOutput,
|
||||
determineSourceHandle,
|
||||
isPointInLoopNode,
|
||||
getNodes,
|
||||
resizeLoopNodesWrapper,
|
||||
addBlock,
|
||||
setTriggerWarning,
|
||||
]
|
||||
)
|
||||
|
||||
// Listen for toolbar block click events
|
||||
useEffect(() => {
|
||||
const handleAddBlockFromToolbar = (event: CustomEvent) => {
|
||||
// Check if user has permission to interact with blocks
|
||||
if (!effectivePermissions.canEdit) {
|
||||
return
|
||||
}
|
||||
|
||||
const { type, enableTriggerMode } = event.detail
|
||||
|
||||
if (!type) return
|
||||
if (type === 'connectionBlock') return
|
||||
|
||||
// Special handling for container nodes (loop or parallel)
|
||||
if (type === 'loop' || type === 'parallel') {
|
||||
const id = crypto.randomUUID()
|
||||
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
const centerPosition = project({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
})
|
||||
|
||||
// Auto-connect logic for container nodes
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (isAutoConnectEnabled) {
|
||||
const closestBlock = findClosestOutput(centerPosition)
|
||||
if (closestBlock) {
|
||||
const sourceHandle = determineSourceHandle(closestBlock)
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the container node with default dimensions and auto-connect edge
|
||||
addBlock(
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
centerPosition,
|
||||
{
|
||||
width: 500,
|
||||
height: 300,
|
||||
type: 'subflowNode',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
autoConnectEdge
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const blockConfig = getBlock(type)
|
||||
if (!blockConfig) {
|
||||
logger.error('Invalid block type:', { type })
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate the center position of the viewport
|
||||
const centerPosition = project({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
})
|
||||
|
||||
// Create a new block with a unique ID
|
||||
const id = crypto.randomUUID()
|
||||
// Prefer semantic default names for triggers; then ensure unique numbering centrally
|
||||
const defaultTriggerName = TriggerUtils.getDefaultTriggerName(type)
|
||||
const baseName = defaultTriggerName || blockConfig.name
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
// Auto-connect logic
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (isAutoConnectEnabled && type !== 'starter') {
|
||||
const closestBlock = findClosestOutput(centerPosition)
|
||||
logger.info('Closest block found:', closestBlock)
|
||||
if (closestBlock) {
|
||||
// Don't create edges into trigger blocks
|
||||
const targetBlockConfig = blockConfig
|
||||
const isTargetTrigger = enableTriggerMode || targetBlockConfig?.category === 'triggers'
|
||||
|
||||
if (!isTargetTrigger) {
|
||||
const sourceHandle = determineSourceHandle(closestBlock)
|
||||
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
logger.info('Auto-connect edge created:', autoConnectEdge)
|
||||
} else {
|
||||
logger.info('Skipping auto-connect into trigger block', {
|
||||
target: type,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Centralized trigger constraints
|
||||
const additionIssue = TriggerUtils.getTriggerAdditionIssue(blocks, type)
|
||||
if (additionIssue) {
|
||||
if (additionIssue.issue === 'legacy') {
|
||||
setTriggerWarning({
|
||||
open: true,
|
||||
triggerName: additionIssue.triggerName,
|
||||
type: TriggerWarningType.LEGACY_INCOMPATIBILITY,
|
||||
})
|
||||
} else {
|
||||
setTriggerWarning({
|
||||
open: true,
|
||||
triggerName: additionIssue.triggerName,
|
||||
type: TriggerWarningType.DUPLICATE_TRIGGER,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Add the block to the workflow with auto-connect edge
|
||||
// Enable trigger mode if this is a trigger-capable block from the triggers tab
|
||||
addBlock(
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
centerPosition,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
autoConnectEdge,
|
||||
enableTriggerMode
|
||||
)
|
||||
}
|
||||
|
||||
window.addEventListener('add-block-from-toolbar', handleAddBlockFromToolbar as EventListener)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'add-block-from-toolbar',
|
||||
handleAddBlockFromToolbar as EventListener
|
||||
)
|
||||
}
|
||||
}, [
|
||||
project,
|
||||
blocks,
|
||||
addBlock,
|
||||
addEdge,
|
||||
findClosestOutput,
|
||||
determineSourceHandle,
|
||||
effectivePermissions.canEdit,
|
||||
setTriggerWarning,
|
||||
])
|
||||
|
||||
/**
|
||||
* Listen for toolbar drops that occur on the empty-workflow overlay (command list).
|
||||
*
|
||||
* The overlay forwards drop events with the cursor position; this handler
|
||||
* computes the corresponding ReactFlow coordinates and delegates to
|
||||
* `handleToolbarDrop` so the behavior matches native canvas drops.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleOverlayToolbarDrop = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{
|
||||
type: string
|
||||
enableTriggerMode?: boolean
|
||||
clientX: number
|
||||
clientY: number
|
||||
}>
|
||||
|
||||
const detail = customEvent.detail
|
||||
if (!detail?.type) return
|
||||
|
||||
try {
|
||||
const canvasElement = document.querySelector('.workflow-container') as HTMLElement | null
|
||||
if (!canvasElement) {
|
||||
logger.warn('Workflow canvas element not found for overlay toolbar drop')
|
||||
return
|
||||
}
|
||||
|
||||
const bounds = canvasElement.getBoundingClientRect()
|
||||
const position = project({
|
||||
x: detail.clientX - bounds.left,
|
||||
y: detail.clientY - bounds.top,
|
||||
})
|
||||
|
||||
handleToolbarDrop(
|
||||
{
|
||||
type: detail.type,
|
||||
enableTriggerMode: detail.enableTriggerMode ?? false,
|
||||
},
|
||||
position
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('Error handling toolbar drop from empty-workflow overlay', { err })
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener(
|
||||
'toolbar-drop-on-empty-workflow-overlay',
|
||||
handleOverlayToolbarDrop as EventListener
|
||||
)
|
||||
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
'toolbar-drop-on-empty-workflow-overlay',
|
||||
handleOverlayToolbarDrop as EventListener
|
||||
)
|
||||
}, [project, handleToolbarDrop])
|
||||
|
||||
/**
|
||||
* Recenter canvas when diff appears
|
||||
* Tracks when diff becomes ready to automatically fit the view with smooth animation
|
||||
*/
|
||||
const prevDiffReadyRef = useRef(false)
|
||||
useEffect(() => {
|
||||
// Only recenter when diff transitions from not ready to ready
|
||||
if (isDiffReady && !prevDiffReadyRef.current && diffAnalysis) {
|
||||
logger.info('Diff ready - recentering canvas to show changes')
|
||||
// Use a small delay to ensure the diff has fully rendered
|
||||
setTimeout(() => {
|
||||
fitView({ padding: 0.3, duration: 600 })
|
||||
}, 100)
|
||||
}
|
||||
prevDiffReadyRef.current = isDiffReady
|
||||
}, [isDiffReady, diffAnalysis, fitView])
|
||||
|
||||
// Listen for trigger warning events
|
||||
useEffect(() => {
|
||||
const handleShowTriggerWarning = (event: CustomEvent) => {
|
||||
const { type, triggerName } = event.detail
|
||||
setTriggerWarning({
|
||||
open: true,
|
||||
triggerName: triggerName || 'trigger',
|
||||
type: type === 'trigger_in_subflow' ? TriggerWarningType.TRIGGER_IN_SUBFLOW : type,
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('show-trigger-warning', handleShowTriggerWarning as EventListener)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('show-trigger-warning', handleShowTriggerWarning as EventListener)
|
||||
}
|
||||
}, [setTriggerWarning])
|
||||
|
||||
// Update the onDrop handler to delegate to the shared toolbar-drop handler
|
||||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
try {
|
||||
const raw = event.dataTransfer.getData('application/json')
|
||||
if (!raw) return
|
||||
const data = JSON.parse(raw)
|
||||
if (!data?.type) return
|
||||
|
||||
const reactFlowBounds = event.currentTarget.getBoundingClientRect()
|
||||
const position = project({
|
||||
x: event.clientX - reactFlowBounds.left,
|
||||
y: event.clientY - reactFlowBounds.top,
|
||||
})
|
||||
|
||||
handleToolbarDrop(
|
||||
{
|
||||
type: data.type,
|
||||
enableTriggerMode: data.enableTriggerMode ?? false,
|
||||
},
|
||||
position
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('Error dropping block on ReactFlow canvas:', { err })
|
||||
}
|
||||
},
|
||||
[project, handleToolbarDrop]
|
||||
)
|
||||
|
||||
const handleCanvasPointerMove = useCallback(
|
||||
(event: React.PointerEvent<Element>) => {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
|
||||
import { BookOpen, Layout, RepeatIcon, ScrollText, Search, SplitIcon } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Dialog, DialogOverlay, DialogPortal, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Dialog, DialogPortal, DialogTitle } from '@/components/ui/dialog'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/trigger-utils'
|
||||
@@ -332,7 +332,7 @@ export function SearchModal({
|
||||
}, [workspaces, workflows, pages, blocks, triggers, tools, docs])
|
||||
|
||||
const sectionOrder = useMemo<SearchItem['type'][]>(
|
||||
() => ['workspace', 'workflow', 'page', 'tool', 'trigger', 'block', 'doc'],
|
||||
() => ['block', 'tool', 'trigger', 'workflow', 'workspace', 'page', 'doc'],
|
||||
[]
|
||||
)
|
||||
|
||||
@@ -447,7 +447,10 @@ export function SearchModal({
|
||||
if (open && selectedIndex >= 0) {
|
||||
const element = document.querySelector(`[data-search-item-index="${selectedIndex}"]`)
|
||||
if (element) {
|
||||
element.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
element.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'auto',
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [selectedIndex, open])
|
||||
@@ -481,16 +484,13 @@ export function SearchModal({
|
||||
trigger: 'Triggers',
|
||||
block: 'Blocks',
|
||||
tool: 'Tools',
|
||||
doc: 'Documentation',
|
||||
doc: 'Docs',
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
className='z-40 bg-white/80 dark:bg-[#1b1b1b]/90'
|
||||
style={{ backdropFilter: 'blur(4px)' }}
|
||||
/>
|
||||
<DialogPrimitive.Overlay className='fixed inset-0 z-40 backdrop-blur-md' />
|
||||
<DialogPrimitive.Content className='fixed top-[15%] left-[50%] z-50 flex w-[500px] translate-x-[-50%] flex-col gap-[12px] p-0 focus:outline-none focus-visible:outline-none'>
|
||||
<VisuallyHidden.Root>
|
||||
<DialogTitle>Search</DialogTitle>
|
||||
@@ -498,13 +498,13 @@ export function SearchModal({
|
||||
|
||||
{/* Search input container */}
|
||||
<div className='flex items-center gap-[8px] rounded-[10px] border border-[var(--border)] bg-[var(--surface-5)] px-[12px] py-[8px] shadow-sm dark:border-[var(--border)] dark:bg-[var(--surface-5)]'>
|
||||
<Search className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-subtle)] dark:text-[var(--text-subtle)]' />
|
||||
<Search className='h-[15px] w-[15px] flex-shrink-0 text-[var(--text-subtle)] dark:text-[var(--text-subtle)]' />
|
||||
<input
|
||||
type='text'
|
||||
placeholder='Search anything...'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className='w-full border-0 bg-transparent font-base text-[18px] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] focus:outline-none dark:text-[var(--text-primary)] dark:placeholder:text-[var(--text-secondary)]'
|
||||
className='w-full border-0 bg-transparent font-base text-[15px] text-[var(--text-primary)] placeholder:text-[var(--text-secondary)] focus:outline-none dark:text-[var(--text-primary)] dark:placeholder:text-[var(--text-secondary)]'
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -30,6 +30,14 @@ const MAX_PILL_COUNT = 8
|
||||
*/
|
||||
const WIDTH_PER_PILL = 50
|
||||
|
||||
/**
|
||||
* Animation configuration for usage pills
|
||||
* Controls how smoothly and quickly the highlight progresses across pills
|
||||
*/
|
||||
const PILL_ANIMATION_TICK_MS = 30
|
||||
const PILLS_PER_SECOND = 1.8
|
||||
const PILL_STEP_PER_TICK = (PILLS_PER_SECOND * PILL_ANIMATION_TICK_MS) / 1000
|
||||
|
||||
/**
|
||||
* Plan name mapping
|
||||
*/
|
||||
@@ -62,28 +70,6 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
const usage = getUsage(subscriptionData?.data)
|
||||
const subscription = getSubscriptionStatus(subscriptionData?.data)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex flex-shrink-0 flex-col gap-[8px] border-t pt-[12px] pr-[13.5px] pb-[10px] pl-[12px] dark:border-[var(--border)]'>
|
||||
{/* Top row skeleton */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Skeleton className='h-[14px] w-[40px] rounded-[4px]' />
|
||||
<Skeleton className='h-[14px] w-[70px] rounded-[4px]' />
|
||||
</div>
|
||||
<Skeleton className='h-[12px] w-[50px] rounded-[4px]' />
|
||||
</div>
|
||||
|
||||
{/* Pills skeleton */}
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
{Array.from({ length: pillCount }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-[6px] flex-1 rounded-[2px]' />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const progressPercentage = Math.min(usage.percentUsed, 100)
|
||||
|
||||
const planType = subscription.isEnterprise
|
||||
@@ -106,6 +92,67 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
const filledPillsCount = Math.ceil((progressPercentage / 100) * pillCount)
|
||||
const isAlmostOut = filledPillsCount === pillCount
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [wavePosition, setWavePosition] = useState<number | null>(null)
|
||||
const [hasWrapped, setHasWrapped] = useState(false)
|
||||
|
||||
const startAnimationIndex = pillCount === 0 ? 0 : Math.min(filledPillsCount, pillCount - 1)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHovered || pillCount <= 0) {
|
||||
setWavePosition(null)
|
||||
setHasWrapped(false)
|
||||
return
|
||||
}
|
||||
|
||||
const totalSpan = pillCount
|
||||
let wrapped = false
|
||||
setHasWrapped(false)
|
||||
setWavePosition(0)
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
setWavePosition((prev) => {
|
||||
const current = prev ?? 0
|
||||
const next = current + PILL_STEP_PER_TICK
|
||||
|
||||
// Mark as wrapped after first complete cycle
|
||||
if (next >= totalSpan && !wrapped) {
|
||||
wrapped = true
|
||||
setHasWrapped(true)
|
||||
}
|
||||
|
||||
// Return continuous value, never reset (seamless loop)
|
||||
return next
|
||||
})
|
||||
}, PILL_ANIMATION_TICK_MS)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval)
|
||||
}
|
||||
}, [isHovered, pillCount, startAnimationIndex])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex flex-shrink-0 flex-col gap-[8px] border-t pt-[12px] pr-[13.5px] pb-[10px] pl-[12px] dark:border-[var(--border)]'>
|
||||
{/* Top row skeleton */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
<Skeleton className='h-[14px] w-[40px] rounded-[4px]' />
|
||||
<Skeleton className='h-[14px] w-[70px] rounded-[4px]' />
|
||||
</div>
|
||||
<Skeleton className='h-[12px] w-[50px] rounded-[4px]' />
|
||||
</div>
|
||||
|
||||
{/* Pills skeleton */}
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
{Array.from({ length: pillCount }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-[6px] flex-1 rounded-[2px]' />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
try {
|
||||
if (onClick) {
|
||||
@@ -127,7 +174,12 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-shrink-0 flex-col gap-[8px] border-t px-[13.5px] pt-[8px] pb-[10px] dark:border-[var(--border)]'>
|
||||
<div
|
||||
className='group flex flex-shrink-0 cursor-pointer flex-col gap-[8px] border-t px-[13.5px] pt-[8px] pb-[10px]'
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Top row */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[6px]'>
|
||||
@@ -155,10 +207,10 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
{showUpgradeButton && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='!h-auto !px-1 !py-0 -mx-1 mt-[-2px] text-[var(--text-secondary)]'
|
||||
className='-mx-1 !h-auto !px-1 !py-0 !text-[#F473B7] group-hover:!text-[#F789C4] mt-[-2px] transition-colors duration-100'
|
||||
onClick={handleClick}
|
||||
>
|
||||
Upgrade
|
||||
<span className='font-medium text-[12px]'>Upgrade</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -167,12 +219,75 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
{Array.from({ length: pillCount }).map((_, i) => {
|
||||
const isFilled = i < filledPillsCount
|
||||
|
||||
const baseColor = isFilled ? (isAlmostOut ? '#ef4444' : '#34B5FF') : '#414141'
|
||||
|
||||
let backgroundColor = baseColor
|
||||
let backgroundImage: string | undefined
|
||||
|
||||
if (isHovered && wavePosition !== null && pillCount > 0) {
|
||||
const totalSpan = pillCount
|
||||
const grayColor = '#414141'
|
||||
const activeColor = isAlmostOut ? '#ef4444' : '#34B5FF'
|
||||
|
||||
if (!hasWrapped) {
|
||||
// First pass: respect original fill state, start from startAnimationIndex
|
||||
const headIndex = Math.floor(wavePosition)
|
||||
const progress = wavePosition - headIndex
|
||||
|
||||
const pillOffsetFromStart =
|
||||
i >= startAnimationIndex
|
||||
? i - startAnimationIndex
|
||||
: totalSpan - startAnimationIndex + i
|
||||
|
||||
if (pillOffsetFromStart < headIndex) {
|
||||
backgroundColor = baseColor
|
||||
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} 100%)`
|
||||
} else if (pillOffsetFromStart === headIndex) {
|
||||
const fillPercent = Math.max(0, Math.min(1, progress)) * 100
|
||||
backgroundColor = baseColor
|
||||
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} ${fillPercent}%, ${baseColor} ${fillPercent}%, ${baseColor} 100%)`
|
||||
}
|
||||
} else {
|
||||
// Subsequent passes: render wave at BOTH current and next-cycle positions for seamless wrap
|
||||
const wrappedPosition = wavePosition % totalSpan
|
||||
const currentHead = Math.floor(wrappedPosition)
|
||||
const progress = wrappedPosition - currentHead
|
||||
|
||||
// Primary wave position
|
||||
const primaryFilled = i < currentHead
|
||||
const primaryActive = i === currentHead
|
||||
|
||||
// Secondary wave position (one full cycle ahead, wraps to beginning)
|
||||
const secondaryHead = Math.floor(wavePosition + totalSpan) % totalSpan
|
||||
const secondaryProgress =
|
||||
wavePosition + totalSpan - Math.floor(wavePosition + totalSpan)
|
||||
const secondaryFilled = i < secondaryHead
|
||||
const secondaryActive = i === secondaryHead
|
||||
|
||||
// Render: pill is filled if either wave position has filled it
|
||||
if (primaryFilled || secondaryFilled) {
|
||||
backgroundColor = grayColor
|
||||
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} 100%)`
|
||||
} else if (primaryActive || secondaryActive) {
|
||||
const activeProgress = primaryActive ? progress : secondaryProgress
|
||||
const fillPercent = Math.max(0, Math.min(1, activeProgress)) * 100
|
||||
backgroundColor = grayColor
|
||||
backgroundImage = `linear-gradient(to right, ${activeColor} 0%, ${activeColor} ${fillPercent}%, ${grayColor} ${fillPercent}%, ${grayColor} 100%)`
|
||||
} else {
|
||||
backgroundColor = grayColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className='h-[6px] flex-1 rounded-[2px]'
|
||||
style={{
|
||||
backgroundColor: isFilled ? (isAlmostOut ? '#ef4444' : '#34B5FF') : '#414141',
|
||||
backgroundColor,
|
||||
backgroundImage,
|
||||
transition: isHovered ? 'none' : 'background-color 200ms',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ArrowDown, Plus, Search } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button, FolderPlus, Tooltip } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { getEnv, isTruthy } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import {
|
||||
@@ -27,12 +26,14 @@ import {
|
||||
useImportWorkspace,
|
||||
} from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useSearchModalStore } from '@/stores/search-modal/store'
|
||||
import { MIN_SIDEBAR_WIDTH, useSidebarStore } from '@/stores/sidebar/store'
|
||||
|
||||
const logger = createLogger('SidebarNew')
|
||||
|
||||
// Feature flag: Billing usage indicator visibility (matches legacy sidebar behavior)
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
// const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
const isBillingEnabled = true
|
||||
|
||||
/**
|
||||
* Sidebar component with resizable width that persists across page refreshes.
|
||||
@@ -84,8 +85,12 @@ export function SidebarNew() {
|
||||
// Workspace popover state
|
||||
const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false)
|
||||
|
||||
// Search modal state
|
||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false)
|
||||
// Global search modal state
|
||||
const {
|
||||
isOpen: isSearchModalOpen,
|
||||
setOpen: setIsSearchModalOpen,
|
||||
open: openSearchModal,
|
||||
} = useSearchModalStore()
|
||||
|
||||
// Workspace management hook
|
||||
const {
|
||||
@@ -452,8 +457,7 @@ export function SidebarNew() {
|
||||
shortcut: 'Mod+K',
|
||||
allowInEditable: true,
|
||||
handler: () => {
|
||||
setIsSearchModalOpen(true)
|
||||
logger.info('Search modal opened')
|
||||
openSearchModal()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@@ -11,7 +11,7 @@ const buttonVariants = cva(
|
||||
'bg-[var(--surface-5)] dark:bg-[var(--surface-5)] hover:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-9)]',
|
||||
active:
|
||||
'bg-[var(--surface-9)] dark:bg-[var(--surface-9)] hover:bg-[var(--surface-11)] dark:hover:bg-[var(--surface-11)] dark:text-[var(--text-primary)] text-[var(--text-primary)]',
|
||||
'3d': 'dark:text-[var(--text-tertiary)] border-t border-l border-r dark:border-[var(--border-strong)] shadow-[0_2px_0_0] dark:shadow-[var(--border-strong)] hover:shadow-[0_4px_0_0] transition-all hover:-translate-y-0.5 hover:dark:text-[var(--text-primary)]',
|
||||
'3d': 'text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)] border-t border-l border-r border-[#303030] dark:border-[#303030] shadow-[0_2px_0_0_rgba(48,48,48,1)] hover:shadow-[0_4px_0_0_rgba(48,48,48,1)] transition-all hover:-translate-y-0.5 hover:text-[var(--text-primary)] hover:dark:text-[var(--text-primary)]',
|
||||
outline:
|
||||
'border border-[#727272] bg-[var(--border-strong)] hover:bg-[var(--surface-11)] dark:border-[#727272] dark:bg-[var(--border-strong)] dark:hover:bg-[var(--surface-11)]',
|
||||
primary:
|
||||
|
||||
@@ -55,11 +55,20 @@ export const useExecutionStore = create<ExecutionState & ExecutionActions>()((se
|
||||
// Reset auto-pan disabled state when starting execution
|
||||
if (isExecuting) {
|
||||
set({ autoPanDisabled: false })
|
||||
// Clear run path when starting a new execution
|
||||
set({ lastRunPath: new Map() })
|
||||
}
|
||||
},
|
||||
setIsDebugging: (isDebugging) => set({ isDebugging }),
|
||||
setExecutor: (executor) => set({ executor }),
|
||||
setDebugContext: (debugContext) => set({ debugContext }),
|
||||
setAutoPanDisabled: (disabled) => set({ autoPanDisabled: disabled }),
|
||||
setBlockRunStatus: (blockId, status) => {
|
||||
const { lastRunPath } = get()
|
||||
const newRunPath = new Map(lastRunPath)
|
||||
newRunPath.set(blockId, status)
|
||||
set({ lastRunPath: newRunPath })
|
||||
},
|
||||
clearRunPath: () => set({ lastRunPath: new Map() }),
|
||||
reset: () => set(initialState),
|
||||
}))
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Executor } from '@/executor'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
|
||||
/**
|
||||
* Represents the execution result of a block in the last run
|
||||
*/
|
||||
export type BlockRunStatus = 'success' | 'error'
|
||||
|
||||
export interface ExecutionState {
|
||||
activeBlockIds: Set<string>
|
||||
isExecuting: boolean
|
||||
@@ -9,6 +14,11 @@ export interface ExecutionState {
|
||||
executor: Executor | null
|
||||
debugContext: ExecutionContext | null
|
||||
autoPanDisabled: boolean
|
||||
/**
|
||||
* Tracks blocks from the last execution run and their success/error status.
|
||||
* Cleared when a new run starts. Used to show run path indicators (green/red rings).
|
||||
*/
|
||||
lastRunPath: Map<string, BlockRunStatus>
|
||||
}
|
||||
|
||||
export interface ExecutionActions {
|
||||
@@ -19,6 +29,8 @@ export interface ExecutionActions {
|
||||
setExecutor: (executor: Executor | null) => void
|
||||
setDebugContext: (context: ExecutionContext | null) => void
|
||||
setAutoPanDisabled: (disabled: boolean) => void
|
||||
setBlockRunStatus: (blockId: string, status: BlockRunStatus) => void
|
||||
clearRunPath: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
@@ -30,6 +42,7 @@ export const initialState: ExecutionState = {
|
||||
executor: null,
|
||||
debugContext: null,
|
||||
autoPanDisabled: false,
|
||||
lastRunPath: new Map(),
|
||||
}
|
||||
|
||||
// Types for panning functionality
|
||||
|
||||
40
apps/sim/stores/search-modal/store.ts
Normal file
40
apps/sim/stores/search-modal/store.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
/**
|
||||
* Global state for the universal search modal.
|
||||
*
|
||||
* Centralizing this state in a store allows any component (e.g. sidebar,
|
||||
* workflow command list, keyboard shortcuts) to open or close the modal
|
||||
* without relying on DOM events or prop drilling.
|
||||
*/
|
||||
interface SearchModalState {
|
||||
/** Whether the search modal is currently open. */
|
||||
isOpen: boolean
|
||||
/**
|
||||
* Explicitly set the open state of the modal.
|
||||
*
|
||||
* @param open - New open state.
|
||||
*/
|
||||
setOpen: (open: boolean) => void
|
||||
/**
|
||||
* Convenience method to open the modal.
|
||||
*/
|
||||
open: () => void
|
||||
/**
|
||||
* Convenience method to close the modal.
|
||||
*/
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export const useSearchModalStore = create<SearchModalState>((set) => ({
|
||||
isOpen: false,
|
||||
setOpen: (open: boolean) => {
|
||||
set({ isOpen: open })
|
||||
},
|
||||
open: () => {
|
||||
set({ isOpen: true })
|
||||
},
|
||||
close: () => {
|
||||
set({ isOpen: false })
|
||||
},
|
||||
}))
|
||||
@@ -2,6 +2,7 @@ import { create } from 'zustand'
|
||||
import { devtools, persist } from 'zustand/middleware'
|
||||
import { redactApiKeys } from '@/lib/utils'
|
||||
import type { NormalizedBlockOutput } from '@/executor/types'
|
||||
import { useExecutionStore } from '@/stores/execution/store'
|
||||
import type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from './types'
|
||||
|
||||
/**
|
||||
@@ -98,17 +99,19 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
},
|
||||
|
||||
/**
|
||||
* Clears console entries for a specific workflow
|
||||
* Clears console entries for a specific workflow and clears the run path
|
||||
* @param workflowId - The workflow ID to clear entries for
|
||||
*/
|
||||
clearWorkflowConsole: (workflowId: string) => {
|
||||
set((state) => ({
|
||||
entries: state.entries.filter((entry) => entry.workflowId !== workflowId),
|
||||
}))
|
||||
// Clear run path indicators when console is cleared
|
||||
useExecutionStore.getState().clearRunPath()
|
||||
},
|
||||
|
||||
/**
|
||||
* Clears all console entries or entries for a specific workflow
|
||||
* Clears all console entries or entries for a specific workflow and clears the run path
|
||||
* @param workflowId - The workflow ID to clear entries for, or null to clear all
|
||||
* @deprecated Use clearWorkflowConsole for clearing specific workflows
|
||||
*/
|
||||
@@ -118,6 +121,8 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
? state.entries.filter((entry) => entry.workflowId !== workflowId)
|
||||
: [],
|
||||
}))
|
||||
// Clear run path indicators when console is cleared
|
||||
useExecutionStore.getState().clearRunPath()
|
||||
},
|
||||
|
||||
exportConsoleCSV: (workflowId: string) => {
|
||||
|
||||
@@ -126,17 +126,6 @@ export default {
|
||||
strokeDashoffset: '-24',
|
||||
},
|
||||
},
|
||||
'pulse-ring': {
|
||||
'0%': {
|
||||
boxShadow: '0 0 0 0 hsl(var(--border))',
|
||||
},
|
||||
'50%': {
|
||||
boxShadow: '0 0 0 8px hsl(var(--border))',
|
||||
},
|
||||
'100%': {
|
||||
boxShadow: '0 0 0 0 hsl(var(--border))',
|
||||
},
|
||||
},
|
||||
'code-shimmer': {
|
||||
'0%': {
|
||||
transform: 'translateX(-100%)',
|
||||
@@ -153,15 +142,23 @@ export default {
|
||||
opacity: '0.8',
|
||||
},
|
||||
},
|
||||
'ring-pulse': {
|
||||
'0%, 100%': {
|
||||
opacity: '1',
|
||||
},
|
||||
'50%': {
|
||||
opacity: '0.6',
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'caret-blink': 'caret-blink 1.25s ease-out infinite',
|
||||
'slide-left': 'slide-left 80s linear infinite',
|
||||
'slide-right': 'slide-right 80s linear infinite',
|
||||
'dash-animation': 'dash-animation 1.5s linear infinite',
|
||||
'pulse-ring': 'pulse-ring 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'code-shimmer': 'code-shimmer 1.5s infinite',
|
||||
'placeholder-pulse': 'placeholder-pulse 1.5s ease-in-out infinite',
|
||||
'ring-pulse': 'ring-pulse 1.5s ease-in-out infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user