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.
This commit is contained in:
Michael Suchacz
2026-07-18 22:43:34 +02:00
committed by GitHub
parent 46d1823c0a
commit 1ea61b976b
2 changed files with 40 additions and 4 deletions
@@ -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<HTMLElement>(
"[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.
@@ -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 && (
<PopoverAnchor asChild>
<span
aria-hidden="true"
style={{
position: "fixed",
top: anchorRect.top,
left: anchorRect.left,
top: renderedAnchorRect.top,
left: renderedAnchorRect.left,
width: 1,
height: Math.max(anchorRect.height, MIN_ANCHOR_HEIGHT_PX),
height: Math.max(renderedAnchorRect.height, MIN_ANCHOR_HEIGHT_PX),
pointerEvents: "none",
}}
/>