Fix markdown mention chips
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
cd7c6ee751
commit
8232456ce8
14 changed files with 527 additions and 264 deletions
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
29
packages/shared/src/project-mentions.test.ts
Normal file
29
packages/shared/src/project-mentions.test.ts
Normal file
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string>();
|
||||
|
|
@ -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<string>();
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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<string>(explicitAgentMentionIds);
|
||||
for (const agent of rows) {
|
||||
if (tokens.has(agent.name.toLowerCase())) {
|
||||
resolved.add(agent.id);
|
||||
}
|
||||
}
|
||||
return [...resolved];
|
||||
},
|
||||
|
||||
findMentionedProjectIds: async (issueId: string) => {
|
||||
|
|
|
|||
|
|
@ -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<AgentIconName, LucideIcon> = {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<ThemeProvider>
|
||||
<MarkdownBody>
|
||||
{`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")})`}
|
||||
</MarkdownBody>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
|
|
@ -125,16 +102,23 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
|
|||
return <pre {...preProps}>{preChildren}</pre>;
|
||||
},
|
||||
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 (
|
||||
<a
|
||||
href={`/projects/${parsed.projectId}`}
|
||||
className="paperclip-project-mention-chip"
|
||||
style={mentionChipStyle(parsed.color)}
|
||||
href={targetHref}
|
||||
className={cn(
|
||||
"paperclip-mention-chip",
|
||||
`paperclip-mention-chip--${parsed.kind}`,
|
||||
parsed.kind === "project" && "paperclip-project-mention-chip",
|
||||
)}
|
||||
data-mention-kind={parsed.kind}
|
||||
style={mentionChipInlineStyle(parsed)}
|
||||
>
|
||||
{label}
|
||||
{linkChildren}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
@ -160,7 +144,7 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
|
|||
className,
|
||||
)}
|
||||
>
|
||||
<Markdown remarkPlugins={[remarkGfm]} components={components}>
|
||||
<Markdown remarkPlugins={[remarkGfm]} components={components} urlTransform={(url) => url}>
|
||||
{children}
|
||||
</Markdown>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 `@<query>` 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<MarkdownEditorRef, MarkdownEditorProps>(function MarkdownEditor({
|
||||
|
|
@ -221,11 +200,15 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
const mentionStateRef = useRef<MentionState | null>(null);
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const mentionActive = mentionState !== null && mentions && mentions.length > 0;
|
||||
const projectColorById = useMemo(() => {
|
||||
const map = new Map<string, string | null>();
|
||||
const mentionOptionByKey = useMemo(() => {
|
||||
const map = new Map<string, MentionOption>();
|
||||
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<MarkdownEditorRef, MarkdownEditorProps>
|
|||
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<MarkdownEditorRef, MarkdownEditorProps>
|
|||
// 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<MarkdownEditorRef, MarkdownEditorProps>
|
|||
style={{ backgroundColor: option.projectColor ?? "#64748b" }}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">@</span>
|
||||
<AgentIcon
|
||||
icon={option.agentIcon}
|
||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
<span>{option.name}</span>
|
||||
{option.kind === "project" && option.projectId && (
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
98
ui/src/lib/agent-icons.ts
Normal file
98
ui/src/lib/agent-icons.ts
Normal file
|
|
@ -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<AgentIconName, LucideIcon> = {
|
||||
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];
|
||||
}
|
||||
160
ui/src/lib/mention-chips.ts
Normal file
160
ui/src/lib/mention-chips.ts
Normal file
|
|
@ -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<string, string>();
|
||||
|
||||
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<string, string> = {};
|
||||
|
||||
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<string, string>)[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<CSSProperties, "borderColor" | "backgroundColor" | "color"> {
|
||||
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<string, unknown>,
|
||||
ref: unknown,
|
||||
) => { props?: { iconNode?: Array<[string, Record<string, string>]> } };
|
||||
}
|
||||
).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}` : ""}></${tag}>`;
|
||||
}).join("");
|
||||
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" ` +
|
||||
`fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" ` +
|
||||
`stroke-linejoin="round">${body}</svg>`;
|
||||
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(">", ">");
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue