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
3838import { isElectron } from "../env";
3939import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
4040import {
41 clampCollapsedComposerCursor,
4142 type ComposerTrigger,
43 collapseExpandedComposerCursor,
4244 detectComposerTrigger,
4345 expandCollapsedComposerCursor,
4446 parseStandaloneComposerSlashCommand,
......@@ -266,7 +268,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
266268 const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<
267269 Record<string, string[]>
268270 >({});
269 const [composerCursor, setComposerCursor] = useState(() => prompt.length);
271 const [composerCursor, setComposerCursor] = useState(() =>
272 collapseExpandedComposerCursor(prompt, prompt.length),
273 );
270274 const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() =>
271275 detectComposerTrigger(prompt, prompt.length),
272276 );
......@@ -662,11 +666,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
662666 }
663667
664668 promptRef.current = nextCustomAnswer;
665 setComposerCursor(nextCustomAnswer.length);
669 const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length);
670 setComposerCursor(nextCursor);
666671 setComposerTrigger(
667672 detectComposerTrigger(
668673 nextCustomAnswer,
669 expandCollapsedComposerCursor(nextCustomAnswer, nextCustomAnswer.length),
674 expandCollapsedComposerCursor(nextCustomAnswer, nextCursor),
670675 ),
671676 );
672677 setComposerHighlightedItemId(null);
......@@ -1742,7 +1747,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
17421747
17431748 useEffect(() => {
17441749 promptRef.current = prompt;
1745 setComposerCursor((existing) => Math.min(Math.max(0, existing), prompt.length));
1750 setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing));
17461751 }, [prompt]);
17471752
17481753 useEffect(() => {
......@@ -1755,7 +1760,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
17551760 setSendPhase("idle");
17561761 setSendStartedAt(null);
17571762 setComposerHighlightedItemId(null);
1758 setComposerCursor(promptRef.current.length);
1763 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
17591764 setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length));
17601765 dragDepthRef.current = 0;
17611766 setIsDragOverComposer(false);
......@@ -2433,7 +2438,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
24332438 });
24342439 promptRef.current = trimmed;
24352440 setPrompt(trimmed);
2436 setComposerCursor(trimmed.length);
2441 setComposerCursor(collapseExpandedComposerCursor(trimmed, trimmed.length));
24372442 addComposerImagesToDraft(composerImagesSnapshot.map(cloneComposerImageForRetry));
24382443 setComposerTrigger(detectComposerTrigger(trimmed, trimmed.length));
24392444 }
......@@ -2551,7 +2556,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
25512556 );
25522557
25532558 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 ) => {
25552566 if (!activePendingUserInput) {
25562567 return;
25572568 }
......@@ -2568,9 +2579,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
25682579 }));
25692580 setComposerCursor(nextCursor);
25702581 setComposerTrigger(
2571 cursorAdjacentToMention
2572 ? null
2573 : detectComposerTrigger(value, expandCollapsedComposerCursor(value, nextCursor)),
2582 cursorAdjacentToMention ? null : detectComposerTrigger(value, expandedCursor),
25742583 );
25752584 },
25762585 [activePendingUserInput],
......@@ -2906,6 +2915,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
29062915 return false;
29072916 }
29082917 const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
2918 const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor);
29092919 promptRef.current = next.text;
29102920 const activePendingQuestion = activePendingProgress?.activeQuestion;
29112921 if (activePendingQuestion && activePendingUserInput) {
......@@ -2922,10 +2932,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
29222932 } else {
29232933 setPrompt(next.text);
29242934 }
2925 setComposerCursor(next.cursor);
2926 setComposerTrigger(detectComposerTrigger(next.text, next.cursor));
2935 setComposerCursor(nextCursor);
2936 setComposerTrigger(
2937 detectComposerTrigger(next.text, expandCollapsedComposerCursor(next.text, nextCursor)),
2938 );
29272939 window.requestAnimationFrame(() => {
2928 composerEditorRef.current?.focusAt(next.cursor);
2940 composerEditorRef.current?.focusAt(nextCursor);
29292941 });
29302942 return true;
29312943 },
......@@ -2935,23 +2947,38 @@ export default function ChatView({ threadId }: ChatViewProps) {
29352947 const readComposerSnapshot = useCallback((): {
29362948 value: string;
29372949 cursor: number;
2950 expandedCursor: number;
29382951 } => {
29392952 const editorSnapshot = composerEditorRef.current?.readSnapshot();
29402953 if (editorSnapshot) {
29412954 return editorSnapshot;
29422955 }
2943 return { value: promptRef.current, cursor: composerCursor };
2956 return {
2957 value: promptRef.current,
2958 cursor: composerCursor,
2959 expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor),
2960 };
29442961 }, [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
29462974 const resolveActiveComposerTrigger = useCallback((): {
2947 snapshot: { value: string; cursor: number };
2975 snapshot: { value: string; cursor: number; expandedCursor: number };
29482976 trigger: ComposerTrigger | null;
29492977 } => {
29502978 const snapshot = readComposerSnapshot();
2951 const expandedCursor = expandCollapsedComposerCursor(snapshot.value, snapshot.cursor);
29522979 return {
29532980 snapshot,
2954 trigger: detectComposerTrigger(snapshot.value, expandedCursor),
2981 trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor),
29552982 };
29562983 }, [readComposerSnapshot]);
29572984
......@@ -2964,13 +2991,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
29642991 });
29652992 const { snapshot, trigger } = resolveActiveComposerTrigger();
29662993 if (!trigger) return;
2967 const expectedToken = snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd);
29682994 if (item.type === "path") {
2995 const replacement = `@${item.path} `;
2996 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
2997 snapshot.value,
2998 trigger.rangeEnd,
2999 replacement,
3000 );
29693001 const applied = applyPromptReplacement(
29703002 trigger.rangeStart,
2971 trigger.rangeEnd,
2972 `@${item.path} `,
2973 { expectedText: expectedToken },
3003 replacementRangeEnd,
3004 replacement,
3005 { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) },
29743006 );
29753007 if (applied) {
29763008 setComposerHighlightedItemId(null);
......@@ -2979,9 +3011,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
29793011 }
29803012 if (item.type === "slash-command") {
29813013 if (item.command === "model") {
2982 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "/model ", {
2983 expectedText: expectedToken,
2984 });
3014 const replacement = "/model ";
3015 const replacementRangeEnd = extendReplacementRangeForTrailingSpace(
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 );
29853026 if (applied) {
29863027 setComposerHighlightedItemId(null);
29873028 }
......@@ -2989,7 +3030,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
29893030 }
29903031 void handleInteractionModeChange(item.command === "plan" ? "plan" : "default");
29913032 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
2992 expectedText: expectedToken,
3033 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
29933034 });
29943035 if (applied) {
29953036 setComposerHighlightedItemId(null);
......@@ -2998,7 +3039,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
29983039 }
29993040 onProviderModelSelect(item.provider, item.model);
30003041 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
3001 expectedText: expectedToken,
3042 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
30023043 });
30033044 if (applied) {
30043045 setComposerHighlightedItemId(null);
......@@ -3039,12 +3080,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
30393080 workspaceEntriesQuery.isFetching);
30403081
30413082 const onPromptChange = useCallback(
3042 (nextPrompt: string, nextCursor: number, cursorAdjacentToMention: boolean) => {
3083 (
3084 nextPrompt: string,
3085 nextCursor: number,
3086 expandedCursor: number,
3087 cursorAdjacentToMention: boolean,
3088 ) => {
30433089 if (activePendingProgress?.activeQuestion && activePendingUserInput) {
30443090 onChangeActivePendingUserInputCustomAnswer(
30453091 activePendingProgress.activeQuestion.id,
30463092 nextPrompt,
30473093 nextCursor,
3094 expandedCursor,
30483095 cursorAdjacentToMention,
30493096 );
30503097 return;
......@@ -3053,12 +3100,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
30533100 setPrompt(nextPrompt);
30543101 setComposerCursor(nextCursor);
30553102 setComposerTrigger(
3056 cursorAdjacentToMention
3057 ? null
3058 : detectComposerTrigger(
3059 nextPrompt,
3060 expandCollapsedComposerCursor(nextPrompt, nextCursor),
3061 ),
3103 cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
30623104 );
30633105 },
30643106 [
apps/web/src/components/ComposerPromptEditor.tsx+143-17
......@@ -47,7 +47,12 @@ import {
4747 type Ref,
4848} from "react";
4949
50import { isCollapsedCursorAdjacentToMention } from "~/composer-logic";
50import {
51 clampCollapsedComposerCursor,
52 collapseExpandedComposerCursor,
53 expandCollapsedComposerCursor,
54 isCollapsedCursorAdjacentToMention,
55} from "~/composer-logic";
5156import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
5257import { cn } from "~/lib/utils";
5358import { basenameOfPath, getVscodeIconUrlForEntry } from "~/vscode-icons";
......@@ -171,7 +176,7 @@ function renderMentionChipDom(container: HTMLElement, pathValue: string): void {
171176 container.append(icon, label);
172177}
173178
174function clampCursor(value: string, cursor: number): number {
179function clampExpandedCursor(value: string, cursor: number): number {
175180 if (!Number.isFinite(cursor)) return value.length;
176181 return Math.max(0, Math.min(value.length, Math.floor(cursor)));
177182}
......@@ -192,6 +197,21 @@ function getComposerNodeTextLength(node: LexicalNode): number {
192197 return 0;
193198}
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
195215function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number {
196216 let offset = 0;
197217 let current: LexicalNode | null = node;
......@@ -236,6 +256,50 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb
236256 return offset;
237257}
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
239303function findSelectionPointAtOffset(
240304 node: LexicalNode,
241305 remainingRef: { value: number },
......@@ -350,6 +414,17 @@ function $readSelectionOffsetFromEditorState(fallback: number): number {
350414 return Math.max(0, Math.min(offset, composerLength));
351415}
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
353428function $appendTextWithLineBreaks(parent: ElementNode, text: string): void {
354429 const lines = text.split("\n");
355430 for (let index = 0; index < lines.length; index += 1) {
......@@ -383,7 +458,7 @@ export interface ComposerPromptEditorHandle {
383458 focus: () => void;
384459 focusAt: (cursor: number) => void;
385460 focusAtEnd: () => void;
386 readSnapshot: () => { value: string; cursor: number };
461 readSnapshot: () => { value: string; cursor: number; expandedCursor: number };
387462}
388463
389464interface ComposerPromptEditorProps {
......@@ -392,7 +467,12 @@ interface ComposerPromptEditorProps {
392467 disabled: boolean;
393468 placeholder: string;
394469 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;
396476 onCommandKeyDown?: (
397477 key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
398478 event: KeyboardEvent,
......@@ -628,7 +708,12 @@ function ComposerPromptEditorInner({
628708}: ComposerPromptEditorInnerProps) {
629709 const [editor] = useLexicalComposerContext();
630710 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 });
632717 const isApplyingControlledUpdateRef = useRef(false);
633718
634719 useEffect(() => {
......@@ -640,13 +725,17 @@ function ComposerPromptEditorInner({
640725 }, [disabled, editor]);
641726
642727 useLayoutEffect(() => {
643 const normalizedCursor = clampCursor(value, cursor);
728 const normalizedCursor = clampCollapsedComposerCursor(value, cursor);
644729 const previousSnapshot = snapshotRef.current;
645730 if (previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor) {
646731 return;
647732 }
648733
649 snapshotRef.current = { value, cursor: normalizedCursor };
734 snapshotRef.current = {
735 value,
736 cursor: normalizedCursor,
737 expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor),
738 };
650739
651740 const rootElement = editor.getRootElement();
652741 const isFocused = Boolean(rootElement && document.activeElement === rootElement);
......@@ -673,7 +762,7 @@ function ComposerPromptEditorInner({
673762 (nextCursor: number) => {
674763 const rootElement = editor.getRootElement();
675764 if (!rootElement) return;
676 const boundedCursor = clampCursor(snapshotRef.current.value, nextCursor);
765 const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor);
677766 rootElement.focus();
678767 editor.update(() => {
679768 $setSelectionAtComposerOffset(boundedCursor);
......@@ -681,24 +770,43 @@ function ComposerPromptEditorInner({
681770 snapshotRef.current = {
682771 value: snapshotRef.current.value,
683772 cursor: boundedCursor,
773 expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor),
684774 };
685 onChangeRef.current(snapshotRef.current.value, boundedCursor, false);
775 onChangeRef.current(
776 snapshotRef.current.value,
777 boundedCursor,
778 snapshotRef.current.expandedCursor,
779 false,
780 );
686781 },
687782 [editor],
688783 );
689784
690 const readSnapshot = useCallback((): { value: string; cursor: number } => {
785 const readSnapshot = useCallback((): {
786 value: string;
787 cursor: number;
788 expandedCursor: number;
789 } => {
691790 let snapshot = snapshotRef.current;
692791 editor.getEditorState().read(() => {
693792 const nextValue = $getRoot().getTextContent();
694 const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor);
695 const nextCursor = clampCursor(
793 const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor);
794 const nextCursor = clampCollapsedComposerCursor(
696795 nextValue,
697796 $readSelectionOffsetFromEditorState(fallbackCursor),
698797 );
798 const fallbackExpandedCursor = clampExpandedCursor(
799 nextValue,
800 snapshotRef.current.expandedCursor,
801 );
802 const nextExpandedCursor = clampExpandedCursor(
803 nextValue,
804 $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor),
805 );
699806 snapshot = {
700807 value: nextValue,
701808 cursor: nextCursor,
809 expandedCursor: nextExpandedCursor,
702810 };
703811 });
704812 snapshotRef.current = snapshot;
......@@ -713,7 +821,12 @@ function ComposerPromptEditorInner({
713821 },
714822 focusAt,
715823 focusAtEnd: () => {
716 focusAt(snapshotRef.current.value.length);
824 focusAt(
825 collapseExpandedComposerCursor(
826 snapshotRef.current.value,
827 snapshotRef.current.value.length,
828 ),
829 );
717830 },
718831 readSnapshot,
719832 }),
......@@ -723,13 +836,25 @@ function ComposerPromptEditorInner({
723836 const handleEditorChange = useCallback((editorState: EditorState) => {
724837 editorState.read(() => {
725838 const nextValue = $getRoot().getTextContent();
726 const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor);
727 const nextCursor = clampCursor(
839 const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor);
840 const nextCursor = clampCollapsedComposerCursor(
728841 nextValue,
729842 $readSelectionOffsetFromEditorState(fallbackCursor),
730843 );
844 const fallbackExpandedCursor = clampExpandedCursor(
845 nextValue,
846 snapshotRef.current.expandedCursor,
847 );
848 const nextExpandedCursor = clampExpandedCursor(
849 nextValue,
850 $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor),
851 );
731852 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 ) {
733858 return;
734859 }
735860 if (isApplyingControlledUpdateRef.current) {
......@@ -738,11 +863,12 @@ function ComposerPromptEditorInner({
738863 snapshotRef.current = {
739864 value: nextValue,
740865 cursor: nextCursor,
866 expandedCursor: nextExpandedCursor,
741867 };
742868 const cursorAdjacentToMention =
743869 isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "left") ||
744870 isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "right");
745 onChangeRef.current(nextValue, nextCursor, cursorAdjacentToMention);
871 onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, cursorAdjacentToMention);
746872 });
747873 }, []);
748874
apps/web/src/composer-logic.test.ts+100
......@@ -1,6 +1,8 @@
11import { describe, expect, it } from "vitest";
22
33import {
4 clampCollapsedComposerCursor,
5 collapseExpandedComposerCursor,
46 detectComposerTrigger,
57 expandCollapsedComposerCursor,
68 isCollapsedCursorAdjacentToMention,
......@@ -56,6 +58,46 @@ describe("detectComposerTrigger", () => {
5658 rangeEnd: text.length,
5759 });
5860 });
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 });
59101});
60102
61103describe("replaceTextRange", () => {
......@@ -92,6 +134,64 @@ describe("expandCollapsedComposerCursor", () => {
92134 });
93135});
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
95195describe("isCollapsedCursorAdjacentToMention", () => {
96196 it("returns false when no mention exists", () => {
97197 expect(isCollapsedCursorAdjacentToMention("plain text", 6, "left")).toBe(false);
apps/web/src/composer-logic.ts+44-2
......@@ -67,7 +67,7 @@ function collapsedSegmentLength(
6767 return segment.type === "mention" ? 1 : segment.text.length;
6868}
6969
70function clampCollapsedComposerCursor(
70function clampCollapsedComposerCursorForSegments(
7171 segments: ReadonlyArray<{ type: "text"; text: string } | { type: "mention" }>,
7272 cursorInput: number,
7373): number {
......@@ -81,6 +81,48 @@ function clampCollapsedComposerCursor(
8181 return Math.max(0, Math.min(collapsedLength, Math.floor(cursorInput)));
8282}
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
84126export function isCollapsedCursorAdjacentToMention(
85127 text: string,
86128 cursorInput: number,
......@@ -91,7 +133,7 @@ export function isCollapsedCursorAdjacentToMention(
91133 return false;
92134 }
93135
94 const cursor = clampCollapsedComposerCursor(segments, cursorInput);
136 const cursor = clampCollapsedComposerCursorForSegments(segments, cursorInput);
95137 let collapsedOffset = 0;
96138
97139 for (const segment of segments) {