mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user