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 };