(null);
+ const [nowMs, setNowMs] = useState(() => Date.now());
+ const isPending = state === "pending";
+ const isDestructive = payload.risk === "destructive";
+
+ useEffect(() => {
+ if (!isPending) return;
+ const timer = setInterval(() => setNowMs(Date.now()), 30000);
+ return () => clearInterval(timer);
+ }, [isPending]);
+
+ useEffect(() => {
+ if (state !== "pending") {
+ setRejecting(false);
+ setWorking(null);
+ }
+ }, [interaction.id, state]);
+
+ async function handleAccept() {
+ if (!onAcceptInteraction) return;
+ setWorking("accept");
+ setActionError(null);
+ try {
+ await onAcceptInteraction(interaction);
+ } catch {
+ setActionError("Couldn't submit. Try again.");
+ } finally {
+ setWorking(null);
+ }
+ }
+
+ async function handleReject() {
+ if (!onRejectInteraction) return;
+ setWorking("reject");
+ setActionError(null);
+ try {
+ await onRejectInteraction(interaction, rejectReason.trim() || undefined);
+ setRejecting(false);
+ } catch {
+ setActionError("Couldn't submit. Try again.");
+ } finally {
+ setWorking(null);
+ }
+ }
+
+ const countdown = isPending ? formatToolActionCountdown(payload.expiresAt, nowMs) : null;
+
+ return (
+
+
+
+
+
+ {payload.previewMarkdown}
+
+
+
+
+
+ {isPending ? (
+ <>
+ {countdown ? (
+
+
+ {countdown.text}
+
+ ) : null}
+
+
+
+
+
+
+ Approving runs this action now.
+
+
+
+ {rejecting ? (
+
+ ) : null}
+
+ {actionError ? (
+
+ {actionError}
+
+ ) : null}
+
+ >
+ ) : (
+
+ )}
+
+ );
+}
+
function RequestConfirmationCard({
interaction,
isPlan = false,
@@ -2454,10 +3020,19 @@ export function IssueThreadInteractionCard({
externalReferences,
}: IssueThreadInteractionCardProps) {
const isPlan = isPlanConfirmation(interaction);
+ const isToolAction =
+ interaction.kind === "request_confirmation" && isToolActionConfirmation(interaction);
+ const toolActionState =
+ isToolAction && interaction.kind === "request_confirmation"
+ ? toolActionCardState(interaction)
+ : null;
+ const toolActionStyles = toolActionState ? toolActionStatusClasses(toolActionState) : null;
const resumeFailure = requestConfirmationResumeFailure(interaction);
const planStyles = isPlan ? planStatusClasses(interaction.status, resumeFailure) : null;
- const StatusIcon = planStyles ? planStyles.Icon : statusIcon(interaction.status);
- const styles = planStyles ?? statusClasses(interaction.status);
+ const activeStyles = toolActionStyles ?? planStyles;
+ const StatusIcon = activeStyles ? activeStyles.Icon : statusIcon(interaction.status);
+ const iconSpin = toolActionStyles?.spin ?? false;
+ const styles = activeStyles ?? statusClasses(interaction.status);
const createdByLabel = resolveActorLabel({
agentId: interaction.createdByAgentId,
userId: interaction.createdByUserId,
@@ -2482,10 +3057,10 @@ export function IssueThreadInteractionCard({
-
+
{isPlan ? "Plan" : interactionKindLabel(interaction.kind)}
/
- {planStyles ? planStyles.label : statusLabel(interaction.status)}
+ {activeStyles ? activeStyles.label : statusLabel(interaction.status)}
@@ -2497,11 +3072,13 @@ export function IssueThreadInteractionCard({
? interaction.payload.title ?? "Questions for the operator"
: interaction.kind === "request_checkbox_confirmation"
? "Checkbox confirmation requested"
- : interaction.kind === "request_item_verdicts"
- ? "Review these items"
- : isPlan
- ? "Plan review"
- : "Confirmation requested")}
+ : isToolAction
+ ? "Tool approval requested"
+ : interaction.kind === "request_item_verdicts"
+ ? "Review these items"
+ : isPlan
+ ? "Plan review"
+ : "Confirmation requested")}
{interaction.summary ? (
@@ -2547,6 +3124,16 @@ export function IssueThreadInteractionCard({
onRejectInteraction={onRejectInteraction}
externalReferences={externalReferences}
/>
+ ) : isToolAction && interaction.kind === "request_confirmation" && toolActionState ? (
+
) : interaction.kind === "request_item_verdicts" ? (
- {resolvedByLabel ? (
+ {resolvedByLabel && !isToolAction ? (
Resolved by
{resolvedByLabel}
{interaction.resolvedAt ? ` on ${formatShortDate(interaction.resolvedAt)}` : ""}
diff --git a/ui/src/components/JsonSchemaForm.test.tsx b/ui/src/components/JsonSchemaForm.test.tsx
index b9d6c9d3d2..ac56784d8a 100644
--- a/ui/src/components/JsonSchemaForm.test.tsx
+++ b/ui/src/components/JsonSchemaForm.test.tsx
@@ -134,7 +134,7 @@ describe("JsonSchemaForm secret-ref rendering", () => {
});
});
- it("writes the secret id to form values when the picker selects an existing secret", async () => {
+ it("writes a secret_ref binding to form values when the picker selects an existing secret", async () => {
const root = createRoot(container);
const onChange = vi.fn();
@@ -173,7 +173,11 @@ describe("JsonSchemaForm secret-ref rendering", () => {
});
expect(onChange).toHaveBeenCalledWith({
- apiKey: "11111111-1111-4111-8111-111111111111",
+ apiKey: {
+ type: "secret_ref",
+ secretId: "11111111-1111-4111-8111-111111111111",
+ version: "latest",
+ },
});
await act(async () => {
diff --git a/ui/src/components/JsonSchemaForm.tsx b/ui/src/components/JsonSchemaForm.tsx
index eaa9540539..5668665854 100644
--- a/ui/src/components/JsonSchemaForm.tsx
+++ b/ui/src/components/JsonSchemaForm.tsx
@@ -7,7 +7,7 @@ import {
Plus,
Trash2,
} from "lucide-react";
-import { isUuidLike } from "@paperclipai/shared";
+import { isUuidLike, type EnvSecretRefBinding } from "@paperclipai/shared";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@@ -107,6 +107,8 @@ export interface JsonSchemaFormProps {
disabled?: boolean;
/** Additional CSS class for the root container. */
className?: string;
+ /** Label for the disclosure that hides advanced fields. Defaults to "Advanced options". */
+ advancedLabel?: string;
}
// ---------------------------------------------------------------------------
@@ -189,6 +191,13 @@ export function validateField(
// Skip further validation if empty and not required
if (value === undefined || value === null || value === "") return null;
+ if (type === "secret-ref" && isSecretRefBinding(value)) {
+ return null;
+ }
+ if (type === "secret-ref" && typeof value === "object") {
+ return "Invalid secret reference";
+ }
+
if (type === "string" || type === "secret-ref") {
const str = String(value);
if (schema.minLength != null && str.length < schema.minLength) {
@@ -446,6 +455,16 @@ BooleanField.displayName = "BooleanField";
*/
const ENUM_UNSET_VALUE = "__paperclip_unset__";
+function isSecretRefBinding(value: unknown): value is EnvSecretRefBinding {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ !Array.isArray(value) &&
+ (value as { type?: unknown }).type === "secret_ref" &&
+ typeof (value as { secretId?: unknown }).secretId === "string"
+ );
+}
+
/**
* Specialized field for enum (select) values.
*/
@@ -557,9 +576,11 @@ const SecretField = React.memo(({
const [isVisible, setIsVisible] = useState(false);
const isTextArea = maxLength != null && maxLength > TEXTAREA_THRESHOLD;
+ const secretRefValue = isSecretRefBinding(value) ? value : null;
const stringValue = typeof value === "string" ? value : "";
const trimmed = stringValue.trim();
- const isBoundToSecret = trimmed.length > 0 && isUuidLike(trimmed);
+ const legacySecretId = trimmed.length > 0 && isUuidLike(trimmed) ? trimmed : null;
+ const isBoundToSecret = secretRefValue !== null || legacySecretId !== null;
const hasRawValue = stringValue.length > 0 && !isBoundToSecret;
const [showRawInput, setShowRawInput] = useState(hasRawValue);
@@ -572,14 +593,20 @@ const SecretField = React.memo(({
if (hasRawValue) setShowRawInput(true);
}, [hasRawValue]);
- const bindingValue: SecretBindingValue | null = isBoundToSecret
- ? { secretId: trimmed }
- : null;
+ const bindingValue: SecretBindingValue | null = secretRefValue
+ ? { secretId: secretRefValue.secretId, version: secretRefValue.version }
+ : legacySecretId
+ ? { secretId: legacySecretId }
+ : null;
const handlePickerChange = useCallback(
(next: SecretBindingValue | null) => {
if (next) {
- onChange(next.secretId);
+ onChange({
+ type: "secret_ref",
+ secretId: next.secretId,
+ version: next.version ?? "latest",
+ });
setShowRawInput(false);
setIsVisible(false);
} else {
@@ -1186,6 +1213,7 @@ export function JsonSchemaForm({
errors = {},
disabled,
className,
+ advancedLabel = "Advanced options",
}: JsonSchemaFormProps) {
const type = resolveType(schema);
@@ -1331,7 +1359,7 @@ export function JsonSchemaForm({
onClick={() => setIsAdvancedOpen((open) => !open)}
aria-expanded={isAdvancedOpen}
>
-
Advanced options
+
{advancedLabel}
{isAdvancedOpen ? (
) : (
diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx
index e6b878faf3..d22203df82 100644
--- a/ui/src/components/OnboardingWizard.tsx
+++ b/ui/src/components/OnboardingWizard.tsx
@@ -114,9 +114,6 @@ export function OnboardingWizard() {
const location = useLocation();
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
- // Sync disabled adapter types from server so the adapter grid filters them out.
- const disabledTypes = useDisabledAdaptersSync();
-
// Support opening the wizard from a route (e.g. /onboarding or an existing
// company's "add agent" entry point) in addition to the dialog context.
const routeOnboardingOptions =
@@ -133,6 +130,11 @@ export function OnboardingWizard() {
? onboardingOptions
: routeOnboardingOptions ?? {};
+ // Sync disabled adapter types only when the wizard is visible. The wizard is
+ // mounted globally, including on /auth, where protected adapter routes are
+ // expected to reject signed-out browsers.
+ const disabledTypes = useDisabledAdaptersSync({ enabled: effectiveOnboardingOpen });
+
const initialStep = effectiveOnboardingOptions.initialStep ?? 0;
const existingCompanyId = effectiveOnboardingOptions.companyId;
diff --git a/ui/src/components/SmokeLabDashboardCard.test.tsx b/ui/src/components/SmokeLabDashboardCard.test.tsx
new file mode 100644
index 0000000000..6d2babb167
--- /dev/null
+++ b/ui/src/components/SmokeLabDashboardCard.test.tsx
@@ -0,0 +1,131 @@
+// @vitest-environment jsdom
+
+import { flushSync } from "react-dom";
+import type { ReactNode } from "react";
+import { createRoot } from "react-dom/client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { SmokeLabDashboardCard } from "./SmokeLabDashboardCard";
+
+const getExperimentalMock = vi.hoisted(() => vi.fn());
+const listRunsMock = vi.hoisted(() => vi.fn());
+const getRunMock = vi.hoisted(() => vi.fn());
+
+vi.mock("@/api/instanceSettings", () => ({
+ instanceSettingsApi: { getExperimental: () => getExperimentalMock() },
+}));
+
+vi.mock("@/api/smokeLab", () => ({
+ smokeLabApi: {
+ listRuns: (c: string) => listRunsMock(c),
+ getRun: (c: string, r: string) => getRunMock(c, r),
+ },
+}));
+
+vi.mock("@/lib/router", () => ({
+ Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
+
+ {children}
+
+ ),
+}));
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+async function act(callback: () => void | Promise
) {
+ let result: void | Promise = undefined;
+ flushSync(() => {
+ result = callback();
+ });
+ await result;
+}
+
+async function flushReact() {
+ for (let i = 0; i < 3; i += 1) {
+ await act(async () => {
+ await Promise.resolve();
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ });
+ }
+}
+
+const RUN = {
+ id: "run-1",
+ companyId: "company-1",
+ trigger: "manual",
+ status: "failed",
+ startedAt: "2026-07-10T00:00:00Z",
+ finishedAt: "2026-07-10T00:05:00Z",
+ summary: {},
+ createdAt: "2026-07-10T00:00:00Z",
+ updatedAt: "2026-07-10T00:05:00Z",
+};
+
+describe("SmokeLabDashboardCard", () => {
+ let container: HTMLDivElement;
+ let root: ReturnType;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ getExperimentalMock.mockResolvedValue({ enableSmokeLab: true });
+ listRunsMock.mockResolvedValue({ runs: [RUN] });
+ getRunMock.mockResolvedValue({
+ run: RUN,
+ steps: [
+ {
+ id: "s1",
+ companyId: "company-1",
+ runId: "run-1",
+ path: "P3",
+ scenarioStep: "allowed-read",
+ status: "fail",
+ detail: null,
+ screenshotArtifactRef: null,
+ durationMs: null,
+ createdAt: "2026-07-10T00:00:01Z",
+ updatedAt: "2026-07-10T00:00:01Z",
+ },
+ ],
+ });
+ });
+
+ afterEach(() => {
+ flushSync(() => root?.unmount());
+ container.remove();
+ vi.clearAllMocks();
+ });
+
+ async function render() {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ root = createRoot(container);
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+ }
+
+ it("renders nothing when the flag is off", async () => {
+ getExperimentalMock.mockResolvedValue({ enableSmokeLab: false });
+ await render();
+
+ expect(container.querySelector('[data-testid="smoke-lab-dashboard-card"]')).toBeNull();
+ expect(container.textContent).not.toContain("Integration smoke");
+ expect(listRunsMock).not.toHaveBeenCalled();
+ });
+
+ it("renders the card with failing paths and a link to the Smoke Lab tab when enabled", async () => {
+ await render();
+
+ const card = container.querySelector('[data-testid="smoke-lab-dashboard-card"]');
+ expect(card).not.toBeNull();
+ expect(card?.getAttribute("href")).toBe("/apps/advanced/smoke-lab");
+ expect(container.textContent).toContain("Integration smoke");
+ expect(container.textContent).toContain("Failing paths: P3");
+ });
+});
diff --git a/ui/src/components/SmokeLabDashboardCard.tsx b/ui/src/components/SmokeLabDashboardCard.tsx
new file mode 100644
index 0000000000..c6c46b0adc
--- /dev/null
+++ b/ui/src/components/SmokeLabDashboardCard.tsx
@@ -0,0 +1,89 @@
+import { useQuery } from "@tanstack/react-query";
+import { FlaskConical, ChevronRight } from "lucide-react";
+import { Link } from "@/lib/router";
+import { smokeLabApi } from "@/api/smokeLab";
+import { queryKeys } from "@/lib/queryKeys";
+import { useSmokeLabEnabled } from "@/hooks/useSmokeLabEnabled";
+import { advancedTabHref } from "@/pages/tools/tool-tabs";
+import { cn } from "@/lib/utils";
+import { failingPaths, runHealth, type SmokeHealth } from "@/pages/tools/smoke-lab-matrix";
+
+const HEALTH_DOT: Record = {
+ green: "bg-emerald-500",
+ amber: "bg-amber-500",
+ red: "bg-destructive",
+ unknown: "bg-muted-foreground/40",
+};
+
+const HEALTH_LABEL: Record = {
+ green: "All paths passing",
+ amber: "Needs a run",
+ red: "Failing paths",
+ unknown: "No runs yet",
+};
+
+function formatTime(value: string | Date | null | undefined): string {
+ if (!value) return "—";
+ const date = new Date(value as string | Date);
+ if (Number.isNaN(date.getTime())) return "—";
+ return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
+}
+
+/**
+ * Compact "Integration smoke" dashboard card (PAP-13347 / S2, plan §D3).
+ * Renders only when `experimental.enableSmokeLab` is on (board-readable flag),
+ * so the dashboard stays clean for everyone who isn't running the Smoke Lab.
+ * Operator-facing copy stays plain; protocol depth lives behind the link into
+ * the Developer › Smoke Lab tab.
+ */
+export function SmokeLabDashboardCard({ companyId }: { companyId: string }) {
+ const { enabled, loaded } = useSmokeLabEnabled();
+
+ const runsQuery = useQuery({
+ queryKey: queryKeys.smokeLab.runs(companyId),
+ queryFn: () => smokeLabApi.listRuns(companyId),
+ enabled: enabled && loaded,
+ });
+
+ const latestRun = runsQuery.data?.runs?.[0];
+
+ const detailQuery = useQuery({
+ queryKey: queryKeys.smokeLab.run(companyId, latestRun?.id ?? "__none__"),
+ queryFn: () => smokeLabApi.getRun(companyId, latestRun!.id),
+ enabled: enabled && loaded && !!latestRun,
+ });
+
+ if (!loaded || !enabled) return null;
+
+ const steps = detailQuery.data?.steps ?? [];
+ const health = runHealth(latestRun, steps);
+ const failing = failingPaths(steps);
+
+ return (
+
+
+
+
+
+
+
+
+ {HEALTH_LABEL[health]}
+ {failing.length > 0 && `: ${failing.join(", ")}`}
+
+
+ {latestRun ? `Last run ${formatTime(latestRun.startedAt)}` : "Run one from the Smoke Lab tab"}
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/StatusBadge.tsx b/ui/src/components/StatusBadge.tsx
index ed50dffca5..32f20d2d5d 100644
--- a/ui/src/components/StatusBadge.tsx
+++ b/ui/src/components/StatusBadge.tsx
@@ -25,9 +25,9 @@ function sentenceCaseStatus(status: string): string {
/**
* Generic status badge for runs / goals / approvals (not task status).
*/
-// design-allow(pill-pattern): DECISION-SHEET.md C8 — status badges keep the bespoke WCAG-tuned
+// design-allow(pill-pattern): DECISION-SHEET.md C8 - status badges keep the bespoke WCAG-tuned
// .status-chip color-mix mechanic and do not wrap the Badge primitive.
-export function StatusBadge({ status }: { status: string }) {
+export function StatusBadge({ status, label }: { status: string; label?: string }) {
return (
- {status.replace(/_/g, " ")}
+ {label ?? status.replace(/[_-]/g, " ")}
);
}
diff --git a/ui/src/components/actions/ActionCard.test.tsx b/ui/src/components/actions/ActionCard.test.tsx
new file mode 100644
index 0000000000..917fd76f00
--- /dev/null
+++ b/ui/src/components/actions/ActionCard.test.tsx
@@ -0,0 +1,141 @@
+// @vitest-environment jsdom
+
+import { createRoot } from "react-dom/client";
+import { flushSync } from "react-dom";
+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) flushSync(() => root?.unmount());
+ root = null;
+ container?.remove();
+ container = null;
+});
+
+function render(element: ReactElement) {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ flushSync(() => 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);
+ flushSync(() => 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 0000000000..2c9ad9d438
--- /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 */}
+
+
+ {/* 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/components/transcript/RunTranscriptView.test.tsx b/ui/src/components/transcript/RunTranscriptView.test.tsx
index f9b7e39f1f..4e980c11e0 100644
--- a/ui/src/components/transcript/RunTranscriptView.test.tsx
+++ b/ui/src/components/transcript/RunTranscriptView.test.tsx
@@ -3,8 +3,8 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { parseAcpxStdoutLine } from "@paperclipai/adapter-utils/acpx-engine/ui";
-import type { TranscriptEntry } from "../../adapters";
-import { buildTranscript, type RunLogChunk } from "../../adapters";
+import { buildTranscript, type RunLogChunk, type TranscriptEntry } from "../../adapters";
+import type { ToolRunDecision } from "@paperclipai/shared";
import { ThemeProvider } from "../../context/ThemeContext";
import { RunTranscriptView, normalizeTranscript } from "./RunTranscriptView";
@@ -207,6 +207,103 @@ describe("RunTranscriptView", () => {
expect(html).not.toContain("result");
});
+ it("links tool rows to pending governed action decisions", () => {
+ const invocationId = "11111111-1111-4111-8111-111111111111";
+ const actionRequestId = "22222222-2222-4222-8222-222222222222";
+ const decision: ToolRunDecision = {
+ invocation: {
+ id: invocationId,
+ companyId: "company-1",
+ idempotencyKey: null,
+ actorType: "agent",
+ actorId: "agent-1",
+ agentId: "agent-1",
+ issueId: "issue-1",
+ runId: "run-1",
+ applicationId: null,
+ connectionId: null,
+ catalogEntryId: null,
+ toolName: "send_email",
+ argumentsHash: "hash-1",
+ argumentsSummary: { summary: "{\"to\":\"redacted\"}" },
+ policyDecision: "require_approval",
+ matchedPolicyIds: [],
+ approvalState: "pending",
+ status: "awaiting_approval",
+ upstreamRequestId: null,
+ resultHash: null,
+ resultSummary: null,
+ resultSizeBytes: null,
+ resultArtifactId: null,
+ errorCode: null,
+ errorMessage: null,
+ startedAt: null,
+ completedAt: null,
+ createdAt: new Date("2026-03-12T00:00:00.000Z"),
+ updatedAt: new Date("2026-03-12T00:00:00.000Z"),
+ },
+ actionRequest: {
+ id: actionRequestId,
+ companyId: "company-1",
+ invocationId,
+ issueId: "issue-1",
+ interactionId: "33333333-3333-4333-8333-333333333333",
+ approvalId: null,
+ status: "pending",
+ canonicalArgumentsHash: "hash-1",
+ canonicalArgumentsSummary: { summary: "{\"to\":\"redacted\"}" },
+ signedArguments: null,
+ previewMarkdown: "Tool: `send_email`",
+ requestedByAgentId: "agent-1",
+ requestedByUserId: null,
+ resolvedByAgentId: null,
+ resolvedByUserId: null,
+ decidedByAgentId: null,
+ decidedByUserId: null,
+ decidedAt: null,
+ expiresAt: null,
+ resolvedAt: null,
+ createdAt: new Date("2026-03-12T00:00:00.000Z"),
+ updatedAt: new Date("2026-03-12T00:00:00.000Z"),
+ },
+ auditEvents: [],
+ latestAuditEvent: null,
+ decision: "require_approval",
+ outcome: "pending",
+ reasonCode: "requires_approval_policy",
+ denialReason: null,
+ pendingAction: {
+ actionRequestId,
+ issueId: "issue-1",
+ interactionId: "33333333-3333-4333-8333-333333333333",
+ approvalId: null,
+ status: "pending",
+ previewMarkdown: "Tool: `send_email`",
+ },
+ };
+
+ const html = renderToStaticMarkup(
+
+
+ ,
+ );
+
+ expect(html).toContain("Needs approval");
+ expect(html).toContain(`Action request ${actionRequestId.slice(0, 8)}`);
+ });
+
it("windows large raw transcripts instead of rendering every entry at once", () => {
const entries: TranscriptEntry[] = Array.from({ length: 500 }, (_, index) => ({
kind: "stdout",
diff --git a/ui/src/components/transcript/RunTranscriptView.tsx b/ui/src/components/transcript/RunTranscriptView.tsx
index 737dfaa641..c95e2f1415 100644
--- a/ui/src/components/transcript/RunTranscriptView.tsx
+++ b/ui/src/components/transcript/RunTranscriptView.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { TranscriptEntry } from "../../adapters";
+import type { ToolRunDecision } from "@paperclipai/shared";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "../MarkdownBody";
import { cn, formatTokens } from "../../lib/utils";
import { runningLabelText } from "../../lib/status-colors";
@@ -24,6 +25,7 @@ const RAW_INITIAL_ROWS = 180;
interface RunTranscriptViewProps {
entries: TranscriptEntry[];
+ toolDecisions?: readonly ToolRunDecision[];
mode?: TranscriptMode;
density?: TranscriptDensity;
limit?: number;
@@ -55,6 +57,8 @@ type TranscriptBlock =
endTs?: string;
name: string;
toolUseId?: string;
+ invocationId?: string;
+ actionRequestId?: string;
input: unknown;
result?: string;
isError?: boolean;
@@ -74,6 +78,9 @@ type TranscriptBlock =
items: Array<{
ts: string;
endTs?: string;
+ toolUseId?: string;
+ invocationId?: string;
+ actionRequestId?: string;
input: unknown;
result?: string;
isError?: boolean;
@@ -88,6 +95,9 @@ type TranscriptBlock =
ts: string;
endTs?: string;
name: string;
+ toolUseId?: string;
+ invocationId?: string;
+ actionRequestId?: string;
input: unknown;
result?: string;
isError?: boolean;
@@ -325,6 +335,89 @@ function summarizeToolResult(result: string | undefined, isError: boolean | unde
return truncate(firstLine, density === "compact" ? 84 : 140);
}
+type ToolDecisionRefs = {
+ toolUseId?: string;
+ invocationId?: string;
+ actionRequestId?: string;
+};
+
+type ToolDecisionMaps = {
+ byInvocationId: Map;
+ byActionRequestId: Map;
+};
+
+function buildToolDecisionMaps(decisions: readonly ToolRunDecision[] | undefined): ToolDecisionMaps {
+ const byInvocationId = new Map();
+ const byActionRequestId = new Map();
+ for (const decision of decisions ?? []) {
+ byInvocationId.set(decision.invocation.id, decision);
+ if (decision.actionRequest?.id) {
+ byActionRequestId.set(decision.actionRequest.id, decision);
+ }
+ if (decision.latestAuditEvent?.actionRequestId) {
+ byActionRequestId.set(decision.latestAuditEvent.actionRequestId, decision);
+ }
+ }
+ return { byInvocationId, byActionRequestId };
+}
+
+function findToolDecision(maps: ToolDecisionMaps, refs: ToolDecisionRefs): ToolRunDecision | null {
+ if (refs.invocationId) {
+ const decision = maps.byInvocationId.get(refs.invocationId);
+ if (decision) return decision;
+ }
+ if (refs.actionRequestId) {
+ const decision = maps.byActionRequestId.get(refs.actionRequestId);
+ if (decision) return decision;
+ }
+ if (refs.toolUseId) {
+ return maps.byInvocationId.get(refs.toolUseId) ?? maps.byActionRequestId.get(refs.toolUseId) ?? null;
+ }
+ return null;
+}
+
+function summarizeToolDecision(decision: ToolRunDecision | null): { label: string; className: string; detail?: string } | null {
+ if (!decision) return null;
+ if (decision.pendingAction) {
+ return {
+ label: "Needs approval",
+ className: "text-amber-700 dark:text-amber-300",
+ detail: `Action request ${decision.pendingAction.actionRequestId.slice(0, 8)}`,
+ };
+ }
+ if (decision.denialReason || decision.invocation.status === "denied" || decision.outcome === "denied") {
+ return {
+ label: "Denied",
+ className: "text-red-700 dark:text-red-300",
+ detail: decision.denialReason ?? decision.reasonCode ?? undefined,
+ };
+ }
+ if (decision.invocation.status === "failed" || decision.invocation.status === "timed_out" || decision.outcome === "failure" || decision.outcome === "timeout") {
+ return {
+ label: decision.invocation.status === "timed_out" || decision.outcome === "timeout" ? "Timed out" : "Failed",
+ className: "text-red-700 dark:text-red-300",
+ detail: decision.denialReason ?? decision.reasonCode ?? undefined,
+ };
+ }
+ if (decision.actionRequest?.status === "approved") {
+ return { label: "Approved", className: "text-emerald-700 dark:text-emerald-300" };
+ }
+ if (decision.actionRequest?.status === "executed") {
+ return { label: "Executed", className: "text-emerald-700 dark:text-emerald-300" };
+ }
+ if (decision.decision === "allow" || decision.invocation.status === "authorized" || decision.invocation.status === "executing" || decision.invocation.status === "succeeded") {
+ return { label: "Allowed", className: "text-emerald-700 dark:text-emerald-300" };
+ }
+ if (decision.decision === "require_approval" || decision.invocation.approvalState === "pending") {
+ return { label: "Needs approval", className: "text-amber-700 dark:text-amber-300" };
+ }
+ return {
+ label: humanizeLabel(decision.invocation.status),
+ className: "text-foreground/70",
+ detail: decision.reasonCode ?? undefined,
+ };
+}
+
function parseSystemActivity(text: string): { activityId?: string; name: string; status: "running" | "completed" } | null {
const match = text.match(/^item (started|completed):\s*([a-z0-9_-]+)(?:\s+\(id=([^)]+)\))?$/i);
if (!match) return null;
@@ -368,6 +461,9 @@ function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
pending.push({
ts: block.ts,
endTs: block.endTs,
+ toolUseId: block.toolUseId,
+ invocationId: block.invocationId,
+ actionRequestId: block.actionRequestId,
input: block.input,
result: block.result,
isError: block.isError,
@@ -412,6 +508,9 @@ function groupToolBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
ts: block.ts,
endTs: block.endTs,
name: block.name,
+ toolUseId: block.toolUseId,
+ invocationId: block.invocationId,
+ actionRequestId: block.actionRequestId,
input: block.input,
result: block.result,
isError: block.isError,
@@ -484,6 +583,8 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
ts: entry.ts,
name: displayToolName(entry.name, entry.input),
toolUseId,
+ invocationId: entry.invocationId,
+ actionRequestId: entry.actionRequestId,
input: entry.input,
status: "running",
};
@@ -726,14 +827,69 @@ function TranscriptThinkingBlock({
);
}
+function ToolDecisionBadge({ decision }: { decision: ToolRunDecision | null }) {
+ const summary = summarizeToolDecision(decision);
+ if (!summary) return null;
+ return (
+
+ {summary.label}
+
+ );
+}
+
+function ToolDecisionInlineDetail({ decision }: { decision: ToolRunDecision | null }) {
+ const summary = summarizeToolDecision(decision);
+ if (!summary?.detail) return null;
+ return (
+
+ {summary.detail}
+
+ );
+}
+
+function ToolDecisionDetails({ decision, compact }: { decision: ToolRunDecision | null; compact: boolean }) {
+ if (!decision) return null;
+ const actionRequest = decision.actionRequest;
+ return (
+
+
+ Decision
+
+ {decision.reasonCode && {decision.reasonCode}}
+
+ {decision.denialReason && (
+
+ {decision.denialReason}
+
+ )}
+
+ invocation {decision.invocation.id.slice(0, 8)}
+ audit {decision.auditEvents.length}
+ {actionRequest && action {actionRequest.status} {actionRequest.id.slice(0, 8)}}
+ {actionRequest?.interactionId && card {actionRequest.interactionId.slice(0, 8)}}
+
+ {decision.pendingAction?.previewMarkdown && (
+
+ {decision.pendingAction.previewMarkdown}
+
+ )}
+
+ );
+}
+
function TranscriptToolCard({
block,
density,
+ decision,
}: {
block: Extract;
density: TranscriptDensity;
+ decision: ToolRunDecision | null;
}) {
- const [open, setOpen] = useState(block.status === "error");
+ const [open, setOpen] = useState(block.status === "error" || Boolean(decision?.pendingAction || decision?.denialReason));
const compact = density === "compact";
const parsedResult = parseStructuredToolResult(block.result);
const statusLabel =
@@ -784,10 +940,12 @@ function TranscriptToolCard({
{statusLabel}
+
{summary}
+