feat(ui): Action approval card v2 — stale variant + bindings + signed payload + mobile (PAP-10787)

New ui/src/components/actions/ActionCard.tsx implementing the issue-thread
approval card per the PAP-10771 v2 spec (surfaces 11/12/99):

- Real Card: avatar + "Coder requested approval to call" header + outcome pill;
  body with tool name + risk/capability pills; bindings table (application +
  manifest version, connection, catalog sha256, signed payload sha256); JSON
  input mono block; "Why I'm asking" referencing the policy by number; footrow
  with expiry icon + Edit & re-sign / Deny / Approve.
- Stale variant (PAP-10400 hardening): amber border, catalog-hash-mismatch
  banner, previous catalog hash struck through next to the current one, and
  Approve rendered disabled until re-issuance.
- Mobile (390×844) via ActionCardMobile: identical content, full-width buttons
  stacked Approve / Deny / Edit & re-sign, 70px bindings label column.
- New exported BindingsTable (two-column mono key/value) lives inside the card
  and is reusable in the audit row drilldown.

Showcased in the design guide and a Storybook story; unit tests cover the
PAP-10400 security states (signed payload + expiry always shown, stale Approve
disabled, struck-through prior hash) and the mobile button order. Typecheck and
the new component tests pass.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-10 12:49:31 +00:00
1 parent 378bcafb6b
commit d28508fbe3
4 files changed
+644

No files matched your search

