Skip to content
Merged
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
time took it for a shell: it opened the terminal instead of the chat, and resized the
terminal shared with the other devices.
([#537](https://github.com/devswha/herdr-web-ui/pull/537))
- A message can no longer freeze or crash the chat by its shape alone. A long run of underscores
nested emphasis until the page ran out of stack; a URL followed by thousands of closing
parentheses, a formula opened on many lines and never closed, a long blank line under a table
header, a list item of thousands of lines, and a heading or list item holding a line-separator
character after a long run of spaces each took seconds to read; a line of plus signs took half
a second, and a formula of thousands of nested fractions a second more. Each is read in a few
milliseconds now. Emphasis stops nesting after sixteen levels, and a formula nested deeper than
a hundred braces shows as its source.
([#547](https://github.com/devswha/herdr-web-ui/pull/547))
- A workspace row's state rolls up as herdr's does: a workspace with one finished agent and one
still running shows DONE, where it showed RUN.
([#521](https://github.com/devswha/herdr-web-ui/pull/521))
Expand Down
4 changes: 2 additions & 2 deletions src/components/Markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Check, Copy } from "lucide-react";

import { foldCode, parseMarkdown, type InlineNode, type ListBlock, type MarkdownBlock } from "../lib/markdown.ts";
import { foldCode, mathNestsTooDeep, parseMarkdown, type InlineNode, type ListBlock, type MarkdownBlock } from "../lib/markdown.ts";
import { codeIsFilePath, OpenFileContext, splitFilePaths } from "../lib/filePaths.ts";
import { fileUriPath } from "../lib/terminalFileLinks.ts";
import { useT } from "../lib/i18n.ts";
Expand Down Expand Up @@ -33,7 +33,7 @@ function useKatex(): Katex | null {
function MathExpression({ value, displayMode = false }: { value: string; displayMode?: boolean }) {
const katex = useKatex();
const source = displayMode ? `\\[${value}\\]` : `\\(${value}\\)`;
if (!katex) return <span>{source}</span>;
if (!katex || mathNestsTooDeep(value)) return <span>{source}</span>;
try {
// KaTeX escapes text and rejects untrusted commands by default.
const html = katex.renderToString(value, { displayMode, strict: "ignore" });
Expand Down
11 changes: 11 additions & 0 deletions src/lib/filePaths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ describe("file paths in chat text", () => {
expect(paths("and/or, e.g. v1.2.3, 1.0/2.5, README.md, TCP/IP")).toEqual([]);
});

it("starts a path once in a run of plus signs, not at every one", () => {
// each `+` is a character a path may hold: tried from every one of them, a line of 48,000 takes half a second
const start = performance.now();
expect(splitFilePaths("+".repeat(48_000))).toEqual(["+".repeat(48_000)]);
expect(splitFilePaths("+/".repeat(24_000))).toHaveLength(1);
expect(performance.now() - start).toBeLessThan(250);
// a plus sign before or inside a path reads as it did
expect(paths("+src/app.ts and c++/x/y.cc, a+b/c.ts")).toEqual(["+src/app.ts", "c++/x/y.cc", "a+b/c.ts"]);
expect(splitFilePaths("+~/notes/a.md")).toEqual(["+", { path: "~/notes/a.md" }]);
});

it("takes a code span that is one file name or path", () => {
for (const code of ["README.md", "src/app.ts", "~/x/y.png", "/tmp/a.log", "docs/demo.mp4"]) expect(codeIsFilePath(code)).toBe(true);
for (const code of ["bun test", "1.2.3", "git log --oneline", "a/b", "foo()", "x.y()", "tool.monitor", "Math.random", "process.env", "os.path", "process.env.HOME", "console.log", "JSON.parse"]) expect(codeIsFilePath(code)).toBe(false);
Expand Down
5 changes: 4 additions & 1 deletion src/lib/filePaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import { createContext } from "react";
* like "and/or" or "e.g." stay text; code spans need only look like one file name.
*/

const BARE_PATH = /(?<![\w/.@~-])((?:~\/|\.{1,2}\/|\/)?(?:[\w@.+-]+\/)+[\w@+-][\w@.+-]*\.[A-Za-z0-9]{1,8})(?![\w/])/g;
// a path starts where a run of its characters starts, `+` among them: a line of plus signs is
// otherwise tried as a path from every one of them, to its end. `~/` still starts one after a
// plus sign (`+~/notes/a.md`, a line of a diff)
const BARE_PATH = /(?<![\w/.@~-])(?<!\+(?!~))((?:~\/|\.{1,2}\/|\/)?(?:[\w@.+-]+\/)+[\w@+-][\w@.+-]*\.[A-Za-z0-9]{1,8})(?![\w/])/g;
const CODE_PATH = /^(?:~\/|\.{1,2}\/|\/)?(?:[\w@.+-]+\/)*[\w@+-][\w@.+-]*\.[A-Za-z0-9]{1,8}$/;

/**
Expand Down
138 changes: 137 additions & 1 deletion src/lib/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { loadKatex, Markdown } from "../components/Markdown.tsx";
import { SettingsProvider } from "./settings.ts";
import { FOLD_CODE_AFTER_LINES, FOLDED_CODE_LINES, foldCode, parseInline, parseMarkdown, safeMarkdownHref, type InlineNode, type ListBlock } from "./markdown.ts";
import { FOLD_CODE_AFTER_LINES, FOLDED_CODE_LINES, foldCode, isTableSeparator, mathNestsTooDeep, parseInline, parseMarkdown, safeMarkdownHref, trimUrl, type InlineNode, type ListBlock } from "./markdown.ts";

describe("parseMarkdown", () => {
it("renders inline and display math while leaving fenced code untouched", async () => {
Expand Down Expand Up @@ -218,6 +218,142 @@ describe("foldCode", () => {
});
});

describe("text no one wrote by hand", () => {
// what an agent prints, or a changelog holds, is not always prose: the page parses it while it
// draws, so a line that takes seconds freezes the tab, and one that nests without end crashes it
const within = (milliseconds: number, run: () => void): void => {
const start = performance.now();
run();
expect(performance.now() - start).toBeLessThan(milliseconds);
};
/** every string of up to `length` of the `parts`, in order */
const everyLine = (parts: string[], length: number): string[] => {
let lines = [""];
const all = [""];
for (let step = 0; step < length; step += 1) {
lines = lines.flatMap((line) => parts.map((part) => line + part));
all.push(...lines);
}
return all;
};

it("cuts a URL's closing parentheses as it did, counting them once", () => {
// the rule as it was written first: both counts taken again at every closing parenthesis
const asBefore = (url: string): string => {
let end = url.length;
for (;;) {
const last = url[end - 1];
if (last !== undefined && ".,:;!?'\"*_~".includes(last)) { end -= 1; continue; }
if (last === ")") {
const text = url.slice(0, end);
if (text.split(")").length > text.split("(").length) { end -= 1; continue; }
}
break;
}
return url.slice(0, end);
};
const urls = everyLine(["a", "(", ")", ".", "_"], 6).map((tail) => `https://x.dev/${tail}`);
expect(urls.length).toBeGreaterThan(19_000);
for (const url of urls) expect(trimUrl(url)).toBe(asBefore(url));
within(1000, () => expect(trimUrl(`https://x.dev/${")".repeat(48_000)}`)).toBe("https://x.dev/"));
within(1000, () => parseMarkdown(`https://x.dev/${")".repeat(48_000)}`));
});

it("knows a table's separator line as it did, cell by cell", () => {
// the one pattern it was: it shares a run of spaces between its parts in every way there is
const asBefore = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/;
const lines = everyLine([" ", "|", "---", "--", ":", "x"], 6);
expect(lines.length).toBeGreaterThan(55_000);
let separators = 0;
for (const line of lines) {
expect(`${line}: ${isTableSeparator(line)}`).toBe(`${line}: ${asBefore.test(line)}`);
if (isTableSeparator(line)) separators += 1;
}
expect(separators).toBeGreaterThan(100);
for (const line of ["|---|---|", "---|---", " | :--- | ---: | :---: | ", "\t|---\t|\t---|\t"]) expect(isTableSeparator(line)).toBe(true);
for (const line of ["", "---", "|---|", "||---|---", "|---|---||", "|--|--|", "|---|-x-|"]) expect(isTableSeparator(line)).toBe(false);
within(1000, () => parseMarkdown(`a|b\n${" ".repeat(96_000)}x`));
within(1000, () => parseMarkdown(`a|b\n---|---${" ".repeat(96_000)}x`));
});

it("looks once for the end of a formula that never closes", () => {
within(1000, () => {
const blocks = parseMarkdown("\\[\n".repeat(32_000));
expect(blocks).toHaveLength(32_000);
expect(blocks.every((block) => block.type === "paragraph")).toBe(true);
});
// the search ends at a fence: what opens after it is looked for again
expect(parseMarkdown("\\[ a\n\\[ b\n```\ncode\n```\n\\[ c \\]\n\\[ d").map((block) => block.type))
.toEqual(["paragraph", "paragraph", "code", "math", "paragraph"]);
});

it("adds an item's lines to it without copying the item at every line", () => {
within(1000, () => {
const [list] = parseMarkdown(`- a\n${" x\n".repeat(48_000)}`) as [ListBlock];
expect(list.items).toHaveLength(1);
expect(list.items[0]?.content).toHaveLength(96_001);
});
});

it("stops nesting emphasis after a few levels, and keeps what is inside as text", () => {
const depth = (nodes: InlineNode[]): number => {
let levels = 0;
for (let level = nodes; level.length > 0; levels += 1) {
level = level.flatMap((node) => "children" in node ? node.children : []);
}
return levels;
};
within(1000, () => {
const nodes = parseInline(`${"_".repeat(48_000)} x`);
expect(depth(nodes)).toBeLessThan(20);
// nothing is lost: what the levels left over hold is still there, as text
expect(JSON.stringify(nodes)).toContain("_".repeat(40_000));
});
// what people write nests as before
expect(parseInline("**a _b ~~c~~_**")).toEqual([{ type: "strong", children: [{ type: "text", value: "a " },
{ type: "em", children: [{ type: "text", value: "b " }, { type: "del", children: [{ type: "text", value: "c" }] }] }] }]);
expect(depth(parseInline(`${"__".repeat(8)}a${"__".repeat(8)}`))).toBe(9);
// a link around it is not a level: sixteen of emphasis read whole inside a label too
const sixteen = `${"__".repeat(16)}a\`x\`b${"__".repeat(16)}`;
expect(JSON.stringify(parseInline(sixteen))).toContain('{"type":"code","value":"x"}');
expect(JSON.stringify(parseInline(`[${sixteen}](https://x.dev)`))).toContain('{"type":"code","value":"x"}');
});

it("reads a heading or an item whose text holds a line separator, without trying every split of its spaces", () => {
// U+2028 is not a line here (only \n is), and `.` stops at it unless told otherwise
within(1000, () => {
const [heading] = parseMarkdown(`# ${" ".repeat(48_000)}a\u2028b`);
expect(heading).toEqual({ type: "heading", level: 1, content: [{ type: "text", value: "a\u2028b" }] });
});
within(1000, () => {
const [list] = parseMarkdown(`- ${" ".repeat(48_000)}a\u2028b`) as [ListBlock];
expect(list.items).toEqual([{ content: [{ type: "text", value: "a\u2028b" }] }]);
});
within(1000, () => parseMarkdown(`1. ${" ".repeat(48_000)}\u2029`));
});

it("shows a formula nested beyond reason as its source, without asking KaTeX", async () => {
for (const formula of ["x", "\\frac{1}{2}", "{".repeat(100) + "}".repeat(100), "\\{".repeat(500), "{}".repeat(5000)]) expect(mathNestsTooDeep(formula)).toBe(false);
expect(mathNestsTooDeep("{".repeat(101))).toBe(true);
const fractions = `${"\\frac{1}{".repeat(4000)}x${"}".repeat(4000)}`;
expect(mathNestsTooDeep(fractions)).toBe(true);
await loadKatex();
const languages = Object.getOwnPropertyDescriptor(navigator, "languages");
Object.defineProperty(navigator, "languages", { configurable: true, value: ["en"] });
try {
within(1000, () => {
const html = renderToStaticMarkup(createElement(SettingsProvider, { children: createElement(Markdown, { children: `\\[${fractions}\\]\n\n\\(\\frac{1}{2}\\)` }) }));
expect(html).toContain("\\frac{1}{\\frac{1}{");
// the formula beside it is still drawn
expect(html).toContain("katex-html");
});
} finally {
if (languages) Object.defineProperty(navigator, "languages", languages);
else Reflect.deleteProperty(navigator, "languages");
}
});
});

describe("numbered lists as agents write them", () => {
const render = (source: string): string => {
const languages = Object.getOwnPropertyDescriptor(navigator, "languages");
Expand Down
Loading