mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
+156
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user