From 332a9341777dcf3a16ae4c0dd80c647b3a28c66b Mon Sep 17 00:00:00 2001 From: Chukwudi Nwobodo Date: Wed, 11 Mar 2026 22:23:07 +0000 Subject: [PATCH 1/2] fix: compute expanded cursor from Lexical editor for mid-text @file autocomplete expandCollapsedComposerCursor uses a regex to find mentions in raw text, which false-matches plain text like @in when the user types @ before an existing word. This corrupts the cursor mapping and prevents trigger detection from firing. Compute the expanded cursor directly from the Lexical editor's node tree, which knows which nodes are actual ComposerMentionNode instances. Pass this expanded cursor through onChange and readSnapshot so detectComposerTrigger receives the correct text offset. Closes #291 Related to #922 --- apps/web/src/components/ChatView.tsx | 39 ++++--- .../src/components/ComposerPromptEditor.tsx | 108 ++++++++++++++++-- apps/web/src/composer-logic.test.ts | 42 +++++++ 3 files changed, 163 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d81e024f3..cd5e92365 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -40,7 +40,6 @@ import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch" import { type ComposerTrigger, detectComposerTrigger, - expandCollapsedComposerCursor, parseStandaloneComposerSlashCommand, replaceTextRange, } from "../composer-logic"; @@ -639,10 +638,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerTrigger( detectComposerTrigger( activePendingProgress.customAnswer, - expandCollapsedComposerCursor( - activePendingProgress.customAnswer, - activePendingProgress.customAnswer.length, - ), + activePendingProgress.customAnswer.length, ), ); setComposerHighlightedItemId(null); @@ -2536,7 +2532,13 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const onChangeActivePendingUserInputCustomAnswer = useCallback( - (questionId: string, value: string, nextCursor: number, cursorAdjacentToMention: boolean) => { + ( + questionId: string, + value: string, + nextCursor: number, + expandedCursor: number, + cursorAdjacentToMention: boolean, + ) => { if (!activePendingUserInput) { return; } @@ -2553,9 +2555,7 @@ export default function ChatView({ threadId }: ChatViewProps) { })); setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention - ? null - : detectComposerTrigger(value, expandCollapsedComposerCursor(value, nextCursor)), + cursorAdjacentToMention ? null : detectComposerTrigger(value, expandedCursor), ); }, [activePendingUserInput], @@ -2920,12 +2920,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const readComposerSnapshot = useCallback((): { value: string; cursor: number; + expandedCursor: number; } => { const editorSnapshot = composerEditorRef.current?.readSnapshot(); if (editorSnapshot) { return editorSnapshot; } - return { value: promptRef.current, cursor: composerCursor }; + return { value: promptRef.current, cursor: composerCursor, expandedCursor: composerCursor }; }, [composerCursor]); const resolveActiveComposerTrigger = useCallback((): { @@ -2933,10 +2934,9 @@ 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, expandedCursor), + trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), }; }, [readComposerSnapshot]); @@ -3024,12 +3024,18 @@ export default function ChatView({ threadId }: ChatViewProps) { workspaceEntriesQuery.isFetching); const onPromptChange = useCallback( - (nextPrompt: string, nextCursor: number, cursorAdjacentToMention: boolean) => { + ( + nextPrompt: string, + nextCursor: number, + expandedCursor: number, + cursorAdjacentToMention: boolean, + ) => { if (activePendingProgress?.activeQuestion && activePendingUserInput) { onChangeActivePendingUserInputCustomAnswer( activePendingProgress.activeQuestion.id, nextPrompt, nextCursor, + expandedCursor, cursorAdjacentToMention, ); return; @@ -3038,12 +3044,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setPrompt(nextPrompt); setComposerCursor(nextCursor); setComposerTrigger( - cursorAdjacentToMention - ? null - : detectComposerTrigger( - nextPrompt, - expandCollapsedComposerCursor(nextPrompt, nextCursor), - ), + cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), ); }, [ diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 96efc0fbf..0274afa3c 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -192,6 +192,21 @@ function getComposerNodeTextLength(node: LexicalNode): number { return 0; } +function getComposerNodeExpandedTextLength(node: LexicalNode): number { + if ($isTextNode(node)) { + return node.getTextContentSize(); + } + if ($isLineBreakNode(node)) { + return 1; + } + if ($isElementNode(node)) { + return node + .getChildren() + .reduce((total, child) => total + getComposerNodeExpandedTextLength(child), 0); + } + return 0; +} + function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { let offset = 0; let current: LexicalNode | null = node; @@ -236,6 +251,50 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb return offset; } +function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { + let offset = 0; + let current: LexicalNode | null = node; + + while (current) { + const nextParent = current.getParent() as LexicalNode | null; + if (!nextParent || !$isElementNode(nextParent)) { + break; + } + const siblings = nextParent.getChildren(); + const index = current.getIndexWithinParent(); + for (let i = 0; i < index; i += 1) { + const sibling = siblings[i]; + if (!sibling) continue; + offset += getComposerNodeExpandedTextLength(sibling); + } + current = nextParent; + } + + if ($isTextNode(node)) { + if (node instanceof ComposerMentionNode) { + return offset + (pointOffset > 0 ? node.getTextContentSize() : 0); + } + return offset + Math.min(pointOffset, node.getTextContentSize()); + } + + if ($isLineBreakNode(node)) { + return offset + Math.min(pointOffset, 1); + } + + if ($isElementNode(node)) { + const children = node.getChildren(); + const clampedOffset = Math.max(0, Math.min(pointOffset, children.length)); + for (let i = 0; i < clampedOffset; i += 1) { + const child = children[i]; + if (!child) continue; + offset += getComposerNodeExpandedTextLength(child); + } + return offset; + } + + return offset; +} + function findSelectionPointAtOffset( node: LexicalNode, remainingRef: { value: number }, @@ -350,6 +409,17 @@ function $readSelectionOffsetFromEditorState(fallback: number): number { return Math.max(0, Math.min(offset, composerLength)); } +function $readExpandedSelectionOffsetFromEditorState(fallback: number): number { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + return fallback; + } + const anchorNode = selection.anchor.getNode(); + const offset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); + const expandedLength = $getRoot().getTextContent().length; + return Math.max(0, Math.min(offset, expandedLength)); +} + function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { const lines = text.split("\n"); for (let index = 0; index < lines.length; index += 1) { @@ -383,7 +453,7 @@ export interface ComposerPromptEditorHandle { focus: () => void; focusAt: (cursor: number) => void; focusAtEnd: () => void; - readSnapshot: () => { value: string; cursor: number }; + readSnapshot: () => { value: string; cursor: number; expandedCursor: number }; } interface ComposerPromptEditorProps { @@ -392,7 +462,12 @@ interface ComposerPromptEditorProps { disabled: boolean; placeholder: string; className?: string; - onChange: (nextValue: string, nextCursor: number, cursorAdjacentToMention: boolean) => void; + onChange: ( + nextValue: string, + nextCursor: number, + expandedCursor: number, + cursorAdjacentToMention: boolean, + ) => void; onCommandKeyDown?: ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent, @@ -672,17 +747,27 @@ function ComposerPromptEditorInner({ editor.update(() => { $setSelectionAtComposerOffset(boundedCursor); }); + const nextExpandedCursor = editor + .getEditorState() + .read(() => $readExpandedSelectionOffsetFromEditorState(boundedCursor)); snapshotRef.current = { value: snapshotRef.current.value, cursor: boundedCursor, }; - onChangeRef.current(snapshotRef.current.value, boundedCursor, false); + onChangeRef.current(snapshotRef.current.value, boundedCursor, nextExpandedCursor, false); }, [editor], ); - const readSnapshot = useCallback((): { value: string; cursor: number } => { - let snapshot = snapshotRef.current; + const readSnapshot = useCallback((): { + value: string; + cursor: number; + expandedCursor: number; + } => { + let snapshot: { value: string; cursor: number; expandedCursor: number } = { + ...snapshotRef.current, + expandedCursor: snapshotRef.current.cursor, + }; editor.getEditorState().read(() => { const nextValue = $getRoot().getTextContent(); const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor); @@ -690,12 +775,17 @@ function ComposerPromptEditorInner({ nextValue, $readSelectionOffsetFromEditorState(fallbackCursor), ); + const nextExpandedCursor = clampCursor( + nextValue, + $readExpandedSelectionOffsetFromEditorState(fallbackCursor), + ); snapshot = { value: nextValue, cursor: nextCursor, + expandedCursor: nextExpandedCursor, }; }); - snapshotRef.current = snapshot; + snapshotRef.current = { value: snapshot.value, cursor: snapshot.cursor }; return snapshot; }, [editor]); @@ -724,6 +814,10 @@ function ComposerPromptEditorInner({ nextValue, $readSelectionOffsetFromEditorState(fallbackCursor), ); + const nextExpandedCursor = clampCursor( + nextValue, + $readExpandedSelectionOffsetFromEditorState(fallbackCursor), + ); const previousSnapshot = snapshotRef.current; if (previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor) { return; @@ -735,7 +829,7 @@ function ComposerPromptEditorInner({ const cursorAdjacentToMention = isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "left") || isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "right"); - onChangeRef.current(nextValue, nextCursor, cursorAdjacentToMention); + onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, cursorAdjacentToMention); }); }, []); diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 7e6805c96..91dc444f7 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -56,6 +56,34 @@ describe("detectComposerTrigger", () => { rangeEnd: text.length, }); }); + + it("detects @path trigger in the middle of existing text", () => { + // User typed @ between "inspect " and "in this sentence" + const text = "Please inspect @in this sentence"; + const cursorAfterAt = "Please inspect @".length; + + const trigger = detectComposerTrigger(text, cursorAfterAt); + expect(trigger).toEqual({ + kind: "path", + query: "", + rangeStart: "Please inspect ".length, + rangeEnd: cursorAfterAt, + }); + }); + + it("detects @path trigger with query typed mid-text", () => { + // User typed @sr between "inspect " and "in this sentence" + const text = "Please inspect @srin this sentence"; + const cursorAfterQuery = "Please inspect @sr".length; + + const trigger = detectComposerTrigger(text, cursorAfterQuery); + expect(trigger).toEqual({ + kind: "path", + query: "sr", + rangeStart: "Please inspect ".length, + rangeEnd: cursorAfterQuery, + }); + }); }); describe("replaceTextRange", () => { @@ -90,6 +118,20 @@ describe("expandCollapsedComposerCursor", () => { expect(detectComposerTrigger(text, expandedCursor)).toBeNull(); }); + + it("detectComposerTrigger works with true cursor even when expandCollapsedComposerCursor is wrong", () => { + // expandCollapsedComposerCursor uses MENTION_TOKEN_REGEX which can false-match + // plain text like "@in" as a mention. The fix bypasses it by computing the expanded + // cursor directly from the Lexical editor's node tree. + const text = "Please inspect @in this sentence"; + const cursorAfterAt = "Please inspect @".length; + + // With the true cursor position, trigger detection works correctly + const trigger = detectComposerTrigger(text, cursorAfterAt); + expect(trigger).not.toBeNull(); + expect(trigger?.kind).toBe("path"); + expect(trigger?.query).toBe(""); + }); }); describe("isCollapsedCursorAdjacentToMention", () => { From 28a40d70124bb5b6d4de9f8077369f6758bd09e4 Mon Sep 17 00:00:00 2001 From: Chukwudi Nwobodo Date: Thu, 12 Mar 2026 02:17:12 +0000 Subject: [PATCH 2/2] fix: collapse cursor offsets in applyPromptReplacement and consume trailing space applyPromptReplacement stored the expanded cursor from replaceTextRange as a collapsed cursor, causing the caret to jump to the end of the line after selecting a completion when a mention pill existed earlier in the prompt. Convert the cursor with collapseExpandedComposerCursor before storing and restoring focus. When a replacement already ends with a space and the character at the cursor is also a space, extend the replacement range to consume the existing space so the result has exactly one. Fix all other setComposerCursor call sites that passed expanded offsets where collapsed offsets were expected. --- apps/web/src/components/ChatView.tsx | 81 +++++++++++++----- .../src/components/ComposerPromptEditor.tsx | 82 +++++++++++++------ apps/web/src/composer-logic.test.ts | 80 +++++++++++++++--- apps/web/src/composer-logic.ts | 46 ++++++++++- 4 files changed, 231 insertions(+), 58 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cd5e92365..d8d88e2ad 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -38,8 +38,11 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { + clampCollapsedComposerCursor, type ComposerTrigger, + collapseExpandedComposerCursor, detectComposerTrigger, + expandCollapsedComposerCursor, parseStandaloneComposerSlashCommand, replaceTextRange, } from "../composer-logic"; @@ -262,7 +265,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); - const [composerCursor, setComposerCursor] = useState(() => prompt.length); + const [composerCursor, setComposerCursor] = useState(() => + collapseExpandedComposerCursor(prompt, prompt.length), + ); const [composerTrigger, setComposerTrigger] = useState(() => detectComposerTrigger(prompt, prompt.length), ); @@ -634,11 +639,15 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } promptRef.current = activePendingProgress.customAnswer; - setComposerCursor(activePendingProgress.customAnswer.length); + const nextCursor = collapseExpandedComposerCursor( + activePendingProgress.customAnswer, + activePendingProgress.customAnswer.length, + ); + setComposerCursor(nextCursor); setComposerTrigger( detectComposerTrigger( activePendingProgress.customAnswer, - activePendingProgress.customAnswer.length, + expandCollapsedComposerCursor(activePendingProgress.customAnswer, nextCursor), ), ); setComposerHighlightedItemId(null); @@ -1716,7 +1725,7 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { promptRef.current = prompt; - setComposerCursor((existing) => Math.min(Math.max(0, existing), prompt.length)); + setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); }, [prompt]); useEffect(() => { @@ -1729,7 +1738,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setSendPhase("idle"); setSendStartedAt(null); setComposerHighlightedItemId(null); - setComposerCursor(promptRef.current.length); + setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); dragDepthRef.current = 0; setIsDragOverComposer(false); @@ -2414,7 +2423,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); promptRef.current = trimmed; setPrompt(trimmed); - setComposerCursor(trimmed.length); + setComposerCursor(collapseExpandedComposerCursor(trimmed, trimmed.length)); addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry)); setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length)); } @@ -2891,6 +2900,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return false; } const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); + const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); promptRef.current = next.text; const activePendingQuestion = activePendingProgress?.activeQuestion; if (activePendingQuestion && activePendingUserInput) { @@ -2907,10 +2917,12 @@ export default function ChatView({ threadId }: ChatViewProps) { } else { setPrompt(next.text); } - setComposerCursor(next.cursor); - setComposerTrigger(detectComposerTrigger(next.text, next.cursor)); + setComposerCursor(nextCursor); + setComposerTrigger( + detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor)), + ); window.requestAnimationFrame(() => { - composerEditorRef.current?.focusAt(next.cursor); + composerEditorRef.current?.focusAt(nextCursor); }); return true; }, @@ -2926,11 +2938,26 @@ export default function ChatView({ threadId }: ChatViewProps) { if (editorSnapshot) { return editorSnapshot; } - return { value: promptRef.current, cursor: composerCursor, expandedCursor: composerCursor }; + return { + value: promptRef.current, + cursor: composerCursor, + expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), + }; }, [composerCursor]); + const extendReplacementRangeForTrailingSpace = ( + text: string, + rangeEnd: number, + replacement: string, + ): number => { + if (!replacement.endsWith(" ")) { + return rangeEnd; + } + return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; + }; + const resolveActiveComposerTrigger = useCallback((): { - snapshot: { value: string; cursor: number }; + snapshot: { value: string; cursor: number; expandedCursor: number }; trigger: ComposerTrigger | null; } => { const snapshot = readComposerSnapshot(); @@ -2949,13 +2976,18 @@ export default function ChatView({ threadId }: ChatViewProps) { }); const { snapshot, trigger } = resolveActiveComposerTrigger(); if (!trigger) return; - const expectedToken = snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd); if (item.type === "path") { + const replacement = `@${item.path} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); const applied = applyPromptReplacement( trigger.rangeStart, - trigger.rangeEnd, - `@${item.path} `, - { expectedText: expectedToken }, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, ); if (applied) { setComposerHighlightedItemId(null); @@ -2964,9 +2996,18 @@ export default function ChatView({ threadId }: ChatViewProps) { } if (item.type === "slash-command") { if (item.command === "model") { - const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "/model ", { - expectedText: expectedToken, - }); + const replacement = "/model "; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement( + trigger.rangeStart, + replacementRangeEnd, + replacement, + { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + ); if (applied) { setComposerHighlightedItemId(null); } @@ -2974,7 +3015,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: expectedToken, + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), }); if (applied) { setComposerHighlightedItemId(null); @@ -2983,7 +3024,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } onProviderModelSelect(item.provider, item.model); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { - expectedText: expectedToken, + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), }); if (applied) { setComposerHighlightedItemId(null); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 0274afa3c..deaf567ac 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -47,7 +47,12 @@ import { type Ref, } from "react"; -import { isCollapsedCursorAdjacentToMention } from "~/composer-logic"; +import { + clampCollapsedComposerCursor, + collapseExpandedComposerCursor, + expandCollapsedComposerCursor, + isCollapsedCursorAdjacentToMention, +} from "~/composer-logic"; import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; import { cn } from "~/lib/utils"; import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons"; @@ -171,7 +176,7 @@ function renderMentionChipDom(container: HTMLElement, pathValue: string): void { container.append(icon, label); } -function clampCursor(value: string, cursor: number): number { +function clampExpandedCursor(value: string, cursor: number): number { if (!Number.isFinite(cursor)) return value.length; return Math.max(0, Math.min(value.length, Math.floor(cursor))); } @@ -703,7 +708,12 @@ function ComposerPromptEditorInner({ }: ComposerPromptEditorInnerProps) { const [editor] = useLexicalComposerContext(); const onChangeRef = useRef(onChange); - const snapshotRef = useRef({ value, cursor: clampCursor(value, cursor) }); + const initialCursor = clampCollapsedComposerCursor(value, cursor); + const snapshotRef = useRef({ + value, + cursor: initialCursor, + expandedCursor: expandCollapsedComposerCursor(value, initialCursor), + }); useEffect(() => { onChangeRef.current = onChange; @@ -714,7 +724,7 @@ function ComposerPromptEditorInner({ }, [disabled, editor]); useLayoutEffect(() => { - const normalizedCursor = clampCursor(value, cursor); + const normalizedCursor = clampCollapsedComposerCursor(value, cursor); const previousSnapshot = snapshotRef.current; if (previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor) { return; @@ -726,7 +736,11 @@ function ComposerPromptEditorInner({ }); } - snapshotRef.current = { value, cursor: normalizedCursor }; + snapshotRef.current = { + value, + cursor: normalizedCursor, + expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), + }; const rootElement = editor.getRootElement(); if (!rootElement || document.activeElement !== rootElement) { @@ -742,19 +756,22 @@ function ComposerPromptEditorInner({ (nextCursor: number) => { const rootElement = editor.getRootElement(); if (!rootElement) return; - const boundedCursor = clampCursor(snapshotRef.current.value, nextCursor); + const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); rootElement.focus(); editor.update(() => { $setSelectionAtComposerOffset(boundedCursor); }); - const nextExpandedCursor = editor - .getEditorState() - .read(() => $readExpandedSelectionOffsetFromEditorState(boundedCursor)); snapshotRef.current = { value: snapshotRef.current.value, cursor: boundedCursor, + expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), }; - onChangeRef.current(snapshotRef.current.value, boundedCursor, nextExpandedCursor, false); + onChangeRef.current( + snapshotRef.current.value, + boundedCursor, + snapshotRef.current.expandedCursor, + false, + ); }, [editor], ); @@ -764,20 +781,21 @@ function ComposerPromptEditorInner({ cursor: number; expandedCursor: number; } => { - let snapshot: { value: string; cursor: number; expandedCursor: number } = { - ...snapshotRef.current, - expandedCursor: snapshotRef.current.cursor, - }; + let snapshot = snapshotRef.current; editor.getEditorState().read(() => { const nextValue = $getRoot().getTextContent(); - const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor); - const nextCursor = clampCursor( + const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); + const nextCursor = clampCollapsedComposerCursor( nextValue, $readSelectionOffsetFromEditorState(fallbackCursor), ); - const nextExpandedCursor = clampCursor( + const fallbackExpandedCursor = clampExpandedCursor( nextValue, - $readExpandedSelectionOffsetFromEditorState(fallbackCursor), + snapshotRef.current.expandedCursor, + ); + const nextExpandedCursor = clampExpandedCursor( + nextValue, + $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); snapshot = { value: nextValue, @@ -785,7 +803,7 @@ function ComposerPromptEditorInner({ expandedCursor: nextExpandedCursor, }; }); - snapshotRef.current = { value: snapshot.value, cursor: snapshot.cursor }; + snapshotRef.current = snapshot; return snapshot; }, [editor]); @@ -799,7 +817,12 @@ function ComposerPromptEditorInner({ focusAt(nextCursor); }, focusAtEnd: () => { - focusAt(snapshotRef.current.value.length); + focusAt( + collapseExpandedComposerCursor( + snapshotRef.current.value, + snapshotRef.current.value.length, + ), + ); }, readSnapshot, }), @@ -809,22 +832,31 @@ function ComposerPromptEditorInner({ const handleEditorChange = useCallback((editorState: EditorState) => { editorState.read(() => { const nextValue = $getRoot().getTextContent(); - const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor); - const nextCursor = clampCursor( + const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); + const nextCursor = clampCollapsedComposerCursor( nextValue, $readSelectionOffsetFromEditorState(fallbackCursor), ); - const nextExpandedCursor = clampCursor( + const fallbackExpandedCursor = clampExpandedCursor( + nextValue, + snapshotRef.current.expandedCursor, + ); + const nextExpandedCursor = clampExpandedCursor( nextValue, - $readExpandedSelectionOffsetFromEditorState(fallbackCursor), + $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); const previousSnapshot = snapshotRef.current; - if (previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor) { + if ( + previousSnapshot.value === nextValue && + previousSnapshot.cursor === nextCursor && + previousSnapshot.expandedCursor === nextExpandedCursor + ) { return; } snapshotRef.current = { value: nextValue, cursor: nextCursor, + expandedCursor: nextExpandedCursor, }; const cursorAdjacentToMention = isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "left") || diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 91dc444f7..36532e904 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + clampCollapsedComposerCursor, + collapseExpandedComposerCursor, detectComposerTrigger, expandCollapsedComposerCursor, isCollapsedCursorAdjacentToMention, @@ -84,6 +86,18 @@ describe("detectComposerTrigger", () => { rangeEnd: cursorAfterQuery, }); }); + + it("detects trigger with true cursor even when regex-based mention detection would false-match", () => { + // MENTION_TOKEN_REGEX can false-match plain text like "@in" as a mention. + // The fix bypasses it by computing the expanded cursor from the Lexical node tree. + const text = "Please inspect @in this sentence"; + const cursorAfterAt = "Please inspect @".length; + + const trigger = detectComposerTrigger(text, cursorAfterAt); + expect(trigger).not.toBeNull(); + expect(trigger?.kind).toBe("path"); + expect(trigger?.query).toBe(""); + }); }); describe("replaceTextRange", () => { @@ -118,19 +132,63 @@ describe("expandCollapsedComposerCursor", () => { expect(detectComposerTrigger(text, expandedCursor)).toBeNull(); }); +}); - it("detectComposerTrigger works with true cursor even when expandCollapsedComposerCursor is wrong", () => { - // expandCollapsedComposerCursor uses MENTION_TOKEN_REGEX which can false-match - // plain text like "@in" as a mention. The fix bypasses it by computing the expanded - // cursor directly from the Lexical editor's node tree. - const text = "Please inspect @in this sentence"; - const cursorAfterAt = "Please inspect @".length; +describe("collapseExpandedComposerCursor", () => { + it("keeps cursor unchanged when no mention segment is present", () => { + expect(collapseExpandedComposerCursor("plain text", 5)).toBe(5); + }); - // With the true cursor position, trigger detection works correctly - const trigger = detectComposerTrigger(text, cursorAfterAt); - expect(trigger).not.toBeNull(); - expect(trigger?.kind).toBe("path"); - expect(trigger?.query).toBe(""); + it("maps expanded mention cursor back to collapsed 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(collapseExpandedComposerCursor(text, expandedCursorAfterMention)).toBe( + collapsedCursorAfterMention, + ); + }); + + it("keeps replacement cursors aligned when another mention already exists earlier", () => { + const text = "open @AGENTS.md then @src/index.ts "; + const expandedCursor = text.length; + const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor); + + expect(collapsedCursor).toBe("open ".length + 1 + " then ".length + 2); + expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor); + }); +}); + +describe("clampCollapsedComposerCursor", () => { + it("clamps to collapsed prompt length when mentions are present", () => { + const text = "open @AGENTS.md then "; + + expect(clampCollapsedComposerCursor(text, text.length)).toBe( + "open ".length + 1 + " then ".length, + ); + expect(clampCollapsedComposerCursor(text, Number.POSITIVE_INFINITY)).toBe( + "open ".length + 1 + " then ".length, + ); + }); +}); + +describe("replaceTextRange trailing space consumption", () => { + it("double space after insertion when replacement ends with space", () => { + // Simulates: "and then |@AG| summarize" where | marks replacement range + // The replacement is "@AGENTS.md " (with trailing space) + // But if we don't extend rangeEnd, the existing space stays + const text = "and then @AG summarize"; + const rangeStart = "and then ".length; + const rangeEnd = "and then @AG".length; + + // Without consuming trailing space: double space + const withoutConsume = replaceTextRange(text, rangeStart, rangeEnd, "@AGENTS.md "); + expect(withoutConsume.text).toBe("and then @AGENTS.md summarize"); + + // With consuming trailing space: single space + const extendedEnd = text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd; + const withConsume = replaceTextRange(text, rangeStart, extendedEnd, "@AGENTS.md "); + expect(withConsume.text).toBe("and then @AGENTS.md summarize"); }); }); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 70b3567c3..b696d8038 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -67,7 +67,7 @@ function collapsedSegmentLength( return segment.type === "mention" ? 1 : segment.text.length; } -function clampCollapsedComposerCursor( +function clampCollapsedComposerCursorForSegments( segments: ReadonlyArray<{ type: "text"; text: string } | { type: "mention" }>, cursorInput: number, ): number { @@ -81,6 +81,48 @@ function clampCollapsedComposerCursor( return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput))); } +export function clampCollapsedComposerCursor(text: string, cursorInput: number): number { + return clampCollapsedComposerCursorForSegments( + splitPromptIntoComposerSegments(text), + cursorInput, + ); +} + +export function collapseExpandedComposerCursor(text: string, cursorInput: number): number { + const expandedCursor = clampCursor(text, cursorInput); + const segments = splitPromptIntoComposerSegments(text); + if (segments.length === 0) { + return expandedCursor; + } + + let remaining = expandedCursor; + let collapsedCursor = 0; + + for (const segment of segments) { + if (segment.type === "mention") { + const expandedLength = segment.path.length + 1; + if (remaining === 0) { + return collapsedCursor; + } + if (remaining <= expandedLength) { + return collapsedCursor + 1; + } + remaining -= expandedLength; + collapsedCursor += 1; + continue; + } + + const segmentLength = segment.text.length; + if (remaining <= segmentLength) { + return collapsedCursor + remaining; + } + remaining -= segmentLength; + collapsedCursor += segmentLength; + } + + return collapsedCursor; +} + export function isCollapsedCursorAdjacentToMention( text: string, cursorInput: number, @@ -91,7 +133,7 @@ export function isCollapsedCursorAdjacentToMention( return false; } - const cursor = clampCollapsedComposerCursor(segments, cursorInput); + const cursor = clampCollapsedComposerCursorForSegments(segments, cursorInput); let collapsedOffset = 0; for (const segment of segments) {