Skip to content

Commit 05d24fd

Browse files
DylanMerigaudclaude
andcommitted
Make department routing discoverable + add a clarifying turn to the editor
The conversational editor didn't scale: only one department (Product) was demonstrable, nothing told the user which departments/conditions exist, and the model accepted any department string — so "add a Finance review" created a gate that never fired. Three changes fix that: - Seed three departments (Product, Operations, Finance) on real POs, each mapping to a real org head, so a department-scoped gate is demonstrable. - Discoverability in the editor: the org's REAL departments render as coloured chips ("Route by department") you can click to route on, and the condition fields (amount / department / variance / verdict) are listed in the "What can I change?" popover. The chips' departments are sent to the edit agent. - A clarifying turn the model chooses ONLY when needed: a new `clarify` op lets the planner ask "which department?" (with the valid options) instead of guessing or inventing a dead gate, when an instruction is missing or names an unknown one. The agent short-circuits on it (workflow unchanged), the oRPC EditResult carries it, and the UI shows the same chips; picking one re-submits the completed instruction. A complete instruction still applies directly. Verified live in the browser (e2e/edit-clarify.e2e.ts): "add a department review" → the agent asks which one → pick Finance → a Finance gate is proposed. The model even omits a department the workflow already gates. Gate green, 194 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5a48026 commit 05d24fd

13 files changed

Lines changed: 373 additions & 16 deletions

components/onboarding.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ export const Onboarding = ({
130130
key={state.data.workflow.name + state.data.employeeCount}
131131
initial={state.data.workflow}
132132
suggestions={state.data.suggestions}
133+
departments={departmentsOf(state.data.employees)}
133134
onCurrentChange={onWorkflowChange}
134135
/>
135136
) : (
@@ -244,12 +245,36 @@ const WhatCanIChange = () => {
244245
</li>
245246
))}
246247
</ul>
248+
{/* The levers a gate's condition can route on — so "what can I gate on?" is
249+
answerable, not guesswork. Values come from the invoice in flight. */}
250+
<div className="mt-1 border-t border-line px-2 pb-1 pt-2">
251+
<span className="block text-[10px] font-medium uppercase tracking-wider text-faint">
252+
Conditions you can route on
253+
</span>
254+
<span className="mt-1 block font-mono text-[10.5px] leading-relaxed text-muted">
255+
amount &gt; $ · department == · variance ≥ % · verdict
256+
(clean/exception)
257+
</span>
258+
</div>
247259
</div>
248260
)}
249261
</div>
250262
);
251263
};
252264

265+
/** The distinct, non-empty departments in the org — the values a department gate can
266+
route on, so the editor can offer them (and the agent only proposes real ones).
267+
Sorted for a stable order; "Company" is the org root, not a buying team, so it's
268+
dropped. */
269+
const departmentsOf = (employees: OrgEmployee[]): string[] => {
270+
const set = new Set(
271+
employees
272+
.map((e) => e.department.trim())
273+
.filter((d) => d.length > 0 && d !== "Company"),
274+
);
275+
return [...set].sort();
276+
};
277+
253278
/** Initials for an avatar chip ("Riley Carter" → "RC"). */
254279
const initials = (name: string): string =>
255280
name

