Skip to content

Commit cf55d48

Browse files
DylanMerigaudclaude
andcommitted
Redesign the pipeline pane: graph as hero, trace in a drawer
The right pane was doing too much and had a real bug: the trace timeline rendered a SECOND workflow graph (trace-detail) inside a skinny column, which overflowed and bled under the neighbouring card. The two-column layout (graph + trace) cramped both. Now the workflow graph IS the pane, full-width, no header: - Kill the duplicate graph in the trace; that step is a compact text summary. - Graph is the hero (the active workflow, or the default DAG when none), drawn even at idle so you see what an invoice routes through. Run pipeline is a centered button over the canvas; the PDF-read reveal only plays during the scan, not at idle (this also kills the "looks like a re-scan" flash on replay). - The step-by-step trace moved into a slide-over drawer ("View trace"), at real width, closeable. No more cramped column. - Human-in-the-loop moved ONTO the graph: Approve/Reject inline on the gate nodes (single and multi unified) with a floating Submit bar to resume. No header to hold them. fitView pans to the awaiting gate on pause (verified). - Skipped gates render dimmed (kept for audit, faded so the live path reads). - Hovering an exception/blocked queue row reveals WHY (the scenario text). - Fix a duplicate run-btn testid (dashboard + trace-timeline) that made the Run click unreliable. e2e updated for the relocated controls: open the trace drawer for step asserts, decide on the gate node + submit, and scope graph-node lookups to the visible tab (both tabs stay mounted and share the workflow). Verified live: the clean / approve / reject flows and the read-only pipeline graph pass; full-suite reruns are gated by the per-IP rate limit (e2e is local-only by design). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a570ab7 commit cf55d48

12 files changed

Lines changed: 430 additions & 332 deletions

components/dashboard.tsx

Lines changed: 250 additions & 265 deletions
Large diffs are not rendered by default.

components/trace-detail.tsx

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Badge } from "@/components/ui/badge";
2-
import { WorkflowGraph } from "@/components/workflow-graph";
32
import type { ApprovalWorkflow } from "@/lib/approval-workflow";
43
import { formatMoney, formatPct, humanize } from "@/lib/format";
54
import type { MatchResult, ReconResult, Investigation } from "@/lib/schema";
@@ -57,14 +56,35 @@ type WorkflowRunData = {
5756
outcome: string;
5857
};
5958

59+
/** A per-step status dot colour for the workflow run summary (matches the queue dots). */
60+
const stepDot = (status: string): string => {
61+
if (status === "approved" || status === "done" || status === "posted")
62+
return "#047857";
63+
if (status === "rejected" || status === "blocked") return "#B91C1C";
64+
if (status === "pending" || status === "awaiting") return "#B45309";
65+
return "#D1D5DB"; // skipped / neutral
66+
};
67+
68+
// The graph itself is the hero on the pane, so the trace just SUMMARISES the run as
69+
// a compact per-step list (status + the engine's detail line). No second graph here,
70+
// that nested canvas overflowed its column.
6071
const WorkflowRunDetail = ({ data }: { data: WorkflowRunData }) => {
61-
const statuses: Record<string, string> = {};
62-
for (const s of data.steps) statuses[s.id] = s.status;
6372
return (
64-
// React Flow needs a definite height; the trace node gives it a fixed canvas.
65-
<div className="h-64 w-full overflow-hidden rounded-xl bg-subtle/30 ring-1 ring-inset ring-line">
66-
<WorkflowGraph workflow={data.workflow} statuses={statuses} />
67-
</div>
73+
<ul className="space-y-1.5 rounded-xl bg-subtle/30 px-3 py-2.5 ring-1 ring-inset ring-line">
74+
{data.steps.map((s) => (
75+
<li key={s.id} className="flex gap-2 text-[12px] leading-snug">
76+
<span
77+
aria-hidden
78+
className="mt-1 h-2 w-2 shrink-0 rounded-full"
79+
style={{ backgroundColor: stepDot(s.status) }}
80+
/>
81+
<span className="min-w-0">
82+
<span className="font-medium text-ink">{humanize(s.status)}</span>
83+
<span className="text-muted"> {s.detail}</span>
84+
</span>
85+
</li>
86+
))}
87+
</ul>
6888
);
6989
};
7090

