Files
PaperClipAI/ui/src/components/IssueReferencePill.tsx
T
DottaandPaperclip b315580640 feat: add private task creation and sharing controls (#14718)
Add private task creation and sharing controls, effective access explanations, private project management, and locked task references.

Constrain the mobile composer plus menu to the viewport and explain named inherited privacy on hover, focus, or tap. Include 125 production-component Storybook stories and thirteen interactive user journeys.

Final head passes all CI checks and Greptile Apex 5/5 with no findings.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-07 07:27:52 -05:00

105 lines
3.7 KiB
TypeScript

import { X } from "lucide-react";
import type { ReactNode } from "react";
import type { IssueLockedStub, IssueRelationIssueSummary } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { cn } from "../lib/utils";
import { LockedIssueChip, isLockedIssueStub } from "./LockedIssueChip";
import { badgeVariants } from "./ui/badge";
import { StatusIcon } from "./StatusIcon";
export function IssueReferencePill({
issue,
strikethrough,
className,
children,
onRemove,
variant = "mention",
}: {
issue:
| (Pick<IssueRelationIssueSummary, "id" | "identifier" | "title"> &
{ status?: string })
| IssueLockedStub;
strikethrough?: boolean;
variant?: "mention" | "property";
className?: string;
children?: ReactNode;
/** Reserves space for a separate hover/focus action without moving the task link. */
onRemove?: (issueId: string) => void;
}) {
// Private issue referenced from a visible edge — never leak a title or a link.
if (isLockedIssueStub(issue)) {
return <LockedIssueChip identifier={issue.identifier} className={className} />;
}
const issueLabel = issue.identifier ?? issue.title;
const classNames = cn(
variant === "property" || onRemove
? cn(badgeVariants({ variant: "outline" }), "min-w-0 max-w-full shrink font-normal no-underline")
: "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",
issue.identifier && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring",
onRemove && "pr-6",
strikethrough && "opacity-60 line-through decoration-muted-foreground",
className,
);
const content = (
<>
{issue.status ? <StatusIcon status={issue.status} className="h-3 w-3 shrink-0" /> : null}
{children !== undefined ? children : <span className="min-w-0 truncate">{issue.identifier ?? issue.title}</span>}
</>
);
if (onRemove) {
return (
<span className="group/issue-reference relative inline-flex min-w-0 max-w-full">
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
disableIssueQuicklook
data-mention-kind="issue"
className={cn(classNames, "min-w-0 max-w-full")}
title={issue.title}
aria-label={`Task ${issueLabel}: ${issue.title}`}
>
{content}
</Link>
<button
type="button"
className="absolute right-1 top-1/2 -translate-y-1/2 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground opacity-0 hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/issue-reference:opacity-100 group-focus-within/issue-reference:opacity-100 pointer-coarse:opacity-100"
aria-label={`Remove ${issueLabel} as blocker`}
title={`Remove ${issueLabel} as blocker`}
onClick={(event) => {
event.stopPropagation();
onRemove(issue.id);
}}
>
<X className="h-3 w-3" aria-hidden />
</button>
</span>
);
}
if (!issue.identifier && variant === "mention") {
return (
<span
data-mention-kind="issue"
className={classNames}
title={issue.title}
aria-label={`Task: ${issue.title}`}
>
{content}
</span>
);
}
return (
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
disableIssueQuicklook={variant === "property"}
data-mention-kind="issue"
className={classNames}
title={issue.title}
aria-label={`Task ${issueLabel}: ${issue.title}`}
>
{content}
</Link>
);
}