diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx new file mode 100644 index 0000000000..fe13c013b1 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx @@ -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 = { + title: "components/ChatMessageInput/ChatMessageInput", + component: ChatMessageInput, + args: { + "aria-label": "Chat message input", + placeholder: "Message the agent", + personalSkillsOverride: mockSkills, + onChange: fn(), + onEnter: fn(), + }, + decorators: [ + (Story) => ( +
+ + +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +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("/"); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.test.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.test.tsx index 5cafc7f13a..0a7605bb80 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.test.tsx @@ -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( + {children}, + ); +}; + 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(); + renderWithQueryClient( + , + ); 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( { it("returns updated content even without an external onChange prop", async () => { const inputRef = { current: null as ChatMessageInputRef | null }; - render(); + renderWithQueryClient( + , + ); await waitFor(() => { expect(inputRef.current).not.toBeNull(); diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx index 582078bfa3..431a537d5f 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx @@ -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(null); + const [skillsTrigger, setSkillsTrigger] = + useState(null); + const suppressedSkillsTriggerRef = useRef(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} /> + {autoFocus && } + handleSkillsTriggerChange(null)} + /> ); diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.stories.tsx new file mode 100644 index 0000000000..876a6c1bc4 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.stories.tsx @@ -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 = { + 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) => ( +
+

+ The menu is anchored to a mock caret position. +

+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +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]); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.tsx new file mode 100644 index 0000000000..31fccabf28 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.tsx @@ -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 ( + { + if (!nextOpen) { + onClose(); + } + }} + > + {shouldRender && ( + + + )} + event.preventDefault()} + onOpenAutoFocus={(event) => event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + > + + + {isLoading ? ( + + Loading personal skills... + + ) : isError ? ( + + Could not load personal skills. Close and type / again to retry. + + ) : skills.length === 0 ? ( + + {query + ? "No personal skills match that query." + : "No personal skills found."} + + ) : ( + + {skills.map((skill) => ( + onSelect(skill)} + > +
+
+ {personalSkillTriggerText(skill)} +
+ {skill.description.trim() && ( +
+ {skill.description} +
+ )} +
+
+ ))} +
+ )} +
+
+
+
+ ); +}; diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx new file mode 100644 index 0000000000..67194f11e4 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx @@ -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(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; +}; diff --git a/site/src/pages/AgentsPage/utils/personalSkills.test.ts b/site/src/pages/AgentsPage/utils/personalSkills.test.ts index e0a66bd82c..5c40910b2d 100644 --- a/site/src/pages/AgentsPage/utils/personalSkills.test.ts +++ b/site/src/pages/AgentsPage/utils/personalSkills.test.ts @@ -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( diff --git a/site/src/pages/AgentsPage/utils/personalSkills.ts b/site/src/pages/AgentsPage/utils/personalSkills.ts index e3f67bb6f5..8f3baa3f48 100644 --- a/site/src/pages/AgentsPage/utils/personalSkills.ts +++ b/site/src/pages/AgentsPage/utils/personalSkills.ts @@ -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 => {