components/workflow-editor.tsx

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,15 @@ type Proposal = {
3737
export const WorkflowEditor = ({
3838
initial,
3939
suggestions = [],
40+
departments = [],
4041
onCurrentChange,
4142
}: {
4243
initial: ApprovalWorkflow;
4344
/** AI-generated next-edit suggestions for the initial workflow (may be empty). */
4445
suggestions?: string[];
46+
/** The departments that exist in the org — shown as chips you can route on, and
47+
sent to the edit agent so it only ever proposes a real one. */
48+
departments?: string[];
4549
/** Called with the CURRENT (approved) workflow whenever it changes — the initial
4650
one, then each kept edit. Never the pending proposal (preview-only). Lets a
4751
parent (AppView) run the pipeline against exactly what's on screen here. */
@@ -51,6 +55,13 @@ export const WorkflowEditor = ({
5155
const [proposal, setProposal] = useState<Proposal | null>(null);
5256
const [instruction, setInstruction] = useState("");
5357
const [error, setError] = useState<string | null>(null);
58+
// A pending clarification from the agent ("which department?") + the instruction
59+
// that triggered it, so clicking an option can re-submit a completed instruction.
60+
const [clarify, setClarify] = useState<{
61+
question: string;
62+
options: string[];
63+
instruction: string;
64+
} | null>(null);
5465
// Suggestions are consumable: once one produces a proposal it's removed, so a
5566
// chip never lingers after it's been used.
5667
const [chips, setChips] = useState<string[]>(suggestions);
@@ -77,7 +88,16 @@ export const WorkflowEditor = ({
7788
const data = await editMutation.mutateAsync({
7889
workflow: current,
7990
instruction: value,
91+
departments,
8092
});
93+
// The agent needs a missing piece (e.g. which department) — offer the options
94+
// instead of an edit. Remember the instruction so a pick can complete it.
95+
if (data.clarify) {
96+
setClarify({ ...data.clarify, instruction: value });
97+
setInstruction("");
98+
return;
99+
}
100+
setClarify(null);
81101
const realChanges = data.changes.filter((c) => c.kind !== "unchanged");
82102
if (realChanges.length === 0) {
83103
// The agent declined (redundant / off-topic) — say so, don't offer a no-op.
@@ -97,6 +117,14 @@ export const WorkflowEditor = ({
97117
}
98118
};
99119

120+
/** The user picked a clarification option → re-submit the original instruction
121+
completed with the choice (re-uses the whole edit flow). */
122+
const pickClarifyOption = (option: string) => {
123+
const base = clarify?.instruction ?? "";
124+
setClarify(null);
125+
void submit(`${base} for ${option}`);
126+
};
127+
100128
// Validate whatever's on screen (the proposal if pending, else the live one).
101129
// Errors block applying a proposal; warnings are surfaced but don't block.
102130
const shown = proposal?.proposed ?? current;
@@ -120,6 +148,7 @@ export const WorkflowEditor = ({
120148
setProposal(null);
121149
setChips(suggestions);
122150
setError(null);
151+
setClarify(null);
123152
};
124153

125154
const changedCount = proposal
@@ -176,11 +205,31 @@ export const WorkflowEditor = ({
176205
{error}
177206
</div>
178207
)}
208+
{/* The agent asked for a missing piece (e.g. which department) — show its
209+
question + the choices as chips. Clicking one re-submits the completed
210+
instruction. Takes over the chip area while it's pending. */}
211+
{!proposal && clarify && (
212+
<div className="space-y-1.5 rounded-xl bg-accent-soft/40 px-3 py-2.5 ring-1 ring-inset ring-accent/15">
213+
<p className="text-[12.5px] font-medium text-ink">
214+
{clarify.question}
215+
</p>
216+
<div className="flex flex-wrap gap-1.5">
217+
{clarify.options.map((o) => (
218+
<DeptChip
219+
key={o}
220+
label={o}
221+
onClick={() => pickClarifyOption(o)}
222+
disabled={busy}
223+
/>
224+
))}
225+
</div>
226+
</div>
227+
)}
179228
{/* AI-suggested next edits for this workflow. Only shown before a pending
180229
proposal, and only when the model returned some — no fixed chips, so a
181230
suggestion is always a real, applicable next step. A used chip is
182231
removed (consumed) once it produces a proposal. */}
183-
{!proposal && chips.length > 0 && (
232+
{!proposal && !clarify && chips.length > 0 && (
184233
<div className="space-y-1.5">
185234
<div className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-faint">
186235
<SparkIcon />
@@ -201,6 +250,28 @@ export const WorkflowEditor = ({
201250
</div>
202251
</div>
203252
)}
253+
{/* Route-by-department chips — the REAL departments from the org, so a user
254+
sees what they can route on (and the agent only ever proposes a real one).
255+
Clicking a chip asks for a review scoped to that department. Hidden while a
256+
proposal or a clarification is pending. */}
257+
{!proposal && !clarify && departments.length > 0 && (
258+
<div className="space-y-1.5">
259+
<div className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-faint">
260+
<BuildingIcon />
261+
Route by department
262+
</div>
263+
<div className="flex flex-wrap gap-1.5">
264+
{departments.map((d) => (
265+
<DeptChip
266+
key={d}
267+
label={d}
268+
onClick={() => submit(`add a review for ${d} bills`)}
269+
disabled={busy}
270+
/>
271+
))}
272+
</div>
273+
</div>
274+
)}
204275
<form
205276
onSubmit={(e) => {
206277
e.preventDefault();
@@ -303,3 +374,55 @@ const SparkIcon = () => {
303374
</svg>
304375
);
305376
};
377+
378+
/** A small building glyph, marking the route-by-department chips. */
379+
const BuildingIcon = () => {
380+
return (
381+
<svg viewBox="0 0 12 12" className="size-3" fill="currentColor" aria-hidden>
382+
<path d="M2 11V2.5A.5.5 0 0 1 2.5 2H7a.5.5 0 0 1 .5.5V5H10a.5.5 0 0 1 .5.5V11H2Zm1.5-1H5V8.5H3.5V10Zm0-2.5H5V6H3.5v1.5Zm0-2.5H5V3.5H3.5V5Zm5 5H9V8.5H8.5V10Zm-2 0H7V8.5h-.5V10Zm2-2.5H9V6h-.5v1.5Z" />
383+
</svg>
384+
);
385+
};
386+
387+
/**
388+
* Stable decorative hue from a label (not semantic — it must NOT reuse ok/danger
389+
* tones, which carry meaning). Same name → same colour across renders, so the org's
390+
* departments read as a consistent little palette.
391+
*/
392+
const hueFor = (label: string): number => {
393+
let h = 0;
394+
for (const ch of label) h = (h * 31 + ch.charCodeAt(0)) % 360;
395+
return h;
396+
};
397+
398+
/**
399+
* A department / clarification chip: the app's interactive accent chip plus a small
400+
* coloured dot keyed to the label, so a row of departments looks alive without
401+
* inventing a semantic colour scale. Clicking submits the relevant instruction.
402+
*/
403+
const DeptChip = ({
404+
label,
405+
onClick,
406+
disabled,
407+
}: {
408+
label: string;
409+
onClick: () => void;
410+
disabled?: boolean;
411+
}) => {
412+
const hue = hueFor(label);
413+
return (
414+
<button
415+
type="button"
416+
onClick={onClick}
417+
disabled={disabled}
418+
className="inline-flex items-center gap-1.5 rounded-lg bg-subtle px-2.5 py-1.5 text-[12px] font-medium text-muted ring-1 ring-inset ring-line-strong transition-colors hover:bg-accent-soft hover:text-accent hover:ring-accent/30 disabled:opacity-50"
419+
>
420+
<span
421+
aria-hidden
422+
className="size-2 rounded-full"
423+
style={{ backgroundColor: `hsl(${hue} 55% 55%)` }}
424+
/>
425+
{label}
426+
</button>
427+
);
428+
};

db/seed-data.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ const byId = (id: string): SeedBundle => {
3939
return b;
4040
};
4141

42+
test("the demo's department POs carry their buying department", () => {
43+
// Three distinct departments are seeded so a department-scoped gate is demonstrable
44+
// (and each maps to a real org head). If a future edit drops one, the
45+
// "route by department" demo would quietly stop firing.
46+
const dept = (id: string) => byId(id).purchaseOrder?.department;
47+
assert.equal(dept("INV-2044"), "Product"); // PO-7744
48+
assert.equal(dept("INV-2042"), "Operations"); // PO-7742
49+
assert.equal(dept("INV-2047"), "Finance"); // PO-7747
50+
});
51+
4252
test("every seeded document validates against the Zod schema", () => {
4353
for (const b of SEED_BUNDLES) {
4454
assert.doesNotThrow(() => Invoice.parse(b.invoice), `${b.id} invoice`);

db/seed-data.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,8 @@ const priceMismatch: SeedBundle = {
167167
currency: "GBP",
168168
lineItems: poSteelLines,
169169
total: sum(poSteelLines),
170-
department: "",
170+
// Operational/facilities spend → the Operations team owns it (COO is the head).
171+
department: "Operations",
171172
},
172173
goodsReceipt: {
173174
grNumber: "GR-5542",
@@ -373,7 +374,8 @@ const cleanChem: SeedBundle = {
373374
currency: "USD",
374375
lineItems: chemLines,
375376
total: sum(chemLines),
376-
department: "",
377+
// A Finance-controlled budget line → the Finance team reviews it (CFO is the head).
378+
department: "Finance",
377379
},
378380
goodsReceipt: {
379381
grNumber: "GR-5547",

e2e/edit-clarify.e2e.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { test, expect } from "@playwright/test";
2+
3+
/**
4+
* The conversational editor's discoverability + clarifying turn, through the real
5+
* browser + the live edit model.
6+
*
7+
* After discovery the editor shows the org's REAL departments as chips. An ambiguous
8+
* instruction ("add a department review", no department named) makes the agent ask
9+
* which one instead of inventing a dead gate — the clarifying turn the model chooses
10+
* only when a slot is missing. Picking an option completes the instruction and the
11+
* gate is proposed. Needs ANTHROPIC_API_KEY + DATABASE_URL (discovery + edit model),
12+
* so it's local-only (`pnpm e2e`), recorded HRIS (BAMBOO_HR_API_KEY= unset) is fine.
13+
*/
14+
15+
const DISCOVERY_TIMEOUT = 90_000;
16+
const EDIT_TIMEOUT = 45_000;
17+
18+
test("an ambiguous department edit asks which one, then applies the pick", async ({
19+
page,
20+
}) => {
21+
await page.goto("/");
22+
await page.getByRole("button", { name: /Discover from BambooHR/ }).click();
23+
24+
// Discoverability: the real org departments show as chips you can route on.
25+
await expect(page.getByText(/Route by department/)).toBeVisible({
26+
timeout: DISCOVERY_TIMEOUT,
27+
});
28+
29+
// Ambiguous instruction → the agent asks which department (it must NOT guess).
30+
await page
31+
.getByPlaceholder(/Describe a change/)
32+
.fill("add a department review");
33+
await page.getByRole("button", { name: /^Edit$/ }).click();
34+
await expect(page.getByText(/which department/i)).toBeVisible({
35+
timeout: EDIT_TIMEOUT,
36+
});
37+
38+
// Pick Finance from the clarification options → it completes the instruction and
39+
// proposes a Finance-scoped gate (the diff bar appears).
40+
await page.getByRole("button", { name: "Finance" }).last().click();
41+
await expect(page.getByText(/not applied yet/i)).toBeVisible({
42+
timeout: EDIT_TIMEOUT,
43+
});
44+
// The proposed graph mentions Finance (the new gate's department scope).
45+
await expect(page.getByText(/Finance/).first()).toBeVisible();
46+
});

eval/edit-agent-run.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ import { validateWorkflow, isActivatable } from "@/lib/workflow-validate";
2626

2727
const dryRun = process.argv.includes("--dry-run");
2828

29+
/** The departments the agent may scope a gate to (the demo org's set). A live case
30+
naming one outside this list should make the planner clarify, not invent. */
31+
const EVAL_DEPARTMENTS = ["Finance", "Operations", "Product", "Sales"];
32+
2933
const loadEnv = (): void => {
3034
for (const f of [".env.local", ".env"]) {
3135
try {
@@ -89,7 +93,9 @@ const main = async (): Promise<void> => {
8993
const rows: Row[] = [];
9094
for (const c of AGENT_CASES) {
9195
const model = dryRun ? stubModel(c) : (liveModel ?? stubModel(c));
92-
const result = await runEditAgent(model, EDIT_FIXTURE, c.instruction);
96+
const result = await runEditAgent(model, EDIT_FIXTURE, c.instruction, {
97+
departments: EVAL_DEPARTMENTS,
98+
});
9399
const row = score(c, result);
94100
rows.push(row);
95101
console.log(

lib/orpc/router.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,13 @@ const editWorkflow = rateLimited
8888
.output(EditResult)
8989
.handler(async ({ input }) => {
9090
try {
91-
const { proposed, changes, reason } = await runEditAgent(
91+
const { proposed, changes, reason, clarify } = await runEditAgent(
9292
anthropicPlanModel,
9393
input.workflow,
9494
input.instruction,
95+
{ departments: input.departments },
9596
);
96-
return { proposed, changes, reason };
97+
return { proposed, changes, reason, clarify };
9798
} catch {
9899
throw new ORPCError("UNPROCESSABLE_CONTENT", {
99100
message:

lib/orpc/schemas.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,23 @@ const StepChangeSchema = z.discriminatedUnion("kind", [
6767
export const EditInput = z.object({
6868
workflow: ApprovalWorkflow,
6969
instruction: z.string().trim().min(1, "an instruction is required"),
70+
/** The departments that exist in the client's org, so a department gate can only
71+
target a real one (the agent returns a clarify when it can't). Optional —
72+
defaults to none, in which case any department instruction is clarified. */
73+
departments: z.array(z.string()).default([]),
74+
});
75+
76+
/** The agent asks for a missing piece (e.g. which department) — the UI shows the
77+
question + clickable options; the user's pick re-submits a completed instruction. */
78+
const ClarificationSchema = z.object({
79+
question: z.string(),
80+
options: z.array(z.string()),
7081
});
7182

7283
export const EditResult = z.object({
7384
proposed: ApprovalWorkflow,
7485
changes: z.array(StepChangeSchema),
7586
reason: z.string().nullable(),
87+
/** Set when the agent needs a clarification before editing (workflow unchanged). */
88+
clarify: ClarificationSchema.nullable(),
7689
});

0 commit comments

Comments
 (0)