fix: make presentation workflow graph readable

This commit is contained in:
lda
2026-07-10 02:36:53 +07:00 Verified
parent b33e37b732
commit edc5422351
9 changed files with 391 additions and 193 deletions
@@ -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" },
];
+29
View File
@@ -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;
}