fix(web): defer Enter to slash/mention suggestion when menu is open (#928)
This commit is contained in:
@@ -379,6 +379,10 @@ export const TipTapInput = forwardRef<TipTapInputHandle, TipTapInputProps>(funct
|
||||
setSlashMenu,
|
||||
});
|
||||
|
||||
const isSuggestionMenuOpen =
|
||||
(mentionMenu.isOpen && mentionMenu.items.length > 0) ||
|
||||
(slashMenu.isOpen && slashMenu.items.length > 0);
|
||||
|
||||
const editor = useTipTapEditor({
|
||||
value,
|
||||
onChange,
|
||||
@@ -395,6 +399,7 @@ export const TipTapInput = forwardRef<TipTapInputHandle, TipTapInputProps>(funct
|
||||
onImagePaste,
|
||||
mentionSuggestion,
|
||||
slashSuggestion,
|
||||
isSuggestionMenuOpen,
|
||||
ref,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decideSubmitShortcut } from "./use-tiptap-editor";
|
||||
|
||||
describe("decideSubmitShortcut", () => {
|
||||
describe("submitKey=enter", () => {
|
||||
it("submits on Enter when no suggestion menu is open", () => {
|
||||
expect(
|
||||
decideSubmitShortcut({
|
||||
pressed: "enter",
|
||||
disabled: false,
|
||||
submitKey: "enter",
|
||||
isSuggestionMenuOpen: false,
|
||||
}),
|
||||
).toBe("submit");
|
||||
});
|
||||
|
||||
// Regression: when slash/@ suggestion popup is open and the user presses
|
||||
// Enter to pick the highlighted item, the keymap must defer to the
|
||||
// suggestion plugin instead of submitting the message.
|
||||
it("defers to the suggestion plugin when the menu is open", () => {
|
||||
expect(
|
||||
decideSubmitShortcut({
|
||||
pressed: "enter",
|
||||
disabled: false,
|
||||
submitKey: "enter",
|
||||
isSuggestionMenuOpen: true,
|
||||
}),
|
||||
).toBe("defer");
|
||||
});
|
||||
|
||||
it("does not submit on Mod-Enter (Mod-Enter is treated as a newline path)", () => {
|
||||
expect(
|
||||
decideSubmitShortcut({
|
||||
pressed: "mod-enter",
|
||||
disabled: false,
|
||||
submitKey: "enter",
|
||||
isSuggestionMenuOpen: false,
|
||||
}),
|
||||
).toBe("defer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("submitKey=cmd_enter", () => {
|
||||
it("does not submit on Enter — defers (suggestion or newline)", () => {
|
||||
expect(
|
||||
decideSubmitShortcut({
|
||||
pressed: "enter",
|
||||
disabled: false,
|
||||
submitKey: "cmd_enter",
|
||||
isSuggestionMenuOpen: false,
|
||||
}),
|
||||
).toBe("defer");
|
||||
});
|
||||
|
||||
it("submits on Mod-Enter when no menu is open", () => {
|
||||
expect(
|
||||
decideSubmitShortcut({
|
||||
pressed: "mod-enter",
|
||||
disabled: false,
|
||||
submitKey: "cmd_enter",
|
||||
isSuggestionMenuOpen: false,
|
||||
}),
|
||||
).toBe("submit");
|
||||
});
|
||||
|
||||
// Mod-Enter is not a suggestion-pick key (handleMenuKeyDown only handles
|
||||
// Enter and Tab) so the menu state is intentionally ignored — Mod-Enter
|
||||
// always submits in cmd_enter mode.
|
||||
it("submits on Mod-Enter even when the menu is open", () => {
|
||||
expect(
|
||||
decideSubmitShortcut({
|
||||
pressed: "mod-enter",
|
||||
disabled: false,
|
||||
submitKey: "cmd_enter",
|
||||
isSuggestionMenuOpen: true,
|
||||
}),
|
||||
).toBe("submit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("disabled", () => {
|
||||
it("consumes Enter without submitting when the input is disabled", () => {
|
||||
expect(
|
||||
decideSubmitShortcut({
|
||||
pressed: "enter",
|
||||
disabled: true,
|
||||
submitKey: "enter",
|
||||
isSuggestionMenuOpen: false,
|
||||
}),
|
||||
).toBe("consume-noop");
|
||||
});
|
||||
|
||||
it("consumes Mod-Enter without submitting when the input is disabled", () => {
|
||||
expect(
|
||||
decideSubmitShortcut({
|
||||
pressed: "mod-enter",
|
||||
disabled: true,
|
||||
submitKey: "cmd_enter",
|
||||
isSuggestionMenuOpen: false,
|
||||
}),
|
||||
).toBe("consume-noop");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,10 @@ type UseTipTapEditorOptions = {
|
||||
mentionSuggestion: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
slashSuggestion: any;
|
||||
/** True when a slash/@ suggestion menu is open with selectable items. Enter
|
||||
* must defer to the suggestion plugin so the highlighted item is inserted
|
||||
* instead of submitting the message. */
|
||||
isSuggestionMenuOpen: boolean;
|
||||
ref: React.ForwardedRef<TipTapInputHandle>;
|
||||
};
|
||||
|
||||
@@ -127,6 +131,7 @@ function useTipTapRefs(opts: UseTipTapEditorOptions) {
|
||||
const sessionIdRef = useRef(opts.sessionId);
|
||||
const planModeEnabledRef = useRef(opts.planModeEnabled);
|
||||
const onPlanModeChangeRef = useRef(opts.onPlanModeChange);
|
||||
const isSuggestionMenuOpenRef = useRef(opts.isSuggestionMenuOpen);
|
||||
const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts);
|
||||
const keyboardShortcutsRef = useRef(keyboardShortcuts);
|
||||
useLayoutEffect(() => {
|
||||
@@ -138,6 +143,7 @@ function useTipTapRefs(opts: UseTipTapEditorOptions) {
|
||||
sessionIdRef.current = opts.sessionId;
|
||||
planModeEnabledRef.current = opts.planModeEnabled;
|
||||
onPlanModeChangeRef.current = opts.onPlanModeChange;
|
||||
isSuggestionMenuOpenRef.current = opts.isSuggestionMenuOpen;
|
||||
keyboardShortcutsRef.current = keyboardShortcuts;
|
||||
});
|
||||
return {
|
||||
@@ -149,6 +155,7 @@ function useTipTapRefs(opts: UseTipTapEditorOptions) {
|
||||
sessionIdRef,
|
||||
planModeEnabledRef,
|
||||
onPlanModeChangeRef,
|
||||
isSuggestionMenuOpenRef,
|
||||
keyboardShortcutsRef,
|
||||
};
|
||||
}
|
||||
@@ -175,6 +182,7 @@ export function useTipTapEditor(opts: UseTipTapEditorOptions) {
|
||||
onSubmitRef: refs.onSubmitRef,
|
||||
planModeEnabledRef: refs.planModeEnabledRef,
|
||||
onPlanModeChangeRef: refs.onPlanModeChangeRef,
|
||||
isSuggestionMenuOpenRef: refs.isSuggestionMenuOpenRef,
|
||||
keyboardShortcutsRef: refs.keyboardShortcutsRef,
|
||||
});
|
||||
const isSyncingRef = useRef(false);
|
||||
@@ -349,6 +357,30 @@ function syncEditorValue({
|
||||
initialSyncDoneRef.current = true;
|
||||
}
|
||||
|
||||
// ── Submit shortcut decision ────────────────────────────────────────
|
||||
|
||||
export type SubmitShortcutDecision = "consume-noop" | "submit" | "defer";
|
||||
|
||||
/** Pure decision for whether an Enter/Mod-Enter press in the chat input should
|
||||
* submit the message, defer to the next ProseMirror handler (e.g. the slash/
|
||||
* mention suggestion plugin or paragraph-split), or no-op while disabled.
|
||||
* Kept pure so the keymap contract is unit-testable without mounting TipTap. */
|
||||
export function decideSubmitShortcut(args: {
|
||||
pressed: "enter" | "mod-enter";
|
||||
disabled: boolean;
|
||||
submitKey: "enter" | "cmd_enter";
|
||||
isSuggestionMenuOpen: boolean;
|
||||
}): SubmitShortcutDecision {
|
||||
if (args.disabled) return "consume-noop";
|
||||
if (args.pressed === "enter") {
|
||||
if (args.submitKey !== "enter") return "defer";
|
||||
if (args.isSuggestionMenuOpen) return "defer";
|
||||
return "submit";
|
||||
}
|
||||
if (args.submitKey !== "cmd_enter") return "defer";
|
||||
return "submit";
|
||||
}
|
||||
|
||||
// ── Submit keymap hook ──────────────────────────────────────────────
|
||||
|
||||
function useSubmitKeymap(refs: {
|
||||
@@ -357,6 +389,7 @@ function useSubmitKeymap(refs: {
|
||||
onSubmitRef: React.RefObject<(() => void) | undefined>;
|
||||
planModeEnabledRef: React.RefObject<boolean>;
|
||||
onPlanModeChangeRef: React.RefObject<((enabled: boolean) => void) | undefined>;
|
||||
isSuggestionMenuOpenRef: React.RefObject<boolean>;
|
||||
keyboardShortcutsRef: React.RefObject<StoredShortcutOverrides | undefined>;
|
||||
}) {
|
||||
const {
|
||||
@@ -365,29 +398,30 @@ function useSubmitKeymap(refs: {
|
||||
onSubmitRef,
|
||||
planModeEnabledRef,
|
||||
onPlanModeChangeRef,
|
||||
isSuggestionMenuOpenRef,
|
||||
keyboardShortcutsRef,
|
||||
} = refs;
|
||||
return useMemo(() => {
|
||||
return Extension.create({
|
||||
name: "submitKeymap",
|
||||
addKeyboardShortcuts() {
|
||||
const run = (pressed: "enter" | "mod-enter") => {
|
||||
const decision = decideSubmitShortcut({
|
||||
pressed,
|
||||
disabled: !!disabledRef.current,
|
||||
submitKey: submitKeyRef.current ?? "cmd_enter",
|
||||
isSuggestionMenuOpen: isSuggestionMenuOpenRef.current,
|
||||
});
|
||||
if (decision === "consume-noop") return true;
|
||||
if (decision === "submit") {
|
||||
onSubmitRef.current?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return {
|
||||
Enter: () => {
|
||||
if (disabledRef.current) return true;
|
||||
if (submitKeyRef.current === "enter") {
|
||||
onSubmitRef.current?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
"Mod-Enter": () => {
|
||||
if (disabledRef.current) return true;
|
||||
if (submitKeyRef.current === "cmd_enter") {
|
||||
onSubmitRef.current?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
Enter: () => run("enter"),
|
||||
"Mod-Enter": () => run("mod-enter"),
|
||||
};
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
|
||||
Reference in New Issue
Block a user