fix(app): preserve markdown semantics and render limits in prose runs

This commit is contained in:
Mohamed Boudra
2026-10-15 15:32:02 +02:00
parent e831814d73
commit 83061e0312
9 changed files with 109 additions and 50 deletions
@@ -124,6 +124,43 @@ function rows(result: { tail: StreamItem[]; head: StreamItem[] }): StreamItem[]
}
describe("native prose runs", () => {
it("starts another run before combined paragraphs exceed the render cap", () => {
const first = "a".repeat(20_000);
const second = "b".repeat(20_000);
const present = createStreamPresentation({ markdown: "prose-runs" });
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
for (const text of [first, "\n\n" + second.slice(0, 10_000), second.slice(10_000)]) {
state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) });
present({ ...presentationOptions, ...state, transform: undefined });
}
const live = present({ ...presentationOptions, ...state, transform: undefined });
expect([...live.tail, ...live.head]).toMatchObject([
{ id: "message-1:run:0", text: first },
{ id: "message-1:run:1", text: second },
]);
const source = hydrateStreamState([
{ event: assistant(`${first}\n\n${second}`), timestamp: new Date(1000) },
]);
expect(
present({ ...presentationOptions, tail: source, head: [], transform: undefined }).tail,
).toMatchObject([{ text: first }, { text: second }]);
});
it.each([
"![Screenshot][pic]\n\n[pic]: shots/app.png",
"- parent\n - before ![x](shots/app.png) after\n - next",
])("keeps image context in Paseo's block renderer: %s", (text) => {
const source = hydrateStreamState([{ event: assistant(text), timestamp: new Date(1000) }]);
const result = createStreamPresentation({ markdown: "prose-runs" })({
...presentationOptions,
tail: source,
head: [],
transform: undefined,
});
expect(result.tail).toMatchObject([{ text, markdownKind: "block" }]);
expect(result.tail).toHaveLength(1);
});
it("splits inline workspace images out of prose without mistaking code for images", () => {
const source = hydrateStreamState([
{
@@ -9,7 +9,7 @@ import {
type ToolCallDetailLevel,
} from "@/tool-calls/detail-level/projection";
import { groupMarkdownProse } from "@/components/markdown-prose/runs";
import { groupMarkdownProse } from "@/components/markdown-prose";
interface PresentationInput {
tail: StreamItem[];
@@ -1,2 +1,2 @@
// TypeScript's platform-neutral entry; Metro chooses the native or web implementation.
export { MarkdownProse } from "./index.web";
export { MarkdownProse } from "./renderer";
export { groupMarkdownProse } from "./runs";
@@ -9,6 +9,8 @@ describe("native prose dialect", () => {
it.each([
"Literal & and ©",
"# #",
"Setext heading\n==============",
'A [titled link](https://example.com "A title")',
"- first\n\tsecond",
"Blank ` ` code and ` normal ` code",
"# Heading\n\n- **First**\n- Second\n\n~~Deleted~~",
@@ -77,3 +79,25 @@ it("uppercases heading-six text without changing inline code or link destination
'<h6>SMALL <strong>BOLD</strong> <code>lower.ts</code> <a href="https://example.com/lower">LINK</a></h6>\n',
);
});
it.each([false, true])(
"preserves inline semantics and leaves reference definitions untouched (streaming=%s)",
(streaming) => {
const text =
"[foo][ref] <https://example.org> &amp; \\* **bold** \n[linked `code`](https://example.net)\n\n[ref]: https://example.com";
const prepared = prepareProse(text, noFiles, streaming);
expect(prepared).toContain("[ref]: https://example.com");
expect(parser.render(prepared)).toBe(parser.render(text));
},
);
it.each(["- [ ] Task", "- [x] Done", "1. [X] Done", "- parent\n - [ ] Nested task"])(
"keeps task markers literal through streaming and settlement: %s",
(text) => {
const settled = prepareProse(text, noFiles);
const streaming = prepareProse(text, noFiles, true);
expect(settled).toMatch(/\\\[/);
expect(streaming).toBe(settled);
expect(parser.render(settled)).toBe(parser.render(text));
},
);
@@ -16,7 +16,7 @@ function escapeText(text: string): string {
}
function renderInline(tokens: Token[], canResolveCode: (text: string) => boolean): string {
const links: string[] = [];
const links: { href: string; title: string | null }[] = [];
return tokens
.map((token) => {
switch (token.type) {
@@ -27,10 +27,15 @@ function renderInline(tokens: Token[], canResolveCode: (text: string) => boolean
case "hardbreak":
return " \n";
case "link_open":
links.push(token.attrGet("href") ?? "");
links.push({ href: token.attrGet("href") ?? "", title: token.attrGet("title") });
return "[";
case "link_close": {
return `](<${links.pop() ?? ""}>)`;
const link = links.pop()!;
const title =
link.title === null
? ""
: ` "${link.title.replace(/&/g, "&amp;").replace(/"/g, "&quot;")}"`;
return `](<${link.href}>${title})`;
}
case "code_inline": {
const needsPadding =
@@ -60,52 +65,22 @@ export function prepareProse(
text: string,
canResolveCode: (text: string) => boolean,
streaming = false,
): string {
text = prepareBlockInlines(text, canResolveCode, streaming);
const state = new parser.inline.State(text, parser, {}, []);
let prepared = "";
while (state.pos < state.posMax) {
const start = state.pos;
parser.inline.skipToken(state);
const source = text.slice(start, state.pos);
// Escapes and entities already protect their contents from delimiter parsing.
if (source.startsWith("\\") || source.startsWith("&")) {
prepared += source;
continue;
}
const tokens = parser.parseInline(source, {})[0]!.children!;
const containsLinkOrCode = tokens.some(
(token) => token.type === "code_inline" || token.type === "link_open",
);
prepared += containsLinkOrCode
? renderInline(tokens, canResolveCode)
: source.replace(/\|/g, "\\|");
}
return prepared;
}
function prepareBlockInlines(
text: string,
canResolveCode: (text: string) => boolean,
streaming: boolean,
): string {
const tokens = (streaming ? streamingParser : parser).parse(text, {});
const tail = tokens.findLast((token) => token.nesting !== -1);
const lines = text.split("\n");
for (let index = tokens.length - 1; index >= 0; index--) {
const token = tokens[index]!;
const uppercase = tokens[index - 1]?.type === "heading_open" && tokens[index - 1]?.tag === "h6";
if (
token.type !== "inline" ||
!token.map ||
!token.children ||
!(uppercase || (streaming && token === tail))
)
continue;
const [start, end] = token.map;
if (token.type !== "inline" || !token.map || !token.children) continue;
const [start] = token.map;
const prefixes = token.content.split("\n").map((content, offset) => {
const line = lines[start + offset]!;
const position = line.indexOf(content);
const heading = tokens[index - 1];
const headingPrefix =
heading?.type === "heading_open" && heading.markup.startsWith("#")
? (line.match(/^(?:\s*>\s*)*\s*#{1,6}\s+/)?.[0].length ?? 0)
: 0;
const position = line.indexOf(content, headingPrefix);
return position < 0 ? "" : line.slice(0, position);
});
if (uppercase) {
@@ -119,7 +94,7 @@ function prepareBlockInlines(
const replacement = rendered.map(
(line, offset) => `${prefixes[offset] ?? prefixes.at(-1) ?? ""}${line}`,
);
lines.splice(start, end - start, ...replacement);
lines.splice(start, token.content.split("\n").length, ...replacement);
}
return lines.join("\n");
}
@@ -58,8 +58,17 @@ function proseStyleMapping(theme: Theme): { markdownStyle: MarkdownStyle } {
marginBottom: styles.blockquote.marginVertical,
},
link: { color: styles.link.color, underline: false },
code: { ...styles.code_inline, borderColor: "transparent" },
strong: styles.strong,
code: {
// RCTFont does not resolve Fabric's ui-monospace alias. An empty family
// selects the library's native system-monospace font on iOS.
fontFamily:
styles.code_inline.fontFamily === "ui-monospace" ? "" : styles.code_inline.fontFamily,
fontSize: styles.code_inline.fontSize,
color: styles.code_inline.color,
backgroundColor: styles.code_inline.backgroundColor,
borderColor: "transparent",
},
strong: { fontWeight: "bold" },
em: styles.em,
strikethrough: { color: styles.s.color },
thematicBreak: {
@@ -0,0 +1 @@
export { MarkdownProse } from "./renderer.web";
@@ -1,3 +1,4 @@
import { ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT } from "@/components/assistant-message-render-limit";
import MarkdownIt from "markdown-it";
import type { AssistantMessageItem } from "@/types/stream";
@@ -7,7 +8,8 @@ const partsByBlock = new WeakMap<AssistantMessageItem, AssistantMessageItem[]>()
function splitBlock(block: AssistantMessageItem): AssistantMessageItem[] {
const cached = partsByBlock.get(block);
if (cached) return cached;
const tokens = parser.parse(block.text, {});
const environment: { references?: unknown } = {};
const tokens = parser.parse(block.text, environment);
const preserved = tokens.some(
(token) =>
["fence", "code_block", "table_open", "html_block"].includes(token.type) ||
@@ -16,7 +18,14 @@ function splitBlock(block: AssistantMessageItem): AssistantMessageItem[] {
const isQuote = tokens.some((token) => token.type === "blockquote_open");
const hasImage = tokens.some((token) => token.children?.some((child) => child.type === "image"));
let parts: AssistantMessageItem[];
if (preserved || (isQuote && hasImage)) {
// Retain the container and its definitions when splitting would lose image context.
if (
preserved ||
(hasImage &&
(isQuote ||
environment.references ||
tokens.some((token) => token.type === "list_item_open")))
) {
parts = [{ ...block, markdownKind: "block" }];
} else if (hasImage) {
parts = splitParagraphImages(block);
@@ -56,7 +65,11 @@ export function groupMarkdownProse(blocks: AssistantMessageItem[]): AssistantMes
const rows: AssistantMessageItem[] = [];
for (const block of blocks.flatMap(splitBlock)) {
const previous = rows.at(-1);
if (block.markdownKind === "prose" && previous?.markdownKind === "prose") {
if (
block.markdownKind === "prose" &&
previous?.markdownKind === "prose" &&
previous.text.length + 2 + block.text.length <= ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT
) {
previous.text += `\n\n${block.text}`;
continue;
}