feat: render factual horizontal presentation graph

This commit is contained in:
lda
2026-07-12 13:32:57 +07:00 Verified
parent 24d53276b6
commit 92ba1dcf70
11 changed files with 304 additions and 323 deletions
@@ -95,6 +95,19 @@ describe("buildWorkflowGraph", () => {
expect(okEdge?.label).toBe("ok");
});
it("accepts a presentation label override without changing node refs", () => {
const model = buildWorkflowGraph(samplePlan, {
label: (node) => node.id === "open" ? "Open page" : undefined,
});
const openNode = model.nodes.find((node) => node.id === "open");
expect(openNode?.data.label).toBe("Open page");
expect(openNode?.data.nodeRef).toBe("local.browser_click.open_click_page");
expect(model.nodes.find((node) => node.id === "wait")?.data.label).toBe(
"wait_for_click",
);
});
it("keeps the default layout top-to-bottom", () => {
const model = buildWorkflowGraph(samplePlan);
const open = model.nodes.find((node) => node.id === "open");
+9 -3
View File
@@ -42,9 +42,10 @@ export type WorkflowGraphLayoutOptions = {
readonly nodeHeight?: number;
readonly nodesep?: number;
readonly ranksep?: number;
readonly label?: (node: Readonly<Record<string, unknown>>) => string | undefined;
};
const DEFAULT_LAYOUT: Required<WorkflowGraphLayoutOptions> = {
const DEFAULT_LAYOUT: Required<Omit<WorkflowGraphLayoutOptions, "label">> = {
direction: "TB",
nodeWidth: 180,
nodeHeight: 60,
@@ -73,7 +74,12 @@ const mapNodeKind = (type: string): WorkflowGraphNodeKind => {
}
};
const buildLabel = (node: Record<string, unknown>): string => {
const buildLabel = (
node: Record<string, unknown>,
labelOverride?: WorkflowGraphLayoutOptions["label"],
): string => {
const overriddenLabel = labelOverride?.(node);
if (overriddenLabel) return overriddenLabel;
const type = node.type as string;
if (type === "end") return (node.outcome as string) ?? "End";
if (type === "condition") return "Condition";
@@ -137,7 +143,7 @@ export const buildWorkflowGraph = (
data: {
nodeId: id,
kind: mapNodeKind(node.type as string),
label: buildLabel(node),
label: buildLabel(node, layout.label),
nodeRef: (node.node as string | null) ?? null,
raw: node as Record<string, unknown>,
},
@@ -177,8 +177,7 @@ describe("DemoWorkflowScene", () => {
it("passes run proof into full graph beats", () => {
const { unmount } = renderBeat("graph");
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("run_recorded_lda_report");
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("10 plan nodes");
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("3 trace frames");
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("JSON-RPC evidence");
unmount();
renderBeat("output", "resume-output-evidence");
@@ -85,8 +85,6 @@ export const DemoWorkflowScene = ({
const runProof = {
runId: runStart?.resultingIds.runId ?? null,
planLabel: "10 plan nodes",
traceLabel: "3 trace frames",
evidenceLabel: "JSON-RPC evidence",
};
@@ -0,0 +1,14 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { NodeSpotlight } from "./NodeSpotlight.js";
afterEach(() => cleanup());
describe("NodeSpotlight", () => {
it("uses a readable fallback for raw interrupt nodes without labels", () => {
render(<NodeSpotlight nodeId="review_issues" close={vi.fn()} />);
expect(screen.getByRole("dialog", { name: "Review issues" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Review issues" })).toBeInTheDocument();
});
});
@@ -20,6 +20,11 @@ export const NodeSpotlight = ({ nodeId, close }: NodeSpotlightProps) => {
const dialogRef = useRef<HTMLElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const node = presentationNodes.find((candidate) => candidate.id === nodeId);
const label = node && "label" in node
? node.label
: node?.id === "review_issues"
? "Review issues"
: node?.id ?? nodeId;
useEffect(() => {
const previouslyFocused = document.activeElement instanceof HTMLElement
@@ -54,12 +59,12 @@ export const NodeSpotlight = ({ nodeId, close }: NodeSpotlightProps) => {
className="node-spotlight"
role="dialog"
aria-modal="true"
aria-label={node.label}
aria-label={label}
onKeyDown={handleKeyDown}
>
<button type="button" ref={closeButtonRef} onClick={close}>Close</button>
<p>Workflow node</p>
<h2>{node.label}</h2>
<h2>{label}</h2>
<p>{nodeDescription(node.id)}</p>
</aside>
);
@@ -1,7 +1,10 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WorkflowGraphStage } from "./WorkflowGraphStage.js";
import { presentationEdges, presentationNodes } from "./workflow-graph-data.js";
import {
presentationWorkflowPlan,
presentationWorkflowNodeIds,
} from "./workflow-graph-data.js";
afterEach(() => cleanup());
@@ -13,7 +16,7 @@ const graphNodeByLabel = (label: RegExp): HTMLElement => {
};
describe("WorkflowGraphStage", () => {
it("renders curated workflow nodes and allows node selection", async () => {
it("renders the factual workflow nodes and allows node selection", async () => {
const selectNode = vi.fn();
render(
<WorkflowGraphStage
@@ -23,11 +26,11 @@ describe("WorkflowGraphStage", () => {
/>,
);
fireEvent.click(graphNodeByLabel(/issue review/i));
fireEvent.click(graphNodeByLabel(/review issues/i));
expect(selectNode).toHaveBeenCalledWith("review_issues");
});
it("renders the prepared report workflow plan nodes", () => {
it("renders the ten-node prepared report workflow plan", () => {
render(
<WorkflowGraphStage
execution={{ completedNodeIds: [], currentNodeId: "read_docs" }}
@@ -37,21 +40,33 @@ describe("WorkflowGraphStage", () => {
);
const graph = screen.getByRole("group", { name: /workflow graph/i });
expect(graph).toHaveTextContent("Read docs");
expect(graph).toHaveTextContent("Reset board");
expect(graph).toHaveTextContent("Read documents");
expect(graph).toHaveTextContent("Reset issue board");
expect(graph).toHaveTextContent("Analyze");
expect(graph).toHaveTextContent("Build report");
expect(graph).toHaveTextContent("Draft issues");
expect(graph).toHaveTextContent("Issue review");
expect(graph).toHaveTextContent("Review issues");
expect(graph).toHaveTextContent("Create issues");
expect(graph).toHaveTextContent("Finalise");
expect(graph).toHaveTextContent("Revision requested");
expect(graph).toHaveTextContent("Completed");
expect(graph).not.toHaveTextContent("Cancelled");
expect(graph).toHaveTextContent("completed");
expect(graph).not.toHaveTextContent("end_cancelled");
expect(document.querySelectorAll(".workflow-graph-stage__node")).toHaveLength(10);
expect(presentationWorkflowNodeIds).toEqual([
"reset_board",
"read_docs",
"analyze",
"build_report",
"draft_issues",
"review_issues",
"create_issues",
"finalise",
"revision_requested",
"end_completed",
]);
});
it("labels graph proof as plan nodes and trace frames separately", () => {
it("does not render graph proof count chips", () => {
render(
<WorkflowGraphStage
execution={{ completedNodeIds: ["read_docs"], currentNodeId: "analyze" }}
@@ -59,19 +74,16 @@ describe("WorkflowGraphStage", () => {
selectNode={vi.fn()}
proof={{
runId: "run_recorded_lda_report",
planLabel: "10 plan nodes",
traceLabel: "3 trace frames",
evidenceLabel: "JSON-RPC evidence",
}}
/>,
);
const proof = screen.getByLabelText("workflow graph proof");
expect(proof).toHaveTextContent("10 plan nodes");
expect(proof).toHaveTextContent("3 trace frames");
expect(screen.queryByText("10 plan nodes")).not.toBeInTheDocument();
expect(screen.queryByText("3 trace frames")).not.toBeInTheDocument();
});
it("distinguishes completed, current interrupt, and future nodes semantically", () => {
it("does not render current-state markers or a state legend", () => {
render(
<WorkflowGraphStage
execution={{
@@ -83,34 +95,29 @@ describe("WorkflowGraphStage", () => {
/>,
);
const readDocs = graphNodeByLabel(/read docs/i);
const reviewIssues = graphNodeByLabel(/issue review/i);
const revisionReq = graphNodeByLabel(/revision requested/i);
expect(readDocs).toHaveAttribute("data-execution-state", "completed");
expect(reviewIssues).toHaveAttribute("data-execution-state", "current");
expect(reviewIssues).toHaveAttribute("data-current-interrupt", "true");
expect(reviewIssues).toHaveTextContent("Current interrupt");
expect(revisionReq).toHaveAttribute("data-execution-state", "future");
expect(screen.queryByText("Current")).not.toBeInTheDocument();
expect(screen.queryByText("Current interrupt")).not.toBeInTheDocument();
expect(screen.queryByText("Queued")).not.toBeInTheDocument();
expect(screen.queryByText("Completed")).not.toBeInTheDocument();
expect(screen.queryByText("Human boundary")).not.toBeInTheDocument();
});
it("renders connectors between nodes", () => {
expect(presentationEdges).toHaveLength(9);
expect(presentationEdges).toContainEqual({
from: "read_docs",
to: "reset_board",
fromHandle: "right",
toHandle: "left",
});
expect(presentationEdges).toContainEqual({
from: "review_issues",
to: "revision_requested",
fromHandle: "bottom",
toHandle: "top",
});
it("keeps raw plan facts and canonical labeled edge order", () => {
expect(presentationWorkflowPlan.edges.map((edge) => `${edge.from}:${edge.outcome}:${edge.to}`)).toEqual([
"reset_board:ok:read_docs",
"read_docs:ok:analyze",
"analyze:ok:build_report",
"build_report:ok:draft_issues",
"draft_issues:ok:review_issues",
"review_issues:submitted:create_issues",
"create_issues:ok:finalise",
"finalise:completed:end_completed",
"review_issues:cancelled:revision_requested",
]);
expect(presentationWorkflowPlan.nodes.every((node) => !("x" in node) && !("y" in node))).toBe(true);
});
it("uses flow coordinates rather than viewport percentages", () => {
it("uses a horizontal graph with visible controls", () => {
render(
<WorkflowGraphStage
execution={{ completedNodeIds: ["read_docs"], currentNodeId: "reset_board" }}
@@ -119,25 +126,23 @@ describe("WorkflowGraphStage", () => {
/>,
);
for (const node of presentationNodes) {
expect(Number.isFinite(node.x)).toBe(true);
expect(Number.isFinite(node.y)).toBe(true);
}
expect(screen.getByRole("group", { name: "workflow graph" })).toHaveAttribute("data-graph-direction", "horizontal");
expect(screen.getByRole("button", { name: /zoom in/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /zoom out/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /fit view/i })).toBeInTheDocument();
});
it("renders compact run proof inside the graph", () => {
it("renders the run identity without plan or trace count chips", () => {
render(
<WorkflowGraphStage
execution={{ completedNodeIds: ["read_docs"], currentNodeId: "reset_board" }}
selectedNodeId={null}
selectNode={vi.fn()}
proof={{ runId: "run_recorded_lda_report", planLabel: "10 plan nodes", traceLabel: "3 trace frames", evidenceLabel: "JSON-RPC captured" }}
proof={{ runId: "run_recorded_lda_report", evidenceLabel: "JSON-RPC captured" }}
/>,
);
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("run_recorded_lda_report");
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("10 plan nodes");
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("3 trace frames");
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("JSON-RPC captured");
});
@@ -148,7 +153,7 @@ describe("WorkflowGraphStage", () => {
selectedNodeId={null}
selectNode={vi.fn()}
variant="compact"
proof={{ runId: "run_recorded_lda_report", planLabel: "10 plan nodes", traceLabel: "3 trace frames", evidenceLabel: "JSON-RPC evidence" }}
proof={{ runId: "run_recorded_lda_report", evidenceLabel: "JSON-RPC evidence" }}
/>,
);
@@ -174,12 +179,11 @@ describe("WorkflowGraphStage", () => {
execution={{ completedNodeIds: [], currentNodeId: null }}
selectedNodeId={null}
selectNode={vi.fn()}
proof={{ runId: null, planLabel: "10 plan nodes", traceLabel: "trace label", evidenceLabel: "evidence label" }}
proof={{ runId: null, evidenceLabel: "evidence label" }}
/>,
);
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("run unavailable");
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("trace label");
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("evidence label");
});
});
@@ -12,23 +12,12 @@ import {
type NodeTypes,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { buildWorkflowGraph, type WorkflowGraphNodeData } from "../graph/graph-model.js";
import type { GraphExecutionPresentation } from "./demo-workflow-model.js";
import { presentationEdges, presentationNodes, type PresentationHandle, type PresentationNode } from "./workflow-graph-data.js";
type NodeExecutionState = "completed" | "current" | "future";
type PresentationNodeData = {
readonly node: PresentationNode;
readonly executionState: NodeExecutionState;
readonly currentInterrupt: boolean;
readonly selected: boolean;
readonly selectNode: (nodeId: string) => void;
};
import { presentationWorkflowPlan } from "./workflow-graph-data.js";
export type WorkflowGraphProof = {
readonly runId: string | null;
readonly planLabel: string;
readonly traceLabel: string;
readonly evidenceLabel: string;
};
@@ -40,82 +29,38 @@ type WorkflowGraphStageProps = {
readonly variant?: "full" | "compact";
};
const executionStateForNode = (
nodeId: string,
execution: GraphExecutionPresentation,
): NodeExecutionState => {
if (execution.currentNodeId === nodeId) return "current";
if (execution.completedNodeIds.includes(nodeId)) return "completed";
return "future";
type PresentationNodeData = WorkflowGraphNodeData & {
readonly selected: boolean;
readonly selectNode: (nodeId: string) => void;
};
const requireNode = (nodeId: string): PresentationNode => {
const node = presentationNodes.find((candidate) => candidate.id === nodeId);
if (!node) throw new Error(`presentation edge references unknown node ${nodeId}`);
return node;
};
const edgeActive = (edge: { source: string; target: string }, execution: GraphExecutionPresentation): boolean =>
execution.completedNodeIds.includes(edge.source)
&& (execution.completedNodeIds.includes(edge.target) || execution.currentNodeId === edge.target);
const stateLabelFor = (
executionState: NodeExecutionState,
currentInterrupt: boolean,
): string => {
if (currentInterrupt) return "Current interrupt";
if (executionState === "current") return "Current";
if (executionState === "completed") return "Completed";
return "Queued";
};
const edgeActive = (
fromId: string,
toId: string,
execution: GraphExecutionPresentation,
): boolean =>
execution.completedNodeIds.includes(fromId)
&& (execution.completedNodeIds.includes(toId) || execution.currentNodeId === toId);
const handlePositionFor = (handle: PresentationHandle): Position => {
if (handle === "left") return Position.Left;
if (handle === "right") return Position.Right;
if (handle === "top") return Position.Top;
return Position.Bottom;
};
const PresentationFlowNode = ({ data }: NodeProps<Node<PresentationNodeData>>) => {
const { node, executionState, currentInterrupt, selected, selectNode } = data;
const stateLabel = stateLabelFor(executionState, currentInterrupt);
return (
const PresentationFlowNode = ({ data }: NodeProps<Node<PresentationNodeData>>) => (
<>
{(["left", "top"] as const).map((handle) => (
<Handle key={`target-${handle}`} id={handle} type="target" position={handlePositionFor(handle)} />
))}
<Handle type="target" position={Position.Left} />
<button
type="button"
className="workflow-graph-stage__node"
data-kind={node.kind}
data-execution-state={executionState}
data-current-interrupt={currentInterrupt}
data-selected={selected}
aria-pressed={selected}
aria-label={`${node.label}, ${stateLabel}`}
data-kind={data.kind}
data-selected={data.selected}
aria-pressed={data.selected}
aria-label={data.label}
onClick={(event) => {
event.stopPropagation();
selectNode(node.id);
data.selectNode(data.nodeId);
}}
>
<span className="workflow-graph-stage__node-state">{stateLabel}</span>
<strong>{node.label}</strong>
<small>{node.detail}</small>
<strong>{data.label}</strong>
{data.nodeRef && <small>{data.nodeRef}</small>}
</button>
{(["right", "bottom"] as const).map((handle) => (
<Handle key={`source-${handle}`} id={handle} type="source" position={handlePositionFor(handle)} />
))}
<Handle type="source" position={Position.Right} />
</>
);
};
const nodeTypes: NodeTypes = {
presentation: PresentationFlowNode,
};
const nodeTypes: NodeTypes = { presentation: PresentationFlowNode };
const WorkflowGraphStageInner = ({
execution,
@@ -124,71 +69,71 @@ const WorkflowGraphStageInner = ({
proof,
variant = "full",
}: WorkflowGraphStageProps) => {
const model = useMemo(
() => buildWorkflowGraph(presentationWorkflowPlan, {
direction: "LR",
nodeWidth: 190,
nodeHeight: 72,
nodesep: 55,
ranksep: 100,
label: (node) => {
if (typeof node.label === "string") return node.label;
if (node.id === "review_issues") return "Review issues";
return undefined;
},
}),
[],
);
const nodes: Node<PresentationNodeData>[] = useMemo(
() =>
presentationNodes.map((node) => {
const executionState = executionStateForNode(node.id, execution);
const currentInterrupt = executionState === "current" && node.kind === "interrupt";
return {
() => model.nodes.map((node) => ({
id: node.id,
type: "presentation",
position: { x: node.x, y: node.y },
position: node.position,
draggable: false,
selectable: false,
data: {
node,
executionState,
currentInterrupt,
...node.data,
selected: selectedNodeId === node.id,
selectNode,
},
};
}),
[execution, selectedNodeId, selectNode],
})),
[model.nodes, selectedNodeId, selectNode],
);
const edges: Edge[] = useMemo(
() =>
presentationEdges.map((transition, index) => {
requireNode(transition.from);
requireNode(transition.to);
const active = edgeActive(transition.from, transition.to, execution);
() => model.edges.map((edge) => {
const active = edgeActive(edge, execution);
return {
id: `presentation-edge-${index}-${transition.from}-${transition.to}`,
source: transition.from,
target: transition.to,
sourceHandle: transition.fromHandle ?? "right",
targetHandle: transition.toHandle ?? "left",
type: "smoothstep",
id: edge.id,
source: edge.source,
target: edge.target,
label: edge.label,
type: "default",
animated: active,
focusable: false,
selectable: false,
data: { active },
className: active
? "workflow-graph-stage__edge workflow-graph-stage__edge--active"
: "workflow-graph-stage__edge",
};
}),
[execution],
[model.edges, execution],
);
const handleNodeClick = useCallback((_: MouseEvent, node: Node) => selectNode(node.id), [selectNode]);
return (
<div className="workflow-graph-stage" role="group" aria-label="workflow graph" data-graph-variant={variant}>
<div className="workflow-graph-stage__legend" aria-hidden="true">
<span><i data-state="completed" />Completed</span>
<span><i data-state="current" />Current</span>
<span><i data-state="interrupt" />Human boundary</span>
</div>
{/* Compact mode is used beside interrupt contracts; proof chips would
compete with the contract and outcome panel in that narrow layout. */}
<div
className="workflow-graph-stage"
role="group"
aria-label="workflow graph"
data-graph-variant={variant}
data-graph-direction="horizontal"
>
{variant === "full" && proof && (
<div className="workflow-graph-stage__proof" aria-label="workflow graph proof">
<span><b>Run</b><code>{proof.runId ?? "run unavailable"}</code></span>
<span><b>Plan</b>{proof.planLabel}</span>
<span><b>Trace</b>{proof.traceLabel}</span>
<span><b>Evidence</b>{proof.evidenceLabel}</span>
</div>
)}
@@ -198,13 +143,12 @@ const WorkflowGraphStageInner = ({
nodeTypes={nodeTypes}
onNodeClick={handleNodeClick}
fitView
fitViewOptions={{ padding: variant === "compact" ? 0.04 : 0.08 }}
fitViewOptions={{ padding: 0.12, minZoom: 0.45, maxZoom: 1 }}
minZoom={0.25}
maxZoom={1.5}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
edgesFocusable={false}
panOnDrag
zoomOnScroll
zoomOnPinch
@@ -791,97 +791,76 @@
.workflow-graph-stage {
position: relative;
min-height: 12rem;
min-height: 24rem;
flex: 1;
border: 1px solid var(--stage-line);
background: var(--stage-canvas);
border: 1px solid oklch(0.3 0.035 250);
border-radius: 0.7rem;
background: oklch(0.13 0.025 250);
overflow: hidden;
}
.workflow-graph-stage__connectors {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.workflow-graph-stage__connector {
stroke: var(--stage-line);
stroke-width: 2;
stroke-dasharray: 4 4;
}
.workflow-graph-stage__connector--active {
stroke: var(--accent-cyan);
stroke-dasharray: none;
}
.workflow-graph-stage__arrowhead {
fill: var(--stage-line);
}
.workflow-graph-stage__arrowhead--active {
fill: var(--accent-cyan);
}
.workflow-graph-stage__node {
position: absolute;
transform: translate(-50%, -50%);
border: 1px solid var(--stage-line);
background: var(--stage-surface);
display: grid;
width: 100%;
min-height: 4.5rem;
gap: 0.35rem;
padding: 0.7rem 0.8rem;
border: 1px solid oklch(0.45 0.04 250);
border-radius: 0.45rem;
background: oklch(0.19 0.03 250);
color: var(--text-primary);
padding: 0.5rem 0.7rem;
border-radius: 0.6rem;
font-size: 0.8rem;
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
}
.workflow-graph-stage__node--completed {
background: oklch(0.18 0.02 250);
border-color: oklch(0.44 0.04 250);
}
.workflow-graph-stage__node--current {
border-color: var(--accent-cyan);
box-shadow: 0 0 0 2px oklch(0.72 0.17 195 / 0.3);
}
.workflow-graph-stage__node--interrupt {
border-color: var(--accent-amber);
box-shadow: 0 0 0 2px oklch(0.76 0.18 70 / 0.3);
}
.workflow-graph-stage__node--future {
opacity: 0.6;
}
.workflow-graph-stage__node-label {
display: block;
}
.workflow-graph-stage__node-state {
display: block;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.8;
}
.workflow-graph-stage__node--current .workflow-graph-stage__node-state {
color: var(--accent-cyan);
}
.workflow-graph-stage__node--interrupt .workflow-graph-stage__node-state {
color: var(--accent-amber);
.workflow-graph-stage[data-graph-direction="horizontal"] .react-flow__node {
width: 11.875rem;
min-height: 4.5rem;
}
.workflow-graph-stage__node[data-kind="interrupt"] {
border-color: var(--accent-amber);
}
.workflow-graph-stage__node[data-kind="end"] {
border-color: oklch(0.5 0.025 250);
background: oklch(0.16 0.02 250);
}
.workflow-graph-stage__node[data-selected="true"] {
outline: 3px solid var(--accent-cyan);
outline: 2px solid var(--accent-cyan);
}
.workflow-graph-stage__node small {
color: var(--text-secondary);
font: 0.62rem/1.25 var(--font-evidence);
overflow-wrap: anywhere;
}
.workflow-graph-stage .react-flow__edge-text {
paint-order: stroke;
stroke: oklch(0.13 0.025 250);
stroke-width: 0.4rem;
stroke-linejoin: round;
fill: var(--text-primary);
font: 700 0.68rem/1 var(--font-evidence);
}
.workflow-graph-stage .react-flow__edge-path {
stroke: oklch(0.55 0.04 250);
}
.workflow-graph-stage .react-flow__edge--active .react-flow__edge-path {
stroke: var(--accent-cyan);
}
.workflow-graph-stage .react-flow__controls {
border-color: oklch(0.38 0.035 250);
box-shadow: none;
}
.workflow-graph-stage .react-flow__controls-button {
border-color: oklch(0.38 0.035 250);
background: oklch(0.2 0.03 250);
fill: var(--text-primary);
}
.node-spotlight {
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { presentationWorkflowPlan } from "./workflow-graph-data.js";
describe("presentationWorkflowPlan", () => {
it("contains the canonical ten-node raw story without layout coordinates", () => {
expect(presentationWorkflowPlan.nodes.map((node) => node.id)).toEqual([
"reset_board",
"read_docs",
"analyze",
"build_report",
"draft_issues",
"review_issues",
"create_issues",
"finalise",
"revision_requested",
"end_completed",
]);
expect(presentationWorkflowPlan.nodes.every((node) => !("x" in node) && !("y" in node))).toBe(true);
expect(presentationWorkflowPlan.nodes.find((node) => node.id === "read_docs")?.node).toBe(
"local.lda_docs.read_documents",
);
expect(presentationWorkflowPlan.nodes.find((node) => node.id === "revision_requested")?.node).toBe(
"local.lda_report.record_revision_request",
);
expect(presentationWorkflowPlan.nodes.map((node) => node.id)).not.toContain("end_cancelled");
});
it("keeps submitted and cancelled branch labels factual", () => {
expect(presentationWorkflowPlan.edges.filter((edge) => edge.from === "review_issues")).toEqual([
{ from: "review_issues", to: "create_issues", outcome: "submitted" },
{ from: "review_issues", to: "revision_requested", outcome: "cancelled" },
]);
});
});
@@ -1,49 +1,34 @@
export type PresentationNode = {
readonly id: string;
readonly label: string;
readonly detail: string;
readonly kind: "node" | "interrupt" | "end";
readonly x: number;
readonly y: number;
};
/**
* The presentation subset deliberately keeps workflow facts raw. Dagre owns
* every rendered coordinate; this module only chooses the factual story slice.
*/
export const presentationWorkflowPlan = {
nodes: [
{ id: "reset_board", type: "node", node: "local.issue_board.reset_issue_board", label: "Reset issue board" },
{ id: "read_docs", type: "node", node: "local.lda_docs.read_documents", label: "Read documents" },
{ id: "analyze", type: "node", node: "local.lda_report.analyze_documents", label: "Analyze documents" },
{ id: "build_report", type: "node", node: "local.lda_report.build_report", label: "Build report" },
{ id: "draft_issues", type: "node", node: "local.lda_report.create_issue_drafts", label: "Draft issues" },
{ id: "review_issues", type: "interrupt", kind: "issue_review" },
{ id: "create_issues", type: "node", node: "local.issue_board.create_issues", label: "Create issues" },
{ id: "finalise", type: "node", node: "local.lda_report.finalise_report", label: "Finalise report" },
{ id: "revision_requested", type: "node", node: "local.lda_report.record_revision_request", label: "Revision requested" },
{ id: "end_completed", type: "end", outcome: "completed" },
],
edges: [
{ from: "reset_board", to: "read_docs", outcome: "ok" },
{ from: "read_docs", to: "analyze", outcome: "ok" },
{ from: "analyze", to: "build_report", outcome: "ok" },
{ from: "build_report", to: "draft_issues", outcome: "ok" },
{ from: "draft_issues", to: "review_issues", outcome: "ok" },
{ from: "review_issues", to: "create_issues", outcome: "submitted" },
{ from: "create_issues", to: "finalise", outcome: "ok" },
{ from: "finalise", to: "end_completed", outcome: "completed" },
{ from: "review_issues", to: "revision_requested", outcome: "cancelled" },
],
} as const;
export const presentationNodes: ReadonlyArray<PresentationNode> = [
{ id: "read_docs", label: "Read docs", detail: "document source", kind: "node", x: 0, y: 120 },
{ id: "reset_board", label: "Reset board", detail: "issue board", kind: "node", x: 190, y: 120 },
{ id: "analyze", label: "Analyze", detail: "report source", kind: "node", x: 380, y: 120 },
{ id: "build_report", label: "Build report", detail: "markdown", kind: "node", x: 570, y: 120 },
{ id: "draft_issues", label: "Draft issues", detail: "proposals", kind: "node", x: 760, y: 120 },
{ id: "review_issues", label: "Issue review", detail: "typed interrupt", kind: "interrupt", x: 950, y: 120 },
{ id: "create_issues", label: "Create issues", detail: "selected only", kind: "node", x: 1140, y: 120 },
{ id: "finalise", label: "Finalise", detail: "state output", kind: "node", x: 1330, y: 120 },
{ id: "end_completed", label: "Completed", detail: "persisted run", kind: "end", x: 1520, y: 120 },
{ id: "revision_requested", label: "Revision requested", detail: "operator branch", kind: "end", x: 950, y: 300 },
];
export const presentationWorkflowNodeIds = presentationWorkflowPlan.nodes.map((node) => node.id);
export type PresentationHandle = "left" | "right" | "top" | "bottom";
export type PresentationEdge = {
readonly from: string;
readonly to: string;
readonly fromHandle?: PresentationHandle;
readonly toHandle?: PresentationHandle;
};
const mainEdge = (from: string, to: string): PresentationEdge => ({
from,
to,
fromHandle: "right",
toHandle: "left",
});
export const presentationEdges: ReadonlyArray<PresentationEdge> = [
mainEdge("read_docs", "reset_board"),
mainEdge("reset_board", "analyze"),
mainEdge("analyze", "build_report"),
mainEdge("build_report", "draft_issues"),
mainEdge("draft_issues", "review_issues"),
mainEdge("review_issues", "create_issues"),
mainEdge("create_issues", "finalise"),
mainEdge("finalise", "end_completed"),
{ from: "review_issues", to: "revision_requested", fromHandle: "bottom", toHandle: "top" },
];
// Kept as a raw-plan alias for the existing node spotlight consumer.
export const presentationNodes = presentationWorkflowPlan.nodes;