mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
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:
@@ -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}
|
||||
|
||||
+47
-6
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user