diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9f625762c..e609a6960 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -36,12 +36,14 @@ import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib 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 { clampCollapsedComposerCursor, type ComposerTrigger, collapseExpandedComposerCursor, detectComposerTrigger, + detectComposerTriggerFromSnapshot, expandCollapsedComposerCursor, parseStandaloneComposerSlashCommand, replaceTextRange, @@ -921,7 +923,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") { @@ -3101,6 +3109,7 @@ export default function ChatView({ threadId }: ChatViewProps) { nextCursor, expandedCursor, cursorAdjacentToMention, + nextExpandedCursor, ); return; } diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index ab68f1fcb..ad0810ede 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -181,9 +181,14 @@ function clampExpandedCursor(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(); @@ -192,7 +197,9 @@ 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; } @@ -226,14 +233,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()); } @@ -248,7 +258,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; } @@ -381,10 +391,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 { @@ -403,14 +413,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)); } @@ -766,6 +779,10 @@ function ComposerPromptEditorInner({ rootElement.focus(); editor.update(() => { $setSelectionAtComposerOffset(boundedCursor); + nextExpandedCursor = clampCursor( + snapshotRef.current.value, + $readSelectionOffsetFromEditorState(nextExpandedCursor, "expanded"), + ); }); snapshotRef.current = { value: snapshotRef.current.value, @@ -793,7 +810,11 @@ function ComposerPromptEditorInner({ const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); const nextCursor = clampCollapsedComposerCursor( nextValue, - $readSelectionOffsetFromEditorState(fallbackCursor), + $readSelectionOffsetFromEditorState(fallbackCollapsedCursor, "collapsed"), + ); + const nextExpandedCursor = clampCursor( + nextValue, + $readSelectionOffsetFromEditorState(fallbackExpandedCursor, "expanded"), ); const fallbackExpandedCursor = clampExpandedCursor( nextValue, @@ -839,7 +860,11 @@ function ComposerPromptEditorInner({ const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); const nextCursor = clampCollapsedComposerCursor( nextValue, - $readSelectionOffsetFromEditorState(fallbackCursor), + $readSelectionOffsetFromEditorState(fallbackCollapsedCursor, "collapsed"), + ); + const nextExpandedCursor = clampCursor( + nextValue, + $readSelectionOffsetFromEditorState(fallbackExpandedCursor, "expanded"), ); const fallbackExpandedCursor = clampExpandedCursor( nextValue, diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 36532e904..87d4f1234 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -4,6 +4,7 @@ import { clampCollapsedComposerCursor, collapseExpandedComposerCursor, detectComposerTrigger, + detectComposerTriggerFromSnapshot, expandCollapsedComposerCursor, isCollapsedCursorAdjacentToMention, parseStandaloneComposerSlashCommand, @@ -100,6 +101,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 b696d8038..4190d6fca 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 { @@ -204,6 +210,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; +}