feat(giellie): show stock quantity approvals as a table

A 40-line stocktake in raw JSON is unreviewable. The stock quantity
tool gets a table with the mode, SKU, description and quantity.
Every other tool keeps its JSON exactly as it was.
This commit is contained in:
Dave 2026-11-06 00:52:35 +02:00
parent 5c106cf033
commit 0db696c898
2 changed files with 133 additions and 3 deletions

View file

@ -94,3 +94,82 @@ describe("ApprovalCard", () => {
expect((screen.getByRole("button", { name: /reject/i }) as HTMLButtonElement).disabled).toBe(true);
});
});
describe("the stock quantity tool renders a table", () => {
it("lists the sku, description and qty", () => {
render(
<ApprovalCard
pending={{
tool_uses: [
{
id: "w1",
name: "stock_items.set_quantities",
input: {
mode: "set",
lines: [
{ sku: "ABC-123", description: "Bolt M12", qty: 50 },
{ sku: "BOLT-6MM", description: "Bolt M6", qty: 120 },
],
},
confirmAlways: false,
},
],
completed: [],
order: ["w1"],
}}
onDecide={vi.fn()}
busy={false}
/>,
);
expect(screen.getByText("ABC-123")).toBeTruthy();
expect(screen.getByText("Bolt M12")).toBeTruthy();
expect(screen.getByText("50")).toBeTruthy();
expect(screen.getByText(/new quantity on hand/i)).toBeTruthy();
expect(document.body.textContent).not.toMatch(/"lines"/);
});
it("says when the quantities are being added instead of set", () => {
render(
<ApprovalCard
pending={{
tool_uses: [
{
id: "w1",
name: "stock_items.set_quantities",
input: { mode: "add", lines: [{ sku: "ABC-123", qty: 50 }] },
confirmAlways: false,
},
],
completed: [],
order: ["w1"],
}}
onDecide={vi.fn()}
busy={false}
/>,
);
expect(screen.getByText(/adding these quantities/i)).toBeTruthy();
});
it("leaves other tools as JSON", () => {
render(
<ApprovalCard
pending={{
tool_uses: [
{
id: "w1",
name: "orders.update",
input: { id: "abc", status: "approved" },
confirmAlways: false,
},
],
completed: [],
order: ["w1"],
}}
onDecide={vi.fn()}
busy={false}
/>,
);
expect(document.body.textContent).toMatch(/"status"/);
});
});

View file

@ -1,9 +1,62 @@
"use client";
import { Check, Mail, X } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import type { PendingAction } from "@/lib/giellie-types";
type StockQuantityLine = { sku?: string; description?: string; qty?: number };
/**
* The stock quantity tool is the one tool whose input is a list the
* operator has to read line by line. JSON is not reviewable at that
* size, so it gets a table. Every other tool keeps raw JSON.
*/
export function renderToolInput(toolName: string, input: unknown): ReactNode {
if (toolName === "stock_items.set_quantities" && input && typeof input === "object") {
const payload = input as { mode?: unknown; lines?: unknown };
const mode = typeof payload.mode === "string" ? payload.mode : "";
const lines = Array.isArray(payload.lines) ? (payload.lines as StockQuantityLine[]) : [];
if (lines.length > 0) {
return (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
{mode === "add"
? "Adding these quantities to the current stock."
: "Setting these as the new quantity on hand."}
</p>
<div className="max-h-64 overflow-auto rounded-md border">
<table className="w-full text-xs">
<thead className="bg-muted/50">
<tr>
<th className="px-2 py-1 text-left font-medium">SKU</th>
<th className="px-2 py-1 text-left font-medium">Description</th>
<th className="px-2 py-1 text-right font-medium">Qty</th>
</tr>
</thead>
<tbody>
{lines.map((line, i) => (
<tr key={`${line.sku ?? "line"}-${i}`} className="border-t">
<td className="px-2 py-1 font-mono">{line.sku ?? "—"}</td>
<td className="px-2 py-1">{line.description ?? "—"}</td>
<td className="px-2 py-1 text-right tabular-nums">{line.qty ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
}
// Every other tool keeps its raw JSON exactly as it was.
return (
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap break-words text-xs text-muted-foreground">
{JSON.stringify(input, null, 2)}
</pre>
);
}
interface Props {
pending: PendingAction;
busy: boolean;
@ -39,9 +92,7 @@ export function ApprovalCard({ pending, busy, onDecide }: Props) {
</span>
)}
</div>
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap break-words text-xs text-muted-foreground">
{JSON.stringify(tu.input, null, 2)}
</pre>
{renderToolInput(tu.name, tu.input)}
</li>
))}
</ul>