PAP-2822 Address UX review on disposition badge

- Drop the dispatchable "Ready" variant from the badge: the only place it ever
  rendered was Storybook (IssueRow and IssueProperties already returned null
  for it). dispositionCategory now returns null for dispatchable.
- Pull agent_continuable into a discrete "Resuming" category with a muted
  indigo treatment so it doesn't share the rose alarm family with Recovery /
  Needs attention / Stalled, and surface "Resuming · n/m" on the visible
  badge instead of hiding the attempt counter in the tooltip.
- Differentiate human_escalation_required by owner on the visible label:
  Needs board / Needs manager / Needs owner / Needs external. Color stays in
  the rose family but the actionable bit is no longer hover-only.
- Bump invalid (Stalled) to a solid rose-600 fill so it stands out against
  the rest of the rose family at scan distance, satisfying the parent plan's
  acceptance criterion that the board can spot a stalled review leaf without
  reading the full thread.
- Mirror the IssueRow suppression rule on IssueProperties so the Status row
  doesn't double-render the same waiting/recovery signal as the chat-thread
  IssueBlockedNotice.
- Add aria-label={tooltip} on the badge so screen readers get the path
  detail, not just the category label.
- Storybook: rename and split the disposition matrix to cover all four
  escalation owners, both Resuming attempts, and add a populated IssueRow
  surface that shows the badge in production layout context (Live, Resuming,
  Needs board, Needs manager, Stalled).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-29 11:45:11 -05:00
1 parent 925ac9a3c1
commit aa1628008e
6 files changed
+338 -38

No files matched your search

