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 = ( + + ); + const denyButton = ( + + ); + const editButton = ( + + ); + + 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 */} +
+

Input

+
+            {json}
+          
+
+ + {/* 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: () => ( +
+ +
+ ), +};