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", () => {
|
||||
const { unmount } = renderBeat("graph");
|
||||
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");
|
||||
unmount();
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ export const DemoWorkflowScene = ({
|
||||
|
||||
const runProof = {
|
||||
runId: runStart?.resultingIds.runId ?? null,
|
||||
planLabel: "11 plan nodes",
|
||||
planLabel: "10 plan nodes",
|
||||
traceLabel: "3 trace frames",
|
||||
evidenceLabel: "JSON-RPC evidence",
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, type KeyboardEvent } from "react";
|
||||
import { presentationNodes } from "./WorkflowGraphStage.js";
|
||||
import { presentationNodes } from "./workflow-graph-data.js";
|
||||
|
||||
type NodeSpotlightProps = {
|
||||
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 { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -41,6 +41,13 @@ const setReplayMode = () => {
|
||||
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(() => {
|
||||
cleanup();
|
||||
window.sessionStorage.clear();
|
||||
@@ -77,7 +84,7 @@ describe("PresentationRoute", () => {
|
||||
window.location.hash = "#scene/run-from-deployment/graph";
|
||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||
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.getByText("Workflow node")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
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());
|
||||
|
||||
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", () => {
|
||||
it("renders curated workflow nodes and allows node selection", async () => {
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -40,8 +47,8 @@ describe("WorkflowGraphStage", () => {
|
||||
expect(graph).toHaveTextContent("Finalise");
|
||||
expect(graph).toHaveTextContent("Revision requested");
|
||||
expect(graph).toHaveTextContent("Completed");
|
||||
expect(graph).toHaveTextContent("Cancelled");
|
||||
expect(screen.getAllByRole("button", { name: /queued|current|completed|interrupt/i })).toHaveLength(11);
|
||||
expect(graph).not.toHaveTextContent("Cancelled");
|
||||
expect(document.querySelectorAll(".workflow-graph-stage__node")).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("labels graph proof as plan nodes and trace frames separately", () => {
|
||||
@@ -52,7 +59,7 @@ describe("WorkflowGraphStage", () => {
|
||||
selectNode={vi.fn()}
|
||||
proof={{
|
||||
runId: "run_recorded_lda_report",
|
||||
planLabel: "11 plan nodes",
|
||||
planLabel: "10 plan nodes",
|
||||
traceLabel: "3 trace frames",
|
||||
evidenceLabel: "JSON-RPC evidence",
|
||||
}}
|
||||
@@ -60,7 +67,7 @@ describe("WorkflowGraphStage", () => {
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -76,9 +83,9 @@ describe("WorkflowGraphStage", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const readDocs = screen.getByRole("button", { name: /read docs/i });
|
||||
const reviewIssues = screen.getByRole("button", { name: /issue review/i });
|
||||
const revisionReq = screen.getByRole("button", { name: /revision requested/i });
|
||||
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");
|
||||
@@ -88,6 +95,22 @@ describe("WorkflowGraphStage", () => {
|
||||
});
|
||||
|
||||
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(
|
||||
<WorkflowGraphStage
|
||||
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) {
|
||||
expect(node.x).toBeGreaterThanOrEqual(8);
|
||||
expect(node.x).toBeLessThanOrEqual(92);
|
||||
expect(node.y).toBeGreaterThanOrEqual(34);
|
||||
expect(node.y).toBeLessThanOrEqual(78);
|
||||
expect(Number.isFinite(node.x)).toBe(true);
|
||||
expect(Number.isFinite(node.y)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -116,12 +131,12 @@ describe("WorkflowGraphStage", () => {
|
||||
execution={{ completedNodeIds: ["read_docs"], currentNodeId: "reset_board" }}
|
||||
selectedNodeId={null}
|
||||
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("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("JSON-RPC captured");
|
||||
});
|
||||
@@ -133,7 +148,7 @@ describe("WorkflowGraphStage", () => {
|
||||
selectedNodeId={null}
|
||||
selectNode={vi.fn()}
|
||||
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 }}
|
||||
selectedNodeId={null}
|
||||
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 { useId } from "react";
|
||||
import { useCallback, useMemo, type MouseEvent } 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";
|
||||
|
||||
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"],
|
||||
];
|
||||
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;
|
||||
};
|
||||
|
||||
export type WorkflowGraphProof = {
|
||||
readonly runId: string | null;
|
||||
readonly planLabel: string;
|
||||
@@ -72,101 +55,170 @@ const requireNode = (nodeId: string): PresentationNode => {
|
||||
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,
|
||||
selectedNodeId,
|
||||
selectNode,
|
||||
proof,
|
||||
variant = "full",
|
||||
}: WorkflowGraphStageProps) => {
|
||||
const markerPrefix = useId().replaceAll(":", "");
|
||||
const arrowMarkerId = `${markerPrefix}-workflow-arrow`;
|
||||
const activeArrowMarkerId = `${markerPrefix}-workflow-arrow-active`;
|
||||
const nodes: Node<PresentationNodeData>[] = useMemo(
|
||||
() =>
|
||||
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 (
|
||||
<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. */}
|
||||
{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 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>
|
||||
)}
|
||||
|
||||
<svg className="workflow-graph-stage__connectors" aria-hidden="true">
|
||||
<defs>
|
||||
<marker className="workflow-graph-stage__arrow-marker" id={arrowMarkerId} markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" />
|
||||
</marker>
|
||||
<marker className="workflow-graph-stage__arrow-marker--active" id={activeArrowMarkerId} markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" />
|
||||
</marker>
|
||||
</defs>
|
||||
{presentationEdges.map(([fromId, toId]) => {
|
||||
const from = requireNode(fromId);
|
||||
const to = requireNode(toId);
|
||||
const active = execution.completedNodeIds.includes(from.id);
|
||||
return (
|
||||
<line
|
||||
key={`${fromId}-${toId}`}
|
||||
data-testid="workflow-connector"
|
||||
data-active={active}
|
||||
x1={`${from.x}%`}
|
||||
y1={`${from.y}%`}
|
||||
x2={`${to.x}%`}
|
||||
y2={`${to.y}%`}
|
||||
markerEnd={active ? `url(#${activeArrowMarkerId})` : `url(#${arrowMarkerId})`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{presentationNodes.map((node, index) => {
|
||||
const executionState = executionStateForNode(node.id, execution);
|
||||
const currentInterrupt = executionState === "current" && node.kind === "interrupt";
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{/* 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>
|
||||
)}
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodeClick={handleNodeClick}
|
||||
fitView
|
||||
fitViewOptions={{ padding: variant === "compact" ? 0.04 : 0.08 }}
|
||||
minZoom={0.25}
|
||||
maxZoom={1.5}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
edgesFocusable={false}
|
||||
panOnDrag
|
||||
zoomOnScroll
|
||||
zoomOnPinch
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background gap={28} color="oklch(0.35 0.03 250 / 0.22)" />
|
||||
<Controls showInteractive={false} />
|
||||
</ReactFlow>
|
||||
</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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
@@ -365,40 +433,6 @@
|
||||
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"] {
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -468,7 +502,7 @@
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
width: clamp(7rem, 10vw, 8.75rem);
|
||||
width: 9.2rem;
|
||||
min-height: 4.15rem;
|
||||
transform: none;
|
||||
border: 1px solid var(--stage-line);
|
||||
@@ -481,8 +515,15 @@
|
||||
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 {
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.05;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -493,11 +534,11 @@
|
||||
}
|
||||
|
||||
.workflow-graph-stage__node small {
|
||||
font-size: 0.67rem;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -528,10 +569,15 @@
|
||||
}
|
||||
|
||||
.workflow-graph-stage__node[data-execution-state="future"] {
|
||||
opacity: 0.88;
|
||||
opacity: 0.72;
|
||||
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"] {
|
||||
outline: 2px solid var(--accent-cyan);
|
||||
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";
|
||||
|
||||
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