Skip to content

Commit 8f3b32a

Browse files
committed
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
1 parent ff6a66d commit 8f3b32a

File tree

3 files changed

+167
-27
lines changed

3 files changed

+167
-27
lines changed

apps/web/src/components/ChatView.tsx

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ import {
5858
type ComposerTrigger,
5959
type ComposerTriggerKind,
6060
detectComposerTrigger,
61-
expandCollapsedComposerCursor,
6261
parseStandaloneComposerSlashCommand,
6362
replaceTextRange,
6463
} from "../composer-logic";
@@ -1039,10 +1038,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
10391038
setComposerTrigger(
10401039
detectComposerTrigger(
10411040
activePendingProgress.customAnswer,
1042-
expandCollapsedComposerCursor(
1043-
activePendingProgress.customAnswer,
1044-
activePendingProgress.customAnswer.length,
1045-
),
1041+
activePendingProgress.customAnswer.length,
10461042
),
10471043
);
10481044
setComposerHighlightedItemId(null);
@@ -2930,7 +2926,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
29302926
);
29312927

29322928
const onChangeActivePendingUserInputCustomAnswer = useCallback(
2933-
(questionId: string, value: string, nextCursor: number, cursorAdjacentToMention: boolean) => {
2929+
(
2930+
questionId: string,
2931+
value: string,
2932+
nextCursor: number,
2933+
expandedCursor: number,
2934+
cursorAdjacentToMention: boolean,
2935+
) => {
29342936
if (!activePendingUserInput) {
29352937
return;
29362938
}
@@ -2947,9 +2949,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
29472949
}));
29482950
setComposerCursor(nextCursor);
29492951
setComposerTrigger(
2950-
cursorAdjacentToMention
2951-
? null
2952-
: detectComposerTrigger(value, expandCollapsedComposerCursor(value, nextCursor)),
2952+
cursorAdjacentToMention ? null : detectComposerTrigger(value, expandedCursor),
29532953
);
29542954
},
29552955
[activePendingUserInput],
@@ -3311,23 +3311,26 @@ export default function ChatView({ threadId }: ChatViewProps) {
33113311
[activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt],
33123312
);
33133313

3314-
const readComposerSnapshot = useCallback((): { value: string; cursor: number } => {
3314+
const readComposerSnapshot = useCallback((): {
3315+
value: string;
3316+
cursor: number;
3317+
expandedCursor: number;
3318+
} => {
33153319
const editorSnapshot = composerEditorRef.current?.readSnapshot();
33163320
if (editorSnapshot) {
33173321
return editorSnapshot;
33183322
}
3319-
return { value: promptRef.current, cursor: composerCursor };
3323+
return { value: promptRef.current, cursor: composerCursor, expandedCursor: composerCursor };
33203324
}, [composerCursor]);
33213325

33223326
const resolveActiveComposerTrigger = useCallback((): {
33233327
snapshot: { value: string; cursor: number };
33243328
trigger: ComposerTrigger | null;
33253329
} => {
33263330
const snapshot = readComposerSnapshot();
3327-
const expandedCursor = expandCollapsedComposerCursor(snapshot.value, snapshot.cursor);
33283331
return {
33293332
snapshot,
3330-
trigger: detectComposerTrigger(snapshot.value, expandedCursor),
3333+
trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor),
33313334
};
33323335
}, [readComposerSnapshot]);
33333336

@@ -3415,12 +3418,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
34153418
workspaceEntriesQuery.isFetching);
34163419

