fix: restore readable presentation graph language

This commit is contained in:
lda
2026-07-12 13:51:10 +07:00 Verified
parent 92ba1dcf70
commit d99f844f9c
9 changed files with 139 additions and 40 deletions
@@ -13,6 +13,7 @@ export type WorkflowGraphNodeData = {
readonly nodeId: string;
readonly kind: WorkflowGraphNodeKind;
readonly label: string;
readonly detail?: string | null;
readonly nodeRef: string | null;
readonly raw: Readonly<Record<string, unknown>>;
readonly onSelect?: (nodeId: string) => void;
@@ -144,6 +145,7 @@ export const buildWorkflowGraph = (
nodeId: id,
kind: mapNodeKind(node.type as string),
label: buildLabel(node, layout.label),
detail: typeof node.detail === "string" ? node.detail : null,
nodeRef: (node.node as string | null) ?? null,
raw: node as Record<string, unknown>,
},
@@ -22,9 +22,9 @@ export const NodeSpotlight = ({ nodeId, close }: NodeSpotlightProps) => {
const node = presentationNodes.find((candidate) => candidate.id === nodeId);
const label = node && "label" in node
? node.label
: node?.id === "review_issues"
: nodeId === "review_issues"
? "Review issues"
: node?.id ?? nodeId;
: nodeId;
useEffect(() => {
const previouslyFocused = document.activeElement instanceof HTMLElement
@@ -164,8 +164,8 @@ describe("PresentationRoute", () => {
window.location.hash = "#scene/run-from-deployment/graph";
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
fireEvent.click(graphNodeByLabel(/issue review/i));
expect(screen.getByRole("dialog", { name: /issue review/i })).toBeInTheDocument();
fireEvent.click(graphNodeByLabel(/review issues/i));
expect(screen.getByRole("dialog", { name: /review issues/i })).toBeInTheDocument();
expect(screen.getByText("Workflow node")).toBeInTheDocument();
});
@@ -380,6 +380,24 @@ describe("SceneBody", () => {
expect(within(rail).getByRole("button", { name: /Hosted automation future-work/i })).toBeInTheDocument();
});
it("keeps discussion rails out of workflow proof scenes", () => {
const location: PresentationLocation = { kind: "main", sceneId: "run-from-deployment", beatId: "graph", focusPath: [] };
render(
<SceneBody
location={location}
demo={demo}
selectedNodeId={null}
selectNode={noop}
openEvidence={noop}
openDiscussion={noop}
onFocusPathChange={noop}
motionDisabled={false}
/>,
);
expect(screen.queryByLabelText("defense discussion topics")).not.toBeInTheDocument();
});
it("keeps discussion rail actions wired to branch ids", async () => {
const user = userEvent.setup();
const location: PresentationLocation = { kind: "main", sceneId: "positioning", beatId: "landscape", focusPath: [] };
@@ -41,6 +41,13 @@ type SceneBodyProps = {
readonly onScene9Advance?: (() => void) | undefined;
};
const workflowDemoSceneIds = new Set([
"prepared-lifecycle",
"run-from-deployment",
"typed-human-boundary",
"resume-output-evidence",
]);
const DiscussionLinks = ({
sceneId,
openDiscussion,
@@ -315,9 +322,12 @@ export const SceneBody = ({ location, demo, timelineAgent, selectedNodeId, selec
const scene = findScene(sceneId) ?? findScene("thesis")!;
const beat = findBeat(sceneId, beatId) ?? scene.beats[0]!;
// The questions beat renders its own grouped discussion index. Suppress the
// generic per-scene rail there so the same actions do not appear twice.
const showDiscussionRail = !(scene.id === "conclusion" && beat.id === "questions");
// The questions beat owns the full discussion index. Workflow demo scenes
// keep the product proof uncluttered; their discussion topics remain
// available from the global presenter controls.
const isWorkflowDemoScene = workflowDemoSceneIds.has(scene.id);
const showDiscussionRail = !(scene.id === "conclusion" && beat.id === "questions")
&& !isWorkflowDemoScene;
const discussionLinks = showDiscussionRail ? <DiscussionLinks sceneId={scene.id} openDiscussion={openDiscussion} /> : null;
const content = (() => {
switch (scene.view) {
@@ -49,7 +49,7 @@ describe("WorkflowGraphStage", () => {
expect(graph).toHaveTextContent("Create issues");
expect(graph).toHaveTextContent("Finalise");
expect(graph).toHaveTextContent("Revision requested");
expect(graph).toHaveTextContent("completed");
expect(graph).toHaveTextContent("persisted run");
expect(graph).not.toHaveTextContent("end_cancelled");
expect(document.querySelectorAll(".workflow-graph-stage__node")).toHaveLength(10);
expect(presentationWorkflowNodeIds).toEqual([
@@ -98,8 +98,9 @@ describe("WorkflowGraphStage", () => {
expect(screen.queryByText("Current")).not.toBeInTheDocument();
expect(screen.queryByText("Current interrupt")).not.toBeInTheDocument();
expect(screen.queryByText("Queued")).not.toBeInTheDocument();
expect(screen.queryByText("Completed")).not.toBeInTheDocument();
expect(screen.queryByText("Human boundary")).not.toBeInTheDocument();
expect(screen.getByLabelText("workflow graph node types")).toHaveTextContent("Action");
expect(screen.getByLabelText("workflow graph node types")).toHaveTextContent("Human boundary");
expect(screen.getByLabelText("workflow graph node types")).toHaveTextContent("Outcome");
});
it("keeps raw plan facts and canonical labeled edge order", () => {
@@ -47,14 +47,15 @@ const PresentationFlowNode = ({ data }: NodeProps<Node<PresentationNodeData>>) =
data-kind={data.kind}
data-selected={data.selected}
aria-pressed={data.selected}
aria-label={data.label}
aria-label={`${data.label}${data.detail ? `, ${data.detail}` : ""}`}
title={data.nodeRef ?? undefined}
onClick={(event) => {
event.stopPropagation();
data.selectNode(data.nodeId);
}}
>
<strong>{data.label}</strong>
{data.nodeRef && <small>{data.nodeRef}</small>}
<small>{data.detail ?? data.nodeRef ?? "workflow step"}</small>
</button>
<Handle type="source" position={Position.Right} />
</>
@@ -72,10 +73,10 @@ const WorkflowGraphStageInner = ({
const model = useMemo(
() => buildWorkflowGraph(presentationWorkflowPlan, {
direction: "LR",
nodeWidth: 190,
nodeHeight: 72,
nodesep: 55,
ranksep: 100,
nodeWidth: 150,
nodeHeight: 64,
nodesep: 35,
ranksep: 80,
label: (node) => {
if (typeof node.label === "string") return node.label;
if (node.id === "review_issues") return "Review issues";
@@ -131,6 +132,11 @@ const WorkflowGraphStageInner = ({
data-graph-variant={variant}
data-graph-direction="horizontal"
>
<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="interrupt"><i aria-hidden="true" />Human boundary</span>
<span data-kind="end"><i aria-hidden="true" />Outcome</span>
</div>
{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>
@@ -143,7 +149,7 @@ const WorkflowGraphStageInner = ({
nodeTypes={nodeTypes}
onNodeClick={handleNodeClick}
fitView
fitViewOptions={{ padding: 0.12, minZoom: 0.45, maxZoom: 1 }}
fitViewOptions={{ padding: 0.02, minZoom: 0.5, maxZoom: 1 }}
minZoom={0.25}
maxZoom={1.5}
nodesDraggable={false}
@@ -791,7 +791,7 @@
.workflow-graph-stage {
position: relative;
min-height: 24rem;
min-height: 25rem;
flex: 1;
border: 1px solid oklch(0.3 0.035 250);
border-radius: 0.7rem;
@@ -802,46 +802,108 @@
.workflow-graph-stage__node {
display: grid;
width: 100%;
min-height: 4.5rem;
min-height: 4.2rem;
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);
align-content: center;
padding: 0.62rem 0.72rem;
border: 1px solid oklch(0.48 0.04 250);
border-radius: 0.55rem;
background: oklch(0.17 0.03 250);
color: var(--text-primary);
text-align: left;
transition: border-color 160ms ease, background 160ms ease, box-shadow 160ms ease;
}
.workflow-graph-stage[data-graph-direction="horizontal"] .react-flow__node {
width: 11.875rem;
min-height: 4.5rem;
width: 9.375rem;
min-height: 4rem;
}
.workflow-graph-stage__node[data-kind="use"] {
border-color: oklch(0.62 0.12 195 / 0.78);
background: oklch(0.16 0.035 220);
}
.workflow-graph-stage__node[data-kind="interrupt"] {
border-color: var(--accent-amber);
border: 2px solid var(--accent-amber);
border-radius: 1rem;
background: oklch(0.19 0.04 70 / 0.22);
}
.workflow-graph-stage__node[data-kind="end"] {
border-color: oklch(0.5 0.025 250);
min-width: 9.5rem;
border-color: oklch(0.64 0.025 250);
border-radius: 999px;
background: oklch(0.16 0.02 250);
}
.workflow-graph-stage__node[data-selected="true"] {
outline: 2px solid var(--accent-cyan);
outline-offset: 2px;
box-shadow: 0 0 0 4px oklch(0.72 0.17 195 / 0.18);
}
.workflow-graph-stage__node strong {
color: var(--text-primary);
font: 700 0.9rem/1.05 var(--font-display);
}
.workflow-graph-stage__node[data-kind="interrupt"] strong {
color: var(--accent-amber);
}
.workflow-graph-stage__node small {
color: var(--text-secondary);
font: 0.62rem/1.25 var(--font-evidence);
font: 600 0.64rem/1.25 var(--font-evidence);
overflow-wrap: anywhere;
}
.workflow-graph-stage__legend {
position: absolute;
top: 0.7rem;
right: 0.8rem;
z-index: 5;
display: flex;
gap: 0.85rem;
color: var(--text-muted);
font: 700 0.62rem/1 var(--font-evidence);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.workflow-graph-stage__legend span {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.workflow-graph-stage__legend i {
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: oklch(0.62 0.12 195);
}
.workflow-graph-stage__legend [data-kind="interrupt"] i {
background: var(--accent-amber);
}
.workflow-graph-stage__legend [data-kind="end"] i {
background: oklch(0.68 0.025 250);
}
.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);
font: 700 0.78rem/1 var(--font-evidence);
}
.workflow-graph-stage .react-flow__edge-textbg {
fill: oklch(0.13 0.025 250);
stroke: oklch(0.13 0.025 250);
}
.workflow-graph-stage .react-flow__edge-path {
@@ -1847,7 +1909,7 @@
}
.workflow-graph-stage {
min-height: 10rem;
min-height: 22rem;
}
}
@@ -4,16 +4,16 @@
*/
export const presentationWorkflowPlan = {
nodes: [
{ 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" },
{ id: "reset_board", type: "node", node: "local.issue_board.reset_issue_board", label: "Reset issue board", detail: "issue board source" },
{ id: "read_docs", type: "node", node: "local.lda_docs.read_documents", label: "Read documents", detail: "document source" },
{ id: "analyze", type: "node", node: "local.lda_report.analyze_documents", label: "Analyze documents", detail: "report source" },
{ id: "build_report", type: "node", node: "local.lda_report.build_report", label: "Build report", detail: "Markdown output" },
{ id: "draft_issues", type: "node", node: "local.lda_report.create_issue_drafts", label: "Draft issues", detail: "issue proposals" },
{ id: "review_issues", type: "interrupt", kind: "issue_review", label: "Review issues", detail: "typed human boundary" },
{ id: "create_issues", type: "node", node: "local.issue_board.create_issues", label: "Create issues", detail: "selected issues only" },
{ id: "finalise", type: "node", node: "local.lda_report.finalise_report", label: "Finalise report", detail: "state output" },
{ id: "revision_requested", type: "node", node: "local.lda_report.record_revision_request", label: "Revision requested", detail: "operator branch" },
{ id: "end_completed", type: "end", outcome: "completed", label: "Completed", detail: "persisted run" },
],
edges: [
{ from: "reset_board", to: "read_docs", outcome: "ok" },