diff --git a/ui/src/components/IssueDispositionBadge.test.tsx b/ui/src/components/IssueDispositionBadge.test.tsx new file mode 100644 index 00000000000..14ff6679e5a --- /dev/null +++ b/ui/src/components/IssueDispositionBadge.test.tsx @@ -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(); + }); + 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"); + }); +}); diff --git a/ui/src/components/IssueDispositionBadge.tsx b/ui/src/components/IssueDispositionBadge.tsx new file mode 100644 index 00000000000..76fcb726f2c --- /dev/null +++ b/ui/src/components/IssueDispositionBadge.tsx @@ -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 = { + 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 = { + active_run: "Active run", + queued_wake: "Queued wake", + scheduled_retry: "Scheduled retry", + deferred_execution: "Deferred execution", +}; + +const WAITING_PATH_LABEL: Record = { + 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 = { + dispatch: "Awaiting dispatch repair", + continuation: "Awaiting continuation", + repair_wait: "Repairing wait state", +}; + +const ESCALATION_LABEL: Record = { + 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 = { + 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> = { + 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 ( + + + {hideLabel ? null : {label}} + + ); +} diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index c11631a84cb..45f48f1a8cd 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -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 ( + + + {detail ? ( + {detail} + ) : null} + + ); + })()} diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 4250065584d..193c33bc294 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -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(); + }); + 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(); + }); + 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(); + }); + 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); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index bd8b98b8092..73f69234559 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -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 ) : 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 ? ( + + ) : null; const productivityReviewIndicator = productivityReview ? ( - {(desktopTrailing || trailingMeta || desktopWaitingPill) ? ( + {(desktopTrailing || trailingMeta || desktopWaitingPill || dispositionBadge) ? ( {desktopWaitingPill} + {dispositionBadge} {desktopTrailing} {trailingMeta ? ( {trailingMeta} ) : null} ) : null} - {mobileWaitingPill ? ( - + {mobileWaitingPill || dispositionBadge ? ( + {mobileWaitingPill} + {dispositionBadge} ) : null} {showUnreadSlot ? ( diff --git a/ui/storybook/stories/status-language.stories.tsx b/ui/storybook/stories/status-language.stories.tsx index c25a66b8cfa..ab3b7299285 100644 --- a/ui/storybook/stories/status-language.stories.tsx +++ b/ui/storybook/stories/status-language.stories.tsx @@ -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() {

+
+

+ The badge derives every category from the API's executionDisposition field — + the same classifier the recovery and transition guard services use. The board reads this + instead of inferring health from status alone. +

+
+ {[ + { 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) => ( +
+ {item.label} + +
+ ))} +
+
+