feat(dashboard): switch graph layout from Dagre to ELK
What: Replace Dagre layout engine with ELK (Eclipse Layout Kernel) for workflow graph visualization in the dashboard. Why: Dagre lacks support for edge label placement and orthogonal edge routing, causing condition labels to overlap with nodes. ELK provides proper label positioning, better edge routing, and more compact layouts. Changes: - packages/workflow-dashboard/package.json: add elkjs dependency - packages/workflow-dashboard/src/components/workflow-graph/use-layout.ts: rewrite layout from Dagre to async ELK with layered algorithm, orthogonal routing, reduced spacing for compactness - packages/workflow-dashboard/src/components/workflow-graph/condition-edge.tsx: use ELK-computed label positions, show all labels including FALLBACK, switch to getSmoothStepPath for all edges - packages/workflow-dashboard/src/components/workflow-graph/workflow-graph.tsx: wrap in ReactFlowProvider, add fitView on async layout change, key-based remount for layout stability - packages/workflow-dashboard/src/components/workflow-list.tsx: left-right layout (info left, graph right), fix toggleExpanded React 18 batching bug, increase graph container height
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dagrejs/dagre": "^3.0.0",
|
"@dagrejs/dagre": "^3.0.0",
|
||||||
"@xyflow/react": "^12.10.2",
|
"@xyflow/react": "^12.10.2",
|
||||||
|
"elkjs": "^0.11.1",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
BaseEdge,
|
BaseEdge,
|
||||||
EdgeLabelRenderer,
|
EdgeLabelRenderer,
|
||||||
type EdgeProps,
|
type EdgeProps,
|
||||||
getBezierPath,
|
|
||||||
getSmoothStepPath,
|
getSmoothStepPath,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import type { ConditionEdgeData } from "./types.ts";
|
import type { ConditionEdgeData } from "./types.ts";
|
||||||
@@ -21,31 +20,28 @@ export function ConditionEdge(props: EdgeProps) {
|
|||||||
data,
|
data,
|
||||||
markerEnd,
|
markerEnd,
|
||||||
} = props;
|
} = props;
|
||||||
const edgeData = data as ConditionEdgeData | undefined;
|
const edgeData = data as (ConditionEdgeData & { elkLabelX?: number | null; elkLabelY?: number | null }) | undefined;
|
||||||
const isFallback = edgeData?.isFallback ?? false;
|
const isFallback = edgeData?.isFallback ?? false;
|
||||||
const isSelfLoop = source === target;
|
const isSelfLoop = source === target;
|
||||||
|
|
||||||
const [path, labelX, labelY] = isSelfLoop
|
const [path, defaultLabelX, defaultLabelY] = getSmoothStepPath({
|
||||||
? getSmoothStepPath({
|
sourceX,
|
||||||
sourceX,
|
sourceY,
|
||||||
sourceY,
|
targetX,
|
||||||
targetX,
|
targetY,
|
||||||
targetY,
|
sourcePosition,
|
||||||
sourcePosition,
|
targetPosition,
|
||||||
targetPosition,
|
borderRadius: isSelfLoop ? 20 : 8,
|
||||||
borderRadius: 20,
|
offset: isSelfLoop ? 50 : undefined,
|
||||||
})
|
});
|
||||||
: getBezierPath({
|
|
||||||
sourceX,
|
|
||||||
sourceY,
|
|
||||||
targetX,
|
|
||||||
targetY,
|
|
||||||
sourcePosition,
|
|
||||||
targetPosition,
|
|
||||||
});
|
|
||||||
|
|
||||||
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-text)";
|
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-accent)";
|
||||||
const strokeDasharray = isFallback ? "5 4" : undefined;
|
const strokeDasharray = isFallback ? "5 4" : undefined;
|
||||||
|
const label = edgeData?.condition ?? "";
|
||||||
|
|
||||||
|
// Use ELK-computed label position if available, otherwise fall back to ReactFlow default
|
||||||
|
const labelX = edgeData?.elkLabelX ?? defaultLabelX;
|
||||||
|
const labelY = edgeData?.elkLabelY ?? defaultLabelY;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -55,19 +51,21 @@ export function ConditionEdge(props: EdgeProps) {
|
|||||||
markerEnd={markerEnd}
|
markerEnd={markerEnd}
|
||||||
style={{ stroke, strokeWidth: 1.5, strokeDasharray }}
|
style={{ stroke, strokeWidth: 1.5, strokeDasharray }}
|
||||||
/>
|
/>
|
||||||
{edgeData && !isFallback && edgeData.condition !== "" && (
|
{label !== "" && (
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
<div
|
<div
|
||||||
className="absolute px-1.5 py-0.5 rounded text-[10px] font-mono pointer-events-auto"
|
className="absolute px-1.5 py-0.5 rounded text-[10px] font-mono pointer-events-auto"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||||
background: "var(--color-bg)",
|
background: "var(--color-surface)",
|
||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
color: "var(--color-text)",
|
color: isFallback ? "var(--color-text-muted)" : "var(--color-text)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
zIndex: 10,
|
||||||
}}
|
}}
|
||||||
title={edgeData.conditionDescription ?? undefined}
|
title={edgeData?.conditionDescription ?? undefined}
|
||||||
>
|
>
|
||||||
{edgeData.condition}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
</EdgeLabelRenderer>
|
</EdgeLabelRenderer>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Dagre from "@dagrejs/dagre";
|
|
||||||
import type { Edge, Node } from "@xyflow/react";
|
import type { Edge, Node } from "@xyflow/react";
|
||||||
import { useMemo } from "react";
|
import ELK, { type ElkExtendedEdge, type ElkNode } from "elkjs/lib/elk.bundled.js";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import type { WorkflowGraphEdge } from "../../api.ts";
|
import type { WorkflowGraphEdge } from "../../api.ts";
|
||||||
import type { ConditionEdgeData, NodeState, RoleNodeData, TerminalNodeData } from "./types.ts";
|
import type { ConditionEdgeData, NodeState, RoleNodeData, TerminalNodeData } from "./types.ts";
|
||||||
|
|
||||||
@@ -37,6 +37,10 @@ function nodeSize(id: string): { width: number; height: number } {
|
|||||||
return { width: ROLE_NODE_WIDTH, height: ROLE_NODE_HEIGHT };
|
return { width: ROLE_NODE_WIDTH, height: ROLE_NODE_HEIGHT };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function edgeKey(e: WorkflowGraphEdge): string {
|
||||||
|
return `${e.from}->${e.to}::${e.condition}`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildRoleNode(
|
function buildRoleNode(
|
||||||
id: string,
|
id: string,
|
||||||
pos: { x: number; y: number },
|
pos: { x: number; y: number },
|
||||||
@@ -68,14 +72,24 @@ function buildTerminalNode(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function edgeKey(e: WorkflowGraphEdge): string {
|
function buildEdge(e: WorkflowGraphEdge, elkEdgeMap: Map<string, ElkExtendedEdge>): Edge<ConditionEdgeData> {
|
||||||
return `${e.from}->${e.to}::${e.condition}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildEdge(e: WorkflowGraphEdge): Edge<ConditionEdgeData> {
|
|
||||||
const isFallback = e.condition === "FALLBACK";
|
const isFallback = e.condition === "FALLBACK";
|
||||||
|
const key = edgeKey(e);
|
||||||
|
const elkEdge = elkEdgeMap.get(key);
|
||||||
|
|
||||||
|
// Extract ELK's computed label position
|
||||||
|
let labelX: number | null = null;
|
||||||
|
let labelY: number | null = null;
|
||||||
|
if (elkEdge?.labels && elkEdge.labels.length > 0) {
|
||||||
|
const label = elkEdge.labels[0];
|
||||||
|
if (label.x !== undefined && label.y !== undefined) {
|
||||||
|
labelX = label.x + (label.width ?? 0) / 2;
|
||||||
|
labelY = label.y + (label.height ?? 0) / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: edgeKey(e),
|
id: key,
|
||||||
source: e.from,
|
source: e.from,
|
||||||
target: e.to,
|
target: e.to,
|
||||||
type: "condition",
|
type: "condition",
|
||||||
@@ -83,45 +97,111 @@ function buildEdge(e: WorkflowGraphEdge): Edge<ConditionEdgeData> {
|
|||||||
condition: e.condition,
|
condition: e.condition,
|
||||||
conditionDescription: e.conditionDescription,
|
conditionDescription: e.conditionDescription,
|
||||||
isFallback,
|
isFallback,
|
||||||
},
|
elkLabelX: labelX,
|
||||||
|
elkLabelY: labelY,
|
||||||
|
} as ConditionEdgeData,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLayout(input: LayoutInput): LayoutResult {
|
const elk = new ELK();
|
||||||
return useMemo(() => {
|
|
||||||
const ids = collectNodeIds(input.edges);
|
|
||||||
|
|
||||||
const g = new Dagre.graphlib.Graph({ multigraph: true }).setDefaultEdgeLabel(() => ({}));
|
async function computeLayout(input: LayoutInput): Promise<LayoutResult> {
|
||||||
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
|
const ids = collectNodeIds(input.edges);
|
||||||
|
|
||||||
for (const id of ids) {
|
const elkNodes: ElkNode[] = [];
|
||||||
const size = nodeSize(id);
|
for (const id of ids) {
|
||||||
g.setNode(id, { width: size.width, height: size.height });
|
const size = nodeSize(id);
|
||||||
}
|
elkNodes.push({ id, width: size.width, height: size.height });
|
||||||
for (const e of input.edges) {
|
}
|
||||||
if (e.from === e.to) {
|
|
||||||
continue;
|
const elkEdges: ElkExtendedEdge[] = input.edges
|
||||||
}
|
.filter((e) => e.from !== e.to)
|
||||||
g.setEdge(e.from, e.to, {}, edgeKey(e));
|
.map((e) => ({
|
||||||
|
id: edgeKey(e),
|
||||||
|
sources: [e.from],
|
||||||
|
targets: [e.to],
|
||||||
|
labels: e.condition !== ""
|
||||||
|
? [{ text: e.condition, width: Math.max(e.condition.length * 7 + 16, 60), height: 22 }]
|
||||||
|
: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const graph: ElkNode = {
|
||||||
|
id: "root",
|
||||||
|
layoutOptions: {
|
||||||
|
"elk.algorithm": "layered",
|
||||||
|
"elk.direction": "DOWN",
|
||||||
|
// Node spacing
|
||||||
|
"elk.spacing.nodeNode": "30",
|
||||||
|
"elk.layered.spacing.nodeNodeBetweenLayers": "50",
|
||||||
|
// Edge spacing — keep edges apart from each other and from nodes
|
||||||
|
"elk.spacing.edgeNode": "25",
|
||||||
|
"elk.spacing.edgeEdge": "15",
|
||||||
|
"elk.layered.spacing.edgeNodeBetweenLayers": "25",
|
||||||
|
"elk.layered.spacing.edgeEdgeBetweenLayers": "15",
|
||||||
|
// Edge routing
|
||||||
|
"elk.edgeRouting": "ORTHOGONAL",
|
||||||
|
"elk.layered.mergeEdges": "false",
|
||||||
|
// Node placement
|
||||||
|
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
|
||||||
|
// Edge label placement
|
||||||
|
"elk.edgeLabels.placement": "CENTER",
|
||||||
|
// Crossing minimization
|
||||||
|
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
|
||||||
|
// Compaction
|
||||||
|
"elk.layered.compaction.postCompaction.strategy": "EDGE_LENGTH",
|
||||||
|
// Cycle breaking — keep main flow top-to-bottom
|
||||||
|
"elk.layered.cycleBreaking.strategy": "DEPTH_FIRST",
|
||||||
|
},
|
||||||
|
children: elkNodes,
|
||||||
|
edges: elkEdges,
|
||||||
|
};
|
||||||
|
|
||||||
|
const laid = await elk.layout(graph);
|
||||||
|
|
||||||
|
// Build map of ELK edge results for label positions
|
||||||
|
const elkEdgeMap = new Map<string, ElkExtendedEdge>();
|
||||||
|
for (const e of laid.edges ?? []) {
|
||||||
|
elkEdgeMap.set(e.id, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes: Node[] = [];
|
||||||
|
for (const child of laid.children ?? []) {
|
||||||
|
const pos = { x: child.x ?? 0, y: child.y ?? 0 };
|
||||||
|
const state = input.nodeStates.get(child.id) ?? "default";
|
||||||
|
if (child.id === START_ID || child.id === END_ID) {
|
||||||
|
nodes.push(buildTerminalNode(child.id, pos, state));
|
||||||
|
} else {
|
||||||
|
nodes.push(buildRoleNode(child.id, pos, input.roles, state));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Dagre.layout(g);
|
const edges: Edge[] = input.edges.map((e) => buildEdge(e, elkEdgeMap));
|
||||||
|
|
||||||
const nodes: Node[] = [];
|
return { nodes, edges };
|
||||||
for (const id of ids) {
|
}
|
||||||
const dagNode = g.node(id);
|
|
||||||
const size = nodeSize(id);
|
const EMPTY_LAYOUT: LayoutResult = { nodes: [], edges: [] };
|
||||||
const pos = { x: dagNode.x - size.width / 2, y: dagNode.y - size.height / 2 };
|
|
||||||
const state = input.nodeStates.get(id) ?? "default";
|
export function useLayout(input: LayoutInput): LayoutResult {
|
||||||
if (id === START_ID || id === END_ID) {
|
const [layout, setLayout] = useState<LayoutResult>(EMPTY_LAYOUT);
|
||||||
nodes.push(buildTerminalNode(id, pos, state));
|
|
||||||
} else {
|
const edgeJson = JSON.stringify(input.edges);
|
||||||
nodes.push(buildRoleNode(id, pos, input.roles, state));
|
const roleJson = JSON.stringify(input.roles);
|
||||||
}
|
|
||||||
}
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
const edges: Edge[] = input.edges.map(buildEdge);
|
const parsed = {
|
||||||
|
edges: JSON.parse(edgeJson) as readonly WorkflowGraphEdge[],
|
||||||
return { nodes, edges };
|
roles: JSON.parse(roleJson) as Record<string, { description: string }>,
|
||||||
}, [input.edges, input.roles, input.nodeStates]);
|
nodeStates: input.nodeStates,
|
||||||
|
};
|
||||||
|
computeLayout(parsed).then((result) => {
|
||||||
|
if (!cancelled) setLayout(result);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [edgeJson, roleJson, input.nodeStates]);
|
||||||
|
|
||||||
|
return layout;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
type NodeTypes,
|
type NodeTypes,
|
||||||
type OnNodeClick,
|
type OnNodeClick,
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
|
ReactFlowProvider,
|
||||||
|
useReactFlow,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
import { useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
|
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
|
||||||
import { ConditionEdge } from "./condition-edge.tsx";
|
import { ConditionEdge } from "./condition-edge.tsx";
|
||||||
import { RoleNode } from "./role-node.tsx";
|
import { RoleNode } from "./role-node.tsx";
|
||||||
@@ -37,12 +39,30 @@ function handleRoleNodeClick(onRoleClick: (roleName: string) => void, node: Node
|
|||||||
onRoleClick(node.id);
|
onRoleClick(node.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props) {
|
function WorkflowGraphInner({ graph, roles, nodeStates, onNodeClick }: Props) {
|
||||||
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
|
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
|
||||||
|
const { fitView } = useReactFlow();
|
||||||
|
|
||||||
const onNodeClickHandler: OnNodeClick | undefined =
|
const onNodeClickHandler: OnNodeClick | undefined =
|
||||||
onNodeClick !== null ? (_e, node) => handleRoleNodeClick(onNodeClick, node) : undefined;
|
onNodeClick !== null ? (_e, node) => handleRoleNodeClick(onNodeClick, node) : undefined;
|
||||||
|
|
||||||
|
// Re-fit when layout changes (ELK is async)
|
||||||
|
// Use requestAnimationFrame + setTimeout to ensure ReactFlow has processed nodes
|
||||||
|
useEffect(() => {
|
||||||
|
if (layout.nodes.length > 0) {
|
||||||
|
let cancelled = false;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!cancelled) fitView({ padding: 0.1, duration: 300 });
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [layout.nodes, layout.edges, fitView]);
|
||||||
|
|
||||||
const styledEdges = useMemo(
|
const styledEdges = useMemo(
|
||||||
() =>
|
() =>
|
||||||
layout.edges.map((e) => ({
|
layout.edges.map((e) => ({
|
||||||
@@ -57,15 +77,25 @@ export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props)
|
|||||||
[layout.edges],
|
[layout.edges],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Generate a stable key that changes when layout changes, to force ReactFlow remount + fitView
|
||||||
|
const layoutKey = useMemo(
|
||||||
|
() => layout.nodes.map((n) => `${n.id}:${n.position.x}:${n.position.y}`).join(","),
|
||||||
|
[layout.nodes],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
|
key={layoutKey}
|
||||||
nodes={layout.nodes}
|
nodes={layout.nodes}
|
||||||
edges={styledEdges}
|
edges={styledEdges}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
onNodeClick={onNodeClickHandler}
|
onNodeClick={onNodeClickHandler}
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.15 }}
|
fitViewOptions={{ padding: 0.1, minZoom: 0.1, maxZoom: 1.5 }}
|
||||||
|
minZoom={0.1}
|
||||||
|
maxZoom={1.5}
|
||||||
|
defaultViewport={{ x: 0, y: 0, zoom: 0.5 }}
|
||||||
nodesDraggable={false}
|
nodesDraggable={false}
|
||||||
nodesConnectable={false}
|
nodesConnectable={false}
|
||||||
elementsSelectable={false}
|
elementsSelectable={false}
|
||||||
@@ -77,3 +107,11 @@ export function WorkflowGraph({ graph, roles, nodeStates, onNodeClick }: Props)
|
|||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function WorkflowGraph(props: Props) {
|
||||||
|
return (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<WorkflowGraphInner {...props} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,38 +45,45 @@ function ExpandedWorkflowBody({
|
|||||||
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
|
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
|
||||||
const vc = versionCount(detail);
|
const vc = versionCount(detail);
|
||||||
|
|
||||||
|
const hasGraph = descriptor !== null && edgeCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-3 space-y-3 border-t" style={{ borderColor: "var(--color-border)" }}>
|
<div
|
||||||
<div>
|
className="pt-3 border-t flex gap-4"
|
||||||
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
style={{ borderColor: "var(--color-border)" }}
|
||||||
{detail.name}
|
>
|
||||||
|
<div className="space-y-3 shrink-0" style={{ minWidth: 200, maxWidth: 280 }}>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||||
|
{detail.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1 mb-1" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
Hash
|
||||||
|
</p>
|
||||||
|
<code className="text-xs font-mono block" style={{ color: "var(--color-accent)" }}>
|
||||||
|
{detail.hash}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{vc} version{vc !== 1 ? "s" : ""}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs mt-1 mb-1" style={{ color: "var(--color-text-muted)" }}>
|
<div>
|
||||||
Hash
|
<p className="text-xs mb-1 font-medium" style={{ color: "var(--color-text-muted)" }}>
|
||||||
</p>
|
Description
|
||||||
<code className="text-xs font-mono block" style={{ color: "var(--color-accent)" }}>
|
</p>
|
||||||
{detail.hash}
|
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--color-text)" }}>
|
||||||
</code>
|
{descriptor !== null && descriptor.description !== ""
|
||||||
|
? descriptor.description
|
||||||
|
: descriptor !== null
|
||||||
|
? "—"
|
||||||
|
: "No descriptor available for this workflow version."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
{hasGraph ? (
|
||||||
{vc} version{vc !== 1 ? "s" : ""}
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs mb-1 font-medium" style={{ color: "var(--color-text-muted)" }}>
|
|
||||||
Description
|
|
||||||
</p>
|
|
||||||
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--color-text)" }}>
|
|
||||||
{descriptor !== null && descriptor.description !== ""
|
|
||||||
? descriptor.description
|
|
||||||
: descriptor !== null
|
|
||||||
? "—"
|
|
||||||
: "No descriptor available for this workflow version."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{descriptor !== null && edgeCount > 0 ? (
|
|
||||||
<div
|
<div
|
||||||
className="rounded-lg border overflow-hidden"
|
className="rounded-lg border overflow-hidden flex-1"
|
||||||
style={{ borderColor: "var(--color-border)", background: "var(--color-bg)" }}
|
style={{ borderColor: "var(--color-border)", background: "var(--color-bg)", minHeight: 500 }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="px-3 py-2 text-xs flex justify-between items-center"
|
className="px-3 py-2 text-xs flex justify-between items-center"
|
||||||
@@ -87,7 +94,7 @@ function ExpandedWorkflowBody({
|
|||||||
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: 300, width: "100%" }}>
|
<div style={{ height: 600, width: "100%" }}>
|
||||||
<WorkflowGraph
|
<WorkflowGraph
|
||||||
graph={descriptor.graph}
|
graph={descriptor.graph}
|
||||||
roles={descriptor.roles}
|
roles={descriptor.roles}
|
||||||
@@ -148,18 +155,17 @@ export function WorkflowList({ agent }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
function toggleExpanded(name: string) {
|
function toggleExpanded(name: string) {
|
||||||
let shouldLoad = false;
|
const wasExpanded = expanded.has(name);
|
||||||
setExpanded((prev) => {
|
setExpanded((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(name)) {
|
if (next.has(name)) {
|
||||||
next.delete(name);
|
next.delete(name);
|
||||||
return next;
|
} else {
|
||||||
|
next.add(name);
|
||||||
}
|
}
|
||||||
next.add(name);
|
|
||||||
shouldLoad = true;
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
if (shouldLoad) {
|
if (!wasExpanded) {
|
||||||
ensureDetailLoaded(name);
|
ensureDetailLoaded(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user