From 26a641ad13d1c1dcb8ea06e03caa0ea290bd7884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 17 May 2026 21:56:22 +0100 Subject: [PATCH] fix(web): defer Enter to slash/mention suggestion when menu is open (#928) --- .../web/components/task/chat/tiptap-input.tsx | 5 + .../task/chat/use-tiptap-editor.test.ts | 104 ++++++++++++++++++ .../components/task/chat/use-tiptap-editor.ts | 66 ++++++++--- 3 files changed, 159 insertions(+), 16 deletions(-) create mode 100644 apps/web/components/task/chat/use-tiptap-editor.test.ts diff --git a/apps/web/components/task/chat/tiptap-input.tsx b/apps/web/components/task/chat/tiptap-input.tsx index 3a3bd17184..77bc06887e 100644 --- a/apps/web/components/task/chat/tiptap-input.tsx +++ b/apps/web/components/task/chat/tiptap-input.tsx @@ -379,6 +379,10 @@ export const TipTapInput = forwardRef(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(funct onImagePaste, mentionSuggestion, slashSuggestion, + isSuggestionMenuOpen, ref, }); diff --git a/apps/web/components/task/chat/use-tiptap-editor.test.ts b/apps/web/components/task/chat/use-tiptap-editor.test.ts new file mode 100644 index 0000000000..ab0b40ba7f --- /dev/null +++ b/apps/web/components/task/chat/use-tiptap-editor.test.ts @@ -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"); + }); + }); +}); diff --git a/apps/web/components/task/chat/use-tiptap-editor.ts b/apps/web/components/task/chat/use-tiptap-editor.ts index 02f6345f40..a8cdaefdc3 100644 --- a/apps/web/components/task/chat/use-tiptap-editor.ts +++ b/apps/web/components/task/chat/use-tiptap-editor.ts @@ -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; }; @@ -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; onPlanModeChangeRef: React.RefObject<((enabled: boolean) => void) | undefined>; + isSuggestionMenuOpenRef: React.RefObject; keyboardShortcutsRef: React.RefObject; }) { 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() {