authorgravatar for 142016413+chuks-qua@users.noreply.github.com(CJ) Chukwudi Nwobodo <142016413+chuks-qua@users.noreply.github.com> 2026-03-12 23:33:37+00:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-12 16:33:37-07:00
log71a7473a81c75a70bef55844a410e5d7f1e5de10
tree77c260319f434cf6e4b58d1d9705804aee943558
parent5c03fb62c4ede0a347938b796a611d5f6b9d61fb
signature Signed by PGP key B5690EEEBB952194

fix: composer @file autocomplete cursor and spacing bugs (#936)


4 files changed, 362 insertions(+), 52 deletions(-)

apps/web/src/components/ChatView.tsx+75-33
...@@ -38,7 +38,9 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer...@@ -38,7 +38,9 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer
38import { isElectron } from "../env";38import { isElectron } from "../env";
39import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";39import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
40import {40import {
41 clampCollapsedComposerCursor,
41 type ComposerTrigger,42 type ComposerTrigger,
43 collapseExpandedComposerCursor,
42 detectComposerTrigger,44 detectComposerTrigger,
43 expandCollapsedComposerCursor,45 expandCollapsedComposerCursor,
44 parseStandaloneComposerSlashCommand,46 parseStandaloneComposerSlashCommand,
...@@ -266,7 +268,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -266,7 +268,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
266 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<268 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<
267 Record<string, string[]>269 Record<string, string[]>
268 >({});270 >({});
269 const [composerCursor, setComposerCursor] = useState(() => prompt.length);271 const [composerCursor, setComposerCursor] = useState(() =>
272 collapseExpandedComposerCursor(prompt, prompt.length),
273 );
270 const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() =>274 const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() =>
271 detectComposerTrigger(prompt, prompt.length),275 detectComposerTrigger(prompt, prompt.length),
272 );276 );
...@@ -662,11 +666,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -662,11 +666,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
662 }666 }
663667
664 promptRef.current = nextCustomAnswer;668 promptRef.current = nextCustomAnswer;
665 setComposerCursor(nextCustomAnswer.length);669 const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length);
670 setComposerCursor(nextCursor);
666 setComposerTrigger(671 setComposerTrigger(
667 detectComposerTrigger(672 detectComposerTrigger(
668 nextCustomAnswer,673 nextCustomAnswer,
669 expandCollapsedComposerCursor(nextCustomAnswer, nextCustomAnswer.length),674 expandCollapsedComposerCursor(nextCustomAnswer, nextCursor),
670 ),675 ),
671 );676 );
672 setComposerHighlightedItemId(null);677 setComposerHighlightedItemId(null);
...@@ -1742,7 +1747,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1742,7 +1747,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
17421747
1743 useEffect(() => {1748 useEffect(() => {
1744 promptRef.current = prompt;1749 promptRef.current = prompt;
1745 setComposerCursor((existing) => Math.min(Math.max(0, existing), prompt.length));1750 setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing));
1746 }, [prompt]);1751 }, [prompt]);
17471752
1748 useEffect(() => {1753 useEffect(() => {
...@@ -1755,7 +1760,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1755,7 +1760,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1755 setSendPhase("idle");1760 setSendPhase("idle");
1756 setSendStartedAt(null);1761 setSendStartedAt(null);
1757 setComposerHighlightedItemId(null);1762 setComposerHighlightedItemId(null);
1758 setComposerCursor(promptRef.current.length);1763 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
1759 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));1764 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
1760 dragDepthRef.current = 0;1765 dragDepthRef.current = 0;
1761 setIsDragOverComposer(false);1766 setIsDragOverComposer(false);
...@@ -2433,7 +2438,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2433,7 +2438,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2433 });2438 });
2434 promptRef.current = trimmed;2439 promptRef.current = trimmed;
2435 setPrompt(trimmed);2440 setPrompt(trimmed);
2436 setComposerCursor(trimmed.length);2441 setComposerCursor(collapseExpandedComposerCursor(trimmed, trimmed.length));
2437 addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry));2442 addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry));
2438 setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length));2443 setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length));
2439 }2444 }
...@@ -2551,7 +2556,13 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2551,7 +2556,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
2551 );2556 );
25522557
2553 const onChangeActivePendingUserInputCustomAnswer = useCallback(2558 const onChangeActivePendingUserInputCustomAnswer = useCallback(
2554 (questionId: string, value: string, nextCursor: number, cursorAdjacentToMention: boolean) => {2559 (
2560 questionId: string,
2561 value: string,
2562 nextCursor: number,
2563 expandedCursor: number,
2564 cursorAdjacentToMention: boolean,
2565 ) => {
2555 if (!activePendingUserInput) {2566 if (!activePendingUserInput) {
2556 return;2567 return;
2557 }2568 }
...@@ -2568,9 +2579,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2568,9 +2579,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2568 }));2579 }));
2569 setComposerCursor(nextCursor);2580 setComposerCursor(nextCursor);
2570 setComposerTrigger(2581 setComposerTrigger(
2571 cursorAdjacentToMention2582 cursorAdjacentToMention ? null : detectComposerTrigger(value, expandedCursor),
2572 ? null
2573 : detectComposerTrigger(value, expandCollapsedComposerCursor(value, nextCursor)),
2574 );2583 );
2575 },2584 },
2576 [activePendingUserInput],2585 [activePendingUserInput],
...@@ -2906,6 +2915,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2906,6 +2915,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2906 return false;2915 return false;
2907 }2916 }
2908 const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);2917 const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
2918 const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor);
2909 promptRef.current = next.text;2919 promptRef.current = next.text;
2910 const activePendingQuestion = activePendingProgress?.activeQuestion;2920 const activePendingQuestion = activePendingProgress?.activeQuestion;
2911 if (activePendingQuestion && activePendingUserInput) {2921 if (activePendingQuestion && activePendingUserInput) {
...@@ -2922,10 +2932,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2922,10 +2932,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
2922 } else {2932 } else {
2923 setPrompt(next.text);2933 setPrompt(next.text);
2924 }2934 }
2925 setComposerCursor(next.cursor);2935 setComposerCursor(nextCursor);
2926 setComposerTrigger(detectComposerTrigger(next.text, next.cursor));2936 setComposerTrigger(
2937 detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor)),
2938 );
2927 window.requestAnimationFrame(() => {2939 window.requestAnimationFrame(() => {
2928 composerEditorRef.current?.focusAt(next.cursor);2940 composerEditorRef.current?.focusAt(nextCursor);
2929 });2941 });
2930 return true;2942 return true;
2931 },2943 },
...@@ -2935,23 +2947,38 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2935,23 +2947,38 @@ export default function ChatView({ threadId }: ChatViewProps) {
2935 const readComposerSnapshot = useCallback((): {2947 const readComposerSnapshot = useCallback((): {
2936 value: string;2948 value: string;
2937 cursor: number;2949 cursor: number;
2950 expandedCursor: number;
2938 } => {2951 } => {
2939 const editorSnapshot = composerEditorRef.current?.readSnapshot();2952 const editorSnapshot = composerEditorRef.current?.readSnapshot();
2940 if (editorSnapshot) {2953 if (editorSnapshot) {
2941 return editorSnapshot;2954 return editorSnapshot;
2942 }2955 }
2943 return { value: promptRef.current, cursor: composerCursor };2956 return {
2957 value: promptRef.current,
2958 cursor: composerCursor,
2959 expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor),
2960 };
2944 }, [composerCursor]);2961 }, [composerCursor]);
29452962
2963 const extendReplacementRangeForTrailingSpace = (
2964 text: string,
2965 rangeEnd: number,
2966 replacement: string,
2967 ): number => {
2968 if (!replacement.endsWith(" ")) {
2969 return rangeEnd;
2970 }
2971 return text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd;
2972 };
2973
2946 const resolveActiveComposerTrigger = useCallback((): {2974 const resolveActiveComposerTrigger = useCallback((): {
2947 snapshot: { value: string; cursor: number };2975 snapshot: { value: string; cursor: number; expandedCursor: number };
2948 trigger: ComposerTrigger | null;2976 trigger: ComposerTrigger | null;
2949 } => {2977 } => {
2950 const snapshot = readComposerSnapshot();2978 const snapshot = readComposerSnapshot();
2951 const expandedCursor = expandCollapsedComposerCursor(snapshot.value, snapshot.cursor);
2952 return {2979 return {
2953 snapshot,2980 snapshot,
2954 trigger: detectComposerTrigger(snapshot.value, expandedCursor),2981 trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor),
2955 };2982 };
2956 }, [readComposerSnapshot]);2983 }, [readComposerSnapshot]);
29572984
...@@ -2964,13 +2991,18 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2964,13 +2991,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
2964 });2991 });
2965 const { snapshot, trigger } = resolveActiveComposerTrigger();2992 const { snapshot, trigger } = resolveActiveComposerTrigger();
2966 if (!trigger) return;2993 if (!trigger) return;
2967 const expectedToken = snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd);
2968 if (item.type === "path") {2994 if (item.type === "path") {
2995 const replacement = `@${item.path} `;
2996 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
2997 snapshot.value,
2998 trigger.rangeEnd,
2999 replacement,
3000 );
2969 const applied = applyPromptReplacement(3001 const applied = applyPromptReplacement(
2970 trigger.rangeStart,3002 trigger.rangeStart,
2971 trigger.rangeEnd,3003 replacementRangeEnd,
2972 `@${item.path} `,3004 replacement,
2973 { expectedText: expectedToken },3005 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
2974 );3006 );
2975 if (applied) {3007 if (applied) {
2976 setComposerHighlightedItemId(null);3008 setComposerHighlightedItemId(null);
...@@ -2979,9 +3011,18 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2979,9 +3011,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
2979 }3011 }
2980 if (item.type === "slash-command") {3012 if (item.type === "slash-command") {
2981 if (item.command === "model") {3013 if (item.command === "model") {
2982 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "/model ", {3014 const replacement = "/model ";
2983 expectedText: expectedToken,3015 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
2984 });3016 snapshot.value,
3017 trigger.rangeEnd,
3018 replacement,
3019 );
3020 const applied = applyPromptReplacement(
3021 trigger.rangeStart,
3022 replacementRangeEnd,
3023 replacement,
3024 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
3025 );
2985 if (applied) {3026 if (applied) {
2986 setComposerHighlightedItemId(null);3027 setComposerHighlightedItemId(null);
2987 }3028 }
...@@ -2989,7 +3030,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2989,7 +3030,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2989 }3030 }
2990 void handleInteractionModeChange(item.command === "plan" ? "plan" : "default");3031 void handleInteractionModeChange(item.command === "plan" ? "plan" : "default");
2991 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {3032 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
2992 expectedText: expectedToken,3033 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
2993 });3034 });
2994 if (applied) {3035 if (applied) {
2995 setComposerHighlightedItemId(null);3036 setComposerHighlightedItemId(null);
...@@ -2998,7 +3039,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2998,7 +3039,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2998 }3039 }
2999 onProviderModelSelect(item.provider, item.model);3040 onProviderModelSelect(item.provider, item.model);
3000 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {3041 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
3001 expectedText: expectedToken,3042 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
3002 });3043 });
3003 if (applied) {3044 if (applied) {
3004 setComposerHighlightedItemId(null);3045 setComposerHighlightedItemId(null);
...@@ -3039,12 +3080,18 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3039,12 +3080,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
3039 workspaceEntriesQuery.isFetching);3080 workspaceEntriesQuery.isFetching);
30403081
3041 const onPromptChange = useCallback(3082 const onPromptChange = useCallback(
3042 (nextPrompt: string, nextCursor: number, cursorAdjacentToMention: boolean) => {3083 (
3084 nextPrompt: string,
3085 nextCursor: number,
3086 expandedCursor: number,
3087 cursorAdjacentToMention: boolean,
3088 ) => {
3043 if (activePendingProgress?.activeQuestion && activePendingUserInput) {3089 if (activePendingProgress?.activeQuestion && activePendingUserInput) {
3044 onChangeActivePendingUserInputCustomAnswer(3090 onChangeActivePendingUserInputCustomAnswer(
3045 activePendingProgress.activeQuestion.id,3091 activePendingProgress.activeQuestion.id,
3046 nextPrompt,3092 nextPrompt,
3047 nextCursor,3093 nextCursor,
3094 expandedCursor,
3048 cursorAdjacentToMention,3095 cursorAdjacentToMention,
3049 );3096 );
3050 return;3097 return;
...@@ -3053,12 +3100,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3053,12 +3100,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3053 setPrompt(nextPrompt);3100 setPrompt(nextPrompt);
3054 setComposerCursor(nextCursor);3101 setComposerCursor(nextCursor);
3055 setComposerTrigger(3102 setComposerTrigger(
3056 cursorAdjacentToMention3103 cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
3057 ? null
3058 : detectComposerTrigger(
3059 nextPrompt,
3060 expandCollapsedComposerCursor(nextPrompt, nextCursor),
3061 ),
3062 );3104 );
3063 },3105 },
3064 [3106 [
apps/web/src/components/ComposerPromptEditor.tsx+143-17
...@@ -47,7 +47,12 @@ import {...@@ -47,7 +47,12 @@ import {
47 type Ref,47 type Ref,
48} from "react";48} from "react";
4949
50import { isCollapsedCursorAdjacentToMention } from "~/composer-logic";50import {
51 clampCollapsedComposerCursor,
52 collapseExpandedComposerCursor,
53 expandCollapsedComposerCursor,
54 isCollapsedCursorAdjacentToMention,
55} from "~/composer-logic";
51import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";56import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
52import { cn } from "~/lib/utils";57import { cn } from "~/lib/utils";
53import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons";58import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons";
...@@ -171,7 +176,7 @@ function renderMentionChipDom(container: HTMLElement, pathValue: string): void {...@@ -171,7 +176,7 @@ function renderMentionChipDom(container: HTMLElement, pathValue: string): void {
171 container.append(icon, label);176 container.append(icon, label);
172}177}
173178
174function clampCursor(value: string, cursor: number): number {179function clampExpandedCursor(value: string, cursor: number): number {
175 if (!Number.isFinite(cursor)) return value.length;180 if (!Number.isFinite(cursor)) return value.length;
176 return Math.max(0, Math.min(value.length, Math.floor(cursor)));181 return Math.max(0, Math.min(value.length, Math.floor(cursor)));
177}182}
...@@ -192,6 +197,21 @@ function getComposerNodeTextLength(node: LexicalNode): number {...@@ -192,6 +197,21 @@ function getComposerNodeTextLength(node: LexicalNode): number {
192 return 0;197 return 0;
193}198}
194199
200function getComposerNodeExpandedTextLength(node: LexicalNode): number {
201 if ($isTextNode(node)) {
202 return node.getTextContentSize();
203 }
204 if ($isLineBreakNode(node)) {
205 return 1;
206 }
207 if ($isElementNode(node)) {
208 return node
209 .getChildren()
210 .reduce((total, child) => total + getComposerNodeExpandedTextLength(child), 0);
211 }
212 return 0;
213}
214
195function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number {215function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number {
196 let offset = 0;216 let offset = 0;
197 let current: LexicalNode | null = node;217 let current: LexicalNode | null = node;
...@@ -236,6 +256,50 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb...@@ -236,6 +256,50 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb
236 return offset;256 return offset;
237}257}
238258
259function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number {
260 let offset = 0;
261 let current: LexicalNode | null = node;
262
263 while (current) {
264 const nextParent = current.getParent() as LexicalNode | null;
265 if (!nextParent || !$isElementNode(nextParent)) {
266 break;
267 }
268 const siblings = nextParent.getChildren();
269 const index = current.getIndexWithinParent();
270 for (let i = 0; i < index; i += 1) {
271 const sibling = siblings[i];
272 if (!sibling) continue;
273 offset += getComposerNodeExpandedTextLength(sibling);
274 }
275 current = nextParent;
276 }
277
278 if ($isTextNode(node)) {
279 if (node instanceof ComposerMentionNode) {
280 return offset + (pointOffset > 0 ? node.getTextContentSize() : 0);
281 }
282 return offset + Math.min(pointOffset, node.getTextContentSize());
283 }
284
285 if ($isLineBreakNode(node)) {
286 return offset + Math.min(pointOffset, 1);
287 }
288
289 if ($isElementNode(node)) {
290 const children = node.getChildren();
291 const clampedOffset = Math.max(0, Math.min(pointOffset, children.length));
292 for (let i = 0; i < clampedOffset; i += 1) {
293 const child = children[i];
294 if (!child) continue;
295 offset += getComposerNodeExpandedTextLength(child);
296 }
297 return offset;
298 }
299
300 return offset;
301}
302
239function findSelectionPointAtOffset(303function findSelectionPointAtOffset(
240 node: LexicalNode,304 node: LexicalNode,
241 remainingRef: { value: number },305 remainingRef: { value: number },
...@@ -350,6 +414,17 @@ function $readSelectionOffsetFromEditorState(fallback: number): number {...@@ -350,6 +414,17 @@ function $readSelectionOffsetFromEditorState(fallback: number): number {
350 return Math.max(0, Math.min(offset, composerLength));414 return Math.max(0, Math.min(offset, composerLength));
351}415}
352416
417function $readExpandedSelectionOffsetFromEditorState(fallback: number): number {
418 const selection = $getSelection();
419 if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
420 return fallback;
421 }
422 const anchorNode = selection.anchor.getNode();
423 const offset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset);
424 const expandedLength = $getRoot().getTextContent().length;
425 return Math.max(0, Math.min(offset, expandedLength));
426}
427
353function $appendTextWithLineBreaks(parent: ElementNode, text: string): void {428function $appendTextWithLineBreaks(parent: ElementNode, text: string): void {
354 const lines = text.split("\n");429 const lines = text.split("\n");
355 for (let index = 0; index < lines.length; index += 1) {430 for (let index = 0; index < lines.length; index += 1) {
...@@ -383,7 +458,7 @@ export interface ComposerPromptEditorHandle {...@@ -383,7 +458,7 @@ export interface ComposerPromptEditorHandle {
383 focus: () => void;458 focus: () => void;
384 focusAt: (cursor: number) => void;459 focusAt: (cursor: number) => void;
385 focusAtEnd: () => void;460 focusAtEnd: () => void;
386 readSnapshot: () => { value: string; cursor: number };461 readSnapshot: () => { value: string; cursor: number; expandedCursor: number };
387}462}
388463
389interface ComposerPromptEditorProps {464interface ComposerPromptEditorProps {
...@@ -392,7 +467,12 @@ interface ComposerPromptEditorProps {...@@ -392,7 +467,12 @@ interface ComposerPromptEditorProps {
392 disabled: boolean;467 disabled: boolean;
393 placeholder: string;468 placeholder: string;
394 className?: string;469 className?: string;
395 onChange: (nextValue: string, nextCursor: number, cursorAdjacentToMention: boolean) => void;470 onChange: (
471 nextValue: string,
472 nextCursor: number,
473 expandedCursor: number,
474 cursorAdjacentToMention: boolean,
475 ) => void;
396 onCommandKeyDown?: (476 onCommandKeyDown?: (
397 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",477 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
398 event: KeyboardEvent,478 event: KeyboardEvent,
...@@ -628,7 +708,12 @@ function ComposerPromptEditorInner({...@@ -628,7 +708,12 @@ function ComposerPromptEditorInner({
628}: ComposerPromptEditorInnerProps) {708}: ComposerPromptEditorInnerProps) {
629 const [editor] = useLexicalComposerContext();709 const [editor] = useLexicalComposerContext();
630 const onChangeRef = useRef(onChange);710 const onChangeRef = useRef(onChange);
631 const snapshotRef = useRef({ value, cursor: clampCursor(value, cursor) });711 const initialCursor = clampCollapsedComposerCursor(value, cursor);
712 const snapshotRef = useRef({
713 value,
714 cursor: initialCursor,
715 expandedCursor: expandCollapsedComposerCursor(value, initialCursor),
716 });
632 const isApplyingControlledUpdateRef = useRef(false);717 const isApplyingControlledUpdateRef = useRef(false);
633718
634 useEffect(() => {719 useEffect(() => {
...@@ -640,13 +725,17 @@ function ComposerPromptEditorInner({...@@ -640,13 +725,17 @@ function ComposerPromptEditorInner({
640 }, [disabled, editor]);725 }, [disabled, editor]);
641726
642 useLayoutEffect(() => {727 useLayoutEffect(() => {
643 const normalizedCursor = clampCursor(value, cursor);728 const normalizedCursor = clampCollapsedComposerCursor(value, cursor);
644 const previousSnapshot = snapshotRef.current;729 const previousSnapshot = snapshotRef.current;
645 if (previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor) {730 if (previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor) {
646 return;731 return;
647 }732 }
648733
649 snapshotRef.current = { value, cursor: normalizedCursor };734 snapshotRef.current = {
735 value,
736 cursor: normalizedCursor,
737 expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor),
738 };
650739
651 const rootElement = editor.getRootElement();740 const rootElement = editor.getRootElement();
652 const isFocused = Boolean(rootElement && document.activeElement === rootElement);741 const isFocused = Boolean(rootElement && document.activeElement === rootElement);
...@@ -673,7 +762,7 @@ function ComposerPromptEditorInner({...@@ -673,7 +762,7 @@ function ComposerPromptEditorInner({
673 (nextCursor: number) => {762 (nextCursor: number) => {
674 const rootElement = editor.getRootElement();763 const rootElement = editor.getRootElement();
675 if (!rootElement) return;764 if (!rootElement) return;
676 const boundedCursor = clampCursor(snapshotRef.current.value, nextCursor);765 const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor);
677 rootElement.focus();766 rootElement.focus();
678 editor.update(() => {767 editor.update(() => {
679 $setSelectionAtComposerOffset(boundedCursor);768 $setSelectionAtComposerOffset(boundedCursor);
...@@ -681,24 +770,43 @@ function ComposerPromptEditorInner({...@@ -681,24 +770,43 @@ function ComposerPromptEditorInner({
681 snapshotRef.current = {770 snapshotRef.current = {
682 value: snapshotRef.current.value,771 value: snapshotRef.current.value,
683 cursor: boundedCursor,772 cursor: boundedCursor,
773 expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor),
684 };774 };
685 onChangeRef.current(snapshotRef.current.value, boundedCursor, false);775 onChangeRef.current(
776 snapshotRef.current.value,
777 boundedCursor,
778 snapshotRef.current.expandedCursor,
779 false,
780 );
686 },781 },
687 [editor],782 [editor],
688 );783 );
689784
690 const readSnapshot = useCallback((): { value: string; cursor: number } => {785 const readSnapshot = useCallback((): {
786 value: string;
787 cursor: number;
788 expandedCursor: number;
789 } => {
691 let snapshot = snapshotRef.current;790 let snapshot = snapshotRef.current;
692 editor.getEditorState().read(() => {791 editor.getEditorState().read(() => {
693 const nextValue = $getRoot().getTextContent();792 const nextValue = $getRoot().getTextContent();
694 const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor);793 const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor);
695 const nextCursor = clampCursor(794 const nextCursor = clampCollapsedComposerCursor(
696 nextValue,795 nextValue,
697 $readSelectionOffsetFromEditorState(fallbackCursor),796 $readSelectionOffsetFromEditorState(fallbackCursor),
698 );797 );
798 const fallbackExpandedCursor = clampExpandedCursor(
799 nextValue,
800 snapshotRef.current.expandedCursor,
801 );
802 const nextExpandedCursor = clampExpandedCursor(
803 nextValue,
804 $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor),
805 );
699 snapshot = {806 snapshot = {
700 value: nextValue,807 value: nextValue,
701 cursor: nextCursor,808 cursor: nextCursor,
809 expandedCursor: nextExpandedCursor,
702 };810 };
703 });811 });
704 snapshotRef.current = snapshot;812 snapshotRef.current = snapshot;
...@@ -713,7 +821,12 @@ function ComposerPromptEditorInner({...@@ -713,7 +821,12 @@ function ComposerPromptEditorInner({
713 },821 },
714 focusAt,822 focusAt,
715 focusAtEnd: () => {823 focusAtEnd: () => {
716 focusAt(snapshotRef.current.value.length);824 focusAt(
825 collapseExpandedComposerCursor(
826 snapshotRef.current.value,
827 snapshotRef.current.value.length,
828 ),
829 );
717 },830 },
718 readSnapshot,831 readSnapshot,
719 }),832 }),
...@@ -723,13 +836,25 @@ function ComposerPromptEditorInner({...@@ -723,13 +836,25 @@ function ComposerPromptEditorInner({
723 const handleEditorChange = useCallback((editorState: EditorState) => {836 const handleEditorChange = useCallback((editorState: EditorState) => {
724 editorState.read(() => {837 editorState.read(() => {
725 const nextValue = $getRoot().getTextContent();838 const nextValue = $getRoot().getTextContent();
726 const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor);839 const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor);
727 const nextCursor = clampCursor(840 const nextCursor = clampCollapsedComposerCursor(
728 nextValue,841 nextValue,
729 $readSelectionOffsetFromEditorState(fallbackCursor),842 $readSelectionOffsetFromEditorState(fallbackCursor),
730 );843 );
844 const fallbackExpandedCursor = clampExpandedCursor(
845 nextValue,
846 snapshotRef.current.expandedCursor,
847 );
848 const nextExpandedCursor = clampExpandedCursor(
849 nextValue,
850 $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor),
851 );
731 const previousSnapshot = snapshotRef.current;852 const previousSnapshot = snapshotRef.current;
732 if (previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor) {853 if (
854 previousSnapshot.value === nextValue &&
855 previousSnapshot.cursor === nextCursor &&
856 previousSnapshot.expandedCursor === nextExpandedCursor
857 ) {
733 return;858 return;
734 }859 }
735 if (isApplyingControlledUpdateRef.current) {860 if (isApplyingControlledUpdateRef.current) {
...@@ -738,11 +863,12 @@ function ComposerPromptEditorInner({...@@ -738,11 +863,12 @@ function ComposerPromptEditorInner({
738 snapshotRef.current = {863 snapshotRef.current = {
739 value: nextValue,864 value: nextValue,
740 cursor: nextCursor,865 cursor: nextCursor,
866 expandedCursor: nextExpandedCursor,
741 };867 };
742 const cursorAdjacentToMention =868 const cursorAdjacentToMention =
743 isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "left") ||869 isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "left") ||
744 isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "right");870 isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "right");
745 onChangeRef.current(nextValue, nextCursor, cursorAdjacentToMention);871 onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, cursorAdjacentToMention);
746 });872 });
747 }, []);873 }, []);
748874
apps/web/src/composer-logic.test.ts+100
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import {3import {
4 clampCollapsedComposerCursor,
5 collapseExpandedComposerCursor,
4 detectComposerTrigger,6 detectComposerTrigger,
5 expandCollapsedComposerCursor,7 expandCollapsedComposerCursor,
6 isCollapsedCursorAdjacentToMention,8 isCollapsedCursorAdjacentToMention,
...@@ -56,6 +58,46 @@ describe("detectComposerTrigger", () => {...@@ -56,6 +58,46 @@ describe("detectComposerTrigger", () => {
56 rangeEnd: text.length,58 rangeEnd: text.length,
57 });59 });
58 });60 });
61
62 it("detects @path trigger in the middle of existing text", () => {
63 // User typed @ between "inspect " and "in this sentence"
64 const text = "Please inspect @in this sentence";
65 const cursorAfterAt = "Please inspect @".length;
66
67 const trigger = detectComposerTrigger(text, cursorAfterAt);
68 expect(trigger).toEqual({
69 kind: "path",
70 query: "",
71 rangeStart: "Please inspect ".length,
72 rangeEnd: cursorAfterAt,
73 });
74 });
75
76 it("detects @path trigger with query typed mid-text", () => {
77 // User typed @sr between "inspect " and "in this sentence"
78 const text = "Please inspect @srin this sentence";
79 const cursorAfterQuery = "Please inspect @sr".length;
80
81 const trigger = detectComposerTrigger(text, cursorAfterQuery);
82 expect(trigger).toEqual({
83 kind: "path",
84 query: "sr",
85 rangeStart: "Please inspect ".length,
86 rangeEnd: cursorAfterQuery,
87 });
88 });
89
90 it("detects trigger with true cursor even when regex-based mention detection would false-match", () => {
91 // MENTION_TOKEN_REGEX can false-match plain text like "@in" as a mention.
92 // The fix bypasses it by computing the expanded cursor from the Lexical node tree.
93 const text = "Please inspect @in this sentence";
94 const cursorAfterAt = "Please inspect @".length;
95
96 const trigger = detectComposerTrigger(text, cursorAfterAt);
97 expect(trigger).not.toBeNull();
98 expect(trigger?.kind).toBe("path");
99 expect(trigger?.query).toBe("");
100 });
59});101});
60102
61describe("replaceTextRange", () => {103describe("replaceTextRange", () => {
...@@ -92,6 +134,64 @@ describe("expandCollapsedComposerCursor", () => {...@@ -92,6 +134,64 @@ describe("expandCollapsedComposerCursor", () => {
92 });134 });
93});135});
94136
137describe("collapseExpandedComposerCursor", () => {
138 it("keeps cursor unchanged when no mention segment is present", () => {
139 expect(collapseExpandedComposerCursor("plain text", 5)).toBe(5);
140 });
141
142 it("maps expanded mention cursor back to collapsed cursor", () => {
143 const text = "what's in my @AGENTS.md fsfdas";
144 const collapsedCursorAfterMention = "what's in my ".length + 2;
145 const expandedCursorAfterMention = "what's in my @AGENTS.md ".length;
146
147 expect(collapseExpandedComposerCursor(text, expandedCursorAfterMention)).toBe(
148 collapsedCursorAfterMention,
149 );
150 });
151
152 it("keeps replacement cursors aligned when another mention already exists earlier", () => {
153 const text = "open @AGENTS.md then @src/index.ts ";
154 const expandedCursor = text.length;
155 const collapsedCursor = collapseExpandedComposerCursor(text, expandedCursor);
156
157 expect(collapsedCursor).toBe("open ".length + 1 + " then ".length + 2);
158 expect(expandCollapsedComposerCursor(text, collapsedCursor)).toBe(expandedCursor);
159 });
160});
161
162describe("clampCollapsedComposerCursor", () => {
163 it("clamps to collapsed prompt length when mentions are present", () => {
164 const text = "open @AGENTS.md then ";
165
166 expect(clampCollapsedComposerCursor(text, text.length)).toBe(
167 "open ".length + 1 + " then ".length,
168 );
169 expect(clampCollapsedComposerCursor(text, Number.POSITIVE_INFINITY)).toBe(
170 "open ".length + 1 + " then ".length,
171 );
172 });
173});
174
175describe("replaceTextRange trailing space consumption", () => {
176 it("double space after insertion when replacement ends with space", () => {
177 // Simulates: "and then |@AG| summarize" where | marks replacement range
178 // The replacement is "@AGENTS.md " (with trailing space)
179 // But if we don't extend rangeEnd, the existing space stays
180 const text = "and then @AG summarize";
181 const rangeStart = "and then ".length;
182 const rangeEnd = "and then @AG".length;
183
184 // Without consuming trailing space: double space
185 const withoutConsume = replaceTextRange(text, rangeStart, rangeEnd, "@AGENTS.md ");
186 expect(withoutConsume.text).toBe("and then @AGENTS.md summarize");
187
188 // With consuming trailing space: single space
189 const extendedEnd = text[rangeEnd] === " " ? rangeEnd + 1 : rangeEnd;
190 const withConsume = replaceTextRange(text, rangeStart, extendedEnd, "@AGENTS.md ");
191 expect(withConsume.text).toBe("and then @AGENTS.md summarize");
192 });
193});
194
95describe("isCollapsedCursorAdjacentToMention", () => {195describe("isCollapsedCursorAdjacentToMention", () => {
96 it("returns false when no mention exists", () => {196 it("returns false when no mention exists", () => {
97 expect(isCollapsedCursorAdjacentToMention("plain text", 6, "left")).toBe(false);197 expect(isCollapsedCursorAdjacentToMention("plain text", 6, "left")).toBe(false);
apps/web/src/composer-logic.ts+44-2
...@@ -67,7 +67,7 @@ function collapsedSegmentLength(...@@ -67,7 +67,7 @@ function collapsedSegmentLength(
67 return segment.type === "mention" ? 1 : segment.text.length;67 return segment.type === "mention" ? 1 : segment.text.length;
68}68}
6969
70function clampCollapsedComposerCursor(70function clampCollapsedComposerCursorForSegments(
71 segments: ReadonlyArray<{ type: "text"; text: string } | { type: "mention" }>,71 segments: ReadonlyArray<{ type: "text"; text: string } | { type: "mention" }>,
72 cursorInput: number,72 cursorInput: number,
73): number {73): number {
...@@ -81,6 +81,48 @@ function clampCollapsedComposerCursor(...@@ -81,6 +81,48 @@ function clampCollapsedComposerCursor(
81 return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput)));81 return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput)));
82}82}
8383
84export function clampCollapsedComposerCursor(text: string, cursorInput: number): number {
85 return clampCollapsedComposerCursorForSegments(
86 splitPromptIntoComposerSegments(text),
87 cursorInput,
88 );
89}
90
91export function collapseExpandedComposerCursor(text: string, cursorInput: number): number {
92 const expandedCursor = clampCursor(text, cursorInput);
93 const segments = splitPromptIntoComposerSegments(text);
94 if (segments.length === 0) {
95 return expandedCursor;
96 }
97
98 let remaining = expandedCursor;
99 let collapsedCursor = 0;
100
101 for (const segment of segments) {
102 if (segment.type === "mention") {
103 const expandedLength = segment.path.length + 1;
104 if (remaining === 0) {
105 return collapsedCursor;
106 }
107 if (remaining <= expandedLength) {
108 return collapsedCursor + 1;
109 }
110 remaining -= expandedLength;
111 collapsedCursor += 1;
112 continue;
113 }
114
115 const segmentLength = segment.text.length;
116 if (remaining <= segmentLength) {
117 return collapsedCursor + remaining;
118 }
119 remaining -= segmentLength;
120 collapsedCursor += segmentLength;
121 }
122
123 return collapsedCursor;
124}
125
84export function isCollapsedCursorAdjacentToMention(126export function isCollapsedCursorAdjacentToMention(
85 text: string,127 text: string,
86 cursorInput: number,128 cursorInput: number,
...@@ -91,7 +133,7 @@ export function isCollapsedCursorAdjacentToMention(...@@ -91,7 +133,7 @@ export function isCollapsedCursorAdjacentToMention(
91 return false;133 return false;
92 }134 }
93135
94 const cursor = clampCollapsedComposerCursor(segments, cursorInput);136 const cursor = clampCollapsedComposerCursorForSegments(segments, cursorInput);
95 let collapsedOffset = 0;137 let collapsedOffset = 0;
96138
97 for (const segment of segments) {139 for (const segment of segments) {