feat(issues): render issue mentions as rich cards with status icon

- Fix mention markdown serialization: use renderMarkdown (tiptap/markdown 3.x API)
  instead of addStorage.markdown.serialize which was silently ignored
- Add IssueMentionCard component showing status icon + identifier + title
- Update Markdown renderer to use card for mention://issue/ links
This commit is contained in:
Jiang Bohan 2026-03-31 16:18:03 +08:00
parent e0e52bca64
commit e16f1579a9
4 changed files with 49 additions and 60 deletions

View file

@ -14,7 +14,6 @@ import Typography from "@tiptap/extension-typography";
import Mention from "@tiptap/extension-mention";
import { Markdown } from "@tiptap/markdown";
import { Extension } from "@tiptap/core";
import type { JSONContent, MarkdownParseHelpers, MarkdownToken } from "@tiptap/core";
import { cn } from "@/lib/utils";
import { createMentionSuggestion } from "./mention-suggestion";
import "./rich-text-editor.css";
@ -83,9 +82,6 @@ const LinkExtension = Link.configure({
},
});
const MENTION_LINK_RE =
/^\[(@?[^\]]*)\]\(mention:\/\/(member|agent|issue)\/([^)]+)\)/;
const MentionExtension = Mention.configure({
HTMLAttributes: { class: "mention" },
suggestion: createMentionSuggestion(),
@ -109,59 +105,22 @@ const MentionExtension = Mention.configure({
...this.parent?.(),
type: {
default: "member",
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-mention-type") ?? "member",
parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-type") ?? "member",
},
description: {
default: null,
parseHTML: (el: HTMLElement) =>
el.getAttribute("data-mention-description"),
parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-description"),
},
};
},
// -- Markdown serialization: [@Label](mention://type/id) --
renderMarkdown(node: JSONContent) {
const type = (node.attrs?.type as string) ?? "member";
const label = (node.attrs?.label as string) ?? node.attrs?.id;
// @tiptap/markdown 3.x uses renderMarkdown as a top-level extension field
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderMarkdown(node: any) {
const type = node.attrs?.type ?? "member";
const label = node.attrs?.label ?? node.attrs?.id;
const display = type === "issue" ? label : `@${label}`;
return `[${display}](mention://${type}/${node.attrs?.id})`;
},
// -- Markdown parsing: turn the link back into a mention node --
parseMarkdown(token: MarkdownToken, h: MarkdownParseHelpers) {
return h.createNode("mention", {
id: token.attributes?.id,
label: token.attributes?.label,
type: token.attributes?.type ?? "member",
});
},
markdownTokenizer: {
name: "mention",
level: "inline" as const,
start(src: string) {
// Find [@ or [ followed by ](mention://
const idx = src.indexOf("](mention://");
if (idx === -1) return -1;
// Walk back to find the opening [
const bracketIdx = src.lastIndexOf("[", idx);
return bracketIdx === -1 ? -1 : bracketIdx;
},
tokenize(src: string) {
const match = MENTION_LINK_RE.exec(src);
if (!match) return undefined;
const [raw, displayLabel = "", type, id] = match;
const label =
displayLabel.startsWith("@") ? displayLabel.slice(1) : displayLabel;
return {
type: "mention",
raw,
content: "",
attributes: { id, label, type },
};
},
},
});
// ---------------------------------------------------------------------------

View file

@ -1,11 +1,11 @@
import * as React from 'react'
import Link from 'next/link'
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/utils'
import { CodeBlock, InlineCode } from './CodeBlock'
import { preprocessLinks } from './linkify'
import { IssueMentionCard } from '@/features/issues/components/issue-mention-card'
/**
* Render modes for markdown content:
@ -71,17 +71,9 @@ function createComponents(
// 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>
)
if (mentionMatch?.[1] === 'issue' && mentionMatch[2]) {
const label = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : undefined
return <IssueMentionCard issueId={mentionMatch[2]} fallbackLabel={label} />
}
return (
<span

View file

@ -6,3 +6,4 @@ export { IssuesPage } from "./issues-page";
export { CommentCard } from "./comment-card";
export { CommentInput } from "./comment-input";
export { ReplyInput } from "./reply-input";
export { IssueMentionCard } from "./issue-mention-card";

View file

@ -0,0 +1,37 @@
"use client";
import Link from "next/link";
import { useIssueStore } from "@/features/issues/store";
import { StatusIcon } from "./status-icon";
interface IssueMentionCardProps {
issueId: string;
/** Fallback text when issue is not in store (e.g. "MUL-7") */
fallbackLabel?: string;
}
export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) {
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
if (!issue) {
return (
<Link
href={`/issues/${issueId}`}
className="text-primary font-medium cursor-pointer hover:underline"
>
{fallbackLabel ?? issueId.slice(0, 8)}
</Link>
);
}
return (
<Link
href={`/issues/${issueId}`}
className="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-sm hover:bg-accent transition-colors cursor-pointer no-underline"
>
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
<span className="font-medium text-muted-foreground">{issue.identifier}</span>
<span className="text-foreground">{issue.title}</span>
</Link>
);
}