@@ -0,0 +1,141 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { ReactElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ActionCard, ActionCardMobile, BindingsTable, shortSha } from "./ActionCard";
// EnforcementBanner pulls in a react-query hook; the stale variant only needs
// its presentational copy, so stub it to keep the test free of a QueryClient.
vi.mock("@/components/EnforcementBanner", () => ({
EnforcementBanner: ({ title, body }: { title?: string; body?: string }) => (
<div data-testid="stale-banner">
<span>{title}</span>
<span>{body}</span>
</div>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
container?.remove();
container = null;
});
function render(element: ReactElement) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(element));
return container;
}
const baseBinding = {
application: "Slack",
manifestVersion: "2.4.1",
connection: "https://slack.com/api",
catalogSha256: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
payloadSha256: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
};
const baseProps = {
toolName: "slack.post_message",
risk: "medium" as const,
isWrite: true,
binding: baseBinding,
input: { channel: "#launch", text: "hi" },
reason: "Write-capable tool.",
policyNumber: 7,
expiresInLabel: "expires in 23h 51m",
};
function approveButton(c: HTMLElement): HTMLButtonElement {
const btn = Array.from(c.querySelectorAll("button")).find((b) => b.textContent?.trim() === "Approve");
if (!btn) throw new Error("Approve button not found");
return btn as HTMLButtonElement;
}
describe("ActionCard", () => {
it("surfaces the signed payload sha256 and expiry (PAP-10400)", () => {
const c = render(<ActionCard {...baseProps} />);
expect(c.textContent).toContain(shortSha(baseBinding.payloadSha256));
expect(c.textContent).toContain("signed");
expect(c.textContent).toContain("expires in 23h 51m");
});
it("references the policy number in the explanation", () => {
const c = render(<ActionCard {...baseProps} />);
expect(c.textContent).toContain("Policy #7");
});
it("enables Approve on the pending variant and fires the handler", () => {
const onApprove = vi.fn();
const c = render(<ActionCard {...baseProps} onApprove={onApprove} />);
const btn = approveButton(c);
expect(btn.disabled).toBe(false);
act(() => btn.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onApprove).toHaveBeenCalledOnce();
});
it("disables Approve and shows the catalog mismatch on the stale variant", () => {
const c = render(
<ActionCard
{...baseProps}
variant="stale"
binding={{
...baseBinding,
catalogSha256: "sha256:7d793037a0760186574b0282f2f435e7deadbeefcafef00dba5eba5eba5eba5e",
previousCatalogSha256: baseBinding.catalogSha256,
}}
/>,
);
expect(approveButton(c).disabled).toBe(true);
expect(c.querySelector('[data-testid="stale-banner"]')).not.toBeNull();
// Previous (now-invalid) hash is struck through next to the current one.
const struck = c.querySelector(".line-through");
expect(struck?.textContent).toContain(shortSha(baseBinding.catalogSha256));
});
it("stacks buttons Approve / Deny / Edit & re-sign on mobile with a 70px label column", () => {
const c = render(<ActionCardMobile {...baseProps} />);
const labels = c.querySelectorAll("dt");
expect(labels.length).toBeGreaterThan(0);
expect((labels[0] as HTMLElement).style.width).toBe("70px");
const buttonText = Array.from(c.querySelectorAll("button")).map((b) => b.textContent?.trim());
const order = buttonText.filter((t) => t === "Approve" || t === "Deny" || t?.startsWith("Edit"));
expect(order[0]).toBe("Approve");
expect(order[1]).toBe("Deny");
expect(order[2]).toContain("Edit");
});
});
describe("BindingsTable", () => {
it("renders mono rows with the default 132px label column", () => {
const c = render(
<BindingsTable rows={[{ label: "Catalog", value: "sha256:abc", mono: true }]} />,
);
const dt = c.querySelector("dt") as HTMLElement;
expect(dt.style.width).toBe("132px");
expect(c.querySelector("dd")?.className).toContain("font-mono");
});
});
describe("shortSha", () => {
it("truncates a long sha to the review form", () => {
expect(shortSha("sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")).toBe(
"sha256:9f86d08188…f00a08",
);
});
it("leaves a short sha intact", () => {
expect(shortSha("sha256:abcd")).toBe("sha256:abcd");
});
});
+322
View File
@@ -0,0 +1,322 @@
import type { ReactNode } from "react";
import { Clock, Pencil, ShieldCheck } from "lucide-react";
import type { ToolRiskLevel } from "@paperclipai/shared";
import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { EnforcementBanner } from "@/components/EnforcementBanner";
import { CapabilityBadges, DecisionBadge, RiskBadge } from "@/pages/tools/shared";
/**
* Action approval card (PAP-10787 / PAP-10778, surfaces 11/12/99).
*
* The card an agent's run posts into the issue thread when a governed tool
* call needs human approval. Two hard requirements from the PAP-10400 security
* hardening must never regress:
*
* 1. The **signed payload sha256 + expiry** are always surfaced, so a reviewer
* approves exactly the bytes that were signed and can see when the request
* lapses.
* 2. The server-driven **stale** variant disables Approve and shows the
* catalog-hash mismatch (previous hash struck through next to current), so
* re-issuance is visibly required — an approval can never be granted
* against a catalog the orchestrator no longer trusts.
*/
export type ActionCardVariant = "pending" | "stale";
/** One key/value row in the {@link BindingsTable}. */
export interface BindingRow {
label: string;
value: ReactNode;
/** Render the value in the mono catalog/hash treatment. */
mono?: boolean;
}
/**
* Two-column key/value block with mono values. Lives inside {@link ActionCard}
* and is reused in the audit row drilldown, so it takes raw rows rather than a
* baked-in binding shape. `labelWidth` narrows to 70px on mobile (surface 99).
*/
export function BindingsTable({
rows,
labelWidth = 132,
className,
}: {
rows: BindingRow[];
labelWidth?: number;
className?: string;
}) {
return (
<dl className={cn("divide-y divide-border rounded-md border border-border text-sm", className)}>
{rows.map((row) => (
<div key={row.label} className="flex gap-3 px-3 py-2">
<dt
className="shrink-0 pt-0.5 text-xs font-medium uppercase tracking-normal text-muted-foreground"
style={{ width: labelWidth }}
>
{row.label}
</dt>
<dd className={cn("min-w-0 flex-1 break-all text-foreground", row.mono && "font-mono text-xs")}>
{row.value}
</dd>
</div>
))}
</dl>
);
}
/** Truncate a sha to the standard `sha256:abcd…1234` review form. */
export function shortSha(sha: string): string {
const hex = sha.replace(/^sha256:/, "");
if (hex.length <= 16) return `sha256:${hex}`;
return `sha256:${hex.slice(0, 10)}…${hex.slice(-6)}`;
}
export interface ActionCardBinding {
/** Application the tool belongs to. */
application: string;
/** Manifest version the catalog was discovered at. */
manifestVersion: string;
/** Connection label (mono URL / command). */
connection: string;
/** Current catalog sha256 the gateway will enforce against. */
catalogSha256: string;
/** sha256 of the signed argument payload — never elided. */
payloadSha256: string;
/**
* Previous catalog sha256, only present on the stale variant. Rendered struck
* through next to {@link catalogSha256} so the mismatch is obvious.
*/
previousCatalogSha256?: string;
}
export interface ActionCardProps {
/** Requesting agent — defaults to "Coder" to match the spec copy. */
agentName?: string;
agentAvatarUrl?: string | null;
/** Tool the agent is asking to call, e.g. `slack.post_message`. */
toolName: string;
risk: ToolRiskLevel;
isReadOnly?: boolean;
isWrite?: boolean;
isDestructive?: boolean;
binding: ActionCardBinding;
/** Raw tool input, rendered as pretty JSON in a mono block. */
input: unknown;
/** Free-form "why I'm asking" explanation. */
reason: ReactNode;
/** Policy number the explanation references, e.g. `7` → "Policy #7". */
policyNumber?: number | string;
/** Footrow expiry copy, e.g. "expires in 23h 51m". */
expiresInLabel?: string;
variant?: ActionCardVariant;
/** Mobile (390×844) layout: stacked full-width buttons + 70px label column. */
mobile?: boolean;
onApprove?: () => void;
onDeny?: () => void;
onEditResign?: () => void;
className?: string;
}
function initials(name: string): string {
return name
.split(/\s+/)
.map((part) => part[0])
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
}
function bindingRows(binding: ActionCardBinding, isStale: boolean): BindingRow[] {
const catalogValue = isStale && binding.previousCatalogSha256 ? (
<span className="inline-flex flex-wrap items-center gap-1.5">
<span className="text-muted-foreground line-through decoration-amber-500" title="Previous catalog hash">
{shortSha(binding.previousCatalogSha256)}
</span>
<span className="text-amber-600 dark:text-amber-400" title="Current catalog hash">
{shortSha(binding.catalogSha256)}
</span>
</span>
) : (
shortSha(binding.catalogSha256)
);
return [
{
label: "Application",
value: (
<span>
{binding.application}
<span className="ml-1.5 text-xs text-muted-foreground">manifest v{binding.manifestVersion}</span>
</span>
),
},
{ label: "Connection", value: binding.connection, mono: true },
{ label: "Catalog", value: catalogValue, mono: !isStale },
{
label: "Payload",
value: (
<span className="inline-flex items-center gap-1.5">
<ShieldCheck className="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
<span>{shortSha(binding.payloadSha256)}</span>
<span className="font-sans text-[11px] uppercase tracking-normal text-muted-foreground">signed</span>
</span>
),
mono: true,
},
];
}
export function ActionCard({
agentName = "Coder",
agentAvatarUrl,
toolName,
risk,
isReadOnly,
isWrite,
isDestructive,
binding,
input,
reason,
policyNumber,
expiresInLabel,
variant = "pending",
mobile = false,
onApprove,
onDeny,
onEditResign,
className,
}: ActionCardProps) {
const isStale = variant === "stale";
const json = typeof input === "string" ? input : JSON.stringify(input, null, 2);
// Surface 99: buttons stack full-width in the order Approve / Deny /
// Edit & re-sign; desktop keeps them inline as Edit & re-sign / Deny / Approve.
const approveButton = (
<Button
size="sm"
onClick={onApprove}
disabled={isStale}
className={mobile ? "w-full" : undefined}
title={isStale ? "Re-issue the request before approving — the catalog hash changed." : undefined}
>
Approve
</Button>
);
const denyButton = (
<Button size="sm" variant="outline" onClick={onDeny} className={mobile ? "w-full" : undefined}>
Deny
</Button>
);
const editButton = (
<Button size="sm" variant="outline" onClick={onEditResign} className={mobile ? "w-full" : undefined}>
<Pencil className="mr-1 h-3.5 w-3.5" />
Edit &amp; re-sign
</Button>
);
return (
<Card
className={cn(
"gap-0 py-0",
isStale && "border-amber-500/50 dark:border-amber-500/40",
className,
)}
data-variant={variant}
>
<CardContent className="space-y-3 p-4">
{/* Header: avatar + request line + outcome pill */}
<div className="flex items-start gap-3">
<Avatar size="sm" className="shrink-0">
{agentAvatarUrl ? <AvatarImage src={agentAvatarUrl} alt={agentName} /> : null}
<AvatarFallback>{initials(agentName)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="text-sm text-foreground">
<span className="font-medium">{agentName}</span> requested approval to call
</p>
<p className="mt-0.5 font-mono text-xs text-muted-foreground break-all">{toolName}</p>
</div>
<div className="shrink-0">
<DecisionBadge decision="require_approval" />
</div>
</div>
{/* Body: tool name + risk / capability pills */}
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm text-foreground">{toolName}</span>
<RiskBadge risk={risk} />
<CapabilityBadges isReadOnly={isReadOnly} isWrite={isWrite} isDestructive={isDestructive} />
</div>
{/* Stale banner (PAP-10400 hardening) */}
{isStale ? (
<EnforcementBanner
tone="warning"
title="Catalog changed since this request was signed."
body="The application's tool catalog hash no longer matches the one this approval was issued against. Approval is disabled — the agent must edit & re-sign to request again."
/>
) : null}
{/* Bindings table */}
<BindingsTable rows={bindingRows(binding, isStale)} labelWidth={mobile ? 70 : 132} />
{/* JSON input */}
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-normal text-muted-foreground">Input</p>
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-xs leading-relaxed text-foreground">
{json}
</pre>
</div>
{/* Why I'm asking */}
<div className="space-y-1">
<p className="text-xs font-medium uppercase tracking-normal text-muted-foreground">Why I&apos;m asking</p>
<p className="text-sm text-muted-foreground">
{reason}
{policyNumber != null ? (
<>
{" "}
<span className="font-medium text-foreground">Policy #{policyNumber}</span> requires approval here.
</>
) : null}
</p>
</div>
</CardContent>
{/* Footrow: expiry + actions */}
<div
className={cn(
"flex gap-2 border-t border-border p-4",
mobile ? "flex-col" : "flex-wrap items-center justify-between",
)}
>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3.5 w-3.5 shrink-0" />
{expiresInLabel ?? "no expiry set"}
</span>
{mobile ? (
<div className="flex flex-col gap-2">
{approveButton}
{denyButton}
{editButton}
</div>
) : (
<div className="flex items-center gap-2">
{editButton}
{denyButton}
{approveButton}
</div>
)}
</div>
</Card>
);
}
/** Mobile (390×844) presentation of {@link ActionCard}. */
export function ActionCardMobile(props: Omit<ActionCardProps, "mobile">) {
return <ActionCard {...props} mobile />;
}
+86
View File
@@ -115,6 +115,7 @@ import {
import { StatusBadge, AgentStatusBadge, AgentStatusCapsule } from "@/components/StatusBadge";
import { StatusIcon } from "@/components/StatusIcon";
import { EnforcementBanner } from "@/components/EnforcementBanner";
import { ActionCard, ActionCardMobile, BindingsTable } from "@/components/actions/ActionCard";
import { PriorityIcon } from "@/components/PriorityIcon";
import { EntityRow } from "@/components/EntityRow";
import { EmptyState } from "@/components/EmptyState";
@@ -1661,6 +1662,91 @@ export function DesignGuide() {
</p>
</SubSection>
<SubSection title="Action approval card — pending / stale (surfaces 11/12)">
<div className="grid gap-4 lg:grid-cols-2">
<ActionCard
toolName="slack.post_message"
risk="medium"
isWrite
binding={{
application: "Slack",
manifestVersion: "2.4.1",
connection: "https://slack.com/api · acme-workspace",
catalogSha256: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
payloadSha256: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
}}
input={{ channel: "#launch", text: "Deploy v2 is live 🎉", unfurl_links: false }}
reason="This tool can write to your workspace, so a human signs off before the agent posts."
policyNumber={7}
expiresInLabel="expires in 23h 51m"
/>
<ActionCard
variant="stale"
toolName="slack.post_message"
risk="medium"
isWrite
binding={{
application: "Slack",
manifestVersion: "2.4.1",
connection: "https://slack.com/api · acme-workspace",
catalogSha256: "sha256:7d793037a0760186574b0282f2f435e7a4b1b2b0b822cd15d6c15b0f00a0e3f1",
previousCatalogSha256: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
payloadSha256: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
}}
input={{ channel: "#launch", text: "Deploy v2 is live 🎉", unfurl_links: false }}
reason="This tool can write to your workspace, so a human signs off before the agent posts."
policyNumber={7}
expiresInLabel="expires in 18h 02m"
/>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Signed payload sha256 + expiry surface on every variant (PAP-10400). The{" "}
<code>stale</code> variant tints the border amber, banners the catalog-hash mismatch, strikes through
the previous hash next to the current one, and renders <code>Approve</code> disabled until the request
is re-issued.
</p>
</SubSection>
<SubSection title="Action approval card — mobile (390×844, surface 99)">
<div className="w-[390px] max-w-full rounded-xl border border-border bg-background p-3">
<ActionCardMobile
toolName="slack.post_message"
risk="medium"
isWrite
binding={{
application: "Slack",
manifestVersion: "2.4.1",
connection: "https://slack.com/api · acme-workspace",
catalogSha256: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
payloadSha256: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
}}
input={{ channel: "#launch", text: "Deploy v2 is live 🎉" }}
reason="This tool can write to your workspace, so a human signs off before the agent posts."
policyNumber={7}
expiresInLabel="expires in 23h 51m"
/>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Identical content; the three buttons stack full-width in the order Approve / Deny / Edit &amp; re-sign,
and the bindings table uses a 70px label column.
</p>
</SubSection>
<SubSection title="BindingsTable (reused in the audit row drilldown)">
<BindingsTable
rows={[
{ label: "Application", value: "Slack · manifest v2.4.1" },
{ label: "Connection", value: "https://slack.com/api · acme-workspace", mono: true },
{ label: "Catalog", value: "sha256:9f86d081…f00a08", mono: true },
{ label: "Payload", value: "sha256:2c26b46b…66e7ae", mono: true },
]}
/>
<p className="mt-2 text-xs text-muted-foreground">
Two-column key/value block with mono values. Lives inside <code>ActionCard</code> and is reused
standalone in the audit row drilldown.
</p>
</SubSection>
<SubSection title="Tool-access status keys (StatusBadge)">
<div className="flex flex-wrap items-center gap-2">
{[
@@ -0,0 +1,95 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ActionCard, ActionCardMobile, BindingsTable } from "@/components/actions/ActionCard";
const binding = {
application: "Slack",
manifestVersion: "2.4.1",
connection: "https://slack.com/api · acme-workspace",
catalogSha256: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
payloadSha256: "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
};
const baseProps = {
toolName: "slack.post_message",
risk: "medium" as const,
isWrite: true,
binding,
input: { channel: "#launch", text: "Deploy v2 is live 🎉", unfurl_links: false },
reason: "This tool can write to your workspace, so a human signs off before the agent posts.",
policyNumber: 7,
expiresInLabel: "expires in 23h 51m",
};
const meta: Meta<typeof ActionCard> = {
title: "Tools & Access/ActionCard",
component: ActionCard,
};
export default meta;
type Story = StoryObj<typeof ActionCard>;
export const Pending: Story = {
render: () => (
<div className="max-w-xl p-6">
<ActionCard {...baseProps} />
</div>
),
};
export const Stale: Story = {
render: () => (
<div className="max-w-xl p-6">
<ActionCard
{...baseProps}
variant="stale"
binding={{
...binding,
catalogSha256: "sha256:7d793037a0760186574b0282f2f435e7a4b1b2b0b822cd15d6c15b0f00a0e3f1",
previousCatalogSha256: binding.catalogSha256,
}}
expiresInLabel="expires in 18h 02m"
/>
</div>
),
};
export const DesktopSideBySide: Story = {
render: () => (
<div className="grid max-w-5xl gap-4 p-6 lg:grid-cols-2">
<ActionCard {...baseProps} />
<ActionCard
{...baseProps}
variant="stale"
binding={{
...binding,
catalogSha256: "sha256:7d793037a0760186574b0282f2f435e7a4b1b2b0b822cd15d6c15b0f00a0e3f1",
previousCatalogSha256: binding.catalogSha256,
}}
expiresInLabel="expires in 18h 02m"
/>
</div>
),
};
export const Mobile: Story = {
render: () => (
<div className="w-[390px] bg-background p-3">
<ActionCardMobile {...baseProps} />
</div>
),
};
export const Bindings: Story = {
render: () => (
<div className="max-w-md p-6">
<BindingsTable
rows={[
{ label: "Application", value: "Slack · manifest v2.4.1" },
{ label: "Connection", value: "https://slack.com/api · acme-workspace", mono: true },
{ label: "Catalog", value: "sha256:9f86d08188…f00a08", mono: true },
{ label: "Payload", value: "sha256:2c26b46b68…66e7ae", mono: true },
]}
/>
</div>
),
};