authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 10:45:13-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 14:16:08-08:00
logc378afeff852c06d7a558c4842ae9033b3775627
tree2ea73637dfd6b279841b6c3e3a2b597b7c71323c
parentbf7002209e6663d6c208c6dd0546277582077aea

Fix composer mention cursor handling and trigger detection

- Treat mention pills as atomic cursor units in Lexical selection math - Add left/right arrow + selection normalization behavior around mention nodes - Expand collapsed mention cursor before trigger detection so @path menu closes correctly - Update mention pill rendering for non-editable, non-selectable chips and theme-aware icons - Add tests for collapsed-to-expanded cursor mapping and trigger behavior

4 files changed, 202 insertions(+), 10 deletions(-)

apps/web/src/components/ChatView.tsx+7-3
......@@ -40,6 +40,7 @@ import {
4040 type ComposerTrigger,
4141 type ComposerTriggerKind,
4242 detectComposerTrigger,
43 expandCollapsedComposerCursor,
4344 replaceTextRange,
4445} from "../composer-logic";
4546import {
......@@ -365,7 +366,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
365366 return (
366367 <CommandItem
367368 value={props.item.id}
368 className="cursor-pointer gap-2"
369 className="cursor-pointer select-none gap-2"
369370 onMouseDown={(event) => {
370371 event.preventDefault();
371372 }}
......@@ -2195,9 +2196,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
21952196 trigger: ComposerTrigger | null;
21962197 } => {
21972198 const snapshot = readComposerSnapshot();
2199 const expandedCursor = expandCollapsedComposerCursor(snapshot.value, snapshot.cursor);
21982200 return {
21992201 snapshot,
2200 trigger: detectComposerTrigger(snapshot.value, snapshot.cursor),
2202 trigger: detectComposerTrigger(snapshot.value, expandedCursor),
22012203 };
22022204 }, [readComposerSnapshot]);
22032205
......@@ -2276,7 +2278,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
22762278 promptRef.current = nextPrompt;
22772279 setPrompt(nextPrompt);
22782280 setComposerCursor(nextCursor);
2279 setComposerTrigger(detectComposerTrigger(nextPrompt, nextCursor));
2281 setComposerTrigger(
2282 detectComposerTrigger(nextPrompt, expandCollapsedComposerCursor(nextPrompt, nextCursor)),
2283 );
22802284 }, [setPrompt]);
22812285
22822286 const onComposerCommandKey = (key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent) => {
apps/web/src/components/ComposerPromptEditor.tsx+132-6
......@@ -21,6 +21,8 @@ import {
2121 $createParagraphNode,
2222 $createTextNode,
2323 KEY_ARROW_DOWN_COMMAND,
24 KEY_ARROW_LEFT_COMMAND,
25 KEY_ARROW_RIGHT_COMMAND,
2426 KEY_ARROW_UP_COMMAND,
2527 KEY_ENTER_COMMAND,
2628 KEY_TAB_COMMAND,
......@@ -97,6 +99,8 @@ class ComposerMentionNode extends TextNode {
9799 const dom = document.createElement("span");
98100 dom.className =
99101 "inline-flex select-none items-center gap-1 rounded-md border border-border/70 bg-accent/40 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-foreground align-middle";
102 dom.contentEditable = "false";
103 dom.setAttribute("spellcheck", "false");
100104 renderMentionChipDom(dom, this.__path);
101105 return dom;
102106 }
......@@ -106,6 +110,7 @@ class ComposerMentionNode extends TextNode {
106110 dom: HTMLElement,
107111 _config: EditorConfig,
108112 ): boolean {
113 dom.contentEditable = "false";
109114 if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) {
110115 renderMentionChipDom(dom, this.__path);
111116 }
......@@ -144,18 +149,25 @@ function inferMentionPathKind(pathValue: string): "file" | "directory" {
144149 return "directory";
145150}
146151
152function resolvedThemeFromDocument(): "light" | "dark" {
153 return document.documentElement.classList.contains("dark") ? "dark" : "light";
154}
155
147156function renderMentionChipDom(container: HTMLElement, pathValue: string): void {
148157 container.textContent = "";
158 container.style.setProperty("user-select", "none");
159 container.style.setProperty("-webkit-user-select", "none");
149160
161 const theme = resolvedThemeFromDocument();
150162 const icon = document.createElement("img");
151163 icon.alt = "";
152164 icon.ariaHidden = "true";
153165 icon.className = "size-3.5 shrink-0 opacity-85";
154166 icon.loading = "lazy";
155 icon.src = getVscodeIconUrlForEntry(pathValue, inferMentionPathKind(pathValue), "dark");
167 icon.src = getVscodeIconUrlForEntry(pathValue, inferMentionPathKind(pathValue), theme);
156168
157169 const label = document.createElement("span");
158 label.className = "truncate leading-tight";
170 label.className = "truncate select-none leading-tight";
159171 label.textContent = basenameOfPath(pathValue);
160172
161173 container.append(icon, label);
......@@ -167,6 +179,9 @@ function clampCursor(value: string, cursor: number): number {
167179}
168180
169181function getComposerNodeTextLength(node: LexicalNode): number {
182 if (node instanceof ComposerMentionNode) {
183 return 1;
184 }
170185 if ($isTextNode(node)) {
171186 return node.getTextContentSize();
172187 }
......@@ -199,6 +214,9 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb
199214 }
200215
201216 if ($isTextNode(node)) {
217 if (node instanceof ComposerMentionNode) {
218 return offset + (pointOffset > 0 ? 1 : 0);
219 }
202220 return offset + Math.min(pointOffset, node.getTextContentSize());
203221 }
204222
......@@ -224,6 +242,28 @@ function findSelectionPointAtOffset(
224242 node: LexicalNode,
225243 remainingRef: { value: number },
226244): { key: string; offset: number; type: "text" | "element" } | null {
245 if (node instanceof ComposerMentionNode) {
246 const parent = node.getParent();
247 if (!parent || !$isElementNode(parent)) return null;
248 const index = node.getIndexWithinParent();
249 if (remainingRef.value === 0) {
250 return {
251 key: parent.getKey(),
252 offset: index,
253 type: "element",
254 };
255 }
256 if (remainingRef.value === 1) {
257 return {
258 key: parent.getKey(),
259 offset: index + 1,
260 type: "element",
261 };
262 }
263 remainingRef.value -= 1;
264 return null;
265 }
266
227267 if ($isTextNode(node)) {
228268 const size = node.getTextContentSize();
229269 if (remainingRef.value <= size) {
......@@ -279,10 +319,16 @@ function findSelectionPointAtOffset(
279319 return null;
280320}
281321
322function $getComposerRootLength(): number {
323 const root = $getRoot();
324 const children = root.getChildren();
325 return children.reduce((sum, child) => sum + getComposerNodeTextLength(child), 0);
326}
327
282328function $setSelectionAtComposerOffset(nextOffset: number): void {
283329 const root = $getRoot();
284 const rootText = root.getTextContent();
285 const boundedOffset = clampCursor(rootText, nextOffset);
330 const composerLength = $getComposerRootLength();
331 const boundedOffset = Math.max(0, Math.min(nextOffset, composerLength));
286332 const remainingRef = { value: boundedOffset };
287333 const point =
288334 findSelectionPointAtOffset(root, remainingRef) ?? {
......@@ -301,9 +347,10 @@ function $readSelectionOffsetFromEditorState(fallback: number): number {
301347 if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
302348 return fallback;
303349 }
304 const rootText = $getRoot().getTextContent();
305350 const anchorNode = selection.anchor.getNode();
306 return clampCursor(rootText, getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset));
351 const offset = getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset);
352 const composerLength = $getComposerRootLength();
353 return Math.max(0, Math.min(offset, composerLength));
307354}
308355
309356function $appendTextWithLineBreaks(parent: ElementNode, text: string): void {
......@@ -416,6 +463,83 @@ function ComposerCommandKeyPlugin(props: {
416463 return null;
417464}
418465
466function ComposerMentionArrowPlugin() {
467 const [editor] = useLexicalComposerContext();
468
469 useEffect(() => {
470 const unregisterLeft = editor.registerCommand(
471 KEY_ARROW_LEFT_COMMAND,
472 () => {
473 let currentOffset = -1;
474 editor.getEditorState().read(() => {
475 const selection = $getSelection();
476 if (!$isRangeSelection(selection) || !selection.isCollapsed()) return;
477 currentOffset = $readSelectionOffsetFromEditorState(0);
478 });
479 if (currentOffset <= 0) return false;
480 editor.update(() => {
481 $setSelectionAtComposerOffset(currentOffset - 1);
482 });
483 return true;
484 },
485 COMMAND_PRIORITY_HIGH,
486 );
487 const unregisterRight = editor.registerCommand(
488 KEY_ARROW_RIGHT_COMMAND,
489 () => {
490 let currentOffset = -1;
491 let composerLength = 0;
492 editor.getEditorState().read(() => {
493 const selection = $getSelection();
494 if (!$isRangeSelection(selection) || !selection.isCollapsed()) return;
495 composerLength = $getComposerRootLength();
496 currentOffset = $readSelectionOffsetFromEditorState(0);
497 });
498 if (currentOffset < 0 || currentOffset >= composerLength) return false;
499 editor.update(() => {
500 $setSelectionAtComposerOffset(currentOffset + 1);
501 });
502 return true;
503 },
504 COMMAND_PRIORITY_HIGH,
505 );
506 return () => {
507 unregisterLeft();
508 unregisterRight();
509 };
510 }, [editor]);
511
512 return null;
513}
514
515function ComposerMentionSelectionNormalizePlugin() {
516 const [editor] = useLexicalComposerContext();
517
518 useEffect(() => {
519 return editor.registerUpdateListener(({ editorState }) => {
520 let afterOffset: number | null = null;
521 editorState.read(() => {
522 const selection = $getSelection();
523 if (!$isRangeSelection(selection) || !selection.isCollapsed()) return;
524 const anchorNode = selection.anchor.getNode();
525 if (!(anchorNode instanceof ComposerMentionNode)) return;
526 if (selection.anchor.offset === 0) return;
527 const beforeOffset = getAbsoluteOffsetForPoint(anchorNode, 0);
528 afterOffset = beforeOffset + 1;
529 });
530 if (afterOffset !== null) {
531 queueMicrotask(() => {
532 editor.update(() => {
533 $setSelectionAtComposerOffset(afterOffset!);
534 });
535 });
536 }
537 });
538 }, [editor]);
539
540 return null;
541}
542
419543function ComposerMentionBackspacePlugin() {
420544 const [editor] = useLexicalComposerContext();
421545
......@@ -618,6 +742,8 @@ function ComposerPromptEditorInner({
618742 />
619743 <OnChangePlugin onChange={handleEditorChange} />
620744 <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} />
745 <ComposerMentionArrowPlugin />
746 <ComposerMentionSelectionNormalizePlugin />
621747 <ComposerMentionBackspacePlugin />
622748 <HistoryPlugin />
623749 </div>
apps/web/src/composer-logic.test.ts+29-1
......@@ -1,6 +1,10 @@
11import { describe, expect, it } from "vitest";
22
3import { detectComposerTrigger, replaceTextRange } from "./composer-logic";
3import {
4 detectComposerTrigger,
5 expandCollapsedComposerCursor,
6 replaceTextRange,
7} from "./composer-logic";
48
59describe("detectComposerTrigger", () => {
610 it("detects @path trigger at cursor", () => {
......@@ -49,3 +53,27 @@ describe("replaceTextRange", () => {
4953 });
5054 });
5155});
56
57describe("expandCollapsedComposerCursor", () => {
58 it("keeps cursor unchanged when no mention segment is present", () => {
59 expect(expandCollapsedComposerCursor("plain text", 5)).toBe(5);
60 });
61
62 it("maps collapsed mention cursor to expanded text cursor", () => {
63 const text = "what's in my @AGENTS.md fsfdas";
64 const collapsedCursorAfterMention = "what's in my ".length + 2;
65 const expandedCursorAfterMention = "what's in my @AGENTS.md ".length;
66
67 expect(expandCollapsedComposerCursor(text, collapsedCursorAfterMention)).toBe(
68 expandedCursorAfterMention,
69 );
70 });
71
72 it("allows path trigger detection to close after selecting a mention", () => {
73 const text = "what's in my @AGENTS.md ";
74 const collapsedCursorAfterMention = "what's in my ".length + 2;
75 const expandedCursor = expandCollapsedComposerCursor(text, collapsedCursorAfterMention);
76
77 expect(detectComposerTrigger(text, expandedCursor)).toBeNull();
78 });
79});
apps/web/src/composer-logic.ts+34
......@@ -1,3 +1,5 @@
1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
2
13export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";
24
35export interface ComposerTrigger {
......@@ -24,6 +26,38 @@ function tokenStartForCursor(text: string, cursor: number): number {
2426 return index + 1;
2527}
2628
29export function expandCollapsedComposerCursor(text: string, cursorInput: number): number {
30 const collapsedCursor = clampCursor(text, cursorInput);
31 const segments = splitPromptIntoComposerSegments(text);
32 if (segments.length === 0) {
33 return collapsedCursor;
34 }
35
36 let remaining = collapsedCursor;
37 let expandedCursor = 0;
38
39 for (const segment of segments) {
40 if (segment.type === "mention") {
41 const expandedLength = segment.path.length + 1;
42 if (remaining <= 1) {
43 return expandedCursor + (remaining === 0 ? 0 : expandedLength);
44 }
45 remaining -= 1;
46 expandedCursor += expandedLength;
47 continue;
48 }
49
50 const segmentLength = segment.text.length;
51 if (remaining <= segmentLength) {
52 return expandedCursor + remaining;
53 }
54 remaining -= segmentLength;
55 expandedCursor += segmentLength;
56 }
57
58 return expandedCursor;
59}
60
2761export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null {
2862 const cursor = clampCursor(text, cursorInput);
2963 const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1;