From b7a8fc0a3fedfcb30380f951af983a2fd428fc9b Mon Sep 17 00:00:00 2001 From: Raj Beladiya Date: Wed, 11 Mar 2026 22:52:34 +0530 Subject: [PATCH] fix: mention ping detection --- apps/web/src/components/ChatView.tsx | 58 +++++++--- .../src/components/ComposerPromptEditor.tsx | 104 ++++++++++++++---- apps/web/src/composer-logic.test.ts | 20 ++++ apps/web/src/composer-logic.ts | 14 +++ apps/web/src/composer-path-menu.test.ts | 55 +++++++++ apps/web/src/composer-path-menu.ts | 15 +++ 6 files changed, 229 insertions(+), 37 deletions(-) create mode 100644 apps/web/src/composer-path-menu.test.ts create mode 100644 apps/web/src/composer-path-menu.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c8a0a152..0425c244c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -52,12 +52,14 @@ import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; +import { resolveComposerPathMenuEntries } from "../composer-path-menu"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { type ComposerSlashCommand, type ComposerTrigger, type ComposerTriggerKind, detectComposerTrigger, + detectComposerTriggerFromSnapshot, expandCollapsedComposerCursor, parseStandaloneComposerSlashCommand, replaceTextRange, @@ -1277,7 +1279,13 @@ export default function ChatView({ threadId }: ChatViewProps) { limit: 80, }), ); - const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; + const workspaceEntries = resolveComposerPathMenuEntries({ + query: pathTriggerQuery, + isDebouncing: composerPathQueryDebouncer.state.isPending, + isFetching: workspaceEntriesQuery.isFetching, + isLoading: workspaceEntriesQuery.isLoading, + entries: workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES, + }); const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; if (composerTrigger.kind === "path") { @@ -2930,7 +2938,13 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const onChangeActivePendingUserInputCustomAnswer = useCallback( - (questionId: string, value: string, nextCursor: number, cursorAdjacentToMention: boolean) => { + ( + questionId: string, + value: string, + nextCursor: number, + cursorAdjacentToMention: boolean, + nextExpandedCursor: number, + ) => { if (!activePendingUserInput) { return; } @@ -2949,7 +2963,11 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerTrigger( cursorAdjacentToMention ? null - : detectComposerTrigger(value, expandCollapsedComposerCursor(value, nextCursor)), + : detectComposerTriggerFromSnapshot({ + value, + cursor: nextCursor, + expandedCursor: nextExpandedCursor, + }), ); }, [activePendingUserInput], @@ -3311,23 +3329,30 @@ export default function ChatView({ threadId }: ChatViewProps) { [activePendingProgress?.activeQuestion, activePendingUserInput, setPrompt], ); - const readComposerSnapshot = useCallback((): { value: string; cursor: number } => { + 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: expandCollapsedComposerCursor(promptRef.current, composerCursor), + }; }, [composerCursor]); const resolveActiveComposerTrigger = useCallback((): { - snapshot: { value: string; cursor: number }; + snapshot: { value: string; cursor: number; expandedCursor: number }; trigger: ComposerTrigger | null; } => { const snapshot = readComposerSnapshot(); - const expandedCursor = expandCollapsedComposerCursor(snapshot.value, snapshot.cursor); return { snapshot, - trigger: detectComposerTrigger(snapshot.value, expandedCursor), + trigger: detectComposerTriggerFromSnapshot(snapshot), }; }, [readComposerSnapshot]); @@ -3415,13 +3440,19 @@ export default function ChatView({ threadId }: ChatViewProps) { workspaceEntriesQuery.isFetching); const onPromptChange = useCallback( - (nextPrompt: string, nextCursor: number, cursorAdjacentToMention: boolean) => { + ( + nextPrompt: string, + nextCursor: number, + cursorAdjacentToMention: boolean, + nextExpandedCursor: number, + ) => { if (activePendingProgress?.activeQuestion && activePendingUserInput) { onChangeActivePendingUserInputCustomAnswer( activePendingProgress.activeQuestion.id, nextPrompt, nextCursor, cursorAdjacentToMention, + nextExpandedCursor, ); return; } @@ -3431,10 +3462,11 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerTrigger( cursorAdjacentToMention ? null - : detectComposerTrigger( - nextPrompt, - expandCollapsedComposerCursor(nextPrompt, nextCursor), - ), + : detectComposerTriggerFromSnapshot({ + value: nextPrompt, + cursor: nextCursor, + expandedCursor: nextExpandedCursor, + }), ); }, [ diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 96efc0fbf..4e020bf9b 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -176,9 +176,14 @@ function clampCursor(value: string, cursor: number): number { return Math.max(0, Math.min(value.length, Math.floor(cursor))); } -function getComposerNodeTextLength(node: LexicalNode): number { +type ComposerCursorMeasureMode = "collapsed" | "expanded"; + +function getComposerNodeTextLength( + node: LexicalNode, + mode: ComposerCursorMeasureMode = "collapsed", +): number { if (node instanceof ComposerMentionNode) { - return 1; + return mode === "collapsed" ? 1 : node.getTextContentSize(); } if ($isTextNode(node)) { return node.getTextContentSize(); @@ -187,12 +192,18 @@ function getComposerNodeTextLength(node: LexicalNode): number { return 1; } if ($isElementNode(node)) { - return node.getChildren().reduce((total, child) => total + getComposerNodeTextLength(child), 0); + return node + .getChildren() + .reduce((total, child) => total + getComposerNodeTextLength(child, mode), 0); } return 0; } -function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { +function getAbsoluteOffsetForPoint( + node: LexicalNode, + pointOffset: number, + mode: ComposerCursorMeasureMode = "collapsed", +): number { let offset = 0; let current: LexicalNode | null = node; @@ -206,14 +217,17 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb for (let i = 0; i < index; i += 1) { const sibling = siblings[i]; if (!sibling) continue; - offset += getComposerNodeTextLength(sibling); + offset += getComposerNodeTextLength(sibling, mode); } current = nextParent; } if ($isTextNode(node)) { if (node instanceof ComposerMentionNode) { - return offset + (pointOffset > 0 ? 1 : 0); + if (mode === "collapsed") { + return offset + (pointOffset > 0 ? 1 : 0); + } + return offset + Math.min(pointOffset, node.getTextContentSize()); } return offset + Math.min(pointOffset, node.getTextContentSize()); } @@ -228,7 +242,7 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb for (let i = 0; i < clampedOffset; i += 1) { const child = children[i]; if (!child) continue; - offset += getComposerNodeTextLength(child); + offset += getComposerNodeTextLength(child, mode); } return offset; } @@ -317,10 +331,10 @@ function findSelectionPointAtOffset( return null; } -function $getComposerRootLength(): number { +function $getComposerRootLength(mode: ComposerCursorMeasureMode = "collapsed"): number { const root = $getRoot(); const children = root.getChildren(); - return children.reduce((sum, child) => sum + getComposerNodeTextLength(child), 0); + return children.reduce((sum, child) => sum + getComposerNodeTextLength(child, mode), 0); } function $setSelectionAtComposerOffset(nextOffset: number): void { @@ -339,14 +353,17 @@ function $setSelectionAtComposerOffset(nextOffset: number): void { $setSelection(selection); } -function $readSelectionOffsetFromEditorState(fallback: number): number { +function $readSelectionOffsetFromEditorState( + fallback: number, + mode: ComposerCursorMeasureMode = "collapsed", +): number { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) { return fallback; } const anchorNode = selection.anchor.getNode(); - const offset = getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); - const composerLength = $getComposerRootLength(); + const offset = getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset, mode); + const composerLength = $getComposerRootLength(mode); return Math.max(0, Math.min(offset, composerLength)); } @@ -383,7 +400,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 +409,12 @@ interface ComposerPromptEditorProps { disabled: boolean; placeholder: string; className?: string; - onChange: (nextValue: string, nextCursor: number, cursorAdjacentToMention: boolean) => void; + onChange: ( + nextValue: string, + nextCursor: number, + cursorAdjacentToMention: boolean, + nextExpandedCursor: number, + ) => void; onCommandKeyDown?: ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent, @@ -628,7 +650,11 @@ function ComposerPromptEditorInner({ }: ComposerPromptEditorInnerProps) { const [editor] = useLexicalComposerContext(); const onChangeRef = useRef(onChange); - const snapshotRef = useRef({ value, cursor: clampCursor(value, cursor) }); + const snapshotRef = useRef({ + value, + cursor: clampCursor(value, cursor), + expandedCursor: clampCursor(value, cursor), + }); useEffect(() => { onChangeRef.current = onChange; @@ -651,7 +677,11 @@ function ComposerPromptEditorInner({ }); } - snapshotRef.current = { value, cursor: normalizedCursor }; + snapshotRef.current = { + value, + cursor: normalizedCursor, + expandedCursor: clampCursor(value, normalizedCursor), + }; const rootElement = editor.getRootElement(); if (!rootElement || document.activeElement !== rootElement) { @@ -668,31 +698,47 @@ function ComposerPromptEditorInner({ const rootElement = editor.getRootElement(); if (!rootElement) return; const boundedCursor = clampCursor(snapshotRef.current.value, nextCursor); + let nextExpandedCursor = clampCursor(snapshotRef.current.value, boundedCursor); rootElement.focus(); editor.update(() => { $setSelectionAtComposerOffset(boundedCursor); + nextExpandedCursor = clampCursor( + snapshotRef.current.value, + $readSelectionOffsetFromEditorState(nextExpandedCursor, "expanded"), + ); }); snapshotRef.current = { value: snapshotRef.current.value, cursor: boundedCursor, + expandedCursor: nextExpandedCursor, }; - onChangeRef.current(snapshotRef.current.value, boundedCursor, false); + onChangeRef.current(snapshotRef.current.value, boundedCursor, false, nextExpandedCursor); }, [editor], ); - const readSnapshot = useCallback((): { value: string; cursor: number } => { + const readSnapshot = useCallback((): { + value: string; + cursor: number; + expandedCursor: number; + } => { let snapshot = snapshotRef.current; editor.getEditorState().read(() => { const nextValue = $getRoot().getTextContent(); - const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor); + const fallbackCollapsedCursor = clampCursor(nextValue, snapshotRef.current.cursor); + const fallbackExpandedCursor = clampCursor(nextValue, snapshotRef.current.expandedCursor); const nextCursor = clampCursor( nextValue, - $readSelectionOffsetFromEditorState(fallbackCursor), + $readSelectionOffsetFromEditorState(fallbackCollapsedCursor, "collapsed"), + ); + const nextExpandedCursor = clampCursor( + nextValue, + $readSelectionOffsetFromEditorState(fallbackExpandedCursor, "expanded"), ); snapshot = { value: nextValue, cursor: nextCursor, + expandedCursor: nextExpandedCursor, }; }); snapshotRef.current = snapshot; @@ -719,23 +765,33 @@ function ComposerPromptEditorInner({ const handleEditorChange = useCallback((editorState: EditorState) => { editorState.read(() => { const nextValue = $getRoot().getTextContent(); - const fallbackCursor = clampCursor(nextValue, snapshotRef.current.cursor); + const fallbackCollapsedCursor = clampCursor(nextValue, snapshotRef.current.cursor); + const fallbackExpandedCursor = clampCursor(nextValue, snapshotRef.current.expandedCursor); const nextCursor = clampCursor( nextValue, - $readSelectionOffsetFromEditorState(fallbackCursor), + $readSelectionOffsetFromEditorState(fallbackCollapsedCursor, "collapsed"), + ); + const nextExpandedCursor = clampCursor( + nextValue, + $readSelectionOffsetFromEditorState(fallbackExpandedCursor, "expanded"), ); 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") || isCollapsedCursorAdjacentToMention(nextValue, nextCursor, "right"); - onChangeRef.current(nextValue, nextCursor, cursorAdjacentToMention); + onChangeRef.current(nextValue, nextCursor, cursorAdjacentToMention, nextExpandedCursor); }); }, []); diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 7e6805c96..e5b618e82 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { detectComposerTrigger, + detectComposerTriggerFromSnapshot, expandCollapsedComposerCursor, isCollapsedCursorAdjacentToMention, parseStandaloneComposerSlashCommand, @@ -58,6 +59,25 @@ describe("detectComposerTrigger", () => { }); }); +describe("detectComposerTriggerFromSnapshot", () => { + it("uses the editor expanded cursor for revisited @tokens", () => { + const text = "@HEAD please review @src/components before sending"; + + expect( + detectComposerTriggerFromSnapshot({ + value: text, + cursor: 31, + expandedCursor: "@HEAD please review @src/components".length, + }), + ).toEqual({ + kind: "path", + query: "src/components", + rangeStart: "@HEAD please review ".length, + rangeEnd: "@HEAD please review @src/components".length, + }); + }); +}); + describe("replaceTextRange", () => { it("replaces a text range and returns new cursor", () => { const replaced = replaceTextRange("hello @src", 6, 10, ""); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 70b3567c3..e56260862 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -10,6 +10,12 @@ export interface ComposerTrigger { rangeEnd: number; } +export interface ComposerTriggerSnapshot { + value: string; + cursor: number; + expandedCursor?: number; +} + const SLASH_COMMANDS: readonly ComposerSlashCommand[] = ["model", "plan", "default"]; function clampCursor(text: string, cursor: number): number { @@ -162,6 +168,14 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos }; } +export function detectComposerTriggerFromSnapshot( + snapshot: ComposerTriggerSnapshot, +): ComposerTrigger | null { + const triggerCursor = + snapshot.expandedCursor ?? expandCollapsedComposerCursor(snapshot.value, snapshot.cursor); + return detectComposerTrigger(snapshot.value, triggerCursor); +} + export function parseStandaloneComposerSlashCommand( text: string, ): Exclude | null { diff --git a/apps/web/src/composer-path-menu.test.ts b/apps/web/src/composer-path-menu.test.ts new file mode 100644 index 000000000..6590f4219 --- /dev/null +++ b/apps/web/src/composer-path-menu.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { resolveComposerPathMenuEntries } from "./composer-path-menu"; + +describe("resolveComposerPathMenuEntries", () => { + const entries = [{ path: "src/helpers.ts" }, { path: "src/index.ts" }] as const; + + it("hides entries for an empty @query", () => { + expect( + resolveComposerPathMenuEntries({ + query: "", + isDebouncing: false, + isFetching: false, + isLoading: false, + entries, + }), + ).toEqual([]); + }); + + it("hides entries while the next query is debouncing", () => { + expect( + resolveComposerPathMenuEntries({ + query: "ssh", + isDebouncing: true, + isFetching: false, + isLoading: false, + entries, + }), + ).toEqual([]); + }); + + it("hides entries while the next query is fetching", () => { + expect( + resolveComposerPathMenuEntries({ + query: "ssh", + isDebouncing: false, + isFetching: true, + isLoading: false, + entries, + }), + ).toEqual([]); + }); + + it("returns entries once the query settles", () => { + expect( + resolveComposerPathMenuEntries({ + query: "ssh", + isDebouncing: false, + isFetching: false, + isLoading: false, + entries, + }), + ).toBe(entries); + }); +}); diff --git a/apps/web/src/composer-path-menu.ts b/apps/web/src/composer-path-menu.ts new file mode 100644 index 000000000..98b878dd1 --- /dev/null +++ b/apps/web/src/composer-path-menu.ts @@ -0,0 +1,15 @@ +export function resolveComposerPathMenuEntries(input: { + query: string; + isDebouncing: boolean; + isFetching: boolean; + isLoading: boolean; + entries: readonly T[]; +}): readonly T[] { + if (input.query.trim().length === 0) { + return []; + } + if (input.isDebouncing || input.isFetching || input.isLoading) { + return []; + } + return input.entries; +}