From c378afeff852c06d7a558c4842ae9033b3775627 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 2 Mar 2026 10:45:13 -0800 Subject: [PATCH] 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 --- apps/web/src/components/ChatView.tsx | 10 +- .../src/components/ComposerPromptEditor.tsx | 138 +++++++++++++++++- apps/web/src/composer-logic.test.ts | 30 +++- apps/web/src/composer-logic.ts | 34 +++++ 4 files changed, 202 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1d60f3c2a14462d7d34dc19fdc5d24ed34820955..38cc2aed8666669a93583cf4382ee49c7b5042dc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -40,6 +40,7 @@ import { type ComposerTrigger, type ComposerTriggerKind, detectComposerTrigger, + expandCollapsedComposerCursor, replaceTextRange, } from "../composer-logic"; import { @@ -365,7 +366,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { return ( { event.preventDefault(); }} @@ -2195,9 +2196,10 @@ export default function ChatView({ threadId }: ChatViewProps) { trigger: ComposerTrigger | null; } => { const snapshot = readComposerSnapshot(); + const expandedCursor = expandCollapsedComposerCursor(snapshot.value, snapshot.cursor); return { snapshot, - trigger: detectComposerTrigger(snapshot.value, snapshot.cursor), + trigger: detectComposerTrigger(snapshot.value, expandedCursor), }; }, [readComposerSnapshot]); @@ -2276,7 +2278,9 @@ export default function ChatView({ threadId }: ChatViewProps) { promptRef.current = nextPrompt; setPrompt(nextPrompt); setComposerCursor(nextCursor); - setComposerTrigger(detectComposerTrigger(nextPrompt, nextCursor)); + setComposerTrigger( + detectComposerTrigger(nextPrompt, expandCollapsedComposerCursor(nextPrompt, nextCursor)), + ); }, [setPrompt]); const onComposerCommandKey = (key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent) => { diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index daa76ccb2bb29ce78eb102744c485813dd0d15ed..6601e558edfaabbd28483963b437baac90fa5621 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -21,6 +21,8 @@ import { $createParagraphNode, $createTextNode, KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND, KEY_TAB_COMMAND, @@ -97,6 +99,8 @@ class ComposerMentionNode extends TextNode { const dom = document.createElement("span"); dom.className = "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"; + dom.contentEditable = "false"; + dom.setAttribute("spellcheck", "false"); renderMentionChipDom(dom, this.__path); return dom; } @@ -106,6 +110,7 @@ class ComposerMentionNode extends TextNode { dom: HTMLElement, _config: EditorConfig, ): boolean { + dom.contentEditable = "false"; if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) { renderMentionChipDom(dom, this.__path); } @@ -144,18 +149,25 @@ function inferMentionPathKind(pathValue: string): "file" | "directory" { return "directory"; } +function resolvedThemeFromDocument(): "light" | "dark" { + return document.documentElement.classList.contains("dark") ? "dark" : "light"; +} + function renderMentionChipDom(container: HTMLElement, pathValue: string): void { container.textContent = ""; + container.style.setProperty("user-select", "none"); + container.style.setProperty("-webkit-user-select", "none"); + const theme = resolvedThemeFromDocument(); const icon = document.createElement("img"); icon.alt = ""; icon.ariaHidden = "true"; icon.className = "size-3.5 shrink-0 opacity-85"; icon.loading = "lazy"; - icon.src = getVscodeIconUrlForEntry(pathValue, inferMentionPathKind(pathValue), "dark"); + icon.src = getVscodeIconUrlForEntry(pathValue, inferMentionPathKind(pathValue), theme); const label = document.createElement("span"); - label.className = "truncate leading-tight"; + label.className = "truncate select-none leading-tight"; label.textContent = basenameOfPath(pathValue); container.append(icon, label); @@ -167,6 +179,9 @@ function clampCursor(value: string, cursor: number): number { } function getComposerNodeTextLength(node: LexicalNode): number { + if (node instanceof ComposerMentionNode) { + return 1; + } if ($isTextNode(node)) { return node.getTextContentSize(); } @@ -199,6 +214,9 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb } if ($isTextNode(node)) { + if (node instanceof ComposerMentionNode) { + return offset + (pointOffset > 0 ? 1 : 0); + } return offset + Math.min(pointOffset, node.getTextContentSize()); } @@ -224,6 +242,28 @@ function findSelectionPointAtOffset( node: LexicalNode, remainingRef: { value: number }, ): { key: string; offset: number; type: "text" | "element" } | null { + if (node instanceof ComposerMentionNode) { + const parent = node.getParent(); + if (!parent || !$isElementNode(parent)) return null; + const index = node.getIndexWithinParent(); + if (remainingRef.value === 0) { + return { + key: parent.getKey(), + offset: index, + type: "element", + }; + } + if (remainingRef.value === 1) { + return { + key: parent.getKey(), + offset: index + 1, + type: "element", + }; + } + remainingRef.value -= 1; + return null; + } + if ($isTextNode(node)) { const size = node.getTextContentSize(); if (remainingRef.value <= size) { @@ -279,10 +319,16 @@ function findSelectionPointAtOffset( return null; } +function $getComposerRootLength(): number { + const root = $getRoot(); + const children = root.getChildren(); + return children.reduce((sum, child) => sum + getComposerNodeTextLength(child), 0); +} + function $setSelectionAtComposerOffset(nextOffset: number): void { const root = $getRoot(); - const rootText = root.getTextContent(); - const boundedOffset = clampCursor(rootText, nextOffset); + const composerLength = $getComposerRootLength(); + const boundedOffset = Math.max(0, Math.min(nextOffset, composerLength)); const remainingRef = { value: boundedOffset }; const point = findSelectionPointAtOffset(root, remainingRef) ?? { @@ -301,9 +347,10 @@ function $readSelectionOffsetFromEditorState(fallback: number): number { if (!$isRangeSelection(selection) || !selection.isCollapsed()) { return fallback; } - const rootText = $getRoot().getTextContent(); const anchorNode = selection.anchor.getNode(); - return clampCursor(rootText, getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset)); + const offset = getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); + const composerLength = $getComposerRootLength(); + return Math.max(0, Math.min(offset, composerLength)); } function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { @@ -416,6 +463,83 @@ function ComposerCommandKeyPlugin(props: { return null; } +function ComposerMentionArrowPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + const unregisterLeft = editor.registerCommand( + KEY_ARROW_LEFT_COMMAND, + () => { + let currentOffset = -1; + editor.getEditorState().read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; + currentOffset = $readSelectionOffsetFromEditorState(0); + }); + if (currentOffset <= 0) return false; + editor.update(() => { + $setSelectionAtComposerOffset(currentOffset - 1); + }); + return true; + }, + COMMAND_PRIORITY_HIGH, + ); + const unregisterRight = editor.registerCommand( + KEY_ARROW_RIGHT_COMMAND, + () => { + let currentOffset = -1; + let composerLength = 0; + editor.getEditorState().read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; + composerLength = $getComposerRootLength(); + currentOffset = $readSelectionOffsetFromEditorState(0); + }); + if (currentOffset < 0 || currentOffset >= composerLength) return false; + editor.update(() => { + $setSelectionAtComposerOffset(currentOffset + 1); + }); + return true; + }, + COMMAND_PRIORITY_HIGH, + ); + return () => { + unregisterLeft(); + unregisterRight(); + }; + }, [editor]); + + return null; +} + +function ComposerMentionSelectionNormalizePlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return editor.registerUpdateListener(({ editorState }) => { + let afterOffset: number | null = null; + editorState.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; + const anchorNode = selection.anchor.getNode(); + if (!(anchorNode instanceof ComposerMentionNode)) return; + if (selection.anchor.offset === 0) return; + const beforeOffset = getAbsoluteOffsetForPoint(anchorNode, 0); + afterOffset = beforeOffset + 1; + }); + if (afterOffset !== null) { + queueMicrotask(() => { + editor.update(() => { + $setSelectionAtComposerOffset(afterOffset!); + }); + }); + } + }); + }, [editor]); + + return null; +} + function ComposerMentionBackspacePlugin() { const [editor] = useLexicalComposerContext(); @@ -618,6 +742,8 @@ function ComposerPromptEditorInner({ /> + + diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index c2b32cf142c189c2f8a3c74c34f56ad309b35c70..22e26531a92b236cff09567b248e522bf10118e2 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { detectComposerTrigger, replaceTextRange } from "./composer-logic"; +import { + detectComposerTrigger, + expandCollapsedComposerCursor, + replaceTextRange, +} from "./composer-logic"; describe("detectComposerTrigger", () => { it("detects @path trigger at cursor", () => { @@ -49,3 +53,27 @@ describe("replaceTextRange", () => { }); }); }); + +describe("expandCollapsedComposerCursor", () => { + it("keeps cursor unchanged when no mention segment is present", () => { + expect(expandCollapsedComposerCursor("plain text", 5)).toBe(5); + }); + + it("maps collapsed mention cursor to expanded text cursor", () => { + const text = "what's in my @AGENTS.md fsfdas"; + const collapsedCursorAfterMention = "what's in my ".length + 2; + const expandedCursorAfterMention = "what's in my @AGENTS.md ".length; + + expect(expandCollapsedComposerCursor(text, collapsedCursorAfterMention)).toBe( + expandedCursorAfterMention, + ); + }); + + it("allows path trigger detection to close after selecting a mention", () => { + const text = "what's in my @AGENTS.md "; + const collapsedCursorAfterMention = "what's in my ".length + 2; + const expandedCursor = expandCollapsedComposerCursor(text, collapsedCursorAfterMention); + + expect(detectComposerTrigger(text, expandedCursor)).toBeNull(); + }); +}); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 8b08459c28aecd9c4981185dc23e211336952736..193db5d70c4993ce70b348bdb457db9f1f69bce4 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,3 +1,5 @@ +import { splitPromptIntoComposerSegments } from "./composer-editor-mentions"; + export type ComposerTriggerKind = "path" | "slash-command" | "slash-model"; export interface ComposerTrigger { @@ -24,6 +26,38 @@ function tokenStartForCursor(text: string, cursor: number): number { return index + 1; } +export function expandCollapsedComposerCursor(text: string, cursorInput: number): number { + const collapsedCursor = clampCursor(text, cursorInput); + const segments = splitPromptIntoComposerSegments(text); + if (segments.length === 0) { + return collapsedCursor; + } + + let remaining = collapsedCursor; + let expandedCursor = 0; + + for (const segment of segments) { + if (segment.type === "mention") { + const expandedLength = segment.path.length + 1; + if (remaining <= 1) { + return expandedCursor + (remaining === 0 ? 0 : expandedLength); + } + remaining -= 1; + expandedCursor += expandedLength; + continue; + } + + const segmentLength = segment.text.length; + if (remaining <= segmentLength) { + return expandedCursor + remaining; + } + remaining -= segmentLength; + expandedCursor += segmentLength; + } + + return expandedCursor; +} + 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; -- 2.54.0