From 1ea61b976b9048aedcb69f52ef023cd1197be11b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:43:34 +0200 Subject: [PATCH] fix(site): prevent slash menu from flashing at top-left corner when closed (#27326) Typing `/` in the agent chat input opens the skills slash menu. Pressing Backspace to erase the `/` made the menu flash at the top-left corner of the app for a split second before disappearing. Radix keeps the popover content mounted through its exit animation, but the caret-positioned `PopoverAnchor` was unmounted in the same render that closed the menu. Without an anchor, the popper repositioned the still-visible closing content to the viewport origin. The anchor now stays rendered at the last known caret rect, so the menu holds its position while it animates out. Verified with a frame-by-frame position recorder in the browser: before the fix the closing menu jumped from the caret position to `(0, 4)`; after the fix it stays in place until unmount. Added a `BackspaceClosesMenuWithoutRepositioning` story that fails without the fix. > This PR was created by Mux, an AI coding agent, on Mike's behalf. --- .../ChatMessageInput.stories.tsx | 27 +++++++++++++++++++ .../ChatMessageInput/SkillsTriggerMenu.tsx | 17 +++++++++--- 2 files changed, 40 insertions(+), 4 deletions(-) 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 && (