diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx index 9adecdf681..3ce7dfb597 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx @@ -6,6 +6,7 @@ import { COMPACT_SLASH_COMMAND } from "../../utils/slashCommands"; import { ChatMessageInput } from "./ChatMessageInput"; import type { SkillMetadata } from "./SkillsTriggerMenu"; import { + expectInsideListViewport, expectNoVisibleText, findVisibleText, MockSkill, @@ -147,6 +148,33 @@ export const ArrowKeysSelectHighlightedSkill: Story = { }, }; +// Enough skills to overflow the menu's max height so arrow-key +// navigation has to scroll the list. +const manyPersonalSkills: TypesGen.UserSkillMetadata[] = Array.from( + { length: 15 }, + (_, index) => ({ + ...MockSkill, + id: `skill-scroll-${index}`, + name: `skill-${String(index).padStart(2, "0")}`, + }), +); + +export const ArrowKeysScrollMenuList: Story = { + args: { + personalSkillsOverride: manyPersonalSkills, + }, + play: async ({ canvasElement }) => { + await typeInEditor(canvasElement, "/"); + const lastItem = await findVisibleText("/skill-14"); + // ArrowUp wraps the highlight to the last item, below the fold. + await userEvent.keyboard("{ArrowUp}"); + await expectInsideListViewport(lastItem); + // ArrowDown wraps back to the first item and its group heading. + await userEvent.keyboard("{ArrowDown}"); + await expectInsideListViewport(await findVisibleText("Personal skills")); + }, +}; + export const TabSelectsSkill: Story = { play: async ({ canvasElement }) => { const editor = await typeInEditor(canvasElement, "/rev"); diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx index 7d0958efae..b8f62a420d 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { type ComponentProps, useState } from "react"; import { expect, fn, userEvent } from "storybook/test"; import { filterSkillsByQuery } from "../../utils/personalSkills"; import { COMPACT_SLASH_COMMAND } from "../../utils/slashCommands"; @@ -9,6 +10,7 @@ import { SkillsTriggerMenu, } from "./SkillsTriggerMenu"; import { + expectInsideListViewport, expectNoVisibleText, findVisibleText, MockSkills, @@ -146,6 +148,44 @@ export const Filtered: Story = { }, }; +const manyPersonalSkillItems = Array.from({ length: 30 }, (_, index) => + createSkillMenuItem("personal", { + name: `skill-${String(index).padStart(2, "0")}`, + description: "", + }), +); + +// cmdk scrolls the controlled highlight into view only at mount, so the +// selection must move after mount to exercise the menu's own scrolling. +const SelectionScrollHarness = ( + args: ComponentProps, +) => { + const [selectedIndex, setSelectedIndex] = useState(0); + return ( + <> + + + + ); +}; + +export const ScrollsSelectionIntoView: Story = { + args: { + personalSkills: manyPersonalSkillItems, + }, + render: (args) => , + play: async () => { + await userEvent.click(await findVisibleText("Highlight last skill")); + await expectInsideListViewport(await findVisibleText("/skill-29")); + }, +}; + export const SelectsByClick: Story = { args: { onSelect: fn(), diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx index 7340d3743e..d0f03d841e 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useLayoutEffect, useRef, useState } from "react"; import { Command, CommandEmpty, @@ -98,16 +98,44 @@ const SkillCommandItem = ({ value, selected, onSelect, + consumePointerHighlight, }: { skill: SkillMenuItem; value: string; selected: boolean; onSelect: (skill: SkillMenuItem) => void; + consumePointerHighlight: () => boolean; }) => { const handleSelect = () => onSelect(skill); + const itemRef = useRef(null); + + // cmdk only auto-scrolls for its own key handling; arrow keys here are + // consumed by the Lexical trigger plugin and arrive as a controlled value + // change, so the item scrolls itself when it becomes the highlight. + // Pointer highlights skip scrolling, like cmdk, to avoid hover/scroll loops. + useLayoutEffect(() => { + if (!selected || consumePointerHighlight()) { + return; + } + const item = itemRef.current; + if (!item) { + return; + } + if (item.parentElement?.firstElementChild === item) { + // First item in a group: reveal the group heading as well. cmdk + // renders headings internally without exposing a ref, so locate + // it through the DOM the same way cmdk does. + item + .closest("[cmdk-group]") + ?.querySelector("[cmdk-group-heading]") + ?.scrollIntoView({ block: "nearest" }); + } + item.scrollIntoView({ block: "nearest" }); + }, [selected, consumePointerHighlight]); return ( = 0 ? String(selectedIndex) : ""; + const pointerHighlightRef = useRef(false); + + const consumePointerHighlight = () => { + const fromPointer = pointerHighlightRef.current; + pointerHighlightRef.current = false; + return fromPointer; + }; + const handleHighlightedValueChange = (value: string) => { const nextIndex = Number(value); if ( Number.isInteger(nextIndex) && nextIndex >= 0 && - nextIndex < allSkills.length + nextIndex < allSkills.length && + nextIndex !== selectedIndex ) { + pointerHighlightRef.current = true; onSelectedIndexChange(nextIndex); } }; @@ -188,6 +226,7 @@ export const SkillsTriggerMenu = ({ value={String(index)} selected={index === selectedIndex} onSelect={onSelect} + consumePointerHighlight={consumePointerHighlight} /> ); diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/storyHelpers.ts b/site/src/pages/AgentsPage/components/ChatMessageInput/storyHelpers.ts index 1b14bed8fd..31af3ed3e3 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/storyHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/storyHelpers.ts @@ -46,3 +46,17 @@ export const expectNoVisibleText = async (text: string): Promise => { ).toBe(true); }); }; + +// The 1px tolerance absorbs subpixel rounding in scrolled rects. +export const expectInsideListViewport = async ( + element: HTMLElement, +): Promise => { + const list = element.closest("[cmdk-list]"); + expect(list).not.toBeNull(); + await waitFor(() => { + const listRect = (list as HTMLElement).getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + expect(elementRect.top).toBeGreaterThanOrEqual(listRect.top - 1); + expect(elementRect.bottom).toBeLessThanOrEqual(listRect.bottom + 1); + }); +};