Fix covered blocked status visual

This commit is contained in:
Dotta committed 2026-04-24 13:32:07 -05:00
1 parent 6a518ca828
commit 528468c5b3
3 files changed
+247 -16

No files matched your search

+28 -5
View File
@@ -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(
<StatusIcon
status="blocked"
@@ -21,9 +21,31 @@ describe("StatusIcon", () => {
);
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(
<StatusIcon
status="blocked"
blockerAttention={{
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 2,
coveredBlockerCount: 2,
attentionBlockerCount: 0,
sampleBlockerIdentifier: null,
}}
/>,
);
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");
});
});
+30 -11
View File
@@ -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 = (
<span
className={cn(
"relative inline-flex h-4 w-4 rounded-full border-2 shrink-0",
colorClass,
isCoveredBlocked && "border-dashed",
onChange && !showLabel && "cursor-pointer",
className
)}
@@ -49,9 +71,6 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
{isDone && (
<span className="absolute inset-0 m-auto h-2 w-2 rounded-full bg-current" />
)}
{isCoveredBlocked && (
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-current" />
)}
</span>
);
@@ -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 (
<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-[340px]" : "min-w-[620px]"}>
<IssueRow
issue={coveredBlockedIssue}
mobileMeta={<StatusBadge status={coveredBlockedIssue.status} />}
trailingMeta="waiting on PAP-2175"
/>
</div>
</div>
</div>
);
}
function StatusLanguage() {
const [priority, setPriority] = useState("high");
@@ -73,6 +227,41 @@ function StatusLanguage() {
</div>
</Section>
<Section eyebrow="Covered blocked" title="Blocked attention state matrix">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{coveredBlockedMatrix.map((item) => (
<div
key={item.label}
className="flex min-h-[136px] flex-col justify-between rounded-lg border border-border bg-background/70 p-4"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium">{item.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{item.expectedVisual}</div>
</div>
<StatusIcon status={item.status} blockerAttention={item.blockerAttention} />
</div>
<div className="mt-4 rounded-md bg-muted/45 px-2.5 py-2 font-mono text-[11px] leading-5 text-muted-foreground">
{item.expectedCopy}
</div>
</div>
))}
</div>
<p className="mt-3 text-xs text-muted-foreground">
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.
</p>
</Section>
<Section eyebrow="Covered blocked" title="IssueRow desktop and mobile surfaces">
<div className="grid gap-4 xl:grid-cols-2">
<CoveredBlockedSurface mode="light" size="desktop" />
<CoveredBlockedSurface mode="dark" size="desktop" />
<CoveredBlockedSurface mode="light" size="mobile" />
<CoveredBlockedSurface mode="dark" size="mobile" />
</div>
</Section>
<Section eyebrow="Priority" title="Static labels and editable popover trigger">
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
<div className="grid gap-3 sm:grid-cols-2">