diff --git a/ui/src/components/StatusIcon.test.tsx b/ui/src/components/StatusIcon.test.tsx index 4330afc0fd1..3be51ebc0d4 100644 --- a/ui/src/components/StatusIcon.test.tsx +++ b/ui/src/components/StatusIcon.test.tsx @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { StatusIcon } from "./StatusIcon"; describe("StatusIcon", () => { - it("renders covered blocked issues with a non-red attention state label", () => { + it("renders covered blocked issues with a dashed red attention state label", () => { const html = renderToStaticMarkup( { ); expect(html).toContain('data-blocker-attention-state="covered"'); - expect(html).toContain('aria-label="Blocked, waiting on running sub-issue"'); - expect(html).toContain("border-cyan-600"); - expect(html).not.toContain("border-red-600"); + expect(html).toContain('aria-label="Blocked · waiting on active sub-issue PAP-2"'); + expect(html).toContain('title="Blocked · waiting on active sub-issue PAP-2"'); + expect(html).toContain("border-red-600"); + expect(html).toContain("border-dashed"); + expect(html).not.toContain("border-cyan-600"); + expect(html).not.toContain("-bottom-0.5"); + }); + + it("uses covered blocked copy for the active dependency count matrix", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('aria-label="Blocked · covered by 2 active dependencies"'); + expect(html).toContain("border-dashed"); }); it("keeps normal blocked issues on the attention-required visual", () => { @@ -42,7 +64,8 @@ describe("StatusIcon", () => { ); expect(html).not.toContain('data-blocker-attention-state="covered"'); - expect(html).toContain('aria-label="Blocked"'); + expect(html).toContain('aria-label="Blocked · 1 unresolved blocker needs attention"'); expect(html).toContain("border-red-600"); + expect(html).not.toContain("border-dashed"); }); }); diff --git a/ui/src/components/StatusIcon.tsx b/ui/src/components/StatusIcon.tsx index 02ddd07bf69..6ec189d819e 100644 --- a/ui/src/components/StatusIcon.tsx +++ b/ui/src/components/StatusIcon.tsx @@ -19,26 +19,48 @@ interface StatusIconProps { showLabel?: boolean; } -function coveredBlockedLabel(blockerAttention: IssueBlockerAttention | null | undefined) { - return blockerAttention?.reason === "active_child" - ? "Blocked, waiting on running sub-issue" - : "Blocked, covered by active dependency"; +function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null | undefined) { + if (!blockerAttention || blockerAttention.state === "none") return "Blocked"; + + if (blockerAttention.reason === "active_child") { + const count = blockerAttention.coveredBlockerCount; + if (count === 1 && blockerAttention.sampleBlockerIdentifier) { + return `Blocked · waiting on active sub-issue ${blockerAttention.sampleBlockerIdentifier}`; + } + if (count === 1) return "Blocked · waiting on 1 active sub-issue"; + return `Blocked · waiting on ${count} active sub-issues`; + } + + if (blockerAttention.reason === "active_dependency") { + const count = blockerAttention.coveredBlockerCount; + if (count === 1 && blockerAttention.sampleBlockerIdentifier) { + return `Blocked · covered by active dependency ${blockerAttention.sampleBlockerIdentifier}`; + } + if (count === 1) return "Blocked · covered by 1 active dependency"; + return `Blocked · covered by ${count} active dependencies`; + } + + if (blockerAttention.reason === "attention_required") { + const count = blockerAttention.unresolvedBlockerCount; + return `Blocked · ${count} unresolved ${count === 1 ? "blocker needs" : "blockers need"} attention`; + } + + return "Blocked"; } export function StatusIcon({ status, blockerAttention, onChange, className, showLabel }: StatusIconProps) { const [open, setOpen] = useState(false); const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered"; - const colorClass = isCoveredBlocked - ? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400" - : issueStatusIcon[status] ?? issueStatusIconDefault; + const colorClass = issueStatusIcon[status] ?? issueStatusIconDefault; const isDone = status === "done"; - const ariaLabel = isCoveredBlocked ? coveredBlockedLabel(blockerAttention) : statusLabel(status); + const ariaLabel = status === "blocked" ? blockedAttentionLabel(blockerAttention) : statusLabel(status); const circle = ( )} - {isCoveredBlocked && ( - - )} ); diff --git a/ui/storybook/stories/status-language.stories.tsx b/ui/storybook/stories/status-language.stories.tsx index 5c0e207db91..d9d467682d4 100644 --- a/ui/storybook/stories/status-language.stories.tsx +++ b/ui/storybook/stories/status-language.stories.tsx @@ -1,15 +1,19 @@ import { useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { AGENT_STATUSES, ISSUE_PRIORITIES, ISSUE_STATUSES } from "@paperclipai/shared"; +import type { IssueBlockerAttention } from "@paperclipai/shared"; import { Bot, CheckCircle2, Clock3, DollarSign, FolderKanban, Inbox, MessageSquare, Users } from "lucide-react"; import { CopyText } from "@/components/CopyText"; import { EmptyState } from "@/components/EmptyState"; import { Identity } from "@/components/Identity"; +import { IssueRow } from "@/components/IssueRow"; import { MetricCard } from "@/components/MetricCard"; import { PriorityIcon } from "@/components/PriorityIcon"; import { QuotaBar } from "@/components/QuotaBar"; import { StatusBadge } from "@/components/StatusBadge"; +import { StatusIcon } from "@/components/StatusIcon"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { createIssue } from "../fixtures/paperclipData"; function Section({ eyebrow, @@ -31,6 +35,156 @@ function Section({ ); } +type CoveredBlockedCell = { + label: string; + status: string; + blockerAttention: IssueBlockerAttention | null; + expectedVisual: string; + expectedCopy: string; +}; + +const coveredBlockedMatrix: CoveredBlockedCell[] = [ + { + label: "Normal blocked", + status: "blocked", + blockerAttention: null, + expectedVisual: "solid red ring", + expectedCopy: "Blocked", + }, + { + label: "Covered by 1 active child", + status: "blocked", + blockerAttention: { + state: "covered", + reason: "active_child", + unresolvedBlockerCount: 1, + coveredBlockerCount: 1, + attentionBlockerCount: 0, + sampleBlockerIdentifier: "PAP-2175", + }, + expectedVisual: "dashed red ring", + expectedCopy: "Blocked · waiting on active sub-issue PAP-2175", + }, + { + label: "Covered by N active children", + status: "blocked", + blockerAttention: { + state: "covered", + reason: "active_child", + unresolvedBlockerCount: 3, + coveredBlockerCount: 3, + attentionBlockerCount: 0, + sampleBlockerIdentifier: null, + }, + expectedVisual: "dashed red ring", + expectedCopy: "Blocked · waiting on 3 active sub-issues", + }, + { + label: "Covered by active dependency", + status: "blocked", + blockerAttention: { + state: "covered", + reason: "active_dependency", + unresolvedBlockerCount: 1, + coveredBlockerCount: 1, + attentionBlockerCount: 0, + sampleBlockerIdentifier: "PAP-1918", + }, + expectedVisual: "dashed red ring", + expectedCopy: "Blocked · covered by active dependency PAP-1918", + }, + { + label: "Covered by N active dependencies", + status: "blocked", + blockerAttention: { + state: "covered", + reason: "active_dependency", + unresolvedBlockerCount: 2, + coveredBlockerCount: 2, + attentionBlockerCount: 0, + sampleBlockerIdentifier: null, + }, + expectedVisual: "dashed red ring", + expectedCopy: "Blocked · covered by 2 active dependencies", + }, + { + label: "Mixed: 1 covered, 1 needs attention", + status: "blocked", + blockerAttention: { + state: "needs_attention", + reason: "attention_required", + unresolvedBlockerCount: 2, + coveredBlockerCount: 1, + attentionBlockerCount: 1, + sampleBlockerIdentifier: null, + }, + expectedVisual: "solid red ring", + expectedCopy: "Blocked · 2 unresolved blockers need attention", + }, + { + label: "Needs attention (single blocker)", + status: "blocked", + blockerAttention: { + state: "needs_attention", + reason: "attention_required", + unresolvedBlockerCount: 1, + coveredBlockerCount: 0, + attentionBlockerCount: 1, + sampleBlockerIdentifier: "PAP-1042", + }, + expectedVisual: "solid red ring", + expectedCopy: "Blocked · 1 unresolved blocker needs attention", + }, + { + label: "Non-blocked with prop ignored", + status: "in_progress", + blockerAttention: { + state: "covered", + reason: "active_child", + unresolvedBlockerCount: 1, + coveredBlockerCount: 1, + attentionBlockerCount: 0, + sampleBlockerIdentifier: "PAP-2175", + }, + expectedVisual: "yellow ring", + expectedCopy: "In Progress", + }, +]; + +const coveredBlockedIssue = createIssue({ + id: "issue-covered-blocked-story", + identifier: "PAP-2178", + issueNumber: 2178, + title: "Covered blocked visual state: final acceptance", + status: "blocked", + priority: "medium", + blockerAttention: coveredBlockedMatrix[1]!.blockerAttention ?? undefined, + lastActivityAt: new Date("2026-04-24T13:40:00.000Z"), + updatedAt: new Date("2026-04-24T13:40:00.000Z"), +}); + +function CoveredBlockedSurface({ mode, size }: { mode: "light" | "dark"; size: "desktop" | "mobile" }) { + const isDark = mode === "dark"; + const isMobile = size === "mobile"; + + return ( +
+
+
+ {size} · {mode} +
+
+ } + trailingMeta="waiting on PAP-2175" + /> +
+
+
+ ); +} + function StatusLanguage() { const [priority, setPriority] = useState("high"); @@ -73,6 +227,41 @@ function StatusLanguage() { +
+
+ {coveredBlockedMatrix.map((item) => ( +
+
+
+
{item.label}
+
{item.expectedVisual}
+
+ +
+
+ {item.expectedCopy} +
+
+ ))} +
+

+ Tooltip and aria-label copy begin with "Blocked · " for cells 2-7; cells 6 and 7 retain the solid red ring + and mention blockers that need attention. +

+
+ +
+
+ + + + +
+
+