PAP-2822 Render execution disposition badge in board UI

Adds IssueDispositionBadge that maps the canonical disposition to a
small palette of categories (live, ready, waiting, blocked-chain,
recovery, needs attention, stalled). Renders the badge in the inbox
row and on the issue detail status row, suppressing it where the
existing waiting/recovery copy already conveys the same signal so we
don't double up. Storybook gains a section showing every category for
UX review.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-29 10:50:32 -05:00
1 parent 2b3a7f683b
commit 6ac545bae7
6 files changed
+420 -3

No files matched your search

@@ -0,0 +1,82 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { IssueExecutionDisposition } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { IssueDispositionBadge, dispositionCategory } from "./IssueDispositionBadge";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("IssueDispositionBadge", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
});
function render(disposition: IssueExecutionDisposition | null) {
const root = createRoot(container);
act(() => {
root.render(<IssueDispositionBadge disposition={disposition} />);
});
return root;
}
it("renders nothing for null disposition", () => {
const root = render(null);
expect(container.querySelector("[data-execution-disposition-kind]")).toBeNull();
act(() => root.unmount());
});
it("renders nothing for terminal and resting kinds by default", () => {
let root = render({ kind: "terminal" });
expect(container.querySelector("[data-execution-disposition-kind]")).toBeNull();
act(() => root.unmount());
root = render({ kind: "resting" });
expect(container.querySelector("[data-execution-disposition-kind]")).toBeNull();
act(() => root.unmount());
});
it("renders Live category for live disposition", () => {
const root = render({ kind: "live", path: "active_run" });
const badge = container.querySelector("[data-execution-disposition-kind]");
expect(badge?.getAttribute("data-execution-disposition-kind")).toBe("live");
expect(badge?.getAttribute("data-execution-disposition-category")).toBe("live");
expect(badge?.textContent).toContain("Live");
expect(badge?.getAttribute("title")).toContain("Active run");
act(() => root.unmount());
});
it("distinguishes blocked_chain from generic waiting", () => {
const root = render({ kind: "waiting", path: "blocker_chain" });
const badge = container.querySelector("[data-execution-disposition-kind]");
expect(badge?.getAttribute("data-execution-disposition-category")).toBe("blocked_chain");
expect(badge?.textContent).toContain("Blocked");
act(() => root.unmount());
});
it("renders invalid category with reason in tooltip", () => {
const root = render({
kind: "invalid",
reason: "in_review_without_action_path",
suggestedCorrection: "fix it",
});
const badge = container.querySelector("[data-execution-disposition-kind]");
expect(badge?.getAttribute("data-execution-disposition-category")).toBe("invalid");
expect(badge?.getAttribute("title")).toContain("Review without action path");
act(() => root.unmount());
});
it("dispositionCategory maps recovery and continuable to recovery", () => {
expect(dispositionCategory({ kind: "recoverable_by_control_plane", recovery: "dispatch" })).toBe("recovery");
expect(dispositionCategory({ kind: "agent_continuable", continuationAttempt: 1, maxAttempts: 2 })).toBe("recovery");
});
});
+209
View File
@@ -0,0 +1,209 @@
import type {
IssueExecutionDisposition,
IssueExecutionHumanEscalationOwner,
IssueExecutionLivePath,
IssueExecutionRecoveryKind,
IssueExecutionWaitingPath,
} from "@paperclipai/shared";
import {
AlertOctagon,
AlertTriangle,
CircleDashed,
Hourglass,
Lock,
Play,
Wrench,
} from "lucide-react";
import { cn } from "../lib/utils";
export type IssueDispositionCategory =
| "live"
| "dispatchable"
| "waiting"
| "blocked_chain"
| "recovery"
| "needs_attention"
| "invalid"
| "terminal"
| "resting";
const CATEGORY_LABEL: Record<IssueDispositionCategory, string> = {
live: "Live",
dispatchable: "Ready",
waiting: "Waiting",
blocked_chain: "Blocked",
recovery: "Recovery",
needs_attention: "Needs attention",
invalid: "Stalled",
terminal: "Done",
resting: "Resting",
};
const LIVE_PATH_LABEL: Record<IssueExecutionLivePath, string> = {
active_run: "Active run",
queued_wake: "Queued wake",
scheduled_retry: "Scheduled retry",
deferred_execution: "Deferred execution",
};
const WAITING_PATH_LABEL: Record<IssueExecutionWaitingPath, string> = {
participant: "Review participant",
interaction: "Interaction response",
approval: "Board approval",
human_owner: "Human owner",
blocker_chain: "Blocker chain",
pause_hold: "Tree paused",
review_artifact: "Recovery work",
external_owner_action: "External owner",
};
const RECOVERY_LABEL: Record<IssueExecutionRecoveryKind, string> = {
dispatch: "Awaiting dispatch repair",
continuation: "Awaiting continuation",
repair_wait: "Repairing wait state",
};
const ESCALATION_LABEL: Record<IssueExecutionHumanEscalationOwner, string> = {
board: "Needs board",
manager: "Needs manager",
recovery_owner: "Needs recovery owner",
external: "Needs external owner",
};
export function dispositionCategory(
disposition: IssueExecutionDisposition | null | undefined,
): IssueDispositionCategory | null {
if (!disposition) return null;
switch (disposition.kind) {
case "terminal":
return "terminal";
case "resting":
return "resting";
case "live":
return "live";
case "dispatchable":
return "dispatchable";
case "waiting":
return disposition.path === "blocker_chain" ? "blocked_chain" : "waiting";
case "recoverable_by_control_plane":
case "agent_continuable":
return "recovery";
case "human_escalation_required":
return "needs_attention";
case "invalid":
return "invalid";
default:
return null;
}
}
export function dispositionDetailLabel(
disposition: IssueExecutionDisposition | null | undefined,
): string | null {
if (!disposition) return null;
switch (disposition.kind) {
case "live":
return LIVE_PATH_LABEL[disposition.path];
case "waiting":
return WAITING_PATH_LABEL[disposition.path];
case "recoverable_by_control_plane":
return RECOVERY_LABEL[disposition.recovery];
case "agent_continuable":
return `Continuation ${disposition.continuationAttempt}/${disposition.maxAttempts}`;
case "human_escalation_required":
return ESCALATION_LABEL[disposition.owner];
case "invalid":
return invalidReasonLabel(disposition.reason);
default:
return null;
}
}
function invalidReasonLabel(reason: string): string {
switch (reason) {
case "in_review_without_action_path":
return "Review without action path";
case "invalid_review_participant":
return "Invalid review participant";
case "blocked_by_invalid_issue":
return "Blocked by invalid issue";
case "blocked_by_cancelled_issue":
return "Blocked by cancelled issue";
case "blocked_by_unassigned_issue":
return "Blocked by unassigned issue";
case "blocked_by_resting_issue":
return "Blocked by resting issue";
case "blocked_without_action_path":
return "Blocked without action path";
case "dual_assignee":
return "Dual assignee";
default:
return reason.replace(/_/g, " ");
}
}
const CATEGORY_PILL: Record<IssueDispositionCategory, string> = {
live: "border-emerald-300/70 bg-emerald-50 text-emerald-800 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-200",
dispatchable: "border-sky-300/70 bg-sky-50 text-sky-800 dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-200",
waiting: "border-sky-300/70 bg-sky-50 text-sky-800 dark:border-sky-500/40 dark:bg-sky-500/10 dark:text-sky-200",
blocked_chain: "border-amber-300/70 bg-amber-50 text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-200",
recovery: "border-rose-300/70 bg-rose-50 text-rose-800 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-200",
needs_attention: "border-rose-300/70 bg-rose-50 text-rose-800 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-200",
invalid: "border-rose-400/80 bg-rose-100 text-rose-900 dark:border-rose-500/60 dark:bg-rose-500/20 dark:text-rose-100",
terminal: "border-border bg-muted text-muted-foreground",
resting: "border-border bg-muted text-muted-foreground",
};
const CATEGORY_ICON: Record<IssueDispositionCategory, React.ComponentType<{ className?: string }>> = {
live: Play,
dispatchable: Play,
waiting: Hourglass,
blocked_chain: Lock,
recovery: Wrench,
needs_attention: AlertTriangle,
invalid: AlertOctagon,
terminal: CircleDashed,
resting: CircleDashed,
};
export interface IssueDispositionBadgeProps {
disposition: IssueExecutionDisposition | null | undefined;
className?: string;
hideLabel?: boolean;
hideForResting?: boolean;
hideForTerminal?: boolean;
}
export function IssueDispositionBadge({
disposition,
className,
hideLabel = false,
hideForResting = true,
hideForTerminal = true,
}: IssueDispositionBadgeProps) {
const category = dispositionCategory(disposition);
if (!category) return null;
if (hideForResting && category === "resting") return null;
if (hideForTerminal && category === "terminal") return null;
const detail = dispositionDetailLabel(disposition);
const label = CATEGORY_LABEL[category];
const Icon = CATEGORY_ICON[category];
const tooltip = detail ? `${label} · ${detail}` : label;
return (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium",
CATEGORY_PILL[category],
className,
)}
title={tooltip}
data-execution-disposition-kind={disposition?.kind}
data-execution-disposition-category={category}
>
<Icon className="h-3 w-3" aria-hidden />
{hideLabel ? null : <span>{label}</span>}
</span>
);
}
+15
View File
@@ -27,6 +27,7 @@ import { formatAssigneeUserLabel } from "../lib/assignees";
import { buildExecutionPolicy, stageParticipantValues } from "../lib/issue-execution-policy";
import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { IssueDispositionBadge, dispositionCategory, dispositionDetailLabel } from "./IssueDispositionBadge";
import { Identity } from "./Identity";
import { IssueReferencePill } from "./IssueReferencePill";
import { formatDate, cn, projectUrl } from "../lib/utils";
@@ -1048,6 +1049,20 @@ export function IssueProperties({
onChange={(status) => onUpdate({ status })}
showLabel
/>
{(() => {
const category = dispositionCategory(issue.executionDisposition);
if (!category) return null;
if (category === "terminal" || category === "resting" || category === "dispatchable") return null;
const detail = dispositionDetailLabel(issue.executionDisposition);
return (
<span className="inline-flex items-center gap-1.5">
<IssueDispositionBadge disposition={issue.executionDisposition ?? null} />
{detail ? (
<span className="text-xs text-muted-foreground">{detail}</span>
) : null}
</span>
);
})()}
</PropertyRow>
<PropertyRow label="Priority">
+55
View File
@@ -261,6 +261,61 @@ describe("IssueRow", () => {
});
});
it("renders the disposition badge for live and invalid dispositions", () => {
const root = createRoot(container);
const liveIssue = createIssue({
executionDisposition: { kind: "live", path: "active_run" },
});
act(() => {
root.render(<IssueRow issue={liveIssue} />);
});
let badge = container.querySelector("[data-execution-disposition-kind]");
expect(badge?.getAttribute("data-execution-disposition-category")).toBe("live");
act(() => root.unmount());
const root2 = createRoot(container);
const stalledIssue = createIssue({
status: "in_review",
executionDisposition: {
kind: "invalid",
reason: "in_review_without_action_path",
suggestedCorrection: "fix",
},
});
act(() => {
root2.render(<IssueRow issue={stalledIssue} />);
});
badge = container.querySelector("[data-execution-disposition-kind]");
expect(badge?.getAttribute("data-execution-disposition-category")).toBe("invalid");
expect(badge?.textContent).toContain("Stalled");
act(() => root2.unmount());
});
it("hides the disposition badge when the explicit waiting pill already conveys the same signal", () => {
const root = createRoot(container);
const issue = createIssue({
blockerAttention: {
state: "covered",
reason: "explicit_waiting",
unresolvedBlockerCount: 0,
coveredBlockerCount: 0,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: null,
sampleStalledBlockerIdentifier: null,
nextActionOwner: { type: "user", agentId: null, userId: null },
nextActionHint: "needs_human_review",
},
executionDisposition: { kind: "waiting", path: "interaction" },
});
act(() => {
root.render(<IssueRow issue={issue} />);
});
const badge = container.querySelector("[data-execution-disposition-kind]");
expect(badge).toBeNull();
act(() => root.unmount());
});
it("renders without error when titleSuffix is omitted", () => {
const root = createRoot(container);
+19 -3
View File
@@ -11,6 +11,7 @@ import { formatAssigneeUserLabel } from "../lib/assignees";
import { cn } from "../lib/utils";
import { StatusIcon } from "./StatusIcon";
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
import { IssueDispositionBadge, dispositionCategory } from "./IssueDispositionBadge";
type UnreadState = "hidden" | "visible" | "fading";
@@ -130,6 +131,19 @@ export function IssueRow({
Waiting
</span>
) : null;
// Disposition badge: skip when the existing waiting/recovery affordance already conveys the same
// signal (explicit waiting pill or blockerAttention recovery_needed copy), so we don't double up.
const dispositionForBadge = (() => {
const category = dispositionCategory(issue.executionDisposition);
if (!category) return null;
if (category === "terminal" || category === "resting" || category === "dispatchable") return null;
if (category === "waiting" && isExplicitWaiting) return null;
if (category === "recovery" && issue.blockerAttention?.state === "recovery_needed") return null;
return issue.executionDisposition ?? null;
})();
const dispositionBadge = dispositionForBadge ? (
<IssueDispositionBadge disposition={dispositionForBadge} />
) : null;
const productivityReviewIndicator = productivityReview ? (
<span
className={cn(
@@ -213,18 +227,20 @@ export function IssueRow({
) : null}
</span>
</span>
{(desktopTrailing || trailingMeta || desktopWaitingPill) ? (
{(desktopTrailing || trailingMeta || desktopWaitingPill || dispositionBadge) ? (
<span className="ml-auto hidden shrink-0 items-center gap-2 sm:order-3 sm:flex sm:gap-3">
{desktopWaitingPill}
{dispositionBadge}
{desktopTrailing}
{trailingMeta ? (
<span className="text-xs text-muted-foreground">{trailingMeta}</span>
) : null}
</span>
) : null}
{mobileWaitingPill ? (
<span className="ml-auto inline-flex shrink-0 items-center sm:hidden">
{mobileWaitingPill || dispositionBadge ? (
<span className="ml-auto inline-flex shrink-0 items-center gap-1.5 sm:hidden">
{mobileWaitingPill}
{dispositionBadge}
</span>
) : null}
{showUnreadSlot ? (
@@ -15,6 +15,7 @@ import { IssueRow } from "@/components/IssueRow";
import { MetricCard } from "@/components/MetricCard";
import { PriorityIcon } from "@/components/PriorityIcon";
import { ProductivityReviewBadge } from "@/components/ProductivityReviewBadge";
import { IssueDispositionBadge } from "@/components/IssueDispositionBadge";
import { QuotaBar } from "@/components/QuotaBar";
import { StatusBadge } from "@/components/StatusBadge";
import { StatusIcon } from "@/components/StatusIcon";
@@ -857,6 +858,45 @@ function StatusLanguage() {
</p>
</Section>
<Section eyebrow="Execution disposition" title="Canonical disposition badges">
<p className="mb-4 max-w-3xl text-sm leading-6 text-muted-foreground">
The badge derives every category from the API's <code>executionDisposition</code> field —
the same classifier the recovery and transition guard services use. The board reads this
instead of inferring health from status alone.
</p>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{[
{ label: "Live · active run", disposition: { kind: "live", path: "active_run" } as const },
{ label: "Live · queued wake", disposition: { kind: "live", path: "queued_wake" } as const },
{ label: "Ready · dispatchable", disposition: { kind: "dispatchable", wakeTarget: "agent-1" } as const },
{ label: "Waiting · interaction", disposition: { kind: "waiting", path: "interaction" } as const },
{ label: "Waiting · approval", disposition: { kind: "waiting", path: "approval" } as const },
{ label: "Waiting · participant", disposition: { kind: "waiting", path: "participant" } as const },
{ label: "Blocked · chain", disposition: { kind: "waiting", path: "blocker_chain" } as const },
{ label: "Recovery · continuation", disposition: { kind: "recoverable_by_control_plane", recovery: "continuation" } as const },
{ label: "Continuable · 1/2", disposition: { kind: "agent_continuable", continuationAttempt: 1, maxAttempts: 2 } as const },
{ label: "Needs manager", disposition: { kind: "human_escalation_required", owner: "manager" } as const },
{ label: "Needs board", disposition: { kind: "human_escalation_required", owner: "board" } as const },
{
label: "Stalled review",
disposition: {
kind: "invalid",
reason: "in_review_without_action_path",
suggestedCorrection: "Add participant or move out of review",
} as const,
},
].map((item) => (
<div
key={item.label}
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background/70 p-4"
>
<span className="text-sm">{item.label}</span>
<IssueDispositionBadge disposition={item.disposition} hideForResting={false} hideForTerminal={false} />
</div>
))}
</div>
</Section>
<Section eyebrow="Productivity review" title="Yellow accountability state on source issues">
<ProductivityReviewMatrix />
</Section>