feat: @project mentions with colored chips in markdown and editors
Add project mention system using project:// URI scheme with optional color parameter. Mentions render as colored pill chips in markdown bodies and the WYSIWYG editor. Autocomplete in editors shows both agents and projects. Server extracts mentioned project IDs from issue content and returns them in the issue detail response. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,7 @@ interface CommentThreadProps {
|
||||
liveRunSlot?: React.ReactNode;
|
||||
enableReassign?: boolean;
|
||||
reassignOptions?: ReassignOption[];
|
||||
mentions?: MentionOption[];
|
||||
}
|
||||
|
||||
const CLOSED_STATUSES = new Set(["done", "cancelled"]);
|
||||
@@ -110,6 +111,7 @@ export function CommentThread({
|
||||
liveRunSlot,
|
||||
enableReassign = false,
|
||||
reassignOptions = [],
|
||||
mentions: providedMentions,
|
||||
}: CommentThreadProps) {
|
||||
const [body, setBody] = useState("");
|
||||
const [reopen, setReopen] = useState(true);
|
||||
@@ -145,6 +147,7 @@ export function CommentThread({
|
||||
|
||||
// Build mention options from agent map (exclude terminated agents)
|
||||
const mentions = useMemo<MentionOption[]>(() => {
|
||||
if (providedMentions) return providedMentions;
|
||||
if (!agentMap) return [];
|
||||
return Array.from(agentMap.values())
|
||||
.filter((a) => a.status !== "terminated")
|
||||
@@ -152,7 +155,7 @@ export function CommentThread({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
}));
|
||||
}, [agentMap]);
|
||||
}, [agentMap, providedMentions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftKey) return;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
|
||||
|
||||
interface InlineEditorProps {
|
||||
value: string;
|
||||
@@ -12,6 +12,7 @@ interface InlineEditorProps {
|
||||
placeholder?: string;
|
||||
multiline?: boolean;
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
mentions?: MentionOption[];
|
||||
}
|
||||
|
||||
/** Shared padding so display and edit modes occupy the exact same box. */
|
||||
@@ -25,6 +26,7 @@ export function InlineEditor({
|
||||
placeholder = "Click to edit...",
|
||||
multiline = false,
|
||||
imageUploadHandler,
|
||||
mentions,
|
||||
}: InlineEditorProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
@@ -81,6 +83,7 @@ export function InlineEditor({
|
||||
placeholder={placeholder}
|
||||
contentClassName={className}
|
||||
imageUploadHandler={imageUploadHandler}
|
||||
mentions={mentions}
|
||||
onSubmit={commit}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { parseProjectMentionHref } from "@paperclip/shared";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
|
||||
@@ -8,6 +10,29 @@ interface MarkdownBodyProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!match) return null;
|
||||
const value = match[1];
|
||||
return {
|
||||
r: parseInt(value.slice(0, 2), 16),
|
||||
g: parseInt(value.slice(2, 4), 16),
|
||||
b: parseInt(value.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function mentionChipStyle(color: string | null): CSSProperties | undefined {
|
||||
if (!color) return undefined;
|
||||
const rgb = hexToRgb(color);
|
||||
if (!rgb) return undefined;
|
||||
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
|
||||
return {
|
||||
borderColor: color,
|
||||
backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`,
|
||||
color: luminance > 0.55 ? "#111827" : "#f8fafc",
|
||||
};
|
||||
}
|
||||
|
||||
export function MarkdownBody({ children, className }: MarkdownBodyProps) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
@@ -18,7 +43,33 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) {
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{children}</Markdown>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ href, children: linkChildren }) => {
|
||||
const parsed = href ? parseProjectMentionHref(href) : null;
|
||||
if (parsed) {
|
||||
const label = linkChildren;
|
||||
return (
|
||||
<a
|
||||
href={`/projects/${parsed.projectId}`}
|
||||
className="paperclip-project-mention-chip"
|
||||
style={mentionChipStyle(parsed.color)}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={href} rel="noreferrer">
|
||||
{linkChildren}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type DragEvent,
|
||||
} from "react";
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
thematicBreakPlugin,
|
||||
type RealmPlugin,
|
||||
} from "@mdxeditor/editor";
|
||||
import { buildProjectMentionHref, parseProjectMentionHref } from "@paperclip/shared";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
/* ---- Mention types ---- */
|
||||
@@ -31,6 +33,9 @@ import { cn } from "../lib/utils";
|
||||
export interface MentionOption {
|
||||
id: string;
|
||||
name: string;
|
||||
kind?: "agent" | "project";
|
||||
projectId?: string;
|
||||
projectColor?: string | null;
|
||||
}
|
||||
|
||||
/* ---- Editor props ---- */
|
||||
@@ -44,7 +49,7 @@ interface MarkdownEditorProps {
|
||||
onBlur?: () => void;
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
bordered?: boolean;
|
||||
/** List of mentionable users/agents. Enables @-mention autocomplete. */
|
||||
/** List of mentionable entities. Enables @-mention autocomplete. */
|
||||
mentions?: MentionOption[];
|
||||
/** Called on Cmd/Ctrl+Enter */
|
||||
onSubmit?: () => void;
|
||||
@@ -131,15 +136,47 @@ function detectMention(container: HTMLElement): MentionState | null {
|
||||
};
|
||||
}
|
||||
|
||||
/** Replace `@<query>` in the markdown string with `@<Name> `. */
|
||||
function mentionMarkdown(option: MentionOption): string {
|
||||
if (option.kind === "project" && option.projectId) {
|
||||
return `[@${option.name}](${buildProjectMentionHref(option.projectId, option.projectColor ?? null)}) `;
|
||||
}
|
||||
return `@${option.name} `;
|
||||
}
|
||||
|
||||
/** Replace `@<query>` in the markdown string with the selected mention token. */
|
||||
function applyMention(markdown: string, query: string, option: MentionOption): string {
|
||||
const search = `@${query}`;
|
||||
const replacement = `@${option.name} `;
|
||||
const replacement = mentionMarkdown(option);
|
||||
const idx = markdown.lastIndexOf(search);
|
||||
if (idx === -1) return markdown;
|
||||
return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length);
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const trimmed = hex.trim();
|
||||
const match = /^#([0-9a-f]{6})$/i.exec(trimmed);
|
||||
if (!match) return null;
|
||||
const value = match[1];
|
||||
return {
|
||||
r: parseInt(value.slice(0, 2), 16),
|
||||
g: parseInt(value.slice(2, 4), 16),
|
||||
b: parseInt(value.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function mentionChipStyle(color: string | null): CSSProperties | undefined {
|
||||
if (!color) return undefined;
|
||||
const rgb = hexToRgb(color);
|
||||
if (!rgb) return undefined;
|
||||
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
|
||||
const textColor = luminance > 0.55 ? "#111827" : "#f8fafc";
|
||||
return {
|
||||
borderColor: color,
|
||||
backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.22)`,
|
||||
color: textColor,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---- Component ---- */
|
||||
|
||||
export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>(function MarkdownEditor({
|
||||
@@ -166,6 +203,15 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
const mentionStateRef = useRef<MentionState | null>(null);
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const mentionActive = mentionState !== null && mentions && mentions.length > 0;
|
||||
const projectColorById = useMemo(() => {
|
||||
const map = new Map<string, string | null>();
|
||||
for (const mention of mentions ?? []) {
|
||||
if (mention.kind === "project" && mention.projectId) {
|
||||
map.set(mention.projectId, mention.projectColor ?? null);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [mentions]);
|
||||
|
||||
const filteredMentions = useMemo(() => {
|
||||
if (!mentionState || !mentions) return [];
|
||||
@@ -218,6 +264,38 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const decorateProjectMentions = useCallback(() => {
|
||||
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
||||
if (!editable) return;
|
||||
const links = editable.querySelectorAll("a");
|
||||
for (const node of links) {
|
||||
const link = node as HTMLAnchorElement;
|
||||
const parsed = parseProjectMentionHref(link.getAttribute("href") ?? "");
|
||||
if (!parsed) {
|
||||
if (link.dataset.projectMention === "true") {
|
||||
link.dataset.projectMention = "false";
|
||||
link.classList.remove("paperclip-project-mention-chip");
|
||||
link.removeAttribute("contenteditable");
|
||||
link.style.removeProperty("border-color");
|
||||
link.style.removeProperty("background-color");
|
||||
link.style.removeProperty("color");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const color = parsed.color ?? projectColorById.get(parsed.projectId) ?? null;
|
||||
link.dataset.projectMention = "true";
|
||||
link.classList.add("paperclip-project-mention-chip");
|
||||
link.setAttribute("contenteditable", "false");
|
||||
const style = mentionChipStyle(color);
|
||||
if (style) {
|
||||
link.style.borderColor = style.borderColor ?? "";
|
||||
link.style.backgroundColor = style.backgroundColor ?? "";
|
||||
link.style.color = style.color ?? "";
|
||||
}
|
||||
}
|
||||
}, [projectColorById]);
|
||||
|
||||
// Mention detection: listen for selection changes and input events
|
||||
const checkMention = useCallback(() => {
|
||||
if (!mentions || mentions.length === 0 || !containerRef.current) {
|
||||
@@ -251,6 +329,21 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
};
|
||||
}, [checkMention, mentions]);
|
||||
|
||||
useEffect(() => {
|
||||
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
||||
if (!editable) return;
|
||||
decorateProjectMentions();
|
||||
const observer = new MutationObserver(() => {
|
||||
decorateProjectMentions();
|
||||
});
|
||||
observer.observe(editable, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, [decorateProjectMentions, value]);
|
||||
|
||||
const selectMention = useCallback(
|
||||
(option: MentionOption) => {
|
||||
// Read from ref to avoid stale-closure issues (selectionchange can
|
||||
@@ -258,7 +351,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
const state = mentionStateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
const replacement = `@${option.name} `;
|
||||
const replacement = mentionMarkdown(option);
|
||||
|
||||
// Replace @query directly via DOM selection so the cursor naturally
|
||||
// lands after the inserted text. Lexical picks up the change through
|
||||
@@ -326,10 +419,14 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
});
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
decorateProjectMentions();
|
||||
});
|
||||
|
||||
mentionStateRef.current = null;
|
||||
setMentionState(null);
|
||||
},
|
||||
[onChange],
|
||||
[decorateProjectMentions, onChange],
|
||||
);
|
||||
|
||||
function hasFilePayload(evt: DragEvent<HTMLDivElement>) {
|
||||
@@ -452,8 +549,20 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
}}
|
||||
onMouseEnter={() => setMentionIndex(i)}
|
||||
>
|
||||
<span className="text-muted-foreground">@</span>
|
||||
{option.kind === "project" && option.projectId ? (
|
||||
<span
|
||||
className="inline-flex h-2 w-2 rounded-full border border-border/50"
|
||||
style={{ backgroundColor: option.projectColor ?? "#64748b" }}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">@</span>
|
||||
)}
|
||||
<span>{option.name}</span>
|
||||
{option.kind === "project" && option.projectId && (
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Project
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors";
|
||||
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
||||
|
||||
@@ -200,6 +200,30 @@ export function NewIssueDialog() {
|
||||
const supportsAssigneeOverrides = Boolean(
|
||||
assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType),
|
||||
);
|
||||
const mentionOptions = useMemo<MentionOption[]>(() => {
|
||||
const options: MentionOption[] = [];
|
||||
const activeAgents = [...(agents ?? [])]
|
||||
.filter((agent) => agent.status !== "terminated")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const agent of activeAgents) {
|
||||
options.push({
|
||||
id: `agent:${agent.id}`,
|
||||
name: agent.name,
|
||||
kind: "agent",
|
||||
});
|
||||
}
|
||||
const sortedProjects = [...(projects ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const project of sortedProjects) {
|
||||
options.push({
|
||||
id: `project:${project.id}`,
|
||||
name: project.name,
|
||||
kind: "project",
|
||||
projectId: project.id,
|
||||
projectColor: project.color,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, [agents, projects]);
|
||||
|
||||
const { data: assigneeAdapterModels } = useQuery({
|
||||
queryKey: ["adapter-models", assigneeAdapterType],
|
||||
@@ -744,6 +768,7 @@ export function NewIssueDialog() {
|
||||
onChange={setDescription}
|
||||
placeholder="Add description..."
|
||||
bordered={false}
|
||||
mentions={mentionOptions}
|
||||
contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-[220px]" : "min-h-[120px]")}
|
||||
imageUploadHandler={async (file) => {
|
||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||
|
||||
@@ -295,6 +295,22 @@
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content a.paperclip-project-mention-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin: 0 0.1rem;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
text-decoration: none;
|
||||
vertical-align: baseline;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content ul,
|
||||
.paperclip-mdxeditor-content ol {
|
||||
margin: 0.35rem 0;
|
||||
|
||||
@@ -17,6 +17,7 @@ import { InlineEditor } from "../components/InlineEditor";
|
||||
import { CommentThread } from "../components/CommentThread";
|
||||
import { IssueProperties } from "../components/IssueProperties";
|
||||
import { LiveRunWidget } from "../components/LiveRunWidget";
|
||||
import type { MentionOption } from "../components/MarkdownEditor";
|
||||
import { StatusIcon } from "../components/StatusIcon";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
@@ -234,6 +235,31 @@ export function IssueDetail() {
|
||||
return map;
|
||||
}, [agents]);
|
||||
|
||||
const mentionOptions = useMemo<MentionOption[]>(() => {
|
||||
const options: MentionOption[] = [];
|
||||
const activeAgents = [...(agents ?? [])]
|
||||
.filter((agent) => agent.status !== "terminated")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const agent of activeAgents) {
|
||||
options.push({
|
||||
id: `agent:${agent.id}`,
|
||||
name: agent.name,
|
||||
kind: "agent",
|
||||
});
|
||||
}
|
||||
const sortedProjects = [...(projects ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const project of sortedProjects) {
|
||||
options.push({
|
||||
id: `project:${project.id}`,
|
||||
name: project.name,
|
||||
kind: "project",
|
||||
projectId: project.id,
|
||||
projectColor: project.color,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, [agents, projects]);
|
||||
|
||||
const childIssues = useMemo(() => {
|
||||
if (!allIssues || !issue) return [];
|
||||
return allIssues
|
||||
@@ -609,6 +635,7 @@ export function IssueDetail() {
|
||||
className="text-sm text-muted-foreground"
|
||||
placeholder="Add a description..."
|
||||
multiline
|
||||
mentions={mentionOptions}
|
||||
imageUploadHandler={async (file) => {
|
||||
const attachment = await uploadAttachment.mutateAsync(file);
|
||||
return attachment.contentPath;
|
||||
@@ -715,6 +742,7 @@ export function IssueDetail() {
|
||||
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
|
||||
enableReassign={canReassignFromComment}
|
||||
reassignOptions={commentReassignOptions}
|
||||
mentions={mentionOptions}
|
||||
onAdd={async (body, reopen, reassignment) => {
|
||||
if (reassignment) {
|
||||
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
|
||||
|
||||
Reference in New Issue
Block a user