feat: render factual horizontal presentation graph
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user