fix(ui): tighten first-class task reference visuals

UX review pass on the three surfaces shipped in PAP-735.

- IssueReferencePill: show the target issue's StatusIcon when status is
  provided so references outside markdown match the inline markdown
  variant (previously only a generic colored dot). Adopt the shared
  `paperclip-mention-chip--issue` class so every issue pill renders
  consistently regardless of context. Add focus-visible ring for
  keyboard users. New optional `strikethrough` prop for removed refs.
- IssueRelatedWorkPanel: pass status through to the pill; collapse
  duplicate source kinds into `label ×N` instead of repeating identical
  chips; drop the heavy nested item border for a lighter hover row;
  normalize ad-hoc `text-[11px]` sizes to `text-xs`; promote the
  section heading weight.
- IssueReferenceActivitySummary: replace the easily-missed "ADDED" /
  "REMOVED" mini-labels with semantic +/- icons (green/red) and render
  removed pills with strikethrough + reduced opacity so activity rows
  are scannable at a glance.
- DesignGuide: add an IssueReferencePill subsection showing default,
  status-aware, and strikethrough variants.
- Test: add coverage for source-kind consolidation (`×3` rollup).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-21 09:07:05 -05:00
1 parent 2e01d1fa00
commit 4d4eb80230
5 files changed
+150 -32

No files matched your search

