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:
parent
3b6f64ba8e
commit
7df140bcda
3 changed files with 58 additions and 9 deletions
|
|
@ -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: () => {
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue