diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8f35bf12..cc6ea42c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -600,14 +600,19 @@ export { deriveProjectUrlKey, normalizeProjectUrlKey, hasNonAsciiContent } from export { AGENT_MENTION_SCHEME, PROJECT_MENTION_SCHEME, + SKILL_MENTION_SCHEME, buildAgentMentionHref, buildProjectMentionHref, + buildSkillMentionHref, extractAgentMentionIds, + extractSkillMentionIds, parseAgentMentionHref, parseProjectMentionHref, + parseSkillMentionHref, extractProjectMentionIds, type ParsedAgentMention, type ParsedProjectMention, + type ParsedSkillMention, } from "./project-mentions.js"; export { diff --git a/packages/shared/src/project-mentions.test.ts b/packages/shared/src/project-mentions.test.ts index 55f27369..5a156959 100644 --- a/packages/shared/src/project-mentions.test.ts +++ b/packages/shared/src/project-mentions.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it } from "vitest"; import { buildAgentMentionHref, buildProjectMentionHref, + buildSkillMentionHref, extractAgentMentionIds, extractProjectMentionIds, + extractSkillMentionIds, parseAgentMentionHref, parseProjectMentionHref, + parseSkillMentionHref, } from "./project-mentions.js"; describe("project-mentions", () => { @@ -26,4 +29,13 @@ describe("project-mentions", () => { }); expect(extractAgentMentionIds(`[@CodexCoder](${href})`)).toEqual(["agent-123"]); }); + + it("round-trips skill mentions with slug metadata", () => { + const href = buildSkillMentionHref("skill-123", "release-changelog"); + expect(parseSkillMentionHref(href)).toEqual({ + skillId: "skill-123", + slug: "release-changelog", + }); + expect(extractSkillMentionIds(`[/release-changelog](${href})`)).toEqual(["skill-123"]); + }); }); diff --git a/packages/shared/src/project-mentions.ts b/packages/shared/src/project-mentions.ts index 66be8948..117fad39 100644 --- a/packages/shared/src/project-mentions.ts +++ b/packages/shared/src/project-mentions.ts @@ -1,5 +1,6 @@ export const PROJECT_MENTION_SCHEME = "project://"; export const AGENT_MENTION_SCHEME = "agent://"; +export const SKILL_MENTION_SCHEME = "skill://"; const HEX_COLOR_RE = /^[0-9a-f]{6}$/i; const HEX_COLOR_SHORT_RE = /^[0-9a-f]{3}$/i; @@ -7,7 +8,9 @@ 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 SKILL_MENTION_LINK_RE = /\[[^\]]*]\((skill:\/\/[^)\s]+)\)/gi; const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i; +const SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i; export interface ParsedProjectMention { projectId: string; @@ -19,6 +22,11 @@ export interface ParsedAgentMention { icon: string | null; } +export interface ParsedSkillMention { + skillId: string; + slug: string | null; +} + function normalizeHexColor(input: string | null | undefined): string | null { if (!input) return null; const trimmed = input.trim(); @@ -103,6 +111,36 @@ export function parseAgentMentionHref(href: string): ParsedAgentMention | null { }; } +export function buildSkillMentionHref(skillId: string, slug?: string | null): string { + const trimmedSkillId = skillId.trim(); + const normalizedSlug = normalizeSkillSlug(slug ?? null); + if (!normalizedSlug) { + return `${SKILL_MENTION_SCHEME}${trimmedSkillId}`; + } + return `${SKILL_MENTION_SCHEME}${trimmedSkillId}?s=${encodeURIComponent(normalizedSlug)}`; +} + +export function parseSkillMentionHref(href: string): ParsedSkillMention | null { + if (!href.startsWith(SKILL_MENTION_SCHEME)) return null; + + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + + if (url.protocol !== "skill:") return null; + + const skillId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim(); + if (!skillId) return null; + + return { + skillId, + slug: normalizeSkillSlug(url.searchParams.get("s") ?? url.searchParams.get("slug")), + }; +} + export function extractProjectMentionIds(markdown: string): string[] { if (!markdown) return []; const ids = new Set(); @@ -127,9 +165,28 @@ export function extractAgentMentionIds(markdown: string): string[] { return [...ids]; } +export function extractSkillMentionIds(markdown: string): string[] { + if (!markdown) return []; + const ids = new Set(); + const re = new RegExp(SKILL_MENTION_LINK_RE); + let match: RegExpExecArray | null; + while ((match = re.exec(markdown)) !== null) { + const parsed = parseSkillMentionHref(match[1]); + if (parsed) ids.add(parsed.skillId); + } + 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; } + +function normalizeSkillSlug(input: string | null | undefined): string | null { + if (!input) return null; + const trimmed = input.trim().toLowerCase(); + if (!trimmed || !SKILL_SLUG_RE.test(trimmed)) return null; + return trimmed; +} diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 5794f989..fa8d46de 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; -import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared"; +import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared"; import { ThemeProvider } from "../context/ThemeContext"; import { MarkdownBody } from "./MarkdownBody"; @@ -30,11 +30,11 @@ describe("MarkdownBody", () => { expect(html).toContain('alt="Org chart"'); }); - it("renders agent and project mentions as chips", () => { + it("renders agent, project, and skill mentions as chips", () => { const html = renderToStaticMarkup( - {`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")})`} + {`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`} , ); @@ -45,5 +45,7 @@ describe("MarkdownBody", () => { expect(html).toContain('href="/projects/project-456"'); expect(html).toContain('data-mention-kind="project"'); expect(html).toContain("--paperclip-mention-project-color:#336699"); + expect(html).toContain('href="/skills/skill-789"'); + expect(html).toContain('data-mention-kind="skill"'); }); }); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index e00afc84..0a933c66 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -106,7 +106,9 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB if (parsed) { const targetHref = parsed.kind === "project" ? `/projects/${parsed.projectId}` - : `/agents/${parsed.agentId}`; + : parsed.kind === "skill" + ? `/skills/${parsed.skillId}` + : `/agents/${parsed.agentId}`; return ( = 0; i--) { const ch = text[i]; - if (ch === "@") { + if (ch === "@" || ch === "/") { if (i === 0 || /\s/.test(text[i - 1])) { atPos = i; + trigger = ch === "@" ? "mention" : "skill"; + marker = ch; } break; } @@ -171,6 +181,8 @@ function detectMention(container: HTMLElement): MentionState | null { const containerRect = container.getBoundingClientRect(); return { + trigger: trigger ?? "mention", + marker: marker ?? "@", query, top: rect.bottom - containerRect.top, left: rect.left - containerRect.left, @@ -242,10 +254,18 @@ function mentionMarkdown(option: MentionOption): string { return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `; } -/** Replace `@` in the markdown string with the selected mention token. */ -function applyMention(markdown: string, query: string, option: MentionOption): string { - const search = `@${query}`; - const replacement = mentionMarkdown(option); +function skillMarkdown(option: SkillCommandOption): string { + return `[/${option.slug}](${option.href}) `; +} + +function autocompleteMarkdown(option: AutocompleteOption): string { + return option.kind === "skill" ? skillMarkdown(option) : mentionMarkdown(option); +} + +/** Replace the active autocomplete token in the markdown string with the selected token. */ +function applyMention(markdown: string, state: MentionState, option: AutocompleteOption): string { + const search = `${state.marker}${state.query}`; + const replacement = autocompleteMarkdown(option); const idx = markdown.lastIndexOf(search); if (idx === -1) return markdown; return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length); @@ -265,6 +285,7 @@ export const MarkdownEditor = forwardRef mentions, onSubmit, }: MarkdownEditorProps, forwardedRef) { + const { slashCommands } = useEditorAutocomplete(); const containerRef = useRef(null); const ref = useRef(null); const valueRef = useRef(value); @@ -289,7 +310,10 @@ export const MarkdownEditor = forwardRef const [mentionState, setMentionState] = useState(null); const mentionStateRef = useRef(null); const [mentionIndex, setMentionIndex] = useState(0); - const mentionActive = mentionState !== null && mentions && mentions.length > 0; + const mentionActive = mentionState !== null && ( + (mentionState.trigger === "mention" && Boolean(mentions?.length)) + || (mentionState.trigger === "skill" && slashCommands.length > 0) + ); const mentionOptionByKey = useMemo(() => { const map = new Map(); for (const mention of mentions ?? []) { @@ -304,11 +328,20 @@ export const MarkdownEditor = forwardRef return map; }, [mentions]); - const filteredMentions = useMemo(() => { - if (!mentionState || !mentions) return []; - const q = mentionState.query.toLowerCase(); + const filteredMentions = useMemo(() => { + if (!mentionState) return []; + const q = mentionState.query.trim().toLowerCase(); + if (mentionState.trigger === "skill") { + return slashCommands + .filter((command) => { + if (!q) return true; + return command.aliases.some((alias) => alias.toLowerCase().includes(q)); + }) + .slice(0, 8); + } + if (!mentions) return []; return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8); - }, [mentionState?.query, mentions]); + }, [mentionState, mentions, slashCommands]); const setEditorRef = useCallback((instance: MDXEditorMethods | null) => { ref.current = instance; @@ -420,6 +453,11 @@ export const MarkdownEditor = forwardRef continue; } + if (parsed.kind === "skill") { + applyMentionChipDecoration(link, parsed); + continue; + } + const option = mentionOptionByKey.get(`agent:${parsed.agentId}`); applyMentionChipDecoration(link, { ...parsed, @@ -430,12 +468,30 @@ export const MarkdownEditor = forwardRef // Mention detection: listen for selection changes and input events const checkMention = useCallback(() => { - if (!mentions || mentions.length === 0 || !containerRef.current) { + if (!containerRef.current || isSelectionInsideCodeLikeElement(containerRef.current)) { mentionStateRef.current = null; setMentionState(null); return; } const result = detectMention(containerRef.current); + if ( + result + && result.trigger === "mention" + && (!mentions || mentions.length === 0) + ) { + mentionStateRef.current = null; + setMentionState(null); + return; + } + if ( + result + && result.trigger === "skill" + && slashCommands.length === 0 + ) { + mentionStateRef.current = null; + setMentionState(null); + return; + } mentionStateRef.current = result; if (result) { setMentionState(result); @@ -443,10 +499,10 @@ export const MarkdownEditor = forwardRef } else { setMentionState(null); } - }, [mentions]); + }, [mentions, slashCommands.length]); useEffect(() => { - if (!mentions || mentions.length === 0) return; + if ((!mentions || mentions.length === 0) && slashCommands.length === 0) return; const el = containerRef.current; // Listen for input events on the container so mention detection @@ -459,7 +515,7 @@ export const MarkdownEditor = forwardRef document.removeEventListener("selectionchange", checkMention); el?.removeEventListener("input", onInput, true); }; - }, [checkMention, mentions]); + }, [checkMention, mentions, slashCommands.length]); useEffect(() => { if (!mentionActive) return; @@ -496,13 +552,13 @@ export const MarkdownEditor = forwardRef }, [decorateProjectMentions, value]); const selectMention = useCallback( - (option: MentionOption) => { + (option: AutocompleteOption) => { // Read from ref to avoid stale-closure issues (selectionchange can // update state between the last render and this callback firing). const state = mentionStateRef.current; if (!state) return; const current = latestValueRef.current; - const next = applyMention(current, state.query, option); + const next = applyMention(current, state, option); if (next !== current) { latestValueRef.current = next; echoIgnoreMarkdownRef.current = next; @@ -517,17 +573,20 @@ export const MarkdownEditor = forwardRef 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 mentionHref = option.kind === "skill" + ? option.href + : option.kind === "project" && option.projectId + ? buildProjectMentionHref(option.projectId, option.projectColor ?? null) + : buildAgentMentionHref( + option.agentId ?? option.id.replace(/^agent:/, ""), + option.agentIcon ?? null, + ); + const expectedLabel = option.kind === "skill" ? `/${option.slug}` : `@${option.name}`; 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}`; + return href === mentionHref && link.textContent === expectedLabel; }); const containerRect = containerRef.current?.getBoundingClientRect(); const target = matchingMentions.sort((a, b) => { @@ -729,7 +788,9 @@ export const MarkdownEditor = forwardRef }} onMouseEnter={() => setMentionIndex(i)} > - {option.kind === "project" && option.projectId ? ( + {option.kind === "skill" ? ( + + ) : option.kind === "project" && option.projectId ? ( className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> )} - {option.name} + {option.kind === "skill" ? `/${option.slug}` : option.name} {option.kind === "project" && option.projectId && ( Project )} + {option.kind === "skill" && ( + + Skill + + )} ))} , diff --git a/ui/src/context/EditorAutocompleteContext.tsx b/ui/src/context/EditorAutocompleteContext.tsx new file mode 100644 index 00000000..8d6c0004 --- /dev/null +++ b/ui/src/context/EditorAutocompleteContext.tsx @@ -0,0 +1,61 @@ +import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { buildSkillMentionHref } from "@paperclipai/shared"; +import { companySkillsApi } from "../api/companySkills"; +import { useCompany } from "./CompanyContext"; +import { queryKeys } from "../lib/queryKeys"; + +export interface SkillCommandOption { + id: string; + kind: "skill"; + skillId: string; + key: string; + name: string; + slug: string; + description: string | null; + href: string; + aliases: string[]; +} + +interface EditorAutocompleteContextValue { + slashCommands: SkillCommandOption[]; +} + +const EditorAutocompleteContext = createContext({ + slashCommands: [], +}); + +export function EditorAutocompleteProvider({ children }: { children: ReactNode }) { + const { selectedCompanyId } = useCompany(); + const { data: companySkills = [] } = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.companySkills.list(selectedCompanyId) + : ["company-skills", "__none__"], + queryFn: () => companySkillsApi.list(selectedCompanyId!), + enabled: Boolean(selectedCompanyId), + }); + + const value = useMemo(() => ({ + slashCommands: companySkills.map((skill) => ({ + id: `skill:${skill.id}`, + kind: "skill", + skillId: skill.id, + key: skill.key, + name: skill.name, + slug: skill.slug, + description: skill.description ?? null, + href: buildSkillMentionHref(skill.id, skill.slug), + aliases: [skill.slug, skill.name, skill.key], + })), + }), [companySkills]); + + return ( + + {children} + + ); +} + +export function useEditorAutocomplete() { + return useContext(EditorAutocompleteContext); +} diff --git a/ui/src/lib/mention-chips.ts b/ui/src/lib/mention-chips.ts index fe043100..7fecbb89 100644 --- a/ui/src/lib/mention-chips.ts +++ b/ui/src/lib/mention-chips.ts @@ -1,5 +1,5 @@ import type { CSSProperties } from "react"; -import { parseAgentMentionHref, parseProjectMentionHref } from "@paperclipai/shared"; +import { parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref } from "@paperclipai/shared"; import { getAgentIcon } from "./agent-icons"; import { hexToRgb, pickTextColorForPillBg } from "./color-contrast"; @@ -13,6 +13,11 @@ export type ParsedMentionChip = kind: "project"; projectId: string; color: string | null; + } + | { + kind: "skill"; + skillId: string; + slug: string | null; }; const iconMaskCache = new Map(); @@ -36,6 +41,15 @@ export function parseMentionChipHref(href: string): ParsedMentionChip | null { }; } + const skill = parseSkillMentionHref(href); + if (skill) { + return { + kind: "skill", + skillId: skill.skillId, + slug: skill.slug, + }; + } + return null; } @@ -86,6 +100,7 @@ export function clearMentionChipDecoration(element: HTMLElement) { "paperclip-mention-chip", "paperclip-mention-chip--agent", "paperclip-mention-chip--project", + "paperclip-mention-chip--skill", "paperclip-project-mention-chip", ); element.removeAttribute("contenteditable"); diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 1292810d..e0efe15e 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -11,6 +11,7 @@ import { BreadcrumbProvider } from "./context/BreadcrumbContext"; import { PanelProvider } from "./context/PanelContext"; import { SidebarProvider } from "./context/SidebarContext"; import { DialogProvider } from "./context/DialogContext"; +import { EditorAutocompleteProvider } from "./context/EditorAutocompleteContext"; import { ToastProvider } from "./context/ToastContext"; import { ThemeProvider } from "./context/ThemeContext"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -42,23 +43,25 @@ createRoot(document.getElementById("root")!).render( - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + +