diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 30dbb9dceb..2c089dab01 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -50,6 +50,7 @@ import { type Ref, } from "react"; +import { isCollapsedCursorAdjacentToMention } from "~/composer-logic"; import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; import { cn } from "~/lib/utils"; import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons"; @@ -469,16 +470,25 @@ function ComposerMentionArrowPlugin() { useEffect(() => { const unregisterLeft = editor.registerCommand( KEY_ARROW_LEFT_COMMAND, - () => { - let currentOffset = -1; + (event) => { + let nextOffset: number | null = null; editor.getEditorState().read(() => { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; - currentOffset = $readSelectionOffsetFromEditorState(0); + const currentOffset = $readSelectionOffsetFromEditorState(0); + if (currentOffset <= 0) return; + const promptValue = $getRoot().getTextContent(); + if (!isCollapsedCursorAdjacentToMention(promptValue, currentOffset, "left")) { + return; + } + nextOffset = currentOffset - 1; }); - if (currentOffset <= 0) return false; + if (nextOffset === null) return false; + const selectionOffset = nextOffset; + event?.preventDefault(); + event?.stopPropagation(); editor.update(() => { - $setSelectionAtComposerOffset(currentOffset - 1); + $setSelectionAtComposerOffset(selectionOffset); }); return true; }, @@ -486,18 +496,26 @@ function ComposerMentionArrowPlugin() { ); const unregisterRight = editor.registerCommand( KEY_ARROW_RIGHT_COMMAND, - () => { - let currentOffset = -1; - let composerLength = 0; + (event) => { + let nextOffset: number | null = null; editor.getEditorState().read(() => { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; - composerLength = $getComposerRootLength(); - currentOffset = $readSelectionOffsetFromEditorState(0); + const currentOffset = $readSelectionOffsetFromEditorState(0); + const composerLength = $getComposerRootLength(); + if (currentOffset >= composerLength) return; + const promptValue = $getRoot().getTextContent(); + if (!isCollapsedCursorAdjacentToMention(promptValue, currentOffset, "right")) { + return; + } + nextOffset = currentOffset + 1; }); - if (currentOffset < 0 || currentOffset >= composerLength) return false; + if (nextOffset === null) return false; + const selectionOffset = nextOffset; + event?.preventDefault(); + event?.stopPropagation(); editor.update(() => { - $setSelectionAtComposerOffset(currentOffset + 1); + $setSelectionAtComposerOffset(selectionOffset); }); return true; }, @@ -540,29 +558,6 @@ function ComposerMentionSelectionNormalizePlugin() { return null; } -function $isCursorAdjacentToMention(): boolean { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false; - const anchorNode = selection.anchor.getNode(); - - if (anchorNode instanceof ComposerMentionNode) return true; - - if ($isElementNode(anchorNode)) { - const childIndex = selection.anchor.offset - 1; - if (childIndex >= 0) { - const child = anchorNode.getChildAtIndex(childIndex); - if (child instanceof ComposerMentionNode) return true; - } - } - - if ($isTextNode(anchorNode) && selection.anchor.offset === 0) { - const prev = anchorNode.getPreviousSibling(); - if (prev instanceof ComposerMentionNode) return true; - } - - return false; -} - function ComposerMentionBackspacePlugin() { const [editor] = useLexicalComposerContext(); @@ -738,7 +733,10 @@ function ComposerPromptEditorInner({ value: nextValue, cursor: nextCursor, }; - onChangeRef.current(nextValue, nextCursor, $isCursorAdjacentToMention()); + const cursorAdjacentToMention = + isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "left") || + isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "right"); + onChangeRef.current(nextValue, nextCursor, cursorAdjacentToMention); }); }, []); diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 22e26531a9..b9990d7fdf 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { detectComposerTrigger, expandCollapsedComposerCursor, + isCollapsedCursorAdjacentToMention, replaceTextRange, } from "./composer-logic"; @@ -77,3 +78,36 @@ describe("expandCollapsedComposerCursor", () => { expect(detectComposerTrigger(text, expandedCursor)).toBeNull(); }); }); + +describe("isCollapsedCursorAdjacentToMention", () => { + it("returns false when no mention exists", () => { + expect(isCollapsedCursorAdjacentToMention("plain text", 6, "left")).toBe(false); + expect(isCollapsedCursorAdjacentToMention("plain text", 6, "right")).toBe(false); + }); + + it("keeps @query typing non-adjacent while no mention pill exists", () => { + const text = "hello @pac"; + expect(isCollapsedCursorAdjacentToMention(text, text.length, "left")).toBe(false); + expect(isCollapsedCursorAdjacentToMention(text, text.length, "right")).toBe(false); + }); + + it("detects left adjacency only when cursor is directly after a mention", () => { + const text = "open @AGENTS.md next"; + const mentionStart = "open ".length; + const mentionEnd = mentionStart + 1; + + expect(isCollapsedCursorAdjacentToMention(text, mentionEnd, "left")).toBe(true); + expect(isCollapsedCursorAdjacentToMention(text, mentionStart, "left")).toBe(false); + expect(isCollapsedCursorAdjacentToMention(text, mentionEnd + 1, "left")).toBe(false); + }); + + it("detects right adjacency only when cursor is directly before a mention", () => { + const text = "open @AGENTS.md next"; + const mentionStart = "open ".length; + const mentionEnd = mentionStart + 1; + + expect(isCollapsedCursorAdjacentToMention(text, mentionStart, "right")).toBe(true); + expect(isCollapsedCursorAdjacentToMention(text, mentionEnd, "right")).toBe(false); + expect(isCollapsedCursorAdjacentToMention(text, mentionStart - 1, "right")).toBe(false); + }); +}); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 193db5d70c..843a5255c3 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -58,6 +58,52 @@ export function expandCollapsedComposerCursor(text: string, cursorInput: number) return expandedCursor; } +function collapsedSegmentLength(segment: { type: "text"; text: string } | { type: "mention" }): number { + return segment.type === "mention" ? 1 : segment.text.length; +} + +function clampCollapsedComposerCursor( + segments: ReadonlyArray<{ type: "text"; text: string } | { type: "mention" }>, + cursorInput: number, +): number { + const collapsedLength = segments.reduce( + (total, segment) => total + collapsedSegmentLength(segment), + 0, + ); + if (!Number.isFinite(cursorInput)) { + return collapsedLength; + } + return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput))); +} + +export function isCollapsedCursorAdjacentToMention( + text: string, + cursorInput: number, + direction: "left" | "right", +): boolean { + const segments = splitPromptIntoComposerSegments(text); + if (!segments.some((segment) => segment.type === "mention")) { + return false; + } + + const cursor = clampCollapsedComposerCursor(segments, cursorInput); + let collapsedOffset = 0; + + for (const segment of segments) { + if (segment.type === "mention") { + if (direction === "left" && cursor === collapsedOffset + 1) { + return true; + } + if (direction === "right" && cursor === collapsedOffset) { + return true; + } + } + collapsedOffset += collapsedSegmentLength(segment); + } + + return false; +} + export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null { const cursor = clampCursor(text, cursorInput); const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1;