diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 40ac3342447..ab7b7390400 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -1190,6 +1190,7 @@ export const AgentChatInput: FC = ({ workspaceSkills={workspaceSkills} autoFocus slashCommands={slashCommands} + skillsMenuAnchor={composerElement} /> {/* Warn about invisible Unicode in the message text. * Unlike the admin/user prompt textareas (which strip diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx index 9f8e9b72f2d..b163b229cee 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx @@ -102,7 +102,7 @@ export const EmptySkills: Story = { }, }; -export const FilteredEmptyKeepsMenuOpen: Story = { +export const FilteredEmptyEnterClosesAndSubmits: Story = { args: { onEnter: fn(), }, @@ -112,11 +112,60 @@ export const FilteredEmptyKeepsMenuOpen: Story = { await findVisibleText("No personal skills match that query."), ).toBeDefined(); await userEvent.keyboard("{Enter}"); - expect(args.onEnter).not.toHaveBeenCalled(); + expect(args.onEnter).toHaveBeenCalledTimes(1); + await expectNoVisibleText("No personal skills match that query."); expect(editor.textContent).toBe("/zzzz"); }, }; +// A trailing absolute path is not a skill query; Enter must close the +// no-match menu and submit the prompt in the same keypress (CODAGT-956). +export const TrailingPathEnterSubmits: Story = { + args: { + slashCommands: [COMPACT_SLASH_COMMAND], + onEnter: fn(), + }, + play: async ({ canvasElement, args }) => { + const editor = await typeInEditor(canvasElement, "check /var/log/syslog"); + expect( + await findVisibleText("No personal skills match that query."), + ).toBeDefined(); + await userEvent.keyboard("{Enter}"); + expect(args.onEnter).toHaveBeenCalledTimes(1); + await expectNoVisibleText("No personal skills match that query."); + expect(editor.textContent).toBe("check /var/log/syslog"); + }, +}; + +// The menu anchors where "/" was typed and must not follow the caret +// as the query grows. +export const MenuStaysAnchoredWhileTyping: Story = { + play: async ({ canvasElement }) => { + await typeInEditor(canvasElement, "/re"); + const item = await findVisibleText("/reviewer"); + const wrapper = item.closest( + "[data-radix-popper-content-wrapper]", + ); + expect(wrapper).not.toBeNull(); + if (!wrapper) return; + const before = wrapper.getBoundingClientRect(); + await userEvent.keyboard("view"); + await findVisibleText("/reviewer"); + // Headless runs do not fire floating-ui's layout-shift tracking, + // so force a reposition through its resize listener, then hold + // the position stable long enough to catch a moved anchor. + dispatchEvent(new Event("resize")); + for (let frame = 0; frame < 30; frame++) { + const rect = wrapper.getBoundingClientRect(); + expect({ top: rect.top, left: rect.left }).toEqual({ + top: before.top, + left: before.left, + }); + await new Promise((resolve) => requestAnimationFrame(resolve)); + } + }, +}; + export const FiltersByQuery: Story = { play: async ({ canvasElement }) => { await typeInEditor(canvasElement, "/rev"); @@ -270,6 +319,25 @@ export const EmptyPersonalKeepsMenuOpenWhileWorkspaceSkillsUnknown: Story = { }, }; +// While a skill source is still loading, a zero-match Enter must keep +// the menu open instead of submitting a token that may become a skill +// trigger once results arrive. +export const EnterWhileSkillsLoadingDoesNotSubmit: Story = { + args: { + personalSkillsOverride: [], + hasWorkspace: true, + onEnter: fn(), + }, + play: async ({ canvasElement, args }) => { + const editor = await typeInEditor(canvasElement, "/rev"); + expect(await findVisibleText("Loading workspace skills...")).toBeDefined(); + await userEvent.keyboard("{Enter}"); + expect(args.onEnter).not.toHaveBeenCalled(); + expect(await findVisibleText("Loading workspace skills...")).toBeDefined(); + expect(editor.textContent).toBe("/rev"); + }, +}; + export const QualifiedPersonalQueryMatchesBareTrigger: Story = { args: { hasWorkspace: true, diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx index fdb04d3e21e..dafc45a01e6 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx @@ -528,6 +528,11 @@ interface ChatMessageInputProps * composer intercepts it at submit time. */ slashCommands?: readonly ChatSlashCommand[]; + /** + * Composer box element the skills menu is pinned above and sized + * to match. Falls back to this component's own container. + */ + skillsMenuAnchor?: HTMLElement | null; "aria-label"?: string; } @@ -572,10 +577,7 @@ const isSameSkillsTrigger = ( 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 + a.query === b.query ); }; @@ -597,6 +599,7 @@ const ChatMessageInput = ({ personalSkillsOverride, workspaceSkills, slashCommands, + skillsMenuAnchor, "aria-label": ariaLabel, ref, ...props @@ -622,6 +625,8 @@ const ChatMessageInput = ({ const pendingReplacementRef = useRef(null); const [skillsTrigger, setSkillsTrigger] = useState(null); + const [containerElement, setContainerElement] = + useState(null); const suppressedSkillsTriggerRef = useRef(null); const [skillsMenuSelectedIndex, setSkillsMenuSelectedIndex] = useState(0); const hasSkillsTrigger = Boolean(skillsTrigger); @@ -935,6 +940,7 @@ const ChatMessageInput = ({ return (
*]:col-start-1 [&>*]:row-start-1", disabled && "cursor-not-allowed opacity-50", @@ -980,6 +986,12 @@ const ChatMessageInput = ({ } createSkillMenuItem("workspace", skill), ); +// Provides the composer-box element the menu anchors to, since the +// menu is pinned above its anchor at the anchor's width. +const MenuStoryHarness = (args: ComponentProps) => { + const [anchor, setAnchor] = useState(null); + return ( + <> +
+ Mock composer +
+ + + ); +}; + const meta: Meta = { title: "components/ChatMessageInput/SkillsTriggerMenu", component: SkillsTriggerMenu, args: { open: true, - anchorRect: { top: 120, left: 80, height: 20 }, + anchor: null, query: "", personalSkills: mockPersonalSkillItems, workspaceSkills: [], @@ -50,12 +67,10 @@ const meta: Meta = { onSelect: fn(), onClose: fn(), }, + render: (args) => , decorators: [ (Story) => ( -
-

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

+
), @@ -161,13 +176,16 @@ const SelectionScrollHarness = ( args: ComponentProps, ) => { const [selectedIndex, setSelectedIndex] = useState(0); + const [anchor, setAnchor] = useState(null); return ( <> +
@@ -240,3 +258,26 @@ export const SelectsCommandByClick: Story = { expect(args.onSelect).toHaveBeenCalledWith(compactCommandItem); }, }; + +// The menu opens above its anchor at the anchor's exact width, +// matching the mobile pinned-above-composer placement (CODAGT-956). +export const OpensAboveAnchorAtAnchorWidth: Story = { + play: async () => { + const item = await findVisibleText("/reviewer"); + const content = item.closest("[data-side]"); + expect(content).not.toBeNull(); + expect(content).toHaveAttribute("data-side", "top"); + const anchorBox = (await findVisibleText("Mock composer")).closest("div"); + expect(anchorBox).not.toBeNull(); + if (!anchorBox || !(content instanceof HTMLElement)) return; + // The entrance animation scales the content from 95%, so wait + // for the settled geometry. + await waitFor(() => { + const anchorRect = anchorBox.getBoundingClientRect(); + const contentRect = content.getBoundingClientRect(); + expect(contentRect.width).toBeCloseTo(anchorRect.width, 0); + expect(contentRect.left).toBeCloseTo(anchorRect.left, 0); + expect(contentRect.bottom).toBeLessThanOrEqual(anchorRect.top); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx index d0f03d841ec..317d54b23d9 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState } from "react"; +import { useLayoutEffect, useRef } from "react"; import { Command, CommandEmpty, @@ -13,15 +13,6 @@ import { } from "#/components/Popover/Popover"; import { cn } from "#/utils/cn"; -// 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 SkillSource = "personal" | "workspace"; export type SkillMetadata = { @@ -67,7 +58,8 @@ export const createSkillMenuItem = ( type SkillsTriggerMenuProps = { open: boolean; - anchorRect: CaretAnchorRect | null; + // The composer box the menu is pinned above and sized to match. + anchor: HTMLElement | null; query: string; commands?: readonly SkillMenuItem[]; personalSkills: readonly SkillMenuItem[]; @@ -160,7 +152,7 @@ const SkillCommandItem = ({ export const SkillsTriggerMenu = ({ open, - anchorRect, + anchor, query, commands = [], personalSkills, @@ -186,15 +178,7 @@ export const SkillsTriggerMenu = ({ ? "Loading workspace skills..." : undefined, ].filter((item) => item !== undefined); - const shouldRender = open && anchorRect; - // Radix keeps closing content mounted through its exit animation. - // Unmounting the anchor then would reposition the closing menu to - // the viewport origin, so keep it at the last known caret rect. - const [lastAnchorRect, setLastAnchorRect] = useState(anchorRect); - if (anchorRect && anchorRect !== lastAnchorRect) { - setLastAnchorRect(anchorRect); - } - const renderedAnchorRect = anchorRect ?? lastAnchorRect; + const shouldRender = open && anchor !== null; const shouldShowEmpty = allSkills.length === 0 && statusItems.length === 0; const selectedValue = selectedIndex >= 0 ? String(selectedIndex) : ""; @@ -239,25 +223,12 @@ export const SkillsTriggerMenu = ({ } }} > - {renderedAnchorRect && ( - - - )} + {anchor && } event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()} onCloseAutoFocus={(event) => event.preventDefault()} diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx index 9d314789426..1f796dea3c5 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx @@ -13,13 +13,12 @@ import { } from "lexical"; import { useEffect, useEffectEvent, useLayoutEffect, useRef } from "react"; import { parsePersonalSkillTrigger } from "../../utils/personalSkills"; -import type { CaretAnchorRect, SkillMenuItem } from "./SkillsTriggerMenu"; +import type { SkillMenuItem } from "./SkillsTriggerMenu"; export type ActiveSkillsTrigger = { nodeKey: NodeKey; slashOffset: number; query: string; - anchorRect: CaretAnchorRect | null; }; type DismissedSkillsTrigger = Pick< @@ -30,42 +29,13 @@ type DismissedSkillsTrigger = Pick< type SkillsTriggerPluginProps = { open: boolean; skills: readonly SkillMenuItem[]; + skillsLoading: boolean; selectedIndex: number; onSelectedIndexChange: (index: number) => void; onTriggerChange: (trigger: ActiveSkillsTrigger | null) => void; onSkillSelect: (skill: SkillMenuItem) => 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, @@ -76,10 +46,7 @@ const isSameTrigger = ( ); }; -const activeTriggerFromSelection = (): Omit< - ActiveSkillsTrigger, - "anchorRect" -> | null => { +const activeTriggerFromSelection = (): ActiveSkillsTrigger | null => { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) { return null; @@ -112,6 +79,7 @@ const activeTriggerFromSelection = (): Omit< export const SkillsTriggerPlugin = ({ open, skills, + skillsLoading, selectedIndex, onSelectedIndexChange, onTriggerChange, @@ -146,30 +114,13 @@ export const SkillsTriggerPlugin = ({ return; } - onTriggerChange({ - ...trigger, - anchorRect: currentCaretRect(), - }); + onTriggerChange(trigger); }); 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) { @@ -193,11 +144,25 @@ export const SkillsTriggerPlugin = ({ if (!open) { return false; } - event?.preventDefault(); const skill = selectedIndex >= 0 ? skills[selectedIndex] : undefined; - if (skill) { - onSkillSelect(skill); + if (!skill) { + // A still-loading source may yet produce matches, so keep + // consuming Enter until every source resolves. + if (skillsLoading) { + event?.preventDefault(); + return true; + } + // Nothing is selectable (e.g. the trailing token is a filesystem + // path, not a skill): dismiss the menu and let the same keypress + // fall through to the submit handler. + dismissedTriggerRef.current = editor + .getEditorState() + .read(() => activeTriggerFromSelection()); + onTriggerChange(null); + return false; } + event?.preventDefault(); + onSkillSelect(skill); return true; });