34173420
const onPromptChange = useCallback(
3418-
(nextPrompt: string, nextCursor: number, cursorAdjacentToMention: boolean) => {
3421+
(
3422+
nextPrompt: string,
3423+
nextCursor: number,
3424+
expandedCursor: number,
3425+
cursorAdjacentToMention: boolean,
3426+
) => {
34193427
if (activePendingProgress?.activeQuestion && activePendingUserInput) {
34203428
onChangeActivePendingUserInputCustomAnswer(
34213429
activePendingProgress.activeQuestion.id,
34223430
nextPrompt,
34233431
nextCursor,
3432+
expandedCursor,
34243433
cursorAdjacentToMention,
34253434
);
34263435
return;
@@ -3429,12 +3438,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
34293438
setPrompt(nextPrompt);
34303439
setComposerCursor(nextCursor);
34313440
setComposerTrigger(
3432-
cursorAdjacentToMention
3433-
? null
3434-
: detectComposerTrigger(
3435-
nextPrompt,
3436-
expandCollapsedComposerCursor(nextPrompt, nextCursor),
3437-
),
3441+
cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
34383442
);
34393443
},
34403444
[

apps/web/src/components/ComposerPromptEditor.tsx

Lines changed: 101 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,21 @@ function getComposerNodeTextLength(node: LexicalNode): number {
192192
return 0;
193193
}
194194

195+
function getComposerNodeExpandedTextLength(node: LexicalNode): number {
196+
if ($isTextNode(node)) {
197+
return node.getTextContentSize();
198+
}
199+
if ($isLineBreakNode(node)) {
200+
return 1;
201+
}
202+
if ($isElementNode(node)) {
203+
return node
204+
.getChildren()
205+
.reduce((total, child) => total + getComposerNodeExpandedTextLength(child), 0);
206+
}
207+
return 0;
208+
}
209+
195210
function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number {
196211
let offset = 0;
197212
let current: LexicalNode | null = node;
@@ -236,6 +251,50 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb
236251
return offset;
237252
}
238253

254+
function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number {
255+
let offset = 0;
256+
let current: LexicalNode | null = node;
257+
258+
while (current) {
259+
const nextParent = current.getParent() as LexicalNode | null;
260+
if (!nextParent || !$isElementNode(nextParent)) {
261+
break;
262+
}
263+
const siblings = nextParent.getChildren();
264+
const index = current.getIndexWithinParent();
265+
for (let i = 0; i < index; i += 1) {
266+
const sibling = siblings[i];
267+
if (!sibling) continue;
268+
offset += getComposerNodeExpandedTextLength(sibling);
269+
}
270+
current = nextParent;
271+
}
272+
273+
if ($isTextNode(node)) {
274+
if (node instanceof ComposerMentionNode) {
275+
return offset + (pointOffset > 0 ? node.getTextContentSize() : 0);
276+
}
277+
return offset + Math.min(pointOffset, node.getTextContentSize());
278+
}
279+
280+
if ($isLineBreakNode(node)) {
281+
return offset + Math.min(pointOffset, 1);
282+
}
283+
284+
if ($isElementNode(node)) {
285+
const children = node.getChildren();
286+
const clampedOffset = Math.max(0, Math.min(pointOffset, children.length));
287+
for (let i = 0; i < clampedOffset; i += 1) {
288+
const child = children[i];
289+
if (!child) continue;
290+
offset += getComposerNodeExpandedTextLength(child);
291+
}
292+
return offset;
293+
}
294+
295+
return offset;
296+
}
297+
239298
function findSelectionPointAtOffset(
240299
node: LexicalNode,
241300
remainingRef: { value: number },
@@ -350,6 +409,17 @@ function $readSelectionOffsetFromEditorState(fallback: number): number {
350409
return Math.max(0, Math.min(offset, composerLength));
351410
}
352411

412+
function $readExpandedSelectionOffsetFromEditorState(fallback: number): number {
413+
const selection = $getSelection();
414+
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
415+
return fallback;
416+
}
417+
const anchorNode = selection.anchor.getNode();
418+
const offset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset);
419+
const expandedLength = $getRoot().getTextContent().length;
420+
return Math.max(0, Math.min(offset, expandedLength));
421+
}
422+
353423
function $appendTextWithLineBreaks(parent: ElementNode, text: string): void {
354424
const lines = text.split("\n");
355425
for (let index = 0; index < lines.length; index += 1) {
@@ -383,7 +453,7 @@ export interface ComposerPromptEditorHandle {
383453
focus: () => void;
384454
focusAt: (cursor: number) => void;
385455
focusAtEnd: () => void;
386-
readSnapshot: () => { value: string; cursor: number };
456+
readSnapshot: () => { value: string; cursor: number; expandedCursor: number };
387457
}
388458

389459
interface ComposerPromptEditorProps {
@@ -392,7 +462,12 @@ interface ComposerPromptEditorProps {
392462
disabled: boolean;
393463
placeholder: string;
394464
className?: string;
395-
onChange: (nextValue: string, nextCursor: number, cursorAdjacentToMention: boolean) => void;
465+
onChange: (
466+
nextValue: string,
467+
nextCursor: number,
468+
expandedCursor: number,
469+
cursorAdjacentToMention: boolean,
470+
) => void;
396471
onCommandKeyDown?: (
397472
key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab",
398473
event: KeyboardEvent,
@@ -672,30 +747,45 @@ function ComposerPromptEditorInner({
672747
editor.update(() => {
673748
$setSelectionAtComposerOffset(boundedCursor);
674749
});
750+
const nextExpandedCursor = editor
751+
.getEditorState()
752+
.read(() => $readExpandedSelectionOffsetFromEditorState(boundedCursor));
675753
snapshotRef.current = {
676754
value: snapshotRef.current.value,
677755
cursor: boundedCursor,
678756
};
679-
onChangeRef.current(snapshotRef.current.value, boundedCursor, false);
757+
onChangeRef.current(snapshotRef.current.value, boundedCursor, nextExpandedCursor, false);
680758
},
681759
[editor],
682760
);
683761

684-
const readSnapshot = useCallback((): { value: string; cursor: number } => {
685-
let snapshot = snapshotRef.current;
762+
const readSnapshot = useCallback((): {
763+
value: string;
764+
cursor: number;
765+
expandedCursor: number;
766+
} => {
767+
let snapshot: { value: string; cursor: number; expandedCursor: number } = {
768+
...snapshotRef.current,
769+
expandedCursor: snapshotRef.current.cursor,
770+
};
686771
editor.getEditorState().read(() => {
687772
const nextValue = $getRoot().getTextContent();
688773
const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor);
689774
const nextCursor = clampCursor(
690775
nextValue,
691776
$readSelectionOffsetFromEditorState(fallbackCursor),
692777
);
778+
const nextExpandedCursor = clampCursor(
779+
nextValue,
780+
$readExpandedSelectionOffsetFromEditorState(fallbackCursor),
781+
);
693782
snapshot = {
694783
value: nextValue,
695784
cursor: nextCursor,
785+
expandedCursor: nextExpandedCursor,
696786
};
697787
});
698-
snapshotRef.current = snapshot;
788+
snapshotRef.current = { value: snapshot.value, cursor: snapshot.cursor };
699789
return snapshot;
700790
}, [editor]);
701791

@@ -724,6 +814,10 @@ function ComposerPromptEditorInner({
724814
nextValue,
725815
$readSelectionOffsetFromEditorState(fallbackCursor),
726816
);
817+
const nextExpandedCursor = clampCursor(
818+
nextValue,
819+
$readExpandedSelectionOffsetFromEditorState(fallbackCursor),
820+
);
727821
const previousSnapshot = snapshotRef.current;
728822
if (previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor) {
729823
return;
@@ -735,7 +829,7 @@ function ComposerPromptEditorInner({
735829
const cursorAdjacentToMention =
736830
isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "left") ||
737831
isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "right");
738-
onChangeRef.current(nextValue, nextCursor, cursorAdjacentToMention);
832+
onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, cursorAdjacentToMention);
739833
});
740834
}, []);
741835

apps/web/src/composer-logic.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,34 @@ describe("detectComposerTrigger", () => {
5656
rangeEnd: text.length,
5757
});
5858
});
59+
60+
it("detects @path trigger in the middle of existing text", () => {
61+
// User typed @ between "inspect " and "in this sentence"
62+
const text = "Please inspect @in this sentence";
63+
const cursorAfterAt = "Please inspect @".length;
64+
65+
const trigger = detectComposerTrigger(text, cursorAfterAt);
66+
expect(trigger).toEqual({
67+
kind: "path",
68+
query: "",
69+
rangeStart: "Please inspect ".length,
70+
rangeEnd: cursorAfterAt,
71+
});
72+
});
73+
74+
it("detects @path trigger with query typed mid-text", () => {
75+
// User typed @sr between "inspect " and "in this sentence"
76+
const text = "Please inspect @srin this sentence";
77+
const cursorAfterQuery = "Please inspect @sr".length;
78+
79+
const trigger = detectComposerTrigger(text, cursorAfterQuery);
80+
expect(trigger).toEqual({
81+
kind: "path",
82+
query: "sr",
83+
rangeStart: "Please inspect ".length,
84+
rangeEnd: cursorAfterQuery,
85+
});
86+
});
5987
});
6088

6189
describe("replaceTextRange", () => {
@@ -90,6 +118,20 @@ describe("expandCollapsedComposerCursor", () => {
90118

91119
expect(detectComposerTrigger(text, expandedCursor)).toBeNull();
92120
});
121+
122+
it("detectComposerTrigger works with true cursor even when expandCollapsedComposerCursor is wrong", () => {
123+
// expandCollapsedComposerCursor uses MENTION_TOKEN_REGEX which can false-match
124+
// plain text like "@in" as a mention. The fix bypasses it by computing the expanded
125+
// cursor directly from the Lexical editor's node tree.
126+
const text = "Please inspect @in this sentence";
127+
const cursorAfterAt = "Please inspect @".length;
128+
129+
// With the true cursor position, trigger detection works correctly
130+
const trigger = detectComposerTrigger(text, cursorAfterAt);
131+
expect(trigger).not.toBeNull();
132+
expect(trigger?.kind).toBe("path");
133+
expect(trigger?.query).toBe("");
134+
});
93135
});
94136

95137
describe("isCollapsedCursorAdjacentToMention", () => {

0 commit comments

Comments
 (0)