diff --git a/ui/src/components/IssueDispositionBadge.test.tsx b/ui/src/components/IssueDispositionBadge.test.tsx index 14ff6679e5a..374bb7b5158 100644 --- a/ui/src/components/IssueDispositionBadge.test.tsx +++ b/ui/src/components/IssueDispositionBadge.test.tsx @@ -4,7 +4,11 @@ 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"; +import { + IssueDispositionBadge, + dispositionCategory, + shouldShowDispositionBadge, +} from "./IssueDispositionBadge"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -45,6 +49,12 @@ describe("IssueDispositionBadge", () => { act(() => root.unmount()); }); + it("renders nothing for dispatchable (transient bookkeeping signal)", () => { + const root = render({ kind: "dispatchable", wakeTarget: "agent-1" }); + 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]"); @@ -52,6 +62,7 @@ describe("IssueDispositionBadge", () => { expect(badge?.getAttribute("data-execution-disposition-category")).toBe("live"); expect(badge?.textContent).toContain("Live"); expect(badge?.getAttribute("title")).toContain("Active run"); + expect(badge?.getAttribute("aria-label")).toContain("Active run"); act(() => root.unmount()); }); @@ -63,7 +74,36 @@ describe("IssueDispositionBadge", () => { act(() => root.unmount()); }); - it("renders invalid category with reason in tooltip", () => { + it("renders agent_continuable as Resuming with attempt counter on the visible label", () => { + const root = render({ kind: "agent_continuable", continuationAttempt: 1, maxAttempts: 2 }); + const badge = container.querySelector("[data-execution-disposition-kind]"); + expect(badge?.getAttribute("data-execution-disposition-category")).toBe("resuming"); + expect(badge?.textContent).toContain("Resuming · 1/2"); + // muted indigo treatment, not rose + expect(badge?.className).toContain("indigo"); + expect(badge?.className).not.toContain("rose"); + act(() => root.unmount()); + }); + + it("differentiates human_escalation_required by owner in the visible label", () => { + const owners = [ + { owner: "board", label: "Needs board" }, + { owner: "manager", label: "Needs manager" }, + { owner: "recovery_owner", label: "Needs owner" }, + { owner: "external", label: "Needs external" }, + ] as const; + for (const { owner, label } of owners) { + const root = render({ kind: "human_escalation_required", owner }); + const badge = container.querySelector("[data-execution-disposition-kind]"); + expect(badge?.getAttribute("data-execution-disposition-category")).toBe("needs_attention"); + expect(badge?.textContent).toContain(label); + // shared rose family for the escalation category + expect(badge?.className).toContain("rose"); + act(() => root.unmount()); + } + }); + + it("renders invalid (Stalled) with solid rose-600 fill so it stands out", () => { const root = render({ kind: "invalid", reason: "in_review_without_action_path", @@ -71,12 +111,60 @@ describe("IssueDispositionBadge", () => { }); const badge = container.querySelector("[data-execution-disposition-kind]"); expect(badge?.getAttribute("data-execution-disposition-category")).toBe("invalid"); + expect(badge?.textContent).toContain("Stalled"); expect(badge?.getAttribute("title")).toContain("Review without action path"); + expect(badge?.className).toContain("bg-rose-600"); + expect(badge?.className).toContain("text-white"); 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"); + it("dispositionCategory routes recovery and continuable to distinct categories", () => { + expect(dispositionCategory({ kind: "recoverable_by_control_plane", recovery: "dispatch" })).toBe( + "recovery", + ); + expect( + dispositionCategory({ kind: "agent_continuable", continuationAttempt: 1, maxAttempts: 2 }), + ).toBe("resuming"); + }); + + it("dispositionCategory returns null for dispatchable", () => { + expect(dispositionCategory({ kind: "dispatchable", wakeTarget: "agent-1" })).toBeNull(); + }); + + describe("shouldShowDispositionBadge", () => { + it("returns false for null, terminal, resting, and dispatchable", () => { + expect(shouldShowDispositionBadge(null)).toBe(false); + expect(shouldShowDispositionBadge({ kind: "terminal" })).toBe(false); + expect(shouldShowDispositionBadge({ kind: "resting" })).toBe(false); + expect(shouldShowDispositionBadge({ kind: "dispatchable", wakeTarget: "a" })).toBe(false); + }); + + it("suppresses generic waiting when the explicit waiting pill is shown", () => { + const waiting: IssueExecutionDisposition = { kind: "waiting", path: "interaction" }; + expect(shouldShowDispositionBadge(waiting, { isExplicitWaiting: true })).toBe(false); + expect(shouldShowDispositionBadge(waiting, { isExplicitWaiting: false })).toBe(true); + }); + + it("suppresses recovery when the IssueBlockedNotice already shows recovery_needed copy", () => { + const recovery: IssueExecutionDisposition = { + kind: "recoverable_by_control_plane", + recovery: "continuation", + }; + expect( + shouldShowDispositionBadge(recovery, { blockerAttentionState: "recovery_needed" }), + ).toBe(false); + expect(shouldShowDispositionBadge(recovery, { blockerAttentionState: "covered" })).toBe(true); + }); + + it("does not suppress resuming when blockerAttention is recovery_needed", () => { + const resuming: IssueExecutionDisposition = { + kind: "agent_continuable", + continuationAttempt: 1, + maxAttempts: 2, + }; + expect( + shouldShowDispositionBadge(resuming, { blockerAttentionState: "recovery_needed" }), + ).toBe(true); + }); }); }); diff --git a/ui/src/components/IssueDispositionBadge.tsx b/ui/src/components/IssueDispositionBadge.tsx index 76fcb726f2c..674a77424f8 100644 --- a/ui/src/components/IssueDispositionBadge.tsx +++ b/ui/src/components/IssueDispositionBadge.tsx @@ -1,4 +1,5 @@ import type { + IssueBlockerAttention, IssueExecutionDisposition, IssueExecutionHumanEscalationOwner, IssueExecutionLivePath, @@ -12,15 +13,16 @@ import { Hourglass, Lock, Play, + RotateCw, Wrench, } from "lucide-react"; import { cn } from "../lib/utils"; export type IssueDispositionCategory = | "live" - | "dispatchable" | "waiting" | "blocked_chain" + | "resuming" | "recovery" | "needs_attention" | "invalid" @@ -29,9 +31,9 @@ export type IssueDispositionCategory = const CATEGORY_LABEL: Record = { live: "Live", - dispatchable: "Ready", waiting: "Waiting", blocked_chain: "Blocked", + resuming: "Resuming", recovery: "Recovery", needs_attention: "Needs attention", invalid: "Stalled", @@ -63,11 +65,18 @@ const RECOVERY_LABEL: Record = { repair_wait: "Repairing wait state", }; -const ESCALATION_LABEL: Record = { +const ESCALATION_VISIBLE_LABEL: Record = { board: "Needs board", manager: "Needs manager", - recovery_owner: "Needs recovery owner", - external: "Needs external owner", + recovery_owner: "Needs owner", + external: "Needs external", +}; + +const ESCALATION_DETAIL_LABEL: Record = { + board: "Board must act", + manager: "Manager must act", + recovery_owner: "Recovery owner must act", + external: "External owner must act", }; export function dispositionCategory( @@ -82,11 +91,14 @@ export function dispositionCategory( case "live": return "live"; case "dispatchable": - return "dispatchable"; + // Dispatchable is the system's "ready to wake" state — it's a transient bookkeeping signal, + // not something the board needs to scan for. We surface no badge for it. + return null; case "waiting": return disposition.path === "blocker_chain" ? "blocked_chain" : "waiting"; - case "recoverable_by_control_plane": case "agent_continuable": + return "resuming"; + case "recoverable_by_control_plane": return "recovery"; case "human_escalation_required": return "needs_attention"; @@ -109,9 +121,9 @@ export function dispositionDetailLabel( case "recoverable_by_control_plane": return RECOVERY_LABEL[disposition.recovery]; case "agent_continuable": - return `Continuation ${disposition.continuationAttempt}/${disposition.maxAttempts}`; + return `Attempt ${disposition.continuationAttempt} of ${disposition.maxAttempts}`; case "human_escalation_required": - return ESCALATION_LABEL[disposition.owner]; + return ESCALATION_DETAIL_LABEL[disposition.owner]; case "invalid": return invalidReasonLabel(disposition.reason); default: @@ -119,6 +131,19 @@ export function dispositionDetailLabel( } } +export function dispositionVisibleLabel( + disposition: IssueExecutionDisposition | null | undefined, + category: IssueDispositionCategory, +): string { + if (disposition?.kind === "agent_continuable") { + return `Resuming · ${disposition.continuationAttempt}/${disposition.maxAttempts}`; + } + if (disposition?.kind === "human_escalation_required") { + return ESCALATION_VISIBLE_LABEL[disposition.owner]; + } + return CATEGORY_LABEL[category]; +} + function invalidReasonLabel(reason: string): string { switch (reason) { case "in_review_without_action_path": @@ -144,21 +169,25 @@ function invalidReasonLabel(reason: string): string { 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", + // Resuming is benign self-recovery — keep it muted (indigo) so it does not compete with sky/Waiting + // or land in the rose alarm family alongside Recovery / Needs attention / Stalled. + resuming: "border-indigo-300/70 bg-indigo-50 text-indigo-800 dark:border-indigo-500/40 dark:bg-indigo-500/10 dark:text-indigo-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", + // Stalled is the canary. Solid fill makes it the visual outlier in the rose family so a board + // scanning the inbox spots it without reading the label. + invalid: "border-rose-600 bg-rose-600 text-white dark:border-rose-500 dark:bg-rose-600 dark:text-white", 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, + resuming: RotateCw, recovery: Wrench, needs_attention: AlertTriangle, invalid: AlertOctagon, @@ -166,6 +195,23 @@ const CATEGORY_ICON: Record diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 45f48f1a8cd..3d81328893a 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -27,7 +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 { IssueDispositionBadge, dispositionDetailLabel, shouldShowDispositionBadge } from "./IssueDispositionBadge"; import { Identity } from "./Identity"; import { IssueReferencePill } from "./IssueReferencePill"; import { formatDate, cn, projectUrl } from "../lib/utils"; @@ -1050,9 +1050,20 @@ export function IssueProperties({ showLabel /> {(() => { - const category = dispositionCategory(issue.executionDisposition); - if (!category) return null; - if (category === "terminal" || category === "resting" || category === "dispatchable") return null; + // Mirror IssueRow's suppression: if the chat-thread IssueBlockedNotice already + // conveys the same signal (explicit waiting or recovery_needed), skip the badge so + // the detail page doesn't double-render the same state. + const isExplicitWaiting = + issue.blockerAttention?.state === "covered" + && issue.blockerAttention?.reason === "explicit_waiting"; + if ( + !shouldShowDispositionBadge(issue.executionDisposition, { + isExplicitWaiting, + blockerAttentionState: issue.blockerAttention?.state ?? null, + }) + ) { + return null; + } const detail = dispositionDetailLabel(issue.executionDisposition); return ( diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 193c33bc294..5469ddfd70a 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -291,6 +291,32 @@ describe("IssueRow", () => { act(() => root2.unmount()); }); + it("does not render a disposition badge for the dispatchable bookkeeping signal", () => { + const root = createRoot(container); + const issue = createIssue({ + executionDisposition: { kind: "dispatchable", wakeTarget: "agent-1" }, + }); + act(() => { + root.render(); + }); + expect(container.querySelector("[data-execution-disposition-kind]")).toBeNull(); + act(() => root.unmount()); + }); + + it("renders the resuming badge with the attempt counter on the visible label", () => { + const root = createRoot(container); + const issue = createIssue({ + executionDisposition: { kind: "agent_continuable", continuationAttempt: 1, maxAttempts: 2 }, + }); + act(() => { + root.render(); + }); + const badge = container.querySelector("[data-execution-disposition-kind]"); + expect(badge?.getAttribute("data-execution-disposition-category")).toBe("resuming"); + expect(badge?.textContent).toContain("Resuming · 1/2"); + act(() => root.unmount()); + }); + it("hides the disposition badge when the explicit waiting pill already conveys the same signal", () => { const root = createRoot(container); const issue = createIssue({ diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 73f69234559..5668f3f4428 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -11,7 +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"; +import { IssueDispositionBadge, shouldShowDispositionBadge } from "./IssueDispositionBadge"; type UnreadState = "hidden" | "visible" | "fading"; @@ -131,18 +131,15 @@ 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 ? ( - + // Skip the disposition badge 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 showDispositionBadge = shouldShowDispositionBadge(issue.executionDisposition, { + isExplicitWaiting, + blockerAttentionState: issue.blockerAttention?.state ?? null, + }); + const dispositionBadge = showDispositionBadge ? ( + ) : null; const productivityReviewIndicator = productivityReview ? ( +
+
+ {size} · {mode} +
+
+ {dispositionRowFixtures.map((fixture) => ( + } + /> + ))} +
+
+ + ); +} + function summaryBlocker( partial: Partial & Pick, ): IssueRelationIssueSummary { @@ -868,15 +968,17 @@ function StatusLanguage() { {[ { 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: "Resuming · 1/2", disposition: { kind: "agent_continuable", continuationAttempt: 1, maxAttempts: 2 } as const }, + { label: "Resuming · 2/2", disposition: { kind: "agent_continuable", continuationAttempt: 2, maxAttempts: 2 } 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: "Needs manager", disposition: { kind: "human_escalation_required", owner: "manager" } as const }, + { label: "Needs owner", disposition: { kind: "human_escalation_required", owner: "recovery_owner" } as const }, + { label: "Needs external", disposition: { kind: "human_escalation_required", owner: "external" } as const }, { label: "Stalled review", disposition: { @@ -885,6 +987,14 @@ function StatusLanguage() { suggestedCorrection: "Add participant or move out of review", } as const, }, + { + label: "Stalled chain", + disposition: { + kind: "invalid", + reason: "blocked_without_action_path", + suggestedCorrection: "Resolve or remove the orphan blocker", + } as const, + }, ].map((item) => (
))}
+

+ Stalled uses a solid rose-600 fill so it stands out against the rose family at scan distance. + Resuming sits in muted indigo (clearly off the rose alarm family) and surfaces the + continuation counter on the visible badge. Each escalation owner gets its own visible + label so the board can see who is blocking work without hovering. Dispatchable is + intentionally invisible — it's a transient bookkeeping signal, not something to scan for. +

+ + +
+
+ + + + +
+

+ Verifies the badge in production layout. Stalled is visually distinct from any rose-family + row at full-page scan, Resuming reads as benign muted indigo with the attempt counter, + and Needs board / Needs manager are independently legible without hover. +