fix(app): keep growing markdown on the streaming block renderer

This commit is contained in:
Mohamed Boudra
2026-10-15 15:32:02 +02:00
parent 9b7a6ba782
commit 1fbdac728f
11 changed files with 162 additions and 275 deletions
@@ -146,7 +146,9 @@ describe("native prose runs", () => {
for (const text of ["First.\n\nBefore ![shot](shots/app.png) after.", "\n\nNext.", " More."]) {
combined += text;
state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) });
const live = rows(present({ ...presentationOptions, ...state, transform: undefined }));
const live = rows(
present({ ...presentationOptions, ...state, isTurnActive: false, transform: undefined }),
);
const history = createStreamPresentation({ markdown: "prose-runs" })({
...presentationOptions,
tail: hydrateStreamState([{ event: assistant(combined), timestamp: new Date(1000) }]),
@@ -156,7 +158,13 @@ describe("native prose runs", () => {
expect(live).toEqual(history.tail);
}
});
it("keeps 600 short-paragraph appends near blocks mode", { retry: 2 }, () => {
it.each([
["short paragraphs", "A short paragraph.\n\n".repeat(3).slice(0, 50)],
[
"one growing paragraph",
"Use `foo` with https://example.com/path and `bar`. ".repeat(2).slice(0, 50),
],
])("keeps 600 appends near blocks mode: %s", { retry: 2 }, (_name, chunk) => {
function measure(markdown: "blocks" | "prose-runs") {
const present = createStreamPresentation({ markdown });
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
@@ -164,7 +172,7 @@ describe("native prose runs", () => {
for (let i = 0; i < 600; i++) {
state = applyStreamEvent({
...state,
event: assistant("A short paragraph.\n\n".repeat(3).slice(0, 50)),
event: assistant(chunk),
timestamp: new Date(1000),
});
present({ ...presentationOptions, ...state, transform: undefined });
@@ -201,7 +209,12 @@ describe("native prose runs", () => {
state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) });
present({ ...presentationOptions, ...state, transform: undefined });
}
const live = present({ ...presentationOptions, ...state, transform: undefined });
const live = present({
...presentationOptions,
...state,
isTurnActive: false,
transform: undefined,
});
const history = createStreamPresentation({ markdown })({
...presentationOptions,
tail: hydrateStreamState([
@@ -242,7 +255,12 @@ describe("native prose runs", () => {
state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) });
present({ ...presentationOptions, ...state, transform: undefined });
}
const live = present({ ...presentationOptions, ...state, transform: undefined });
const live = present({
...presentationOptions,
...state,
isTurnActive: false,
transform: undefined,
});
expect([...live.tail, ...live.head]).toMatchObject([
{ id: "message-1:run:0", text: first },
{ id: "message-1:run:1", text: second },
@@ -315,23 +333,65 @@ describe("native prose runs", () => {
]);
});
it("grows the live run and retains promoted rows", () => {
it.each(["> Quoted text.", "- List item", "### Heading", "Plain paragraph."])(
"classifies the final block at turn completion: %s",
(text) => {
const present = createStreamPresentation({ markdown: "prose-runs" });
const head = hydrateStreamState([{ event: assistant(text), timestamp: new Date(1000) }]);
expect(
present({ ...presentationOptions, tail: [], head, transform: undefined }).head,
).toMatchObject([{ markdownKind: "block" }]);
const completed = present({
...presentationOptions,
isTurnActive: false,
tail: [],
head,
transform: undefined,
});
const history = createStreamPresentation({ markdown: "prose-runs" })({
...presentationOptions,
tail: head,
head: [],
transform: undefined,
});
expect(rows(completed)).toEqual(history.tail);
},
);
it("keeps the growing block separate and merges completed blocks into a stable run", () => {
const present = createStreamPresentation({ markdown: "prose-runs" });
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
function send(text: string) {
state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) });
return present({ ...presentationOptions, ...state, transform: undefined });
}
const first = send("First.");
expect(send("First.").head).toMatchObject([{ id: "message-1:block:0", markdownKind: "block" }]);
const second = send("\n\nSecond.");
expect(second.tail).toEqual([]);
expect(second.head).toMatchObject([{ id: first.head[0]!.id, text: "First.\n\nSecond." }]);
const boundary = send("\n\n```ts\na\n```");
expect(boundary.tail[0]).toBe(second.head[0]);
const last = send("\n\nAfter.");
expect(last.tail[0]).toBe(second.head[0]);
expect(last.tail[1]).toBe(boundary.head[0]);
expect(last.head).toMatchObject([{ id: "message-1:run:3", text: "After." }]);
expect(second.tail).toMatchObject([
{ id: "message-1:run:0", text: "First.", markdownKind: "prose" },
]);
expect(second.head).toMatchObject([
{ id: "message-1:block:1", text: "Second.", markdownKind: "block" },
]);
const growing = send(" More.");
expect(growing.tail[0]).toBe(second.tail[0]);
const third = send("\n\nThird.");
expect(third.tail).toMatchObject([{ id: "message-1:run:0", text: "First.\n\nSecond. More." }]);
const completed = present({
...presentationOptions,
...state,
isTurnActive: false,
transform: undefined,
});
const history = createStreamPresentation({ markdown: "prose-runs" })({
...presentationOptions,
tail: [...state.tail, ...state.head],
head: [],
transform: undefined,
});
expect(rows(completed)).toEqual(history.tail);
expect(rows(completed)).toMatchObject([
{ id: "message-1:run:0", text: "First.\n\nSecond. More.\n\nThird." },
]);
});
it("keeps consecutive paragraphs in one selectable row", () => {
@@ -59,6 +59,7 @@ export function createStreamPresentation({
markdown = "blocks",
}: { markdown?: "blocks" | "prose-runs" } = {}) {
const runsBySource = new WeakMap<AssistantMessageItem, AssistantMessageItem[]>();
const growingRunsBySource = new WeakMap<AssistantMessageItem, AssistantMessageItem[]>();
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
function presentUserMessage(item: UserMessageItem): UserMessageItem {
@@ -145,16 +146,17 @@ export function createStreamPresentation({
return blocks;
}
function presentRows(item: StreamItem): StreamItem[] {
function presentRows(item: StreamItem, growing = false): StreamItem[] {
const blocks = nativeBlocks(item);
if (markdown === "blocks" || item.kind !== "assistant_message") return blocks;
const cached = runsBySource.get(item);
const cache = growing ? growingRunsBySource : runsBySource;
const cached = cache.get(item);
if (cached) return cached;
const previousSource = liveSources.get(item.id);
const previousBlocks = previousSource && blocksBySource.get(previousSource);
// nativeBlocks emits only assistant blocks for an assistant source.
const runs = groupMarkdownProse(blocks as AssistantMessageItem[], previousBlocks);
runsBySource.set(item, runs);
const runs = groupMarkdownProse(blocks as AssistantMessageItem[], previousBlocks, growing);
cache.set(item, runs);
return runs;
}
@@ -165,7 +167,7 @@ export function createStreamPresentation({
// `blocksBySource` keeps both the live block identities and the split cost
// from being paid again when only the tail's array identity changed.
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>(
presentRows,
(item) => presentRows(item),
);
historySource = input.tail;
historyTransform = input.transform;
@@ -174,7 +176,7 @@ export function createStreamPresentation({
const promoted: StreamItem[] = [];
const nextLiveSources = new Map<string, AssistantMessageItem>();
for (const item of projectPluginTimelineItems(input.head, input.transform, "streaming")) {
const blocks = presentRows(item);
const blocks = presentRows(item, input.isTurnActive);
if (item.kind === "assistant_message") nextLiveSources.set(item.id, item);
if (item.kind === "assistant_message" && blocks.length > 1) {
promoted.push(...blocks.slice(0, -1));
@@ -20,13 +20,6 @@ describe("source-preserving prose", () => {
])("uses parser-owned link ranges: %s", (source, expected) => {
expect(prepareProse([source], noFiles)).toBe(expected);
});
it.each([
["Use ~", "Use "],
["Use ~/", "Use \\~/"],
["Use ~/.config", "Use \\~/.config"],
])("keeps provisional tilde escapes attached: %s", (source, expected) => {
expect(prepareProse([source], noFiles, true)).toBe(expected);
});
it.each([
["Paths ~/.config and ~/.local are used.", "Paths \\~/.config and \\~/.local are used."],
["~/a ~/b", "\\~/a \\~/b"],
@@ -35,7 +28,6 @@ describe("source-preserving prose", () => {
["`~~` and `~/a ~/b`", "`~~` and `~/a ~/b`"],
])("keeps single tildes literal: %s", (source, expected) => {
expect(prepareProse([source], noFiles)).toBe(expected);
expect(prepareProse([source], noFiles, true)).toBe(expected);
});
it.each([
["https://example.com/a&amp;b", "[https://example.com/a&b](<https://example.com/a&b>)"],
@@ -99,9 +91,9 @@ describe("source-preserving prose", () => {
candidates.push(text);
return false;
});
prepare(["First `one`.", "Next `two`.", "Last `three`."], true, Infinity);
prepare(["First `one`.", "Next `two`.", "Last `three`."], Infinity);
candidates.length = 0;
prepare(["First `one`.", "Next `two`.", "Last `three`. More."], true, Infinity);
prepare(["First `one`.", "Next `two`.", "Last `three`. More."], Infinity);
expect(candidates).toEqual(["three"]);
});
it("bounds preparation before parsing a capped block", () => {
@@ -111,7 +103,7 @@ describe("source-preserving prose", () => {
return false;
});
const source = "Use `foo` here. ".repeat(70_000);
prepare([source], false, 32_000);
prepare([source], 32_000);
expect(candidates).toBeLessThan(2200);
});
it("decodes entities outside code", () => {
@@ -150,25 +142,6 @@ describe("source-preserving prose", () => {
for (let i = 0; i < 5; i++) expect(prepareProse([text], noFiles)).toBe(text);
expect((performance.now() - start) / 5).toBeLessThan(50);
});
it("reuses prepared prefixes while completing the visible inline tail", () => {
const prepare = createProsePreparer(noFiles);
const blocks = ["First &amp; paragraph.", "Use `foo` and **bold** here. ".repeat(30)];
const source = blocks.join("\n\n");
prepare(blocks, true, source.length);
for (let length = 0; length < source.length; length += 7) {
expect(prepare(blocks, true, length)).toBe(prepareProse(blocks, noFiles, true, length));
}
});
it.each([
["A ||literal|| and escaped \\* text.", 2, "A "],
["Use `README.md` and example.com. Later.", 4, "Use "],
["[unresolved label] later.", 12, "unresolved "],
["[unresolved label] later.", 19, "unresolved label "],
] as const)("keeps cached reveal boundaries literal: %s", (source, length, expected) => {
const prepare = createProsePreparer((text) => text === "README.md");
prepare([source], true, source.length);
expect(prepare([source], true, length)).toBe(expected);
});
it("changes only prose pipes", () => {
expect(prepareProse(["Keep ||literal|| and `a || b` and [a|b](https://x/a|b)."], noFiles)).toBe(
"Keep \\|\\|literal\\|\\| and `a || b` and [a\\|b](https://x/a|b).",
@@ -194,18 +167,6 @@ describe("source-preserving prose", () => {
),
).toBe("###### SMALL **BOLD** `lower.ts` [LINK](https://example.com/lower) &");
});
it.each([
["Before **bold", "Before **bold**"],
["Before `code", "Before `code`"],
["Before [label](https://exa", "Before label"],
["### A **heading", "### A **heading**"],
["- First\n- **Second", "- First\n- **Second**"],
["> First\n> **Second", "> First\n> **Second**"],
["Before **", "Before "],
["Before ~~strike", "Before ~~strike~~"],
])("minimally completes the streaming tail: %s", (source, expected) => {
expect(prepareProse([source], noFiles, true)).toBe(expected);
});
it("prepares source blocks independently, even beside a reference definition", () => {
const blocks = ["![shot][pic]", "Other paragraph.\n\n[pic]: shots/app.png"];
expect(prepareProse(blocks, noFiles)).toBe(
@@ -2,8 +2,7 @@ import type { AssistantFileLinkSource } from "@/assistant-file-links";
import type Token from "markdown-it/lib/token.mjs";
import { type InlineSpan, type InlineTrace, createSourceParser } from "./source-inline";
const settled = createSourceParser(false);
const provisional = createSourceParser(true);
const settled = createSourceParser();
const INLINE_CODE_SCHEME = "paseo-inline-code:";
const FILE_LINK_SCHEME = "paseo-file-link:";
@@ -58,83 +57,34 @@ function applyEdits(source: string, edits: Edit[]): string {
return result + source.slice(cursor);
}
/** Source blocks are presentation's canonical inputs, never reconstructed from a joined run. */
/** Each completed source block retains its own reference scope. */
export function prepareProse(
blocks: readonly string[],
canResolveCode: (text: string) => boolean,
streaming = false,
visibleLength = Infinity,
): string {
const visible: string[] = [];
let remaining = visibleLength;
for (const block of blocks) {
if (remaining < 0) break;
visible.push(block.slice(0, remaining));
remaining -= block.length + 2;
}
return visible
.map((block, index) =>
prepareBlock(block, canResolveCode, streaming && index === visible.length - 1),
)
.join("\n\n");
return createProsePreparer(canResolveCode)(blocks, visibleLength);
}
interface PreparedBlock {
text: string;
cuts: number[];
offsets: number[];
}
/** One cache per mounted prose row. Reveal reuses the prepared prefix and parses only its open tail. */
/** One cache per prose row; joining a completed block does not reparse earlier blocks. */
export function createProsePreparer(canResolveCode: (text: string) => boolean) {
let cache = new Map<string, { source: string; prepared: PreparedBlock }>();
return (blocks: readonly string[], streaming: boolean, visibleLength: number): string => {
const retained = new Map<string, { source: string; prepared: PreparedBlock }>();
let cache = new Map<string, string>();
return (blocks: readonly string[], visibleLength = Infinity): string => {
const retained = new Map<string, string>();
const result: string[] = [];
let remaining = visibleLength;
for (const [index, block] of blocks.entries()) {
for (const block of blocks) {
if (remaining < 0) break;
const live = streaming && (index === blocks.length - 1 || remaining < block.length);
const key = `${live}:${block}`;
const source = block.slice(0, remaining);
const cached = cache.get(key);
const entry =
cached && cached.source.length >= source.length
? cached
: { source, prepared: compileBlock(source, canResolveCode, live) };
retained.set(key, entry);
result.push(
revealBlock(entry.source, entry.prepared, source.length, canResolveCode, streaming),
);
const prepared = cache.get(source) ?? prepareBlock(source, canResolveCode);
retained.set(source, prepared);
result.push(prepared);
remaining -= block.length + 2;
}
cache = retained;
return result.join("\n\n");
};
}
function revealBlock(
source: string,
prepared: PreparedBlock,
length: number,
canResolveCode: (text: string) => boolean,
streaming: boolean,
): string {
if (length >= source.length) return prepared.text;
// Checkpoints are outside inline containers, at whitespace in a plain paragraph.
let low = 0;
let high = prepared.cuts.length;
while (low < high) {
const mid = (low + high) >>> 1;
if (prepared.cuts[mid]! <= length) low = mid + 1;
else high = mid;
}
const index = low - 1;
if (index < 0) return prepareBlock(source.slice(0, length), canResolveCode, streaming);
const cut = prepared.cuts[index]!;
// Retain paragraph context if the tail happens to begin with a block marker.
const tail = prepareBlock(`x ${source.slice(cut, length)}`, canResolveCode, streaming).slice(2);
return prepared.text.slice(0, prepared.offsets[index]) + tail;
}
interface InlinePreparation {
source: string;
@@ -142,7 +92,6 @@ interface InlinePreparation {
trace: InlineTrace;
parser: typeof settled.parser;
env: object;
streaming: boolean;
uppercase: boolean;
canResolveCode: (text: string) => boolean;
protectedRanges: Uint8Array;
@@ -166,29 +115,19 @@ function codeUrl(context: InlinePreparation, content: string): string | null {
const match = matches?.length === 1 ? matches[0] : undefined;
return match?.index === 0 && match.lastIndex === content.length ? match.url : null;
}
function prepareCode(context: InlinePreparation, span: InlineSpan, raw: string, code: Token) {
function prepareCode(context: InlinePreparation, span: InlineSpan, code: Token) {
protect(context.protectedRanges, span);
protect(context.referenceRanges, span);
const complete = raw.length >= code.markup.length * 2 && raw.endsWith(code.markup);
const closing = complete ? "" : code.markup.slice(raw.match(/`+$/)?.[0].length ?? 0);
const url = codeUrl(context, code.content);
if (!span.insideLink && url) {
insert(context, span.start, "[");
insert(context, span.end, `${closing}](<${url}>)`);
} else if (closing) insert(context, span.end, closing);
insert(context, span.end, `](<${url}>)`);
}
}
function labelEnd(context: InlinePreparation, source: string, start: number): number {
const state = new context.parser.inline.State(source, context.parser, context.env, []);
return context.parser.helpers.parseLinkLabel(state, start, false);
}
function prepareUnfinishedLink(context: InlinePreparation, span: InlineSpan, raw: string) {
const start = raw.startsWith("![") ? 1 : 0;
const end = labelEnd(context, raw, start);
context.edits.push({ start: span.start, end: span.start + start + 1, text: "" });
if (start === 1) context.edits.push({ start: span.start + 2, end: span.end, text: "" });
else if (end !== -1) context.edits.push({ start: span.start + end, end: span.end, text: "" });
protect(context.referenceRanges, span);
}
function preserveLinkScope(context: InlinePreparation, span: InlineSpan, raw: string, link: Token) {
const end = labelEnd(context, raw, 0);
protect(context.protectedRanges, { start: span.start + end, end: span.end });
@@ -275,22 +214,10 @@ function prepareSpan(context: InlinePreparation, span: InlineSpan) {
} else if (link && raw.startsWith("[")) {
preserveLinkScope(context, span, raw, link);
} else if (code && raw.startsWith("`")) {
prepareCode(context, span, raw, code);
} else if (
context.streaming &&
!contains(context.referenceRanges, span.start) &&
(raw.startsWith("[") || raw.startsWith("!["))
) {
prepareUnfinishedLink(context, span, raw);
} else if (
context.streaming &&
span.tokens.length === 0 &&
/^(?:\*{1,3}|_{1,3}|~{1,2}|`+)$/.test(raw)
) {
context.edits.push({ start: span.start, end: span.end, text: "" });
protect(context.protectedRanges, span);
prepareCode(context, span, code);
}
}
function preserveUnresolvedReferences(context: InlinePreparation) {
// Protect every unresolved label, regardless of CommonMark's case/whitespace
// normalization. Escapes inside a label do not make its opening bracket safe.
@@ -421,19 +348,7 @@ function sourcePositions(
}
return positions;
}
function prepareBlock(
text: string,
canResolveCode: (text: string) => boolean,
streaming: boolean,
): string {
return compileBlock(text, canResolveCode, streaming).text;
}
function compileBlock(
text: string,
canResolveCode: (text: string) => boolean,
streaming: boolean,
): PreparedBlock {
const normalized = !/[\r\t]/.test(text);
function prepareBlock(text: string, canResolveCode: (text: string) => boolean): string {
text = text.replace(/\r\n?/g, "\n").replaceAll("\0", "\ufffd");
text = text.replace(/^[ \t>]*/gm, (prefix) => {
let expanded = "";
@@ -441,7 +356,7 @@ function compileBlock(
expanded += char === "\t" ? " ".repeat(4 - (expanded.length % 4)) : char;
return expanded;
});
const { parser, traces } = streaming ? provisional : settled;
const { parser, traces } = settled;
const env = {};
const tokens = parser.parse(text, env);
const lines = text.split("\n");
@@ -452,17 +367,9 @@ function compileBlock(
offset += line.length + 1;
}
const edits: Edit[] = [];
let cuts: number[] = [];
for (const [index, token] of tokens.entries()) {
if (token.type !== "inline" || !token.map) continue;
const trace = traces.get(token)!;
if (
normalized &&
text === token.content &&
tokens.length === 3 &&
tokens[0]!.type === "paragraph_open"
)
cuts = paragraphCuts(token.content, trace);
const heading = tokens[index - 1];
const context: InlinePreparation = {
source: token.content,
@@ -470,7 +377,6 @@ function compileBlock(
trace,
parser,
env,
streaming,
uppercase: heading?.type === "heading_open" && heading.tag === "h6",
canResolveCode,
protectedRanges: new Uint8Array(token.content.length),
@@ -478,59 +384,17 @@ function compileBlock(
task: tokens[index - 2]?.type === "list_item_open",
edits: [],
};
// Link/code ownership is established before either references or provisional
// brackets can be rewritten. Linkify uses the parser's emphasis-split text tokens.
// Link/code ownership is established before references can be rewritten. Linkify uses the parser's emphasis-split text tokens.
for (const span of trace.spans)
if (span.tokens.some((child) => ["link_open", "code_inline", "image"].includes(child.type)))
protect(context.referenceRanges, span);
prepareLinkifiedLinks(context);
for (const span of trace.spans) prepareSpan(context, span);
if (trace.trim) {
const tail = { start: token.content.length - trace.trim, end: token.content.length };
context.edits.push({ ...tail, text: "" });
protect(context.protectedRanges, tail);
}
preserveUnresolvedReferences(context);
prepareText(context);
if (trace.suffix) insert(context, token.content.length, trace.suffix);
const positions = sourcePositions(token, heading, lines, lineOffsets);
for (const edit of context.edits)
edits.push({ ...edit, start: positions[edit.start]!, end: positions[edit.end]! });
}
const rendered = applyEdits(text, edits);
return { text: rendered, cuts, offsets: preparedOffsets(cuts, edits) };
}
function preparedOffsets(cuts: number[], edits: Edit[]): number[] {
const offsets: number[] = [];
let editIndex = 0;
let delta = 0;
for (const cut of cuts) {
while (
editIndex < edits.length &&
edits[editIndex]!.end <= cut &&
edits[editIndex]!.start < cut
) {
const edit = edits[editIndex++]!;
delta += edit.text.length - (edit.end - edit.start);
}
offsets.push(cut + delta);
}
return offsets;
}
function paragraphCuts(source: string, trace: InlineTrace): number[] {
const cuts: number[] = [];
let depth = 0;
// A bracket can remain a provisional reference/link even after its closing bracket.
// Keep that suffix together until the block settles.
const bracket = source.indexOf("[");
const stableEnd = bracket < 0 ? source.length : bracket;
for (const span of trace.spans) {
if (span.insideLink) continue;
if (depth === 0 && span.tokens.length === 0 && source[span.start] !== "&") {
for (let i = span.start; i < span.end; i++)
if (i < stableEnd && source[i] === " ") cuts.push(i + 1);
}
for (const token of span.tokens) depth += token.nesting;
}
return cuts;
return applyEdits(text, edits);
}
@@ -3,7 +3,6 @@ import type { ReactNode } from "react";
export interface MarkdownProseProps {
text: string;
sourceBlocks: readonly string[];
streaming: boolean;
kind: "prose" | "quote" | "block";
onLinkPress: (url: string) => boolean;
children: ReactNode;
@@ -96,7 +96,6 @@ const PROSE_CONTAINER = { marginBottom: -12 };
const NativeProse = memo(function NativeProse({
text,
sourceBlocks,
streaming,
kind,
onLinkPress,
}: MarkdownProseProps) {
@@ -106,8 +105,8 @@ const NativeProse = memo(function NativeProse({
);
const prepare = useMemo(() => createProsePreparer(canResolveCode), [canResolveCode]);
const markdown = useMemo(
() => prepare(sourceBlocks, streaming, text.length),
[text.length, sourceBlocks, prepare, streaming],
() => prepare(sourceBlocks, text.length),
[text.length, sourceBlocks, prepare],
);
const handleLinkPress = useStableEvent(({ url }: { url: string }) => {
handleProseLinkPress(url, (source) => actions.open(source, "preferred"), onLinkPress);
@@ -66,21 +66,25 @@ function splitParagraphImages(block: AssistantMessageItem): AssistantMessageItem
}
interface GroupedRuns {
growing: boolean;
rows: AssistantMessageItem[];
starts: number[];
ends: number[];
}
const runsByBlocks = new WeakMap<readonly AssistantMessageItem[], GroupedRuns>();
const growingRunsByBlocks = new WeakMap<readonly AssistantMessageItem[], GroupedRuns>();
/** Rebuild only the run containing the changed source block; completed runs retain identity. */
export function groupMarkdownProse(
blocks: readonly AssistantMessageItem[],
previousBlocks?: readonly AssistantMessageItem[],
growing = false,
): AssistantMessageItem[] {
const cached = runsByBlocks.get(blocks);
const cache = growing ? growingRunsByBlocks : runsByBlocks;
const cached = cache.get(blocks);
if (cached) return cached.rows;
const previous = previousBlocks && runsByBlocks.get(previousBlocks);
const retained = retainedRunCount(blocks, previousBlocks, previous);
const previous = previousGroups(previousBlocks);
const retained = retainedRunCount(blocks, previousBlocks, previous, growing);
const start = previous?.starts[retained] ?? 0;
const rows = previous?.rows.slice(0, retained) ?? [];
const starts = previous?.starts.slice(0, retained) ?? [];
@@ -105,14 +109,13 @@ export function groupMarkdownProse(
length = 0;
}
for (let index = start; index < blocks.length; index++) {
for (const part of splitBlock(blocks[index]!)) {
if (
parts.length &&
(part.markdownKind !== "prose" ||
parts[0]!.markdownKind !== "prose" ||
length + 2 + part.text.length > ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT)
)
flush();
const block = blocks[index]!;
const partsForBlock =
growing && index === blocks.length - 1
? [{ ...block, markdownKind: "block" as const }]
: splitBlock(block);
for (const part of partsForBlock) {
if (parts.length && !canJoinRun(parts[0]!, part, length)) flush();
if (!parts.length) runStart = index;
length += (parts.length ? 2 : 0) + part.text.length;
parts.push(part);
@@ -121,7 +124,7 @@ export function groupMarkdownProse(
}
flush();
reuseUnchangedRows(blocks, previousBlocks, previous, rows, starts, ends, retained);
runsByBlocks.set(blocks, { rows, starts, ends });
cache.set(blocks, { rows, starts, ends, growing });
return rows;
}
@@ -129,15 +132,20 @@ function retainedRunCount(
blocks: readonly AssistantMessageItem[],
previousBlocks: readonly AssistantMessageItem[] | undefined,
previous: GroupedRuns | undefined,
growing: boolean,
): number {
if (!previous || !previousBlocks) return 0;
let shared = 0;
while (shared < blocks.length && blocks[shared] === previousBlocks[shared]) shared++;
if (previous.growing && (!growing || blocks.length !== previousBlocks.length))
shared = Math.min(shared, previousBlocks.length - 1);
let retained = 0;
// Revisit the boundary run because newly appended prose can join it.
while (retained + 1 < previous.rows.length && previous.starts[retained + 1]! < shared) retained++;
// Several image parts can originate from one source block. Rebuild them together.
while (retained > 0 && previous.ends[retained - 1]! >= previous.starts[retained]!) retained--;
while (retained < previous.rows.length && previous.ends[retained]! < shared) retained++;
// Only completed blocks can merge with the preceding run. The live tail is a
// hard boundary, so appending to it never rebuilds completed prose.
if (!(growing && shared === blocks.length - 1)) retained = Math.max(0, retained - 1);
while (retained > 0 && previous.ends[retained - 1]! >= (previous.starts[retained] ?? shared))
retained--;
return retained;
}
function reuseUnchangedRows(
@@ -153,7 +161,8 @@ function reuseUnchangedRows(
for (let index = retained; index < rows.length; index++) {
const old = previous.rows[index];
const row = rows[index]!;
if (old?.id !== row.id || old.text !== row.text) continue;
if (old?.id !== row.id || old.text !== row.text || old.markdownKind !== row.markdownKind)
continue;
const end = ends[index]!;
let identical = true;
for (let block = starts[index]!; block <= end; block++)
@@ -165,3 +174,21 @@ function reuseUnchangedRows(
rows[index] = old;
}
}
function canJoinRun(
first: AssistantMessageItem,
next: AssistantMessageItem,
length: number,
): boolean {
return (
first.markdownKind === "prose" &&
next.markdownKind === "prose" &&
length + 2 + next.text.length <= ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT
);
}
function previousGroups(
blocks: readonly AssistantMessageItem[] | undefined,
): GroupedRuns | undefined {
return blocks && (growingRunsByBlocks.get(blocks) ?? runsByBlocks.get(blocks));
}
@@ -10,19 +10,17 @@ export interface InlineSpan {
export interface InlineTrace {
spans: InlineSpan[];
linkifyTexts: string[];
suffix: string;
trim: number;
}
const TRACE = Symbol("prose source positions");
/** Observe the actual inline rules, including nested labels, without reprinting tokens. */
export function createSourceParser(streaming: boolean) {
const parser = createAssistantMarkdownParser({ streaming });
export function createSourceParser() {
const parser = createAssistantMarkdownParser();
const traces = new WeakMap<Token, InlineTrace>();
const inlineTraces = new WeakMap<Token[], InlineTrace>();
const parseInline = parser.inline.parse.bind(parser.inline);
parser.inline.parse = (source, md, env, output) => {
const trace: InlineTrace = { spans: [], linkifyTexts: [], suffix: "", trim: 0 };
const trace: InlineTrace = { spans: [], linkifyTexts: [] };
parseInline(source, md, { ...env, [TRACE]: trace }, output);
inlineTraces.set(output, trace);
};
@@ -45,30 +43,6 @@ export function createSourceParser(streaming: boolean) {
}
return false;
});
if (streaming) {
const counts = new WeakMap<
object,
{ count: number; last: Token | undefined; content: string }
>();
parser.inline.ruler2.before("streaming_emphasis", "prose_before_completion", (state) => {
counts.set(state, {
count: state.tokens.length,
last: state.tokens.at(-1),
content: state.tokens.at(-1)?.content ?? "",
});
return true;
});
parser.inline.ruler2.after("streaming_emphasis", "prose_after_completion", (state) => {
const before = counts.get(state)!;
const trace: InlineTrace = state.env[TRACE];
trace.suffix = state.tokens
.slice(before.count)
.map((token) => token.content)
.join("");
trace.trim = before.content.length - (before.last?.content.length ?? 0);
return true;
});
}
parser.core.ruler.after("inline", "prose_inline_traces", (state) => {
for (const token of state.tokens) {
if (token.type !== "inline" || !token.children) continue;
-1
View File
@@ -1998,7 +1998,6 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
<View testID="assistant-message" dataSet={revealDataSet} style={assistantContainerStyle}>
<MarkdownProse
streaming={phase === "streaming"}
kind={markdownKind}
text={revealedMessage}
sourceBlocks={markdownProseBlocks ?? [message]}