feat(site/src/pages/AgentsPage): add personal skills slash menu (#25386)

> Mux updated this PR on behalf of Mike.

## Context

PR #25066 has merged. This branch is rebased onto `main` and now
contains only the personal skills slash menu UI changes.

## Summary

- Add a `/` slash-trigger menu in the agent chat composer that filters
personal skills by name and description.
- Insert `/<skill-name>` on click, Enter, or Tab selection while
preserving normal composer behavior when the menu is closed.
- Keep Escape dismissal and post-selection suppression scoped to the
current slash trigger, with menu anchor refresh on editor scroll and
resize.
- Share personal skill trigger formatting and parsing helpers with unit
coverage.
- Add Storybook coverage for open, filter, click, keyboard selection,
Escape, error, empty, and filtered-empty states.

## Validation

- pre-commit hook
- `cd site && pnpm exec vitest run --project=unit
src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.test.tsx
src/pages/AgentsPage/utils/personalSkills.test.ts`
- `cd site && pnpm lint:types`
- `cd site && pnpm lint:check`
This commit is contained in:
Michael Suchacz
2026-05-22 01:24:26 +02:00
committed by GitHub
parent 269bd0cb8d
commit fa9eb1ad56
8 changed files with 1116 additions and 8 deletions
@@ -0,0 +1,221 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { ChatMessageInput } from "./ChatMessageInput";
const now = "2026-05-08T00:00:00Z";
const mockSkills: TypesGen.UserSkillMetadata[] = [
{
id: "skill-reviewer",
name: "reviewer",
description: "Review changed files and suggest fixes.",
created_at: now,
updated_at: now,
},
{
id: "skill-docs",
name: "docs",
description: "Draft docs for user-facing behavior.",
created_at: now,
updated_at: now,
},
{
id: "skill-plan",
name: "plan",
description: "",
created_at: now,
updated_at: now,
},
];
const meta: Meta<typeof ChatMessageInput> = {
title: "components/ChatMessageInput/ChatMessageInput",
component: ChatMessageInput,
args: {
"aria-label": "Chat message input",
placeholder: "Message the agent",
personalSkillsOverride: mockSkills,
onChange: fn(),
onEnter: fn(),
},
decorators: [
(Story) => (
<div className="w-[520px] space-y-3 rounded-md border border-border border-solid p-4">
<button type="button" className="text-content-secondary text-sm">
Outside target
</button>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ChatMessageInput>;
const findVisibleText = async (text: string) => {
let visibleElement: HTMLElement | undefined;
await waitFor(() => {
const matches = within(document.body).queryAllByText(text);
visibleElement = matches.find(
(element) => element.getClientRects().length > 0,
);
expect(visibleElement).toBeDefined();
});
return visibleElement as HTMLElement;
};
const expectNoVisibleText = async (text: string) => {
await waitFor(() => {
const matches = within(document.body).queryAllByText(text);
expect(
matches.every((element) => element.getClientRects().length === 0),
).toBe(true);
});
};
const editorFromCanvas = (canvasElement: HTMLElement) => {
const canvas = within(canvasElement);
return canvas.getByTestId("chat-message-input");
};
const typeInEditor = async (canvasElement: HTMLElement, text: string) => {
const editor = editorFromCanvas(canvasElement);
await userEvent.click(editor);
await userEvent.keyboard(text);
return editor;
};
export const Closed: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByTestId("chat-message-input")).toBeVisible();
await expectNoVisibleText("/reviewer");
},
};
export const OpensWithSkills: Story = {
play: async ({ canvasElement }) => {
await typeInEditor(canvasElement, "/");
expect(await findVisibleText("/reviewer")).toBeDefined();
expect(
await findVisibleText("Review changed files and suggest fixes."),
).toBeDefined();
},
};
export const EmptySkills: Story = {
args: {
personalSkillsOverride: [],
onEnter: fn(),
},
play: async ({ canvasElement, args }) => {
const editor = await typeInEditor(canvasElement, "/");
expect(await findVisibleText("No personal skills found.")).toBeDefined();
await userEvent.keyboard("{Enter}");
expect(args.onEnter).not.toHaveBeenCalled();
expect(editor.textContent).toBe("/");
},
};
export const FiltersByQuery: Story = {
play: async ({ canvasElement }) => {
await typeInEditor(canvasElement, "/rev");
expect(await findVisibleText("/reviewer")).toBeDefined();
await expectNoVisibleText("/docs");
},
};
export const EnterSelectsSkill: Story = {
play: async ({ canvasElement }) => {
const editor = await typeInEditor(canvasElement, "/rev");
await findVisibleText("/reviewer");
await userEvent.keyboard("{Enter}");
await waitFor(() => {
expect(editor.textContent).toBe("/reviewer");
});
await expectNoVisibleText("Review changed files and suggest fixes.");
},
};
export const ArrowKeysSelectHighlightedSkill: Story = {
play: async ({ canvasElement }) => {
const editor = await typeInEditor(canvasElement, "/");
await findVisibleText("/docs");
await userEvent.keyboard("{ArrowDown}{Enter}");
await waitFor(() => {
expect(editor.textContent).toBe("/plan");
});
},
};
export const TabSelectsSkill: Story = {
play: async ({ canvasElement }) => {
const editor = await typeInEditor(canvasElement, "/rev");
await findVisibleText("/reviewer");
await userEvent.keyboard("{Tab}");
await waitFor(() => {
expect(editor.textContent).toBe("/reviewer");
});
await expectNoVisibleText("Review changed files and suggest fixes.");
},
};
export const ClickSelectsSkill: Story = {
play: async ({ canvasElement }) => {
const editor = await typeInEditor(canvasElement, "/rev");
await userEvent.click(await findVisibleText("/reviewer"));
await waitFor(() => {
expect(editor.textContent).toBe("/reviewer");
});
await expectNoVisibleText("Review changed files and suggest fixes.");
},
};
export const EmptyDescriptionInsertsNameOnly: Story = {
play: async ({ canvasElement }) => {
const editor = await typeInEditor(canvasElement, "/pla");
await findVisibleText("/plan");
await userEvent.keyboard("{Enter}");
await waitFor(() => {
expect(editor.textContent).toBe("/plan");
});
},
};
export const SlashInsideUrlDoesNotOpen: Story = {
play: async ({ canvasElement }) => {
await typeInEditor(canvasElement, "https://");
await expectNoVisibleText("/reviewer");
},
};
export const EscapeClosesWithoutReplacing: Story = {
play: async ({ canvasElement }) => {
const editor = await typeInEditor(canvasElement, "/");
await findVisibleText("/reviewer");
await userEvent.keyboard("{Escape}");
await expectNoVisibleText("/reviewer");
await waitFor(() => {
expect(editor).toHaveFocus();
});
expect(editor.textContent).toBe("/");
await userEvent.keyboard("r");
await expectNoVisibleText("/reviewer");
expect(editor.textContent).toBe("/r");
},
};
export const OutsideClickClosesWithoutReplacing: Story = {
play: async ({ canvasElement }) => {
const editor = await typeInEditor(canvasElement, "/");
await findVisibleText("/reviewer");
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Outside target" }),
);
await expectNoVisibleText("/reviewer");
expect(editor.textContent).toBe("/");
},
};
@@ -1,8 +1,24 @@
import { render, screen, waitFor } from "@testing-library/react";
import { type FC, useLayoutEffect, useRef, useState } from "react";
import {
type FC,
type ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";
import { QueryClientProvider } from "react-query";
import { describe, expect, it } from "vitest";
import { createTestQueryClient } from "#/testHelpers/renderHelpers";
import { ChatMessageInput, type ChatMessageInputRef } from "./ChatMessageInput";
const renderWithQueryClient = (children: ReactNode) => {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>,
);
};
const InitialValueHarness: FC<{ initialValue: string }> = ({
initialValue,
}) => {
@@ -51,7 +67,9 @@ const QueuedReplacementHarness: FC<{
describe("ChatMessageInput", () => {
it("returns the initial draft before the editor visually hydrates", async () => {
render(<InitialValueHarness initialValue="persisted draft" />);
renderWithQueryClient(
<InitialValueHarness initialValue="persisted draft" />,
);
expect(screen.getByTestId("observed-value")).toHaveTextContent(
"persisted draft",
@@ -64,7 +82,7 @@ describe("ChatMessageInput", () => {
});
it("queues setValue calls made before the editor is ready", async () => {
render(
renderWithQueryClient(
<QueuedReplacementHarness
initialValue="persisted draft"
replacementValue="queued replacement"
@@ -83,7 +101,9 @@ describe("ChatMessageInput", () => {
it("returns updated content even without an external onChange prop", async () => {
const inputRef = { current: null as ChatMessageInputRef | null };
render(<ChatMessageInput ref={inputRef} aria-label="Chat message input" />);
renderWithQueryClient(
<ChatMessageInput ref={inputRef} aria-label="Chat message input" />,
);
await waitFor(() => {
expect(inputRef.current).not.toBeNull();
@@ -9,11 +9,13 @@ import { mergeRegister } from "@lexical/utils";
import {
$createParagraphNode,
$createTextNode,
$getNodeByKey,
$getRoot,
$getSelection,
$insertNodes,
$isParagraphNode,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_HIGH,
FORMAT_ELEMENT_COMMAND,
FORMAT_TEXT_COMMAND,
@@ -28,8 +30,11 @@ import {
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
import type { AgentChatSendShortcut } from "#/api/typesGenerated";
import { useQuery } from "react-query";
import { userSkills } from "#/api/queries/userSkills";
import type * as TypesGen from "#/api/typesGenerated";
import { cn } from "#/utils/cn";
import { isMobileViewport } from "#/utils/mobile";
import {
@@ -37,11 +42,17 @@ import {
MODIFIER_AGENT_CHAT_SEND_SHORTCUT,
} from "../../utils/agentChatSendShortcut";
import { isChatAttachmentFile } from "../../utils/chatAttachments";
import {
filterPersonalSkills,
isPersonalSkillTriggerToken,
personalSkillTriggerText,
} from "../../utils/personalSkills";
import {
$createFileReferenceNode,
FileReferenceNode,
} from "./FileReferenceNode";
import { IOSBackspacePlugin } from "./iosBackspace";
import { PersonalSkillsTriggerMenu } from "./PersonalSkillsTriggerMenu";
import {
createPasteFile,
getPasteDataTransfer,
@@ -49,6 +60,10 @@ import {
isLargePaste,
type PasteCommandEvent,
} from "./pasteHelpers";
import {
type ActiveSkillsTrigger,
SkillsTriggerPlugin,
} from "./SkillsTriggerPlugin";
// Blocks Cmd+B/I/U and element formatting shortcuts so the editor
// stays plain-text only.
@@ -229,7 +244,7 @@ const PasteSanitizationPlugin: FC<{
// Convert large pastes to file attachments, but
// only for normal Cmd+V. Cmd+Shift+V is the
// user’s explicit "paste inline" escape hatch.
// user's explicit "paste inline" escape hatch.
if (
!isPlainTextPaste &&
allowTextAttachmentPaste &&
@@ -271,7 +286,7 @@ const PasteSanitizationPlugin: FC<{
// Shift+Enter is cumbersome on touch keyboards (CODAGT-210).
const EnterKeyPlugin: FC<{
onEnter?: () => void;
sendShortcut: AgentChatSendShortcut;
sendShortcut: TypesGen.AgentChatSendShortcut;
}> = function EnterKeyPlugin({ onEnter, sendShortcut }) {
const [editor] = useLexicalComposerContext();
@@ -481,11 +496,12 @@ interface ChatMessageInputProps
remountKey?: number;
rows?: number;
onEnter?: () => void;
sendShortcut?: AgentChatSendShortcut;
sendShortcut?: TypesGen.AgentChatSendShortcut;
onFilePaste?: (file: File) => void;
allowTextAttachmentPaste?: boolean;
disabled?: boolean;
autoFocus?: boolean;
personalSkillsOverride?: readonly TypesGen.UserSkillMetadata[];
"aria-label"?: string;
}
@@ -503,6 +519,40 @@ const EditableStatePlugin: FC<{ disabled: boolean }> =
return null;
};
type SkillsTriggerLocation = Pick<
ActiveSkillsTrigger,
"nodeKey" | "slashOffset"
>;
const isSameSkillsTriggerLocation = (
a: SkillsTriggerLocation | null,
b: SkillsTriggerLocation | null,
): boolean => {
return Boolean(
a && b && a.nodeKey === b.nodeKey && a.slashOffset === b.slashOffset,
);
};
const isSameSkillsTrigger = (
a: ActiveSkillsTrigger | null,
b: ActiveSkillsTrigger | null,
): boolean => {
if (a === b) {
return true;
}
if (!a || !b) {
return false;
}
return (
a.nodeKey === b.nodeKey &&
a.slashOffset === b.slashOffset &&
a.query === b.query &&
a.anchorRect?.top === b.anchorRect?.top &&
a.anchorRect?.left === b.anchorRect?.left &&
a.anchorRect?.height === b.anchorRect?.height
);
};
const ChatMessageInput = ({
className,
placeholder,
@@ -517,6 +567,7 @@ const ChatMessageInput = ({
allowTextAttachmentPaste,
disabled,
autoFocus,
personalSkillsOverride,
"aria-label": ariaLabel,
ref,
...props
@@ -541,6 +592,87 @@ const ChatMessageInput = ({
const lastKnownValueRef = useRef(initialValue ?? "");
// Queues a setValue call made before the editor ref is ready.
const pendingReplacementRef = useRef<string | null>(null);
const [skillsTrigger, setSkillsTrigger] =
useState<ActiveSkillsTrigger | null>(null);
const suppressedSkillsTriggerRef = useRef<SkillsTriggerLocation | null>(null);
const [skillsMenuSelectedIndex, setSkillsMenuSelectedIndex] = useState(0);
const skillsMenuOpen = Boolean(skillsTrigger);
const skillsQuery = useQuery({
...userSkills(),
enabled: skillsMenuOpen && personalSkillsOverride === undefined,
});
const personalSkills = personalSkillsOverride ?? skillsQuery.data ?? [];
const filteredPersonalSkills = skillsTrigger
? filterPersonalSkills(personalSkills, skillsTrigger.query)
: [];
const selectedSkillIndex =
filteredPersonalSkills.length === 0
? -1
: Math.min(skillsMenuSelectedIndex, filteredPersonalSkills.length - 1);
const handleSkillsTriggerChange = (trigger: ActiveSkillsTrigger | null) => {
if (
trigger &&
isSameSkillsTriggerLocation(trigger, suppressedSkillsTriggerRef.current)
) {
suppressedSkillsTriggerRef.current = null;
return;
}
suppressedSkillsTriggerRef.current = null;
if (isSameSkillsTrigger(trigger, skillsTrigger)) {
return;
}
if (trigger?.query !== skillsTrigger?.query) {
setSkillsMenuSelectedIndex(0);
}
setSkillsTrigger(trigger);
};
const replaceActiveSkillsTrigger = (skill: TypesGen.UserSkillMetadata) => {
const editor = editorRef.current;
const trigger = skillsTrigger;
if (!editor || !trigger) {
setSkillsTrigger(null);
setSkillsMenuSelectedIndex(0);
return;
}
suppressedSkillsTriggerRef.current = trigger;
editor.update(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
return;
}
const anchor = selection.anchor;
if (anchor.type !== "text" || anchor.key !== trigger.nodeKey) {
return;
}
const node = $getNodeByKey(trigger.nodeKey);
if (!$isTextNode(node)) {
return;
}
const caretOffset = anchor.offset;
const token = node
.getTextContent()
.slice(trigger.slashOffset, caretOffset);
if (
caretOffset < trigger.slashOffset ||
!isPersonalSkillTriggerToken(token)
) {
return;
}
selection.anchor.set(trigger.nodeKey, trigger.slashOffset, "text");
selection.focus.set(trigger.nodeKey, caretOffset, "text");
selection.insertText(personalSkillTriggerText(skill));
});
setSkillsTrigger(null);
setSkillsMenuSelectedIndex(0);
};
const handleEditorReady = (editor: LexicalEditor) => {
editorRef.current = editor;
@@ -744,8 +876,28 @@ const ChatMessageInput = ({
initialEditorState={initialEditorState}
/>
<InsertTextPlugin onEditorReady={handleEditorReady} />
<SkillsTriggerPlugin
open={skillsMenuOpen}
skills={filteredPersonalSkills}
selectedIndex={selectedSkillIndex}
onSelectedIndexChange={setSkillsMenuSelectedIndex}
onTriggerChange={handleSkillsTriggerChange}
onSkillSelect={replaceActiveSkillsTrigger}
/>
<EditableStatePlugin disabled={Boolean(disabled)} />
{autoFocus && <AutoFocusPlugin />}
<PersonalSkillsTriggerMenu
open={skillsMenuOpen}
anchorRect={skillsTrigger?.anchorRect ?? null}
query={skillsTrigger?.query ?? ""}
skills={filteredPersonalSkills}
isLoading={skillsMenuOpen && skillsQuery.isLoading}
onSelectedIndexChange={setSkillsMenuSelectedIndex}
isError={skillsMenuOpen && skillsQuery.isError}
selectedIndex={selectedSkillIndex}
onSelect={replaceActiveSkillsTrigger}
onClose={() => handleSkillsTriggerChange(null)}
/>
</div>
</LexicalComposer>
);
@@ -0,0 +1,156 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type * as TypesGen from "#/api/typesGenerated";
import { filterPersonalSkills } from "../../utils/personalSkills";
import { PersonalSkillsTriggerMenu } from "./PersonalSkillsTriggerMenu";
const now = "2026-05-08T00:00:00Z";
const mockSkills: TypesGen.UserSkillMetadata[] = [
{
id: "skill-reviewer",
name: "reviewer",
description: "Review changed files and suggest fixes.",
created_at: now,
updated_at: now,
},
{
id: "skill-docs",
name: "docs",
description: "Draft docs for user-facing behavior.",
created_at: now,
updated_at: now,
},
{
id: "skill-plan",
name: "plan",
description: "",
created_at: now,
updated_at: now,
},
];
const findVisibleText = async (text: string) => {
let visibleElement: HTMLElement | undefined;
await waitFor(() => {
const matches = within(document.body).queryAllByText(text);
visibleElement = matches.find(
(element) => element.getClientRects().length > 0,
);
expect(visibleElement).toBeDefined();
});
return visibleElement as HTMLElement;
};
const expectNoVisibleText = async (text: string) => {
await waitFor(() => {
const matches = within(document.body).queryAllByText(text);
expect(
matches.every((element) => element.getClientRects().length === 0),
).toBe(true);
});
};
const meta: Meta<typeof PersonalSkillsTriggerMenu> = {
title: "components/ChatMessageInput/PersonalSkillsTriggerMenu",
component: PersonalSkillsTriggerMenu,
args: {
open: true,
anchorRect: { top: 120, left: 80, height: 20 },
query: "",
skills: mockSkills,
onSelectedIndexChange: fn(),
selectedIndex: 0,
onSelect: fn(),
onClose: fn(),
},
decorators: [
(Story) => (
<div className="h-80 p-6">
<p className="text-content-secondary text-sm">
The menu is anchored to a mock caret position.
</p>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof PersonalSkillsTriggerMenu>;
export const Open: Story = {
play: async () => {
expect(await findVisibleText("/reviewer")).toBeDefined();
expect(
await findVisibleText("Review changed files and suggest fixes."),
).toBeDefined();
},
};
export const Loading: Story = {
args: {
isLoading: true,
skills: [],
},
play: async () => {
expect(await findVisibleText("Loading personal skills...")).toBeDefined();
},
};
export const ErrorState: Story = {
args: {
isError: true,
skills: [],
},
play: async () => {
expect(
await findVisibleText(
"Could not load personal skills. Close and type / again to retry.",
),
).toBeDefined();
},
};
export const Empty: Story = {
args: {
skills: [],
},
play: async () => {
expect(await findVisibleText("No personal skills found.")).toBeDefined();
},
};
export const FilteredEmpty: Story = {
args: {
query: "xyz",
skills: [],
},
play: async () => {
expect(
await findVisibleText("No personal skills match that query."),
).toBeDefined();
},
};
export const Filtered: Story = {
args: {
query: "rev",
skills: filterPersonalSkills(mockSkills, "rev"),
},
play: async () => {
expect(await findVisibleText("/reviewer")).toBeDefined();
await expectNoVisibleText("/docs");
},
};
export const SelectsByClick: Story = {
args: {
onSelect: fn(),
},
play: async ({ args }) => {
await userEvent.click(await findVisibleText("/reviewer"));
expect(args.onSelect).toHaveBeenCalledTimes(1);
expect(args.onSelect).toHaveBeenCalledWith(mockSkills[0]);
},
};
@@ -0,0 +1,140 @@
import type * as TypesGen from "#/api/typesGenerated";
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "#/components/Command/Command";
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "#/components/Popover/Popover";
import { personalSkillTriggerText } from "../../utils/personalSkills";
// Prevent zero-height anchors when the browser returns a degenerate caret rect.
const MIN_ANCHOR_HEIGHT_PX = 16;
export type CaretAnchorRect = {
top: number;
left: number;
height: number;
};
type PersonalSkillsTriggerMenuProps = {
open: boolean;
anchorRect: CaretAnchorRect | null;
query: string;
skills: readonly TypesGen.UserSkillMetadata[];
isLoading?: boolean;
isError?: boolean;
selectedIndex: number;
onSelectedIndexChange: (index: number) => void;
onSelect: (skill: TypesGen.UserSkillMetadata) => void;
onClose: () => void;
};
export const PersonalSkillsTriggerMenu = ({
open,
anchorRect,
query,
skills,
isLoading,
isError,
selectedIndex,
onSelectedIndexChange,
onSelect,
onClose,
}: PersonalSkillsTriggerMenuProps) => {
const handleHighlightedValueChange = (value: string) => {
const nextIndex = skills.findIndex((skill) => skill.name === value);
if (nextIndex >= 0) {
onSelectedIndexChange(nextIndex);
}
};
const shouldRender = open && anchorRect;
return (
<Popover
open={Boolean(shouldRender)}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
onClose();
}
}}
>
{shouldRender && (
<PopoverAnchor asChild>
<span
aria-hidden="true"
style={{
position: "fixed",
top: anchorRect.top,
left: anchorRect.left,
width: 1,
height: Math.max(anchorRect.height, MIN_ANCHOR_HEIGHT_PX),
pointerEvents: "none",
}}
/>
</PopoverAnchor>
)}
<PopoverContent
align="start"
side="bottom"
className="w-80 p-1"
onMouseDown={(event) => event.preventDefault()}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<Command
shouldFilter={false}
loop={false}
onValueChange={handleHighlightedValueChange}
value={skills[selectedIndex]?.name ?? ""}
>
<CommandList className="max-h-72 border-t-0">
{isLoading ? (
<CommandItem value="loading" disabled>
Loading personal skills...
</CommandItem>
) : isError ? (
<CommandItem value="error" disabled>
Could not load personal skills. Close and type / again to retry.
</CommandItem>
) : skills.length === 0 ? (
<CommandEmpty>
{query
? "No personal skills match that query."
: "No personal skills found."}
</CommandEmpty>
) : (
<CommandGroup heading="Personal skills">
{skills.map((skill) => (
<CommandItem
key={skill.id}
value={skill.name}
className="items-start"
onSelect={() => onSelect(skill)}
>
<div className="min-w-0 space-y-1">
<div className="truncate font-mono text-content-primary text-xs">
{personalSkillTriggerText(skill)}
</div>
{skill.description.trim() && (
<div className="line-clamp-2 text-content-secondary text-xs leading-snug">
{skill.description}
</div>
)}
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,261 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
$getSelection,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_CRITICAL,
KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_UP_COMMAND,
KEY_ENTER_COMMAND,
KEY_ESCAPE_COMMAND,
KEY_TAB_COMMAND,
type NodeKey,
} from "lexical";
import { useEffect, useEffectEvent, useRef } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { parsePersonalSkillTrigger } from "../../utils/personalSkills";
import type { CaretAnchorRect } from "./PersonalSkillsTriggerMenu";
export type ActiveSkillsTrigger = {
nodeKey: NodeKey;
slashOffset: number;
query: string;
anchorRect: CaretAnchorRect | null;
};
type DismissedSkillsTrigger = Pick<
ActiveSkillsTrigger,
"nodeKey" | "slashOffset"
>;
type SkillsTriggerPluginProps = {
open: boolean;
skills: readonly TypesGen.UserSkillMetadata[];
selectedIndex: number;
onSelectedIndexChange: (index: number) => void;
onTriggerChange: (trigger: ActiveSkillsTrigger | null) => void;
onSkillSelect: (skill: TypesGen.UserSkillMetadata) => void;
};
const currentCaretRect = (): CaretAnchorRect | null => {
const selection = getSelection();
if (!selection || selection.rangeCount === 0) {
return null;
}
const range = selection.getRangeAt(0);
let rect = range.getBoundingClientRect();
if ((rect.width === 0 && rect.height === 0) || Number.isNaN(rect.top)) {
const fallbackRange = range.cloneRange();
if (fallbackRange.startOffset > 0) {
fallbackRange.setStart(
fallbackRange.startContainer,
fallbackRange.startOffset - 1,
);
}
rect = fallbackRange.getBoundingClientRect();
}
if (Number.isNaN(rect.top)) {
return null;
}
return {
top: rect.top,
left: rect.left,
height: rect.height,
};
};
const isSameTrigger = (
trigger: DismissedSkillsTrigger,
dismissedTrigger: DismissedSkillsTrigger | null,
): boolean => {
return (
dismissedTrigger?.nodeKey === trigger.nodeKey &&
dismissedTrigger.slashOffset === trigger.slashOffset
);
};
const activeTriggerFromSelection = (): Omit<
ActiveSkillsTrigger,
"anchorRect"
> | null => {
const selection = $getSelection();
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
return null;
}
const anchor = selection.anchor;
if (anchor.type !== "text") {
return null;
}
const node = anchor.getNode();
if (!$isTextNode(node)) {
return null;
}
const textBeforeCaret = node.getTextContent().slice(0, anchor.offset);
const lineStart = textBeforeCaret.lastIndexOf("\n") + 1;
const trigger = parsePersonalSkillTrigger(textBeforeCaret.slice(lineStart));
if (!trigger) {
return null;
}
return {
nodeKey: node.getKey(),
slashOffset: lineStart + trigger.slashOffset,
query: trigger.query,
};
};
export const SkillsTriggerPlugin = ({
open,
skills,
selectedIndex,
onSelectedIndexChange,
onTriggerChange,
onSkillSelect,
}: SkillsTriggerPluginProps) => {
const [editor] = useLexicalComposerContext();
const dismissedTriggerRef = useRef<DismissedSkillsTrigger | null>(null);
const refreshTrigger = useEffectEvent(() => {
const trigger = editor.getEditorState().read(() => {
return editor.isEditable() ? activeTriggerFromSelection() : null;
});
if (!trigger) {
dismissedTriggerRef.current = null;
onTriggerChange(null);
return;
}
if (isSameTrigger(trigger, dismissedTriggerRef.current)) {
onTriggerChange(null);
return;
}
onTriggerChange({
...trigger,
anchorRect: currentCaretRect(),
});
});
useEffect(() => {
return editor.registerUpdateListener(() => refreshTrigger());
}, [editor]);
useEffect(() => {
return editor.registerRootListener((rootElement, previousRootElement) => {
previousRootElement?.removeEventListener("scroll", refreshTrigger);
rootElement?.addEventListener("scroll", refreshTrigger, {
passive: true,
});
});
}, [editor]);
useEffect(() => {
addEventListener("resize", refreshTrigger);
return () => removeEventListener("resize", refreshTrigger);
}, []);
const moveMenuHighlight = useEffectEvent(
(event: KeyboardEvent, delta: number) => {
if (!open) {
return false;
}
event.preventDefault();
const count = skills.length;
if (count === 0) {
return true;
}
const currentIndex = Math.max(0, selectedIndex);
onSelectedIndexChange((currentIndex + delta + count) % count);
return true;
},
);
const handleEnter = useEffectEvent((event: KeyboardEvent | null) => {
if (!open) {
return false;
}
event?.preventDefault();
const skill = skills[selectedIndex];
if (skill) {
onSkillSelect(skill);
}
return true;
});
const handleTab = useEffectEvent((event: KeyboardEvent | null) => {
if (!open) {
return false;
}
const skill = skills[selectedIndex];
if (!skill) {
return false;
}
event?.preventDefault();
onSkillSelect(skill);
return true;
});
const handleEscape = useEffectEvent((event: KeyboardEvent) => {
if (!open) {
return false;
}
event.preventDefault();
event.stopPropagation();
dismissedTriggerRef.current = editor
.getEditorState()
.read(() => activeTriggerFromSelection());
onTriggerChange(null);
const rootElement = editor.getRootElement();
queueMicrotask(() => {
if (rootElement?.isConnected) {
editor.focus();
}
});
return true;
});
useEffect(() => {
const unregisterArrowDown = editor.registerCommand(
KEY_ARROW_DOWN_COMMAND,
(event: KeyboardEvent) => moveMenuHighlight(event, 1),
COMMAND_PRIORITY_CRITICAL,
);
const unregisterArrowUp = editor.registerCommand(
KEY_ARROW_UP_COMMAND,
(event: KeyboardEvent) => moveMenuHighlight(event, -1),
COMMAND_PRIORITY_CRITICAL,
);
const unregisterEnter = editor.registerCommand(
KEY_ENTER_COMMAND,
handleEnter,
COMMAND_PRIORITY_CRITICAL,
);
const unregisterTab = editor.registerCommand(
KEY_TAB_COMMAND,
handleTab,
COMMAND_PRIORITY_CRITICAL,
);
const unregisterEscape = editor.registerCommand(
KEY_ESCAPE_COMMAND,
handleEscape,
COMMAND_PRIORITY_CRITICAL,
);
return () => {
unregisterArrowDown();
unregisterArrowUp();
unregisterEnter();
unregisterTab();
unregisterEscape();
};
}, [editor]);
return null;
};
@@ -1,14 +1,99 @@
import { describe, expect, it } from "vitest";
import type * as TypesGen from "#/api/typesGenerated";
import {
buildPersonalSkillMarkdown,
filterPersonalSkills,
getPersonalSkillContentSizeBytes,
isPersonalSkillTriggerToken,
isValidPersonalSkillDescription,
isValidPersonalSkillName,
PERSONAL_SKILL_MAX_SIZE_BYTES,
parsePersonalSkillMarkdown,
parsePersonalSkillTrigger,
personalSkillTriggerText,
tryParsePersonalSkillMarkdown,
} from "./personalSkills";
const now = "2026-05-08T00:00:00Z";
const skill = (
name: string,
description: string,
index: number,
): TypesGen.UserSkillMetadata => ({
id: `skill-${index}`,
name,
description,
created_at: now,
updated_at: now,
});
describe("filterPersonalSkills", () => {
const skills = [
skill("deploy", "Ship reviewed production changes", 0),
skill("reviewer", "Review changed files", 1),
skill("docs", "Draft deployment docs", 2),
skill("api-review", "Review API changes", 3),
];
it("sorts unfiltered skills by name", () => {
expect(filterPersonalSkills(skills, "").map(({ name }) => name)).toEqual([
"api-review",
"deploy",
"docs",
"reviewer",
]);
});
it("ranks prefix, name substring, then description matches", () => {
expect(filterPersonalSkills(skills, "rev").map(({ name }) => name)).toEqual(
["reviewer", "api-review", "deploy"],
);
});
it("matches names and descriptions case-insensitively", () => {
const mixedCaseSkills = [
skill("deploy-bot", "Ship Changes", 0),
skill("docs", "Review docs", 1),
];
expect(
filterPersonalSkills(mixedCaseSkills, "DEP").map(({ name }) => name),
).toEqual(["deploy-bot"]);
expect(
filterPersonalSkills(mixedCaseSkills, "changes").map(({ name }) => name),
).toEqual(["deploy-bot"]);
});
});
describe("personal skill slash triggers", () => {
it("formats skill trigger text", () => {
expect(personalSkillTriggerText(skill("reviewer", "", 0))).toBe(
"/reviewer",
);
});
it("parses trigger text at line start or after whitespace", () => {
expect(parsePersonalSkillTrigger("/rev")).toEqual({
slashOffset: 0,
query: "rev",
});
expect(parsePersonalSkillTrigger("ask /docs")).toEqual({
slashOffset: 4,
query: "docs",
});
});
it("rejects mid-token slash triggers", () => {
expect(parsePersonalSkillTrigger("https://")).toBeNull();
});
it("validates replacement trigger tokens", () => {
expect(isPersonalSkillTriggerToken("/rev")).toBe(true);
expect(isPersonalSkillTriggerToken("/bad token")).toBe(false);
});
});
describe("parsePersonalSkillMarkdown", () => {
it("parses SKILL.md frontmatter and body", () => {
expect(
@@ -1,3 +1,5 @@
import type * as TypesGen from "#/api/typesGenerated";
export const PERSONAL_SKILL_MAX_SIZE_BYTES = 64 * 1024;
const PERSONAL_SKILL_MAX_NAME_BYTES = 256;
const PERSONAL_SKILL_MAX_DESCRIPTION_BYTES = 4096;
@@ -12,6 +14,77 @@ export type PersonalSkillFormValues = {
body: string;
};
type RankedPersonalSkill = {
skill: TypesGen.UserSkillMetadata;
rank: number;
index: number;
};
export const personalSkillTriggerText = (
skill: TypesGen.UserSkillMetadata,
): string => `/${skill.name}`;
type PersonalSkillTriggerMatch = {
slashOffset: number;
query: string;
};
export const parsePersonalSkillTrigger = (
linePrefix: string,
): PersonalSkillTriggerMatch | null => {
const match = /(?:^|\s)\/(\S*)$/.exec(linePrefix);
if (!match) {
return null;
}
return {
slashOffset: match.index + match[0].indexOf("/"),
query: match[1] ?? "",
};
};
export const isPersonalSkillTriggerToken = (token: string): boolean =>
/^\/\S*$/.test(token);
/**
* Filters personal skills by name and description. Matches are ranked by
* name prefix, name substring, then description substring.
*/
export const filterPersonalSkills = (
skills: readonly TypesGen.UserSkillMetadata[],
query: string,
): TypesGen.UserSkillMetadata[] => {
const normalizedQuery = query.toLocaleLowerCase("en-US");
if (!normalizedQuery) {
return skills.toSorted((a, b) => a.name.localeCompare(b.name, "en-US"));
}
return skills
.map((skill, index): RankedPersonalSkill => {
const name = skill.name.toLocaleLowerCase("en-US");
const description = skill.description.toLocaleLowerCase("en-US");
let rank = Number.POSITIVE_INFINITY;
if (name.startsWith(normalizedQuery)) {
rank = 0;
} else if (name.includes(normalizedQuery)) {
rank = 1;
} else if (description.includes(normalizedQuery)) {
rank = 2;
}
return { skill, rank, index };
})
.filter(({ rank }) => Number.isFinite(rank))
.toSorted((a, b) => {
if (a.rank !== b.rank) {
return a.rank - b.rank;
}
const nameOrder = a.skill.name.localeCompare(b.skill.name, "en-US");
return nameOrder === 0 ? a.index - b.index : nameOrder;
})
.map(({ skill }) => skill);
};
class PersonalSkillMarkdownError extends Error {}
const unquoteFrontmatterScalar = (value: string): string => {