From 2488dc703c3740b6bac138f49cacfa15ca0d76d3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 2 Mar 2026 13:31:58 -0600 Subject: [PATCH] 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 --- packages/shared/src/index.ts | 7 ++ packages/shared/src/project-mentions.ts | 78 +++++++++++++++ packages/shared/src/types/issue.ts | 6 +- server/src/routes/issues.ts | 8 +- server/src/services/issues.ts | 43 +++++++++ server/src/services/projects.ts | 13 +++ ui/src/components/CommentThread.tsx | 5 +- ui/src/components/InlineEditor.tsx | 5 +- ui/src/components/MarkdownBody.tsx | 53 ++++++++++- ui/src/components/MarkdownEditor.tsx | 121 ++++++++++++++++++++++-- ui/src/components/NewIssueDialog.tsx | 27 +++++- ui/src/index.css | 16 ++++ ui/src/pages/IssueDetail.tsx | 28 ++++++ 13 files changed, 397 insertions(+), 13 deletions(-) create mode 100644 packages/shared/src/project-mentions.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 03e74aca..4dbc7022 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -228,6 +228,13 @@ export { export { API_PREFIX, API } from "./api.js"; export { normalizeAgentUrlKey, deriveAgentUrlKey } from "./agent-url-key.js"; +export { + PROJECT_MENTION_SCHEME, + buildProjectMentionHref, + parseProjectMentionHref, + extractProjectMentionIds, + type ParsedProjectMention, +} from "./project-mentions.js"; export { paperclipConfigSchema, diff --git a/packages/shared/src/project-mentions.ts b/packages/shared/src/project-mentions.ts new file mode 100644 index 00000000..2c167517 --- /dev/null +++ b/packages/shared/src/project-mentions.ts @@ -0,0 +1,78 @@ +export const PROJECT_MENTION_SCHEME = "project://"; + +const HEX_COLOR_RE = /^[0-9a-f]{6}$/i; +const HEX_COLOR_SHORT_RE = /^[0-9a-f]{3}$/i; +const HEX_COLOR_WITH_HASH_RE = /^#[0-9a-f]{6}$/i; +const HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i; +const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi; + +export interface ParsedProjectMention { + projectId: string; + color: string | null; +} + +function normalizeHexColor(input: string | null | undefined): string | null { + if (!input) return null; + const trimmed = input.trim(); + if (!trimmed) return null; + + if (HEX_COLOR_WITH_HASH_RE.test(trimmed)) { + return trimmed.toLowerCase(); + } + if (HEX_COLOR_RE.test(trimmed)) { + return `#${trimmed.toLowerCase()}`; + } + if (HEX_COLOR_SHORT_WITH_HASH_RE.test(trimmed)) { + const raw = trimmed.slice(1).toLowerCase(); + return `#${raw[0]}${raw[0]}${raw[1]}${raw[1]}${raw[2]}${raw[2]}`; + } + if (HEX_COLOR_SHORT_RE.test(trimmed)) { + const raw = trimmed.toLowerCase(); + return `#${raw[0]}${raw[0]}${raw[1]}${raw[1]}${raw[2]}${raw[2]}`; + } + return null; +} + +export function buildProjectMentionHref(projectId: string, color?: string | null): string { + const trimmedProjectId = projectId.trim(); + const normalizedColor = normalizeHexColor(color ?? null); + if (!normalizedColor) { + return `${PROJECT_MENTION_SCHEME}${trimmedProjectId}`; + } + return `${PROJECT_MENTION_SCHEME}${trimmedProjectId}?c=${encodeURIComponent(normalizedColor.slice(1))}`; +} + +export function parseProjectMentionHref(href: string): ParsedProjectMention | null { + if (!href.startsWith(PROJECT_MENTION_SCHEME)) return null; + + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + + if (url.protocol !== "project:") return null; + + const projectId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim(); + if (!projectId) return null; + + const color = normalizeHexColor(url.searchParams.get("c") ?? url.searchParams.get("color")); + + return { + projectId, + color, + }; +} + +export function extractProjectMentionIds(markdown: string): string[] { + if (!markdown) return []; + const ids = new Set(); + const re = new RegExp(PROJECT_MENTION_LINK_RE); + let match: RegExpExecArray | null; + while ((match = re.exec(markdown)) !== null) { + const parsed = parseProjectMentionHref(match[1]); + if (parsed) ids.add(parsed.projectId); + } + return [...ids]; +} diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index e0c92c40..f5adeb95 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -1,5 +1,6 @@ import type { IssuePriority, IssueStatus } from "../constants.js"; -import type { ProjectWorkspace } from "./project.js"; +import type { Goal } from "./goal.js"; +import type { Project, ProjectWorkspace } from "./project.js"; export interface IssueAncestorProject { id: string; @@ -78,6 +79,9 @@ export interface Issue { hiddenAt: Date | null; labelIds?: string[]; labels?: IssueLabel[]; + project?: Project | null; + goal?: Goal | null; + mentionedProjects?: Project[]; createdAt: Date; updatedAt: Date; } diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index a8b93ff5..44fc96c0 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -270,12 +270,16 @@ export function issueRoutes(db: Db, storage: StorageService) { return; } assertCompanyAccess(req, issue.companyId); - const [ancestors, project, goal] = await Promise.all([ + const [ancestors, project, goal, mentionedProjectIds] = await Promise.all([ svc.getAncestors(issue.id), issue.projectId ? projectsSvc.getById(issue.projectId) : null, issue.goalId ? goalsSvc.getById(issue.goalId) : null, + svc.findMentionedProjectIds(issue.id), ]); - res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null }); + const mentionedProjects = mentionedProjectIds.length > 0 + ? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds) + : []; + res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null, mentionedProjects }); }); router.get("/issues/:id/approvals", async (req, res) => { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index eb8d57d1..3952cc82 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -15,6 +15,7 @@ import { projectWorkspaces, projects, } from "@paperclip/db"; +import { extractProjectMentionIds } from "@paperclip/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; @@ -907,6 +908,48 @@ export function issueService(db: Db) { return rows.filter(a => tokens.has(a.name.toLowerCase())).map(a => a.id); }, + findMentionedProjectIds: async (issueId: string) => { + const issue = await db + .select({ + companyId: issues.companyId, + title: issues.title, + description: issues.description, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + if (!issue) return []; + + const comments = await db + .select({ body: issueComments.body }) + .from(issueComments) + .where(eq(issueComments.issueId, issueId)); + + const mentionedIds = new Set(); + for (const source of [ + issue.title, + issue.description ?? "", + ...comments.map((comment) => comment.body), + ]) { + for (const projectId of extractProjectMentionIds(source)) { + mentionedIds.add(projectId); + } + } + if (mentionedIds.size === 0) return []; + + const rows = await db + .select({ id: projects.id }) + .from(projects) + .where( + and( + eq(projects.companyId, issue.companyId), + inArray(projects.id, [...mentionedIds]), + ), + ); + const valid = new Set(rows.map((row) => row.id)); + return [...mentionedIds].filter((projectId) => valid.has(projectId)); + }, + getAncestors: async (issueId: string) => { const raw: Array<{ id: string; identifier: string | null; title: string; description: string | null; diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index f2ac0ad8..108b39fb 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -217,6 +217,19 @@ export function projectService(db: Db) { return attachWorkspaces(db, withGoals); }, + listByIds: async (companyId: string, ids: string[]): Promise => { + const dedupedIds = [...new Set(ids)]; + if (dedupedIds.length === 0) return []; + const rows = await db + .select() + .from(projects) + .where(and(eq(projects.companyId, companyId), inArray(projects.id, dedupedIds))); + const withGoals = await attachGoals(db, rows); + const withWorkspaces = await attachWorkspaces(db, withGoals); + const byId = new Map(withWorkspaces.map((project) => [project.id, project])); + return dedupedIds.map((id) => byId.get(id)).filter((project): project is ProjectWithGoals => Boolean(project)); + }, + getById: async (id: string): Promise => { const row = await db .select() diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 5988bf91..bf2ef6bb 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -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(() => { + 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; diff --git a/ui/src/components/InlineEditor.tsx b/ui/src/components/InlineEditor.tsx index 7196bc8a..8cf5515c 100644 --- a/ui/src/components/InlineEditor.tsx +++ b/ui/src/components/InlineEditor.tsx @@ -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; + 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} />
diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 3b4addbc..2a2ce115 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -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, )} > - {children} + { + const parsed = href ? parseProjectMentionHref(href) : null; + if (parsed) { + const label = linkChildren; + return ( + + {label} + + ); + } + return ( + + {linkChildren} + + ); + }, + }} + > + {children} +
); } diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index b3e06bb6..94af2527 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -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; 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 `@` in the markdown string with `@ `. */ +function mentionMarkdown(option: MentionOption): string { + if (option.kind === "project" && option.projectId) { + return `[@${option.name}](${buildProjectMentionHref(option.projectId, option.projectColor ?? null)}) `; + } + return `@${option.name} `; +} + +/** Replace `@` 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(function MarkdownEditor({ @@ -166,6 +203,15 @@ export const MarkdownEditor = forwardRef const mentionStateRef = useRef(null); const [mentionIndex, setMentionIndex] = useState(0); const mentionActive = mentionState !== null && mentions && mentions.length > 0; + const projectColorById = useMemo(() => { + const map = new Map(); + 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 } }, [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 }; }, [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 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 }); } + requestAnimationFrame(() => { + decorateProjectMentions(); + }); + mentionStateRef.current = null; setMentionState(null); }, - [onChange], + [decorateProjectMentions, onChange], ); function hasFilePayload(evt: DragEvent) { @@ -452,8 +549,20 @@ export const MarkdownEditor = forwardRef }} onMouseEnter={() => setMentionIndex(i)} > - @ + {option.kind === "project" && option.projectId ? ( + + ) : ( + @ + )} {option.name} + {option.kind === "project" && option.projectId && ( + + Project + + )} ))} diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 76c03d65..4f26fa29 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -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(() => { + 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); diff --git a/ui/src/index.css b/ui/src/index.css index c8e33eac..a9a8afca 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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; diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index c0bf7bbd..2175df93 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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(() => { + 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 });