Add skill slash-command autocomplete

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-04 17:00:40 -05:00
parent fe61e650c2
commit 94d4a01b76
9 changed files with 271 additions and 48 deletions

View file

@ -600,14 +600,19 @@ export { deriveProjectUrlKey, normalizeProjectUrlKey, hasNonAsciiContent } from
export { export {
AGENT_MENTION_SCHEME, AGENT_MENTION_SCHEME,
PROJECT_MENTION_SCHEME, PROJECT_MENTION_SCHEME,
SKILL_MENTION_SCHEME,
buildAgentMentionHref, buildAgentMentionHref,
buildProjectMentionHref, buildProjectMentionHref,
buildSkillMentionHref,
extractAgentMentionIds, extractAgentMentionIds,
extractSkillMentionIds,
parseAgentMentionHref, parseAgentMentionHref,
parseProjectMentionHref, parseProjectMentionHref,
parseSkillMentionHref,
extractProjectMentionIds, extractProjectMentionIds,
type ParsedAgentMention, type ParsedAgentMention,
type ParsedProjectMention, type ParsedProjectMention,
type ParsedSkillMention,
} from "./project-mentions.js"; } from "./project-mentions.js";
export { export {

View file

@ -2,10 +2,13 @@ import { describe, expect, it } from "vitest";
import { import {
buildAgentMentionHref, buildAgentMentionHref,
buildProjectMentionHref, buildProjectMentionHref,
buildSkillMentionHref,
extractAgentMentionIds, extractAgentMentionIds,
extractProjectMentionIds, extractProjectMentionIds,
extractSkillMentionIds,
parseAgentMentionHref, parseAgentMentionHref,
parseProjectMentionHref, parseProjectMentionHref,
parseSkillMentionHref,
} from "./project-mentions.js"; } from "./project-mentions.js";
describe("project-mentions", () => { describe("project-mentions", () => {
@ -26,4 +29,13 @@ describe("project-mentions", () => {
}); });
expect(extractAgentMentionIds(`[@CodexCoder](${href})`)).toEqual(["agent-123"]); 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"]);
});
}); });

View file

