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