feat: Dashboard workflow graph visualization with React Flow (#198)
Phase 1: API + static graph rendering Backend: - GET /workflows/:name now returns descriptor (with graph) from bundle YAML - Graceful fallback to null if YAML missing/invalid Frontend: - New workflow-graph/ component module (7 files) - React Flow + dagre auto-layout (TB direction) - Custom nodes: RoleNode (rounded rect) + TerminalNode (circle for START/END) - Custom edges: dashed for FALLBACK, solid with label for conditions - Self-loop edges supported (e.g. coder → coder) - Node states: default/completed/active with color-coded borders - Active node pulse animation - Collapsible graph panel (300px) above thread records - Dark theme using existing CSS variables Integration: - ThreadDetail extracts workflow name → fetches descriptor → computes node states → renders graph - Node states derived from ThreadRecord[] (completed/active/default)
This commit is contained in:
@@ -9,6 +9,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -104,6 +104,36 @@ export type WorkflowResultRecord = {
|
||||
|
||||
export type ThreadRecord = ThreadStartRecord | RoleRecord | WorkflowResultRecord;
|
||||
|
||||
export type WorkflowGraphEdge = {
|
||||
from: string;
|
||||
to: string;
|
||||
condition: string;
|
||||
conditionDescription: string | null;
|
||||
};
|
||||
|
||||
export type WorkflowGraph = {
|
||||
edges: readonly WorkflowGraphEdge[];
|
||||
};
|
||||
|
||||
export type WorkflowRoleDescriptor = {
|
||||
description: string;
|
||||
schema: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type WorkflowDescriptor = {
|
||||
description: string;
|
||||
roles: Record<string, WorkflowRoleDescriptor>;
|
||||
graph: WorkflowGraph;
|
||||
};
|
||||
|
||||
export type WorkflowDetail = {
|
||||
name: string;
|
||||
hash: string;
|
||||
timestamp: number;
|
||||
history: unknown[];
|
||||
descriptor: WorkflowDescriptor | null;
|
||||
};
|
||||
|
||||
// ── Gateway endpoints ───────────────────────────────────────────────
|
||||
|
||||
export function listAgents(): Promise<AgentEndpoint[]> {
|
||||
@@ -117,6 +147,15 @@ export function listWorkflows(agent: string): Promise<{ workflows: WorkflowSumma
|
||||
return fetchJson(agentBase(agent), "/workflows");
|
||||
}
|
||||
|
||||
export function getWorkflowDescriptor(
|
||||
agent: string,
|
||||
name: string,
|
||||
): Promise<WorkflowDescriptor | null> {
|
||||
return fetchJson<WorkflowDetail>(agentBase(agent), `/workflows/${encodeURIComponent(name)}`).then(
|
||||
(res) => res.descriptor,
|
||||
);
|
||||
}
|
||||
|
||||
export function listThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
|
||||
return fetchJson(agentBase(agent), "/threads");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getThread, killThread, pauseThread, resumeThread } from "../api.ts";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
getThread,
|
||||
getWorkflowDescriptor,
|
||||
killThread,
|
||||
pauseThread,
|
||||
resumeThread,
|
||||
type ThreadRecord,
|
||||
type WorkflowDescriptor,
|
||||
} from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
import { useSSE } from "../use-sse.ts";
|
||||
import { RecordCard } from "./record-card.tsx";
|
||||
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
|
||||
|
||||
type Props = {
|
||||
agent: string;
|
||||
@@ -10,6 +19,84 @@ type Props = {
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
function extractWorkflowName(records: readonly ThreadRecord[]): string | null {
|
||||
for (const r of records) {
|
||||
if (r.type === "thread-start") return r.workflow;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type GraphPanelProps = {
|
||||
descriptor: WorkflowDescriptor;
|
||||
workflowName: string | null;
|
||||
nodeStates: Map<string, NodeState>;
|
||||
};
|
||||
|
||||
function GraphPanel({ descriptor, workflowName, nodeStates }: GraphPanelProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const edgeCount = descriptor.graph.edges.length;
|
||||
return (
|
||||
<div
|
||||
className="mb-4 rounded-lg border overflow-hidden"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-xs"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<span className="font-mono">
|
||||
{open ? "▼" : "▶"} Workflow graph
|
||||
{workflowName !== null && (
|
||||
<span className="ml-2" style={{ color: "var(--color-text)" }}>
|
||||
{workflowName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{ height: 300, width: "100%" }}>
|
||||
<WorkflowGraph
|
||||
graph={descriptor.graph}
|
||||
roles={descriptor.roles}
|
||||
nodeStates={nodeStates}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeState> {
|
||||
const states = new Map<string, NodeState>();
|
||||
const roleRecords = records.filter(
|
||||
(r): r is Extract<ThreadRecord, { type: "role" }> => r.type === "role",
|
||||
);
|
||||
const hasResult = records.some((r) => r.type === "workflow-result");
|
||||
|
||||
for (let i = 0; i < roleRecords.length; i++) {
|
||||
const role = roleRecords[i].role;
|
||||
const isLast = i === roleRecords.length - 1;
|
||||
states.set(role, !hasResult && isLast ? "active" : "completed");
|
||||
}
|
||||
|
||||
if (roleRecords.length > 0) {
|
||||
states.set("__start__", "completed");
|
||||
}
|
||||
if (hasResult) {
|
||||
states.set("__end__", "completed");
|
||||
for (const [k, v] of states) {
|
||||
if (v === "active") states.set(k, "completed");
|
||||
}
|
||||
}
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
||||
const sse = useSSE(agent, threadId);
|
||||
const { status, data, error } = useFetch(() => getThread(agent, threadId), [agent, threadId]);
|
||||
@@ -23,6 +110,17 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
||||
? data.records
|
||||
: ([] as typeof sse.records);
|
||||
|
||||
const workflowName = useMemo(() => extractWorkflowName(records), [records]);
|
||||
|
||||
const descriptorFetch = useFetch<WorkflowDescriptor | null>(
|
||||
() =>
|
||||
workflowName === null ? Promise.resolve(null) : getWorkflowDescriptor(agent, workflowName),
|
||||
[agent, workflowName],
|
||||
);
|
||||
|
||||
const descriptor = descriptorFetch.status === "ok" ? descriptorFetch.data : null;
|
||||
const nodeStates = useMemo(() => computeNodeStates(records), [records]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the rendered record list grows
|
||||
useEffect(() => {
|
||||
recordsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
@@ -95,6 +193,10 @@ export function ThreadDetail({ agent, threadId, onBack }: Props) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{descriptor !== null && descriptor.graph.edges.length > 0 && (
|
||||
<GraphPanel descriptor={descriptor} workflowName={workflowName} nodeStates={nodeStates} />
|
||||
)}
|
||||
|
||||
{status === "loading" && !liveActive && records.length === 0 && (
|
||||
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
type EdgeProps,
|
||||
getBezierPath,
|
||||
getSmoothStepPath,
|
||||
} from "@xyflow/react";
|
||||
import type { ConditionEdgeData } from "./types.ts";
|
||||
|
||||
export function ConditionEdge(props: EdgeProps) {
|
||||
const {
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
data,
|
||||
markerEnd,
|
||||
} = props;
|
||||
const edgeData = data as ConditionEdgeData | undefined;
|
||||
const isFallback = edgeData?.isFallback ?? false;
|
||||
const isSelfLoop = source === target;
|
||||
|
||||
const [path, labelX, labelY] = isSelfLoop
|
||||
? getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
borderRadius: 20,
|
||||
})
|
||||
: getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
const stroke = isFallback ? "var(--color-text-muted)" : "var(--color-text)";
|
||||
const strokeDasharray = isFallback ? "5 4" : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={path}
|
||||
markerEnd={markerEnd}
|
||||
style={{ stroke, strokeWidth: 1.5, strokeDasharray }}
|
||||
/>
|
||||
{edgeData && !isFallback && edgeData.condition !== "" && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className="absolute px-1.5 py-0.5 rounded text-[10px] font-mono pointer-events-auto"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
title={edgeData.conditionDescription ?? undefined}
|
||||
>
|
||||
{edgeData.condition}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { NodeState } from "./types.ts";
|
||||
export { WorkflowGraph } from "./workflow-graph.tsx";
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Handle, type NodeProps, Position } from "@xyflow/react";
|
||||
import type { RoleNodeData } from "./types.ts";
|
||||
|
||||
function borderColor(state: RoleNodeData["state"]): string {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "var(--color-success)";
|
||||
case "active":
|
||||
return "var(--color-accent)";
|
||||
default:
|
||||
return "var(--color-border)";
|
||||
}
|
||||
}
|
||||
|
||||
function stateIcon(state: RoleNodeData["state"]): string | null {
|
||||
if (state === "completed") return "✓";
|
||||
if (state === "active") return "●";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function RoleNode(props: NodeProps) {
|
||||
const data = props.data as RoleNodeData;
|
||||
const icon = stateIcon(data.state);
|
||||
const isActive = data.state === "active";
|
||||
const handleStyle = {
|
||||
background: "var(--color-text-muted)",
|
||||
width: 6,
|
||||
height: 6,
|
||||
border: "none",
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`px-3 py-2 rounded-md border-2 text-xs font-medium ${isActive ? "wf-node-pulse" : ""}`}
|
||||
style={{
|
||||
width: 180,
|
||||
height: 60,
|
||||
background: "var(--color-surface)",
|
||||
borderColor: borderColor(data.state),
|
||||
color: "var(--color-text)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
title={data.description}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} style={handleStyle} isConnectable={false} />
|
||||
<div className="flex items-center gap-1.5 font-mono">
|
||||
{icon !== null && (
|
||||
<span
|
||||
style={{
|
||||
color: data.state === "active" ? "var(--color-accent)" : "var(--color-success)",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{data.label}</span>
|
||||
</div>
|
||||
{data.description !== "" && (
|
||||
<div className="text-[10px] truncate mt-0.5" style={{ color: "var(--color-text-muted)" }}>
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
<Handle type="source" position={Position.Bottom} style={handleStyle} isConnectable={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Handle, type NodeProps, Position } from "@xyflow/react";
|
||||
import type { TerminalNodeData } from "./types.ts";
|
||||
|
||||
function borderColor(state: TerminalNodeData["state"]): string {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "var(--color-success)";
|
||||
case "active":
|
||||
return "var(--color-accent)";
|
||||
default:
|
||||
return "var(--color-border)";
|
||||
}
|
||||
}
|
||||
|
||||
function bgColor(state: TerminalNodeData["state"]): string {
|
||||
if (state === "completed") return "var(--color-success)";
|
||||
if (state === "active") return "var(--color-accent)";
|
||||
return "var(--color-surface)";
|
||||
}
|
||||
|
||||
export function TerminalNode(props: NodeProps) {
|
||||
const data = props.data as TerminalNodeData;
|
||||
const isStart = data.kind === "start";
|
||||
const isActive = data.state === "active";
|
||||
const handleStyle = {
|
||||
background: "var(--color-text-muted)",
|
||||
width: 6,
|
||||
height: 6,
|
||||
border: "none",
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-full border-2 flex items-center justify-center text-[10px] font-bold ${isActive ? "wf-node-pulse" : ""}`}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
background: bgColor(data.state),
|
||||
borderColor: borderColor(data.state),
|
||||
color: data.state === "default" ? "var(--color-text-muted)" : "var(--color-bg)",
|
||||
}}
|
||||
title={isStart ? "Start" : "End"}
|
||||
>
|
||||
{isStart ? (
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
style={handleStyle}
|
||||
isConnectable={false}
|
||||
/>
|
||||
) : (
|
||||
<Handle type="target" position={Position.Top} style={handleStyle} isConnectable={false} />
|
||||
)}
|
||||
{isStart ? "▶" : "■"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { WorkflowGraphEdge } from "../../api.ts";
|
||||
|
||||
export type NodeState = "default" | "completed" | "active";
|
||||
|
||||
export type TerminalKind = "start" | "end";
|
||||
|
||||
export type RoleNodeData = {
|
||||
label: string;
|
||||
description: string;
|
||||
state: NodeState;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type TerminalNodeData = {
|
||||
kind: TerminalKind;
|
||||
state: NodeState;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ConditionEdgeData = {
|
||||
condition: string;
|
||||
conditionDescription: string | null;
|
||||
isFallback: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type GraphInput = {
|
||||
edges: readonly WorkflowGraphEdge[];
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import Dagre from "@dagrejs/dagre";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import { useMemo } from "react";
|
||||
import type { WorkflowGraphEdge } from "../../api.ts";
|
||||
import type { ConditionEdgeData, NodeState, RoleNodeData, TerminalNodeData } from "./types.ts";
|
||||
|
||||
const START_ID = "__start__";
|
||||
const END_ID = "__end__";
|
||||
const ROLE_NODE_WIDTH = 180;
|
||||
const ROLE_NODE_HEIGHT = 60;
|
||||
const TERMINAL_NODE_SIZE = 40;
|
||||
|
||||
type LayoutInput = {
|
||||
edges: readonly WorkflowGraphEdge[];
|
||||
roles: Record<string, { description: string }>;
|
||||
nodeStates: Map<string, NodeState>;
|
||||
};
|
||||
|
||||
type LayoutResult = {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
};
|
||||
|
||||
function collectNodeIds(edges: readonly WorkflowGraphEdge[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const e of edges) {
|
||||
ids.add(e.from);
|
||||
ids.add(e.to);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function nodeSize(id: string): { width: number; height: number } {
|
||||
if (id === START_ID || id === END_ID) {
|
||||
return { width: TERMINAL_NODE_SIZE, height: TERMINAL_NODE_SIZE };
|
||||
}
|
||||
return { width: ROLE_NODE_WIDTH, height: ROLE_NODE_HEIGHT };
|
||||
}
|
||||
|
||||
function buildRoleNode(
|
||||
id: string,
|
||||
pos: { x: number; y: number },
|
||||
roles: Record<string, { description: string }>,
|
||||
state: NodeState,
|
||||
): Node<RoleNodeData> {
|
||||
const description = roles[id]?.description ?? "";
|
||||
return {
|
||||
id,
|
||||
type: "role",
|
||||
position: pos,
|
||||
data: { label: id, description, state },
|
||||
draggable: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTerminalNode(
|
||||
id: string,
|
||||
pos: { x: number; y: number },
|
||||
state: NodeState,
|
||||
): Node<TerminalNodeData> {
|
||||
return {
|
||||
id,
|
||||
type: "terminal",
|
||||
position: pos,
|
||||
data: { kind: id === START_ID ? "start" : "end", state },
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
};
|
||||
}
|
||||
|
||||
function edgeKey(e: WorkflowGraphEdge): string {
|
||||
return `${e.from}->${e.to}::${e.condition}`;
|
||||
}
|
||||
|
||||
function buildEdge(e: WorkflowGraphEdge): Edge<ConditionEdgeData> {
|
||||
const isFallback = e.condition === "FALLBACK";
|
||||
return {
|
||||
id: edgeKey(e),
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
type: "condition",
|
||||
data: {
|
||||
condition: e.condition,
|
||||
conditionDescription: e.conditionDescription,
|
||||
isFallback,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useLayout(input: LayoutInput): LayoutResult {
|
||||
return useMemo(() => {
|
||||
const ids = collectNodeIds(input.edges);
|
||||
|
||||
const g = new Dagre.graphlib.Graph({ multigraph: true }).setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
|
||||
|
||||
for (const id of ids) {
|
||||
const size = nodeSize(id);
|
||||
g.setNode(id, { width: size.width, height: size.height });
|
||||
}
|
||||
for (const e of input.edges) {
|
||||
if (e.from === e.to) {
|
||||
continue;
|
||||
}
|
||||
g.setEdge(e.from, e.to, {}, edgeKey(e));
|
||||
}
|
||||
|
||||
Dagre.layout(g);
|
||||
|
||||
const nodes: Node[] = [];
|
||||
for (const id of ids) {
|
||||
const dagNode = g.node(id);
|
||||
const size = nodeSize(id);
|
||||
const pos = { x: dagNode.x - size.width / 2, y: dagNode.y - size.height / 2 };
|
||||
const state = input.nodeStates.get(id) ?? "default";
|
||||
if (id === START_ID || id === END_ID) {
|
||||
nodes.push(buildTerminalNode(id, pos, state));
|
||||
} else {
|
||||
nodes.push(buildRoleNode(id, pos, input.roles, state));
|
||||
}
|
||||
}
|
||||
|
||||
const edges: Edge[] = input.edges.map(buildEdge);
|
||||
|
||||
return { nodes, edges };
|
||||
}, [input.edges, input.roles, input.nodeStates]);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Background, type EdgeTypes, MarkerType, type NodeTypes, ReactFlow } from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { useMemo } from "react";
|
||||
import type { WorkflowGraph as WorkflowGraphData } from "../../api.ts";
|
||||
import { ConditionEdge } from "./condition-edge.tsx";
|
||||
import { RoleNode } from "./role-node.tsx";
|
||||
import { TerminalNode } from "./terminal-node.tsx";
|
||||
import type { NodeState } from "./types.ts";
|
||||
import { useLayout } from "./use-layout.ts";
|
||||
|
||||
type Props = {
|
||||
graph: WorkflowGraphData;
|
||||
roles: Record<string, { description: string }>;
|
||||
nodeStates: Map<string, NodeState>;
|
||||
};
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
role: RoleNode,
|
||||
terminal: TerminalNode,
|
||||
};
|
||||
|
||||
const edgeTypes: EdgeTypes = {
|
||||
condition: ConditionEdge,
|
||||
};
|
||||
|
||||
export function WorkflowGraph({ graph, roles, nodeStates }: Props) {
|
||||
const layout = useLayout({ edges: graph.edges, roles, nodeStates });
|
||||
|
||||
const styledEdges = useMemo(
|
||||
() =>
|
||||
layout.edges.map((e) => ({
|
||||
...e,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 14,
|
||||
height: 14,
|
||||
color: "var(--color-text)",
|
||||
},
|
||||
})),
|
||||
[layout.edges],
|
||||
);
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={layout.nodes}
|
||||
edges={styledEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.15 }}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
colorMode="dark"
|
||||
style={{ background: "var(--color-bg)" }}
|
||||
>
|
||||
<Background color="var(--color-border)" gap={20} size={1} />
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
@@ -19,3 +19,17 @@ body {
|
||||
color: var(--color-text);
|
||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
@keyframes wf-node-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(124, 109, 240, 0.55);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px rgba(124, 109, 240, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.wf-node-pulse {
|
||||
animation: wf-node-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user