Skip to content

Commit 4ee85cd

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/pull-erp
2 parents 887e3f7 + 38e3a8c commit 4ee85cd

10 files changed

Lines changed: 276 additions & 41 deletions

File tree

components/dashboard.tsx

Lines changed: 90 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -395,27 +395,51 @@ export const Dashboard = ({
395395
const [gateChoices, setGateChoices] = useState<
396396
Record<string, "approve" | "reject">
397397
>({});
398-
// Clear staged decisions whenever the run leaves the awaiting state (resolved,
399-
// re-run, or a new wave streams in fresh) so they never leak across waves/runs.
398+
// Reject notes staged per gate (multi-gate), keyed by step id.
399+
const [gateReasons, setGateReasons] = useState<Record<string, string>>({});
400+
// Single-gate header: clicking Reject arms a reason input before confirming.
401+
const [rejecting, setRejecting] = useState(false);
402+
const [rejectReason, setRejectReason] = useState("");
403+
// Clear all staged decision/reason state whenever the run leaves the awaiting
404+
// state (resolved, re-run, or a new wave streams in fresh) so nothing leaks.
400405
const awaiting = state.status === "awaiting";
401406
const wasAwaitingRef = useRef(false);
402407
useEffect(() => {
403-
if (wasAwaitingRef.current && !awaiting) setGateChoices({});
408+
if (wasAwaitingRef.current && !awaiting) {
409+
setGateChoices({});
410+
setGateReasons({});
411+
setRejecting(false);
412+
setRejectReason("");
413+
}
404414
wasAwaitingRef.current = awaiting;
405415
}, [awaiting]);
406416

407417
const setGate = useEventCallback((id: string, choice: "approve" | "reject") =>
408418
setGateChoices((m) => ({ ...m, [id]: choice })),
409419
);
420+
// A gate flipped back to approve drops any reject note it had staged.
421+
const setGateReason = useEventCallback((id: string, reason: string) =>
422+
setGateReasons((m) => ({ ...m, [id]: reason })),
423+
);
410424
const setAllGates = (choice: "approve" | "reject") =>
411425
setGateChoices(Object.fromEntries(pendingIds.map((id) => [id, choice])));
412426
const allDecided =
413427
gates.length > 0 && pendingIds.every((id) => gateChoices[id]);
428+
// Only notes on gates still staged as reject go out (approve drops the note).
429+
const rejectReasons = (): Record<string, string> =>
430+
Object.fromEntries(
431+
Object.entries(gateReasons).filter(
432+
([id, r]) => gateChoices[id] === "reject" && r.trim(),
433+
),
434+
);
414435

415436
const select = (id: string) => {
416437
if (id === selectedId || locked) return;
417438
setSelectedId(id);
418439
setGateChoices({});
440+
setGateReasons({});
441+
setRejecting(false);
442+
setRejectReason("");
419443
reset();
420444
};
421445

@@ -592,33 +616,71 @@ export const Dashboard = ({
592616
size="sm"
593617
data-testid="submit-decisions"
594618
disabled={!allDecided}
595-
onClick={() => decideMany(selected.id, gateChoices)}
619+
onClick={() =>
620+
decideMany(selected.id, gateChoices, rejectReasons())
621+
}
596622
>
597623
Submit decisions
598624
</Button>
599625
</div>
600626
) : state.status === "awaiting" && selected ? (
601-
// A single gate — decide it straight from the header.
627+
// A single gate — decide it straight from the header. Reject first arms a
628+
// reason input (optional note) before confirming, so a blocked bill carries
629+
// a why into the trace + the audit history.
602630
<div
603631
className="flex shrink-0 items-center gap-2"
604632
data-testid="approval-gate"
605633
>
606-
<Button
607-
variant="danger"
608-
size="sm"
609-
data-testid="reject-btn"
610-
onClick={() => decide(selected.id, "reject")}
611-
>
612-
Reject
613-
</Button>
614-
<Button
615-
variant="ok"
616-
size="sm"
617-
data-testid="approve-btn"
618-
onClick={() => decide(selected.id, "approve")}
619-
>
620-
Approve
621-
</Button>
634+
{rejecting ? (
635+
<>
636+
<input
637+
value={rejectReason}
638+
onChange={(e) => setRejectReason(e.target.value)}
639+
onKeyDown={(e) => {
640+
if (e.key === "Enter")
641+
void decide(selected.id, "reject", rejectReason);
642+
if (e.key === "Escape") setRejecting(false);
643+
}}
644+
placeholder="Reason (optional)"
645+
data-testid="reject-reason"
646+
className="h-8 w-48 rounded-lg bg-surface px-2.5 text-[12px] text-ink outline-none ring-1 ring-inset ring-line-strong transition-shadow focus:ring-2 focus:ring-accent-ring"
647+
/>
648+
<Button
649+
variant="ghost"
650+
size="sm"
651+
onClick={() => setRejecting(false)}
652+
>
653+
Cancel
654+
</Button>
655+
<Button
656+
variant="danger"
657+
size="sm"
658+
data-testid="reject-confirm"
659+
onClick={() => decide(selected.id, "reject", rejectReason)}
660+
>
661+
Reject
662+
</Button>
663+
</>
664+
) : (
665+
<>
666+
<Button
667+
variant="danger"
668+
size="sm"
669+
data-testid="reject-btn"
670+
onClick={() => setRejecting(true)}
671+
>
672+
Reject
673+
</Button>
674+
<Button
675+
variant="ok"
676+
size="sm"
677+
data-testid="approve-btn"
678+
onClick={() => decide(selected.id, "approve")}
679+
>
680+
Approve
681+
</Button>
682+
</>
683+
)}
622684
</div>
623685
) : (
624686
// Run lives in the header at all times a row is selected: "Run
@@ -701,9 +763,16 @@ export const Dashboard = ({
701763
<WorkflowGraph
702764
workflow={graphToShow}
703765
statuses={graphStatuses}
766+
// When >1 gate pends in parallel, each pending node gets inline
767+
// Approve/Reject (decide one, reject another) + a reason input on a
768+
// node staged reject. A single gate uses the header buttons, so the
769+
// graph stays read-only there.
704770
decidableIds={gates.length >= 2 ? pendingIds : undefined}
705771
decisions={gates.length >= 2 ? gateChoices : undefined}
772+
reasons={gates.length >= 2 ? gateReasons : undefined}
706773
onDecide={gates.length >= 2 ? setGate : undefined}
774+
onReason={gates.length >= 2 ? setGateReason : undefined}
775+
// Pan to frame the waiting gate(s) the moment the run pauses.
707776
focusIds={awaiting ? pendingIds : undefined}
708777
/>
709778
</div>

components/workflow-graph.tsx

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,12 @@ type NodeData = {
123123
decidable?: boolean;
124124
/** The decision staged on this gate (before the reviewer submits the wave). */
125125
choice?: "approve" | "reject";
126+
/** The reject note staged on this gate (only when choice is "reject"). */
127+
reason?: string;
126128
/** Record the reviewer's choice for this gate. */
127129
onDecide?: (choice: "approve" | "reject") => void;
130+
/** Record the reviewer's reject note for this gate. */
131+
onReason?: (reason: string) => void;
128132
};
129133

130134
/** Ring/bg for a validation issue on a node (only used when there's no diff change). */
@@ -145,7 +149,9 @@ const StepNode = ({ data }: NodeProps<Node<NodeData>>) => {
145149
selected,
146150
decidable,
147151
choice,
152+
reason,
148153
onDecide,
154+
onReason,
149155
} = data;
150156
const st = statusTone(status);
151157
const cb = changeBadge(change);
@@ -287,6 +293,18 @@ const StepNode = ({ data }: NodeProps<Node<NodeData>>) => {
287293
</button>
288294
</div>
289295
)}
296+
297+
{/* Reject note — appears on a gate staged "reject" so the blocked bill carries
298+
a why into the trace + audit. Optional. */}
299+
{decidable && onReason && choice === "reject" && (
300+
<input
301+
value={reason ?? ""}
302+
onChange={(e) => onReason(e.target.value)}
303+
placeholder="Reason (optional)"
304+
data-testid={`gate-reason-${step.id}`}
305+
className="nodrag nopan mt-1.5 h-7 w-full rounded-lg bg-surface px-2 text-[12px] text-ink outline-none ring-1 ring-inset ring-danger-line transition-shadow focus:ring-2 focus:ring-accent-ring"
306+
/>
307+
)}
290308
<Handle
291309
type="source"
292310
position={sourcePos}
@@ -424,7 +442,9 @@ const Inner = ({
424442
selectedId,
425443
decisions,
426444
decidableIds,
445+
reasons,
427446
onDecide,
447+
onReason,
428448
focusIds,
429449
}: WorkflowGraphProps) => {
430450
// Stack the DAG vertically below the `sm` breakpoint (640px), where a wide
@@ -577,20 +597,33 @@ const Inner = ({
577597
.sort()
578598
.join("|")
579599
: "";
600+
const reasonKey = reasons
601+
? Object.entries(reasons)
602+
.map(([k, v]) => `${k}:${v}`)
603+
.sort()
604+
.join("|")
605+
: "";
580606
useEffect(() => {
581607
const decidable = new Set(decidableIds ?? []);
582608
setNodes((cur) =>
583609
cur.map((n) => {
584610
const isDecidable = decidable.has(n.id);
585611
const choice = decisions?.[n.id] ?? undefined;
612+
const reason = reasons?.[n.id] ?? undefined;
586613
const handler =
587614
isDecidable && onDecide
588615
? (c: "approve" | "reject") => onDecide(n.id, c)
589616
: undefined;
617+
const reasonHandler =
618+
isDecidable && onReason
619+
? (r: string) => onReason(n.id, r)
620+
: undefined;
590621
if (
591622
n.data.decidable === isDecidable &&
592623
n.data.choice === choice &&
593-
n.data.onDecide === handler
624+
n.data.reason === reason &&
625+
n.data.onDecide === handler &&
626+
n.data.onReason === reasonHandler
594627
) {
595628
return n;
596629
}
@@ -600,12 +633,24 @@ const Inner = ({
600633
...n.data,
601634
decidable: isDecidable,
602635
choice: choice ?? undefined,
636+
reason: reason ?? undefined,
603637
onDecide: handler,
638+
onReason: reasonHandler,
604639
},
605640
};
606641
}),
607642
);
608-
}, [decidableKey, choiceKey, decidableIds, decisions, onDecide, setNodes]);
643+
}, [
644+
decidableKey,
645+
choiceKey,
646+
reasonKey,
647+
decidableIds,
648+
decisions,
649+
reasons,
650+
onDecide,
651+
onReason,
652+
setNodes,
653+
]);
609654

610655
// Smoothly frame the focused nodes (the pending group, or one on hover). Runs only
611656
// AFTER the initial per-graph layout+fit (the `laidOutFor === graphKey` guard) so it
@@ -658,8 +703,12 @@ type WorkflowGraphProps = {
658703
decisions?: Record<string, "approve" | "reject" | null>;
659704
/** When set, a node here accepts a decision now (a paused gate). */
660705
decidableIds?: string[];
706+
/** Staged reject notes per step id (shown on a node staged "reject"). */
707+
reasons?: Record<string, string>;
661708
/** Record a per-gate decision (the inline node controls). */
662709
onDecide?: (stepId: string, choice: "approve" | "reject") => void;
710+
/** Record a per-gate reject note (the inline node reason input). */
711+
onReason?: (stepId: string, reason: string) => void;
663712
/** Smoothly pan/zoom to frame these nodes (the pending group, or one on hover). */
664713
focusIds?: string[];
665714
};

e2e/approval.e2e.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import { test, expect, type Page } from "@playwright/test";
1515
* INV-2042 — price mismatch (exception → pauses for approval)
1616
*/
1717

18-
const RUN_TIMEOUT = 30_000; // a 4-agent Haiku run is a few seconds; allow margin
18+
const RUN_TIMEOUT = 45_000; // a Haiku run is usually a few seconds, but the model can
19+
// occasionally take 30s+; give the pause/resume asserts headroom so latency isn't a flake
1920

2021
const selectAndRun = async (page: Page, rowId: string) => {
2122
await page.getByTestId(`queue-row-${rowId}`).click();
@@ -104,13 +105,18 @@ test("price-mismatch pauses for approval, then APPROVE posts it", async ({
104105
await expect(page.getByText("Pipeline started")).toHaveCount(0);
105106
});
106107

107-
test("price-mismatch REJECT leaves it un-posted", async ({ page }) => {
108+
test("price-mismatch REJECT (with a reason) leaves it un-posted", async ({
109+
page,
110+
}) => {
108111
await selectAndRun(page, "INV-2042");
109112

110113
await expect(page.getByTestId("approval-gate")).toBeVisible({
111114
timeout: RUN_TIMEOUT,
112115
});
116+
// Reject arms a reason field; type a note, then confirm.
113117
await page.getByTestId("reject-btn").click();
118+
await page.getByTestId("reject-reason").fill("price too high, renegotiate");
119+
await page.getByTestId("reject-confirm").click();
114120

115121
// Reconciliation ends in error (rejected), and nothing was posted.
116122
await expect(step(page, "reconciliation")).toHaveAttribute(
@@ -121,5 +127,8 @@ test("price-mismatch REJECT leaves it un-posted", async ({ page }) => {
121127
},
122128
);
123129
await expect(page.getByText(/NETSUITE-BILL-/)).toHaveCount(0);
124-
await expect(page.getByText(/[Rr]eject/).first()).toBeVisible();
130+
// The reason rides into the trace on the rejected gate's detail.
131+
await expect(
132+
page.getByText(/price too high, renegotiate/).first(),
133+
).toBeVisible();
125134
});

lib/api-types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ export const RunRequest = z.object({
3131
id: z.string().min(1, "an invoice id is required"),
3232
/** Reviewer decisions keyed by workflow step id. Omitted on the first run. */
3333
decisions: z.record(z.string(), StepDecision).optional(),
34+
/** Optional note the reviewer attached when REJECTING a gate, keyed by step id.
35+
Sparse (only rejects), so it rides alongside `decisions` rather than folding
36+
into it. Surfaces in the rejected step's trace detail + the audit history. */
37+
reasons: z.record(z.string(), z.string()).optional(),
3438
/** The approval workflow this run executes — the one the onboarding agent
3539
derived and the user edited, passed in (never persisted: the run stays
3640
stateless). Optional: when absent the run falls back to the default DAG, so a

lib/approval-engine.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,38 @@ test("manager rejection blocks the whole tree", () => {
146146
assert.equal(s.outcome, "rejected");
147147
});
148148

149+
const detail = (s: ReturnType<typeof executeWorkflow>, id: string) =>
150+
s.steps.find((x) => x.id === id)!.detail;
151+
152+
test("a reject reason shows in the rejected step's detail", () => {
153+
const s = executeWorkflow(
154+
wf,
155+
ctx({ amount: 9000 }),
156+
{ manager: "reject" },
157+
{ manager: "price too high, renegotiate" },
158+
);
159+
assert.match(
160+
detail(s, "manager"),
161+
/Rejected by .+: price too high, renegotiate/,
162+
);
163+
});
164+
165+
test("a reject WITHOUT a reason keeps the bare detail (no regression)", () => {
166+
const s = executeWorkflow(wf, ctx({ amount: 9000 }), { manager: "reject" });
167+
assert.match(detail(s, "manager"), /^Rejected by .+\.$/);
168+
});
169+
170+
test("a reason for a non-rejected / absent step is ignored", () => {
171+
// A reason keyed to an APPROVED gate doesn't leak into its detail.
172+
const s = executeWorkflow(
173+
wf,
174+
ctx({ amount: 9000 }),
175+
{ manager: "approve" },
176+
{ manager: "should be ignored", ghost: "nobody" },
177+
);
178+
assert.match(detail(s, "manager"), /^Approved by /);
179+
});
180+
149181
test("director gates exactly at the threshold (strict >)", () => {
150182
const at = executeWorkflow(wf, ctx({ amount: 5000 }), { manager: "approve" });
151183
assert.equal(status(at, "director"), "skipped"); // 5000 is not > 5000

0 commit comments

Comments
 (0)