Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
8 changes: 6 additions & 2 deletions apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
collapseExpandedComposerCursor,
expandCollapsedComposerCursor,
isCollapsedCursorAdjacentToInlineToken,
pastedPathQueryLength,
} from "~/composer-logic";
import {
collectComposerPromptInlineTokens,
Expand Down Expand Up @@ -1412,6 +1413,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
const offset = flatToMarkdown(map, pmToFlat(map, view.state.selection.from));
if (offset > 0 && !/\s/.test(map.value[offset - 1]!)) text = ` ${text}`;
}
const { $from } = view.state.selection;
const lineBefore = $from.parent.textBetween(0, $from.parentOffset, undefined, "\uFFFC");
const literalLength = literalText ? 0 : pastedPathQueryLength(lineBefore, text);
const editorInstance = editorHolder.current;
if (editorInstance) {
// Inside a list item or quote, pasted block markup has nowhere to
Expand All @@ -1424,7 +1428,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
insertMarkdownParagraphs(
text,
skillLabelFor,
{ styling: richText, blocks: !nested, literalText },
{ styling: richText, blocks: !nested, literalText, literalLength },
(content) => {
// Tagged on the same transaction insertContent builds, so the
// paste is one undo step of its own.
Expand Down Expand Up @@ -1815,7 +1819,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
function insertMarkdownParagraphs(
value: string,
skillLabelFor: (name: string) => SkillMeta,
options: { styling: boolean; blocks?: boolean; literalText?: boolean },
options: { styling: boolean; blocks?: boolean; literalText?: boolean; literalLength?: number },
insertContent: (content: JSONContent[] | JSONContent) => void,
): void {
const blocks = buildTiptapContent(value, skillLabelFor, options);
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/composer-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,17 @@ export function detectComposerTrigger(text: string, cursorInput: number): Compos
};
}

/**
* Length of the leading part of `pasted` that continues an `@` path query
* typed at the end of `lineBefore`, the line's plain text up to the caret. A
* chip must read there as one non-space character, so it never passes for a
* typed `@path`.
*/
export function pastedPathQueryLength(lineBefore: string, pasted: string): number {
if (detectComposerTrigger(lineBefore, lineBefore.length)?.kind !== "path") return 0;
return /^\S*/.exec(pasted)![0].length;
}

/** Caret and trigger after replacing composer text and continuing at the end. */
export function composerStateAtPromptEnd(
text: string,
Expand Down
84 changes: 83 additions & 1 deletion apps/web/src/composer-rich-text-doc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { EditorState, TextSelection } from "@tiptap/pm/state";
import { splitBlockKeepMarks } from "@tiptap/pm/commands";
import { describe, expect, it } from "vite-plus/test";

import { collapseExpandedComposerCursor } from "./composer-logic";
import { collapseExpandedComposerCursor, pastedPathQueryLength } from "./composer-logic";

import {
buildDocJson,
Expand Down Expand Up @@ -942,6 +942,88 @@ describe("pasting into a list item or quote", () => {
});
});

describe("pasting into a path query", () => {
const skillLabelFor = (name: string) => ({ label: name, description: null });

// Inserts `pasted` at the end of a one-line prompt the way the composer's
// paste handler does. `prompt` may be a document, to place the caret after a chip.
function paste(prompt: string | ProseMirrorNode, pasted: string) {
const promptDoc =
typeof prompt === "string"
? ProseMirrorNode.fromJSON(schema, buildDocJson(prompt, skillLabelFor))
: prompt;
const line = promptDoc.lastChild!;
const lineBefore = line.textBetween(0, line.content.size, undefined, "\uFFFC");
const literalLength = pastedPathQueryLength(lineBefore, pasted);
const blocks = buildTiptapContent(pasted, skillLabelFor, { literalLength });
const [first, ...rest] = blocks.map((block) => ProseMirrorNode.fromJSON(schema, block));
const head = promptDoc.lastChild!.content.append(first!.content);
const doc = schema.node("doc", null, [first!.copy(head), ...rest]);
doc.check();
return doc;
}

function boldText(doc: ProseMirrorNode) {
const bold: string[] = [];
doc.descendants((node) => {
if (node.isText && node.marks.some((mark) => mark.type.name === "bold")) {
bold.push(node.text!);
}
});
return bold;
}

it.each(["src/__test__.tsx", "src/__tests__/a.ts", "pkg/__init__.py", "a/**b**/c"])(
"keeps %s literal after @ so file search can match it",
(path) => {
const doc = paste("Open @", path);
expect(boldText(doc)).toEqual([]);
expect(serializeEditorDoc(doc).value).toBe(`Open @${path}`);
},
);

it("still styles the text after the path", () => {
const doc = paste("Open @", "src/__test__.tsx and **this**\n- [ ] then __that__");
expect(boldText(doc)).toEqual(["this", "that"]);
expect(doc.lastChild!.type.name).toBe("taskList");
expect(serializeEditorDoc(doc).value).toBe(
"Open @src/__test__.tsx and **this**\n- [ ] then **that**",
);
});

it("keeps the rest of the path's line on that line", () => {
const doc = paste("Open @", "src/a.ts - not a list item");
expect(doc.childCount).toBe(1);
expect(serializeEditorDoc(doc).value).toBe("Open @src/a.ts - not a list item");
});

it("parses markdown when the paste does not continue a path query", () => {
expect(boldText(paste("Open ", "src/__test__.tsx"))).toEqual(["test"]);
expect(boldText(paste("Open @a ", "__b__"))).toEqual(["b"]);
expect(boldText(paste("Open @", " __b__"))).toEqual(["b"]);
});

it("keeps a path literal after a partly styled query", () => {
const doc = paste("Open @**src/**", "__test__.tsx");
expect(boldText(doc)).toEqual(["src/"]);
expect(serializeEditorDoc(doc).value).toBe("Open @**src/**__test__.tsx");
});

it("parses markdown pasted right after a mention chip", () => {
const withSpace = ProseMirrorNode.fromJSON(
schema,
buildDocJson("Open @README.md ", skillLabelFor),
);
const paragraph = withSpace.firstChild!;
expect(paragraph.lastChild!.text).toBe(" ");
const chipLast = schema.node("doc", null, [
paragraph.copy(paragraph.content.cut(0, paragraph.content.size - 1)),
]);
expect(chipLast.firstChild!.lastChild!.type.name).toBe("composer-mention");
expect(boldText(paste(chipLast, "__b__"))).toEqual(["b"]);
});
});

describe("caret stops at styled edges", () => {
function stateAt(value: string, pmPos: number) {
const doc = ProseMirrorNode.fromJSON(
Expand Down
18 changes: 16 additions & 2 deletions apps/web/src/composer-rich-text-doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,10 +534,15 @@ function textJsonForSpan(text: string, marks: RichTextMark[]): Record<string, un
return json;
}

/**
* Document content for a prompt. `literalLength` keeps that many leading
* characters as plain text, for a paste that continues a path query: markers
* inside a path such as `src/__init__.py` must reach file search unstyled.
*/
export function buildTiptapContent(
value: string,
skillLabelFor: (name: string) => SkillMeta,
options?: { styling?: boolean; blocks?: boolean; literalText?: boolean },
options?: { styling?: boolean; blocks?: boolean; literalText?: boolean; literalLength?: number },
): Record<string, unknown>[] {
// Editor answers are verbatim text, including Markdown and context-token sources.
if (options?.literalText) {
Expand All @@ -560,7 +565,8 @@ export function buildTiptapContent(
// Code fences hold no chips, so their lines put the original source back in
// place of the sentinel rather than building an atom for it.
const atomSources: string[] = [];
const text = splitPromptIntoComposerSegments(value)
const literal = value.slice(0, options?.literalLength ?? 0);
const text = splitPromptIntoComposerSegments(value.slice(literal.length))
.map((segment) => {
if (segment.type === "text") return segment.text;
atoms.push(atomJsonForSegment(segment, skillLabelFor));
Expand Down Expand Up @@ -608,6 +614,14 @@ export function buildTiptapContent(

for (let index = 0; index < sourceLines.length; index += 1) {
const line = sourceLines[index]!;
// The rest of the literal's line continues the text before the caret, so
// it cannot open a block.
if (index === 0 && literal) {
entries.push({
line: { list: null, inline: [textJsonForSpan(literal, []), ...buildInline(line)] },
});
continue;
}
const opening = blockSyntax ? parseOpeningFence(line) : null;
if (!opening) {
// A thematic break outranks a list: `- - -` and `* * *` are rules, and
Expand Down
Loading