diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx index dcf643bf11..582078bfa3 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx @@ -41,6 +41,7 @@ import { $createFileReferenceNode, FileReferenceNode, } from "./FileReferenceNode"; +import { IOSBackspacePlugin } from "./iosBackspace"; import { createPasteFile, getPasteDataTransfer, @@ -376,7 +377,7 @@ const ValueSyncPlugin: FC<{ editor.setEditorState(parsed); return; } catch { - // Malformed state — fall through to plain-text path. + // Malformed state, fall through to plain-text path. } } @@ -736,6 +737,7 @@ const ChatMessageInput = ({ onEnter={disabled ? undefined : onEnter} sendShortcut={sendShortcut} /> + void) { + const editor = createEditor({ + namespace: "ios-backspace-test", + nodes: [FileReferenceNode], + onError: (error) => { + throw error; + }, + }); + let result = false; + + editor.update( + () => { + setup(); + result = $shouldUseNativeBackspace($getSelection()); + }, + { discrete: true }, + ); + + return result; +} + +describe("$shouldUseNativeBackspace", () => { + it("allows native deletion from plain text selections", () => { + const result = readBackspaceDecision(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text = $createTextNode("hello"); + root.append(paragraph); + paragraph.append(text); + text.select(5, 5); + }); + + expect(result).toBe(true); + }); + + it("allows native deletion when a file reference precedes a mid-text caret", () => { + const result = readBackspaceDecision(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const fileReference = $createFileReferenceNode( + "main.go", + 1, + 1, + "package main", + ); + const text = $createTextNode("hello"); + root.append(paragraph); + paragraph.append(fileReference, text); + text.select(3, 3); + }); + + expect(result).toBe(true); + }); + + it("keeps Lexical deletion when backspace would hit a file reference", () => { + const result = readBackspaceDecision(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const fileReference = $createFileReferenceNode( + "main.go", + 1, + 1, + "package main", + ); + const text = $createTextNode("hello"); + root.append(paragraph); + paragraph.append(fileReference, text); + text.select(0, 0); + }); + + expect(result).toBe(false); + }); + + it("keeps Lexical deletion when backspace would hit a file reference in the previous paragraph", () => { + const result = readBackspaceDecision(() => { + const root = $getRoot(); + const previousParagraph = $createParagraphNode(); + const currentParagraph = $createParagraphNode(); + const fileReference = $createFileReferenceNode( + "main.go", + 1, + 1, + "package main", + ); + const text = $createTextNode("hello"); + root.append(previousParagraph, currentParagraph); + previousParagraph.append(fileReference); + currentParagraph.append(text); + text.select(0, 0); + }); + + expect(result).toBe(false); + }); + + it("keeps Lexical deletion when the selection contains a file reference", () => { + const result = readBackspaceDecision(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const before = $createTextNode("before"); + const fileReference = $createFileReferenceNode( + "main.go", + 1, + 1, + "package main", + ); + const after = $createTextNode("after"); + root.append(paragraph); + paragraph.append(before, fileReference, after); + + const selection = $createRangeSelection(); + selection.anchor.set(before.getKey(), 0, "text"); + selection.focus.set(after.getKey(), 5, "text"); + $setSelection(selection); + }); + + expect(result).toBe(false); + }); + + it("keeps Lexical deletion for element anchor at offset 0 after a file reference paragraph", () => { + const result = readBackspaceDecision(() => { + const root = $getRoot(); + const firstParagraph = $createParagraphNode(); + const secondParagraph = $createParagraphNode(); + const fileReference = $createFileReferenceNode( + "main.go", + 1, + 1, + "package main", + ); + root.append(firstParagraph, secondParagraph); + firstParagraph.append(fileReference); + secondParagraph.selectStart(); + }); + + expect(result).toBe(false); + }); + + it("keeps Lexical deletion for element anchor at offset > 0 next to a file reference child", () => { + const result = readBackspaceDecision(() => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const fileReference = $createFileReferenceNode( + "main.go", + 1, + 1, + "package main", + ); + const text = $createTextNode("hello"); + root.append(paragraph); + paragraph.append(fileReference, text); + paragraph.select(1, 1); + }); + + expect(result).toBe(false); + }); +}); + +describe("registerIOSBackspaceCommand", () => { + it("falls through when native backspace is disabled", () => { + const editor = createEditor({ + namespace: "ios-backspace-command-disabled-test", + onError: (error) => { + throw error; + }, + }); + let deleteCharacterDispatched = false; + + registerIOSBackspaceCommand(editor, false); + editor.registerCommand( + DELETE_CHARACTER_COMMAND, + () => { + deleteCharacterDispatched = true; + return true; + }, + COMMAND_PRIORITY_HIGH, + ); + + const event = new KeyboardEvent("keydown", { + cancelable: true, + key: "Backspace", + }); + + expect(editor.dispatchCommand(KEY_BACKSPACE_COMMAND, event)).toBe(false); + expect(event.defaultPrevented).toBe(false); + expect(deleteCharacterDispatched).toBe(false); + }); + + it("prevents default and dispatches Lexical deletion near file references", () => { + const editor = createEditor({ + namespace: "ios-backspace-command-test", + nodes: [FileReferenceNode], + onError: (error) => { + throw error; + }, + }); + let deleteCharacterDirection: boolean | undefined; + + registerIOSBackspaceCommand(editor, true); + editor.registerCommand( + DELETE_CHARACTER_COMMAND, + (isBackward) => { + deleteCharacterDirection = isBackward; + return true; + }, + COMMAND_PRIORITY_HIGH, + ); + + editor.update( + () => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const fileReference = $createFileReferenceNode( + "main.go", + 1, + 1, + "package main", + ); + const text = $createTextNode("hello"); + root.append(paragraph); + paragraph.append(fileReference, text); + text.select(0, 0); + }, + { discrete: true }, + ); + + const event = new KeyboardEvent("keydown", { + cancelable: true, + key: "Backspace", + }); + + expect(editor.dispatchCommand(KEY_BACKSPACE_COMMAND, event)).toBe(true); + expect(event.defaultPrevented).toBe(true); + expect(deleteCharacterDirection).toBe(true); + }); + + it("does not prevent default for plain text iOS backspace", () => { + const editor = createEditor({ + namespace: "ios-backspace-command-test", + nodes: [FileReferenceNode], + onError: (error) => { + throw error; + }, + }); + let deleteCharacterDirection: boolean | undefined; + + registerIOSBackspaceCommand(editor, true); + editor.registerCommand( + DELETE_CHARACTER_COMMAND, + (isBackward) => { + deleteCharacterDirection = isBackward; + return true; + }, + COMMAND_PRIORITY_HIGH, + ); + + editor.update( + () => { + const root = $getRoot(); + const paragraph = $createParagraphNode(); + const text = $createTextNode("hello"); + root.append(paragraph); + paragraph.append(text); + text.select(5, 5); + }, + { discrete: true }, + ); + + const event = new KeyboardEvent("keydown", { + cancelable: true, + key: "Backspace", + }); + + expect(editor.dispatchCommand(KEY_BACKSPACE_COMMAND, event)).toBe(true); + expect(event.defaultPrevented).toBe(false); + expect(deleteCharacterDirection).toBeUndefined(); + }); +}); diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/iosBackspace.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/iosBackspace.tsx new file mode 100644 index 0000000000..80de0f93ad --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/iosBackspace.tsx @@ -0,0 +1,85 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { CAN_USE_BEFORE_INPUT, IS_IOS } from "@lexical/utils"; +import { + $getSelection, + $isDecoratorNode, + $isElementNode, + $isRangeSelection, + type BaseSelection, + COMMAND_PRIORITY_HIGH, + DELETE_CHARACTER_COMMAND, + KEY_BACKSPACE_COMMAND, + type LexicalEditor, + type LexicalNode, + type RangeSelection, +} from "lexical"; +import { type FC, useEffect } from "react"; + +function $containsDecoratorNode(node: LexicalNode | null): boolean { + if (!node) return false; + if ($isDecoratorNode(node)) return true; + if (!$isElementNode(node)) return false; + return node.getChildren().some($containsDecoratorNode); +} + +function $backspaceHitsDecoratorNode(selection: RangeSelection): boolean { + const { anchor } = selection; + if (anchor.type === "element") { + const node = anchor.getNode(); + if (anchor.offset === 0) { + const topBlock = node.getTopLevelElement() ?? node; + return $containsDecoratorNode(topBlock.getPreviousSibling()); + } + return $containsDecoratorNode(node.getChildAtIndex(anchor.offset - 1)); + } + + if (anchor.offset !== 0) return false; + const textNode = anchor.getNode(); + if ($containsDecoratorNode(textNode.getPreviousSibling())) return true; + + const topBlock = textNode.getTopLevelElement(); + if (topBlock?.getFirstDescendant() !== textNode) return false; + return $containsDecoratorNode(topBlock.getPreviousSibling()); +} + +export function $shouldUseNativeBackspace( + selection: BaseSelection | null, +): boolean { + if (!$isRangeSelection(selection)) return false; + if (selection.getNodes().some($containsDecoratorNode)) return false; + return !selection.isCollapsed() || !$backspaceHitsDecoratorNode(selection); +} + +export function registerIOSBackspaceCommand( + editor: LexicalEditor, + useNativeBackspace: boolean, +): () => void { + return editor.registerCommand( + KEY_BACKSPACE_COMMAND, + (event) => { + if (!useNativeBackspace) return false; + + const selection = $getSelection(); + if ($shouldUseNativeBackspace(selection)) return true; + if (!$isRangeSelection(selection)) return false; + + // WebKit native deletion can desynchronize contentEditable=false decorator nodes. + event.preventDefault(); + return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true); + }, + COMMAND_PRIORITY_HIGH, + ); +} + +const IOSBackspacePlugin: FC = function IOSBackspacePlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + // Lexical's default Backspace handler blocks the beforeinput event WebKit needs for delete acceleration. + return registerIOSBackspaceCommand(editor, IS_IOS && CAN_USE_BEFORE_INPUT); + }, [editor]); + + return null; +}; + +export { IOSBackspacePlugin };