Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 34 additions & 36 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -469,35 +470,52 @@ function ComposerMentionArrowPlugin() {
useEffect(() => {
const unregisterLeft = editor.registerCommand(
KEY_ARROW_LEFT_COMMAND,
() => {
let currentOffset = -1;
(event) => {
let nextOffset: number | null = null;
editor.getEditorState().read(() => {
Comment on lines +473 to 475
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium components/ComposerPromptEditor.tsx:473

When the user presses Shift + ArrowLeft or Shift + ArrowRight next to a mention, the ComposerMentionArrowPlugin consumes the event and moves the cursor instead of allowing Lexical to extend the selection. This makes mention nodes impossible to select via keyboard. Consider adding a check for event?.shiftKey and returning false early when the shift modifier is held, so standard selection behavior is preserved.

      (event) => {
+        if (event?.shiftKey) return false;
        let nextOffset: number | null = null;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/components/ComposerPromptEditor.tsx around lines 473-475:

When the user presses `Shift + ArrowLeft` or `Shift + ArrowRight` next to a mention, the `ComposerMentionArrowPlugin` consumes the event and moves the cursor instead of allowing Lexical to extend the selection. This makes mention nodes impossible to select via keyboard. Consider adding a check for `event?.shiftKey` and returning `false` early when the shift modifier is held, so standard selection behavior is preserved.

Evidence trail:
apps/web/src/components/ComposerPromptEditor.tsx lines 472-519 at REVIEWED_COMMIT. The KEY_ARROW_LEFT_COMMAND handler (lines 472-495) and KEY_ARROW_RIGHT_COMMAND handler (lines 496-519) both check `!selection.isCollapsed()` but neither checks `event?.shiftKey`. When adjacent to a mention with collapsed selection, both handlers call `event?.preventDefault()`, `event?.stopPropagation()`, and return `true`, consuming the event regardless of whether Shift is held.

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;
},
COMMAND_PRIORITY_HIGH,
);
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;
},
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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);
});
}, []);

Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/composer-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
detectComposerTrigger,
expandCollapsedComposerCursor,
isCollapsedCursorAdjacentToMention,
replaceTextRange,
} from "./composer-logic";

Expand Down Expand Up @@ -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);
});
});
46 changes: 46 additions & 0 deletions apps/web/src/composer-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regex-based mention detection mismatches Lexical node tree

Medium Severity

isCollapsedCursorAdjacentToMention re-detects mentions from raw text using splitPromptIntoComposerSegments, whose regex requires trailing whitespace ((?=\s)). But the Lexical editor identifies mentions by node type (ComposerMentionNode). When a mention lacks trailing whitespace (e.g., at end of text, or user deleted the space), the regex won't find it, so the collapsed cursor offset from Lexical (where mention = 1 unit) gets interpreted in a mismatched coordinate system (where the full @path text counts at its actual length). The arrow key override silently fails to activate. The old code avoided this by always jumping in Lexical's collapsed space without relying on regex re-detection.

Additional Locations (2)

Fix in Cursor Fix in Web


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;
Expand Down