feat(issues): add @ mention for issues in comments

Support mentioning issues via @ in the rich text editor with fuzzy
search on identifier and title. Issue mentions render as clickable
links that navigate to the issue detail page.
This commit is contained in:
Jiang Bohan 2026-03-31 15:38:24 +08:00
parent 3b6f64ba8e
commit 7df140bcda
3 changed files with 58 additions and 9 deletions

View file

@ -6,10 +6,11 @@ import {
useImperativeHandle,
useState,
} from "react";
import { Bot } from "lucide-react";
import { Bot, Hash } from "lucide-react";
import { ReactRenderer } from "@tiptap/react";
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
import { useWorkspaceStore } from "@/features/workspace";
import { useIssueStore } from "@/features/issues";
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
// ---------------------------------------------------------------------------
@ -19,7 +20,9 @@ import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
export interface MentionItem {
id: string;
label: string;
type: "member" | "agent";
type: "member" | "agent" | "issue";
/** Secondary text shown below the label (e.g. issue title) */
description?: string;
}
interface MentionListProps {
@ -88,6 +91,10 @@ const MentionList = forwardRef<MentionListRef, MentionListProps>(
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
<Bot className="h-3 w-3" />
</span>
) : item.type === "issue" ? (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Hash className="h-3 w-3" />
</span>
) : (
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground text-[9px] font-medium">
{item.label
@ -98,7 +105,12 @@ const MentionList = forwardRef<MentionListRef, MentionListProps>(
.slice(0, 2)}
</span>
)}
<span className="truncate">{item.label}</span>
<div className="flex flex-col min-w-0">
<span className="truncate">{item.label}</span>
{item.description && (
<span className="truncate text-xs text-muted-foreground">{item.description}</span>
)}
</div>
</button>
))}
</div>
@ -117,6 +129,7 @@ export function createMentionSuggestion(): Omit<
return {
items: ({ query }) => {
const { members, agents } = useWorkspaceStore.getState();
const { issues } = useIssueStore.getState();
const q = query.toLowerCase();
const memberItems: MentionItem[] = members
@ -131,7 +144,20 @@ export function createMentionSuggestion(): Omit<
.filter((a) => a.name.toLowerCase().includes(q))
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
return [...memberItems, ...agentItems].slice(0, 10);
const issueItems: MentionItem[] = issues
.filter(
(i) =>
i.identifier.toLowerCase().includes(q) ||
i.title.toLowerCase().includes(q),
)
.map((i) => ({
id: i.id,
label: i.identifier,
type: "issue" as const,
description: i.title,
}));
return [...memberItems, ...agentItems, ...issueItems].slice(0, 10);
},
render: () => {

View file

@ -87,15 +87,17 @@ const MentionExtension = Mention.configure({
suggestion: createMentionSuggestion(),
}).extend({
renderHTML({ node, HTMLAttributes }) {
const type = node.attrs.type ?? "member";
const label = node.attrs.label ?? node.attrs.id;
return [
"a",
{
...HTMLAttributes,
href: `mention://${node.attrs.type ?? "member"}/${node.attrs.id}`,
"data-mention-type": node.attrs.type ?? "member",
href: `mention://${type}/${node.attrs.id}`,
"data-mention-type": type,
"data-mention-id": node.attrs.id,
},
`@${node.attrs.label ?? node.attrs.id}`,
type === "issue" ? label : `@${label}`,
];
},
addAttributes() {
@ -105,14 +107,21 @@ const MentionExtension = Mention.configure({
default: "member",
parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-type") ?? "member",
},
description: {
default: null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-description"),
},
};
},
addStorage() {
return {
markdown: {
serialize(state: { write: (s: string) => void }, node: { attrs: { label?: string; type?: string; id?: string } }) {
const type = node.attrs.type ?? "member";
const label = node.attrs.label ?? node.attrs.id;
const display = type === "issue" ? label : `@${label}`;
state.write(
`[@${node.attrs.label ?? node.attrs.id}](mention://${node.attrs.type ?? "member"}/${node.attrs.id})`,
`[${display}](mention://${type}/${node.attrs.id})`,
);
},
parse: {},

View file

@ -1,4 +1,5 @@
import * as React from 'react'
import Link from 'next/link'
import ReactMarkdown, { type Components } from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import remarkGfm from 'remark-gfm'
@ -58,8 +59,21 @@ function createComponents(
const baseComponents: Partial<Components> = {
// Links: Make clickable with callbacks, or render as mention
a: ({ href, children }) => {
// Mention links: mention://member/id or mention://agent/id
// Mention links: mention://member/id, mention://agent/id, mention://issue/id
if (href?.startsWith('mention://')) {
const mentionMatch = href.match(/^mention:\/\/(member|agent|issue)\/(.+)$/)
if (mentionMatch?.[1] === 'issue') {
const issueId = mentionMatch[2]
return (
<Link
href={`/issues/${issueId}`}
className="text-primary font-medium cursor-pointer hover:underline"
style={{ background: 'color-mix(in srgb, var(--primary) 8%, transparent)', padding: '0 0.2em', borderRadius: 'calc(var(--radius) * 0.5)' }}
>
{children}
</Link>
)
}
return (
<span
className="text-primary font-medium"