feat: clarify workflow graph presentation

This commit is contained in:
lda
2026-07-13 02:58:55 +07:00 Verified
parent 759b14a070
commit a4ba0e102f
5 changed files with 107 additions and 15 deletions
@@ -88,6 +88,22 @@ describe("DemoWorkflowScene", () => {
expect(screen.queryByText("Retry live service")).not.toBeInTheDocument(); expect(screen.queryByText("Retry live service")).not.toBeInTheDocument();
}); });
it("presents Scene 10 graph as a ten-node horizontal workflow diagram", () => {
renderBeat("graph");
expect(screen.getByLabelText("demo workflow stage")).toHaveAttribute("data-graph-layout", "horizontal");
const graph = screen.getByRole("group", { name: /workflow graph/i });
expect(graph).toHaveAttribute("data-graph-layout", "horizontal");
expect(document.querySelectorAll('button[aria-label^="workflow node:"]')).toHaveLength(10);
expect(screen.getByText(/revision requested/i)).toBeInTheDocument();
expect(screen.queryByText(/cancel: no submitted output/i)).not.toBeInTheDocument();
const legend = screen.getByLabelText("workflow graph node types");
expect(legend).toHaveTextContent("Action");
expect(legend).toHaveTextContent("Human boundary");
expect(legend).toHaveTextContent("Outcome");
});
it("keeps the run receipt visible when the graph takes over", () => { it("keeps the run receipt visible when the graph takes over", () => {
renderBeat("graph"); renderBeat("graph");
@@ -107,6 +107,7 @@ export const DemoWorkflowScene = ({
className="demo-workflow-stage" className="demo-workflow-stage"
data-beat={beat.id} data-beat={beat.id}
data-demo-layout={layout} data-demo-layout={layout}
data-graph-layout={beat.id === "graph" ? "horizontal" : undefined}
data-primary-surface={surface.primarySurface} data-primary-surface={surface.primarySurface}
data-support-surface={surface.supportSurface} data-support-surface={surface.supportSurface}
aria-label="demo workflow stage" aria-label="demo workflow stage"
@@ -66,6 +66,26 @@ describe("WorkflowGraphStage", () => {
]); ]);
}); });
it("exposes exactly the ten real workflow nodes as accessible controls", () => {
render(
<WorkflowGraphStage
execution={{ completedNodeIds: [], currentNodeId: null }}
selectedNodeId={null}
selectNode={vi.fn()}
/>,
);
const graph = screen.getByRole("group", { name: /workflow graph/i });
// React Flow hides unmeasured node wrappers in jsdom, so assert the
// rendered button contract directly rather than depending on its role tree.
const workflowNodes = document.querySelectorAll<HTMLButtonElement>(
'button[aria-label^="workflow node:"]',
);
expect(workflowNodes).toHaveLength(10);
expect(screen.queryByText(/cancel: no submitted output/i)).not.toBeInTheDocument();
expect(graph).toHaveTextContent("Revision requested");
});
it("does not render graph proof count chips", () => { it("does not render graph proof count chips", () => {
render( render(
<WorkflowGraphStage <WorkflowGraphStage
@@ -127,7 +147,9 @@ describe("WorkflowGraphStage", () => {
/>, />,
); );
expect(screen.getByRole("group", { name: "workflow graph" })).toHaveAttribute("data-graph-direction", "horizontal"); const graph = screen.getByRole("group", { name: "workflow graph" });
expect(graph).toHaveAttribute("data-graph-direction", "horizontal");
expect(graph).toHaveAttribute("data-graph-layout", "horizontal");
expect(screen.getByRole("button", { name: /zoom in/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /zoom in/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /zoom out/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /zoom out/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /fit view/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /fit view/i })).toBeInTheDocument();
@@ -34,6 +34,20 @@ type PresentationNodeData = WorkflowGraphNodeData & {
readonly selectNode: (nodeId: string) => void; readonly selectNode: (nodeId: string) => void;
}; };
const fullGraphLayout = {
nodeWidth: 176,
nodeHeight: 76,
nodesep: 72,
ranksep: 56,
} as const;
const compactGraphLayout = {
nodeWidth: 132,
nodeHeight: 64,
nodesep: 42,
ranksep: 52,
} as const;
const edgeActive = (edge: { source: string; target: string }, execution: GraphExecutionPresentation): boolean => const edgeActive = (edge: { source: string; target: string }, execution: GraphExecutionPresentation): boolean =>
execution.completedNodeIds.includes(edge.source) execution.completedNodeIds.includes(edge.source)
&& (execution.completedNodeIds.includes(edge.target) || execution.currentNodeId === edge.target); && (execution.completedNodeIds.includes(edge.target) || execution.currentNodeId === edge.target);
@@ -47,7 +61,7 @@ const PresentationFlowNode = ({ data }: NodeProps<Node<PresentationNodeData>>) =
data-kind={data.kind} data-kind={data.kind}
data-selected={data.selected} data-selected={data.selected}
aria-pressed={data.selected} aria-pressed={data.selected}
aria-label={`${data.label}${data.detail ? `, ${data.detail}` : ""}`} aria-label={`workflow node: ${data.label}${data.detail ? `, ${data.detail}` : ""}`}
title={data.nodeRef ?? undefined} title={data.nodeRef ?? undefined}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
@@ -73,17 +87,16 @@ const WorkflowGraphStageInner = ({
const model = useMemo( const model = useMemo(
() => buildWorkflowGraph(presentationWorkflowPlan, { () => buildWorkflowGraph(presentationWorkflowPlan, {
direction: "LR", direction: "LR",
nodeWidth: 150, // Keep Dagre's dimensions in lockstep with the CSS node boxes so React
nodeHeight: 64, // Flow's measured handles stay attached when the graph is fit to view.
nodesep: 35, ...(variant === "compact" ? compactGraphLayout : fullGraphLayout),
ranksep: 80,
label: (node) => { label: (node) => {
if (typeof node.label === "string") return node.label; if (typeof node.label === "string") return node.label;
if (node.id === "review_issues") return "Review issues"; if (node.id === "review_issues") return "Review issues";
return undefined; return undefined;
}, },
}), }),
[], [variant],
); );
const nodes: Node<PresentationNodeData>[] = useMemo( const nodes: Node<PresentationNodeData>[] = useMemo(
@@ -131,6 +144,7 @@ const WorkflowGraphStageInner = ({
aria-label="workflow graph" aria-label="workflow graph"
data-graph-variant={variant} data-graph-variant={variant}
data-graph-direction="horizontal" data-graph-direction="horizontal"
data-graph-layout="horizontal"
> >
<div className="workflow-graph-stage__legend" aria-label="workflow graph node types"> <div className="workflow-graph-stage__legend" aria-label="workflow graph node types">
<span data-kind="use"><i aria-hidden="true" />Action</span> <span data-kind="use"><i aria-hidden="true" />Action</span>
@@ -149,7 +163,7 @@ const WorkflowGraphStageInner = ({
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
onNodeClick={handleNodeClick} onNodeClick={handleNodeClick}
fitView fitView
fitViewOptions={{ padding: 0.02, minZoom: 0.5, maxZoom: 1 }} fitViewOptions={{ padding: 0.08, minZoom: 0.42, maxZoom: 1 }}
minZoom={0.25} minZoom={0.25}
maxZoom={1.5} maxZoom={1.5}
nodesDraggable={false} nodesDraggable={false}
@@ -366,6 +366,22 @@
stroke-dasharray: 5 7; stroke-dasharray: 5 7;
} }
.workflow-graph-stage .react-flow__edge-text {
paint-order: stroke;
stroke: oklch(0.105 0.02 250);
stroke-width: 0.38rem;
stroke-linejoin: round;
fill: var(--text-primary);
font: 700 0.68rem/1 var(--font-mono, monospace);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.workflow-graph-stage .react-flow__edge-textbg {
fill: oklch(0.105 0.02 250);
stroke: oklch(0.105 0.02 250);
}
.workflow-graph-stage__edge--active .react-flow__edge-path { .workflow-graph-stage__edge--active .react-flow__edge-path {
stroke: var(--accent-cyan); stroke: var(--accent-cyan);
stroke-width: 2.6; stroke-width: 2.6;
@@ -425,6 +441,23 @@
background: var(--stage-line); background: var(--stage-line);
} }
.workflow-graph-stage__legend span[data-kind="use"] i {
border-radius: 0.12rem;
background: oklch(0.62 0.12 195);
}
.workflow-graph-stage__legend span[data-kind="interrupt"] i {
border-radius: 0.1rem;
background: var(--accent-amber);
transform: rotate(45deg);
}
.workflow-graph-stage__legend span[data-kind="end"] i {
width: 0.66rem;
border-radius: 999px;
background: oklch(0.68 0.025 250);
}
.workflow-graph-stage__legend i[data-state="current"] { .workflow-graph-stage__legend i[data-state="current"] {
background: var(--accent-cyan); background: var(--accent-cyan);
} }
@@ -502,12 +535,12 @@
position: relative; position: relative;
display: grid; display: grid;
gap: 0.25rem; gap: 0.25rem;
width: 9.2rem; width: 11rem;
min-height: 4.15rem; min-height: 4.75rem;
transform: none; transform: none;
border: 1px solid var(--stage-line); border: 1px solid var(--stage-line);
border-radius: 0.7rem; border-radius: 0.7rem;
padding: 0.58rem 0.68rem; padding: 0.68rem 0.78rem;
background: oklch(0.15 0.025 250 / 0.96); background: oklch(0.15 0.025 250 / 0.96);
color: var(--text-primary); color: var(--text-primary);
text-align: left; text-align: left;
@@ -516,14 +549,14 @@
} }
.presentation-route .workflow-graph-stage__node[data-kind="end"] { .presentation-route .workflow-graph-stage__node[data-kind="end"] {
min-height: 3.65rem; min-height: 4.5rem;
border-radius: 999px; border-radius: 999px;
place-content: center; place-content: center;
text-align: center; text-align: center;
} }
.workflow-graph-stage__node strong { .workflow-graph-stage__node strong {
font-size: 0.84rem; font-size: 0.9rem;
line-height: 1.05; line-height: 1.05;
color: var(--text-primary); color: var(--text-primary);
} }
@@ -534,7 +567,7 @@
} }
.workflow-graph-stage__node small { .workflow-graph-stage__node small {
font-size: 0.62rem; font-size: 0.67rem;
} }
.workflow-graph-stage__node-state { .workflow-graph-stage__node-state {
@@ -928,7 +961,13 @@
} }
.presentation-route .workflow-graph-stage__node { .presentation-route .workflow-graph-stage__node {
width: 7rem; width: 11rem;
padding-inline: 0.78rem;
}
.workflow-graph-stage[data-graph-variant="compact"] .workflow-graph-stage__node {
width: 8.25rem;
min-height: 4rem;
padding-inline: 0.55rem; padding-inline: 0.55rem;
} }
} }