mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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.
This commit is contained in:
@@ -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, "/");
|
||||
|
||||
+62
-14
@@ -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<PersonalSkillsMenuState | null>(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 (
|
||||
<Popover
|
||||
open={Boolean(shouldRender)}
|
||||
open={isAnchoredOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{shouldRender && (
|
||||
{menuAnchorRect && (
|
||||
<PopoverAnchor asChild>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: anchorRect.top,
|
||||
left: anchorRect.left,
|
||||
top: menuAnchorRect.top,
|
||||
left: menuAnchorRect.left,
|
||||
width: 1,
|
||||
height: Math.max(anchorRect.height, MIN_ANCHOR_HEIGHT_PX),
|
||||
height: Math.max(menuAnchorRect.height, MIN_ANCHOR_HEIGHT_PX),
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
@@ -92,26 +140,26 @@ export const PersonalSkillsTriggerMenu = ({
|
||||
shouldFilter={false}
|
||||
loop={false}
|
||||
onValueChange={handleHighlightedValueChange}
|
||||
value={skills[selectedIndex]?.name ?? ""}
|
||||
value={menuSkills[menuSelectedIndex]?.name ?? ""}
|
||||
>
|
||||
<CommandList className="max-h-72 border-t-0 mobile-full-width-dropdown-scroll-area">
|
||||
{isLoading ? (
|
||||
{menuState?.isLoading ? (
|
||||
<CommandItem value="loading" disabled>
|
||||
Loading personal skills...
|
||||
</CommandItem>
|
||||
) : isError ? (
|
||||
) : menuState?.isError ? (
|
||||
<CommandItem value="error" disabled>
|
||||
Could not load personal skills. Close and type / again to retry.
|
||||
</CommandItem>
|
||||
) : skills.length === 0 ? (
|
||||
) : menuSkills.length === 0 ? (
|
||||
<CommandEmpty>
|
||||
{query
|
||||
{menuState?.query
|
||||
? "No personal skills match that query."
|
||||
: "No personal skills found."}
|
||||
</CommandEmpty>
|
||||
) : (
|
||||
<CommandGroup heading="Personal skills">
|
||||
{skills.map((skill) => (
|
||||
{menuSkills.map((skill) => (
|
||||
<CommandItem
|
||||
key={skill.id}
|
||||
value={skill.name}
|
||||
|
||||
Reference in New Issue
Block a user