From af419ccd2c25623f9dd1658551295d1ddfbd3f1e Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Fri, 14 Nov 2025 23:13:45 -0800 Subject: [PATCH] improvement: runpath edges, blocks, active --- apps/sim/app/globals.css | 26 +------ .../workflow-block/workflow-block.tsx | 22 +++++- .../workflow-edge/workflow-edge.tsx | 8 ++ .../w/[workflowId]/hooks/use-block-core.ts | 70 ++++------------- .../hooks/use-workflow-execution.ts | 10 ++- .../w/[workflowId]/utils/block-ring-utils.ts | 77 +++++++++++++++++++ .../w/[workflowId]/utils/index.ts | 1 + apps/sim/stores/execution/store.ts | 10 ++- apps/sim/stores/execution/types.ts | 14 +++- apps/sim/tailwind.config.ts | 4 +- 10 files changed, 153 insertions(+), 89 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts diff --git a/apps/sim/app/globals.css b/apps/sim/app/globals.css index e108647e9f..5da83a32b2 100644 --- a/apps/sim/app/globals.css +++ b/apps/sim/app/globals.css @@ -74,30 +74,6 @@ 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) */ @@ -135,6 +111,7 @@ --border-strong: #d1d1d1; --divider: #e5e5e5; --border-muted: #eeeeee; + --border-success: #d5d5d5; /* Brand & state */ --brand-400: #8e4cfb; @@ -250,6 +227,7 @@ --border-strong: #303030; --divider: #393939; --border-muted: #424242; + --border-success: #575757; /* Brand & state */ --brand-400: #8e4cfb; diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index c5a6f174d9..35d268c313 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -343,6 +343,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ handleClick, hasRing, ringStyles, + runPathStatus, } = useBlockCore({ blockId: id, data, isPending }) const currentBlock = currentWorkflow.getBlockById(id) @@ -750,21 +751,26 @@ export const WorkflowBlock = memo(function WorkflowBlock({ e.stopPropagation() }} > -
+
{name}
-
+
{isWorkflowSelector && childWorkflowId && ( <> {typeof childIsDeployed === 'boolean' ? ( @@ -890,6 +896,14 @@ export const WorkflowBlock = memo(function WorkflowBlock({ )} + {/* {isActive && ( +
+ + )} */}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx index 29581f4596..dcd94d4296 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx @@ -1,6 +1,7 @@ import { X } from 'lucide-react' import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from 'reactflow' import type { EdgeDiffStatus } from '@/lib/workflows/diff/types' +import { useExecutionStore } from '@/stores/execution/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff' interface WorkflowEdgeProps extends EdgeProps { @@ -43,6 +44,7 @@ export const WorkflowEdge = ({ const diffAnalysis = useWorkflowDiffStore((state) => state.diffAnalysis) const isShowingDiff = useWorkflowDiffStore((state) => state.isShowingDiff) const isDiffReady = useWorkflowDiffStore((state) => state.isDiffReady) + const lastRunEdges = useExecutionStore((state) => state.lastRunEdges) const generateEdgeIdentity = ( sourceId: string, @@ -78,10 +80,16 @@ export const WorkflowEdge = ({ const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error' + // Check if this edge was traversed during last execution + const edgeRunStatus = lastRunEdges.get(id) + const getEdgeColor = () => { if (edgeDiffStatus === 'deleted') return 'var(--text-error)' if (isErrorEdge) return 'var(--text-error)' if (edgeDiffStatus === 'new') return 'var(--brand-tertiary)' + // Show run path status if edge was traversed + if (edgeRunStatus === 'success') return 'var(--border-success)' + if (edgeRunStatus === 'error') return 'var(--text-error)' return 'var(--surface-12)' } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-core.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-core.ts index 42f45e54f4..68dffa05ed 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-core.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-core.ts @@ -1,10 +1,10 @@ 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' import type { WorkflowBlockProps } from '../components/workflow-block/types' +import { getBlockRingStyles } from '../utils/block-ring-utils' import { useCurrentWorkflow } from './use-current-workflow' interface UseBlockCoreOptions { @@ -43,60 +43,19 @@ 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 || - isPending || - isFocused || - diffStatus === 'new' || - diffStatus === 'edited' || - isDeletedBlock || - !!runPathStatus - - const ringStyles = cn( - // 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, runPathStatus]) + // Priority: active (executing) > pending > focused > deleted > diff > run path + const { hasRing, ringClassName: ringStyles } = useMemo( + () => + getBlockRingStyles({ + isActive, + isPending, + isFocused, + isDeletedBlock, + diffStatus, + runPathStatus, + }), + [isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus] + ) return { // Workflow context @@ -116,5 +75,6 @@ export function useBlockCore({ blockId, data, isPending = false }: UseBlockCoreO // Ring styling hasRing, ringStyles, + runPathStatus, } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 7aaf72b1c2..1e34383832 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -100,6 +100,7 @@ export function useWorkflowExecution() { setDebugContext, setActiveBlocks, setBlockRunStatus, + setEdgeRunStatus, } = useExecutionStore() const [executionResult, setExecutionResult] = useState(null) const executionStream = useExecutionStream() @@ -892,6 +893,12 @@ export function useWorkflowExecution() { activeBlocksSet.add(data.blockId) // Create a new Set to trigger React re-render setActiveBlocks(new Set(activeBlocksSet)) + + // Track edges that led to this block as soon as execution starts + const incomingEdges = workflowEdges.filter((edge) => edge.target === data.blockId) + incomingEdges.forEach((edge) => { + setEdgeRunStatus(edge.id, 'success') + }) }, onBlockCompleted: (data) => { @@ -904,6 +911,8 @@ export function useWorkflowExecution() { // Track successful block execution in run path setBlockRunStatus(data.blockId, 'success') + // Edges already tracked in onBlockStarted, no need to track again + // Add to console addConsole({ input: data.input || {}, @@ -938,7 +947,6 @@ export function useWorkflowExecution() { // Track failed block execution in run path setBlockRunStatus(data.blockId, 'error') - // Add error to console addConsole({ input: data.input || {}, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts new file mode 100644 index 0000000000..b3b7ab814c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts @@ -0,0 +1,77 @@ +import { cn } from '@/lib/utils' + +export type BlockDiffStatus = 'new' | 'edited' | null | undefined + +export type BlockRunPathStatus = 'success' | 'error' | undefined + +export interface BlockRingOptions { + isActive: boolean + isPending: boolean + isFocused: boolean + isDeletedBlock: boolean + diffStatus: BlockDiffStatus + runPathStatus: BlockRunPathStatus +} + +/** + * Derives visual ring visibility and class names for workflow blocks + * based on execution, focus, diff, deletion, and run-path states. + */ +export function getBlockRingStyles(options: BlockRingOptions): { + hasRing: boolean + ringClassName: string +} { + const { isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus } = options + + const hasRing = + isActive || + isPending || + isFocused || + diffStatus === 'new' || + diffStatus === 'edited' || + isDeletedBlock || + !!runPathStatus + + const ringClassName = cn( + // Executing block: pulsing success ring with prominent thickness + isActive && 'ring-[3.5px] ring-[var(--border-success)] 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(--border-success)]', + !isActive && + !isPending && + !isFocused && + !isDeletedBlock && + !diffStatus && + runPathStatus === 'error' && + 'ring-[var(--text-error)]' + ) + + return { hasRing, ringClassName } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/index.ts index 35be456e61..adf8b5f8a3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/index.ts @@ -1,2 +1,3 @@ export * from './auto-layout-utils' +export * from './block-ring-utils' export * from './workflow-execution-utils' diff --git a/apps/sim/stores/execution/store.ts b/apps/sim/stores/execution/store.ts index 8d1df0d94d..6c32355567 100644 --- a/apps/sim/stores/execution/store.ts +++ b/apps/sim/stores/execution/store.ts @@ -56,7 +56,7 @@ export const useExecutionStore = create()((se if (isExecuting) { set({ autoPanDisabled: false }) // Clear run path when starting a new execution - set({ lastRunPath: new Map() }) + set({ lastRunPath: new Map(), lastRunEdges: new Map() }) } }, setIsDebugging: (isDebugging) => set({ isDebugging }), @@ -69,6 +69,12 @@ export const useExecutionStore = create()((se newRunPath.set(blockId, status) set({ lastRunPath: newRunPath }) }, - clearRunPath: () => set({ lastRunPath: new Map() }), + setEdgeRunStatus: (edgeId, status) => { + const { lastRunEdges } = get() + const newRunEdges = new Map(lastRunEdges) + newRunEdges.set(edgeId, status) + set({ lastRunEdges: newRunEdges }) + }, + clearRunPath: () => set({ lastRunPath: new Map(), lastRunEdges: new Map() }), reset: () => set(initialState), })) diff --git a/apps/sim/stores/execution/types.ts b/apps/sim/stores/execution/types.ts index 0fc756f2cb..e374595b33 100644 --- a/apps/sim/stores/execution/types.ts +++ b/apps/sim/stores/execution/types.ts @@ -6,6 +6,11 @@ import type { ExecutionContext } from '@/executor/types' */ export type BlockRunStatus = 'success' | 'error' +/** + * Represents the execution result of an edge in the last run + */ +export type EdgeRunStatus = 'success' | 'error' + export interface ExecutionState { activeBlockIds: Set isExecuting: boolean @@ -16,9 +21,14 @@ export interface ExecutionState { 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). + * Cleared when a new run starts. Used to show run path indicators (rings on blocks). */ lastRunPath: Map + /** + * Tracks edges from the last execution run and their success/error status. + * Cleared when a new run starts. Used to show run path indicators on edges. + */ + lastRunEdges: Map } export interface ExecutionActions { @@ -30,6 +40,7 @@ export interface ExecutionActions { setDebugContext: (context: ExecutionContext | null) => void setAutoPanDisabled: (disabled: boolean) => void setBlockRunStatus: (blockId: string, status: BlockRunStatus) => void + setEdgeRunStatus: (edgeId: string, status: EdgeRunStatus) => void clearRunPath: () => void reset: () => void } @@ -43,6 +54,7 @@ export const initialState: ExecutionState = { debugContext: null, autoPanDisabled: false, lastRunPath: new Map(), + lastRunEdges: new Map(), } // Types for panning functionality diff --git a/apps/sim/tailwind.config.ts b/apps/sim/tailwind.config.ts index 0219a1266f..eea19f4440 100644 --- a/apps/sim/tailwind.config.ts +++ b/apps/sim/tailwind.config.ts @@ -144,10 +144,10 @@ export default { }, 'ring-pulse': { '0%, 100%': { - opacity: '1', + 'box-shadow': '0 0 0 1.5px var(--border-success)', }, '50%': { - opacity: '0.6', + 'box-shadow': '0 0 0 4px var(--border-success)', }, }, },