Skip to content

Commit ada6f19

Browse files
DylanMerigaudclaude
andcommitted
UX: make the workflow graph the hero; collapse intake after its moment
The right pane stacked document → graph → trace vertically, leaving the workflow (the demo's whole pitch — "the workflow you build is what routes the bill") buried between a big PDF and a long log. Now it leads. Three phases so the most important thing owns the pane: - idle / reading: the extraction reveal (the AI reading the real PDF) gets the full width — it's a MOMENT, kept intact. - past intake: that reveal collapses to a one-line "Intake ✓ · vendor · N lines · $total · reconciled with PO" node (expandable to re-show the document + fields), and the pane becomes two columns — the WORKFLOW GRAPH (hero, the larger share) on the left, the trace on the right led by that intake node. - mobile stays one stacked, scrolling column. `doneIntake` (the read document once the run is past intake, else null) both flags the phase and carries the data the collapsed node needs — no cast, the narrowing is clean. Verified visually (screenshots) across paused + expanded. approval.e2e.ts: the post-resume "intake shown once" guard now asserts the collapsed intake node (intake-collapsed) instead of the full reveal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 17c9363 commit ada6f19

2 files changed

Lines changed: 124 additions & 43 deletions

File tree

components/dashboard.tsx

Lines changed: 121 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,60 @@ const QueueHint = ({
144144
);
145145
};
146146

147+
/**
148+
* Once the document has been READ and the run moves on, the big extraction reveal
149+
* (its moment is over) collapses into this one-line node at the top of the trace —
150+
* "Intake · INV-2042 · 3 lines · $730 · reconciled with PO" — expandable to re-show
151+
* the document + extracted fields. Keeps the AI-reads-the-doc proof one click away
152+
* while handing the pane to the workflow (the hero).
153+
*/
154+
const CollapsedIntake = ({
155+
pdfSrc,
156+
document,
157+
state,
158+
}: {
159+
pdfSrc: string;
160+
document: Invoice;
161+
state: ExtractionState;
162+
}) => {
163+
const [open, setOpen] = useState(false);
164+
return (
165+
<div className="mb-3 rounded-lg ring-1 ring-inset ring-line">
166+
<button
167+
type="button"
168+
data-testid="intake-collapsed"
169+
onClick={() => setOpen((o) => !o)}
170+
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] hover:bg-subtle/50"
171+
>
172+
<span aria-hidden className="text-ok">
173+
174+
</span>
175+
<span className="font-medium text-ink">Intake</span>
176+
<span className="min-w-0 flex-1 truncate text-muted">
177+
{document.vendor} · {document.lineItems.length} lines ·{" "}
178+
{formatMoney(document.total, document.currency)}
179+
</span>
180+
{state.matches && <Badge tone="ok">reconciled with PO</Badge>}
181+
<span
182+
aria-hidden
183+
className={`text-faint transition-transform ${open ? "rotate-180" : ""}`}
184+
>
185+
186+
</span>
187+
</button>
188+
{open && (
189+
<div className="border-t border-line p-3">
190+
<ExtractionReveal
191+
pdfSrc={pdfSrc}
192+
state={state}
193+
extractedInvoice={document}
194+
/>
195+
</div>
196+
)}
197+
</div>
198+
);
199+
};
200+
147201
/** Small spinner shown while a run is in flight. */
148202
const Spinner = () => {
149203
return (
@@ -314,6 +368,20 @@ export const Dashboard = ({
314368
const graphToShow = runGraph?.workflow ?? workflow;
315369
const graphStatuses = runGraph?.statuses;
316370

371+
// Has the document been READ and the run moved on? The extraction reveal is a
372+
// MOMENT (the AI reading a real PDF): it owns the pane while it happens, then —
373+
// once matching/a later stage has started — it collapses to a one-line "Intake"
374+
// node at the top of the trace, handing the pane to the workflow (the hero).
375+
// `doneIntake` is the read document ONCE the run is past intake (else null), so
376+
// it both flags the phase and carries the data the collapsed node needs.
377+
const intake = readIntake(state.trace);
378+
const movedPastIntake = state.trace.some(
379+
(e) => e.stage !== "intake" && e.kind !== "run",
380+
);
381+
const doneIntake =
382+
intake && intake.state.status === "done" && movedPastIntake ? intake : null;
383+
const pastIntake = doneIntake !== null;
384+
317385
// The gates the paused run is waiting on (joined: live status + the workflow's
318386
// people). Drives the inline per-node Approve/Reject and the submit affordance.
319387
const gates =
@@ -583,68 +651,81 @@ export const Dashboard = ({
583651
area — the cue that the trace continues below (esp. on macOS where the
584652
scrollbar is hidden). */}
585653
<div className="relative min-h-0 flex-1">
654+
{/* Three phases, so the most important thing always owns the pane:
655+
• IDLE / READING — the extraction reveal (the AI reading the real PDF)
656+
is the MOMENT; it gets the full width, centered.
657+
• PAST INTAKE — the document's been read, so the reveal collapses to a
658+
one-line "Intake ✓" node and the pane becomes two columns: the
659+
WORKFLOW GRAPH (the hero) on the left, the trace on the right (with
660+
that collapsed intake as its first node).
661+
Mobile stacks everything into one scrolling column. */}
586662
<div
587663
ref={traceScrollRef}
588664
onScroll={measureTrace}
589-
className="scrollbar-slim h-full overflow-y-auto px-5 py-4"
665+
className={`scrollbar-slim h-full overflow-y-auto px-5 py-4 ${
666+
pastIntake
667+
? "lg:grid lg:grid-cols-[1.35fr_minmax(300px,1fr)] lg:gap-5 lg:overflow-hidden"
668+
: ""
669+
}`}
590670
>
591-
{/* The document + extraction panel is ALWAYS shown when a row is in
592-
view (idle preview, or live during a run), so it never unmounts —
593-
no flash between selecting and running. `intake` is null until the
594-
run emits its first event; until then it's a static preview. */}
595-
{previewId && (
596-
<div className="mb-4">
597-
<ExtractionReveal
598-
pdfSrc={API_ROUTES.pdf(previewId)}
599-
// Show the scanning state the instant Run is clicked — even
600-
// before the first stream event lands — so the UI feels
601-
// immediate. The real intake event takes over when it arrives.
602-
state={
603-
readIntake(state.trace)?.state ??
604-
(state.status === "running"
605-
? { status: "running", extracted: null, matches: false }
606-
: null)
607-
}
608-
extractedInvoice={readIntake(state.trace)?.document ?? null}
609-
/>
610-
</div>
671+
{/* Before we're past intake: the extraction reveal owns the pane (idle
672+
preview, or the live scan). Always mounted so there's no flash. */}
673+
{!pastIntake && previewId && (
674+
<ExtractionReveal
675+
pdfSrc={API_ROUTES.pdf(previewId)}
676+
// Show the scanning state the instant Run is clicked — even before
677+
// the first stream event lands — so the UI feels immediate.
678+
state={
679+
intake?.state ??
680+
(state.status === "running"
681+
? { status: "running", extracted: null, matches: false }
682+
: null)
683+
}
684+
extractedInvoice={intake?.document ?? null}
685+
/>
611686
)}
612-
{/* The live workflow graph — the SAME canvas onboarding draws, lit up
613-
by this invoice's path (a gate "In review" / "Approved" / "Skipped").
614-
This is the loop made visible: the workflow you built on the left is
615-
exactly what routes the invoice here. Shown once a run is underway, above
616-
the detailed text timeline. */}
617-
{state.status !== "idle" && graphToShow && (
618-
<div className="mb-4">
687+
688+
{/* Past intake: LEFT column — the workflow graph, the hero, given the
689+
larger share of the width. */}
690+
{pastIntake && graphToShow && (
691+
<div className="mb-4 lg:mb-0 lg:flex lg:min-h-0 lg:flex-col">
619692
<p className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-faint">
620693
Routing through {graphToShow.name}
621694
</p>
622695
<div
623696
data-testid="live-graph"
624-
className="h-[440px] overflow-hidden rounded-xl bg-subtle/30 ring-1 ring-inset ring-line sm:h-64"
697+
className="h-[440px] overflow-hidden rounded-xl bg-subtle/30 ring-1 ring-inset ring-line sm:h-64 lg:h-auto lg:min-h-[320px] lg:flex-1"
625698
>
626699
<WorkflowGraph
627700
workflow={graphToShow}
628701
statuses={graphStatuses}
629-
// When >1 gate pends in parallel, each pending node gets inline
630-
// Approve/Reject (decide one, reject another). A single gate uses
631-
// the header buttons, so the graph stays read-only there.
632702
decidableIds={gates.length >= 2 ? pendingIds : undefined}
633703
decisions={gates.length >= 2 ? gateChoices : undefined}
634704
onDecide={gates.length >= 2 ? setGate : undefined}
635-
// Pan to frame the waiting gate(s) the moment the run pauses.
636705
focusIds={awaiting ? pendingIds : undefined}
637706
/>
638707
</div>
639708
</div>
640709
)}
641-
{state.status !== "idle" && (
642-
<TraceTimeline
643-
state={state}
644-
invoiceLabel={selected?.invoiceNumber ?? null}
645-
canRun={!!selected}
646-
onRun={() => selected && run(selected.id)}
647-
/>
710+
711+
{/* Past intake: RIGHT column — the trace, led by the collapsed Intake
712+
node (expandable to re-show the document + extracted fields). */}
713+
{doneIntake && (
714+
<div className="scrollbar-slim lg:h-full lg:overflow-y-auto lg:pl-1">
715+
{previewId && (
716+
<CollapsedIntake
717+
pdfSrc={API_ROUTES.pdf(previewId)}
718+
document={doneIntake.document}
719+
state={doneIntake.state}
720+
/>
721+
)}
722+
<TraceTimeline
723+
state={state}
724+
invoiceLabel={selected?.invoiceNumber ?? null}
725+
canRun={!!selected}
726+
onRun={() => selected && run(selected.id)}
727+
/>
728+
</div>
648729
)}
649730
</div>
650731
{/* "more ↓" is a trace affordance — only meaningful once a run is

e2e/approval.e2e.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,9 @@ test("price-mismatch pauses for approval, then APPROVE posts it", async ({
9292

9393
// 4. No duplicated stage nodes after the resume (this is the audit-bug guard —
9494
// a phase-2 resume must upsert the stages in place, not stack a second set).
95-
// Exactly one node per stage. (Intake is shown by the extraction reveal, not
96-
// a timeline node — assert the reveal is present exactly once instead.)
97-
await expect(page.getByTestId("extraction-reveal")).toHaveCount(1);
95+
// Exactly one node per stage. (Once past intake the extraction collapses to a
96+
// single "Intake" node at the top of the trace — assert it's present once.)
97+
await expect(page.getByTestId("intake-collapsed")).toHaveCount(1);
9898
await expect(step(page, "matching")).toHaveCount(1);
9999
await expect(step(page, "approval")).toHaveCount(1);
100100
await expect(step(page, "reconciliation")).toHaveCount(1);

0 commit comments

Comments
 (0)