@@ -1,4 +1,5 @@
import type { ActivityEvent } from "@paperclipai/shared";
import { Plus, Minus } from "lucide-react";
import { IssueReferencePill } from "./IssueReferencePill";
type ActivityIssueReference = {
@@ -13,14 +14,31 @@ function readIssueReferences(details: Record<string, unknown> | null | undefined
return value.filter((item): item is ActivityIssueReference => !!item && typeof item === "object");
}
function Section({ label, items }: { label: string; items: ActivityIssueReference[] }) {
function Section({
label,
icon,
items,
strikethrough,
}: {
label: string;
icon: React.ReactNode;
items: ActivityIssueReference[];
strikethrough?: boolean;
}) {
if (items.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">{label}</span>
<span
aria-label={label}
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
>
{icon}
<span className="sr-only">{label}</span>
</span>
{items.map((issue) => (
<IssueReferencePill
key={`${label}:${issue.id}`}
strikethrough={strikethrough}
issue={{
id: issue.id,
identifier: issue.identifier ?? null,
@@ -39,8 +57,17 @@ export function IssueReferenceActivitySummary({ event }: { event: Pick<ActivityE
return (
<div className="mt-2 space-y-1">
<Section label="Added" items={added} />
<Section label="Removed" items={removed} />
<Section
label="Added references"
icon={<Plus className="h-3 w-3 text-green-600 dark:text-green-400" aria-hidden="true" />}
items={added}
/>
<Section
label="Removed references"
icon={<Minus className="h-3 w-3 text-red-600 dark:text-red-400" aria-hidden="true" />}
items={removed}
strikethrough
/>
</div>
);
}
+15 -3
View File
@@ -1,12 +1,16 @@
import type { IssueRelationIssueSummary } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { cn } from "../lib/utils";
import { StatusIcon } from "./StatusIcon";
export function IssueReferencePill({
issue,
strikethrough,
className,
}: {
issue: Pick<IssueRelationIssueSummary, "id" | "identifier" | "title">;
issue: Pick<IssueRelationIssueSummary, "id" | "identifier" | "title"> &
Partial<Pick<IssueRelationIssueSummary, "status">>;
strikethrough?: boolean;
className?: string;
}) {
const issueLabel = issue.identifier ?? issue.id;
@@ -14,14 +18,22 @@ export function IssueReferencePill({
return (
<Link
to={`/issues/${issueLabel}`}
data-mention-kind="issue"
className={cn(
"inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs hover:bg-accent/50",
"paperclip-mention-chip paperclip-mention-chip--issue",
"inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline",
"hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring",
strikethrough && "opacity-60 line-through decoration-muted-foreground",
className,
)}
title={issue.title}
aria-label={`Issue ${issueLabel}: ${issue.title}`}
>
<span className="h-1.5 w-1.5 rounded-[0.2rem] bg-current opacity-70" />
{issue.status ? (
<StatusIcon status={issue.status} className="h-3 w-3 shrink-0" />
) : (
<span className="h-1.5 w-1.5 rounded-[0.14rem] bg-current opacity-70" />
)}
<span>{issue.identifier ?? issue.title}</span>
</Link>
);
@@ -60,4 +60,37 @@ describe("IssueRelatedWorkPanel", () => {
expect(html).toContain("plan");
expect(html).toContain("comment");
});
it("collapses duplicate source labels into a single chip with a count", () => {
const html = renderToStaticMarkup(
<IssueRelatedWorkPanel
relatedWork={{
outbound: [],
inbound: [
{
issue: {
id: "issue-4",
identifier: "PAP-44",
title: "Chatty inbound",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
mentionCount: 3,
sources: [
{ kind: "comment", sourceRecordId: "c1", label: "comment", matchedText: "PAP-44 first" },
{ kind: "comment", sourceRecordId: "c2", label: "comment", matchedText: "PAP-44 second" },
{ kind: "comment", sourceRecordId: "c3", label: "comment", matchedText: "PAP-44 third" },
],
},
],
}}
/>,
);
const commentMatches = html.match(/>comment</g) ?? [];
expect(commentMatches).toHaveLength(1);
expect(html).toContain("×3");
});
});
+55 -25
View File
@@ -1,6 +1,29 @@
import type { IssueRelatedWorkItem, IssueRelatedWorkSummary } from "@paperclipai/shared";
import { IssueReferencePill } from "./IssueReferencePill";
type GroupedSource = {
label: string;
count: number;
sampleMatchedText: string | null;
};
function groupSourcesByLabel(sources: IssueRelatedWorkItem["sources"]): GroupedSource[] {
const groups = new Map<string, GroupedSource>();
for (const source of sources) {
const existing = groups.get(source.label);
if (existing) {
existing.count += 1;
} else {
groups.set(source.label, {
label: source.label,
count: 1,
sampleMatchedText: source.matchedText ?? null,
});
}
}
return Array.from(groups.values());
}
function Section({
title,
description,
@@ -15,39 +38,46 @@ function Section({
return (
<section className="space-y-3 rounded-lg border border-border p-3">
<div className="space-y-1">
<h3 className="text-sm font-medium">{title}</h3>
<h3 className="text-sm font-semibold">{title}</h3>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
{items.length === 0 ? (
<p className="text-xs text-muted-foreground">{emptyLabel}</p>
) : (
<div className="space-y-2">
{items.map((item) => (
<div key={item.issue.id} className="space-y-2 rounded-md border border-border/60 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<ul className="-mx-1 flex flex-col">
{items.map((item) => {
const groupedSources = groupSourcesByLabel(item.sources);
const showTitle = item.issue.identifier !== item.issue.title;
return (
<li
key={item.issue.id}
className="flex flex-wrap items-center gap-x-2 gap-y-1.5 rounded-md px-1 py-1.5 hover:bg-accent/40"
>
<IssueReferencePill issue={item.issue} />
{item.issue.identifier !== item.issue.title ? (
<span className="text-sm text-muted-foreground">{item.issue.title}</span>
) : null}
<span className="ml-auto text-[11px] uppercase tracking-[0.12em] text-muted-foreground">
{item.mentionCount} source{item.mentionCount === 1 ? "" : "s"}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{item.sources.map((source) => (
<span
key={`${item.issue.id}:${source.kind}:${source.sourceRecordId ?? "root"}`}
className="inline-flex items-center rounded-full border border-border bg-muted/40 px-2 py-0.5 text-[11px] text-muted-foreground"
title={source.matchedText ?? undefined}
>
{source.label}
{showTitle ? (
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
{item.issue.title}
</span>
))}
</div>
</div>
))}
</div>
) : null}
<div className="flex flex-wrap items-center gap-1.5">
{groupedSources.map((group) => (
<span
key={`${item.issue.id}:${group.label}`}
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted/40 px-2 py-0.5 text-xs text-muted-foreground"
title={group.sampleMatchedText ?? undefined}
>
<span>{group.label}</span>
{group.count > 1 ? (
<span className="tabular-nums text-[10px] font-medium opacity-80">×{group.count}</span>
) : null}
</span>
))}
</div>
</li>
);
})}
</ul>
)}
</section>
);
+16
View File
@@ -123,6 +123,7 @@ import { FilterBar, type FilterValue } from "@/components/FilterBar";
import { InlineEditor } from "@/components/InlineEditor";
import { PageSkeleton } from "@/components/PageSkeleton";
import { Identity } from "@/components/Identity";
import { IssueReferencePill } from "@/components/IssueReferencePill";
/* ------------------------------------------------------------------ */
/* Section wrapper */
@@ -466,6 +467,21 @@ export function DesignGuide() {
))}
</div>
</SubSection>
<SubSection title="IssueReferencePill">
<p className="text-xs text-muted-foreground">
Used wherever a task is referenced — in markdown, the Related Work tab, and activity summaries.
Pass <code className="font-mono">status</code> to show the target issue&apos;s state at a glance.
Use <code className="font-mono">strikethrough</code> for &quot;removed&quot; contexts.
</p>
<div className="flex items-center gap-2 flex-wrap">
<IssueReferencePill issue={{ id: "demo-1", identifier: "PAP-123", title: "Identifier only — no status yet" }} />
<IssueReferencePill issue={{ id: "demo-2", identifier: "PAP-456", title: "With in_progress status", status: "in_progress" }} />
<IssueReferencePill issue={{ id: "demo-3", identifier: "PAP-789", title: "Done status", status: "done" }} />
<IssueReferencePill issue={{ id: "demo-4", identifier: "PAP-101", title: "Blocked status", status: "blocked" }} />
<IssueReferencePill strikethrough issue={{ id: "demo-5", identifier: "PAP-202", title: "Removed (strikethrough)", status: "todo" }} />
</div>
</SubSection>
</Section>
{/* ============================================================ */}