mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
PAP-2822 Walk back visible disposition badges per board feedback
The board accepted the canonical executionDisposition payload + classifier but found the visible badges in IssueRow / IssueProperties / Storybook too noisy on production surfaces. UI-only walkback: keep the data layer, drop every visible badge. - Delete IssueDispositionBadge component and its test. - Drop the badge import, suppression rule, and trailing-slot references in IssueRow (desktop + mobile). Existing waiting pill, status-icon ring, and blockerAttention copy remain unchanged. - Drop the badge IIFE and related imports from IssueProperties so the Status row only renders the StatusIcon again. - Prune the badge cases from IssueRow.test (Live, Stalled, dispatchable, Resuming, explicit-waiting suppression) so the file still type-checks. - Storybook: remove "Canonical disposition badges" and "IssueRow with disposition badge inline" sections plus DispositionRowSurface and dispositionRowFixtures. The rest of Status Language is unchanged. Kept: executionDisposition on issue payloads, the resolver and its test, and the PAP-2818 contract doc — future features can consume the field without re-running the classifier work. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7690c21148
commit
4c5eaeadd5
6 files changed
+2
-719
No files matched your search
@@ -1,170 +0,0 @@
|
||||
// @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,
|
||||
shouldShowDispositionBadge,
|
||||
} 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 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]");
|
||||
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");
|
||||
expect(badge?.getAttribute("aria-label")).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 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",
|
||||
suggestedCorrection: "fix it",
|
||||
});
|
||||
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 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
import type {
|
||||
IssueBlockerAttention,
|
||||
IssueExecutionDisposition,
|
||||
IssueExecutionHumanEscalationOwner,
|
||||
IssueExecutionLivePath,
|
||||
IssueExecutionRecoveryKind,
|
||||
IssueExecutionWaitingPath,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
AlertOctagon,
|
||||
AlertTriangle,
|
||||
CircleDashed,
|
||||
Hourglass,
|
||||
Lock,
|
||||
Play,
|
||||
RotateCw,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export type IssueDispositionCategory =
|
||||
| "live"
|
||||
| "waiting"
|
||||
| "blocked_chain"
|
||||
| "resuming"
|
||||
| "recovery"
|
||||
| "needs_attention"
|
||||
| "invalid"
|
||||
| "terminal"
|
||||
| "resting";
|
||||
|
||||
const CATEGORY_LABEL: Record<IssueDispositionCategory, string> = {
|
||||
live: "Live",
|
||||
waiting: "Waiting",
|
||||
blocked_chain: "Blocked",
|
||||
resuming: "Resuming",
|
||||
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_VISIBLE_LABEL: Record<IssueExecutionHumanEscalationOwner, string> = {
|
||||
board: "Needs board",
|
||||
manager: "Needs manager",
|
||||
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(
|
||||
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":
|
||||
// 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 "agent_continuable":
|
||||
return "resuming";
|
||||
case "recoverable_by_control_plane":
|
||||
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 `Attempt ${disposition.continuationAttempt} of ${disposition.maxAttempts}`;
|
||||
case "human_escalation_required":
|
||||
return ESCALATION_DETAIL_LABEL[disposition.owner];
|
||||
case "invalid":
|
||||
return invalidReasonLabel(disposition.reason);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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":
|
||||
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",
|
||||
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",
|
||||
// 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,
|
||||
waiting: Hourglass,
|
||||
blocked_chain: Lock,
|
||||
resuming: RotateCw,
|
||||
recovery: Wrench,
|
||||
needs_attention: AlertTriangle,
|
||||
invalid: AlertOctagon,
|
||||
terminal: CircleDashed,
|
||||
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;
|
||||
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 = dispositionVisibleLabel(disposition, 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}
|
||||
aria-label={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,7 +27,6 @@ import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { buildExecutionPolicy, stageParticipantValues } from "../lib/issue-execution-policy";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { IssueDispositionBadge, dispositionDetailLabel, shouldShowDispositionBadge } from "./IssueDispositionBadge";
|
||||
import { Identity } from "./Identity";
|
||||
import { IssueReferencePill } from "./IssueReferencePill";
|
||||
import { formatDate, cn, projectUrl } from "../lib/utils";
|
||||
@@ -1049,31 +1048,6 @@ export function IssueProperties({
|
||||
onChange={(status) => onUpdate({ status })}
|
||||
showLabel
|
||||
/>
|
||||
{(() => {
|
||||
// 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">
|
||||
<IssueDispositionBadge disposition={issue.executionDisposition ?? null} />
|
||||
{detail ? (
|
||||
<span className="text-xs text-muted-foreground">{detail}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</PropertyRow>
|
||||
|
||||
<PropertyRow label="Priority">
|
||||
|
||||
@@ -261,87 +261,6 @@ 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("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({
|
||||
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,7 +11,6 @@ import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { cn } from "../lib/utils";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
|
||||
import { IssueDispositionBadge, shouldShowDispositionBadge } from "./IssueDispositionBadge";
|
||||
|
||||
type UnreadState = "hidden" | "visible" | "fading";
|
||||
|
||||
@@ -131,16 +130,6 @@ export function IssueRow({
|
||||
Waiting
|
||||
</span>
|
||||
) : null;
|
||||
// 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
|
||||
className={cn(
|
||||
@@ -224,20 +213,18 @@ export function IssueRow({
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
{(desktopTrailing || trailingMeta || desktopWaitingPill || dispositionBadge) ? (
|
||||
{(desktopTrailing || trailingMeta || desktopWaitingPill) ? (
|
||||
<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 || dispositionBadge ? (
|
||||
{mobileWaitingPill ? (
|
||||
<span className="ml-auto inline-flex shrink-0 items-center gap-1.5 sm:hidden">
|
||||
{mobileWaitingPill}
|
||||
{dispositionBadge}
|
||||
</span>
|
||||
) : null}
|
||||
{showUnreadSlot ? (
|
||||
|
||||
@@ -15,7 +15,6 @@ 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";
|
||||
@@ -291,106 +290,6 @@ 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 {
|
||||
@@ -958,76 +857,6 @@ 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: "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: "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: {
|
||||
kind: "invalid",
|
||||
reason: "in_review_without_action_path",
|
||||
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}
|
||||
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>
|
||||
<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">
|
||||
<ProductivityReviewMatrix />
|
||||
</Section>
|
||||
|
||||
Reference in new issue
Block a user