paperclip/ui/src/components/IssueWriteDenialNotice.tsx

115 lines
4.4 KiB
TypeScript

import { Clock, EyeOff, ShieldX, UserCog } from "lucide-react";
import {
describeIssueWriteDenial,
type IssueWriteDenialCode,
type IssueWriteDenialContext,
type IssueWriteDenialTone,
} from "@paperclipai/shared";
import { cn } from "../lib/utils";
/**
* Renders actionable copy for a denied issue write (the open cross-task write design (failure UX)).
*
* Cross-issue writes are open by default, so the walls that remain are rare —
* which is exactly why hitting one has to be self-explaining. A real incident
* burned a full detour discovering a workaround behind an opaque 403; this notice always
* states the boundary that fired, who *can* act, and the sanctioned path.
*
* Copy comes from the shared `describeIssueWriteDenial` contract, so the words a
* human reads here match the API error body an agent reads.
*/
const TONE_ICONS: Record<IssueWriteDenialTone, typeof ShieldX> = {
boundary: ShieldX,
lock: Clock,
cap: Clock,
attribution: UserCog,
};
const TONE_CLASSES: Record<IssueWriteDenialTone, { surface: string; icon: string; action: string }> = {
// A real authorization wall — the strongest signal.
boundary: {
surface:
"border-red-300/70 bg-red-50/90 text-red-950 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-100",
icon: "text-red-600 dark:text-red-300",
action: "text-red-800 dark:text-red-200",
},
// Run-lifecycle machinery that clears on its own; not a permission problem.
lock: {
surface:
"border-amber-300/70 bg-amber-50/90 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100",
icon: "text-amber-600 dark:text-amber-300",
action: "text-amber-800 dark:text-amber-200",
},
// A rate backstop — the write was allowed, the budget was not.
cap: {
surface:
"border-amber-300/70 bg-amber-50/90 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100",
icon: "text-amber-600 dark:text-amber-300",
action: "text-amber-800 dark:text-amber-200",
},
// Rejected attribution: the write is fine, the claimed identity is not.
// Neutral surface, but the action lines stay near-foreground — on a muted
// background `text-muted-foreground` drops below comfortable contrast.
attribution: {
surface:
"border-border bg-muted/60 text-foreground dark:border-border dark:bg-muted/30",
icon: "text-muted-foreground",
action: "text-foreground/80",
},
};
/** Codes whose primary failure is that the target is simply not visible. */
const VISIBILITY_CODES: ReadonlySet<IssueWriteDenialCode> = new Set([
"issue_write_not_visible",
]);
export function IssueWriteDenialNotice({
code,
context,
className,
}: {
code: IssueWriteDenialCode;
context?: IssueWriteDenialContext;
className?: string;
}) {
const copy = describeIssueWriteDenial(code, context ?? {});
const tone = TONE_CLASSES[copy.tone];
const Icon = VISIBILITY_CODES.has(code) ? EyeOff : TONE_ICONS[copy.tone];
return (
<div
role="status"
data-testid="issue-write-denial-notice"
data-denial-code={copy.code}
data-denial-tone={copy.tone}
className={cn("rounded-md border px-3 py-2.5 text-sm shadow-sm", tone.surface, className)}
>
<div className="flex items-start gap-2">
<Icon className={cn("mt-0.5 size-4 shrink-0", tone.icon)} aria-hidden="true" />
<div className="min-w-0 space-y-1">
<p className="font-medium leading-5">
{copy.title}
{/* Naming the boundary is obligation 1 of plan §6. */}
<span className="ml-1.5 font-normal opacity-80">({copy.boundary})</span>
</p>
<p className="leading-5">{copy.description}</p>
{/* Inline flow, not flex rows: a flex `dd` is an unbreakable line box,
so a long value drops whole and orphans its label. Inline keeps the
label and the first words of the value together at every width. */}
<dl className={cn("space-y-0.5 text-xs leading-5", tone.action)}>
<div className="min-w-0">
<dt className="inline font-medium">Who can act:</dt>{" "}
<dd className="inline">{copy.whoCanAct}</dd>
</div>
<div className="min-w-0">
<dt className="inline font-medium">Try this:</dt>{" "}
<dd className="inline">{copy.sanctionedPath}</dd>
</div>
</dl>
</div>
</div>
</div>
);
}