Skip to content
Closed
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
23 changes: 23 additions & 0 deletions apps/web/src/composer-rich-text-doc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { EditorState, TextSelection } from "@tiptap/pm/state";
import { splitBlockKeepMarks } from "@tiptap/pm/commands";
import { describe, expect, it, onTestFinished } from "vite-plus/test";
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";

import {
collapseExpandedComposerCursor,
Expand Down Expand Up @@ -91,6 +92,28 @@ function roundTrip(value: string) {
}

describe("editable math source", () => {
it.each(["app/routes/posts.$postId.tsx", "pages/posts/[id].tsx"])(
"keeps a mentioned file whose name looks like TeX as a chip: %s",
(path) => {
const source = `fix ${serializeComposerFileLink(path)} please`;
const map = roundTrip(source);
expect(map.value).toBe(source);
expect(
map.runs.filter((run) => run.kind === "token" && run.nodeName === "composer-mention"),
).toHaveLength(1);
},
);

it.each([
"before $x + @README.md$ after",
"before \\(x + @README.md\\) after",
"\\[x + @README.md\\] after",
])("keeps a mention that ends a formula inside the formula: %s", (source) => {
const map = roundTrip(source);
expect(map.value).toBe(source);
expect(map.runs.some((run) => run.kind === "token")).toBe(false);
});

it.each([
"Area $x_i^2$ after",
"before \\(\\frac{1}{2}\\) after",
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/composer-rich-text-doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions";
import { nextOrderedMarkerText } from "~/composer-list-continuation";
import { parseInlineMarkdown, RICH_TEXT_DELIMITERS, type RichTextMark } from "~/composer-rich-text";
import { collectInlineContextIds } from "~/lib/composerContextReferences";
import { markdownMathRanges } from "@t3tools/shared/markdownMath";
import { composerMathRanges } from "@t3tools/shared/composerInlineTokens";

export type SkillMeta = { label: string; description: string | null };

Expand Down Expand Up @@ -576,7 +576,7 @@ export function buildTiptapContent(
const atomSources: string[] = [];
const literal = value.slice(0, options?.literalLength ?? 0);
const remainder = value.slice(literal.length);
const formulas = markdownMathRanges(remainder);
const formulas = composerMathRanges(remainder);
let offset = 0;
const chunks: Array<{ text: string } | { source: string }> = [];
for (const formula of formulas) {
Expand Down
45 changes: 44 additions & 1 deletion packages/shared/src/composerInlineTokens.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vite-plus/test";

import { collectComposerInlineTokens } from "./composerInlineTokens.ts";
import { collectComposerInlineTokens, composerMathRanges } from "./composerInlineTokens.ts";
import { serializeComposerFileLink } from "./composerTrigger.ts";

describe("collectComposerInlineTokens", () => {
it("collects file links, mentions, and skills with source ranges", () => {
Expand Down Expand Up @@ -176,4 +177,46 @@ describe("collectComposerInlineTokens", () => {
"$real-skill",
]);
});

it.each([
"app/routes/posts.$postId.tsx",
"src/routes/_chat.$environmentId.$threadId.tsx",
"pages/posts/[id].tsx",
"app/blog/[...slug].tsx",
])("keeps a file link whose name looks like TeX as one mention: %s", (path) => {
const link = serializeComposerFileLink(path);
expect(collectComposerInlineTokens(`fix ${link} please`)).toMatchObject([
{ type: "mention", value: path, source: link },
]);
});

it.each(["app/routes/posts.$postId.tsx", "pages/posts/[id].tsx"])(
"preserves a confirmed TeX-like pill when its trailing delimiter is removed: %s",
(path) => {
const link = serializeComposerFileLink(path);
const confirmed = collectComposerInlineTokens(`fix ${link} `);

expect(confirmed).toMatchObject([{ type: "mention", value: path, source: link }]);
expect(
collectComposerInlineTokens(`fix ${link}`, { preserveTrailingFrom: confirmed }),
).toEqual(confirmed);
expect(collectComposerInlineTokens(`fix ${link}`)).toEqual([]);
},
);

it.each([
"before $x + @README.md$ after",
"before \\(x + @README.md\\) after",
"\\[x + @README.md\\] after",
"\\[\\text{use @README.md}\\] then go ",
])("keeps a mention that ends a formula inside the formula: %s", (source) => {
expect(collectComposerInlineTokens(source)).toEqual([]);
});

it("still finds a formula after a mention that starts the line", () => {
const link = serializeComposerFileLink("app/routes/posts.$postId.tsx");
expect(composerMathRanges(`${link} sets $x_i$`).map((formula) => formula.source)).toEqual([
"$x_i$",
]);
});
});
29 changes: 28 additions & 1 deletion packages/shared/src/composerInlineTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,32 @@ function collectMentionTokens(text: string): ComposerInlineToken[] {
return matches;
}

/**
* Formulas in composer text, ignoring the `$` and `\[` that file names such as
* `posts.$postId.tsx` or `[id].tsx` put into a file link. File links are masked with letters,
* not spaces, so a link at the start of a line cannot become an indented code block.
* Bare `@path` mentions are not masked: one such as `@README.md$` can run into a formula's
* closing delimiter, and a mention written inside a real formula stays covered by it.
*/
export function composerMathRanges(
text: string,
mentions: ReadonlyArray<ComposerInlineToken> = collectMentionTokens(text),
) {
let masked = text;
for (const mention of mentions) {
if (mention.type !== "mention" || !mention.source.startsWith("[")) continue;
masked =
masked.slice(0, mention.start) +
"x".repeat(mention.end - mention.start) +
masked.slice(mention.end);
}
return markdownMathRanges(masked).map(({ start, end }) => ({
start,
end,
source: text.slice(start, end),
}));
}

export function collectComposerInlineTokens(
text: string,
options: CollectComposerInlineTokensOptions = {},
Expand Down Expand Up @@ -138,7 +164,8 @@ export function collectComposerInlineTokens(

// TeX can contain literal skill and file tokens, especially inside \text{}.
// They remain formula source rather than becoming composer context chips.
const math = matches.length > 0 ? markdownMathRanges(text) : [];
// Confirmed trailing file links are masked too, so a kept chip is not read as TeX.
const math = matches.length > 0 ? composerMathRanges(text, matches) : [];
return matches
.filter(
(match) => !math.some((formula) => match.start < formula.end && match.end > formula.start),
Expand Down
Loading