Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/conductor/web/frontend/src/components/detail/DetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -40,7 +50,12 @@ export function DetailPanel() {
})();

return (
<div className="h-full flex flex-col bg-[var(--surface)]">
<div
className={cn(
'h-full flex flex-col bg-[var(--surface)] transition-all duration-150 ease-out',
mounted ? 'translate-x-0 opacity-100' : 'translate-x-4 opacity-0',
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)] flex-shrink-0">
<h2 className="text-sm font-semibold text-[var(--text)] truncate">{selectedNode}</h2>
Expand Down
174 changes: 130 additions & 44 deletions src/conductor/web/frontend/src/components/graph/AgentNode.tsx
Original file line number Diff line number Diff line change
@@ -1,79 +1,165 @@
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;

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 (
<>
<Handle type="target" position={Position.Top} className="!bg-[var(--border)] !border-none !w-2 !h-2" />
<div
title={tooltip}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg border-2 bg-[var(--node-bg)] min-w-[140px] max-w-[200px] transition-all duration-300',
selected && 'ring-2 ring-[var(--accent)] ring-offset-1 ring-offset-[var(--bg)]',
status === 'running' && 'shadow-[0_0_12px_var(--running-glow)]',
)}
style={{ borderColor }}
<NodeTooltip
data={{
status,
elapsed,
model,
tokens,
inputTokens,
outputTokens,
costUsd,
iteration,
errorType,
errorMessage,
}}
>
<div
className={cn(
'flex items-center justify-center w-6 h-6 rounded-md flex-shrink-0',
status === 'running' && 'animate-pulse',
'flex items-center gap-2 px-3 py-1.5 rounded-lg border-2 bg-[var(--node-bg)] min-w-[140px] max-w-[220px] transition-all duration-300',
selected && 'ring-2 ring-[var(--accent)] ring-offset-1 ring-offset-[var(--bg)]',
status === 'running' && 'shadow-[0_0_12px_var(--running-glow)]',
transitionClass,
)}
style={{ backgroundColor: `${borderColor}20` }}
style={{ borderColor }}
>
<Bot className="w-3.5 h-3.5" style={{ color: borderColor }} />
</div>
<span className="text-xs font-medium text-[var(--text)] truncate">{nodeData.label}</span>
{iteration != null && iteration > 1 && (
<span
className="ml-auto flex-shrink-0 inline-flex items-center justify-center px-1.5 py-0.5 rounded-full text-[9px] font-bold leading-none"
style={{
backgroundColor: `${borderColor}25`,
color: borderColor,
}}
<div
className={cn(
'flex items-center justify-center w-6 h-6 rounded-md flex-shrink-0',
status === 'running' && 'animate-pulse',
)}
style={{ backgroundColor: `${borderColor}20` }}
>
×{iteration}
</span>
)}
</div>
<Bot className="w-3.5 h-3.5" style={{ color: borderColor }} />
</div>
<div className="flex flex-col min-w-0 flex-1">
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-[var(--text)] truncate">{nodeData.label}</span>
{iteration != null && iteration > 1 && (
<span
className="flex-shrink-0 inline-flex items-center justify-center px-1.5 py-0.5 rounded-full text-[9px] font-bold leading-none"
style={{
backgroundColor: `${borderColor}25`,
color: borderColor,
}}
>
x{iteration}
</span>
)}
</div>
{statsLine.text && (
<span className={cn('text-[10px] truncate leading-tight', statsLine.className)}>
{statsLine.text}
</span>
)}
</div>
</div>
</NodeTooltip>
<Handle type="source" position={Position.Bottom} className="!bg-[var(--border)] !border-none !w-2 !h-2" />
</>
);
});

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<number | null>(null);
const rafRef = useRef<ReturnType<typeof setInterval> | 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<NodeStatus>(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;
}
28 changes: 22 additions & 6 deletions src/conductor/web/frontend/src/components/graph/AnimatedEdge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,23 +38,29 @@ 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) {
strokeColor = 'var(--edge-active)';
strokeWidth = 3;
}

if (hasWhen && !isTaken && !isHighlighted) {
if (hasWhen && !isTaken && !isHighlighted && !isFailed) {
strokeDasharray = '6 3';
}

const markerSuffix = isFailed ? 'failed' : isTaken ? 'taken' : isHighlighted ? 'active' : 'default';

return (
<>
<BaseEdge
Expand All @@ -66,7 +72,7 @@ export const AnimatedEdge = memo(function AnimatedEdge({
strokeDasharray,
transition: 'stroke 0.3s ease, stroke-width 0.3s ease',
}}
markerEnd={`url(#arrow-${isTaken ? 'taken' : isHighlighted ? 'active' : 'default'})`}
markerEnd={`url(#arrow-${markerSuffix})`}
/>
{/* Condition label for conditional edges */}
{hasWhen && (
Expand All @@ -82,9 +88,13 @@ export const AnimatedEdge = memo(function AnimatedEdge({
<span
className="inline-block px-1.5 py-0.5 rounded-full text-[9px] font-mono leading-tight max-w-[140px] truncate"
style={{
backgroundColor: isTaken ? 'var(--edge-taken)' : 'var(--surface)',
color: isTaken ? 'var(--bg)' : 'var(--text-muted)',
border: `1px solid ${isTaken ? 'var(--edge-taken)' : 'var(--border)'}`,
backgroundColor: isFailed
? 'var(--failed)'
: isTaken
? 'var(--edge-taken)'
: 'var(--surface)',
color: isFailed || isTaken ? 'var(--bg)' : 'var(--text-muted)',
border: `1px solid ${isFailed ? 'var(--failed)' : isTaken ? 'var(--edge-taken)' : 'var(--border)'}`,
}}
title={whenExpr}
>
Expand All @@ -99,6 +109,12 @@ export const AnimatedEdge = memo(function AnimatedEdge({
<animateMotion dur="1s" repeatCount="indefinite" path={edgePath} />
</circle>
)}
{/* Pulsing dot for failed edges */}
{isFailed && (
<circle r="3" fill="var(--failed)" opacity="0.8">
<animateMotion dur="1.5s" repeatCount="indefinite" path={edgePath} />
</circle>
)}
</>
);
});
Loading
Loading