From b069a5a367ea144e64f2528c2cf511be337d0846 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:19:03 +0000 Subject: [PATCH 1/5] fix(site): improve chat slash menu enter handling, anchoring, and placement When a follow-up prompt ends in a filesystem path, the skill menu opened with no matches and swallowed Enter, blocking submission. Enter with no selectable item now dismisses the menu and falls through to submit. The menu also anchors to the slash character instead of following the caret, and opens above the input on desktop to match mobile placement. Fixes CODAGT-956 --- .../ChatMessageInput.stories.tsx | 53 ++++++++++++++++++- .../SkillsTriggerMenu.stories.tsx | 14 +++++ .../ChatMessageInput/SkillsTriggerMenu.tsx | 2 +- .../ChatMessageInput/SkillsTriggerPlugin.tsx | 49 +++++++++++++++-- 4 files changed, 111 insertions(+), 7 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx index 9f8e9b72f2d..655bed10e3c 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx @@ -102,7 +102,7 @@ export const EmptySkills: Story = { }, }; -export const FilteredEmptyKeepsMenuOpen: Story = { +export const FilteredEmptyEnterClosesAndSubmits: Story = { args: { onEnter: fn(), }, @@ -112,11 +112,60 @@ export const FilteredEmptyKeepsMenuOpen: Story = { await findVisibleText("No personal skills match that query."), ).toBeDefined(); await userEvent.keyboard("{Enter}"); - expect(args.onEnter).not.toHaveBeenCalled(); + expect(args.onEnter).toHaveBeenCalledTimes(1); + await expectNoVisibleText("No personal skills match that query."); expect(editor.textContent).toBe("/zzzz"); }, }; +// A trailing absolute path is not a skill query; Enter must close the +// no-match menu and submit the prompt in the same keypress (CODAGT-956). +export const TrailingPathEnterSubmits: Story = { + args: { + slashCommands: [COMPACT_SLASH_COMMAND], + onEnter: fn(), + }, + play: async ({ canvasElement, args }) => { + const editor = await typeInEditor(canvasElement, "check /var/log/syslog"); + expect( + await findVisibleText("No personal skills match that query."), + ).toBeDefined(); + await userEvent.keyboard("{Enter}"); + expect(args.onEnter).toHaveBeenCalledTimes(1); + await expectNoVisibleText("No personal skills match that query."); + expect(editor.textContent).toBe("check /var/log/syslog"); + }, +}; + +// The menu anchors where "/" was typed and must not follow the caret +// as the query grows. +export const MenuStaysAnchoredWhileTyping: Story = { + play: async ({ canvasElement }) => { + await typeInEditor(canvasElement, "/re"); + const item = await findVisibleText("/reviewer"); + const wrapper = item.closest( + "[data-radix-popper-content-wrapper]", + ); + expect(wrapper).not.toBeNull(); + if (!wrapper) return; + const before = wrapper.getBoundingClientRect(); + await userEvent.keyboard("view"); + await findVisibleText("/reviewer"); + // Headless runs do not fire floating-ui's layout-shift tracking, + // so force a reposition through its resize listener, then hold + // the position stable long enough to catch a moved anchor. + window.dispatchEvent(new Event("resize")); + for (let frame = 0; frame < 30; frame++) { + const rect = wrapper.getBoundingClientRect(); + expect({ top: rect.top, left: rect.left }).toEqual({ + top: before.top, + left: before.left, + }); + await new Promise((resolve) => requestAnimationFrame(resolve)); + } + }, +}; + export const FiltersByQuery: Story = { play: async ({ canvasElement }) => { await typeInEditor(canvasElement, "/rev"); diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx index b8f62a420dd..4803630d8a5 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.stories.tsx @@ -240,3 +240,17 @@ export const SelectsCommandByClick: Story = { expect(args.onSelect).toHaveBeenCalledWith(compactCommandItem); }, }; + +// The menu opens above its anchor on desktop, matching the mobile +// pinned-above-composer placement (CODAGT-956). +export const OpensAboveAnchor: Story = { + args: { + anchorRect: { top: 400, left: 80, height: 20 }, + }, + play: async () => { + const item = await findVisibleText("/reviewer"); + const content = item.closest("[data-side]"); + expect(content).not.toBeNull(); + expect(content).toHaveAttribute("data-side", "top"); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx index d0f03d841ec..d2ee81c000d 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx @@ -256,7 +256,7 @@ export const SkillsTriggerMenu = ({ )} event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()} diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx index 9d314789426..7690789bc09 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx @@ -9,6 +9,7 @@ import { KEY_ENTER_COMMAND, KEY_ESCAPE_COMMAND, KEY_TAB_COMMAND, + type LexicalEditor, type NodeKey, } from "lexical"; import { useEffect, useEffectEvent, useLayoutEffect, useRef } from "react"; @@ -66,6 +67,38 @@ const currentCaretRect = (): CaretAnchorRect | null => { }; }; +// Anchors the menu to the slash character itself so it stays put +// while the user types the query, instead of following the caret. +const slashAnchorRect = ( + editor: LexicalEditor, + trigger: Omit, +): CaretAnchorRect | null => { + const element = editor.getElementByKey(trigger.nodeKey); + const textNode = element?.firstChild; + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) { + return null; + } + + const textLength = textNode.textContent?.length ?? 0; + if (trigger.slashOffset >= textLength) { + return null; + } + + const range = document.createRange(); + range.setStart(textNode, trigger.slashOffset); + range.setEnd(textNode, trigger.slashOffset + 1); + const rect = range.getBoundingClientRect(); + if ((rect.width === 0 && rect.height === 0) || Number.isNaN(rect.top)) { + return null; + } + + return { + top: rect.top, + left: rect.left, + height: rect.height, + }; +}; + const isSameTrigger = ( trigger: DismissedSkillsTrigger, dismissedTrigger: DismissedSkillsTrigger | null, @@ -148,7 +181,7 @@ export const SkillsTriggerPlugin = ({ onTriggerChange({ ...trigger, - anchorRect: currentCaretRect(), + anchorRect: slashAnchorRect(editor, trigger) ?? currentCaretRect(), }); }); @@ -193,11 +226,19 @@ export const SkillsTriggerPlugin = ({ if (!open) { return false; } - event?.preventDefault(); const skill = selectedIndex >= 0 ? skills[selectedIndex] : undefined; - if (skill) { - onSkillSelect(skill); + if (!skill) { + // Nothing is selectable (e.g. the trailing token is a filesystem + // path, not a skill): dismiss the menu and let the same keypress + // fall through to the submit handler. + dismissedTriggerRef.current = editor + .getEditorState() + .read(() => activeTriggerFromSelection()); + onTriggerChange(null); + return false; } + event?.preventDefault(); + onSkillSelect(skill); return true; }); From 765d5d3bdc3c33bea579c26bc48dd9dd9390d941 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:26:56 +0000 Subject: [PATCH 2/5] fix(site): use bare dispatchEvent browser global in story --- .../components/ChatMessageInput/ChatMessageInput.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx index 655bed10e3c..5e571239f03 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx @@ -154,7 +154,7 @@ export const MenuStaysAnchoredWhileTyping: Story = { // Headless runs do not fire floating-ui's layout-shift tracking, // so force a reposition through its resize listener, then hold // the position stable long enough to catch a moved anchor. - window.dispatchEvent(new Event("resize")); + dispatchEvent(new Event("resize")); for (let frame = 0; frame < 30; frame++) { const rect = wrapper.getBoundingClientRect(); expect({ top: rect.top, left: rect.left }).toEqual({ From 72e1ca5c1d3d482e0dfd141b2b63d8fa1717d1ba Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:37:01 +0000 Subject: [PATCH 3/5] fix(site): keep slash menu consuming enter while skill sources load A zero-match menu during a pending skills fetch is not a resolved no-match: submitting then would send a token that may become a skill trigger once results arrive, and dismissing would keep the menu from reopening for them. --- .../ChatMessageInput.stories.tsx | 19 +++++++++++++++++++ .../ChatMessageInput/ChatMessageInput.tsx | 6 ++++++ .../ChatMessageInput/SkillsTriggerPlugin.tsx | 14 ++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx index 5e571239f03..b163b229cee 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.stories.tsx @@ -319,6 +319,25 @@ export const EmptyPersonalKeepsMenuOpenWhileWorkspaceSkillsUnknown: Story = { }, }; +// While a skill source is still loading, a zero-match Enter must keep +// the menu open instead of submitting a token that may become a skill +// trigger once results arrive. +export const EnterWhileSkillsLoadingDoesNotSubmit: Story = { + args: { + personalSkillsOverride: [], + hasWorkspace: true, + onEnter: fn(), + }, + play: async ({ canvasElement, args }) => { + const editor = await typeInEditor(canvasElement, "/rev"); + expect(await findVisibleText("Loading workspace skills...")).toBeDefined(); + await userEvent.keyboard("{Enter}"); + expect(args.onEnter).not.toHaveBeenCalled(); + expect(await findVisibleText("Loading workspace skills...")).toBeDefined(); + expect(editor.textContent).toBe("/rev"); + }, +}; + export const QualifiedPersonalQueryMatchesBareTrigger: Story = { args: { hasWorkspace: true, diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx index fdb04d3e21e..c07a308b3e1 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx @@ -980,6 +980,12 @@ const ChatMessageInput = ({ void; onTriggerChange: (trigger: ActiveSkillsTrigger | null) => void; @@ -145,6 +146,7 @@ const activeTriggerFromSelection = (): Omit< export const SkillsTriggerPlugin = ({ open, skills, + skillsLoading, selectedIndex, onSelectedIndexChange, onTriggerChange, @@ -228,6 +230,12 @@ export const SkillsTriggerPlugin = ({ } const skill = selectedIndex >= 0 ? skills[selectedIndex] : undefined; if (!skill) { + // A still-loading source may yet produce matches, so keep + // consuming Enter until every source resolves. + if (skillsLoading) { + event?.preventDefault(); + return true; + } // Nothing is selectable (e.g. the trailing token is a filesystem // path, not a skill): dismiss the menu and let the same keypress // fall through to the submit handler. @@ -248,6 +256,12 @@ export const SkillsTriggerPlugin = ({ } const skill = selectedIndex >= 0 ? skills[selectedIndex] : undefined; if (!skill) { + // A still-loading source may yet produce matches, so keep + // consuming Enter until every source resolves. + if (skillsLoading) { + event?.preventDefault(); + return true; + } return false; } event?.preventDefault(); From 8d333b5d15b3913337e2efeb20fbacbe03a2a235 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:48:09 +0000 Subject: [PATCH 4/5] fix(site): let no-match tab fall through while skills load --- .../components/ChatMessageInput/SkillsTriggerPlugin.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx index 3dafb38c785..c8646fe4ac9 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx @@ -256,12 +256,6 @@ export const SkillsTriggerPlugin = ({ } const skill = selectedIndex >= 0 ? skills[selectedIndex] : undefined; if (!skill) { - // A still-loading source may yet produce matches, so keep - // consuming Enter until every source resolves. - if (skillsLoading) { - event?.preventDefault(); - return true; - } return false; } event?.preventDefault(); From 8fa8069cccdb83e603f6cc32c98bea19597bc5f3 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:29:16 +0000 Subject: [PATCH 5/5] fix(site): pin slash menu above the composer at composer width Anchor the menu to the composer box instead of a caret-derived rect so the desktop presentation matches mobile: full composer width, pinned above the input. This removes the caret rect plumbing entirely. --- .../AgentsPage/components/AgentChatInput.tsx | 1 + .../ChatMessageInput/ChatMessageInput.tsx | 16 ++-- .../SkillsTriggerMenu.stories.tsx | 51 ++++++++--- .../ChatMessageInput/SkillsTriggerMenu.tsx | 45 ++-------- .../ChatMessageInput/SkillsTriggerPlugin.tsx | 90 +------------------ 5 files changed, 62 insertions(+), 141 deletions(-) diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 40ac3342447..ab7b7390400 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -1190,6 +1190,7 @@ export const AgentChatInput: FC = ({ workspaceSkills={workspaceSkills} autoFocus slashCommands={slashCommands} + skillsMenuAnchor={composerElement} /> {/* Warn about invisible Unicode in the message text. * Unlike the admin/user prompt textareas (which strip diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx index c07a308b3e1..dafc45a01e6 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx @@ -528,6 +528,11 @@ interface ChatMessageInputProps * composer intercepts it at submit time. */ slashCommands?: readonly ChatSlashCommand[]; + /** + * Composer box element the skills menu is pinned above and sized + * to match. Falls back to this component's own container. + */ + skillsMenuAnchor?: HTMLElement | null; "aria-label"?: string; } @@ -572,10 +577,7 @@ const isSameSkillsTrigger = ( return ( a.nodeKey === b.nodeKey && a.slashOffset === b.slashOffset && - a.query === b.query && - a.anchorRect?.top === b.anchorRect?.top && - a.anchorRect?.left === b.anchorRect?.left && - a.anchorRect?.height === b.anchorRect?.height + a.query === b.query ); }; @@ -597,6 +599,7 @@ const ChatMessageInput = ({ personalSkillsOverride, workspaceSkills, slashCommands, + skillsMenuAnchor, "aria-label": ariaLabel, ref, ...props @@ -622,6 +625,8 @@ const ChatMessageInput = ({ const pendingReplacementRef = useRef(null); const [skillsTrigger, setSkillsTrigger] = useState(null); + const [containerElement, setContainerElement] = + useState(null); const suppressedSkillsTriggerRef = useRef(null); const [skillsMenuSelectedIndex, setSkillsMenuSelectedIndex] = useState(0); const hasSkillsTrigger = Boolean(skillsTrigger); @@ -935,6 +940,7 @@ const ChatMessageInput = ({ return (
*]:col-start-1 [&>*]:row-start-1", disabled && "cursor-not-allowed opacity-50", @@ -995,7 +1001,7 @@ const ChatMessageInput = ({ {autoFocus && } createSkillMenuItem("workspace", skill), ); +// Provides the composer-box element the menu anchors to, since the +// menu is pinned above its anchor at the anchor's width. +const MenuStoryHarness = (args: ComponentProps) => { + const [anchor, setAnchor] = useState(null); + return ( + <> +
+ Mock composer +
+ + + ); +}; + const meta: Meta = { title: "components/ChatMessageInput/SkillsTriggerMenu", component: SkillsTriggerMenu, args: { open: true, - anchorRect: { top: 120, left: 80, height: 20 }, + anchor: null, query: "", personalSkills: mockPersonalSkillItems, workspaceSkills: [], @@ -50,12 +67,10 @@ const meta: Meta = { onSelect: fn(), onClose: fn(), }, + render: (args) => , decorators: [ (Story) => ( -
-

- The menu is anchored to a mock caret position. -

+
), @@ -161,13 +176,16 @@ const SelectionScrollHarness = ( args: ComponentProps, ) => { const [selectedIndex, setSelectedIndex] = useState(0); + const [anchor, setAnchor] = useState(null); return ( <> +
@@ -241,16 +259,25 @@ export const SelectsCommandByClick: Story = { }, }; -// The menu opens above its anchor on desktop, matching the mobile -// pinned-above-composer placement (CODAGT-956). -export const OpensAboveAnchor: Story = { - args: { - anchorRect: { top: 400, left: 80, height: 20 }, - }, +// The menu opens above its anchor at the anchor's exact width, +// matching the mobile pinned-above-composer placement (CODAGT-956). +export const OpensAboveAnchorAtAnchorWidth: Story = { play: async () => { const item = await findVisibleText("/reviewer"); const content = item.closest("[data-side]"); expect(content).not.toBeNull(); expect(content).toHaveAttribute("data-side", "top"); + const anchorBox = (await findVisibleText("Mock composer")).closest("div"); + expect(anchorBox).not.toBeNull(); + if (!anchorBox || !(content instanceof HTMLElement)) return; + // The entrance animation scales the content from 95%, so wait + // for the settled geometry. + await waitFor(() => { + const anchorRect = anchorBox.getBoundingClientRect(); + const contentRect = content.getBoundingClientRect(); + expect(contentRect.width).toBeCloseTo(anchorRect.width, 0); + expect(contentRect.left).toBeCloseTo(anchorRect.left, 0); + expect(contentRect.bottom).toBeLessThanOrEqual(anchorRect.top); + }); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx index d2ee81c000d..317d54b23d9 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerMenu.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState } from "react"; +import { useLayoutEffect, useRef } from "react"; import { Command, CommandEmpty, @@ -13,15 +13,6 @@ import { } from "#/components/Popover/Popover"; import { cn } from "#/utils/cn"; -// Prevent zero-height anchors when the browser returns a degenerate caret rect. -const MIN_ANCHOR_HEIGHT_PX = 16; - -export type CaretAnchorRect = { - top: number; - left: number; - height: number; -}; - type SkillSource = "personal" | "workspace"; export type SkillMetadata = { @@ -67,7 +58,8 @@ export const createSkillMenuItem = ( type SkillsTriggerMenuProps = { open: boolean; - anchorRect: CaretAnchorRect | null; + // The composer box the menu is pinned above and sized to match. + anchor: HTMLElement | null; query: string; commands?: readonly SkillMenuItem[]; personalSkills: readonly SkillMenuItem[]; @@ -160,7 +152,7 @@ const SkillCommandItem = ({ export const SkillsTriggerMenu = ({ open, - anchorRect, + anchor, query, commands = [], personalSkills, @@ -186,15 +178,7 @@ export const SkillsTriggerMenu = ({ ? "Loading workspace skills..." : 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 shouldRender = open && anchor !== null; const shouldShowEmpty = allSkills.length === 0 && statusItems.length === 0; const selectedValue = selectedIndex >= 0 ? String(selectedIndex) : ""; @@ -239,25 +223,12 @@ export const SkillsTriggerMenu = ({ } }} > - {renderedAnchorRect && ( - - - )} + {anchor && } event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()} onCloseAutoFocus={(event) => event.preventDefault()} diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx index c8646fe4ac9..1f796dea3c5 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/SkillsTriggerPlugin.tsx @@ -9,18 +9,16 @@ import { KEY_ENTER_COMMAND, KEY_ESCAPE_COMMAND, KEY_TAB_COMMAND, - type LexicalEditor, type NodeKey, } from "lexical"; import { useEffect, useEffectEvent, useLayoutEffect, useRef } from "react"; import { parsePersonalSkillTrigger } from "../../utils/personalSkills"; -import type { CaretAnchorRect, SkillMenuItem } from "./SkillsTriggerMenu"; +import type { SkillMenuItem } from "./SkillsTriggerMenu"; export type ActiveSkillsTrigger = { nodeKey: NodeKey; slashOffset: number; query: string; - anchorRect: CaretAnchorRect | null; }; type DismissedSkillsTrigger = Pick< @@ -38,68 +36,6 @@ type SkillsTriggerPluginProps = { onSkillSelect: (skill: SkillMenuItem) => void; }; -const currentCaretRect = (): CaretAnchorRect | null => { - const selection = getSelection(); - if (!selection || selection.rangeCount === 0) { - return null; - } - - const range = selection.getRangeAt(0); - let rect = range.getBoundingClientRect(); - if ((rect.width === 0 && rect.height === 0) || Number.isNaN(rect.top)) { - const fallbackRange = range.cloneRange(); - if (fallbackRange.startOffset > 0) { - fallbackRange.setStart( - fallbackRange.startContainer, - fallbackRange.startOffset - 1, - ); - } - rect = fallbackRange.getBoundingClientRect(); - } - - if (Number.isNaN(rect.top)) { - return null; - } - - return { - top: rect.top, - left: rect.left, - height: rect.height, - }; -}; - -// Anchors the menu to the slash character itself so it stays put -// while the user types the query, instead of following the caret. -const slashAnchorRect = ( - editor: LexicalEditor, - trigger: Omit, -): CaretAnchorRect | null => { - const element = editor.getElementByKey(trigger.nodeKey); - const textNode = element?.firstChild; - if (!textNode || textNode.nodeType !== Node.TEXT_NODE) { - return null; - } - - const textLength = textNode.textContent?.length ?? 0; - if (trigger.slashOffset >= textLength) { - return null; - } - - const range = document.createRange(); - range.setStart(textNode, trigger.slashOffset); - range.setEnd(textNode, trigger.slashOffset + 1); - const rect = range.getBoundingClientRect(); - if ((rect.width === 0 && rect.height === 0) || Number.isNaN(rect.top)) { - return null; - } - - return { - top: rect.top, - left: rect.left, - height: rect.height, - }; -}; - const isSameTrigger = ( trigger: DismissedSkillsTrigger, dismissedTrigger: DismissedSkillsTrigger | null, @@ -110,10 +46,7 @@ const isSameTrigger = ( ); }; -const activeTriggerFromSelection = (): Omit< - ActiveSkillsTrigger, - "anchorRect" -> | null => { +const activeTriggerFromSelection = (): ActiveSkillsTrigger | null => { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) { return null; @@ -181,30 +114,13 @@ export const SkillsTriggerPlugin = ({ return; } - onTriggerChange({ - ...trigger, - anchorRect: slashAnchorRect(editor, trigger) ?? currentCaretRect(), - }); + onTriggerChange(trigger); }); useEffect(() => { return editor.registerUpdateListener(() => refreshTrigger()); }, [editor]); - useEffect(() => { - return editor.registerRootListener((rootElement, previousRootElement) => { - previousRootElement?.removeEventListener("scroll", refreshTrigger); - rootElement?.addEventListener("scroll", refreshTrigger, { - passive: true, - }); - }); - }, [editor]); - - useEffect(() => { - addEventListener("resize", refreshTrigger); - return () => removeEventListener("resize", refreshTrigger); - }, []); - const moveMenuHighlight = useEffectEvent( (event: KeyboardEvent, delta: number) => { if (!open) {