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 {...@@ -40,6 +40,7 @@ import {
40 type ComposerTrigger,40 type ComposerTrigger,
41 type ComposerTriggerKind,41 type ComposerTriggerKind,
42 detectComposerTrigger,42 detectComposerTrigger,
43 expandCollapsedComposerCursor,
43 replaceTextRange,44 replaceTextRange,
44} from "../composer-logic";45} from "../composer-logic";
45import {46import {
...@@ -365,7 +366,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {...@@ -365,7 +366,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
365 return (366 return (
366 <CommandItem367 <CommandItem
367 value={props.item.id}368 value={props.item.id}
368 className="cursor-pointer gap-2"369 className="cursor-pointer select-none gap-2"
369 onMouseDown={(event) => {370 onMouseDown={(event) => {
370 event.preventDefault();371 event.preventDefault();
371 }}372 }}
...@@ -2195,9 +2196,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2195,9 +2196,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
2195 trigger: ComposerTrigger | null;2196 trigger: ComposerTrigger | null;
2196 } => {2197 } => {
2197 const snapshot = readComposerSnapshot();2198 const snapshot = readComposerSnapshot();
2199 const expandedCursor = expandCollapsedComposerCursor(snapshot.value, snapshot.cursor);
2198 return {2200 return {
2199 snapshot,2201 snapshot,
2200 trigger: detectComposerTrigger(snapshot.value, snapshot.cursor),2202 trigger: detectComposerTrigger(snapshot.value, expandedCursor),
2201 };2203 };
2202 }, [readComposerSnapshot]);2204 }, [readComposerSnapshot]);
22032205
...@@ -2276,7 +2278,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2276,7 +2278,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
2276 promptRef.current = nextPrompt;2278 promptRef.current = nextPrompt;
2277 setPrompt(nextPrompt);2279 setPrompt(nextPrompt);
2278 setComposerCursor(nextCursor);2280 setComposerCursor(nextCursor);
2279 setComposerTrigger(detectComposerTrigger(nextPrompt, nextCursor));2281 setComposerTrigger(
2282 detectComposerTrigger(nextPrompt, expandCollapsedComposerCursor(nextPrompt, nextCursor)),
2283 );
2280 }, [setPrompt]);2284 }, [setPrompt]);
22812285
2282 const onComposerCommandKey = (key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent) => {2286 const onComposerCommandKey = (key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent) => {
apps/web/src/components/ComposerPromptEditor.tsx+132-6
...@@ -21,6 +21,8 @@ import {...@@ -21,6 +21,8 @@ import {
21 $createParagraphNode,21 $createParagraphNode,
22 $createTextNode,22 $createTextNode,
23 KEY_ARROW_DOWN_COMMAND,23 KEY_ARROW_DOWN_COMMAND,
24 KEY_ARROW_LEFT_COMMAND,
25 KEY_ARROW_RIGHT_COMMAND,
24 KEY_ARROW_UP_COMMAND,26 KEY_ARROW_UP_COMMAND,
25 KEY_ENTER_COMMAND,27 KEY_ENTER_COMMAND,
26 KEY_TAB_COMMAND,28 KEY_TAB_COMMAND,
...@@ -97,6 +99,8 @@ class ComposerMentionNode extends TextNode {...@@ -97,6 +99,8 @@ class ComposerMentionNode extends TextNode {
97 const dom = document.createElement("span");99 const dom = document.createElement("span");
98 dom.className =100 dom.className =
99 "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";101 "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");
100 renderMentionChipDom(dom, this.__path);104 renderMentionChipDom(dom, this.__path);
101 return dom;105 return dom;
102 }106 }
...@@ -106,6 +110,7 @@ class ComposerMentionNode extends TextNode {...@@ -106,6 +110,7 @@ class ComposerMentionNode extends TextNode {
106 dom: HTMLElement,110 dom: HTMLElement,
107 _config: EditorConfig,111 _config: EditorConfig,
108 ): boolean {112 ): boolean {
113 dom.contentEditable = "false";
109 if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) {114 if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) {
110 renderMentionChipDom(dom, this.__path);115 renderMentionChipDom(dom, this.__path);
111 }116 }
...@@ -144,18 +149,25 @@ function inferMentionPathKind(pathValue: string): "file" | "directory" {...@@ -144,18 +149,25 @@ function inferMentionPathKind(pathValue: string): "file" | "directory" {
144 return "directory";149 return "directory";
145}150}
146151
152function resolvedThemeFromDocument(): "light" | "dark" {
153 return document.documentElement.classList.contains("dark") ? "dark" : "light";
154}
155
147function renderMentionChipDom(container: HTMLElement, pathValue: string): void {156function renderMentionChipDom(container: HTMLElement, pathValue: string): void {
148 container.textContent = "";157 container.textContent = "";
158 container.style.setProperty("user-select", "none");
159 container.style.setProperty("-webkit-user-select", "none");
149160
161 const theme = resolvedThemeFromDocument();
150 const icon = document.createElement("img");162 const icon = document.createElement("img");
151 icon.alt = "";163 icon.alt = "";
152 icon.ariaHidden = "true";164 icon.ariaHidden = "true";
153 icon.className = "size-3.5 shrink-0 opacity-85";165 icon.className = "size-3.5 shrink-0 opacity-85";
154 icon.loading = "lazy";166 icon.loading = "lazy";
155 icon.src = getVscodeIconUrlForEntry(pathValue, inferMentionPathKind(pathValue), "dark");167 icon.src = getVscodeIconUrlForEntry(pathValue, inferMentionPathKind(pathValue), theme);
156168
157 const label = document.createElement("span");169 const label = document.createElement("span");
158 label.className = "truncate leading-tight";170 label.className = "truncate select-none leading-tight";
159 label.textContent = basenameOfPath(pathValue);171 label.textContent = basenameOfPath(pathValue);
160172
161 container.append(icon, label);173 container.append(icon, label);
...@@ -167,6 +179,9 @@ function clampCursor(value: string, cursor: number): number {...@@ -167,6 +179,9 @@ function clampCursor(value: string, cursor: number): number {
167}179}
168180
169function getComposerNodeTextLength(node: LexicalNode): number {181function getComposerNodeTextLength(node: LexicalNode): number {
182 if (node instanceof ComposerMentionNode) {
183 return 1;
184 }
170 if ($isTextNode(node)) {185 if ($isTextNode(node)) {
171 return node.getTextContentSize();186 return node.getTextContentSize();
172 }187 }
...@@ -199,6 +214,9 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb...@@ -199,6 +214,9 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb
199 }214 }
200215
201 if ($isTextNode(node)) {216 if ($isTextNode(node)) {
217 if (node instanceof ComposerMentionNode) {
218 return offset + (pointOffset > 0 ? 1 : 0);
219 }
202 return offset + Math.min(pointOffset, node.getTextContentSize());220 return offset + Math.min(pointOffset, node.getTextContentSize());
203 }221 }
204222
...@@ -224,6 +242,28 @@ function findSelectionPointAtOffset(...@@ -224,6 +242,28 @@ function findSelectionPointAtOffset(
224 node: LexicalNode,242 node: LexicalNode,
225 remainingRef: { value: number },243 remainingRef: { value: number },
226): { key: string; offset: number; type: "text" | "element" } | null {244): { 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
227 if ($isTextNode(node)) {267 if ($isTextNode(node)) {
228 const size = node.getTextContentSize();268 const size = node.getTextContentSize();
229 if (remainingRef.value <= size) {269 if (remainingRef.value <= size) {
...@@ -279,10 +319,16 @@ function findSelectionPointAtOffset(...@@ -279,10 +319,16 @@ function findSelectionPointAtOffset(
279 return null;319 return null;
280}320}
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
282function $setSelectionAtComposerOffset(nextOffset: number): void {328function $setSelectionAtComposerOffset(nextOffset: number): void {
283 const root = $getRoot();329 const root = $getRoot();
284 const rootText = root.getTextContent();330 const composerLength = $getComposerRootLength();
285 const boundedOffset = clampCursor(rootText, nextOffset);331 const boundedOffset = Math.max(0, Math.min(nextOffset, composerLength));
286 const remainingRef = { value: boundedOffset };332 const remainingRef = { value: boundedOffset };
287 const point =333 const point =
288 findSelectionPointAtOffset(root, remainingRef) ?? {334 findSelectionPointAtOffset(root, remainingRef) ?? {
...@@ -301,9 +347,10 @@ function $readSelectionOffsetFromEditorState(fallback: number): number {...@@ -301,9 +347,10 @@ function $readSelectionOffsetFromEditorState(fallback: number): number {
301 if (!$isRangeSelection(selection) || !selection.isCollapsed()) {347 if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
302 return fallback;348 return fallback;
303 }349 }
304 const rootText = $getRoot().getTextContent();
305 const anchorNode = selection.anchor.getNode();350 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));
307}354}
308355
309function $appendTextWithLineBreaks(parent: ElementNode, text: string): void {356function $appendTextWithLineBreaks(parent: ElementNode, text: string): void {
...@@ -416,6 +463,83 @@ function ComposerCommandKeyPlugin(props: {...@@ -416,6 +463,83 @@ function ComposerCommandKeyPlugin(props: {
416 return null;463 return null;
417}464}
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
419function ComposerMentionBackspacePlugin() {543function ComposerMentionBackspacePlugin() {
420 const [editor] = useLexicalComposerContext();544 const [editor] = useLexicalComposerContext();
421545
...@@ -618,6 +742,8 @@ function ComposerPromptEditorInner({...@@ -618,6 +742,8 @@ function ComposerPromptEditorInner({
618 />742 />
619 <OnChangePlugin onChange={handleEditorChange} />743 <OnChangePlugin onChange={handleEditorChange} />
620 <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} />744 <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} />
745 <ComposerMentionArrowPlugin />
746 <ComposerMentionSelectionNormalizePlugin />
621 <ComposerMentionBackspacePlugin />747 <ComposerMentionBackspacePlugin />
622 <HistoryPlugin />748 <HistoryPlugin />
623 </div>749 </div>
apps/web/src/composer-logic.test.ts+29-1
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import { detectComposerTrigger, replaceTextRange } from "./composer-logic";3import {
4 detectComposerTrigger,
5 expandCollapsedComposerCursor,
6 replaceTextRange,
7} from "./composer-logic";
48
5describe("detectComposerTrigger", () => {9describe("detectComposerTrigger", () => {
6 it("detects @path trigger at cursor", () => {10 it("detects @path trigger at cursor", () => {
...@@ -49,3 +53,27 @@ describe("replaceTextRange", () => {...@@ -49,3 +53,27 @@ describe("replaceTextRange", () => {
49 });53 });
50 });54 });
51});55});
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 @@...@@ -1,3 +1,5 @@
1import { splitPromptIntoComposerSegments } from "./composer-editor-mentions";
2
1export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";3export type ComposerTriggerKind = "path" | "slash-command" | "slash-model";
24
3export interface ComposerTrigger {5export interface ComposerTrigger {
...@@ -24,6 +26,38 @@ function tokenStartForCursor(text: string, cursor: number): number {...@@ -24,6 +26,38 @@ function tokenStartForCursor(text: string, cursor: number): number {
24 return index + 1;26 return index + 1;
25}27}
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
27export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null {61export function detectComposerTrigger(text: string, cursorInput: number): ComposerTrigger | null {
28 const cursor = clampCursor(text, cursorInput);62 const cursor = clampCursor(text, cursorInput);
29 const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1;63 const lineStart = text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1;