From 9313d2de3efa6c6203a385a77e17d22a69e04f96 Mon Sep 17 00:00:00 2001 From: Jason Robert Date: Mon, 2 Mar 2026 12:35:06 -0500 Subject: [PATCH] feat(web): polish node UI with tooltips, animations, and error styling - Add NodeTooltip component with detailed stats on hover - Add live elapsed timer for running nodes - Add status transition animations (activate, complete, fail glow) - Show inline stats (elapsed, tokens, cost) on completed nodes - Add ErrorBanner component for workflow-level failures - Improve StatusBar error styling with red tint - Track input/output tokens and error details in workflow store - Rebuild static assets --- .../src/components/detail/DetailPanel.tsx | 17 +- .../src/components/graph/AgentNode.tsx | 174 ++++++++---- .../src/components/graph/AnimatedEdge.tsx | 28 +- .../src/components/graph/GateNode.tsx | 84 ++++-- .../src/components/graph/GroupNode.tsx | 48 +++- .../src/components/graph/NodeTooltip.tsx | 135 ++++++++++ .../src/components/graph/ScriptNode.tsx | 136 ++++++++-- .../src/components/graph/WorkflowGraph.tsx | 39 ++- .../src/components/graph/graph-layout.ts | 4 +- .../src/components/layout/ErrorBanner.tsx | 88 +++++++ .../src/components/layout/StatusBar.tsx | 38 ++- src/conductor/web/frontend/src/globals.css | 73 ++++++ .../web/frontend/src/stores/workflow-store.ts | 34 ++- .../web/frontend/tsconfig.tsbuildinfo | 2 +- .../web/static/assets/index-BjXAFktV.js | 230 ---------------- .../web/static/assets/index-BmAYzbsH.js | 247 ++++++++++++++++++ .../web/static/assets/index-Ds97qr91.css | 1 + .../web/static/assets/index-JgDg1jWx.css | 1 - src/conductor/web/static/index.html | 4 +- 19 files changed, 1023 insertions(+), 360 deletions(-) create mode 100644 src/conductor/web/frontend/src/components/graph/NodeTooltip.tsx create mode 100644 src/conductor/web/frontend/src/components/layout/ErrorBanner.tsx delete mode 100644 src/conductor/web/static/assets/index-BjXAFktV.js create mode 100644 src/conductor/web/static/assets/index-BmAYzbsH.js create mode 100644 src/conductor/web/static/assets/index-Ds97qr91.css delete mode 100644 src/conductor/web/static/assets/index-JgDg1jWx.css diff --git a/src/conductor/web/frontend/src/components/detail/DetailPanel.tsx b/src/conductor/web/frontend/src/components/detail/DetailPanel.tsx index 62223f6..161b193 100644 --- a/src/conductor/web/frontend/src/components/detail/DetailPanel.tsx +++ b/src/conductor/web/frontend/src/components/detail/DetailPanel.tsx @@ -1,15 +1,25 @@ +import { useEffect, useState } from 'react'; import { X } from 'lucide-react'; import { useWorkflowStore } from '@/stores/workflow-store'; import { AgentDetail } from './AgentDetail'; import { ScriptDetail } from './ScriptDetail'; import { GateDetail } from './GateDetail'; import { GroupDetail } from './GroupDetail'; +import { cn } from '@/lib/utils'; export function DetailPanel() { const selectedNode = useWorkflowStore((s) => s.selectedNode); const nodes = useWorkflowStore((s) => s.nodes); const selectNode = useWorkflowStore((s) => s.selectNode); + // Slide-in animation state + const [mounted, setMounted] = useState(false); + useEffect(() => { + // Trigger animation on next frame after mount + requestAnimationFrame(() => setMounted(true)); + return () => setMounted(false); + }, [selectedNode]); + const node = selectedNode ? nodes[selectedNode] : null; if (!selectedNode || !node) { @@ -40,7 +50,12 @@ export function DetailPanel() { })(); return ( -
+
{/* Header */}

{selectedNode}

diff --git a/src/conductor/web/frontend/src/components/graph/AgentNode.tsx b/src/conductor/web/frontend/src/components/graph/AgentNode.tsx index 5d9d3f7..1ac3f88 100644 --- a/src/conductor/web/frontend/src/components/graph/AgentNode.tsx +++ b/src/conductor/web/frontend/src/components/graph/AgentNode.tsx @@ -1,16 +1,15 @@ -import { memo, useMemo } from 'react'; +import { memo, useEffect, useRef, useState } from 'react'; import { Handle, Position, type NodeProps } from '@xyflow/react'; import { Bot } from 'lucide-react'; -import { cn } from '@/lib/utils'; +import { cn, formatElapsed, formatTokens, formatCost } from '@/lib/utils'; import { NODE_STATUS_HEX } from '@/lib/constants'; import { useWorkflowStore } from '@/stores/workflow-store'; +import { NodeTooltip } from './NodeTooltip'; import type { GraphNodeData } from './graph-layout'; import type { NodeStatus } from '@/lib/constants'; export const AgentNode = memo(function AgentNode({ data, id, selected }: NodeProps) { const nodeData = data as unknown as GraphNodeData; - // Read status directly from the store so parallel-group child nodes update - // immediately instead of waiting for the graph-data sync useEffect. const storeStatus = useWorkflowStore((s) => s.nodes[id]?.status); const status = (storeStatus || nodeData.status || 'pending') as NodeStatus; const borderColor = NODE_STATUS_HEX[status] || NODE_STATUS_HEX.pending; @@ -18,62 +17,149 @@ export const AgentNode = memo(function AgentNode({ data, id, selected }: NodePro const elapsed = useWorkflowStore((s) => s.nodes[id]?.elapsed); const model = useWorkflowStore((s) => s.nodes[id]?.model); const tokens = useWorkflowStore((s) => s.nodes[id]?.tokens); + const inputTokens = useWorkflowStore((s) => s.nodes[id]?.input_tokens); + const outputTokens = useWorkflowStore((s) => s.nodes[id]?.output_tokens); const costUsd = useWorkflowStore((s) => s.nodes[id]?.cost_usd); const iteration = useWorkflowStore((s) => s.nodes[id]?.iteration); + const errorType = useWorkflowStore((s) => s.nodes[id]?.error_type); + const errorMessage = useWorkflowStore((s) => s.nodes[id]?.error_message); - const tooltip = useMemo(() => { - const parts: string[] = [`Status: ${status}`]; - if (iteration != null && iteration > 1) parts.push(`Iteration: ${iteration}`); - if (elapsed != null) parts.push(`Elapsed: ${formatSec(elapsed)}`); - if (model) parts.push(`Model: ${model}`); - if (tokens != null) parts.push(`Tokens: ${tokens.toLocaleString()}`); - if (costUsd != null) parts.push(`Cost: $${costUsd.toFixed(4)}`); - return parts.join('\n'); - }, [status, elapsed, model, tokens, costUsd, iteration]); + // Live elapsed timer for running nodes + const liveElapsed = useLiveElapsed(status); + + // Status transition animation + const transitionClass = useStatusTransition(status); + + // Build stats line + const statsLine = (() => { + if (status === 'failed' && errorMessage) { + const msg = errorMessage.length > 40 ? errorMessage.slice(0, 37) + '...' : errorMessage; + return { text: msg, className: 'text-red-400' }; + } + if (status === 'running') { + return { text: liveElapsed, className: 'text-[var(--text-muted)]' }; + } + if (status === 'completed') { + const parts: string[] = []; + if (elapsed != null) parts.push(formatElapsed(elapsed)); + if (tokens != null) parts.push(`${formatTokens(tokens)} tok`); + if (costUsd != null) parts.push(formatCost(costUsd)); + return { text: parts.join(' · ') || null, className: 'text-[var(--text-muted)]' }; + } + return { text: null, className: '' }; + })(); return ( <> -
- -
- {nodeData.label} - {iteration != null && iteration > 1 && ( - - ×{iteration} - - )} -
+ +
+
+
+ {nodeData.label} + {iteration != null && iteration > 1 && ( + + x{iteration} + + )} +
+ {statsLine.text && ( + + {statsLine.text} + + )} +
+
+ ); }); -function formatSec(s: number): string { - if (s < 1) return `${(s * 1000).toFixed(0)}ms`; - if (s < 60) return `${s.toFixed(1)}s`; - const m = Math.floor(s / 60); - const sec = (s % 60).toFixed(0); - return `${m}m ${sec}s`; +/** Hook that returns a live-ticking elapsed string while status is 'running'. */ +function useLiveElapsed(status: NodeStatus): string { + const [display, setDisplay] = useState('0.0s'); + const startRef = useRef(null); + const rafRef = useRef | null>(null); + + useEffect(() => { + if (status === 'running') { + startRef.current = Date.now(); + const tick = () => { + if (startRef.current != null) { + const sec = (Date.now() - startRef.current) / 1000; + setDisplay(formatElapsed(sec)); + } + }; + tick(); + rafRef.current = setInterval(tick, 1000); + return () => { + if (rafRef.current) clearInterval(rafRef.current); + }; + } else { + if (rafRef.current) clearInterval(rafRef.current); + startRef.current = null; + } + }, [status]); + + return display; +} + +/** Hook that returns a transient CSS class on status transitions. */ +function useStatusTransition(status: NodeStatus): string { + const prevStatusRef = useRef(status); + const [transitionClass, setTransitionClass] = useState(''); + + useEffect(() => { + const prev = prevStatusRef.current; + prevStatusRef.current = status; + if (prev === status) return; + + if (prev === 'pending' && status === 'running') { + setTransitionClass('node-activate'); + } else if (prev === 'running' && (status === 'completed' || status === 'failed')) { + setTransitionClass(status === 'completed' ? 'node-complete' : 'node-fail'); + } + + const timer = setTimeout(() => setTransitionClass(''), 400); + return () => clearTimeout(timer); + }, [status]); + + return transitionClass; } diff --git a/src/conductor/web/frontend/src/components/graph/AnimatedEdge.tsx b/src/conductor/web/frontend/src/components/graph/AnimatedEdge.tsx index 10bea3d..2374434 100644 --- a/src/conductor/web/frontend/src/components/graph/AnimatedEdge.tsx +++ b/src/conductor/web/frontend/src/components/graph/AnimatedEdge.tsx @@ -38,12 +38,16 @@ export const AnimatedEdge = memo(function AnimatedEdge({ const hasWhen = !!whenExpr; const isTaken = edgeHighlight?.state === 'taken'; const isHighlighted = edgeHighlight?.state === 'highlighted'; + const isFailed = edgeHighlight?.state === 'failed'; let strokeColor = 'var(--edge-color)'; let strokeWidth = 2; let strokeDasharray: string | undefined; - if (isTaken) { + if (isFailed) { + strokeColor = 'var(--failed)'; + strokeWidth = 3; + } else if (isTaken) { strokeColor = 'var(--edge-taken)'; strokeWidth = 3; } else if (isHighlighted) { @@ -51,10 +55,12 @@ export const AnimatedEdge = memo(function AnimatedEdge({ strokeWidth = 3; } - if (hasWhen && !isTaken && !isHighlighted) { + if (hasWhen && !isTaken && !isHighlighted && !isFailed) { strokeDasharray = '6 3'; } + const markerSuffix = isFailed ? 'failed' : isTaken ? 'taken' : isHighlighted ? 'active' : 'default'; + return ( <> {/* Condition label for conditional edges */} {hasWhen && ( @@ -82,9 +88,13 @@ export const AnimatedEdge = memo(function AnimatedEdge({ @@ -99,6 +109,12 @@ export const AnimatedEdge = memo(function AnimatedEdge({ )} + {/* Pulsing dot for failed edges */} + {isFailed && ( + + + + )} ); }); diff --git a/src/conductor/web/frontend/src/components/graph/GateNode.tsx b/src/conductor/web/frontend/src/components/graph/GateNode.tsx index 6926577..187bf51 100644 --- a/src/conductor/web/frontend/src/components/graph/GateNode.tsx +++ b/src/conductor/web/frontend/src/components/graph/GateNode.tsx @@ -1,54 +1,90 @@ -import { memo, useMemo } from 'react'; +import { memo, useEffect, useRef, useState } from 'react'; import { Handle, Position, type NodeProps } from '@xyflow/react'; import { ShieldCheck } from 'lucide-react'; import { cn } from '@/lib/utils'; import { NODE_STATUS_HEX } from '@/lib/constants'; import { useWorkflowStore } from '@/stores/workflow-store'; +import { NodeTooltip } from './NodeTooltip'; import type { GraphNodeData } from './graph-layout'; import type { NodeStatus } from '@/lib/constants'; export const GateNode = memo(function GateNode({ data, id, selected }: NodeProps) { const nodeData = data as unknown as GraphNodeData; - // Read status directly from the store for immediate updates const storeStatus = useWorkflowStore((s) => s.nodes[id]?.status); const status = (storeStatus || nodeData.status || 'pending') as NodeStatus; const borderColor = NODE_STATUS_HEX[status] || NODE_STATUS_HEX.pending; const selectedOption = useWorkflowStore((s) => s.nodes[id]?.selected_option); - const route = useWorkflowStore((s) => s.nodes[id]?.route); - const tooltip = useMemo(() => { - const parts: string[] = [`Status: ${status}`]; - if (selectedOption) parts.push(`Selected: ${selectedOption}`); - if (route) parts.push(`Route: ${route}`); - return parts.join('\n'); - }, [status, selectedOption, route]); + // Status transition animation + const transitionClass = useStatusTransition(status); return ( <> -
- +
+ +
+
+ {nodeData.label} + {status === 'waiting' && ( + + Awaiting input... + + )} + {status === 'completed' && selectedOption && ( + + {selectedOption} + + )} +
- {nodeData.label} -
+ ); }); + +function useStatusTransition(status: NodeStatus): string { + const prevStatusRef = useRef(status); + const [transitionClass, setTransitionClass] = useState(''); + + useEffect(() => { + const prev = prevStatusRef.current; + prevStatusRef.current = status; + if (prev === status) return; + + if (prev === 'pending' && (status === 'running' || status === 'waiting')) { + setTransitionClass('node-activate'); + } else if ((prev === 'running' || prev === 'waiting') && status === 'completed') { + setTransitionClass('node-complete'); + } + + const timer = setTimeout(() => setTransitionClass(''), 400); + return () => clearTimeout(timer); + }, [status]); + + return transitionClass; +} diff --git a/src/conductor/web/frontend/src/components/graph/GroupNode.tsx b/src/conductor/web/frontend/src/components/graph/GroupNode.tsx index ba27f05..b152f0f 100644 --- a/src/conductor/web/frontend/src/components/graph/GroupNode.tsx +++ b/src/conductor/web/frontend/src/components/graph/GroupNode.tsx @@ -1,4 +1,4 @@ -import { memo } from 'react'; +import { memo, useEffect, useRef, useState } from 'react'; import { Handle, Position, type NodeProps } from '@xyflow/react'; import { GitBranch, Repeat } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -13,15 +13,24 @@ export const GroupNode = memo(function GroupNode({ data, id, selected }: NodePro const Icon = isForEach ? Repeat : GitBranch; const progress = nodeData.progress; - // Subscribe to store status directly so we always reflect the latest state const storeStatus = useWorkflowStore((s) => s.nodes[id]?.status); const status = (storeStatus || nodeData.status || 'pending') as NodeStatus; const borderColor = NODE_STATUS_HEX[status] || NODE_STATUS_HEX.pending; + // Status transition animation + const transitionClass = useStatusTransition(status); + const progressText = progress ? `${progress.completed + progress.failed}/${progress.total}${progress.failed > 0 ? ` (${progress.failed} failed)` : ''}` : null; + const progressPct = + progress && progress.total > 0 + ? ((progress.completed + progress.failed) / progress.total) * 100 + : 0; + + const hasFailures = progress != null && progress.failed > 0; + return ( <> @@ -30,6 +39,7 @@ export const GroupNode = memo(function GroupNode({ data, id, selected }: NodePro 'flex flex-col gap-1 px-4 py-3 rounded-xl border-2 border-dashed bg-[var(--surface)]/80 min-w-[180px] transition-all duration-300', selected && 'ring-2 ring-[var(--accent)] ring-offset-1 ring-offset-[var(--bg)]', status === 'running' && 'shadow-[0_0_16px_var(--running-glow)]', + transitionClass, )} style={{ borderColor, minHeight: '100%' }} > @@ -40,8 +50,42 @@ export const GroupNode = memo(function GroupNode({ data, id, selected }: NodePro {progressText && ( {progressText} )} + {/* Inline progress bar */} + {progress && progress.total > 0 && status === 'running' && ( +
+
+
+ )}
); }); + +function useStatusTransition(status: NodeStatus): string { + const prevStatusRef = useRef(status); + const [transitionClass, setTransitionClass] = useState(''); + + useEffect(() => { + const prev = prevStatusRef.current; + prevStatusRef.current = status; + if (prev === status) return; + + if (prev === 'pending' && status === 'running') { + setTransitionClass('node-activate'); + } else if (prev === 'running' && (status === 'completed' || status === 'failed')) { + setTransitionClass(status === 'completed' ? 'node-complete' : 'node-fail'); + } + + const timer = setTimeout(() => setTransitionClass(''), 400); + return () => clearTimeout(timer); + }, [status]); + + return transitionClass; +} diff --git a/src/conductor/web/frontend/src/components/graph/NodeTooltip.tsx b/src/conductor/web/frontend/src/components/graph/NodeTooltip.tsx new file mode 100644 index 0000000..da74594 --- /dev/null +++ b/src/conductor/web/frontend/src/components/graph/NodeTooltip.tsx @@ -0,0 +1,135 @@ +import { useState, useRef, useCallback, type ReactNode } from 'react'; +import { cn, formatElapsed, formatCost, formatTokens } from '@/lib/utils'; +import { NODE_STATUS_HEX, type NodeStatus } from '@/lib/constants'; + +interface TooltipData { + status: NodeStatus; + elapsed?: number | null; + model?: string | null; + tokens?: number | null; + inputTokens?: number | null; + outputTokens?: number | null; + costUsd?: number | null; + exitCode?: number | null; + errorType?: string | null; + errorMessage?: string | null; + iteration?: number | null; + selectedOption?: string | null; +} + +interface NodeTooltipProps { + data: TooltipData; + children: ReactNode; +} + +export function NodeTooltip({ data, children }: NodeTooltipProps) { + const [visible, setVisible] = useState(false); + const timeoutRef = useRef | null>(null); + + const handleEnter = useCallback(() => { + timeoutRef.current = setTimeout(() => setVisible(true), 200); + }, []); + + const handleLeave = useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + setVisible(false); + }, []); + + const statusColor = NODE_STATUS_HEX[data.status] || NODE_STATUS_HEX.pending; + + return ( +
+ {children} + {visible && ( +
+ {/* Arrow */} +
+ +
+ {/* Status badge */} +
+ + {data.status} + {data.iteration != null && data.iteration > 1 && ( + iter {data.iteration} + )} +
+ + {/* Divider */} +
+ + {/* Details grid */} +
+ {data.elapsed != null && ( + <> + Elapsed + {formatElapsed(data.elapsed)} + + )} + {data.model && ( + <> + Model + {data.model} + + )} + {data.tokens != null && ( + <> + Tokens + + {formatTokens(data.tokens)} + {data.inputTokens != null && data.outputTokens != null && ( + + {' '}({formatTokens(data.inputTokens)}↑ {formatTokens(data.outputTokens)}↓) + + )} + + + )} + {data.costUsd != null && ( + <> + Cost + {formatCost(data.costUsd)} + + )} + {data.exitCode != null && ( + <> + Exit code + + {data.exitCode} + + + )} + {data.selectedOption && ( + <> + Selected + {data.selectedOption} + + )} +
+ + {/* Error message */} + {data.errorMessage && ( + <> +
+
+ {data.errorType && {data.errorType}: } + {data.errorMessage.slice(0, 120)}{data.errorMessage.length > 120 ? '...' : ''} +
+ + )} +
+
+ )} +
+ ); +} diff --git a/src/conductor/web/frontend/src/components/graph/ScriptNode.tsx b/src/conductor/web/frontend/src/components/graph/ScriptNode.tsx index 6ae9c1f..8fa09e9 100644 --- a/src/conductor/web/frontend/src/components/graph/ScriptNode.tsx +++ b/src/conductor/web/frontend/src/components/graph/ScriptNode.tsx @@ -1,61 +1,139 @@ -import { memo, useMemo } from 'react'; +import { memo, useEffect, useRef, useState } from 'react'; import { Handle, Position, type NodeProps } from '@xyflow/react'; import { Terminal } from 'lucide-react'; -import { cn } from '@/lib/utils'; +import { cn, formatElapsed } from '@/lib/utils'; import { NODE_STATUS_HEX } from '@/lib/constants'; import { useWorkflowStore } from '@/stores/workflow-store'; +import { NodeTooltip } from './NodeTooltip'; import type { GraphNodeData } from './graph-layout'; import type { NodeStatus } from '@/lib/constants'; export const ScriptNode = memo(function ScriptNode({ data, id, selected }: NodeProps) { const nodeData = data as unknown as GraphNodeData; - // Read status directly from the store for immediate updates const storeStatus = useWorkflowStore((s) => s.nodes[id]?.status); const status = (storeStatus || nodeData.status || 'pending') as NodeStatus; const borderColor = NODE_STATUS_HEX[status] || NODE_STATUS_HEX.pending; const elapsed = useWorkflowStore((s) => s.nodes[id]?.elapsed); const exitCode = useWorkflowStore((s) => s.nodes[id]?.exit_code); + const errorType = useWorkflowStore((s) => s.nodes[id]?.error_type); + const errorMessage = useWorkflowStore((s) => s.nodes[id]?.error_message); - const tooltip = useMemo(() => { - const parts: string[] = [`Status: ${status}`]; - if (elapsed != null) parts.push(`Elapsed: ${formatSec(elapsed)}`); - if (exitCode != null) parts.push(`Exit code: ${exitCode}`); - return parts.join('\n'); - }, [status, elapsed, exitCode]); + // Live elapsed timer + const liveElapsed = useLiveElapsed(status); + + // Status transition animation + const transitionClass = useStatusTransition(status); + + // Build stats line + const statsLine = (() => { + if (status === 'failed' && errorMessage) { + const msg = errorMessage.length > 40 ? errorMessage.slice(0, 37) + '...' : errorMessage; + return { text: msg, className: 'text-red-400' }; + } + if (status === 'running') { + return { text: liveElapsed, className: 'text-[var(--text-muted)]' }; + } + if (status === 'completed') { + const parts: string[] = []; + if (elapsed != null) parts.push(formatElapsed(elapsed)); + if (exitCode != null) parts.push(`exit ${exitCode}`); + return { text: parts.join(' · ') || null, className: 'text-[var(--text-muted)]' }; + } + return { text: null, className: '' }; + })(); return ( <> -
- +
+ +
+
+ {nodeData.label} + {statsLine.text && ( + + {statsLine.text} + + )} +
- {nodeData.label} -
+ ); }); -function formatSec(s: number): string { - if (s < 1) return `${(s * 1000).toFixed(0)}ms`; - if (s < 60) return `${s.toFixed(1)}s`; - const m = Math.floor(s / 60); - const sec = (s % 60).toFixed(0); - return `${m}m ${sec}s`; +function useLiveElapsed(status: NodeStatus): string { + const [display, setDisplay] = useState('0.0s'); + const startRef = useRef(null); + const rafRef = useRef | null>(null); + + useEffect(() => { + if (status === 'running') { + startRef.current = Date.now(); + const tick = () => { + if (startRef.current != null) { + const sec = (Date.now() - startRef.current) / 1000; + setDisplay(formatElapsed(sec)); + } + }; + tick(); + rafRef.current = setInterval(tick, 1000); + return () => { + if (rafRef.current) clearInterval(rafRef.current); + }; + } else { + if (rafRef.current) clearInterval(rafRef.current); + startRef.current = null; + } + }, [status]); + + return display; +} + +function useStatusTransition(status: NodeStatus): string { + const prevStatusRef = useRef(status); + const [transitionClass, setTransitionClass] = useState(''); + + useEffect(() => { + const prev = prevStatusRef.current; + prevStatusRef.current = status; + if (prev === status) return; + + if (prev === 'pending' && status === 'running') { + setTransitionClass('node-activate'); + } else if (prev === 'running' && (status === 'completed' || status === 'failed')) { + setTransitionClass(status === 'completed' ? 'node-complete' : 'node-fail'); + } + + const timer = setTimeout(() => setTransitionClass(''), 400); + return () => clearTimeout(timer); + }, [status]); + + return transitionClass; } diff --git a/src/conductor/web/frontend/src/components/graph/WorkflowGraph.tsx b/src/conductor/web/frontend/src/components/graph/WorkflowGraph.tsx index d935ded..3db847d 100644 --- a/src/conductor/web/frontend/src/components/graph/WorkflowGraph.tsx +++ b/src/conductor/web/frontend/src/components/graph/WorkflowGraph.tsx @@ -24,9 +24,10 @@ import { GroupNode } from './GroupNode'; import { EndNode } from './EndNode'; import { StartNode } from './StartNode'; import { AnimatedEdge } from './AnimatedEdge'; +import { WorkflowErrorBanner, WorkflowSuccessBanner } from '@/components/layout/ErrorBanner'; import { NODE_STATUS_HEX } from '@/lib/constants'; import type { NodeStatus } from '@/lib/constants'; -import { Loader2, Maximize } from 'lucide-react'; +import { Loader2, Maximize, Zap } from 'lucide-react'; const nodeTypes: NodeTypes = { agentNode: AgentNode, @@ -59,6 +60,9 @@ function EdgeMarkers() { + + + ); @@ -75,6 +79,8 @@ export function WorkflowGraph() { const selectedNode = useWorkflowStore((s) => s.selectedNode); const workflowStatus = useWorkflowStore((s) => s.workflowStatus); const entryPoint = useWorkflowStore((s) => s.entryPoint); + const wsStatus = useWorkflowStore((s) => s.wsStatus); + const workflowFailedAgent = useWorkflowStore((s) => s.workflowFailedAgent); const [flowNodes, setFlowNodes, onNodesChange] = useNodesState>([]); const [flowEdges, setFlowEdges, onEdgesChange] = useEdgesState([]); @@ -168,16 +174,43 @@ export function WorkflowGraph() { ); }, [selectedNode, setFlowNodes]); + // Auto-select failed agent when workflow fails + useEffect(() => { + if (workflowStatus === 'failed' && workflowFailedAgent) { + selectNode(workflowFailedAgent); + } + }, [workflowStatus, workflowFailedAgent, selectNode]); + const showEmptyState = workflowStatus === 'pending' && agents.length === 0; + // Better empty state message based on ws status + const emptyMessage = (() => { + switch (wsStatus) { + case 'connecting': + return 'Connecting to workflow\u2026'; + case 'reconnecting': + return 'Reconnecting\u2026'; + case 'disconnected': + return 'Connection lost. Retrying\u2026'; + default: + return 'Waiting for workflow\u2026'; + } + })(); + return (
+ {/* Workflow status banners */} + + {showEmptyState && (
- +
+ + +

- Waiting for workflow… + {emptyMessage}

)} diff --git a/src/conductor/web/frontend/src/components/graph/graph-layout.ts b/src/conductor/web/frontend/src/components/graph/graph-layout.ts index be04821..3f2b606 100644 --- a/src/conductor/web/frontend/src/components/graph/graph-layout.ts +++ b/src/conductor/web/frontend/src/components/graph/graph-layout.ts @@ -12,8 +12,8 @@ export interface GraphNodeData { [key: string]: unknown; } -const NODE_WIDTH = 180; -const NODE_HEIGHT = 44; +const NODE_WIDTH = 200; +const NODE_HEIGHT = 56; const GROUP_PADDING_X = 20; const GROUP_PADDING_TOP = 40; const GROUP_PADDING_BOTTOM = 20; diff --git a/src/conductor/web/frontend/src/components/layout/ErrorBanner.tsx b/src/conductor/web/frontend/src/components/layout/ErrorBanner.tsx new file mode 100644 index 0000000..0aed505 --- /dev/null +++ b/src/conductor/web/frontend/src/components/layout/ErrorBanner.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import { AlertTriangle, CheckCircle2, X, Eye } from 'lucide-react'; +import { useWorkflowStore } from '@/stores/workflow-store'; +import { formatCost, formatTokens, cn } from '@/lib/utils'; +import { useElapsedTimer } from '@/hooks/use-elapsed-timer'; + +export function WorkflowErrorBanner() { + const workflowStatus = useWorkflowStore((s) => s.workflowStatus); + const workflowFailure = useWorkflowStore((s) => s.workflowFailure); + const workflowFailedAgent = useWorkflowStore((s) => s.workflowFailedAgent); + const selectNode = useWorkflowStore((s) => s.selectNode); + + if (workflowStatus !== 'failed' || !workflowFailure) return null; + + const errorText = workflowFailure.message || workflowFailure.error_type || 'Unknown error'; + + return ( +
+
+ +
+ Workflow Failed + {errorText} +
+ {workflowFailedAgent && ( + + )} +
+
+ ); +} + +export function WorkflowSuccessBanner() { + const [dismissed, setDismissed] = useState(false); + const workflowStatus = useWorkflowStore((s) => s.workflowStatus); + const totalCost = useWorkflowStore((s) => s.totalCost); + const totalTokens = useWorkflowStore((s) => s.totalTokens); + const agentsCompleted = useWorkflowStore((s) => s.agentsCompleted); + const agentsTotal = useWorkflowStore((s) => s.agentsTotal); + const elapsed = useElapsedTimer(); + + if (workflowStatus !== 'completed' || dismissed) return null; + + return ( +
+
+ + Completed +
+ {elapsed} + {agentsTotal > 0 && ( + {agentsCompleted}/{agentsTotal} agents + )} + {totalTokens > 0 && ( + {formatTokens(totalTokens)} tok + )} + {totalCost > 0 && ( + {formatCost(totalCost)} + )} +
+ +
+
+ ); +} diff --git a/src/conductor/web/frontend/src/components/layout/StatusBar.tsx b/src/conductor/web/frontend/src/components/layout/StatusBar.tsx index beb4172..1e410c0 100644 --- a/src/conductor/web/frontend/src/components/layout/StatusBar.tsx +++ b/src/conductor/web/frontend/src/components/layout/StatusBar.tsx @@ -13,10 +13,12 @@ export function StatusBar() { const workflowFailure = useWorkflowStore((s) => s.workflowFailure); const elapsed = useElapsedTimer(); + const isFailed = workflowStatus === 'failed'; + const statusText = (() => { switch (workflowStatus) { case 'pending': - return 'Waiting for workflow…'; + return 'Waiting for workflow\u2026'; case 'running': return 'Running'; case 'completed': @@ -26,7 +28,12 @@ export function StatusBar() { const et = workflowFailure.error_type || ''; if (et === 'MaxIterationsError') return 'Failed: exceeded maximum iterations'; if (et === 'TimeoutError') return 'Failed: workflow timed out'; - if (workflowFailure.message) return `Failed: ${workflowFailure.message}`; + if (workflowFailure.message) { + const msg = workflowFailure.message.length > 60 + ? workflowFailure.message.slice(0, 57) + '...' + : workflowFailure.message; + return `Failed: ${msg}`; + } return `Failed: ${et}`; } } @@ -59,39 +66,50 @@ export function StatusBar() { return ( - Reconnecting… + Reconnecting\u2026 ); case 'connecting': return ( - Connecting… + Connecting\u2026 ); } })(); return ( -