diff --git a/src/components/ai-elements/prompt-lexical-editor.commands.ts b/src/components/ai-elements/prompt-lexical-editor.commands.ts new file mode 100644 index 0000000..19d84c0 --- /dev/null +++ b/src/components/ai-elements/prompt-lexical-editor.commands.ts @@ -0,0 +1,20 @@ +import { + COMMAND_PRIORITY_CRITICAL, + KEY_ENTER_COMMAND, + type LexicalEditor, +} from "lexical"; + +/** + * The prompt composer handles plain Enter during React's capture phase. + * Lexical's plain-text plugin does not honor `defaultPrevented` and would + * otherwise insert a trailing line break after the prompt has been sent. + */ +export function registerPromptLexicalPreventedEnterCommand( + editor: LexicalEditor, +) { + return editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => event?.defaultPrevented === true, + COMMAND_PRIORITY_CRITICAL, + ); +} diff --git a/src/components/ai-elements/prompt-lexical-editor.tsx b/src/components/ai-elements/prompt-lexical-editor.tsx index 19dffcd..52a07d3 100644 --- a/src/components/ai-elements/prompt-lexical-editor.tsx +++ b/src/components/ai-elements/prompt-lexical-editor.tsx @@ -54,6 +54,9 @@ import { } from "@/lib/prompt-token-chips"; import type { WorkspaceInformationReferenceOption } from "@/lib/workspace-information-references"; import { cn } from "@/lib/utils"; +import { + registerPromptLexicalPreventedEnterCommand, +} from "./prompt-lexical-editor.commands"; import { PromptTokenChip } from "./prompt-token-chip"; const PROMPT_SYNC_TAG = "stave-prompt-sync"; @@ -576,6 +579,17 @@ function PromptLexicalTokenDeletionPlugin() { return null; } +function PromptLexicalPreventedEnterPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect( + () => registerPromptLexicalPreventedEnterCommand(editor), + [editor], + ); + + return null; +} + function PromptLexicalImperativePlugin(args: { forwardedRef: ForwardedRef; }) { @@ -783,6 +797,7 @@ export const PromptLexicalEditor = forwardRef< + { + test("consumes Enter after the prompt composer prevents it", () => { + const editor = createEditor({ namespace: "prompt-enter-prevented" }); + let plainTextHandlerCalls = 0; + const unregisterGuard = + registerPromptLexicalPreventedEnterCommand(editor); + const unregisterPlainTextHandler = editor.registerCommand( + KEY_ENTER_COMMAND, + () => { + plainTextHandlerCalls += 1; + return true; + }, + COMMAND_PRIORITY_EDITOR, + ); + + const handled = editor.dispatchCommand(KEY_ENTER_COMMAND, { + defaultPrevented: true, + } as KeyboardEvent); + + expect(handled).toBe(true); + expect(plainTextHandlerCalls).toBe(0); + unregisterPlainTextHandler(); + unregisterGuard(); + }); + + test("leaves multiline Enter behavior to Lexical when it is not prevented", () => { + const editor = createEditor({ namespace: "prompt-enter-multiline" }); + let plainTextHandlerCalls = 0; + const unregisterGuard = + registerPromptLexicalPreventedEnterCommand(editor); + const unregisterPlainTextHandler = editor.registerCommand( + KEY_ENTER_COMMAND, + () => { + plainTextHandlerCalls += 1; + return true; + }, + COMMAND_PRIORITY_EDITOR, + ); + + const handled = editor.dispatchCommand(KEY_ENTER_COMMAND, { + defaultPrevented: false, + } as KeyboardEvent); + + expect(handled).toBe(true); + expect(plainTextHandlerCalls).toBe(1); + unregisterPlainTextHandler(); + unregisterGuard(); + }); +});