fix: make presentation workflow graph readable
This commit is contained in:
@@ -132,7 +132,7 @@ describe("DemoWorkflowScene", () => {
|
|||||||
it("passes run proof into full graph beats", () => {
|
it("passes run proof into full graph beats", () => {
|
||||||
const { unmount } = renderBeat("graph");
|
const { unmount } = renderBeat("graph");
|
||||||
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("run_recorded_lda_report");
|
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("run_recorded_lda_report");
|
||||||
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("11 plan nodes");
|
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("3 trace frames");
|
||||||
unmount();
|
unmount();
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export const DemoWorkflowScene = ({
|
|||||||
|
|
||||||
const runProof = {
|
const runProof = {
|
||||||
runId: runStart?.resultingIds.runId ?? null,
|
runId: runStart?.resultingIds.runId ?? null,
|
||||||
planLabel: "11 plan nodes",
|
planLabel: "10 plan nodes",
|
||||||
traceLabel: "3 trace frames",
|
traceLabel: "3 trace frames",
|
||||||
evidenceLabel: "JSON-RPC evidence",
|
evidenceLabel: "JSON-RPC evidence",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, type KeyboardEvent } from "react";
|
import { useEffect, useRef, type KeyboardEvent } from "react";
|
||||||
import { presentationNodes } from "./WorkflowGraphStage.js";
|
import { presentationNodes } from "./workflow-graph-data.js";
|
||||||
|
|
||||||
type NodeSpotlightProps = {
|
type NodeSpotlightProps = {
|
||||||
readonly nodeId: string;
|
readonly nodeId: string;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
@@ -41,6 +41,13 @@ const setReplayMode = () => {
|
|||||||
window.sessionStorage.setItem("lda.workflowConsole.target", "file:///invalid");
|
window.sessionStorage.setItem("lda.workflowConsole.target", "file:///invalid");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const graphNodeByLabel = (label: RegExp): HTMLElement => {
|
||||||
|
const nodes = Array.from(document.querySelectorAll<HTMLElement>(".workflow-graph-stage__node"));
|
||||||
|
const node = nodes.find((candidate) => label.test(candidate.getAttribute("aria-label") ?? ""));
|
||||||
|
if (!node) throw new Error(`Could not find workflow graph node matching ${label}`);
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
window.sessionStorage.clear();
|
window.sessionStorage.clear();
|
||||||
@@ -77,7 +84,7 @@ describe("PresentationRoute", () => {
|
|||||||
window.location.hash = "#scene/run-from-deployment/graph";
|
window.location.hash = "#scene/run-from-deployment/graph";
|
||||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||||
render(<PresentationRoute />);
|
render(<PresentationRoute />);
|
||||||
await userEvent.click(screen.getByRole("button", { name: /issue review/i }));
|
fireEvent.click(graphNodeByLabel(/issue review/i));
|
||||||
expect(screen.getByRole("dialog", { name: /issue review/i })).toBeInTheDocument();
|
expect(screen.getByRole("dialog", { name: /issue review/i })).toBeInTheDocument();
|
||||||
expect(screen.getByText("Workflow node")).toBeInTheDocument();
|
expect(screen.getByText("Workflow node")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { presentationNodes, WorkflowGraphStage } from "./WorkflowGraphStage.js";
|
import { WorkflowGraphStage } from "./WorkflowGraphStage.js";
|
||||||
|
import { presentationEdges, presentationNodes } from "./workflow-graph-data.js";
|
||||||
|
|
||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
const graphNodeByLabel = (label: RegExp): HTMLElement => {
|
||||||
|
const nodes = Array.from(document.querySelectorAll<HTMLElement>(".workflow-graph-stage__node"));
|
||||||
|
const node = nodes.find((candidate) => label.test(candidate.getAttribute("aria-label") ?? ""));
|
||||||
|
if (!node) throw new Error(`Could not find workflow graph node matching ${label}`);
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
describe("WorkflowGraphStage", () => {
|
describe("WorkflowGraphStage", () => {
|
||||||
it("renders curated workflow nodes and allows node selection", async () => {
|
it("renders curated workflow nodes and allows node selection", async () => {
|
||||||
const selectNode = vi.fn();
|
const selectNode = vi.fn();
|
||||||
@@ -16,7 +23,7 @@ describe("WorkflowGraphStage", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: /issue review/i }));
|
fireEvent.click(graphNodeByLabel(/issue review/i));
|
||||||
expect(selectNode).toHaveBeenCalledWith("review_issues");
|
expect(selectNode).toHaveBeenCalledWith("review_issues");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,8 +47,8 @@ describe("WorkflowGraphStage", () => {
|
|||||||
expect(graph).toHaveTextContent("Finalise");
|
expect(graph).toHaveTextContent("Finalise");
|
||||||
expect(graph).toHaveTextContent("Revision requested");
|
expect(graph).toHaveTextContent("Revision requested");
|
||||||
expect(graph).toHaveTextContent("Completed");
|
expect(graph).toHaveTextContent("Completed");
|
||||||
expect(graph).toHaveTextContent("Cancelled");
|
expect(graph).not.toHaveTextContent("Cancelled");
|
||||||
expect(screen.getAllByRole("button", { name: /queued|current|completed|interrupt/i })).toHaveLength(11);
|
expect(document.querySelectorAll(".workflow-graph-stage__node")).toHaveLength(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("labels graph proof as plan nodes and trace frames separately", () => {
|
it("labels graph proof as plan nodes and trace frames separately", () => {
|
||||||
@@ -52,7 +59,7 @@ describe("WorkflowGraphStage", () => {
|
|||||||
selectNode={vi.fn()}
|
selectNode={vi.fn()}
|
||||||
proof={{
|
proof={{
|
||||||
runId: "run_recorded_lda_report",
|
runId: "run_recorded_lda_report",
|
||||||
planLabel: "11 plan nodes",
|
planLabel: "10 plan nodes",
|
||||||
traceLabel: "3 trace frames",
|
traceLabel: "3 trace frames",
|
||||||
evidenceLabel: "JSON-RPC evidence",
|
evidenceLabel: "JSON-RPC evidence",
|
||||||
}}
|
}}
|
||||||
@@ -60,7 +67,7 @@ describe("WorkflowGraphStage", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const proof = screen.getByLabelText("workflow graph proof");
|
const proof = screen.getByLabelText("workflow graph proof");
|
||||||
expect(proof).toHaveTextContent("11 plan nodes");
|
expect(proof).toHaveTextContent("10 plan nodes");
|
||||||
expect(proof).toHaveTextContent("3 trace frames");
|
expect(proof).toHaveTextContent("3 trace frames");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,9 +83,9 @@ describe("WorkflowGraphStage", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const readDocs = screen.getByRole("button", { name: /read docs/i });
|
const readDocs = graphNodeByLabel(/read docs/i);
|
||||||
const reviewIssues = screen.getByRole("button", { name: /issue review/i });
|
const reviewIssues = graphNodeByLabel(/issue review/i);
|
||||||
const revisionReq = screen.getByRole("button", { name: /revision requested/i });
|
const revisionReq = graphNodeByLabel(/revision requested/i);
|
||||||
|
|
||||||
expect(readDocs).toHaveAttribute("data-execution-state", "completed");
|
expect(readDocs).toHaveAttribute("data-execution-state", "completed");
|
||||||
expect(reviewIssues).toHaveAttribute("data-execution-state", "current");
|
expect(reviewIssues).toHaveAttribute("data-execution-state", "current");
|
||||||
@@ -88,6 +95,22 @@ describe("WorkflowGraphStage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders connectors between nodes", () => {
|
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("uses flow coordinates rather than viewport percentages", () => {
|
||||||
render(
|
render(
|
||||||
<WorkflowGraphStage
|
<WorkflowGraphStage
|
||||||
execution={{ completedNodeIds: ["read_docs"], currentNodeId: "reset_board" }}
|
execution={{ completedNodeIds: ["read_docs"], currentNodeId: "reset_board" }}
|
||||||
@@ -96,17 +119,9 @@ describe("WorkflowGraphStage", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectors = screen.getAllByTestId("workflow-connector");
|
|
||||||
expect(connectors).toHaveLength(10);
|
|
||||||
expect(connectors.filter((connector) => connector.dataset.active === "true")).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps all graph nodes inside the visible percentage frame", () => {
|
|
||||||
for (const node of presentationNodes) {
|
for (const node of presentationNodes) {
|
||||||
expect(node.x).toBeGreaterThanOrEqual(8);
|
expect(Number.isFinite(node.x)).toBe(true);
|
||||||
expect(node.x).toBeLessThanOrEqual(92);
|
expect(Number.isFinite(node.y)).toBe(true);
|
||||||
expect(node.y).toBeGreaterThanOrEqual(34);
|
|
||||||
expect(node.y).toBeLessThanOrEqual(78);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,12 +131,12 @@ describe("WorkflowGraphStage", () => {
|
|||||||
execution={{ completedNodeIds: ["read_docs"], currentNodeId: "reset_board" }}
|
execution={{ completedNodeIds: ["read_docs"], currentNodeId: "reset_board" }}
|
||||||
selectedNodeId={null}
|
selectedNodeId={null}
|
||||||
selectNode={vi.fn()}
|
selectNode={vi.fn()}
|
||||||
proof={{ runId: "run_recorded_lda_report", planLabel: "11 plan nodes", traceLabel: "3 trace frames", evidenceLabel: "JSON-RPC captured" }}
|
proof={{ runId: "run_recorded_lda_report", planLabel: "10 plan nodes", traceLabel: "3 trace frames", evidenceLabel: "JSON-RPC captured" }}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("run_recorded_lda_report");
|
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("run_recorded_lda_report");
|
||||||
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("11 plan nodes");
|
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("3 trace frames");
|
||||||
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("JSON-RPC captured");
|
expect(screen.getByLabelText("workflow graph proof")).toHaveTextContent("JSON-RPC captured");
|
||||||
});
|
});
|
||||||
@@ -133,7 +148,7 @@ describe("WorkflowGraphStage", () => {
|
|||||||
selectedNodeId={null}
|
selectedNodeId={null}
|
||||||
selectNode={vi.fn()}
|
selectNode={vi.fn()}
|
||||||
variant="compact"
|
variant="compact"
|
||||||
proof={{ runId: "run_recorded_lda_report", planLabel: "11 plan nodes", traceLabel: "3 trace frames", evidenceLabel: "JSON-RPC evidence" }}
|
proof={{ runId: "run_recorded_lda_report", planLabel: "10 plan nodes", traceLabel: "3 trace frames", evidenceLabel: "JSON-RPC evidence" }}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -159,7 +174,7 @@ describe("WorkflowGraphStage", () => {
|
|||||||
execution={{ completedNodeIds: [], currentNodeId: null }}
|
execution={{ completedNodeIds: [], currentNodeId: null }}
|
||||||
selectedNodeId={null}
|
selectedNodeId={null}
|
||||||
selectNode={vi.fn()}
|
selectNode={vi.fn()}
|
||||||
proof={{ runId: null, planLabel: "11 plan nodes", traceLabel: "trace label", evidenceLabel: "evidence label" }}
|
proof={{ runId: null, planLabel: "10 plan nodes", traceLabel: "trace label", evidenceLabel: "evidence label" }}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +1,30 @@
|
|||||||
import { m } from "motion/react";
|
import { useCallback, useMemo, type MouseEvent } from "react";
|
||||||
import { useId } from "react";
|
import {
|
||||||
|
Background,
|
||||||
|
Controls,
|
||||||
|
Handle,
|
||||||
|
Position,
|
||||||
|
ReactFlow,
|
||||||
|
ReactFlowProvider,
|
||||||
|
type Edge,
|
||||||
|
type Node,
|
||||||
|
type NodeProps,
|
||||||
|
type NodeTypes,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
import "@xyflow/react/dist/style.css";
|
||||||
import type { GraphExecutionPresentation } from "./demo-workflow-model.js";
|
import type { GraphExecutionPresentation } from "./demo-workflow-model.js";
|
||||||
|
import { presentationEdges, presentationNodes, type PresentationHandle, type PresentationNode } from "./workflow-graph-data.js";
|
||||||
export type PresentationNode = {
|
|
||||||
readonly id: string;
|
|
||||||
readonly label: string;
|
|
||||||
readonly detail: string;
|
|
||||||
readonly kind: "node" | "interrupt" | "end";
|
|
||||||
readonly x: number;
|
|
||||||
readonly y: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const presentationNodes: ReadonlyArray<PresentationNode> = [
|
|
||||||
{ id: "read_docs", label: "Read docs", detail: "document source", kind: "node", x: 8, y: 54 },
|
|
||||||
{ id: "reset_board", label: "Reset board", detail: "issue board", kind: "node", x: 20, y: 34 },
|
|
||||||
{ id: "analyze", label: "Analyze", detail: "report source", kind: "node", x: 32, y: 54 },
|
|
||||||
{ id: "build_report", label: "Build report", detail: "markdown", kind: "node", x: 44, y: 34 },
|
|
||||||
{ id: "draft_issues", label: "Draft issues", detail: "proposals", kind: "node", x: 56, y: 54 },
|
|
||||||
{ id: "review_issues", label: "Issue review", detail: "typed interrupt", kind: "interrupt", x: 68, y: 34 },
|
|
||||||
{ id: "create_issues", label: "Create issues", detail: "selected only", kind: "node", x: 80, y: 54 },
|
|
||||||
{ id: "finalise", label: "Finalise", detail: "state output", kind: "node", x: 92, y: 34 },
|
|
||||||
{ id: "revision_requested", label: "Revision requested", detail: "operator branch", kind: "end", x: 68, y: 78 },
|
|
||||||
{ id: "end_completed", label: "Completed", detail: "persisted run", kind: "end", x: 92, y: 72 },
|
|
||||||
{ id: "end_cancelled", label: "Cancelled", detail: "no submitted output", kind: "end", x: 80, y: 78 },
|
|
||||||
];
|
|
||||||
|
|
||||||
type PresentationEdge = readonly [from: string, to: string];
|
|
||||||
|
|
||||||
const presentationEdges: ReadonlyArray<PresentationEdge> = [
|
|
||||||
["read_docs", "reset_board"],
|
|
||||||
["reset_board", "analyze"],
|
|
||||||
["analyze", "build_report"],
|
|
||||||
["build_report", "draft_issues"],
|
|
||||||
["draft_issues", "review_issues"],
|
|
||||||
["review_issues", "create_issues"],
|
|
||||||
["review_issues", "revision_requested"],
|
|
||||||
["review_issues", "end_cancelled"],
|
|
||||||
["create_issues", "finalise"],
|
|
||||||
["finalise", "end_completed"],
|
|
||||||
];
|
|
||||||
|
|
||||||
type NodeExecutionState = "completed" | "current" | "future";
|
type NodeExecutionState = "completed" | "current" | "future";
|
||||||
|
|
||||||
|
type PresentationNodeData = {
|
||||||
|
readonly node: PresentationNode;
|
||||||
|
readonly executionState: NodeExecutionState;
|
||||||
|
readonly currentInterrupt: boolean;
|
||||||
|
readonly selected: boolean;
|
||||||
|
readonly selectNode: (nodeId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkflowGraphProof = {
|
export type WorkflowGraphProof = {
|
||||||
readonly runId: string | null;
|
readonly runId: string | null;
|
||||||
readonly planLabel: string;
|
readonly planLabel: string;
|
||||||
@@ -72,101 +55,170 @@ const requireNode = (nodeId: string): PresentationNode => {
|
|||||||
return node;
|
return node;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WorkflowGraphStage = ({
|
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 (
|
||||||
|
<>
|
||||||
|
{(["left", "top"] as const).map((handle) => (
|
||||||
|
<Handle key={`target-${handle}`} id={handle} type="target" position={handlePositionFor(handle)} />
|
||||||
|
))}
|
||||||
|
<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}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
selectNode(node.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="workflow-graph-stage__node-state">{stateLabel}</span>
|
||||||
|
<strong>{node.label}</strong>
|
||||||
|
<small>{node.detail}</small>
|
||||||
|
</button>
|
||||||
|
{(["right", "bottom"] as const).map((handle) => (
|
||||||
|
<Handle key={`source-${handle}`} id={handle} type="source" position={handlePositionFor(handle)} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeTypes: NodeTypes = {
|
||||||
|
presentation: PresentationFlowNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
const WorkflowGraphStageInner = ({
|
||||||
execution,
|
execution,
|
||||||
selectedNodeId,
|
selectedNodeId,
|
||||||
selectNode,
|
selectNode,
|
||||||
proof,
|
proof,
|
||||||
variant = "full",
|
variant = "full",
|
||||||
}: WorkflowGraphStageProps) => {
|
}: WorkflowGraphStageProps) => {
|
||||||
const markerPrefix = useId().replaceAll(":", "");
|
const nodes: Node<PresentationNodeData>[] = useMemo(
|
||||||
const arrowMarkerId = `${markerPrefix}-workflow-arrow`;
|
() =>
|
||||||
const activeArrowMarkerId = `${markerPrefix}-workflow-arrow-active`;
|
presentationNodes.map((node) => {
|
||||||
|
const executionState = executionStateForNode(node.id, execution);
|
||||||
|
const currentInterrupt = executionState === "current" && node.kind === "interrupt";
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
type: "presentation",
|
||||||
|
position: { x: node.x, y: node.y },
|
||||||
|
draggable: false,
|
||||||
|
selectable: false,
|
||||||
|
data: {
|
||||||
|
node,
|
||||||
|
executionState,
|
||||||
|
currentInterrupt,
|
||||||
|
selected: selectedNodeId === node.id,
|
||||||
|
selectNode,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[execution, selectedNodeId, selectNode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const edges: Edge[] = useMemo(
|
||||||
|
() =>
|
||||||
|
presentationEdges.map((transition, index) => {
|
||||||
|
requireNode(transition.from);
|
||||||
|
requireNode(transition.to);
|
||||||
|
const active = edgeActive(transition.from, transition.to, 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",
|
||||||
|
animated: active,
|
||||||
|
focusable: false,
|
||||||
|
selectable: false,
|
||||||
|
data: { active },
|
||||||
|
className: active
|
||||||
|
? "workflow-graph-stage__edge workflow-graph-stage__edge--active"
|
||||||
|
: "workflow-graph-stage__edge",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[execution],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNodeClick = useCallback((_: MouseEvent, node: Node) => selectNode(node.id), [selectNode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workflow-graph-stage" role="group" aria-label="workflow graph" data-graph-variant={variant}>
|
<div className="workflow-graph-stage" role="group" aria-label="workflow graph" data-graph-variant={variant}>
|
||||||
<div className="workflow-graph-stage__legend" aria-hidden="true">
|
<div className="workflow-graph-stage__legend" aria-hidden="true">
|
||||||
<span><i data-state="completed" />Completed</span>
|
<span><i data-state="completed" />Completed</span>
|
||||||
<span><i data-state="current" />Current</span>
|
<span><i data-state="current" />Current</span>
|
||||||
<span><i data-state="interrupt" />Human boundary</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. */}
|
|
||||||
{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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<svg className="workflow-graph-stage__connectors" aria-hidden="true">
|
{/* Compact mode is used beside interrupt contracts; proof chips would
|
||||||
<defs>
|
compete with the contract and outcome panel in that narrow layout. */}
|
||||||
<marker className="workflow-graph-stage__arrow-marker" id={arrowMarkerId} markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
{variant === "full" && proof && (
|
||||||
<polygon points="0 0, 8 3, 0 6" />
|
<div className="workflow-graph-stage__proof" aria-label="workflow graph proof">
|
||||||
</marker>
|
<span><b>Run</b><code>{proof.runId ?? "run unavailable"}</code></span>
|
||||||
<marker className="workflow-graph-stage__arrow-marker--active" id={activeArrowMarkerId} markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
<span><b>Plan</b>{proof.planLabel}</span>
|
||||||
<polygon points="0 0, 8 3, 0 6" />
|
<span><b>Trace</b>{proof.traceLabel}</span>
|
||||||
</marker>
|
<span><b>Evidence</b>{proof.evidenceLabel}</span>
|
||||||
</defs>
|
</div>
|
||||||
{presentationEdges.map(([fromId, toId]) => {
|
)}
|
||||||
const from = requireNode(fromId);
|
<ReactFlow
|
||||||
const to = requireNode(toId);
|
nodes={nodes}
|
||||||
const active = execution.completedNodeIds.includes(from.id);
|
edges={edges}
|
||||||
return (
|
nodeTypes={nodeTypes}
|
||||||
<line
|
onNodeClick={handleNodeClick}
|
||||||
key={`${fromId}-${toId}`}
|
fitView
|
||||||
data-testid="workflow-connector"
|
fitViewOptions={{ padding: variant === "compact" ? 0.04 : 0.08 }}
|
||||||
data-active={active}
|
minZoom={0.25}
|
||||||
x1={`${from.x}%`}
|
maxZoom={1.5}
|
||||||
y1={`${from.y}%`}
|
nodesDraggable={false}
|
||||||
x2={`${to.x}%`}
|
nodesConnectable={false}
|
||||||
y2={`${to.y}%`}
|
elementsSelectable={false}
|
||||||
markerEnd={active ? `url(#${activeArrowMarkerId})` : `url(#${arrowMarkerId})`}
|
edgesFocusable={false}
|
||||||
/>
|
panOnDrag
|
||||||
);
|
zoomOnScroll
|
||||||
})}
|
zoomOnPinch
|
||||||
</svg>
|
proOptions={{ hideAttribution: true }}
|
||||||
|
>
|
||||||
{presentationNodes.map((node, index) => {
|
<Background gap={28} color="oklch(0.35 0.03 250 / 0.22)" />
|
||||||
const executionState = executionStateForNode(node.id, execution);
|
<Controls showInteractive={false} />
|
||||||
const currentInterrupt = executionState === "current" && node.kind === "interrupt";
|
</ReactFlow>
|
||||||
const stateLabel = currentInterrupt
|
|
||||||
? "Current interrupt"
|
|
||||||
: executionState === "current"
|
|
||||||
? "Current"
|
|
||||||
: executionState === "completed"
|
|
||||||
? "Completed"
|
|
||||||
: "Queued";
|
|
||||||
return (
|
|
||||||
<m.div
|
|
||||||
key={node.id}
|
|
||||||
className="workflow-graph-stage__node-slot"
|
|
||||||
style={{ left: `${node.x}%`, top: `${node.y}%` }}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.2, delay: index * 0.025 }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="workflow-graph-stage__node"
|
|
||||||
data-kind={node.kind}
|
|
||||||
data-execution-state={executionState}
|
|
||||||
data-current-interrupt={currentInterrupt}
|
|
||||||
data-selected={selectedNodeId === node.id}
|
|
||||||
aria-pressed={selectedNodeId === node.id}
|
|
||||||
aria-label={`${node.label}, ${stateLabel}`}
|
|
||||||
onClick={() => selectNode(node.id)}
|
|
||||||
>
|
|
||||||
<span className="workflow-graph-stage__node-state">{stateLabel}</span>
|
|
||||||
<strong>{node.label}</strong>
|
|
||||||
<small>{node.detail}</small>
|
|
||||||
</button>
|
|
||||||
</m.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const WorkflowGraphStage = (props: WorkflowGraphStageProps) => (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<WorkflowGraphStageInner {...props} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
);
|
||||||
|
|||||||
@@ -331,6 +331,74 @@
|
|||||||
background-size: 1.75rem 1.75rem, 1.75rem 1.75rem, auto, auto;
|
background-size: 1.75rem 1.75rem, 1.75rem 1.75rem, auto, auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-graph-stage .react-flow {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-graph-stage .react-flow__pane {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-graph-stage .react-flow__pane:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-graph-stage .react-flow__node {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-graph-stage .react-flow__handle {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
border: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-graph-stage .react-flow__edge-path {
|
||||||
|
stroke: oklch(0.52 0.045 250 / 0.68);
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-dasharray: 5 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-graph-stage__edge--active .react-flow__edge-path {
|
||||||
|
stroke: var(--accent-cyan);
|
||||||
|
stroke-width: 2.6;
|
||||||
|
stroke-dasharray: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-graph-stage .react-flow__edge.animated path {
|
||||||
|
animation-duration: 1.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-graph-stage .react-flow__controls {
|
||||||
|
right: 0.85rem;
|
||||||
|
bottom: 3.2rem;
|
||||||
|
left: auto;
|
||||||
|
border: 1px solid var(--stage-line);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background: oklch(0.09 0.018 250 / 0.92);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-graph-stage .react-flow__controls-button {
|
||||||
|
width: 1.8rem;
|
||||||
|
height: 1.65rem;
|
||||||
|
border-bottom: 1px solid var(--stage-line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-graph-stage .react-flow__controls-button svg {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
.workflow-graph-stage__legend {
|
.workflow-graph-stage__legend {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
@@ -365,40 +433,6 @@
|
|||||||
background: var(--accent-amber);
|
background: var(--accent-amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-graph-stage__connectors {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-graph-stage__connectors line {
|
|
||||||
stroke: oklch(0.48 0.035 250 / 0.55);
|
|
||||||
stroke-width: 1.4;
|
|
||||||
stroke-dasharray: 4 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-graph-stage__connectors line[data-active="true"] {
|
|
||||||
stroke: var(--accent-cyan);
|
|
||||||
stroke-width: 2;
|
|
||||||
stroke-dasharray: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-graph-stage__arrow-marker polygon {
|
|
||||||
fill: oklch(0.48 0.035 250);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-graph-stage__arrow-marker--active polygon {
|
|
||||||
fill: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-graph-stage__node-slot {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 2;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-graph-stage[data-graph-variant="compact"] {
|
.workflow-graph-stage[data-graph-variant="compact"] {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
@@ -468,7 +502,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
width: clamp(7rem, 10vw, 8.75rem);
|
width: 9.2rem;
|
||||||
min-height: 4.15rem;
|
min-height: 4.15rem;
|
||||||
transform: none;
|
transform: none;
|
||||||
border: 1px solid var(--stage-line);
|
border: 1px solid var(--stage-line);
|
||||||
@@ -481,8 +515,15 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.presentation-route .workflow-graph-stage__node[data-kind="end"] {
|
||||||
|
min-height: 3.65rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
place-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.workflow-graph-stage__node strong {
|
.workflow-graph-stage__node strong {
|
||||||
font-size: 0.82rem;
|
font-size: 0.84rem;
|
||||||
line-height: 1.05;
|
line-height: 1.05;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
@@ -493,11 +534,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workflow-graph-stage__node small {
|
.workflow-graph-stage__node small {
|
||||||
font-size: 0.67rem;
|
font-size: 0.62rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-graph-stage__node-state {
|
.workflow-graph-stage__node-state {
|
||||||
font: 650 0.55rem/1 var(--font-mono, monospace);
|
font: 650 0.56rem/1 var(--font-mono, monospace);
|
||||||
letter-spacing: 0.09em;
|
letter-spacing: 0.09em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
@@ -528,10 +569,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workflow-graph-stage__node[data-execution-state="future"] {
|
.workflow-graph-stage__node[data-execution-state="future"] {
|
||||||
opacity: 0.88;
|
opacity: 0.72;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-graph-stage__node[data-kind="end"][data-execution-state="future"] {
|
||||||
|
border-style: dashed;
|
||||||
|
background: oklch(0.12 0.02 250 / 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
.workflow-graph-stage__node[data-selected="true"] {
|
.workflow-graph-stage__node[data-selected="true"] {
|
||||||
outline: 2px solid var(--accent-cyan);
|
outline: 2px solid var(--accent-cyan);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export type PresentationNode = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly detail: string;
|
||||||
|
readonly kind: "node" | "interrupt" | "end";
|
||||||
|
readonly x: number;
|
||||||
|
readonly y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 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" },
|
||||||
|
];
|
||||||
@@ -1 +1,30 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
class TestResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// React Flow measures nodes through browser layout APIs. JSDOM does not provide
|
||||||
|
// these APIs, so tests install stable no-op versions and assert our projected
|
||||||
|
// graph state rather than actual browser layout.
|
||||||
|
if (!globalThis.ResizeObserver) {
|
||||||
|
globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.DOMRect) {
|
||||||
|
globalThis.DOMRect = {
|
||||||
|
fromRect: () => ({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
toJSON() {},
|
||||||
|
}),
|
||||||
|
} as unknown as typeof DOMRect;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user