mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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's state at a glance.
|
||||
Use <code className="font-mono">strikethrough</code> for "removed" 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>
|
||||
|
||||
{/* ============================================================ */}
|
||||
|
||||
Reference in new issue
Block a user