Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ComposerCommandItem[]>(() => {
if (!composerTrigger) return [];
if (composerTrigger.kind === "path") {
Expand Down Expand Up @@ -3101,6 +3109,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
nextCursor,
expandedCursor,
cursorAdjacentToMention,
nextExpandedCursor,
);
return;
}
Expand Down
51 changes: 38 additions & 13 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
}
Expand Down Expand Up @@ -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());
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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));
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/composer-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
clampCollapsedComposerCursor,
collapseExpandedComposerCursor,
detectComposerTrigger,
detectComposerTriggerFromSnapshot,
expandCollapsedComposerCursor,
isCollapsedCursorAdjacentToMention,
parseStandaloneComposerSlashCommand,
Expand Down Expand Up @@ -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, "");
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/composer-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ComposerSlashCommand, "model"> | null {
Expand Down
55 changes: 55 additions & 0 deletions apps/web/src/composer-path-menu.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
15 changes: 15 additions & 0 deletions apps/web/src/composer-path-menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export function resolveComposerPathMenuEntries<T>(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;
}