fix(site/src/pages/AgentsPage): dismiss skills trigger on outside click (#25613)

When the personal skills menu is open and the user clicks outside (e.g.
the send button), the Popover closes via `onOpenChange` but the
`SkillsTriggerPlugin`'s `dismissedTriggerRef` is not set. The next
Lexical update listener call detects the trigger again and briefly
reopens the menu, causing a visible flash.

Addresses this symptom:


https://github.com/user-attachments/assets/0c1442a2-df75-442b-bcf8-4b028dc647b0



Fix by recording the current trigger position in `dismissedTriggerRef`
when the `open` prop transitions from `true` to `false`. This mirrors
what the Escape key handler already does and prevents `refreshTrigger`
from immediately re-opening the menu at the same position.

<details><summary>Implementation details</summary>

- Added a `useLayoutEffect` in `SkillsTriggerPlugin` that tracks `open`
prop transitions via a `prevOpenRef`. When `open` goes from `true` to
`false`, it snapshots the current trigger position into
`dismissedTriggerRef`, matching the pattern the Escape handler uses
(line 225-227).
- Added `OutsideClickDismissesTriggerOnRefocus` Storybook regression
story that verifies the menu stays closed when clicking back into the
editor after an outside-click dismissal.

</details>

---

*PR generated with Coder Agents*
This commit is contained in:
Matt Vollmer
2026-05-22 08:49:00 -04:00
committed by GitHub
parent fbf6fa1d25
commit 3a2a97602e
2 changed files with 26 additions and 1 deletions
@@ -219,3 +219,18 @@ export const OutsideClickClosesWithoutReplacing: Story = {
expect(editor.textContent).toBe("/");
},
};
export const OutsideClickDismissesTriggerOnRefocus: Story = {
play: async ({ canvasElement }) => {
const editor = await typeInEditor(canvasElement, "/");
await findVisibleText("/reviewer");
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Outside target" }),
);
await expectNoVisibleText("/reviewer");
await userEvent.click(editor);
await expectNoVisibleText("/reviewer");
expect(editor.textContent).toBe("/");
},
};
@@ -11,7 +11,7 @@ import {
KEY_TAB_COMMAND,
type NodeKey,
} from "lexical";
import { useEffect, useEffectEvent, useRef } from "react";
import { useEffect, useEffectEvent, useLayoutEffect, useRef } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { parsePersonalSkillTrigger } from "../../utils/personalSkills";
import type { CaretAnchorRect } from "./PersonalSkillsTriggerMenu";
@@ -121,6 +121,16 @@ export const SkillsTriggerPlugin = ({
const [editor] = useLexicalComposerContext();
const dismissedTriggerRef = useRef<DismissedSkillsTrigger | null>(null);
const prevOpenRef = useRef(open);
useLayoutEffect(() => {
if (prevOpenRef.current && !open) {
dismissedTriggerRef.current = editor
.getEditorState()
.read(() => activeTriggerFromSelection());
}
prevOpenRef.current = open;
}, [open, editor]);
const refreshTrigger = useEffectEvent(() => {
const trigger = editor.getEditorState().read(() => {
return editor.isEditable() ? activeTriggerFromSelection() : null;