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:
Emir Karabeg
2025-11-14 16:13:23 -08:00
committed by GitHub
parent 6f29e2413c
commit 96958104c0
16 changed files with 756 additions and 332 deletions

View File

@@ -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' />

View File

@@ -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)
*/

View File

@@ -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}

View File

@@ -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}

View File

@@ -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

View File

@@ -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 || {},

View File

@@ -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

View File

@@ -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>

View File

@@ -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',
}}
/>
)

View File

@@ -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()
},
},
])

View File

@@ -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:

View File

@@ -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),
}))

View File

@@ -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

View 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 })
},
}))

View File

@@ -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) => {

View File

@@ -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',
},
},
},