From dc842ff7ea7c657028c15321e230047c5ca28366 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 6 Apr 2026 11:36:20 -0500 Subject: [PATCH] Polish board approval card styling Co-Authored-By: Paperclip --- ui/src/components/ApprovalCard.tsx | 153 +++++++++++++-------- ui/src/components/ApprovalPayload.test.tsx | 26 ++++ ui/src/components/ApprovalPayload.tsx | 111 +++++++++++---- 3 files changed, 209 insertions(+), 81 deletions(-) diff --git a/ui/src/components/ApprovalCard.tsx b/ui/src/components/ApprovalCard.tsx index 7fcbf131..7084f6b0 100644 --- a/ui/src/components/ApprovalCard.tsx +++ b/ui/src/components/ApprovalCard.tsx @@ -1,8 +1,15 @@ import { CheckCircle2, XCircle, Clock } from "lucide-react"; import { Link } from "@/lib/router"; +import { Badge } from "@/components/ui/badge"; import { Button, buttonVariants } from "@/components/ui/button"; import { Identity } from "./Identity"; -import { approvalLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "./ApprovalPayload"; +import { + approvalSubject, + typeIcon, + defaultTypeIcon, + ApprovalPayloadRenderer, + typeLabel, +} from "./ApprovalPayload"; import { timeAgo } from "../lib/timeAgo"; import type { Approval, Agent } from "@paperclipai/shared"; import { cn } from "@/lib/utils"; @@ -34,80 +41,110 @@ export function ApprovalCard({ isPending?: boolean; pendingAction?: "approve" | "reject" | null; }) { + const payload = approval.payload as Record | null; const Icon = typeIcon[approval.type] ?? defaultTypeIcon; - const label = approvalLabel(approval.type, approval.payload as Record | null); + const kindLabel = typeLabel[approval.type] ?? approval.type; + const subject = approvalSubject(payload); const showResolutionButtons = Boolean(onApprove && onReject) && approval.type !== "budget_override_required" && (approval.status === "pending" || approval.status === "revision_requested"); + const hasFooter = showResolutionButtons || Boolean(detailLink || onOpen); return ( -
- {/* Header */} -
-
- -
- {label} - {requesterAgent && ( - - requested by - - )} +
+
+
+
+
+ +
+
+
+ + {kindLabel} + + {requesterAgent && ( +
+ Requested by + +
+ )} +
+
+

+ {subject ?? kindLabel} +

+

+ Approval request created {timeAgo(approval.createdAt)} +

+
+
-
- {statusIcon(approval.status)} - {approval.status} - ยท {timeAgo(approval.createdAt)} +
+
+ {statusIcon(approval.status)} + {approval.status.replace(/_/g, " ")} +
- {/* Payload */} - +
+ +
- {/* Decision note */} {approval.decisionNote && ( -
- Note: {approval.decisionNote} +
+ Decision note. {approval.decisionNote}
)} - {/* Actions */} - {showResolutionButtons && ( -
- - -
- )} - {(detailLink || onOpen) ? ( -
- {detailLink ? ( - - View details - - ) : ( - - )} + {hasFooter ? ( +
+
+ {showResolutionButtons && ( + <> + + + + )} +
+ {(detailLink || onOpen) ? ( + detailLink ? ( + + View details + + ) : ( + + ) + ) : null}
) : null}
diff --git a/ui/src/components/ApprovalPayload.test.tsx b/ui/src/components/ApprovalPayload.test.tsx index 51814483..c11405e9 100644 --- a/ui/src/components/ApprovalPayload.test.tsx +++ b/ui/src/components/ApprovalPayload.test.tsx @@ -42,6 +42,7 @@ describe("ApprovalPayloadRenderer", () => { summary: "Board asked for approval before posting the frog.", recommendedAction: "Approve the frog reply.", nextActionOnApproval: "Post the frog comment on the issue.", + risks: ["The frog might be too powerful."], proposedComment: "(o)<", }} />, @@ -52,6 +53,7 @@ describe("ApprovalPayloadRenderer", () => { expect(container.textContent).toContain("Board asked for approval before posting the frog."); expect(container.textContent).toContain("Approve the frog reply."); expect(container.textContent).toContain("Post the frog comment on the issue."); + expect(container.textContent).toContain("The frog might be too powerful."); expect(container.textContent).toContain("(o)<"); expect(container.textContent).not.toContain("\"recommendedAction\""); @@ -59,4 +61,28 @@ describe("ApprovalPayloadRenderer", () => { root.unmount(); }); }); + + it("can hide the repeated title when the card header already shows it", () => { + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).toContain("Board asked for approval before posting the frog."); + expect(container.textContent).not.toContain("TitleReply with an ASCII frog"); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/ApprovalPayload.tsx b/ui/src/components/ApprovalPayload.tsx index bc6c06ae..6a1fe259 100644 --- a/ui/src/components/ApprovalPayload.tsx +++ b/ui/src/components/ApprovalPayload.tsx @@ -17,15 +17,19 @@ function firstNonEmptyString(...values: unknown[]): string | null { return null; } -/** Build a contextual label for an approval, e.g. "Hire Agent: Designer" */ -export function approvalLabel(type: string, payload?: Record | null): string { - const base = typeLabel[type] ?? type; - const subject = firstNonEmptyString( +export function approvalSubject(payload?: Record | null): string | null { + return firstNonEmptyString( payload?.title, payload?.name, payload?.summary, payload?.recommendedAction, ); +} + +/** Build a contextual label for an approval, e.g. "Hire Agent: Designer" */ +export function approvalLabel(type: string, payload?: Record | null): string { + const base = typeLabel[type] ?? type; + const subject = approvalSubject(payload); if (subject) { return `${base}: ${subject}`; } @@ -144,39 +148,100 @@ export function BudgetOverridePayload({ payload }: { payload: Record }) { +export function BoardApprovalPayload({ + payload, + hideTitle = false, +}: { + payload: Record; + hideTitle?: boolean; +}) { + const nextPayload = hideTitle ? { ...payload, title: undefined } : payload; return ( -
- - {!!payload.summary && ( -
- Summary - {String(payload.summary)} + + ); +} + +function BoardApprovalPayloadContent({ payload }: { payload: Record }) { + const risks = Array.isArray(payload.risks) + ? payload.risks + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean) + : []; + const title = firstNonEmptyString(payload.title); + const summary = firstNonEmptyString(payload.summary); + const recommendedAction = firstNonEmptyString(payload.recommendedAction); + const nextActionOnApproval = firstNonEmptyString(payload.nextActionOnApproval); + const proposedComment = firstNonEmptyString(payload.proposedComment); + + return ( +
+ {title && ( +
+

Title

+

{title}

)} - {!!payload.recommendedAction && ( -
- Recommended: {String(payload.recommendedAction)} + {summary && ( +
+

Summary

+

{summary}

)} - {!!payload.nextActionOnApproval && ( -
- On approval - {String(payload.nextActionOnApproval)} + {recommendedAction && ( +
+

+ Recommended action +

+

{recommendedAction}

)} - {!!payload.proposedComment && ( -
- {String(payload.proposedComment)} + {nextActionOnApproval && ( +
+

On approval

+

{nextActionOnApproval}

+
+ )} + {risks.length > 0 && ( +
+

Risks

+
    + {risks.map((risk) => ( +
  • + + {risk} +
  • + ))} +
+
+ )} + {proposedComment && ( +
+

+ Proposed comment +

+
+            {proposedComment}
+          
)}
); } -export function ApprovalPayloadRenderer({ type, payload }: { type: string; payload: Record }) { +export function ApprovalPayloadRenderer({ + type, + payload, + hidePrimaryTitle = false, +}: { + type: string; + payload: Record; + hidePrimaryTitle?: boolean; +}) { if (type === "hire_agent") return ; if (type === "budget_override_required") return ; - if (type === "request_board_approval") return ; + if (type === "request_board_approval") { + return ; + } return ; }