diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6a105d79..8ba20ba9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -535,10 +535,15 @@ export { API_PREFIX, API } from "./api.js"; export { normalizeAgentUrlKey, deriveAgentUrlKey, isUuidLike } from "./agent-url-key.js"; export { deriveProjectUrlKey, normalizeProjectUrlKey } from "./project-url-key.js"; export { + AGENT_MENTION_SCHEME, PROJECT_MENTION_SCHEME, + buildAgentMentionHref, buildProjectMentionHref, + extractAgentMentionIds, + parseAgentMentionHref, parseProjectMentionHref, extractProjectMentionIds, + type ParsedAgentMention, type ParsedProjectMention, } from "./project-mentions.js"; diff --git a/packages/shared/src/project-mentions.test.ts b/packages/shared/src/project-mentions.test.ts new file mode 100644 index 00000000..55f27369 --- /dev/null +++ b/packages/shared/src/project-mentions.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + buildAgentMentionHref, + buildProjectMentionHref, + extractAgentMentionIds, + extractProjectMentionIds, + parseAgentMentionHref, + parseProjectMentionHref, +} from "./project-mentions.js"; + +describe("project-mentions", () => { + it("round-trips project mentions with color metadata", () => { + const href = buildProjectMentionHref("project-123", "#336699"); + expect(parseProjectMentionHref(href)).toEqual({ + projectId: "project-123", + color: "#336699", + }); + expect(extractProjectMentionIds(`[@Paperclip App](${href})`)).toEqual(["project-123"]); + }); + + it("round-trips agent mentions with icon metadata", () => { + const href = buildAgentMentionHref("agent-123", "code"); + expect(parseAgentMentionHref(href)).toEqual({ + agentId: "agent-123", + icon: "code", + }); + expect(extractAgentMentionIds(`[@CodexCoder](${href})`)).toEqual(["agent-123"]); + }); +}); diff --git a/packages/shared/src/project-mentions.ts b/packages/shared/src/project-mentions.ts index 2c167517..66be8948 100644 --- a/packages/shared/src/project-mentions.ts +++ b/packages/shared/src/project-mentions.ts @@ -1,16 +1,24 @@ export const PROJECT_MENTION_SCHEME = "project://"; +export const AGENT_MENTION_SCHEME = "agent://"; 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; +const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi; +const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i; export interface ParsedProjectMention { projectId: string; color: string | null; } +export interface ParsedAgentMention { + agentId: string; + icon: string | null; +} + function normalizeHexColor(input: string | null | undefined): string | null { if (!input) return null; const trimmed = input.trim(); @@ -65,6 +73,36 @@ export function parseProjectMentionHref(href: string): ParsedProjectMention | nu }; } +export function buildAgentMentionHref(agentId: string, icon?: string | null): string { + const trimmedAgentId = agentId.trim(); + const normalizedIcon = normalizeAgentIcon(icon ?? null); + if (!normalizedIcon) { + return `${AGENT_MENTION_SCHEME}${trimmedAgentId}`; + } + return `${AGENT_MENTION_SCHEME}${trimmedAgentId}?i=${encodeURIComponent(normalizedIcon)}`; +} + +export function parseAgentMentionHref(href: string): ParsedAgentMention | null { + if (!href.startsWith(AGENT_MENTION_SCHEME)) return null; + + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + + if (url.protocol !== "agent:") return null; + + const agentId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim(); + if (!agentId) return null; + + return { + agentId, + icon: normalizeAgentIcon(url.searchParams.get("i") ?? url.searchParams.get("icon")), + }; +} + export function extractProjectMentionIds(markdown: string): string[] { if (!markdown) return []; const ids = new Set(); @@ -76,3 +114,22 @@ export function extractProjectMentionIds(markdown: string): string[] { } return [...ids]; } + +export function extractAgentMentionIds(markdown: string): string[] { + if (!markdown) return []; + const ids = new Set(); + const re = new RegExp(AGENT_MENTION_LINK_RE); + let match: RegExpExecArray | null; + while ((match = re.exec(markdown)) !== null) { + const parsed = parseAgentMentionHref(match[1]); + if (parsed) ids.add(parsed.agentId); + } + return [...ids]; +} + +function normalizeAgentIcon(input: string | null | undefined): string | null { + if (!input) return null; + const trimmed = input.trim().toLowerCase(); + if (!trimmed || !AGENT_ICON_NAME_RE.test(trimmed)) return null; + return trimmed; +} diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 681da27d..02a0cb9e 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -19,7 +19,7 @@ import { projectWorkspaces, projects, } from "@paperclipai/db"; -import { extractProjectMentionIds } from "@paperclipai/shared"; +import { extractAgentMentionIds, extractProjectMentionIds } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; import { defaultIssueExecutionWorkspaceSettingsForProject, @@ -1462,10 +1462,19 @@ export function issueService(db: Db) { const tokens = new Set(); let m: RegExpExecArray | null; while ((m = re.exec(body)) !== null) tokens.add(m[1].toLowerCase()); - if (tokens.size === 0) return []; + + const explicitAgentMentionIds = extractAgentMentionIds(body); + if (tokens.size === 0 && explicitAgentMentionIds.length === 0) return []; + const rows = await db.select({ id: agents.id, name: agents.name }) .from(agents).where(eq(agents.companyId, companyId)); - return rows.filter(a => tokens.has(a.name.toLowerCase())).map(a => a.id); + const resolved = new Set(explicitAgentMentionIds); + for (const agent of rows) { + if (tokens.has(agent.name.toLowerCase())) { + resolved.add(agent.id); + } + } + return [...resolved]; }, findMentionedProjectIds: async (issueId: string) => { diff --git a/ui/src/components/AgentIconPicker.tsx b/ui/src/components/AgentIconPicker.tsx index 8f53d87d..06257fb9 100644 --- a/ui/src/components/AgentIconPicker.tsx +++ b/ui/src/components/AgentIconPicker.tsx @@ -1,46 +1,5 @@ import { useState, useMemo } from "react"; import { - Bot, - Cpu, - Brain, - Zap, - Rocket, - Code, - Terminal, - Shield, - Eye, - Search, - Wrench, - Hammer, - Lightbulb, - Sparkles, - Star, - Heart, - Flame, - Bug, - Cog, - Database, - Globe, - Lock, - Mail, - MessageSquare, - FileCode, - GitBranch, - Package, - Puzzle, - Target, - Wand2, - Atom, - CircuitBoard, - Radar, - Swords, - Telescope, - Microscope, - Crown, - Gem, - Hexagon, - Pentagon, - Fingerprint, type LucideIcon, } from "lucide-react"; import { AGENT_ICON_NAMES, type AgentIconName } from "@paperclipai/shared"; @@ -51,60 +10,10 @@ import { } from "@/components/ui/popover"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; - -export const AGENT_ICONS: Record = { - bot: Bot, - cpu: Cpu, - brain: Brain, - zap: Zap, - rocket: Rocket, - code: Code, - terminal: Terminal, - shield: Shield, - eye: Eye, - search: Search, - wrench: Wrench, - hammer: Hammer, - lightbulb: Lightbulb, - sparkles: Sparkles, - star: Star, - heart: Heart, - flame: Flame, - bug: Bug, - cog: Cog, - database: Database, - globe: Globe, - lock: Lock, - mail: Mail, - "message-square": MessageSquare, - "file-code": FileCode, - "git-branch": GitBranch, - package: Package, - puzzle: Puzzle, - target: Target, - wand: Wand2, - atom: Atom, - "circuit-board": CircuitBoard, - radar: Radar, - swords: Swords, - telescope: Telescope, - microscope: Microscope, - crown: Crown, - gem: Gem, - hexagon: Hexagon, - pentagon: Pentagon, - fingerprint: Fingerprint, -}; +import { AGENT_ICONS, getAgentIcon } from "../lib/agent-icons"; const DEFAULT_ICON: AgentIconName = "bot"; -export function getAgentIcon(iconName: string | null | undefined): LucideIcon { - if (iconName && AGENT_ICON_NAMES.includes(iconName as AgentIconName)) { - return AGENT_ICONS[iconName as AgentIconName]; - } - return AGENT_ICONS[DEFAULT_ICON]; -} - interface AgentIconProps { icon: string | null | undefined; className?: string; diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index eda28518..cdf0ddd2 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -311,8 +311,11 @@ export function CommentThread({ return Array.from(agentMap.values()) .filter((a) => a.status !== "terminated") .map((a) => ({ - id: a.id, + id: `agent:${a.id}`, name: a.name, + kind: "agent", + agentId: a.id, + agentIcon: a.icon, })); }, [agentMap, providedMentions]); diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 06cfc70a..5794f989 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared"; import { ThemeProvider } from "../context/ThemeContext"; import { MarkdownBody } from "./MarkdownBody"; @@ -28,4 +29,21 @@ describe("MarkdownBody", () => { expect(html).toContain('src="/resolved/images/org-chart.png"'); expect(html).toContain('alt="Org chart"'); }); + + it("renders agent and project mentions as chips", () => { + const html = renderToStaticMarkup( + + + {`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")})`} + + , + ); + + expect(html).toContain('href="/agents/agent-123"'); + expect(html).toContain('data-mention-kind="agent"'); + expect(html).toContain("--paperclip-mention-icon-mask"); + expect(html).toContain('href="/projects/project-456"'); + expect(html).toContain('data-mention-kind="project"'); + expect(html).toContain("--paperclip-mention-project-color:#336699"); + }); }); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 0fbb52c4..e00afc84 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,9 +1,9 @@ -import { isValidElement, useEffect, useId, useState, type CSSProperties, type ReactNode } from "react"; +import { isValidElement, useEffect, useId, useState, type ReactNode } from "react"; import Markdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; -import { parseProjectMentionHref } from "@paperclipai/shared"; import { cn } from "../lib/utils"; import { useTheme } from "../context/ThemeContext"; +import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips"; interface MarkdownBodyProps { children: string; @@ -36,29 +36,6 @@ function extractMermaidSource(children: ReactNode): string | null { return flattenText(childProps.children).replace(/\n$/, ""); } -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", - }; -} - function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) { const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); const [svg, setSvg] = useState(null); @@ -125,16 +102,23 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB return
{preChildren}
; }, a: ({ href, children: linkChildren }) => { - const parsed = href ? parseProjectMentionHref(href) : null; + const parsed = href ? parseMentionChipHref(href) : null; if (parsed) { - const label = linkChildren; + const targetHref = parsed.kind === "project" + ? `/projects/${parsed.projectId}` + : `/agents/${parsed.agentId}`; return ( - {label} + {linkChildren} ); } @@ -160,7 +144,7 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB className, )} > - + url}> {children} diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 29a57a3a..0f6bd402 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -6,7 +6,6 @@ import { useMemo, useRef, useState, - type CSSProperties, type DragEvent, } from "react"; import { @@ -27,7 +26,9 @@ import { thematicBreakPlugin, type RealmPlugin, } from "@mdxeditor/editor"; -import { buildProjectMentionHref, parseProjectMentionHref } from "@paperclipai/shared"; +import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared"; +import { AgentIcon } from "./AgentIconPicker"; +import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips"; import { cn } from "../lib/utils"; /* ---- Mention types ---- */ @@ -36,6 +37,8 @@ export interface MentionOption { id: string; name: string; kind?: "agent" | "project"; + agentId?: string; + agentIcon?: string | null; projectId?: string; projectColor?: string | null; } @@ -154,7 +157,8 @@ function mentionMarkdown(option: MentionOption): string { if (option.kind === "project" && option.projectId) { return `[@${option.name}](${buildProjectMentionHref(option.projectId, option.projectColor ?? null)}) `; } - return `@${option.name} `; + const agentId = option.agentId ?? option.id.replace(/^agent:/, ""); + return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `; } /** Replace `@` in the markdown string with the selected mention token. */ @@ -166,31 +170,6 @@ function applyMention(markdown: string, query: string, option: MentionOption): s 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({ @@ -221,11 +200,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(); + const mentionOptionByKey = useMemo(() => { + const map = new Map(); for (const mention of mentions ?? []) { + if (mention.kind === "agent") { + const agentId = mention.agentId ?? mention.id.replace(/^agent:/, ""); + map.set(`agent:${agentId}`, mention); + } if (mention.kind === "project" && mention.projectId) { - map.set(mention.projectId, mention.projectColor ?? null); + map.set(`project:${mention.projectId}`, mention); } } return map; @@ -315,31 +298,28 @@ export const MarkdownEditor = forwardRef const links = editable.querySelectorAll("a"); for (const node of links) { const link = node as HTMLAnchorElement; - const parsed = parseProjectMentionHref(link.getAttribute("href") ?? ""); + const parsed = parseMentionChipHref(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"); - } + clearMentionChipDecoration(link); 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 ?? ""; + if (parsed.kind === "project") { + const option = mentionOptionByKey.get(`project:${parsed.projectId}`); + applyMentionChipDecoration(link, { + ...parsed, + color: parsed.color ?? option?.projectColor ?? null, + }); + continue; } + + const option = mentionOptionByKey.get(`agent:${parsed.agentId}`); + applyMentionChipDecoration(link, { + ...parsed, + icon: parsed.icon ?? option?.agentIcon ?? null, + }); } - }, [projectColorById]); + }, [mentionOptionByKey]); // Mention detection: listen for selection changes and input events const checkMention = useCallback(() => { @@ -395,94 +375,67 @@ export const MarkdownEditor = forwardRef // update state between the last render and this callback firing). const state = mentionStateRef.current; if (!state) return; - - if (option.kind === "project" && option.projectId) { - const current = latestValueRef.current; - const next = applyMention(current, state.query, option); - if (next !== current) { - latestValueRef.current = next; - ref.current?.setMarkdown(next); - onChange(next); - } - requestAnimationFrame(() => { - ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); - decorateProjectMentions(); - }); - mentionStateRef.current = null; - setMentionState(null); - return; - } - - 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 - // its normal input-event handling. - const sel = window.getSelection(); - if (sel && state.textNode.isConnected) { - const range = document.createRange(); - range.setStart(state.textNode, state.atPos); - range.setEnd(state.textNode, state.endPos); - sel.removeAllRanges(); - sel.addRange(range); - document.execCommand("insertText", false, replacement); - - // After Lexical reconciles the DOM, the cursor position set by - // execCommand may be lost. Explicitly reposition it after the - // inserted mention text. - const cursorTarget = state.atPos + replacement.length; - requestAnimationFrame(() => { - const newSel = window.getSelection(); - if (!newSel) return; - // Try the original text node first (it may still be valid) - if (state.textNode.isConnected) { - const len = state.textNode.textContent?.length ?? 0; - if (cursorTarget <= len) { - const r = document.createRange(); - r.setStart(state.textNode, cursorTarget); - r.collapse(true); - newSel.removeAllRanges(); - newSel.addRange(r); - return; - } - } - // Fallback: search for the replacement in text nodes - const editable = containerRef.current?.querySelector('[contenteditable="true"]'); - if (!editable) return; - const walker = document.createTreeWalker(editable, NodeFilter.SHOW_TEXT); - let node: Text | null; - while ((node = walker.nextNode() as Text | null)) { - const text = node.textContent ?? ""; - const idx = text.indexOf(replacement); - if (idx !== -1) { - const pos = idx + replacement.length; - if (pos <= text.length) { - const r = document.createRange(); - r.setStart(node, pos); - r.collapse(true); - newSel.removeAllRanges(); - newSel.addRange(r); - return; - } - } - } - }); - } else { - // Fallback: full markdown replacement when DOM node is stale - const current = latestValueRef.current; - const next = applyMention(current, state.query, option); - if (next !== current) { - latestValueRef.current = next; - ref.current?.setMarkdown(next); - onChange(next); - } - requestAnimationFrame(() => { - ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); - }); + const current = latestValueRef.current; + const next = applyMention(current, state.query, option); + if (next !== current) { + latestValueRef.current = next; + ref.current?.setMarkdown(next); + onChange(next); } requestAnimationFrame(() => { - decorateProjectMentions(); + requestAnimationFrame(() => { + const editable = containerRef.current?.querySelector('[contenteditable="true"]'); + if (!(editable instanceof HTMLElement)) return; + decorateProjectMentions(); + editable.focus(); + + const mentionHref = option.kind === "project" && option.projectId + ? buildProjectMentionHref(option.projectId, option.projectColor ?? null) + : buildAgentMentionHref( + option.agentId ?? option.id.replace(/^agent:/, ""), + option.agentIcon ?? null, + ); + const matchingMentions = Array.from(editable.querySelectorAll("a")) + .filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement) + .filter((link) => { + const href = link.getAttribute("href") ?? ""; + return href === mentionHref && link.textContent === `@${option.name}`; + }); + const containerRect = containerRef.current?.getBoundingClientRect(); + const target = matchingMentions.sort((a, b) => { + const rectA = a.getBoundingClientRect(); + const rectB = b.getBoundingClientRect(); + const leftA = containerRect ? rectA.left - containerRect.left : rectA.left; + const topA = containerRect ? rectA.top - containerRect.top : rectA.top; + const leftB = containerRect ? rectB.left - containerRect.left : rectB.left; + const topB = containerRect ? rectB.top - containerRect.top : rectB.top; + const distA = Math.hypot(leftA - state.left, topA - state.top); + const distB = Math.hypot(leftB - state.left, topB - state.top); + return distA - distB; + })[0] ?? null; + if (!target) return; + + const selection = window.getSelection(); + if (!selection) return; + const range = document.createRange(); + const nextSibling = target.nextSibling; + if (nextSibling?.nodeType === Node.TEXT_NODE) { + const text = nextSibling.textContent ?? ""; + if (text.startsWith(" ")) { + range.setStart(nextSibling, 1); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + return; + } + } + + range.setStartAfter(target); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + }); }); mentionStateRef.current = null; @@ -616,7 +569,10 @@ export const MarkdownEditor = forwardRef style={{ backgroundColor: option.projectColor ?? "#64748b" }} /> ) : ( - @ + )} {option.name} {option.kind === "project" && option.projectId && ( diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 727a54e6..0a35bd34 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -376,6 +376,8 @@ export function NewIssueDialog() { id: `agent:${agent.id}`, name: agent.name, kind: "agent", + agentId: agent.id, + agentIcon: agent.icon, }); } for (const project of orderedProjects) { diff --git a/ui/src/index.css b/ui/src/index.css index c8eb8366..d6b1d359 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -339,6 +339,7 @@ margin-top: 1.1em; } +.paperclip-mdxeditor-content a.paperclip-mention-chip, .paperclip-mdxeditor-content a.paperclip-project-mention-chip { display: inline-flex; align-items: center; @@ -355,6 +356,35 @@ user-select: none; } +.paperclip-mdxeditor-content a.paperclip-mention-chip::before, +a.paperclip-mention-chip::before { + content: ""; + flex: none; +} + +.paperclip-mdxeditor-content a.paperclip-mention-chip[data-mention-kind="project"]::before, +a.paperclip-mention-chip[data-mention-kind="project"]::before { + width: 0.45rem; + height: 0.45rem; + border-radius: 999px; + background-color: var(--paperclip-mention-project-color, currentColor); +} + +.paperclip-mdxeditor-content a.paperclip-mention-chip[data-mention-kind="agent"]::before, +a.paperclip-mention-chip[data-mention-kind="agent"]::before { + width: 0.75rem; + height: 0.75rem; + background-color: currentColor; + -webkit-mask-image: var(--paperclip-mention-icon-mask); + mask-image: var(--paperclip-mention-icon-mask); + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: contain; + mask-size: contain; +} + .paperclip-mdxeditor-content ul, .paperclip-mdxeditor-content ol { margin: 1.1em 0; @@ -700,6 +730,7 @@ } /* Project mention chips rendered inside MarkdownBody */ +a.paperclip-mention-chip, a.paperclip-project-mention-chip { display: inline-flex; align-items: center; diff --git a/ui/src/lib/agent-icons.ts b/ui/src/lib/agent-icons.ts new file mode 100644 index 00000000..f57b760a --- /dev/null +++ b/ui/src/lib/agent-icons.ts @@ -0,0 +1,98 @@ +import { + Atom, + Bot, + Brain, + Bug, + CircuitBoard, + Code, + Cog, + Cpu, + Crown, + Database, + Eye, + FileCode, + Fingerprint, + Flame, + Gem, + GitBranch, + Globe, + Hammer, + Heart, + Hexagon, + Lightbulb, + Lock, + Mail, + MessageSquare, + Microscope, + Package, + Pentagon, + Puzzle, + Radar, + Rocket, + Search, + Shield, + Sparkles, + Star, + Swords, + Target, + Telescope, + Terminal, + Wand2, + Wrench, + Zap, + type LucideIcon, +} from "lucide-react"; +import { AGENT_ICON_NAMES, type AgentIconName } from "@paperclipai/shared"; + +export const AGENT_ICONS: Record = { + bot: Bot, + cpu: Cpu, + brain: Brain, + zap: Zap, + rocket: Rocket, + code: Code, + terminal: Terminal, + shield: Shield, + eye: Eye, + search: Search, + wrench: Wrench, + hammer: Hammer, + lightbulb: Lightbulb, + sparkles: Sparkles, + star: Star, + heart: Heart, + flame: Flame, + bug: Bug, + cog: Cog, + database: Database, + globe: Globe, + lock: Lock, + mail: Mail, + "message-square": MessageSquare, + "file-code": FileCode, + "git-branch": GitBranch, + package: Package, + puzzle: Puzzle, + target: Target, + wand: Wand2, + atom: Atom, + "circuit-board": CircuitBoard, + radar: Radar, + swords: Swords, + telescope: Telescope, + microscope: Microscope, + crown: Crown, + gem: Gem, + hexagon: Hexagon, + pentagon: Pentagon, + fingerprint: Fingerprint, +}; + +const DEFAULT_ICON: AgentIconName = "bot"; + +export function getAgentIcon(iconName: string | null | undefined): LucideIcon { + if (iconName && AGENT_ICON_NAMES.includes(iconName as AgentIconName)) { + return AGENT_ICONS[iconName as AgentIconName]; + } + return AGENT_ICONS[DEFAULT_ICON]; +} diff --git a/ui/src/lib/mention-chips.ts b/ui/src/lib/mention-chips.ts new file mode 100644 index 00000000..d082cd7f --- /dev/null +++ b/ui/src/lib/mention-chips.ts @@ -0,0 +1,160 @@ +import type { CSSProperties } from "react"; +import { parseAgentMentionHref, parseProjectMentionHref } from "@paperclipai/shared"; +import { getAgentIcon } from "./agent-icons"; + +export type ParsedMentionChip = + | { + kind: "agent"; + agentId: string; + icon: string | null; + } + | { + kind: "project"; + projectId: string; + color: string | null; + }; + +const iconMaskCache = new Map(); + +export function parseMentionChipHref(href: string): ParsedMentionChip | null { + const agent = parseAgentMentionHref(href); + if (agent) { + return { + kind: "agent", + agentId: agent.agentId, + icon: agent.icon, + }; + } + + const project = parseProjectMentionHref(href); + if (project) { + return { + kind: "project", + projectId: project.projectId, + color: project.color, + }; + } + + return null; +} + +export function mentionChipInlineStyle(mention: ParsedMentionChip): CSSProperties | undefined { + const style: CSSProperties & Record = {}; + + if (mention.kind === "project" && mention.color) { + const projectStyle = projectMentionColors(mention.color); + Object.assign(style, projectStyle); + style["--paperclip-mention-project-color"] = mention.color; + } + + if (mention.kind === "agent") { + const iconMask = buildAgentIconMask(mention.icon); + if (iconMask) { + style["--paperclip-mention-icon-mask"] = iconMask; + } + } + + return Object.keys(style).length > 0 ? (style as CSSProperties) : undefined; +} + +export function applyMentionChipDecoration(element: HTMLElement, mention: ParsedMentionChip) { + clearMentionChipDecoration(element); + element.dataset.mentionKind = mention.kind; + element.setAttribute("contenteditable", "false"); + element.classList.add("paperclip-mention-chip", `paperclip-mention-chip--${mention.kind}`); + if (mention.kind === "project") { + element.classList.add("paperclip-project-mention-chip"); + } + + const style = mentionChipInlineStyle(mention); + if (!style) return; + for (const [key, value] of Object.entries(style)) { + if (typeof value === "string") { + if (key.startsWith("--")) { + element.style.setProperty(key, value); + } else { + (element.style as CSSStyleDeclaration & Record)[key] = value; + } + } + } +} + +export function clearMentionChipDecoration(element: HTMLElement) { + delete element.dataset.mentionKind; + element.classList.remove( + "paperclip-mention-chip", + "paperclip-mention-chip--agent", + "paperclip-mention-chip--project", + "paperclip-project-mention-chip", + ); + element.removeAttribute("contenteditable"); + element.style.removeProperty("border-color"); + element.style.removeProperty("background-color"); + element.style.removeProperty("color"); + element.style.removeProperty("--paperclip-mention-project-color"); + element.style.removeProperty("--paperclip-mention-icon-mask"); +} + +function projectMentionColors(color: string): Pick { + const rgb = hexToRgb(color); + if (!rgb) return {}; + 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", + }; +} + +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 buildAgentIconMask(iconName: string | null): string | null { + const cacheKey = iconName ?? "__default__"; + const cached = iconMaskCache.get(cacheKey); + if (cached) return cached; + + const Icon = getAgentIcon(iconName); + const rendered = ( + Icon as unknown as { + render: ( + props: Record, + ref: unknown, + ) => { props?: { iconNode?: Array<[string, Record]> } }; + } + ).render({ size: 12, strokeWidth: 2 }, null); + const iconNode = rendered?.props?.iconNode; + if (!Array.isArray(iconNode) || iconNode.length === 0) return null; + + const body = iconNode.map(([tag, attrs]) => { + const attrString = Object.entries(attrs) + .filter(([key]) => key !== "key") + .map(([key, value]) => `${key}="${escapeAttribute(String(value))}"`) + .join(" "); + return `<${tag}${attrString ? ` ${attrString}` : ""}>`; + }).join(""); + + const svg = + `${body}`; + const url = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; + iconMaskCache.set(cacheKey, url); + return url; +} + +function escapeAttribute(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 2c6ae104..ecdba988 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -341,6 +341,8 @@ export function IssueDetail() { id: `agent:${agent.id}`, name: agent.name, kind: "agent", + agentId: agent.id, + agentIcon: agent.icon, }); } for (const project of orderedProjects) {