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}
+
+
+ ))}
+
+
+