fix(site): improve chat slash menu enter handling, anchoring, and placement (#28411)

When the last token of a follow-up prompt is an absolute filesystem path
(e.g. `check /var/log/syslog`), the skill slash menu opened with "No
skills match that query." and swallowed Enter, so the prompt could not
be submitted without adding a trailing space or quoting the path.
Follow-up composers always pass slash commands, so the menu opened for
any `/`-token; the Enter handler then consumed the keypress
unconditionally while the menu was open.

Three fixes (CODAGT-956):

- Enter with no selectable match now dismisses the menu and lets the
same keypress fall through to the submit handler. While a skill source
is still loading (personal skills fetching, or workspace skills
unknown), a zero-match Enter stays consumed so a pending fetch cannot
let a would-be skill trigger submit as raw text; a no-match Tab still
falls through so keyboard users can leave the composer.
- The menu is anchored to the composer box instead of a caret-derived
rect, so it no longer follows the cursor while the query is typed.
- The menu renders directly above the chat input at exactly the
composer's width on desktop, matching its pinned-above-composer
presentation on small viewports. The caret-rect plumbing (live caret
measurement, fixed 1px anchor span, scroll/resize listeners) is removed.

Storybook interaction coverage was updated accordingly:
`FilteredEmptyEnterClosesAndSubmits` (replaces
`FilteredEmptyKeepsMenuOpen`), `TrailingPathEnterSubmits`,
`EnterWhileSkillsLoadingDoesNotSubmit`, `MenuStaysAnchoredWhileTyping`,
and `OpensAboveAnchorAtAnchorWidth`. Each new story was verified red
against the reverted fix and green with it. Remote dogfood UAT validated
the Enter/anchoring/placement scenarios and regressions in the real UI
across two rounds (round 2 covering the composer-width presentation).

Fixes
https://linear.app/codercom/issue/CODAGT-956/slash-menu-improvements

> 🤖 This pull request was created by Mux on behalf of @ibetitsmike.

<!-- mux-attribution: model=claude-opus-4-6 thinking=high -->
This commit is contained in:
Michael Suchacz
2026-08-20 22:35:35 +02:00
committed by GitHub
parent bb7f2f2332
commit 6f1d225d2e
6 changed files with 166 additions and 108 deletions
@@ -1190,6 +1190,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
workspaceSkills={workspaceSkills}
autoFocus
slashCommands={slashCommands}
skillsMenuAnchor={composerElement}
/>
{/* Warn about invisible Unicode in the message text.
* Unlike the admin/user prompt textareas (which strip
@@ -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<HTMLElement>(
"[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.
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");
@@ -270,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,
@@ -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<string | null>(null);
const [skillsTrigger, setSkillsTrigger] =
useState<ActiveSkillsTrigger | null>(null);
const [containerElement, setContainerElement] =
useState<HTMLDivElement | null>(null);
const suppressedSkillsTriggerRef = useRef<SkillsTriggerLocation | null>(null);
const [skillsMenuSelectedIndex, setSkillsMenuSelectedIndex] = useState(0);
const hasSkillsTrigger = Boolean(skillsTrigger);
@@ -935,6 +940,7 @@ const ChatMessageInput = ({
return (
<LexicalComposer initialConfig={initialConfig} key={remountKey}>
<div
ref={setContainerElement}
className={cn(
"grid w-full rounded-md bg-transparent text-base placeholder:text-content-secondary focus-visible:outline-none whitespace-pre-wrap break-words [&>*]:col-start-1 [&>*]:row-start-1",
disabled && "cursor-not-allowed opacity-50",
@@ -980,6 +986,12 @@ const ChatMessageInput = ({
<SkillsTriggerPlugin
open={skillsMenuOpen}
skills={allFilteredSkills}
skillsLoading={
(personalSkillsQueryEnabled &&
skillsQuery.isFetching &&
skillsQuery.data === undefined) ||
!workspaceSkillsKnown
}
selectedIndex={selectedSkillIndex}
onSelectedIndexChange={setSkillsMenuSelectedIndex}
onTriggerChange={handleSkillsTriggerChange}
@@ -989,7 +1001,7 @@ const ChatMessageInput = ({
{autoFocus && <AutoFocusPlugin />}
<SkillsTriggerMenu
open={skillsMenuOpen}
anchorRect={skillsTrigger?.anchorRect ?? null}
anchor={skillsMenuAnchor ?? containerElement}
query={skillsSearchQuery}
commands={commandMenuItems}
personalSkills={personalSkillItems}
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type ComponentProps, useState } from "react";
import { expect, fn, userEvent } from "storybook/test";
import { expect, fn, userEvent, waitFor } from "storybook/test";
import { filterSkillsByQuery } from "../../utils/personalSkills";
import { COMPACT_SLASH_COMMAND } from "../../utils/slashCommands";
import {
@@ -35,12 +35,29 @@ const mockWorkspaceSkillItems = mockWorkspaceSkills.map((skill) =>
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<typeof SkillsTriggerMenu>) => {
const [anchor, setAnchor] = useState<HTMLDivElement | null>(null);
return (
<>
<div
ref={setAnchor}
className="mt-64 h-16 w-64 rounded-md border border-border border-solid p-2 text-content-secondary text-sm"
>
Mock composer
</div>
<SkillsTriggerMenu {...args} anchor={anchor} />
</>
);
};
const meta: Meta<typeof SkillsTriggerMenu> = {
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<typeof SkillsTriggerMenu> = {
onSelect: fn(),
onClose: fn(),
},
render: (args) => <MenuStoryHarness {...args} />,
decorators: [
(Story) => (
<div className="h-80 p-6">
<p className="text-content-secondary text-sm">
The menu is anchored to a mock caret position.
</p>
<div className="h-96 p-6">
<Story />
</div>
),
@@ -161,13 +176,16 @@ const SelectionScrollHarness = (
args: ComponentProps<typeof SkillsTriggerMenu>,
) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const [anchor, setAnchor] = useState<HTMLDivElement | null>(null);
return (
<>
<button type="button" onClick={() => setSelectedIndex(29)}>
Highlight last skill
</button>
<div ref={setAnchor} className="mt-96 h-16 w-96" />
<SkillsTriggerMenu
{...args}
anchor={anchor}
selectedIndex={selectedIndex}
onSelectedIndexChange={setSelectedIndex}
/>
@@ -240,3 +258,26 @@ export const SelectsCommandByClick: Story = {
expect(args.onSelect).toHaveBeenCalledWith(compactCommandItem);
},
};
// 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);
});
},
};
@@ -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 && (
<PopoverAnchor asChild>
<span
aria-hidden="true"
style={{
position: "fixed",
top: renderedAnchorRect.top,
left: renderedAnchorRect.left,
width: 1,
height: Math.max(renderedAnchorRect.height, MIN_ANCHOR_HEIGHT_PX),
pointerEvents: "none",
}}
/>
</PopoverAnchor>
)}
{anchor && <PopoverAnchor virtualRef={{ current: anchor }} />}
<PopoverContent
align="start"
side="bottom"
className="w-80 overflow-hidden p-1 mobile-full-width-dropdown mobile-full-width-dropdown-above-composer"
side="top"
sideOffset={8}
className="w-[var(--radix-popper-anchor-width)] overflow-hidden p-1 mobile-full-width-dropdown mobile-full-width-dropdown-above-composer"
onMouseDown={(event) => event.preventDefault()}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
@@ -13,13 +13,12 @@ import {
} 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<
@@ -30,42 +29,13 @@ type DismissedSkillsTrigger = Pick<
type SkillsTriggerPluginProps = {
open: boolean;
skills: readonly SkillMenuItem[];
skillsLoading: boolean;
selectedIndex: number;
onSelectedIndexChange: (index: number) => void;
onTriggerChange: (trigger: ActiveSkillsTrigger | null) => void;
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,
};
};
const isSameTrigger = (
trigger: DismissedSkillsTrigger,
dismissedTrigger: DismissedSkillsTrigger | null,
@@ -76,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;
@@ -112,6 +79,7 @@ const activeTriggerFromSelection = (): Omit<
export const SkillsTriggerPlugin = ({
open,
skills,
skillsLoading,
selectedIndex,
onSelectedIndexChange,
onTriggerChange,
@@ -146,30 +114,13 @@ export const SkillsTriggerPlugin = ({
return;
}
onTriggerChange({
...trigger,
anchorRect: 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) {
@@ -193,11 +144,25 @@ export const SkillsTriggerPlugin = ({
if (!open) {
return false;
}
event?.preventDefault();
const skill = selectedIndex >= 0 ? skills[selectedIndex] : undefined;
if (skill) {
onSkillSelect(skill);
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.
dismissedTriggerRef.current = editor
.getEditorState()
.read(() => activeTriggerFromSelection());
onTriggerChange(null);
return false;
}
event?.preventDefault();
onSkillSelect(skill);
return true;
});