diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 7a1b83e6a0e..37cbc94e548 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -597,6 +597,66 @@ describe("IssueRow", () => { ), ).toEqual({ state: "in_progress", text: "Recovery in progress" }); }); + + // PAP-17070: the chip was painted twice on mobile — once in the `sm:hidden` + // leading cluster and again beside the identifier, whose copy had no desktop + // gate. Because badges are `whitespace-nowrap shrink-0`, that duplicate + // pushed a 390px document out to 403px. Exactly one copy may be visible at + // any one breakpoint. + it.each([ + ["needed", {}], + [ + "in_progress", + { status: "in_progress", executionRunId: "run-1", assigneeAgentId: "cto-agent" }, + ], + [ + "observe_only", + { status: "in_progress", executionRunId: "run-1", assigneeAgentId: "coder-agent" }, + ], + ["escalated", { activeRecoveryAction: { ...recoveryAction, status: "escalated" } }], + ] as const)("renders one %s chip per breakpoint, never two at once", (state, overrides) => { + const root = createRoot(container); + act(() => { + root.render( + ), + })} + />, + ); + }); + + const chips = Array.from( + container.querySelectorAll('[data-testid="issue-row-recovery-indicator"]'), + ); + expect(chips.map((chip) => chip.getAttribute("data-recovery-state"))).toEqual([state, state]); + + // Classify each copy by the breakpoint gate on its ancestors. + const visibility = chips.map((chip) => { + let node = chip.parentElement; + while (node && node !== container) { + const classes = node.className; + if (typeof classes === "string") { + if (classes.includes("sm:hidden")) return "mobile"; + if (classes.includes("hidden") && classes.includes("sm:inline-flex")) return "desktop"; + } + node = node.parentElement; + } + return "always"; + }); + expect(visibility.sort()).toEqual(["desktop", "mobile"]); + + // A hard width ceiling is the backstop for a long label in either slot. + chips.forEach((chip) => { + expect(chip.className).toContain("max-w-(--sz-12rem)"); + expect(chip.querySelector("span")?.className).toContain("truncate"); + }); + + act(() => { + root.unmount(); + }); + }); }); it("keeps the hover wash on the row root while the overlay link stays a bare positioning layer", () => { diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index d3c651553b3..b6cf5c51072 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -279,8 +279,18 @@ export function IssueRow({ {identifier} - {parkedBlockerIndicator} - {recoveryIndicator} + {/* Desktop-only copies. The mobile row already renders both chips + in its `sm:hidden` leading cluster above, so leaving these + ungated painted a SECOND nowrap chip beside the identifier at + 390px — and because badges are `whitespace-nowrap shrink-0` + inside a cluster with no `min-w-0`, that duplicate pushed the + document from 390px to 403px (PAP-17070). */} + {parkedBlockerIndicator || recoveryIndicator ? ( + + {parkedBlockerIndicator} + {recoveryIndicator} + + ) : null} )} {mobileMeta ? ( @@ -357,14 +367,18 @@ function renderRecoveryChip( role="status" aria-label={label} className={cn( - "ml-1.5 gap-0.5 text-(length:--text-nano)", + // `max-w` caps the chip even though badges are `shrink-0`: it is the only + // thing standing between a long recovery label and a row wider than a + // 390px viewport. Every current label fits well inside 12rem, so this is + // a ceiling, not a truncation the operator will normally see. + "ml-1.5 max-w-(--sz-12rem) gap-0.5 text-(length:--text-nano)", tone.className, selected ? "!border-muted-foreground !text-muted-foreground" : null, )} title={`${label} — open the source task to act.`} > - {label} + {label} ); } diff --git a/ui/src/components/RecoveryProgressLine.test.tsx b/ui/src/components/RecoveryProgressLine.test.tsx index e8aa7735d91..22482fae7ec 100644 --- a/ui/src/components/RecoveryProgressLine.test.tsx +++ b/ui/src/components/RecoveryProgressLine.test.tsx @@ -212,8 +212,37 @@ describe("RecoveryProgressLine", () => { const label = toggle?.querySelector("span"); expect(label?.className).toContain("truncate"); // The "Details" hint is desktop-only so the owner name keeps the mobile row. - const hint = toggle?.querySelectorAll("span")[1]; + const hint = host.querySelector('[data-testid="recovery-progress-line-hint"]'); expect(hint?.className).toContain("hidden"); expect(hint?.className).toContain("sm:inline"); }); + + // PAP-17070: `text-muted-foreground/70` measured 2.71:1 (light) and 4.20:1 + // (dark) against the surfaces this line renders on — a serious axe + // `color-contrast` violation at 12px. The undiluted token measures 4.73:1 and + // 7.63:1, and 4.53:1 against the `hover:bg-muted/50` wash, so the hint must + // inherit the button's colour rather than fade it further. + it("does not dilute the disclosure hint below WCAG AA contrast", () => { + for (const open of [false, true]) { + const host = render( + , + ); + const hint = host.querySelector('[data-testid="recovery-progress-line-hint"]'); + expect(hint?.textContent).toContain(open ? "Hide details" : "Details"); + // No opacity-diluted foreground token anywhere on the hint... + expect(hint?.className).not.toMatch(/text-(muted-)?foreground\/\d+/); + // ...and no diluted token on the button it inherits from either. + const toggle = host.querySelector('[data-testid="recovery-progress-line-toggle"]'); + expect(toggle?.className).toContain("text-muted-foreground"); + expect(toggle?.className).not.toMatch(/text-(muted-)?foreground\/\d+/); + // Hierarchy is carried by weight: the label is medium, the hint is not. + expect(host.querySelector(".truncate")?.className).toContain("font-medium"); + expect(hint?.className).toContain("font-normal"); + } + }); }); diff --git a/ui/src/components/RecoveryProgressLine.tsx b/ui/src/components/RecoveryProgressLine.tsx index 20d5d34a400..d142bec4d68 100644 --- a/ui/src/components/RecoveryProgressLine.tsx +++ b/ui/src/components/RecoveryProgressLine.tsx @@ -67,7 +67,11 @@ export function RecoveryProgressLine({ > {label} - + {/* Inherits the button's `text-muted-foreground`; the hint must NOT be + diluted further (`text-muted-foreground/70` measured 2.71:1 light / + 4.20:1 dark and failed WCAG AA — PAP-17070). Weight, not contrast, + is what keeps this subordinate to the label. */} + · {open ? "Hide details" : "Details"} + @@ -402,6 +420,11 @@ function InboxRowPanel() { identifier: "PAP-10409", title: "Phase 6 follow-up failed: git workspace lost", status: "blocked", + // No live run, so this keeps the `needed` state and its longer + // "Workspace recovery needed" label — the widest chip the row has to + // survive at 390px. + executionRunId: null, + scheduledRetry: null, activeRecoveryAction: buildAction({ kind: "workspace_validation", cause: "workspace_validation_failed",