diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx index a79e613b54..bc178a3fb3 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx @@ -335,6 +335,33 @@ export const OutsideClickDismissesTriggerOnRefocus: Story = { }, }; +export const BackspaceClosesMenuWithoutRepositioning: Story = { + play: async ({ canvasElement }) => { + await typeInEditor(canvasElement, "/"); + await findVisibleText("/reviewer"); + const popperWrapper = () => + document.querySelector( + "[data-radix-popper-content-wrapper]", + ); + const openRect = popperWrapper()?.getBoundingClientRect(); + expect(openRect?.left).toBeGreaterThan(0); + await userEvent.keyboard("{Backspace}"); + // The closing menu must hold its position through the exit + // animation instead of flashing at the viewport origin. + let wrapper = popperWrapper(); + for (let frame = 0; wrapper && frame < 300; frame++) { + const rect = wrapper.getBoundingClientRect(); + expect({ top: rect.top, left: rect.left }).toEqual({ + top: openRect?.top, + left: openRect?.left, + }); + await new Promise((resolve) => requestAnimationFrame(resolve)); + wrapper = popperWrapper(); + } + expect(wrapper).toBeNull(); + }, +}; + // Stories below verify that on mobile viewports, the skills popup // sits directly above the chat input rather than being clipped // above the visible viewport. diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx index 9e9a2ac443..362b1537c5 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Command, CommandEmpty, @@ -143,6 +144,14 @@ export const SkillsTriggerMenu = ({ : 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 shouldShowEmpty = allSkills.length === 0 && statusItems.length === 0; const selectedValue = selectedIndex >= 0 ? String(selectedIndex) : ""; @@ -176,16 +185,16 @@ export const SkillsTriggerMenu = ({ } }} > - {shouldRender && ( + {renderedAnchorRect && (