From d28508fbe375cea2b1e7d7bae00612d17a4cba1f Mon Sep 17 00:00:00 2001
From: Dotta
Date: Wed, 10 Jun 2026 12:20:23 +0000
Subject: [PATCH] =?UTF-8?q?feat(ui):=20Action=20approval=20card=20v2=20?=
=?UTF-8?q?=E2=80=94=20stale=20variant=20+=20bindings=20+=20signed=20paylo?=
=?UTF-8?q?ad=20+=20mobile=20(PAP-10787)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
ui/src/components/actions/ActionCard.test.tsx | 141 ++++++++
ui/src/components/actions/ActionCard.tsx | 322 ++++++++++++++++++
ui/src/pages/DesignGuide.tsx | 86 +++++
ui/storybook/stories/action-card.stories.tsx | 95 ++++++
4 files changed, 644 insertions(+)
create mode 100644 ui/src/components/actions/ActionCard.test.tsx
create mode 100644 ui/src/components/actions/ActionCard.tsx
create mode 100644 ui/storybook/stories/action-card.stories.tsx
diff --git a/ui/src/components/actions/ActionCard.test.tsx b/ui/src/components/actions/ActionCard.test.tsx
new file mode 100644
index 00000000000..4e6846f45aa
--- /dev/null
+++ b/ui/src/components/actions/ActionCard.test.tsx
@@ -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 }) => (
+
+ {title}
+ {body}
+
+ ),
+}));
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+let root: ReturnType | 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( );
+ 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( );
+ expect(c.textContent).toContain("Policy #7");
+ });
+
+ it("enables Approve on the pending variant and fires the handler", () => {
+ const onApprove = vi.fn();
+ const c = render( );
+ 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(
+ ,
+ );
+ 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( );
+ 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(
+ ,
+ );
+ 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");
+ });
+});
diff --git a/ui/src/components/actions/ActionCard.tsx b/ui/src/components/actions/ActionCard.tsx
new file mode 100644
index 00000000000..a619d6c74ec
--- /dev/null
+++ b/ui/src/components/actions/ActionCard.tsx
@@ -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 (
+
+ {rows.map((row) => (
+
+
+ {row.label}
+
+
+ {row.value}
+
+
+ ))}
+
+ );
+}
+
+/** 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 ? (
+
+
+ {shortSha(binding.previousCatalogSha256)}
+
+
+ {shortSha(binding.catalogSha256)}
+
+
+ ) : (
+ shortSha(binding.catalogSha256)
+ );
+
+ return [
+ {
+ label: "Application",
+ value: (
+
+ {binding.application}
+ manifest v{binding.manifestVersion}
+
+ ),
+ },
+ { label: "Connection", value: binding.connection, mono: true },
+ { label: "Catalog", value: catalogValue, mono: !isStale },
+ {
+ label: "Payload",
+ value: (
+
+
+ {shortSha(binding.payloadSha256)}
+ signed
+
+ ),
+ 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 = (
+
+ Approve
+
+ );
+ const denyButton = (
+
+ Deny
+
+ );
+ const editButton = (
+
+
+ Edit & re-sign
+
+ );
+
+ return (
+
+
+ {/* Header: avatar + request line + outcome pill */}
+
+
+ {agentAvatarUrl ? : null}
+ {initials(agentName)}
+
+
+
+ {agentName} requested approval to call
+
+
{toolName}
+
+
+
+
+
+
+ {/* Body: tool name + risk / capability pills */}
+
+ {toolName}
+
+
+
+
+ {/* Stale banner (PAP-10400 hardening) */}
+ {isStale ? (
+
+ ) : null}
+
+ {/* Bindings table */}
+
+
+ {/* JSON input */}
+
+
+ {/* Why I'm asking */}
+
+
Why I'm asking
+
+ {reason}
+ {policyNumber != null ? (
+ <>
+ {" "}
+ Policy #{policyNumber} requires approval here.
+ >
+ ) : null}
+
+
+
+
+ {/* Footrow: expiry + actions */}
+
+
+
+ {expiresInLabel ?? "no expiry set"}
+
+ {mobile ? (
+
+ {approveButton}
+ {denyButton}
+ {editButton}
+
+ ) : (
+
+ {editButton}
+ {denyButton}
+ {approveButton}
+
+ )}
+
+
+ );
+}
+
+/** Mobile (390×844) presentation of {@link ActionCard}. */
+export function ActionCardMobile(props: Omit) {
+ return ;
+}
diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx
index 3b343637f83..ab1f4261929 100644
--- a/ui/src/pages/DesignGuide.tsx
+++ b/ui/src/pages/DesignGuide.tsx
@@ -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() {
+
+
+
+ Signed payload sha256 + expiry surface on every variant (PAP-10400). The{" "}
+ stale variant tints the border amber, banners the catalog-hash mismatch, strikes through
+ the previous hash next to the current one, and renders Approve disabled until the request
+ is re-issued.
+
+
+
+
+
+
+ Identical content; the three buttons stack full-width in the order Approve / Deny / Edit & re-sign,
+ and the bindings table uses a 70px label column.
+
+
+
+
+
+
+ Two-column key/value block with mono values. Lives inside ActionCard and is reused
+ standalone in the audit row drilldown.
+
+
+
{[
diff --git a/ui/storybook/stories/action-card.stories.tsx b/ui/storybook/stories/action-card.stories.tsx
new file mode 100644
index 00000000000..869b070ac91
--- /dev/null
+++ b/ui/storybook/stories/action-card.stories.tsx
@@ -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
= {
+ title: "Tools & Access/ActionCard",
+ component: ActionCard,
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Pending: Story = {
+ render: () => (
+
+ ),
+};
+
+export const Stale: Story = {
+ render: () => (
+
+ ),
+};
+
+export const DesktopSideBySide: Story = {
+ render: () => (
+
+ ),
+};
+
+export const Mobile: Story = {
+ render: () => (
+
+ ),
+};
+
+export const Bindings: Story = {
+ render: () => (
+
+
+
+ ),
+};