@ -1,5 +1,6 @@
export const PROJECT_MENTION_SCHEME = "project://"; export const PROJECT_MENTION_SCHEME = "project://";
export const AGENT_MENTION_SCHEME = "agent://"; export const AGENT_MENTION_SCHEME = "agent://";
export const SKILL_MENTION_SCHEME = "skill://";
const HEX_COLOR_RE = /^[0-9a-f]{6}$/i; const HEX_COLOR_RE = /^[0-9a-f]{6}$/i;
const HEX_COLOR_SHORT_RE = /^[0-9a-f]{3}$/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 HEX_COLOR_SHORT_WITH_HASH_RE = /^#[0-9a-f]{3}$/i;
const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi; const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi;
const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\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 AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i;
const SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i;
export interface ParsedProjectMention { export interface ParsedProjectMention {
projectId: string; projectId: string;
@ -19,6 +22,11 @@ export interface ParsedAgentMention {
icon: string | null; icon: string | null;
} }
export interface ParsedSkillMention {
skillId: string;
slug: string | null;
}
function normalizeHexColor(input: string | null | undefined): string | null { function normalizeHexColor(input: string | null | undefined): string | null {
if (!input) return null; if (!input) return null;
const trimmed = input.trim(); 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[] { export function extractProjectMentionIds(markdown: string): string[] {
if (!markdown) return []; if (!markdown) return [];
const ids = new Set<string>(); const ids = new Set<string>();
@ -127,9 +165,28 @@ export function extractAgentMentionIds(markdown: string): string[] {
return [...ids]; return [...ids];
} }
export function extractSkillMentionIds(markdown: string): string[] {
if (!markdown) return [];
const ids = new Set<string>();
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 { function normalizeAgentIcon(input: string | null | undefined): string | null {
if (!input) return null; if (!input) return null;
const trimmed = input.trim().toLowerCase(); const trimmed = input.trim().toLowerCase();
if (!trimmed || !AGENT_ICON_NAME_RE.test(trimmed)) return null; if (!trimmed || !AGENT_ICON_NAME_RE.test(trimmed)) return null;
return trimmed; 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;
}

View file

@ -2,7 +2,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server"; 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 { ThemeProvider } from "../context/ThemeContext";
import { MarkdownBody } from "./MarkdownBody"; import { MarkdownBody } from "./MarkdownBody";
@ -30,11 +30,11 @@ describe("MarkdownBody", () => {
expect(html).toContain('alt="Org chart"'); 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( const html = renderToStaticMarkup(
<ThemeProvider> <ThemeProvider>
<MarkdownBody> <MarkdownBody>
{`[@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")})`}
</MarkdownBody> </MarkdownBody>
</ThemeProvider>, </ThemeProvider>,
); );
@ -45,5 +45,7 @@ describe("MarkdownBody", () => {
expect(html).toContain('href="/projects/project-456"'); expect(html).toContain('href="/projects/project-456"');
expect(html).toContain('data-mention-kind="project"'); expect(html).toContain('data-mention-kind="project"');
expect(html).toContain("--paperclip-mention-project-color:#336699"); expect(html).toContain("--paperclip-mention-project-color:#336699");
expect(html).toContain('href="/skills/skill-789"');
expect(html).toContain('data-mention-kind="skill"');
}); });
}); });

View file

@ -106,7 +106,9 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
if (parsed) { if (parsed) {
const targetHref = parsed.kind === "project" const targetHref = parsed.kind === "project"
? `/projects/${parsed.projectId}` ? `/projects/${parsed.projectId}`
: `/agents/${parsed.agentId}`; : parsed.kind === "skill"
? `/skills/${parsed.skillId}`
: `/agents/${parsed.agentId}`;
return ( return (
<a <a
href={targetHref} href={targetHref}

View file

@ -29,6 +29,7 @@ import {
type RealmPlugin, type RealmPlugin,
} from "@mdxeditor/editor"; } from "@mdxeditor/editor";
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared"; import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
import { Boxes } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker"; import { AgentIcon } from "./AgentIconPicker";
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips"; import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node"; import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
@ -37,6 +38,7 @@ import { looksLikeMarkdownPaste } from "../lib/markdownPaste";
import { normalizeMarkdown } from "../lib/normalize-markdown"; import { normalizeMarkdown } from "../lib/normalize-markdown";
import { pasteNormalizationPlugin } from "../lib/paste-normalization"; import { pasteNormalizationPlugin } from "../lib/paste-normalization";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useEditorAutocomplete, type SkillCommandOption } from "../context/EditorAutocompleteContext";
/* ---- Mention types ---- */ /* ---- Mention types ---- */
@ -84,6 +86,8 @@ function isSafeMarkdownLinkUrl(url: string): boolean {
/* ---- Mention detection helpers ---- */ /* ---- Mention detection helpers ---- */
interface MentionState { interface MentionState {
trigger: "mention" | "skill";
marker: "@" | "/";
query: string; query: string;
top: number; top: number;
left: number; left: number;
@ -95,6 +99,8 @@ interface MentionState {
endPos: number; endPos: number;
} }
type AutocompleteOption = MentionOption | SkillCommandOption;
interface MentionMenuViewport { interface MentionMenuViewport {
offsetLeft: number; offsetLeft: number;
offsetTop: number; offsetTop: number;
@ -146,13 +152,17 @@ function detectMention(container: HTMLElement): MentionState | null {
const text = textNode.textContent ?? ""; const text = textNode.textContent ?? "";
const offset = range.startOffset; const offset = range.startOffset;
// Walk backwards from cursor to find @ // Walk backwards from cursor to find an autocomplete trigger.
let atPos = -1; let atPos = -1;
let trigger: MentionState["trigger"] | null = null;
let marker: MentionState["marker"] | null = null;
for (let i = offset - 1; i >= 0; i--) { for (let i = offset - 1; i >= 0; i--) {
const ch = text[i]; const ch = text[i];
if (ch === "@") { if (ch === "@" || ch === "/") {
if (i === 0 || /\s/.test(text[i - 1])) { if (i === 0 || /\s/.test(text[i - 1])) {
atPos = i; atPos = i;
trigger = ch === "@" ? "mention" : "skill";
marker = ch;
} }
break; break;
} }
@ -171,6 +181,8 @@ function detectMention(container: HTMLElement): MentionState | null {
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
return { return {
trigger: trigger ?? "mention",
marker: marker ?? "@",
query, query,
top: rect.bottom - containerRect.top, top: rect.bottom - containerRect.top,
left: rect.left - containerRect.left, left: rect.left - containerRect.left,
@ -242,10 +254,18 @@ function mentionMarkdown(option: MentionOption): string {
return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `; return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `;
} }
/** Replace `@<query>` in the markdown string with the selected mention token. */ function skillMarkdown(option: SkillCommandOption): string {
function applyMention(markdown: string, query: string, option: MentionOption): string { return `[/${option.slug}](${option.href}) `;
const search = `@${query}`; }
const replacement = mentionMarkdown(option);
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); const idx = markdown.lastIndexOf(search);
if (idx === -1) return markdown; if (idx === -1) return markdown;
return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length); return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length);
@ -265,6 +285,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
mentions, mentions,
onSubmit, onSubmit,
}: MarkdownEditorProps, forwardedRef) { }: MarkdownEditorProps, forwardedRef) {
const { slashCommands } = useEditorAutocomplete();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const ref = useRef<MDXEditorMethods>(null); const ref = useRef<MDXEditorMethods>(null);
const valueRef = useRef(value); const valueRef = useRef(value);
@ -289,7 +310,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const [mentionState, setMentionState] = useState<MentionState | null>(null); const [mentionState, setMentionState] = useState<MentionState | null>(null);
const mentionStateRef = useRef<MentionState | null>(null); const mentionStateRef = useRef<MentionState | null>(null);
const [mentionIndex, setMentionIndex] = useState(0); 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 mentionOptionByKey = useMemo(() => {
const map = new Map<string, MentionOption>(); const map = new Map<string, MentionOption>();
for (const mention of mentions ?? []) { for (const mention of mentions ?? []) {
@ -304,11 +328,20 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
return map; return map;
}, [mentions]); }, [mentions]);
const filteredMentions = useMemo(() => { const filteredMentions = useMemo<AutocompleteOption[]>(() => {
if (!mentionState || !mentions) return []; if (!mentionState) return [];
const q = mentionState.query.toLowerCase(); 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); return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8);
}, [mentionState?.query, mentions]); }, [mentionState, mentions, slashCommands]);
const setEditorRef = useCallback((instance: MDXEditorMethods | null) => { const setEditorRef = useCallback((instance: MDXEditorMethods | null) => {
ref.current = instance; ref.current = instance;
@ -420,6 +453,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
continue; continue;
} }
if (parsed.kind === "skill") {
applyMentionChipDecoration(link, parsed);
continue;
}
const option = mentionOptionByKey.get(`agent:${parsed.agentId}`); const option = mentionOptionByKey.get(`agent:${parsed.agentId}`);
applyMentionChipDecoration(link, { applyMentionChipDecoration(link, {
...parsed, ...parsed,
@ -430,12 +468,30 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
// Mention detection: listen for selection changes and input events // Mention detection: listen for selection changes and input events
const checkMention = useCallback(() => { const checkMention = useCallback(() => {
if (!mentions || mentions.length === 0 || !containerRef.current) { if (!containerRef.current || isSelectionInsideCodeLikeElement(containerRef.current)) {
mentionStateRef.current = null; mentionStateRef.current = null;
setMentionState(null); setMentionState(null);
return; return;
} }
const result = detectMention(containerRef.current); 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; mentionStateRef.current = result;
if (result) { if (result) {
setMentionState(result); setMentionState(result);
@ -443,10 +499,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
} else { } else {
setMentionState(null); setMentionState(null);
} }
}, [mentions]); }, [mentions, slashCommands.length]);
useEffect(() => { useEffect(() => {
if (!mentions || mentions.length === 0) return; if ((!mentions || mentions.length === 0) && slashCommands.length === 0) return;
const el = containerRef.current; const el = containerRef.current;
// Listen for input events on the container so mention detection // Listen for input events on the container so mention detection
@ -459,7 +515,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
document.removeEventListener("selectionchange", checkMention); document.removeEventListener("selectionchange", checkMention);
el?.removeEventListener("input", onInput, true); el?.removeEventListener("input", onInput, true);
}; };
}, [checkMention, mentions]); }, [checkMention, mentions, slashCommands.length]);
useEffect(() => { useEffect(() => {
if (!mentionActive) return; if (!mentionActive) return;
@ -496,13 +552,13 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
}, [decorateProjectMentions, value]); }, [decorateProjectMentions, value]);
const selectMention = useCallback( const selectMention = useCallback(
(option: MentionOption) => { (option: AutocompleteOption) => {
// Read from ref to avoid stale-closure issues (selectionchange can // Read from ref to avoid stale-closure issues (selectionchange can
// update state between the last render and this callback firing). // update state between the last render and this callback firing).
const state = mentionStateRef.current; const state = mentionStateRef.current;
if (!state) return; if (!state) return;
const current = latestValueRef.current; const current = latestValueRef.current;
const next = applyMention(current, state.query, option); const next = applyMention(current, state, option);
if (next !== current) { if (next !== current) {
latestValueRef.current = next; latestValueRef.current = next;
echoIgnoreMarkdownRef.current = next; echoIgnoreMarkdownRef.current = next;
@ -517,17 +573,20 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
decorateProjectMentions(); decorateProjectMentions();
editable.focus(); editable.focus();
const mentionHref = option.kind === "project" && option.projectId const mentionHref = option.kind === "skill"
? buildProjectMentionHref(option.projectId, option.projectColor ?? null) ? option.href
: buildAgentMentionHref( : option.kind === "project" && option.projectId
option.agentId ?? option.id.replace(/^agent:/, ""), ? buildProjectMentionHref(option.projectId, option.projectColor ?? null)
option.agentIcon ?? 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")) const matchingMentions = Array.from(editable.querySelectorAll("a"))
.filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement) .filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement)
.filter((link) => { .filter((link) => {
const href = link.getAttribute("href") ?? ""; const href = link.getAttribute("href") ?? "";
return href === mentionHref && link.textContent === `@${option.name}`; return href === mentionHref && link.textContent === expectedLabel;
}); });
const containerRect = containerRef.current?.getBoundingClientRect(); const containerRect = containerRef.current?.getBoundingClientRect();
const target = matchingMentions.sort((a, b) => { const target = matchingMentions.sort((a, b) => {
@ -729,7 +788,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
}} }}
onMouseEnter={() => setMentionIndex(i)} onMouseEnter={() => setMentionIndex(i)}
> >
{option.kind === "project" && option.projectId ? ( {option.kind === "skill" ? (
<Boxes className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : option.kind === "project" && option.projectId ? (
<span <span
className="inline-flex h-2 w-2 rounded-full border border-border/50" className="inline-flex h-2 w-2 rounded-full border border-border/50"
style={{ backgroundColor: option.projectColor ?? "#64748b" }} style={{ backgroundColor: option.projectColor ?? "#64748b" }}
@ -740,12 +801,17 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
className="h-3.5 w-3.5 shrink-0 text-muted-foreground" className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/> />
)} )}
<span>{option.name}</span> <span>{option.kind === "skill" ? `/${option.slug}` : option.name}</span>
{option.kind === "project" && option.projectId && ( {option.kind === "project" && option.projectId && (
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground"> <span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
Project Project
</span> </span>
)} )}
{option.kind === "skill" && (
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
Skill
</span>
)}
</button> </button>
))} ))}
</div>, </div>,

View file

@ -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<EditorAutocompleteContextValue>({
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<EditorAutocompleteContextValue>(() => ({
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 (
<EditorAutocompleteContext.Provider value={value}>
{children}
</EditorAutocompleteContext.Provider>
);
}
export function useEditorAutocomplete() {
return useContext(EditorAutocompleteContext);
}

View file

@ -1,5 +1,5 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { parseAgentMentionHref, parseProjectMentionHref } from "@paperclipai/shared"; import { parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref } from "@paperclipai/shared";
import { getAgentIcon } from "./agent-icons"; import { getAgentIcon } from "./agent-icons";
import { hexToRgb, pickTextColorForPillBg } from "./color-contrast"; import { hexToRgb, pickTextColorForPillBg } from "./color-contrast";
@ -13,6 +13,11 @@ export type ParsedMentionChip =
kind: "project"; kind: "project";
projectId: string; projectId: string;
color: string | null; color: string | null;
}
| {
kind: "skill";
skillId: string;
slug: string | null;
}; };
const iconMaskCache = new Map<string, string>(); const iconMaskCache = new Map<string, string>();
@ -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; return null;
} }
@ -86,6 +100,7 @@ export function clearMentionChipDecoration(element: HTMLElement) {
"paperclip-mention-chip", "paperclip-mention-chip",
"paperclip-mention-chip--agent", "paperclip-mention-chip--agent",
"paperclip-mention-chip--project", "paperclip-mention-chip--project",
"paperclip-mention-chip--skill",
"paperclip-project-mention-chip", "paperclip-project-mention-chip",
); );
element.removeAttribute("contenteditable"); element.removeAttribute("contenteditable");

View file

@ -11,6 +11,7 @@ import { BreadcrumbProvider } from "./context/BreadcrumbContext";
import { PanelProvider } from "./context/PanelContext"; import { PanelProvider } from "./context/PanelContext";
import { SidebarProvider } from "./context/SidebarContext"; import { SidebarProvider } from "./context/SidebarContext";
import { DialogProvider } from "./context/DialogContext"; import { DialogProvider } from "./context/DialogContext";
import { EditorAutocompleteProvider } from "./context/EditorAutocompleteContext";
import { ToastProvider } from "./context/ToastContext"; import { ToastProvider } from "./context/ToastContext";
import { ThemeProvider } from "./context/ThemeContext"; import { ThemeProvider } from "./context/ThemeContext";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
@ -42,23 +43,25 @@ createRoot(document.getElementById("root")!).render(
<ThemeProvider> <ThemeProvider>
<BrowserRouter> <BrowserRouter>
<CompanyProvider> <CompanyProvider>
<ToastProvider> <EditorAutocompleteProvider>
<LiveUpdatesProvider> <ToastProvider>
<TooltipProvider> <LiveUpdatesProvider>
<BreadcrumbProvider> <TooltipProvider>
<SidebarProvider> <BreadcrumbProvider>
<PanelProvider> <SidebarProvider>
<PluginLauncherProvider> <PanelProvider>
<DialogProvider> <PluginLauncherProvider>
<App /> <DialogProvider>
</DialogProvider> <App />
</PluginLauncherProvider> </DialogProvider>
</PanelProvider> </PluginLauncherProvider>
</SidebarProvider> </PanelProvider>
</BreadcrumbProvider> </SidebarProvider>
</TooltipProvider> </BreadcrumbProvider>
</LiveUpdatesProvider> </TooltipProvider>
</ToastProvider> </LiveUpdatesProvider>
</ToastProvider>
</EditorAutocompleteProvider>
</CompanyProvider> </CompanyProvider>
</BrowserRouter> </BrowserRouter>
</ThemeProvider> </ThemeProvider>