From 5a262f96704bc1f9ea9c13796fd03182b0466f6a Mon Sep 17 00:00:00 2001 From: Magnus Buvarp Date: Sat, 28 Mar 2026 20:27:19 +0100 Subject: [PATCH 01/10] feat(web): add surround selection in composer --- apps/web/src/components/ChatView.browser.tsx | 280 ++++++++++++++++++ .../src/components/ComposerPromptEditor.tsx | 221 ++++++++++++++ 2 files changed, 501 insertions(+) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 06cbf0efbd..999c4ba303 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -614,6 +614,167 @@ async function waitForComposerEditor(): Promise { ); } +async function pressComposerKey(key: string): Promise { + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + const keydownEvent = new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + }); + composerEditor.dispatchEvent(keydownEvent); + if (keydownEvent.defaultPrevented) { + await waitForLayout(); + return; + } + + const beforeInputEvent = new InputEvent("beforeinput", { + data: key, + inputType: "insertText", + bubbles: true, + cancelable: true, + }); + composerEditor.dispatchEvent(beforeInputEvent); + if (beforeInputEvent.defaultPrevented) { + await waitForLayout(); + return; + } + + if ( + typeof document.execCommand === "function" && + document.execCommand("insertText", false, key) + ) { + await waitForLayout(); + return; + } + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + throw new Error("Unable to resolve composer selection for text input."); + } + const range = selection.getRangeAt(0); + range.deleteContents(); + const textNode = document.createTextNode(key); + range.insertNode(textNode); + range.setStartAfter(textNode); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + composerEditor.dispatchEvent( + new InputEvent("input", { + data: key, + inputType: "insertText", + bubbles: true, + }), + ); + await waitForLayout(); +} + +async function waitForComposerText(expectedText: string): Promise { + await vi.waitFor( + () => { + expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.prompt ?? "").toBe( + expectedText, + ); + }, + { timeout: 8_000, interval: 16 }, + ); +} + +async function setComposerSelectionByTextOffsets(options: { + start: number; + end: number; + direction?: "forward" | "backward"; +}): Promise { + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + const resolvePoint = (targetOffset: number) => { + const traversedRef = { value: 0 }; + + const visitNode = (node: Node): { node: Node; offset: number } | null => { + if (node.nodeType === Node.TEXT_NODE) { + const textLength = node.textContent?.length ?? 0; + if (targetOffset <= traversedRef.value + textLength) { + return { + node, + offset: Math.max(0, Math.min(targetOffset - traversedRef.value, textLength)), + }; + } + traversedRef.value += textLength; + return null; + } + + if (node instanceof HTMLBRElement) { + const parent = node.parentNode; + if (!parent) { + return null; + } + const siblingIndex = Array.prototype.indexOf.call(parent.childNodes, node); + if (targetOffset <= traversedRef.value) { + return { node: parent, offset: siblingIndex }; + } + if (targetOffset <= traversedRef.value + 1) { + return { node: parent, offset: siblingIndex + 1 }; + } + traversedRef.value += 1; + return null; + } + + if (node instanceof Element || node instanceof DocumentFragment) { + for (const child of node.childNodes) { + const point = visitNode(child); + if (point) { + return point; + } + } + } + + return null; + }; + + return ( + visitNode(composerEditor) ?? { + node: composerEditor, + offset: composerEditor.childNodes.length, + } + ); + }; + + const startPoint = resolvePoint(options.start); + const endPoint = resolvePoint(options.end); + const selection = window.getSelection(); + if (!selection) { + throw new Error("Unable to resolve window selection."); + } + selection.removeAllRanges(); + + if (options.direction === "backward" && "setBaseAndExtent" in selection) { + selection.setBaseAndExtent(endPoint.node, endPoint.offset, startPoint.node, startPoint.offset); + await waitForLayout(); + return; + } + + const range = document.createRange(); + range.setStart(startPoint.node, startPoint.offset); + range.setEnd(endPoint.node, endPoint.offset); + selection.addRange(range); + await waitForLayout(); +} + +async function selectAllComposerContent(): Promise { + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + const selection = window.getSelection(); + if (!selection) { + throw new Error("Unable to resolve window selection."); + } + selection.removeAllRanges(); + const range = document.createRange(); + range.selectNodeContents(composerEditor); + selection.addRange(range); + await waitForLayout(); +} + async function waitForSendButton(): Promise { return waitForElement( () => document.querySelector('button[aria-label="Send message"]'), @@ -1582,6 +1743,125 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("surrounds selected plain text and preserves the inner selection for repeated wrapping", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-surround-basic" as MessageId, + targetText: "surround basic", + }), + }); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_ID, "selected"); + await waitForComposerText("selected"); + await setComposerSelectionByTextOffsets({ start: 0, end: "selected".length }); + await pressComposerKey("("); + await waitForComposerText("(selected)"); + + await pressComposerKey("["); + await waitForComposerText("([selected])"); + } finally { + await mounted.cleanup(); + } + }); + + it("leaves collapsed-caret typing unchanged for surround symbols", async () => { + useComposerDraftStore.getState().setPrompt(THREAD_ID, "selected"); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-surround-collapsed" as MessageId, + targetText: "surround collapsed", + }), + }); + + try { + await waitForComposerText("selected"); + await setComposerSelectionByTextOffsets({ + start: "selected".length, + end: "selected".length, + }); + await pressComposerKey("("); + await waitForComposerText("selected("); + } finally { + await mounted.cleanup(); + } + }); + + it("supports symmetric and backward-selection surrounds", async () => { + useComposerDraftStore.getState().setPrompt(THREAD_ID, "backward"); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-surround-backward" as MessageId, + targetText: "surround backward", + }), + }); + + try { + await waitForComposerText("backward"); + await setComposerSelectionByTextOffsets({ + start: 0, + end: "backward".length, + direction: "backward", + }); + await pressComposerKey("*"); + await waitForComposerText("*backward*"); + } finally { + await mounted.cleanup(); + } + }); + + it("supports option-produced surround symbols like guillemets", async () => { + useComposerDraftStore.getState().setPrompt(THREAD_ID, "quoted"); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-surround-guillemet" as MessageId, + targetText: "surround guillemet", + }), + }); + + try { + await waitForComposerText("quoted"); + await setComposerSelectionByTextOffsets({ start: 0, end: "quoted".length }); + await pressComposerKey("«"); + await waitForComposerText("«quoted»"); + } finally { + await mounted.cleanup(); + } + }); + + it("falls back to normal replacement when the selection includes a mention token", async () => { + useComposerDraftStore.getState().setPrompt(THREAD_ID, "hi @package.json there "); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-surround-token" as MessageId, + targetText: "surround token", + }), + }); + + try { + await vi.waitFor( + () => { + expect(document.body.textContent).toContain("package.json"); + }, + { timeout: 8_000, interval: 16 }, + ); + await selectAllComposerContent(); + await pressComposerKey("("); + await waitForComposerText("("); + } finally { + await mounted.cleanup(); + } + }); + it("keeps removed terminal context pills removed when a new one is added", async () => { const removedLabel = "Terminal 1 lines 1-2"; const addedLabel = "Terminal 2 lines 9-10"; diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 338d9f7bf1..a091e21d40 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -8,6 +8,7 @@ import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; import { $applyNodeReplacement, $createRangeSelection, + BEFORE_INPUT_COMMAND, $getSelection, $setSelection, $isElementNode, @@ -73,6 +74,21 @@ import { import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; +const SURROUND_SYMBOLS: [string, string][] = [ + ["(", ")"], + ["[", "]"], + ["{", "}"], + ["'", "'"], + ['"', '"'], + ['`', '`'], + ['“', '”'], + ['´', '´'], + ["<", ">"], + ["«", "»"], + ["*", "*"], + ["_", "_"], +]; +const SURROUND_SYMBOLS_MAP = new Map(SURROUND_SYMBOLS); type SerializedComposerMentionNode = Spread< { @@ -553,6 +569,53 @@ function $setSelectionAtComposerOffset(nextOffset: number): void { $setSelection(selection); } +function $setSelectionRangeAtComposerOffsets(startOffset: number, endOffset: number): void { + const root = $getRoot(); + const composerLength = $getComposerRootLength(); + const boundedStart = Math.max(0, Math.min(startOffset, composerLength)); + const boundedEnd = Math.max(0, Math.min(endOffset, composerLength)); + const anchorRemainingRef = { value: boundedStart }; + const focusRemainingRef = { value: boundedEnd }; + const anchorPoint = findSelectionPointAtOffset(root, anchorRemainingRef) ?? { + key: root.getKey(), + offset: root.getChildren().length, + type: "element" as const, + }; + const focusPoint = findSelectionPointAtOffset(root, focusRemainingRef) ?? { + key: root.getKey(), + offset: root.getChildren().length, + type: "element" as const, + }; + const selection = $createRangeSelection(); + selection.anchor.set(anchorPoint.key, anchorPoint.offset, anchorPoint.type); + selection.focus.set(focusPoint.key, focusPoint.offset, focusPoint.type); + $setSelection(selection); +} + +function getSelectionRangeForComposerOffsets(selection: ReturnType): { + start: number; + end: number; +} | null { + if (!$isRangeSelection(selection)) { + return null; + } + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + const anchorOffset = getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); + const focusOffset = getAbsoluteOffsetForPoint(focusNode, selection.focus.offset); + return { + start: Math.min(anchorOffset, focusOffset), + end: Math.max(anchorOffset, focusOffset), + }; +} + +function $selectionTouchesInlineToken(selection: ReturnType): boolean { + if (!$isRangeSelection(selection)) { + return false; + } + return selection.getNodes().some((node) => isComposerInlineTokenNode(node)); +} + function $readSelectionOffsetFromEditorState(fallback: number): number { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) { @@ -878,6 +941,147 @@ function ComposerInlineTokenBackspacePlugin() { return null; } +function ComposerSurroundSelectionPlugin(props: { + terminalContexts: ReadonlyArray; +}) { + const [editor] = useLexicalComposerContext(); + const terminalContextsRef = useRef(props.terminalContexts); + const pendingSurroundSelectionRef = useRef<{ + value: string; + start: number; + end: number; + } | null>(null); + + useEffect(() => { + terminalContextsRef.current = props.terminalContexts; + }, [props.terminalContexts]); + + const applySurroundInsertion = useCallback( + (inputData: string): boolean => { + const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData); + const pendingSurroundSelection = pendingSurroundSelectionRef.current; + if (!surroundCloseSymbol) { + pendingSurroundSelectionRef.current = null; + return false; + } + + let handled = false; + editor.update(() => { + const selectionSnapshot = + pendingSurroundSelection ?? + (() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || selection.isCollapsed()) { + return null; + } + if ($selectionTouchesInlineToken(selection)) { + return null; + } + const range = getSelectionRangeForComposerOffsets(selection); + if (!range || range.start === range.end) { + return null; + } + return { + value: $getRoot().getTextContent(), + start: range.start, + end: range.end, + }; + })(); + + if (!selectionSnapshot || !surroundCloseSymbol) { + return; + } + + const selectedText = selectionSnapshot.value.slice( + selectionSnapshot.start, + selectionSnapshot.end, + ); + const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.start)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.end)}`; + $setComposerEditorPrompt(nextValue, terminalContextsRef.current); + $setSelectionRangeAtComposerOffsets( + selectionSnapshot.start + inputData.length, + selectionSnapshot.start + inputData.length + selectedText.length, + ); + handled = true; + pendingSurroundSelectionRef.current = null; + }); + + return handled; + }, + [editor], + ); + + useEffect(() => { + const unregisterRootListener = editor.registerRootListener((rootElement, prevRootElement) => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey) { + pendingSurroundSelectionRef.current = null; + return; + } + + editor.getEditorState().read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || selection.isCollapsed()) { + pendingSurroundSelectionRef.current = null; + return; + } + if ($selectionTouchesInlineToken(selection)) { + pendingSurroundSelectionRef.current = null; + return; + } + const range = getSelectionRangeForComposerOffsets(selection); + if (!range || range.start === range.end) { + pendingSurroundSelectionRef.current = null; + return; + } + const snapshot = { + value: $getRoot().getTextContent(), + start: range.start, + end: range.end, + }; + pendingSurroundSelectionRef.current = snapshot; + }); + }; + const onBeforeInput = (event: InputEvent) => { + if (typeof event.data !== 'string') { + pendingSurroundSelectionRef.current = null; + return + } + const inputData = + (event.inputType === "insertText") && typeof event.data === "string" ? event.data : null; + if (!inputData || inputData.length !== 1) { + pendingSurroundSelectionRef.current = null; + return; + } + if (!applySurroundInsertion(inputData)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + }; + + prevRootElement?.removeEventListener("keydown", onKeyDown); + prevRootElement?.removeEventListener("beforeinput", onBeforeInput, true); + rootElement?.addEventListener("keydown", onKeyDown); + rootElement?.addEventListener("beforeinput", onBeforeInput, true); + }); + const unregisterBeforeInputCommand = editor.registerCommand( + BEFORE_INPUT_COMMAND, + () => false, + COMMAND_PRIORITY_HIGH, + ); + + return () => { + unregisterRootListener(); + unregisterBeforeInputCommand(); + }; + }, [applySurroundInsertion, editor]); + + return null; +} + function ComposerPromptEditorInner({ value, cursor, @@ -901,6 +1105,8 @@ function ComposerPromptEditorInner({ cursor: initialCursor, expandedCursor: expandCollapsedComposerCursor(value, initialCursor), terminalContextIds: terminalContexts.map((context) => context.id), + selectionStart: initialCursor, + selectionEnd: initialCursor, }); const isApplyingControlledUpdateRef = useRef(false); const terminalContextActions = useMemo( @@ -933,6 +1139,8 @@ function ComposerPromptEditorInner({ cursor: normalizedCursor, expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), terminalContextIds: terminalContexts.map((context) => context.id), + selectionStart: normalizedCursor, + selectionEnd: normalizedCursor, }; terminalContextsSignatureRef.current = terminalContextsSignature; @@ -971,6 +1179,8 @@ function ComposerPromptEditorInner({ cursor: boundedCursor, expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), terminalContextIds: snapshotRef.current.terminalContextIds, + selectionStart: boundedCursor, + selectionEnd: boundedCursor, }; onChangeRef.current( snapshotRef.current.value, @@ -1011,6 +1221,8 @@ function ComposerPromptEditorInner({ cursor: nextCursor, expandedCursor: nextExpandedCursor, terminalContextIds, + selectionStart: nextCursor, + selectionEnd: nextCursor, }; }); snapshotRef.current = snapshot; @@ -1053,12 +1265,18 @@ function ComposerPromptEditorInner({ nextValue, $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); + const selectionRange = getSelectionRangeForComposerOffsets($getSelection()) ?? { + start: nextCursor, + end: nextCursor, + }; const terminalContextIds = collectTerminalContextIds($getRoot()); const previousSnapshot = snapshotRef.current; if ( previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor && previousSnapshot.expandedCursor === nextExpandedCursor && + previousSnapshot.selectionStart === selectionRange.start && + previousSnapshot.selectionEnd === selectionRange.end && previousSnapshot.terminalContextIds.length === terminalContextIds.length && previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index]) ) { @@ -1072,6 +1290,8 @@ function ComposerPromptEditorInner({ cursor: nextCursor, expandedCursor: nextExpandedCursor, terminalContextIds, + selectionStart: selectionRange.start, + selectionEnd: selectionRange.end, }; const cursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || @@ -1113,6 +1333,7 @@ function ComposerPromptEditorInner({ /> + From 3e18743e4d266e8e18c99a6c3870e9556b6f3577 Mon Sep 17 00:00:00 2001 From: Magnus Buvarp Date: Sat, 28 Mar 2026 20:55:45 +0100 Subject: [PATCH 02/10] fix(web): avoid duplicate composer input listeners --- .../src/components/ComposerPromptEditor.tsx | 91 ++++++++++--------- 1 file changed, 48 insertions(+), 43 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index a091e21d40..2cb3293b5e 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -80,9 +80,8 @@ const SURROUND_SYMBOLS: [string, string][] = [ ["{", "}"], ["'", "'"], ['"', '"'], - ['`', '`'], - ['“', '”'], - ['´', '´'], + ["“", "”"], + ["`", "`"], ["<", ">"], ["«", "»"], ["*", "*"], @@ -1012,60 +1011,62 @@ function ComposerSurroundSelectionPlugin(props: { ); useEffect(() => { - const unregisterRootListener = editor.registerRootListener((rootElement, prevRootElement) => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey) { - pendingSurroundSelectionRef.current = null; - return; - } + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey) { + pendingSurroundSelectionRef.current = null; + return; + } - editor.getEditorState().read(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || selection.isCollapsed()) { - pendingSurroundSelectionRef.current = null; - return; - } - if ($selectionTouchesInlineToken(selection)) { - pendingSurroundSelectionRef.current = null; - return; - } - const range = getSelectionRangeForComposerOffsets(selection); - if (!range || range.start === range.end) { - pendingSurroundSelectionRef.current = null; - return; - } - const snapshot = { - value: $getRoot().getTextContent(), - start: range.start, - end: range.end, - }; - pendingSurroundSelectionRef.current = snapshot; - }); - }; - const onBeforeInput = (event: InputEvent) => { - if (typeof event.data !== 'string') { + editor.getEditorState().read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || selection.isCollapsed()) { pendingSurroundSelectionRef.current = null; - return + return; } - const inputData = - (event.inputType === "insertText") && typeof event.data === "string" ? event.data : null; - if (!inputData || inputData.length !== 1) { + if ($selectionTouchesInlineToken(selection)) { pendingSurroundSelectionRef.current = null; return; } - if (!applySurroundInsertion(inputData)) { + const range = getSelectionRangeForComposerOffsets(selection); + if (!range || range.start === range.end) { + pendingSurroundSelectionRef.current = null; return; } + const snapshot = { + value: $getRoot().getTextContent(), + start: range.start, + end: range.end, + }; + pendingSurroundSelectionRef.current = snapshot; + }); + }; - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); - }; + const onBeforeInput = (event: InputEvent) => { + if (typeof event.data !== "string") { + pendingSurroundSelectionRef.current = null; + return; + } + const inputData = event.inputType === "insertText" ? event.data : null; + if (!inputData || inputData.length !== 1) { + pendingSurroundSelectionRef.current = null; + return; + } + if (!applySurroundInsertion(inputData)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + }; + + let activeRootElement: HTMLElement | null = null; + const unregisterRootListener = editor.registerRootListener((rootElement, prevRootElement) => { prevRootElement?.removeEventListener("keydown", onKeyDown); prevRootElement?.removeEventListener("beforeinput", onBeforeInput, true); rootElement?.addEventListener("keydown", onKeyDown); rootElement?.addEventListener("beforeinput", onBeforeInput, true); + activeRootElement = rootElement; }); const unregisterBeforeInputCommand = editor.registerCommand( BEFORE_INPUT_COMMAND, @@ -1074,6 +1075,10 @@ function ComposerSurroundSelectionPlugin(props: { ); return () => { + if (activeRootElement) { + activeRootElement.removeEventListener("keydown", onKeyDown); + activeRootElement.removeEventListener("beforeinput", onBeforeInput, true); + } unregisterRootListener(); unregisterBeforeInputCommand(); }; From 77c605fb257a6d4b4f74ea54fd0cf365eec3f94b Mon Sep 17 00:00:00 2001 From: Magnus Buvarp Date: Sat, 28 Mar 2026 22:05:52 +0100 Subject: [PATCH 03/10] fix(web): support dead-key backtick surround --- .../src/components/ComposerPromptEditor.tsx | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 2cb3293b5e..99764cc7c7 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -27,6 +27,7 @@ import { COMMAND_PRIORITY_HIGH, KEY_BACKSPACE_COMMAND, $getRoot, + HISTORY_PUSH_TAG, DecoratorNode, type ElementNode, type LexicalNode, @@ -88,6 +89,7 @@ const SURROUND_SYMBOLS: [string, string][] = [ ["_", "_"], ]; const SURROUND_SYMBOLS_MAP = new Map(SURROUND_SYMBOLS); +const BACKTICK_SURROUND_CLOSE_SYMBOL = SURROUND_SYMBOLS_MAP.get("`") ?? null; type SerializedComposerMentionNode = Spread< { @@ -950,6 +952,11 @@ function ComposerSurroundSelectionPlugin(props: { start: number; end: number; } | null>(null); + const pendingDeadKeySelectionRef = useRef<{ + value: string; + start: number; + end: number; + } | null>(null); useEffect(() => { terminalContextsRef.current = props.terminalContexts; @@ -1014,22 +1021,35 @@ function ComposerSurroundSelectionPlugin(props: { const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey) { pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; return; } + if (pendingDeadKeySelectionRef.current) { + if (event.key === "Dead" || event.key === " " || event.code === "Space") { + return; + } + pendingDeadKeySelectionRef.current = null; + } + + const shouldTrackDeadKeyBacktick = + BACKTICK_SURROUND_CLOSE_SYMBOL !== null && event.key === "Dead" && event.shiftKey; editor.getEditorState().read(() => { const selection = $getSelection(); if (!$isRangeSelection(selection) || selection.isCollapsed()) { pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; return; } if ($selectionTouchesInlineToken(selection)) { pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; return; } const range = getSelectionRangeForComposerOffsets(selection); if (!range || range.start === range.end) { pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; return; } const snapshot = { @@ -1038,10 +1058,15 @@ function ComposerSurroundSelectionPlugin(props: { end: range.end, }; pendingSurroundSelectionRef.current = snapshot; + pendingDeadKeySelectionRef.current = shouldTrackDeadKeyBacktick ? snapshot : null; }); }; const onBeforeInput = (event: InputEvent) => { + if (pendingDeadKeySelectionRef.current) { + return; + } + if (typeof event.data !== "string") { pendingSurroundSelectionRef.current = null; return; @@ -1060,12 +1085,76 @@ function ComposerSurroundSelectionPlugin(props: { event.stopImmediatePropagation(); }; + const tryApplyDeadKeyBacktickSurround = () => { + queueMicrotask(() => { + editor.update( + () => { + const pendingDeadKeySelection = pendingDeadKeySelectionRef.current; + if (!pendingDeadKeySelection) { + return; + } + + const currentValue = $getRoot().getTextContent(); + const backtickCloseSymbol = BACKTICK_SURROUND_CLOSE_SYMBOL; + if (backtickCloseSymbol === null) { + pendingDeadKeySelectionRef.current = null; + return; + } + + const expectedResolvedValue = `${pendingDeadKeySelection.value.slice(0, pendingDeadKeySelection.start)}\`${pendingDeadKeySelection.value.slice(pendingDeadKeySelection.end)}`; + if (currentValue !== expectedResolvedValue) { + return; + } + + const selectedText = pendingDeadKeySelection.value.slice( + pendingDeadKeySelection.start, + pendingDeadKeySelection.end, + ); + $setSelectionRangeAtComposerOffsets( + pendingDeadKeySelection.start, + pendingDeadKeySelection.start + 1, + ); + const replacementSelection = $getSelection(); + if (!$isRangeSelection(replacementSelection)) { + return; + } + replacementSelection.insertText(`\`${selectedText}${backtickCloseSymbol}`); + $setSelectionRangeAtComposerOffsets( + pendingDeadKeySelection.start + 1, + pendingDeadKeySelection.start + 1 + selectedText.length, + ); + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + }, + { tag: HISTORY_PUSH_TAG }, + ); + }); + }; + + const onInput = (event: Event) => { + const inputEvent = event as InputEvent; + if ( + inputEvent.inputType === "insertText" || + inputEvent.inputType === "insertCompositionText" + ) { + tryApplyDeadKeyBacktickSurround(); + } + }; + + const onCompositionEnd = () => { + tryApplyDeadKeyBacktickSurround(); + }; + let activeRootElement: HTMLElement | null = null; const unregisterRootListener = editor.registerRootListener((rootElement, prevRootElement) => { prevRootElement?.removeEventListener("keydown", onKeyDown); prevRootElement?.removeEventListener("beforeinput", onBeforeInput, true); + prevRootElement?.removeEventListener("input", onInput); + prevRootElement?.removeEventListener("compositionend", onCompositionEnd); rootElement?.addEventListener("keydown", onKeyDown); rootElement?.addEventListener("beforeinput", onBeforeInput, true); + rootElement?.addEventListener("input", onInput); + rootElement?.addEventListener("compositionend", onCompositionEnd); activeRootElement = rootElement; }); const unregisterBeforeInputCommand = editor.registerCommand( @@ -1078,6 +1167,8 @@ function ComposerSurroundSelectionPlugin(props: { if (activeRootElement) { activeRootElement.removeEventListener("keydown", onKeyDown); activeRootElement.removeEventListener("beforeinput", onBeforeInput, true); + activeRootElement.removeEventListener("input", onInput); + activeRootElement.removeEventListener("compositionend", onCompositionEnd); } unregisterRootListener(); unregisterBeforeInputCommand(); From 0bcabaca909d3fd2ba76eb433ebc7d8f48cb7a33 Mon Sep 17 00:00:00 2001 From: Magnus Buvarp Date: Sat, 28 Mar 2026 23:03:28 +0100 Subject: [PATCH 04/10] fix(web): clear stale dead-key surround state --- apps/web/src/components/ComposerPromptEditor.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 99764cc7c7..31e01fc893 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1085,7 +1085,7 @@ function ComposerSurroundSelectionPlugin(props: { event.stopImmediatePropagation(); }; - const tryApplyDeadKeyBacktickSurround = () => { + const tryApplyDeadKeyBacktickSurround = (options?: { finalAttempt?: boolean }) => { queueMicrotask(() => { editor.update( () => { @@ -1103,6 +1103,10 @@ function ComposerSurroundSelectionPlugin(props: { const expectedResolvedValue = `${pendingDeadKeySelection.value.slice(0, pendingDeadKeySelection.start)}\`${pendingDeadKeySelection.value.slice(pendingDeadKeySelection.end)}`; if (currentValue !== expectedResolvedValue) { + if (options?.finalAttempt) { + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + } return; } @@ -1116,6 +1120,8 @@ function ComposerSurroundSelectionPlugin(props: { ); const replacementSelection = $getSelection(); if (!$isRangeSelection(replacementSelection)) { + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; return; } replacementSelection.insertText(`\`${selectedText}${backtickCloseSymbol}`); @@ -1142,7 +1148,7 @@ function ComposerSurroundSelectionPlugin(props: { }; const onCompositionEnd = () => { - tryApplyDeadKeyBacktickSurround(); + tryApplyDeadKeyBacktickSurround({ finalAttempt: true }); }; let activeRootElement: HTMLElement | null = null; From f18468b7ee8836ffc4273afd811635275cf467db Mon Sep 17 00:00:00 2001 From: Magnus Buvarp Date: Sat, 28 Mar 2026 23:18:37 +0100 Subject: [PATCH 05/10] fix(web): align surround selection with mention offsets --- apps/web/src/components/ChatView.browser.tsx | 30 ++++++++ .../src/components/ComposerPromptEditor.tsx | 70 ++++++++++++------- 2 files changed, 75 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 999c4ba303..f674a6d536 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1836,6 +1836,36 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("surrounds text after a mention using the correct expanded offsets", async () => { + useComposerDraftStore.getState().setPrompt(THREAD_ID, "hi @package.json there"); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-surround-after-mention" as MessageId, + targetText: "surround after mention", + }), + }); + + try { + await vi.waitFor( + () => { + expect(document.body.textContent).toContain("package.json"); + }, + { timeout: 8_000, interval: 16 }, + ); + await waitForComposerText("hi @package.json there"); + await setComposerSelectionByTextOffsets({ + start: "hi package.json ".length, + end: "hi package.json there".length, + }); + await pressComposerKey("("); + await waitForComposerText("hi @package.json (there)"); + } finally { + await mounted.cleanup(); + } + }); + it("falls back to normal replacement when the selection includes a mention token", async () => { useComposerDraftStore.getState().setPrompt(THREAD_ID, "hi @package.json there "); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 31e01fc893..6a93595f10 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -610,6 +610,23 @@ function getSelectionRangeForComposerOffsets(selection: ReturnType): { + start: number; + end: number; +} | null { + if (!$isRangeSelection(selection)) { + return null; + } + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + const anchorOffset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); + const focusOffset = getExpandedAbsoluteOffsetForPoint(focusNode, selection.focus.offset); + return { + start: Math.min(anchorOffset, focusOffset), + end: Math.max(anchorOffset, focusOffset), + }; +} + function $selectionTouchesInlineToken(selection: ReturnType): boolean { if (!$isRangeSelection(selection)) { return false; @@ -949,13 +966,13 @@ function ComposerSurroundSelectionPlugin(props: { const terminalContextsRef = useRef(props.terminalContexts); const pendingSurroundSelectionRef = useRef<{ value: string; - start: number; - end: number; + expandedStart: number; + expandedEnd: number; } | null>(null); const pendingDeadKeySelectionRef = useRef<{ value: string; - start: number; - end: number; + expandedStart: number; + expandedEnd: number; } | null>(null); useEffect(() => { @@ -983,14 +1000,14 @@ function ComposerSurroundSelectionPlugin(props: { if ($selectionTouchesInlineToken(selection)) { return null; } - const range = getSelectionRangeForComposerOffsets(selection); + const range = getSelectionRangeForExpandedComposerOffsets(selection); if (!range || range.start === range.end) { return null; } return { value: $getRoot().getTextContent(), - start: range.start, - end: range.end, + expandedStart: range.start, + expandedEnd: range.end, }; })(); @@ -999,14 +1016,18 @@ function ComposerSurroundSelectionPlugin(props: { } const selectedText = selectionSnapshot.value.slice( - selectionSnapshot.start, - selectionSnapshot.end, + selectionSnapshot.expandedStart, + selectionSnapshot.expandedEnd, ); - const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.start)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.end)}`; + const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`; $setComposerEditorPrompt(nextValue, terminalContextsRef.current); + const selectionStart = collapseExpandedComposerCursor( + nextValue, + selectionSnapshot.expandedStart, + ); $setSelectionRangeAtComposerOffsets( - selectionSnapshot.start + inputData.length, - selectionSnapshot.start + inputData.length + selectedText.length, + selectionStart + inputData.length, + selectionStart + inputData.length + selectedText.length, ); handled = true; pendingSurroundSelectionRef.current = null; @@ -1046,7 +1067,7 @@ function ComposerSurroundSelectionPlugin(props: { pendingDeadKeySelectionRef.current = null; return; } - const range = getSelectionRangeForComposerOffsets(selection); + const range = getSelectionRangeForExpandedComposerOffsets(selection); if (!range || range.start === range.end) { pendingSurroundSelectionRef.current = null; pendingDeadKeySelectionRef.current = null; @@ -1054,8 +1075,8 @@ function ComposerSurroundSelectionPlugin(props: { } const snapshot = { value: $getRoot().getTextContent(), - start: range.start, - end: range.end, + expandedStart: range.start, + expandedEnd: range.end, }; pendingSurroundSelectionRef.current = snapshot; pendingDeadKeySelectionRef.current = shouldTrackDeadKeyBacktick ? snapshot : null; @@ -1101,7 +1122,7 @@ function ComposerSurroundSelectionPlugin(props: { return; } - const expectedResolvedValue = `${pendingDeadKeySelection.value.slice(0, pendingDeadKeySelection.start)}\`${pendingDeadKeySelection.value.slice(pendingDeadKeySelection.end)}`; + const expectedResolvedValue = `${pendingDeadKeySelection.value.slice(0, pendingDeadKeySelection.expandedStart)}\`${pendingDeadKeySelection.value.slice(pendingDeadKeySelection.expandedEnd)}`; if (currentValue !== expectedResolvedValue) { if (options?.finalAttempt) { pendingSurroundSelectionRef.current = null; @@ -1111,13 +1132,14 @@ function ComposerSurroundSelectionPlugin(props: { } const selectedText = pendingDeadKeySelection.value.slice( - pendingDeadKeySelection.start, - pendingDeadKeySelection.end, + pendingDeadKeySelection.expandedStart, + pendingDeadKeySelection.expandedEnd, ); - $setSelectionRangeAtComposerOffsets( - pendingDeadKeySelection.start, - pendingDeadKeySelection.start + 1, + const replacementStart = collapseExpandedComposerCursor( + currentValue, + pendingDeadKeySelection.expandedStart, ); + $setSelectionRangeAtComposerOffsets(replacementStart, replacementStart + 1); const replacementSelection = $getSelection(); if (!$isRangeSelection(replacementSelection)) { pendingSurroundSelectionRef.current = null; @@ -1126,8 +1148,8 @@ function ComposerSurroundSelectionPlugin(props: { } replacementSelection.insertText(`\`${selectedText}${backtickCloseSymbol}`); $setSelectionRangeAtComposerOffsets( - pendingDeadKeySelection.start + 1, - pendingDeadKeySelection.start + 1 + selectedText.length, + replacementStart + 1, + replacementStart + 1 + selectedText.length, ); pendingSurroundSelectionRef.current = null; pendingDeadKeySelectionRef.current = null; @@ -1377,8 +1399,6 @@ function ComposerPromptEditorInner({ previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor && previousSnapshot.expandedCursor === nextExpandedCursor && - previousSnapshot.selectionStart === selectionRange.start && - previousSnapshot.selectionEnd === selectionRange.end && previousSnapshot.terminalContextIds.length === terminalContextIds.length && previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index]) ) { From c6f30dfbe66eab5ddcd4951e830ab378b6c68211 Mon Sep 17 00:00:00 2001 From: Magnus Buvarp Date: Sat, 28 Mar 2026 23:39:17 +0100 Subject: [PATCH 06/10] refactor(web): remove unused composer selection snapshot fields --- .../src/components/ComposerPromptEditor.tsx | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 6a93595f10..9541f11005 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -593,23 +593,6 @@ function $setSelectionRangeAtComposerOffsets(startOffset: number, endOffset: num $setSelection(selection); } -function getSelectionRangeForComposerOffsets(selection: ReturnType): { - start: number; - end: number; -} | null { - if (!$isRangeSelection(selection)) { - return null; - } - const anchorNode = selection.anchor.getNode(); - const focusNode = selection.focus.getNode(); - const anchorOffset = getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); - const focusOffset = getAbsoluteOffsetForPoint(focusNode, selection.focus.offset); - return { - start: Math.min(anchorOffset, focusOffset), - end: Math.max(anchorOffset, focusOffset), - }; -} - function getSelectionRangeForExpandedComposerOffsets(selection: ReturnType): { start: number; end: number; @@ -1229,8 +1212,6 @@ function ComposerPromptEditorInner({ cursor: initialCursor, expandedCursor: expandCollapsedComposerCursor(value, initialCursor), terminalContextIds: terminalContexts.map((context) => context.id), - selectionStart: initialCursor, - selectionEnd: initialCursor, }); const isApplyingControlledUpdateRef = useRef(false); const terminalContextActions = useMemo( @@ -1263,8 +1244,6 @@ function ComposerPromptEditorInner({ cursor: normalizedCursor, expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), terminalContextIds: terminalContexts.map((context) => context.id), - selectionStart: normalizedCursor, - selectionEnd: normalizedCursor, }; terminalContextsSignatureRef.current = terminalContextsSignature; @@ -1303,8 +1282,6 @@ function ComposerPromptEditorInner({ cursor: boundedCursor, expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), terminalContextIds: snapshotRef.current.terminalContextIds, - selectionStart: boundedCursor, - selectionEnd: boundedCursor, }; onChangeRef.current( snapshotRef.current.value, @@ -1345,8 +1322,6 @@ function ComposerPromptEditorInner({ cursor: nextCursor, expandedCursor: nextExpandedCursor, terminalContextIds, - selectionStart: nextCursor, - selectionEnd: nextCursor, }; }); snapshotRef.current = snapshot; @@ -1389,10 +1364,6 @@ function ComposerPromptEditorInner({ nextValue, $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); - const selectionRange = getSelectionRangeForComposerOffsets($getSelection()) ?? { - start: nextCursor, - end: nextCursor, - }; const terminalContextIds = collectTerminalContextIds($getRoot()); const previousSnapshot = snapshotRef.current; if ( @@ -1412,8 +1383,6 @@ function ComposerPromptEditorInner({ cursor: nextCursor, expandedCursor: nextExpandedCursor, terminalContextIds, - selectionStart: selectionRange.start, - selectionEnd: selectionRange.end, }; const cursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || From 4dc6517a8acf657f0accd420168e07bc663941e4 Mon Sep 17 00:00:00 2001 From: Magnus Buvarp Date: Sat, 28 Mar 2026 23:53:25 +0100 Subject: [PATCH 07/10] fix(web): preserve dead-key state during composition --- apps/web/src/components/ComposerPromptEditor.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 9541f11005..63fcf8f40a 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1023,12 +1023,6 @@ function ComposerSurroundSelectionPlugin(props: { useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey) { - pendingSurroundSelectionRef.current = null; - pendingDeadKeySelectionRef.current = null; - return; - } - if (pendingDeadKeySelectionRef.current) { if (event.key === "Dead" || event.key === " " || event.code === "Space") { return; @@ -1036,6 +1030,12 @@ function ComposerSurroundSelectionPlugin(props: { pendingDeadKeySelectionRef.current = null; } + if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey) { + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + return; + } + const shouldTrackDeadKeyBacktick = BACKTICK_SURROUND_CLOSE_SYMBOL !== null && event.key === "Dead" && event.shiftKey; editor.getEditorState().read(() => { From 99b6ea1df6c8c8199992d1933f1f1636aa1f31d6 Mon Sep 17 00:00:00 2001 From: Magnus Buvarp Date: Sun, 29 Mar 2026 00:11:27 +0100 Subject: [PATCH 08/10] refactor(web): drop no-op composer beforeinput command --- apps/web/src/components/ComposerPromptEditor.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 63fcf8f40a..689217ec33 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -8,7 +8,6 @@ import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; import { $applyNodeReplacement, $createRangeSelection, - BEFORE_INPUT_COMMAND, $getSelection, $setSelection, $isElementNode, @@ -1168,11 +1167,6 @@ function ComposerSurroundSelectionPlugin(props: { rootElement?.addEventListener("compositionend", onCompositionEnd); activeRootElement = rootElement; }); - const unregisterBeforeInputCommand = editor.registerCommand( - BEFORE_INPUT_COMMAND, - () => false, - COMMAND_PRIORITY_HIGH, - ); return () => { if (activeRootElement) { @@ -1182,7 +1176,6 @@ function ComposerSurroundSelectionPlugin(props: { activeRootElement.removeEventListener("compositionend", onCompositionEnd); } unregisterRootListener(); - unregisterBeforeInputCommand(); }; }, [applySurroundInsertion, editor]); From dcb7d6f9862a5eb2f148d23bd128870219b549dd Mon Sep 17 00:00:00 2001 From: Magnus Buvarp Date: Sun, 29 Mar 2026 00:39:31 +0100 Subject: [PATCH 09/10] fix(web): support dead-key backtick across layouts --- apps/web/src/components/ComposerPromptEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 689217ec33..6d4ab2c146 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1036,7 +1036,7 @@ function ComposerSurroundSelectionPlugin(props: { } const shouldTrackDeadKeyBacktick = - BACKTICK_SURROUND_CLOSE_SYMBOL !== null && event.key === "Dead" && event.shiftKey; + BACKTICK_SURROUND_CLOSE_SYMBOL !== null && event.key === "Dead"; editor.getEditorState().read(() => { const selection = $getSelection(); if (!$isRangeSelection(selection) || selection.isCollapsed()) { From 832d8eef106fe236b75db012cac1b55e1b3fe1d9 Mon Sep 17 00:00:00 2001 From: Magnus Buvarp Date: Mon, 30 Mar 2026 22:04:44 +0200 Subject: [PATCH 10/10] fix(web): guard mention boundary surround selections --- .../src/components/ComposerPromptEditor.tsx | 19 +++++++-- apps/web/src/composer-editor-mentions.test.ts | 37 +++++++++++++++- apps/web/src/composer-editor-mentions.ts | 42 +++++++++++++++++++ 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 6d4ab2c146..6b56056484 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -59,7 +59,10 @@ import { expandCollapsedComposerCursor, isCollapsedCursorAdjacentToInlineToken, } from "~/composer-logic"; -import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; +import { + selectionTouchesMentionBoundary, + splitPromptIntoComposerSegments, +} from "~/composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, @@ -986,8 +989,12 @@ function ComposerSurroundSelectionPlugin(props: { if (!range || range.start === range.end) { return null; } + const value = $getRoot().getTextContent(); + if (selectionTouchesMentionBoundary(value, range.start, range.end)) { + return null; + } return { - value: $getRoot().getTextContent(), + value, expandedStart: range.start, expandedEnd: range.end, }; @@ -1055,8 +1062,14 @@ function ComposerSurroundSelectionPlugin(props: { pendingDeadKeySelectionRef.current = null; return; } + const value = $getRoot().getTextContent(); + if (selectionTouchesMentionBoundary(value, range.start, range.end)) { + pendingSurroundSelectionRef.current = null; + pendingDeadKeySelectionRef.current = null; + return; + } const snapshot = { - value: $getRoot().getTextContent(), + value, expandedStart: range.start, expandedEnd: range.end, }; diff --git a/apps/web/src/composer-editor-mentions.test.ts b/apps/web/src/composer-editor-mentions.test.ts index 1f0a07e096..c89f7a59e6 100644 --- a/apps/web/src/composer-editor-mentions.test.ts +++ b/apps/web/src/composer-editor-mentions.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; +import { + selectionTouchesMentionBoundary, + splitPromptIntoComposerSegments, +} from "./composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; describe("splitPromptIntoComposerSegments", () => { @@ -39,3 +42,35 @@ describe("splitPromptIntoComposerSegments", () => { ]); }); }); + +describe("selectionTouchesMentionBoundary", () => { + it("returns true when selection includes the whitespace after a mention", () => { + expect( + selectionTouchesMentionBoundary( + "hi @package.json there", + "hi @package.json".length, + "hi @package.json there".length, + ), + ).toBe(true); + }); + + it("returns true when selection includes the whitespace before a mention", () => { + expect( + selectionTouchesMentionBoundary( + "hi there @package.json later", + "hi there".length, + "hi there ".length, + ), + ).toBe(true); + }); + + it("returns false when selection starts after the mention boundary whitespace", () => { + expect( + selectionTouchesMentionBoundary( + "hi @package.json there", + "hi @package.json ".length, + "hi @package.json there".length, + ), + ).toBe(false); + }); +}); diff --git a/apps/web/src/composer-editor-mentions.ts b/apps/web/src/composer-editor-mentions.ts index fa1761480c..1cb967db38 100644 --- a/apps/web/src/composer-editor-mentions.ts +++ b/apps/web/src/composer-editor-mentions.ts @@ -19,6 +19,10 @@ export type ComposerPromptSegment = const MENTION_TOKEN_REGEX = /(^|\s)@([^\s@]+)(?=\s)/g; +function rangeIncludesIndex(start: number, end: number, index: number): boolean { + return start <= index && index < end; +} + function pushTextSegment(segments: ComposerPromptSegment[], text: string): void { if (!text) return; const last = segments[segments.length - 1]; @@ -64,6 +68,44 @@ function splitPromptTextIntoComposerSegments(text: string): ComposerPromptSegmen return segments; } +export function selectionTouchesMentionBoundary( + prompt: string, + start: number, + end: number, +): boolean { + if (!prompt || start >= end) { + return false; + } + + for (const match of prompt.matchAll(MENTION_TOKEN_REGEX)) { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const matchIndex = match.index ?? 0; + const mentionStart = matchIndex + prefix.length; + const mentionEnd = mentionStart + fullMatch.length - prefix.length; + const beforeMentionIndex = mentionStart - 1; + const afterMentionIndex = mentionEnd; + + if ( + beforeMentionIndex >= 0 && + /\s/.test(prompt[beforeMentionIndex] ?? "") && + rangeIncludesIndex(start, end, beforeMentionIndex) + ) { + return true; + } + + if ( + afterMentionIndex < prompt.length && + /\s/.test(prompt[afterMentionIndex] ?? "") && + rangeIncludesIndex(start, end, afterMentionIndex) + ) { + return true; + } + } + + return false; +} + export function splitPromptIntoComposerSegments( prompt: string, terminalContexts: ReadonlyArray = [],