components/trace-timeline.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export const TraceTimeline = ({
8282
}
8383
action={
8484
canRun ? (
85-
<Button data-testid="run-btn" onClick={onRun} className="mt-5">
85+
<Button onClick={onRun} className="mt-5">
8686
Run pipeline
8787
</Button>
8888
) : null

components/workflow-graph.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,11 +184,19 @@ const StepNode = ({ data }: NodeProps<Node<NodeData>>) => {
184184
const selectedRing = selected
185185
? "ring-2 ring-accent ring-offset-2 ring-offset-subtle shadow-lift"
186186
: `ring-1 ring-inset ${ring}`;
187+
// A skipped gate didn't fire on this run, fade it so the realized path reads first
188+
// (kept in the graph, not hidden, so the audit shows every gate was considered).
189+
const dim =
190+
change === "removed"
191+
? "opacity-60"
192+
: status === "skipped"
193+
? "opacity-45"
194+
: "";
187195

188196
return (
189197
<div
190198
data-testid={`graph-node-${step.id}`}
191-
className={`w-[244px] rounded-xl bg-surface px-3.5 py-3 shadow-card ${selectedRing} ${change === "removed" ? "opacity-60" : ""}`}
199+
className={`w-[244px] rounded-xl bg-surface px-3.5 py-3 shadow-card ${selectedRing} ${dim}`}
192200
>
193201
<Handle
194202
type="target"

e2e/approval.e2e.ts

Lines changed: 58 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,39 @@ const selectAndRun = async (page: Page, rowId: string) => {
2323
await page.getByTestId("run-btn").click();
2424
};
2525

26-
/** A trace step node for a stage, with its status exposed via data-status. */
26+
/** The graph is the hero; the step-by-step trace lives in a drawer over it. Open it
27+
so the trace-step assertions can see the nodes. */
28+
const openTrace = async (page: Page) => {
29+
await page.getByTestId("view-trace").click({ timeout: RUN_TIMEOUT });
30+
};
31+
32+
/** A trace step node for a stage (inside the drawer), status via data-status. */
2733
const step = (page: Page, stage: string) => {
2834
return page.locator(`[data-testid="trace-step-${stage}"]`);
2935
};
3036

37+
/** Decide a paused gate on its node, then submit. The decision is ON the canvas, so
38+
close the trace drawer first if it's open (the gate is behind it). */
39+
const decideGate = async (
40+
page: Page,
41+
stepId: string,
42+
choice: "approve" | "reject",
43+
reason?: string,
44+
) => {
45+
const close = page.getByTestId("trace-close");
46+
if (await close.isVisible().catch(() => false)) await close.click();
47+
await page.getByTestId(`gate-${choice}-${stepId}`).click();
48+
if (choice === "reject" && reason) {
49+
// The reason input only appears once the gate is staged "reject".
50+
const field = page.getByTestId(`gate-reason-${stepId}`);
51+
await field.waitFor();
52+
await field.fill(reason);
53+
}
54+
const submit = page.getByTestId("submit-decisions");
55+
await expect(submit).toBeEnabled();
56+
await submit.click();
57+
};
58+
3159
test.beforeEach(async ({ page }) => {
3260
await page.goto("/");
3361
// The app opens on the "Build the workflow" tab; the pipeline lives behind the
@@ -42,15 +70,19 @@ test("clean invoice runs straight through, no approval gate", async ({
4270
}) => {
4371
await selectAndRun(page, "INV-2040");
4472

45-
// Reconciliation resolves to ok (posted), and the approval gate never appears.
73+
// A clean invoice trips no gate, so the decision bar never appears.
74+
await expect(page.getByTestId("approval-gate")).toHaveCount(0, {
75+
timeout: RUN_TIMEOUT,
76+
});
77+
// Open the trace: reconciliation resolves to ok (posted).
78+
await openTrace(page);
4679
await expect(step(page, "reconciliation")).toHaveAttribute(
4780
"data-status",
4881
"ok",
4982
{
5083
timeout: RUN_TIMEOUT,
5184
},
5285
);
53-
await expect(page.getByTestId("approval-gate")).toHaveCount(0);
5486
await expect(page.getByText("Pipeline complete")).toBeVisible();
5587
});
5688

@@ -59,49 +91,41 @@ test("price-mismatch pauses for approval, then APPROVE posts it", async ({
5991
}) => {
6092
await selectAndRun(page, "INV-2042");
6193

62-
// 1. Matching catches the variance (amber).
63-
await expect(step(page, "matching")).toHaveAttribute("data-status", "warn", {
94+
// 1. The run PAUSES on the manager gate: the decision bar appears on the canvas.
95+
await expect(page.getByTestId("approval-gate")).toBeVisible({
6496
timeout: RUN_TIMEOUT,
6597
});
6698

67-
// 2. The run PAUSES: reconciliation is "waiting", the gate + banner appear.
99+
// 2. Open the trace: matching caught the variance (amber), reconciliation waits,
100+
// and nothing posted yet.
101+
await openTrace(page);
102+
await expect(step(page, "matching")).toHaveAttribute("data-status", "warn");
68103
await expect(step(page, "reconciliation")).toHaveAttribute(
69104
"data-status",
70105
"waiting",
71-
{
72-
timeout: RUN_TIMEOUT,
73-
},
74106
);
75-
await expect(page.getByTestId("approval-gate")).toBeVisible();
76107
await expect(page.getByText(/Paused/)).toBeVisible();
77-
// It has NOT posted yet, no ERP reference on the trace.
78108
await expect(page.getByText(/NETSUITE-BILL-/)).toHaveCount(0);
79109

80-
// 3. Approve → reconciliation transitions to posted, gate disappears.
81-
await page.getByTestId("approve-btn").click();
110+
// 3. Approve the gate on its node + submit → reconciliation posts, bar disappears.
111+
await decideGate(page, "manager-review", "approve");
112+
await expect(page.getByTestId("approval-gate")).toHaveCount(0, {
113+
timeout: RUN_TIMEOUT,
114+
});
115+
await openTrace(page);
82116
await expect(step(page, "reconciliation")).toHaveAttribute(
83117
"data-status",
84118
"ok",
85-
{
86-
timeout: RUN_TIMEOUT,
87-
},
119+
{ timeout: RUN_TIMEOUT },
88120
);
89-
// The ERP ref shows up (both in the narration and the detail row), assert at
90-
// least one match rather than a single visible node.
91121
await expect(page.getByText(/NETSUITE-BILL-/).first()).toBeVisible();
92-
await expect(page.getByTestId("approval-gate")).toHaveCount(0);
93122

94-
// 4. No duplicated stage nodes after the resume (this is the audit-bug guard,
95-
// a phase-2 resume must upsert the stages in place, not stack a second set).
96-
// Exactly one node per stage. (Once past intake the extraction collapses to a
97-
// single "Intake" node at the top of the trace, assert it's present once.)
123+
// 4. No duplicated stage nodes after the resume (the audit-bug guard: a phase-2
124+
// resume must upsert the stages in place, not stack a second set).
98125
await expect(page.getByTestId("intake-collapsed")).toHaveCount(1);
99126
await expect(step(page, "matching")).toHaveCount(1);
100127
await expect(step(page, "approval")).toHaveCount(1);
101128
await expect(step(page, "reconciliation")).toHaveCount(1);
102-
// No stray "Pipeline started" duplicated by the resume. (Run markers are pruned
103-
// at the pause and the resume adds none, so the count is 0 here, the bug we're
104-
// guarding against would make it ≥ 1 from a re-emitted phase-2 marker.)
105129
await expect(page.getByText("Pipeline started")).toHaveCount(0);
106130
});
107131

@@ -113,12 +137,16 @@ test("price-mismatch REJECT (with a reason) leaves it un-posted", async ({
113137
await expect(page.getByTestId("approval-gate")).toBeVisible({
114138
timeout: RUN_TIMEOUT,
115139
});
116-
// Reject arms a reason field; type a note, then confirm.
117-
await page.getByTestId("reject-btn").click();
118-
await page.getByTestId("reject-reason").fill("price too high, renegotiate");
119-
await page.getByTestId("reject-confirm").click();
140+
// Reject the gate on its node with a reason, then submit.
141+
await decideGate(
142+
page,
143+
"manager-review",
144+
"reject",
145+
"price too high, renegotiate",
146+
);
120147

121148
// Reconciliation ends in error (rejected), and nothing was posted.
149+
await openTrace(page);
122150
await expect(step(page, "reconciliation")).toHaveAttribute(
123151
"data-status",
124152
"error",

e2e/condition-editor.e2e.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ test("edit a gate's trigger: add a condition and a nested group", async ({
1717
await page.goto("/");
1818
await page.getByRole("button", { name: /Discover from BambooHR/ }).click();
1919
// Discovery done once the derived workflow has rendered its gates.
20-
await expect(page.getByTestId("graph-node-manager-review")).toBeVisible({
20+
// Both tabs stay mounted and share the workflow, so scope to the VISIBLE graph.
21+
await expect(
22+
page.getByTestId("graph-node-manager-review").locator("visible=true"),
23+
).toBeVisible({
2124
timeout: DISCOVERY_TIMEOUT,
2225
});
2326

e2e/edit-clarify.e2e.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ test("an ambiguous department edit asks which one, then applies the pick", async
2222
await page.getByRole("button", { name: /Discover from BambooHR/ }).click();
2323

2424
// Discovery done once the derived workflow has rendered its gates.
25-
await expect(page.getByTestId("graph-node-manager-review")).toBeVisible({
25+
// Both tabs stay mounted and share the workflow, so scope to the VISIBLE graph.
26+
await expect(
27+
page.getByTestId("graph-node-manager-review").locator("visible=true"),
28+
).toBeVisible({
2629
timeout: DISCOVERY_TIMEOUT,
2730
});
2831

e2e/edit-levers.e2e.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ test("the editor builds a vendor-scoped gate from a plain instruction", async ({
1818
await page.goto("/");
1919
await page.getByRole("button", { name: /Discover from BambooHR/ }).click();
2020
// Discovery done once the derived workflow has rendered its gates.
21-
await expect(page.getByTestId("graph-node-manager-review")).toBeVisible({
21+
// Both tabs stay mounted and share the workflow, so scope to the VISIBLE graph.
22+
await expect(
23+
page.getByTestId("graph-node-manager-review").locator("visible=true"),
24+
).toBeVisible({
2225
timeout: DISCOVERY_TIMEOUT,
2326
});
2427

e2e/multi-gate-approval.e2e.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,32 @@ const DISCOVERY_TIMEOUT = 90_000;
1818
const RUN_TIMEOUT = 30_000;
1919

2020
const node = (page: Page, id: string) =>
21-
page.getByTestId("live-graph").getByTestId(`graph-node-${id}`);
21+
page.getByTestId("graph-pane").getByTestId(`graph-node-${id}`);
2222

2323
const step = (page: Page, stage: string) =>
2424
page.locator(`[data-testid="trace-step-${stage}"]`);
2525

26+
/** The trace lives in a drawer over the graph; open it for trace-step asserts. */
27+
const openTrace = (page: Page) =>
28+
page.getByTestId("view-trace").click({ timeout: RUN_TIMEOUT });
29+
/** Close the drawer (the gate decision is on the canvas behind it). */
30+
const closeTrace = async (page: Page) => {
31+
const close = page.getByTestId("trace-close");
32+
if (await close.isVisible().catch(() => false)) await close.click();
33+
};
34+
2635
test("two parallel gates: reject one, approve the other → bill blocked", async ({
2736
page,
2837
}) => {
2938
await page.goto("/");
3039

3140
// 1. Derive the workflow from the org (parallel roots: manager + department).
3241
await page.getByRole("button", { name: /Discover from BambooHR/ }).click();
33-
// Discovery done once the derived workflow has rendered its gates.
34-
await expect(page.getByTestId("graph-node-manager-review")).toBeVisible({
42+
// Discovery done once the derived workflow has rendered its gates (scope to the
43+
// VISIBLE onboarding graph; both tabs stay mounted and share the workflow).
44+
await expect(
45+
page.getByTestId("graph-node-manager-review").locator("visible=true"),
46+
).toBeVisible({
3547
timeout: DISCOVERY_TIMEOUT,
3648
});
3749

@@ -41,18 +53,21 @@ test("two parallel gates: reject one, approve the other → bill blocked", async
4153
await page.getByTestId("run-btn").click();
4254

4355
// 3. The run pauses with BOTH gates pending, each showing inline controls.
56+
await expect(page.getByTestId("approval-gate-multi")).toBeVisible({
57+
timeout: RUN_TIMEOUT,
58+
});
59+
await expect(node(page, "manager-review")).toBeVisible();
60+
await expect(node(page, "department-review")).toBeVisible();
61+
// The trace shows reconciliation waiting; nothing posted yet.
62+
await openTrace(page);
4463
await expect(step(page, "reconciliation")).toHaveAttribute(
4564
"data-status",
4665
"waiting",
47-
{ timeout: RUN_TIMEOUT },
4866
);
49-
await expect(page.getByTestId("approval-gate-multi")).toBeVisible();
50-
await expect(node(page, "manager-review")).toBeVisible();
51-
await expect(node(page, "department-review")).toBeVisible();
52-
// Nothing posted yet.
5367
await expect(page.getByText(/NETSUITE-BILL-/)).toHaveCount(0);
68+
await closeTrace(page);
5469

55-
// 4. Decide each gate independently: reject manager, approve department.
70+
// 4. Decide each gate independently on its node: reject manager, approve department.
5671
await node(page, "manager-review")
5772
.getByTestId("gate-reject-manager-review")
5873
.click();
@@ -62,6 +77,7 @@ test("two parallel gates: reject one, approve the other → bill blocked", async
6277

6378
// 5. Submit the wave → the bill is BLOCKED (reject wins), nothing posts.
6479
await page.getByTestId("submit-decisions").click();
80+
await openTrace(page);
6581
await expect(step(page, "reconciliation")).toHaveAttribute(
6682
"data-status",
6783
"error",

e2e/node-edit.e2e.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,18 @@ import { test, expect, type Page } from "@playwright/test";
1010

1111
const DISCOVERY_TIMEOUT = 90_000;
1212

13-
const node = (page: Page, id: string) => page.getByTestId(`graph-node-${id}`);
13+
// Both tabs stay mounted and share the derived workflow, so a node id exists in the
14+
// (hidden) pipeline graph too. Scope to the VISIBLE one (the onboarding editor here).
15+
const node = (page: Page, id: string) =>
16+
page.getByTestId(`graph-node-${id}`).locator("visible=true");
1417

1518
test("clicking a gate opens the panel and the approver picker resolves it", async ({
1619
page,
1720
}) => {
1821
await page.goto("/");
1922
await page.getByRole("button", { name: /Discover from BambooHR/ }).click();
2023
// Discovery done once the derived workflow has rendered its gates.
21-
await expect(page.getByTestId("graph-node-manager-review")).toBeVisible({
24+
await expect(node(page, "manager-review")).toBeVisible({
2225
timeout: DISCOVERY_TIMEOUT,
2326
});
2427

@@ -45,13 +48,12 @@ test("the pipeline graph is read-only, clicking a node does nothing", async ({
4548
await page.getByRole("button", { name: /Run it on invoices/ }).click();
4649
await page.getByTestId("queue-row-INV-2042").click();
4750
await page.getByTestId("run-btn").click();
48-
// Scope to the live routing graph (the trace timeline draws the same nodes lower
49-
// down, so the bare testid isn't unique on this screen).
51+
// The pipeline graph (the hero pane) is read-only: clicking a node does NOT open
52+
// the editor (that's an onboarding-only affordance).
5053
const liveNode = page
51-
.getByTestId("live-graph")
54+
.getByTestId("graph-pane")
5255
.getByTestId("graph-node-manager-review");
5356
await expect(liveNode).toBeVisible({ timeout: 45_000 });
5457
await liveNode.click();
55-
// No edit panel appears in the pipeline (the condition editor's heading is absent).
5658
await expect(page.getByText(/Triggers when/)).toHaveCount(0);
5759
});

0 commit comments

Comments
 (0)