Skip to content

Commit 17c9363

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/link-workflow
2 parents 34c1f6d + 534df7a commit 17c9363

5 files changed

Lines changed: 272 additions & 20 deletions

File tree

app/page.tsx

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,19 +66,81 @@ const Header = () => {
6666
</p>
6767
</div>
6868
</div>
69-
<a
70-
href="https://github.com/DylanMerigaud/ledgerloop"
71-
target="_blank"
72-
rel="noreferrer noopener"
73-
className="hidden shrink-0 items-center gap-1.5 rounded-full bg-subtle px-3 py-1.5 text-[12px] font-medium text-muted ring-1 ring-inset ring-line-strong transition-colors hover:text-ink sm:inline-flex"
74-
>
75-
<span className="size-1.5 rounded-full bg-ok" aria-hidden />
76-
Live demo · source on GitHub
77-
</a>
69+
<div className="flex shrink-0 items-center gap-2">
70+
<HowItWorks />
71+
<a
72+
href="https://github.com/DylanMerigaud/ledgerloop"
73+
target="_blank"
74+
rel="noreferrer noopener"
75+
className="hidden items-center gap-1.5 rounded-full bg-subtle px-3 py-1.5 text-[12px] font-medium text-muted ring-1 ring-inset ring-line-strong transition-colors hover:text-ink sm:inline-flex"
76+
>
77+
<span className="size-1.5 rounded-full bg-ok" aria-hidden />
78+
Live demo · source on GitHub
79+
</a>
80+
</div>
7881
</header>
7982
);
8083
};
8184

85+
/**
86+
* An on-demand glossary — closed by default so it adds zero noise for anyone who
87+
* knows the domain, and a one-line definition for anyone who doesn't (gate, the
88+
* match types, the verdicts, the agent). The terms appear all over the trace and
89+
* the workflow; this is the single place they're defined, without peppering the UI
90+
* with inline tooltips.
91+
*/
92+
const HowItWorks = () => {
93+
return (
94+
<details className="group relative">
95+
<summary className="flex cursor-pointer list-none items-center gap-1.5 rounded-full bg-subtle px-3 py-1.5 text-[12px] font-medium text-muted ring-1 ring-inset ring-line-strong transition-colors hover:text-ink">
96+
How it works
97+
<span
98+
aria-hidden
99+
className="text-faint transition-transform group-open:rotate-180"
100+
>
101+
102+
</span>
103+
</summary>
104+
<div className="absolute right-0 z-20 mt-2 w-[300px] rounded-xl bg-surface p-3 text-[12px] leading-snug text-muted shadow-lift ring-1 ring-inset ring-line">
105+
<dl className="space-y-1.5">
106+
<Term name="Gate">
107+
an approval step that fires on a condition (amount, variance, …) and
108+
pauses for a human.
109+
</Term>
110+
<Term name="2-way / 3-way match">
111+
invoice ↔ PO, or invoice ↔ PO ↔ goods receipt (did we receive it?).
112+
</Term>
113+
<Term name="Verdict">
114+
<span className="text-ink">clean</span> (reconciles),{" "}
115+
<span className="text-ink">exception</span> (a variance needs a
116+
decision), <span className="text-ink">duplicate</span> (blocked,
117+
never paid twice).
118+
</Term>
119+
<Term name="Investigator">
120+
an AI agent that reads messy records on an exception and recommends
121+
— a human still decides.
122+
</Term>
123+
</dl>
124+
</div>
125+
</details>
126+
);
127+
};
128+
129+
const Term = ({
130+
name,
131+
children,
132+
}: {
133+
name: string;
134+
children: React.ReactNode;
135+
}) => {
136+
return (
137+
<div>
138+
<dt className="inline font-medium text-ink">{name}: </dt>
139+
<dd className="inline">{children}</dd>
140+
</div>
141+
);
142+
};
143+
82144
/** Logo mark: an "ll" monogram whose strokes curl into a loop — the ledgerloop
83145
glyph (matches app/icon.svg). The second stroke is the accent. */
84146
const LogoMark = () => {

components/dashboard.tsx

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
outcomeDot,
2626
outcomeLabel,
2727
outcomeTone,
28+
scenarioBadge,
29+
scenarioKind,
2830
type Outcome,
2931
} from "@/lib/display";
3032
import { formatMoney } from "@/lib/format";
@@ -115,6 +117,33 @@ const PlayIcon = () => {
115117
);
116118
};
117119

