diff --git a/apps/web/components/common/mention-suggestion.tsx b/apps/web/components/common/mention-suggestion.tsx index 0cd09b10..b00b4c1d 100644 --- a/apps/web/components/common/mention-suggestion.tsx +++ b/apps/web/components/common/mention-suggestion.tsx @@ -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( + ) : item.type === "issue" ? ( + + + ) : ( {item.label @@ -98,7 +105,12 @@ const MentionList = forwardRef( .slice(0, 2)} )} - {item.label} +
+ {item.label} + {item.description && ( + {item.description} + )} +
))} @@ -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: () => { diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index 447c4278..9817c03d 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -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: {}, diff --git a/apps/web/components/markdown/Markdown.tsx b/apps/web/components/markdown/Markdown.tsx index 511084da..77f5710b 100644 --- a/apps/web/components/markdown/Markdown.tsx +++ b/apps/web/components/markdown/Markdown.tsx @@ -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 = { // 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 ( + + {children} + + ) + } return (