From 84240da0c1cb7c148b68ac32e8b81d82cdb78281 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 25 May 2026 21:58:37 +0200 Subject: [PATCH] fix(site/src/pages/AgentsPage): avoid skills popup flash (#25661) When removing the `/` personal skill trigger, the popover content stayed mounted during its close transition and briefly rendered the empty skills state at the viewport origin. This keeps the menu content mounted for stable Radix positioning, preserves the last open menu state during the close transition, and adds a Storybook regression for the backspace path. > Mux is creating this PR on behalf of Mike. --- .../ChatMessageInput.stories.tsx | 19 +++++ .../PersonalSkillsTriggerMenu.tsx | 76 +++++++++++++++---- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx index e06a9da113..5acf42671c 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx @@ -76,6 +76,13 @@ const expectNoVisibleText = async (text: string) => { }); }; +const expectNoVisibleTextImmediately = (text: string) => { + const matches = within(document.body).queryAllByText(text); + expect( + matches.every((element) => element.getClientRects().length === 0), + ).toBe(true); +}; + const editorFromCanvas = (canvasElement: HTMLElement) => { const canvas = within(canvasElement); return canvas.getByTestId("chat-message-input"); @@ -192,6 +199,18 @@ export const SlashInsideUrlDoesNotOpen: Story = { }, }; +export const BackspaceClosesWithoutEmptyStateFlash: Story = { + play: async ({ canvasElement }) => { + const editor = await typeInEditor(canvasElement, "/"); + await findVisibleText("/reviewer"); + await userEvent.keyboard("{Backspace}"); + + expect(editor.textContent).toBe(""); + expectNoVisibleTextImmediately("No personal skills found."); + await expectNoVisibleText("/reviewer"); + }, +}; + export const EscapeClosesWithoutReplacing: Story = { play: async ({ canvasElement }) => { const editor = await typeInEditor(canvasElement, "/"); diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.tsx index 0c8dba1394..ae0b509bbd 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/PersonalSkillsTriggerMenu.tsx @@ -1,3 +1,4 @@ +import { useLayoutEffect, useState } from "react"; import type * as TypesGen from "#/api/typesGenerated"; import { Command, @@ -35,6 +36,15 @@ type PersonalSkillsTriggerMenuProps = { onClose: () => void; }; +type PersonalSkillsMenuState = { + anchorRect: CaretAnchorRect; + query: string; + skills: readonly TypesGen.UserSkillMetadata[]; + isLoading?: boolean; + isError?: boolean; + selectedIndex: number; +}; + export const PersonalSkillsTriggerMenu = ({ open, anchorRect, @@ -47,34 +57,72 @@ export const PersonalSkillsTriggerMenu = ({ onSelect, onClose, }: PersonalSkillsTriggerMenuProps) => { + const [lastOpenMenuState, setLastOpenMenuState] = + useState(null); + const isAnchoredOpen = open && anchorRect !== null; + const activeMenuState: PersonalSkillsMenuState | null = isAnchoredOpen + ? { + anchorRect, + query, + skills, + isLoading, + isError, + selectedIndex, + } + : null; + const menuState = activeMenuState ?? lastOpenMenuState; + const menuAnchorRect = menuState?.anchorRect ?? null; + const menuSkills = menuState?.skills ?? []; + const menuSelectedIndex = menuState?.selectedIndex ?? -1; + + useLayoutEffect(() => { + if (!isAnchoredOpen) { + return; + } + setLastOpenMenuState({ + anchorRect, + query, + skills, + isLoading, + isError, + selectedIndex, + }); + }, [ + anchorRect, + isAnchoredOpen, + isError, + isLoading, + query, + selectedIndex, + skills, + ]); + const handleHighlightedValueChange = (value: string) => { - const nextIndex = skills.findIndex((skill) => skill.name === value); + const nextIndex = menuSkills.findIndex((skill) => skill.name === value); if (nextIndex >= 0) { onSelectedIndexChange(nextIndex); } }; - const shouldRender = open && anchorRect; - return ( { if (!nextOpen) { onClose(); } }} > - {shouldRender && ( + {menuAnchorRect && (