120+
/**
121+
* The pre-run hint on a queue row: a "Start here" chip on the showcase invoice,
122+
* and a coloured badge ONLY for exception/blocked scenarios (clean rows stay
123+
* unmarked, so the marks draw the eye to the cases worth running). Renders nothing
124+
* for an unmarked clean row.
125+
*/
126+
const QueueHint = ({
127+
scenario,
128+
startHere,
129+
}: {
130+
scenario: string | null;
131+
startHere: boolean;
132+
}) => {
133+
const badge = scenarioBadge(scenarioKind(scenario));
134+
if (!startHere && !badge) return null;
135+
return (
136+
<span className="flex shrink-0 items-center gap-1.5">
137+
{startHere && (
138+
<span className="inline-flex items-center gap-0.5 rounded-full bg-accent-soft px-1.5 py-0.5 text-[10px] font-medium text-accent ring-1 ring-inset ring-accent/20">
139+
<span aria-hidden></span> Start here
140+
</span>
141+
)}
142+
{badge && <Badge tone={badge.tone}>{badge.label}</Badge>}
143+
</span>
144+
);
145+
};
146+
118147
/** Small spinner shown while a run is in flight. */
119148
const Spinner = () => {
120149
return (
@@ -395,18 +424,20 @@ export const Dashboard = ({
395424
{item.poNumber ? ` · ${item.poNumber}` : ""}
396425
</span>
397426
{/* Once a run is active for the selected row, show its live
398-
outcome badge; otherwise always show the seeded scenario
399-
hint (so selecting a row never blanks the label). */}
427+
outcome badge; otherwise signpost the seeded scenario so the
428+
eye goes to the interesting cases. Only exception/blocked rows
429+
get a coloured badge — clean rows stay unmarked, so the marks
430+
mean something. INV-2042 (price mismatch → investigator +
431+
pause: the full wow) also gets a single "Start here" chip. */}
400432
{isSelected && state.status !== "idle" ? (
401433
<Badge tone={outcomeTone(outcome)}>
402434
{outcomeLabel(outcome)}
403435
</Badge>
404436
) : (
405-
item.scenario && (
406-
<span className="shrink-0 text-[10px] text-muted/80">
407-
{item.scenario}
408-
</span>
409-
)
437+
<QueueHint
438+
scenario={item.scenario}
439+
startHere={item.id === "INV-2042"}
440+
/>
410441
)}
411442
</span>
412443
</span>
@@ -457,6 +488,16 @@ export const Dashboard = ({
457488
</p>
458489
)}
459490
<RunningAgainst workflow={workflow} />
491+
{/* Idle: a one-line "what to do" at the top of the pane (where the eye
492+
lands), pointing at the showcase invoice. The queue's "Start here" chip
493+
is the other half of the cue. */}
494+
{state.status === "idle" && (
495+
<p className="mt-1.5 max-w-md text-[11.5px] leading-snug text-muted">
496+
{selected?.id === "INV-2042"
497+
? "Hit Run pipeline — the agent investigates the price variance, then pauses for your approval."
498+
: "New here? Run INV-2042 (the “Start here” row) to see the agent investigate a variance and pause for approval. Clean invoices post straight through."}
499+
</p>
500+
)}
460501
</div>
461502
{state.status === "awaiting" && selected && gates.length >= 2 ? (
462503
// Several gates pend in parallel — decide each on its node in the graph,
@@ -519,6 +560,7 @@ export const Dashboard = ({
519560
<Button
520561
size="sm"
521562
data-testid="run-btn"
563+
className="shrink-0 whitespace-nowrap"
522564
disabled={state.status === "running"}
523565
onClick={() => run(selected.id)}
524566
>

components/trace-timeline.tsx

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,49 @@ import { formatDuration, humanize } from "@/lib/format";
1010
import type { TraceEvent } from "@/lib/trace";
1111
import type { PipelineRunState } from "@/lib/use-pipeline-run";
1212

13+
/**
14+
* Compose a one-line reason the run paused, from the trace data already on screen:
15+
* which gate(s) are pending (the approval step's `steps`) and why (the top
16+
* matching exception's message). Read defensively with `isRecord` — a trace whose
17+
* shape we don't recognise just yields no extras, so the banner degrades to the
18+
* plain "needs a human decision" rather than guessing. Keeps the pause LEGIBLE
19+
* without a paragraph: "Paused at <gate> — <reason>."
20+
*/
21+
/* The slices of the trace data this banner reads, Zod-validated so the unknown
22+
`data` is narrowed without a cast (same discipline as the rest of the app). */
23+
const ApprovalData = z.object({
24+
steps: z
25+
.array(z.object({ status: z.string(), detail: z.string() }))
26+
.optional(),
27+
});
28+
const MatchingData = z.object({
29+
exceptions: z.array(z.object({ message: z.string() })).optional(),
30+
});
31+
32+
const pauseReason = (trace: TraceEvent[]): string => {
33+
const approval = trace.find((e) => e.stage === "approval");
34+
const matching = trace.find((e) => e.stage === "matching");
35+
36+
// Pending gate label — the engine's pending detail reads "Awaiting <approver>…".
37+
let gates = "";
38+
const ap = ApprovalData.safeParse(approval?.data);
39+
if (ap.success) {
40+
const pending = (ap.data.steps ?? []).find((s) => s.status === "pending");
41+
if (pending) gates = pending.detail.replace(/\.$/, "");
42+
}
43+
44+
// The top exception message (e.g. "Line STL-BAR-20: invoiced at 8.18/unit vs PO
45+
// 7.50/unit (9.1% over).") — the "why".
46+
let why = "";
47+
const mt = MatchingData.safeParse(matching?.data);
48+
if (mt.success) why = mt.data.exceptions?.[0]?.message ?? "";
49+
50+
if (gates && why) return `Paused — ${gates}. ${why}`;
51+
if (gates) return `Paused — ${gates}.`;
52+
if (why) return `Paused — ${why}`;
53+
return "Paused — this invoice needs a human decision.";
54+
};
55+
1356
/**
1457
* The execution trace — a vertical timeline streamed in live as the run
1558
* progresses. Each node is one TraceEvent: the deterministic steps, the
@@ -34,8 +77,8 @@ export const TraceTimeline = ({
3477
title="Run the pipeline"
3578
body={
3679
invoiceLabel
37-
? `Run ${invoiceLabel} through matching, routing, and reconciliation — live. Pick a flagged invoice (price or quantity mismatch) to watch the investigator agent dig into the variance, then pause for your decision.`
38-
: "Select an invoice to begin. Flagged ones — a price or quantity mismatch — trigger the investigator agent and pause for your decision."
80+
? `Run ${invoiceLabel} through matching, routing, and reconciliation — live.`
81+
: "Select an invoice to begin."
3982
}
4083
action={
4184
canRun ? (
@@ -80,8 +123,7 @@ export const TraceTimeline = ({
80123

81124
{state.status === "awaiting" && (
82125
<div className="ml-8 mt-1 rounded-lg bg-warn-soft/60 px-3 py-2 text-[12px] text-warn ring-1 ring-inset ring-warn-line">
83-
Paused — this invoice needs a human decision. Approve or reject it
84-
above to continue.
126+
{pauseReason(state.trace)} Approve or reject above to continue.
85127
</div>
86128
)}
87129

lib/display.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
4+
import { SEED_BUNDLES } from "@/db/seed-data";
5+
import { scenarioKind, scenarioBadge } from "@/lib/display";
6+
7+
/**
8+
* The queue signposting helpers — what marks a seeded row BEFORE it's run, so a
9+
* first-time visitor's eye lands on the interesting cases. Classification is
10+
* derived from the scenario label; these pin that every seeded scenario lands in
11+
* the right bucket (a mislabel would mark a clean row as an exception, or hide a
12+
* blocked one).
13+
*/
14+
15+
test("classifies the variance/control scenarios as exceptions", () => {
16+
for (const s of [
17+
"Price mismatch",
18+
"Quantity mismatch",
19+
"Arithmetic error",
20+
"Line not on PO",
21+
"Inactive vendor (ERP)",
22+
]) {
23+
assert.equal(scenarioKind(s), "exception", s);
24+
}
25+
});
26+
27+
test("classifies the duplicate scenarios as blocked", () => {
28+
assert.equal(scenarioKind("Duplicate invoice"), "blocked");
29+
assert.equal(scenarioKind("Already paid (ERP duplicate)"), "blocked");
30+
});
31+
32+
test("classifies clean matches (and the paid original) as clean", () => {
33+
for (const s of [
34+
"Clean 3-way match",
35+
"Clean 2-way (services)",
36+
"Original (paid)",
37+
null,
38+
]) {
39+
assert.equal(scenarioKind(s), "clean", String(s));
40+
}
41+
});
42+
43+
test("scenarioBadge marks exception (warn) and blocked (danger), not clean", () => {
44+
assert.deepEqual(scenarioBadge("exception"), {
45+
tone: "warn",
46+
label: "exception",
47+
});
48+
assert.deepEqual(scenarioBadge("blocked"), {
49+
tone: "danger",
50+
label: "blocked",
51+
});
52+
// Clean rows stay unmarked, so the marks draw the eye.
53+
assert.equal(scenarioBadge("clean"), null);
54+
});
55+
56+
test("every seeded scenario classifies without falling through wrongly", () => {
57+
// A guard against a future seed label that the classifier would silently treat
58+
// as clean when it's actually an exception/blocked. We assert the known kinds
59+
// line up with the labels the demo depends on.
60+
const byId = new Map(SEED_BUNDLES.map((b) => [b.id, b.scenario]));
61+
assert.equal(scenarioKind(byId.get("INV-2042") ?? null), "exception"); // price
62+
assert.equal(scenarioKind(byId.get("INV-2048") ?? null), "exception"); // qty
63+
assert.equal(scenarioKind(byId.get("INV-1990") ?? null), "blocked"); // already paid
64+
assert.equal(scenarioKind(byId.get("INV-2040") ?? null), "clean"); // clean 3-way
65+
});

lib/display.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,47 @@ export const outcomeDot = (outcome: Outcome): string => {
6363
}
6464
};
6565

66+
/**
67+
* How a SEEDED scenario should be signposted in the queue BEFORE it's run — so a
68+
* first-time visitor's eye goes straight to the interesting cases instead of a
69+
* flat list. Derived from the scenario label (already on every QueueItem), not a
70+
* new query. Three kinds, deliberately coarse:
71+
* • "exception" — a flagged invoice (variance / control) that routes to a human
72+
* • "blocked" — a duplicate that's stopped before approval
73+
* • "clean" — a straight-through match (gets NO badge; only the noteworthy
74+
* rows are marked, so the marks mean something)
75+
*/
76+
export type ScenarioKind = "exception" | "blocked" | "clean";
77+
78+
export const scenarioKind = (scenario: string | null): ScenarioKind => {
79+
const s = (scenario ?? "").toLowerCase();
80+
if (s.includes("duplicate") || s.includes("already paid")) return "blocked";
81+
if (
82+
s.includes("mismatch") ||
83+
s.includes("error") ||
84+
s.includes("not on po") ||
85+
s.includes("inactive")
86+
) {
87+
return "exception";
88+
}
89+
return "clean";
90+
};
91+
92+
/** The badge tone + short label for a signposted scenario kind (queue, pre-run).
93+
* `clean` returns null — clean rows stay unmarked so the marks draw the eye. */
94+
export const scenarioBadge = (
95+
kind: ScenarioKind,
96+
): { tone: BadgeTone; label: string } | null => {
97+
switch (kind) {
98+
case "exception":
99+
return { tone: "warn", label: "exception" };
100+
case "blocked":
101+
return { tone: "danger", label: "blocked" };
102+
case "clean":
103+
return null;
104+
}
105+
};
106+
66107
/** Map a trace step's status to a badge tone (for the timeline). */
67108
export const statusTone = (status: TraceStatus): BadgeTone => {
68109
switch (status) {

0 commit comments

Comments
 (0)