@@ -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);
});
});
});
+60 -13
View File
@@ -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<IssueDispositionCategory, string> = {
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<IssueExecutionRecoveryKind, string> = {
repair_wait: "Repairing wait state",
};
const ESCALATION_LABEL: Record<IssueExecutionHumanEscalationOwner, string> = {
const ESCALATION_VISIBLE_LABEL: Record<IssueExecutionHumanEscalationOwner, string> = {
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<IssueExecutionHumanEscalationOwner, string> = {
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<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",
// 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<IssueDispositionCategory, React.ComponentType<{ className?: string }>> = {
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<IssueDispositionCategory, React.ComponentType<{ clas
resting: CircleDashed,
};
export interface ShouldShowDispositionBadgeOptions {
isExplicitWaiting?: boolean;
blockerAttentionState?: IssueBlockerAttention["state"] | null;
}
export function shouldShowDispositionBadge(
disposition: IssueExecutionDisposition | null | undefined,
options: ShouldShowDispositionBadgeOptions = {},
): boolean {
const category = dispositionCategory(disposition);
if (!category) return false;
if (category === "terminal" || category === "resting") return false;
if (category === "waiting" && options.isExplicitWaiting) return false;
if (category === "recovery" && options.blockerAttentionState === "recovery_needed") return false;
return true;
}
export interface IssueDispositionBadgeProps {
disposition: IssueExecutionDisposition | null | undefined;
className?: string;
@@ -187,7 +233,7 @@ export function IssueDispositionBadge({
if (hideForTerminal && category === "terminal") return null;
const detail = dispositionDetailLabel(disposition);
const label = CATEGORY_LABEL[category];
const label = dispositionVisibleLabel(disposition, category);
const Icon = CATEGORY_ICON[category];
const tooltip = detail ? `${label} · ${detail}` : label;
@@ -199,6 +245,7 @@ export function IssueDispositionBadge({
className,
)}
title={tooltip}
aria-label={tooltip}
data-execution-disposition-kind={disposition?.kind}
data-execution-disposition-category={category}
>
+15 -4
View File
@@ -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 (
<span className="inline-flex items-center gap-1.5">
+26
View File
@@ -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(<IssueRow issue={issue} />);
});
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(<IssueRow issue={issue} />);
});
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({
+10 -13
View File
@@ -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
</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} />
// 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 ? (
<IssueDispositionBadge disposition={issue.executionDisposition ?? null} />
) : null;
const productivityReviewIndicator = productivityReview ? (
<span
@@ -291,6 +291,106 @@ const coveredBlockedIssue = createIssue({
updatedAt: new Date("2026-04-24T13:40:00.000Z"),
});
const dispositionRowFixtures = [
{
caption: "Live · active run — emerald",
issue: createIssue({
id: "issue-live-row",
identifier: "PAP-2820",
issueNumber: 2820,
title: "Phase 4 wiring · active execution",
status: "in_progress",
priority: "medium",
executionDisposition: { kind: "live", path: "active_run" },
lastActivityAt: new Date("2026-04-29T13:40:00.000Z"),
updatedAt: new Date("2026-04-29T13:40:00.000Z"),
}),
},
{
caption: "Resuming · 1/2 — muted indigo, attempt counter inline",
issue: createIssue({
id: "issue-resuming-row",
identifier: "PAP-2821",
issueNumber: 2821,
title: "Continuation queued after transient failure",
status: "in_progress",
priority: "medium",
executionDisposition: { kind: "agent_continuable", continuationAttempt: 1, maxAttempts: 2 },
lastActivityAt: new Date("2026-04-29T13:42:00.000Z"),
updatedAt: new Date("2026-04-29T13:42:00.000Z"),
}),
},
{
caption: "Needs board — rose, owner-specific label",
issue: createIssue({
id: "issue-needs-board-row",
identifier: "PAP-2823",
issueNumber: 2823,
title: "Spend approval blocked on board response",
status: "in_review",
priority: "high",
executionDisposition: { kind: "human_escalation_required", owner: "board" },
lastActivityAt: new Date("2026-04-29T13:44:00.000Z"),
updatedAt: new Date("2026-04-29T13:44:00.000Z"),
}),
},
{
caption: "Needs manager — rose, owner-specific label",
issue: createIssue({
id: "issue-needs-manager-row",
identifier: "PAP-2824",
issueNumber: 2824,
title: "Specialty handoff blocked — manager must act",
status: "in_review",
priority: "high",
executionDisposition: { kind: "human_escalation_required", owner: "manager" },
lastActivityAt: new Date("2026-04-29T13:45:00.000Z"),
updatedAt: new Date("2026-04-29T13:45:00.000Z"),
}),
},
{
caption: "Stalled — solid rose-600 fill, the canary outlier",
issue: createIssue({
id: "issue-stalled-row",
identifier: "PAP-2825",
issueNumber: 2825,
title: "Review without action path — operator must intervene",
status: "in_review",
priority: "high",
executionDisposition: {
kind: "invalid",
reason: "in_review_without_action_path",
suggestedCorrection: "Add participant or move out of review",
},
lastActivityAt: new Date("2026-04-29T13:50:00.000Z"),
updatedAt: new Date("2026-04-29T13:50:00.000Z"),
}),
},
];
function DispositionRowSurface({ mode, size }: { mode: "light" | "dark"; size: "desktop" | "mobile" }) {
const isDark = mode === "dark";
const isMobile = size === "mobile";
return (
<div className={isDark ? "dark" : undefined}>
<div className="rounded-lg border border-border bg-background text-foreground">
<div className="border-b border-border px-3 py-2 text-xs font-medium text-muted-foreground">
{size} · {mode}
</div>
<div className={isMobile ? "max-w-[358px]" : "min-w-[620px]"}>
{dispositionRowFixtures.map((fixture) => (
<IssueRow
key={fixture.issue.id}
issue={fixture.issue}
mobileMeta={<StatusBadge status={fixture.issue.status} />}
/>
))}
</div>
</div>
</div>
);
}
function summaryBlocker(
partial: Partial<IssueRelationIssueSummary> & Pick<IssueRelationIssueSummary, "id" | "title" | "status">,
): 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) => (
<div
key={item.label}
@@ -895,6 +1005,27 @@ function StatusLanguage() {
</div>
))}
</div>
<p className="mt-3 max-w-3xl text-xs text-muted-foreground">
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.
</p>
</Section>
<Section eyebrow="Execution disposition" title="IssueRow with disposition badge inline">
<div className="grid gap-4 xl:grid-cols-2">
<DispositionRowSurface mode="light" size="desktop" />
<DispositionRowSurface mode="dark" size="desktop" />
<DispositionRowSurface mode="light" size="mobile" />
<DispositionRowSurface mode="dark" size="mobile" />
</div>
<p className="mt-3 max-w-3xl text-xs text-muted-foreground">
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.
</p>
</Section>
<Section eyebrow="Productivity review" title="Yellow accountability state on source issues">