fix(app): preserve markdown source and file actions in native prose

Prepare original source blocks with source-position edits from the assistant parser. Keep reference scope and live/history projection consistent, and retain link text when dispatching workspace file actions.
This commit is contained in:
Mohamed Boudra
2026-10-15 15:32:02 +02:00
parent 5bf0e4b588
commit 7023d20a5b
12 changed files with 501 additions and 225 deletions
+4 -3
View File
@@ -34,9 +34,10 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
display. Native groups adjacent prose into selectable runs; web retains individual blocks.
Code, tables, images, HTML-like content and blockquotes end a native run. See
[native text selection](native-text-selection.md) for renderer ownership and library constraints.
Appends parse only the growing last source block. Completed source blocks retain their parsed
classification, completed display rows retain object identity, and a source-item cache avoids
re-splitting history. Streamed and fetched messages use the same projection.
Completing a reference definition can change an earlier block boundary. Live and history use
the same structural parse; unchanged blocks retain their classification and completed rows
retain object identity. A source-item cache avoids re-splitting unchanged history. Prose rows
retain the original source blocks so native preparation cannot broaden reference scope.
- **A row id is never a message id.** Block rows are `${messageId}:block:${n}`; native prose runs use `${messageId}:run:${n}` with
`blockGroupId = messageId`, including single-row messages. Inline images split a source block into offset-keyed parts, so nothing can come to rely on the two
being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its
@@ -124,6 +124,44 @@ function rows(result: { tail: StreamItem[]; head: StreamItem[] }): StreamItem[]
}
describe("native prose runs", () => {
it("projects a completed streamed reference definition like fresh history", () => {
const present = createStreamPresentation({ markdown: "prose-runs" });
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
for (const text of ["![shot][pic]\n\n[pic]:", " shots/app.png"]) {
state = applyStreamEvent({ ...state, event: assistant(text), timestamp: new Date(1000) });
present({ ...presentationOptions, ...state, transform: undefined });
}
const live = present({ ...presentationOptions, ...state, transform: undefined });
const history = createStreamPresentation({ markdown: "prose-runs" })({
...presentationOptions,
tail: hydrateStreamState([
{ event: assistant("![shot][pic]\n\n[pic]: shots/app.png"), timestamp: new Date(1000) },
]),
head: [],
transform: undefined,
});
expect([...live.tail, ...live.head]).toEqual(history.tail);
});
it("keeps file URL images in Paseo's renderer", () => {
const source = hydrateStreamState([
{
event: assistant("Before ![shot](file:///repo/shots/app.png) after."),
timestamp: new Date(1000),
},
]);
const result = createStreamPresentation({ markdown: "prose-runs" })({
...presentationOptions,
tail: source,
head: [],
transform: undefined,
});
expect(result.tail).toMatchObject([
{ text: "Before", markdownKind: "prose" },
{ text: "![shot](file:///repo/shots/app.png)", markdownKind: "block" },
{ text: "after.", markdownKind: "prose" },
]);
});
it("starts another run before combined paragraphs exceed the render cap", () => {
const first = "a".repeat(20_000);
const second = "b".repeat(20_000);
+6 -16
View File
@@ -102,23 +102,13 @@ export function createStreamPresentation({
if (cached) return cached;
const previousSource = liveSources.get(item.id);
const previous = previousSource && blocksBySource.get(previousSource);
// Parse only the growing last block on append, as the old live reducer did.
// Canonical text replacements are parsed afresh instead of joining fragments.
const isAppend = previousSource && previous && item.text.startsWith(previousSource.text);
let prefix: AssistantMessageItem[] = [];
let growingText = item.text;
if (isAppend) {
prefix = previous.slice(0, -1);
growingText =
previous[previous.length - 1]!.text + item.text.slice(previousSource.text.length);
}
const parsed = splitMarkdownBlocks(growingText);
// Whitespace-only text has no block, and a message still owns exactly one row.
const textBlocks = parsed.length > 0 || prefix.length > 0 ? parsed : [""];
const blocks = [...prefix];
// Completing a reference definition can join a preceding block. Use the same
// structural parse for live and history, then retain unchanged block objects.
const parsed = splitMarkdownBlocks(item.text);
const textBlocks = parsed.length > 0 ? parsed : [""];
const blocks: AssistantMessageItem[] = [];
for (const [offset, text] of textBlocks.entries()) {
const index = prefix.length + offset;
const index = offset;
let blockText = text;
if (offset === textBlocks.length - 1) {
// The split drops trailing blank lines, including a line break followed only
+1
View File
@@ -730,6 +730,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
{(renderFullContent) => (
<AssistantMessage
markdownKind={item.markdownKind}
markdownProseBlocks={item.markdownProseBlocks}
renderFullContent={renderFullContent}
occurrenceKey={createAssistantImageOccurrenceKey({ agentId, itemId: item.id })}
message={item.text}
@@ -1,114 +1,111 @@
import { describe, expect, it } from "vitest";
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
import { inlineCodeLinkContent, prepareProse } from "./prepare";
import {
prepareProse,
inlineCodeLinkContent,
proseLinkSource,
handleProseLinkPress,
} from "./prepare";
const parser = createAssistantMarkdownParser();
const noFiles = () => false;
describe("native prose dialect", () => {
describe("source-preserving prose", () => {
it.each([
"A & B",
"# A & B",
"- A & B",
"> A & B",
"Literal &amp;amp; and &amp;copy;",
"Escaped \\* and \\|",
"<https://example.org>",
"**bold** and *emphasis* and ~~deleted~~",
"First \nSecond",
"# #",
"Setext heading\n==============",
"Heading\n=======",
'A [titled link](https://example.com "A title")',
"- first\n\tsecond",
"[linked `code`](https://example.net)",
"Before ![shot](file:///repo/shots/app.png) after.",
"Blank ` ` code and ` normal ` code",
"# Heading\n\n- **First**\n- Second\n\n~~Deleted~~",
"> First paragraph\n>\n> Second paragraph",
"A [linked `path.ts`](file:///repo/path.ts)",
])("preserves existing rendered inline content: %s", (text) => {
expect(parser.render(prepareProse(text, noFiles))).toBe(parser.render(text));
"- first\n\tsecond",
"\n\nA paragraph.\n\n",
"- [ ] Task\n- [x] Done",
"> [!NOTE]\n> Note",
"> Ordinary quote",
])("copies unchanged source byte-for-byte: %s", (source) => {
expect(prepareProse([source], noFiles)).toBe(source);
});
it("escapes spoiler delimiters without changing visible text or inline code", () => {
const text = "Keep ||literal||, **bold**, ~~deleted~~ and `a || b`.";
const prepared = prepareProse(text, noFiles);
expect(prepared).toContain("\\|\\|literal\\|\\|");
expect(parser.render(prepared)).toBe(parser.render(text));
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).",
);
});
it("wraps inline file code without reprinting its source", () => {
const source = "[Docs](https://example.com) and `` src/file.ts:12 ``";
expect(prepareProse([source], (text) => text === "src/file.ts:12")).toBe(
"[Docs](https://example.com) and [`` src/file.ts:12 ``](<paseo-inline-code:src%2Ffile.ts%3A12>)",
);
expect(inlineCodeLinkContent("paseo-inline-code:src%2Ffile.ts%3A12")).toBe("src/file.ts:12");
});
it("keeps inline-code URLs tappable", () => {
expect(parser.render(prepareProse("`https://example.com`", noFiles))).toBe(
'<p><a href="https://example.com"><code>https://example.com</code></a></p>\n',
expect(prepareProse(["`https://example.com`"], noFiles)).toBe(
"[`https://example.com`](<https://example.com>)",
);
});
it("retains links and makes inline workspace paths use the same resolver input", () => {
const prepared = prepareProse(
"[Docs](https://example.com) and `src/file.ts:12`",
(text) => text === "src/file.ts:12",
);
const tokens = parser.parseInline(prepared, {})[0]!.children!;
const hrefs = tokens
.filter((token) => token.type === "link_open")
.map((token) => token.attrGet("href"));
expect(hrefs).toEqual(["https://example.com", "paseo-inline-code:src%2Ffile.ts%3A12"]);
expect(inlineCodeLinkContent(hrefs[1]!)).toBe("src/file.ts:12");
it("uppercases only h6 text", () => {
expect(
prepareProse(
["###### Small **bold** `lower.ts` [link](https://example.com/lower) &amp;"],
noFiles,
),
).toBe("###### SMALL **BOLD** `lower.ts` [LINK](https://example.com/lower) &amp;");
});
it("preserves multiline quotes, literal pipes and code links", () => {
const text = "> Before ||literal||\n> after `src/file.ts`";
const prepared = prepareProse(text, () => true);
expect(prepared).toContain("\\|\\|literal\\|\\|");
expect(prepared).toContain("paseo-inline-code:");
});
});
const streamingParser = createAssistantMarkdownParser({ streaming: true });
describe("streaming prose", () => {
it.each([
"Before **bold",
"Before `code",
"Before [label](https://exa",
"### A **heading",
"- First\n- **Second",
"> First\n> **Second",
"Before **",
"Before ~~strike",
])("retains provisional formatting: %s", (text) => {
expect(parser.render(prepareProse(text, noFiles, true))).toBe(streamingParser.render(text));
["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(
blocks.map((block) => prepareProse([block], noFiles)).join("\n\n"),
);
expect(prepareProse(blocks, noFiles)).toBe(
"!\\[shot]\\[pic]\n\nOther paragraph.\n\n[pic]: shots/app.png",
);
});
it("localizes resolved references before joining blocks with conflicting definitions", () => {
const blocks = [
"[foo][ref]\n\n[ref]: https://one.example",
"[bar][ref]\n\n[ref]: https://two.example",
];
expect(prepareProse(blocks, noFiles)).toBe(
"[foo](<https://one.example>)\n\n[ref]: https://one.example\n\n[bar](<https://two.example>)\n\n[ref]: https://two.example",
);
});
it("carries a bare filename's text and href through the file action", () => {
const prepared = prepareProse(["Open README.md"], (text) => text === "README.md");
const source = {
href: "http://README.md",
text: "README.md",
markup: "linkify",
sourceInfo: "auto",
};
const url = `paseo-file-link:${encodeURIComponent(JSON.stringify(source))}`;
expect(prepared).toBe(`Open [README.md](<${url}>)`);
expect(proseLinkSource(url)).toEqual(source);
const opened: unknown[] = [];
handleProseLinkPress(
url,
(value) => opened.push(value),
() => {
throw new Error("Opened website");
},
);
expect(opened).toEqual([source]);
});
});
it("uppercases heading-six text without changing inline code or link destinations", () => {
expect(
parser.render(
prepareProse("###### Small **bold** `lower.ts` [link](https://example.com/lower)", noFiles),
),
).toBe(
'<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",
"> [!NOTE]\n> Keep this note",
"> Ordinary quote",
])("preserves native block markers through streaming and settlement: %s", (text) => {
const settled = prepareProse(text, noFiles);
const streaming = prepareProse(text, noFiles, true);
expect(settled).toBe(text);
expect(streaming).toBe(settled);
expect(parser.render(settled)).toBe(parser.render(text));
});
it.each([false, true])("keeps distant reference images literal (streaming=%s)", (streaming) => {
const text = "![shot][pic]\n\nOther paragraph.\n\n[pic]: shots/app.png";
const prepared = prepareProse(text, noFiles, streaming);
expect(parser.render(prepared)).toContain("![shot][pic]");
expect(parser.render(prepared)).not.toContain("<img");
});
@@ -1,127 +1,297 @@
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
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 parser = createAssistantMarkdownParser();
const streamingParser = createAssistantMarkdownParser({ streaming: true });
const settled = createSourceParser(false);
const provisional = createSourceParser(true);
const INLINE_CODE_SCHEME = "paseo-inline-code:";
const FILE_LINK_SCHEME = "paseo-file-link:";
export function inlineCodeLinkContent(url: string): string | null {
return url.startsWith(INLINE_CODE_SCHEME)
? decodeURIComponent(url.slice(INLINE_CODE_SCHEME.length))
: null;
}
function escapeText(text: string): string {
return text.replace(/&/g, "&amp;").replace(/[\\`*_{}[\]<>()#+\-.!|~]/g, "\\$&");
export function proseLinkSource(url: string): AssistantFileLinkSource | null {
try {
const code = inlineCodeLinkContent(url);
if (code !== null) return { href: code, text: code, sourceType: "inline-code" };
if (!url.startsWith(FILE_LINK_SCHEME)) return null;
const source: unknown = JSON.parse(decodeURIComponent(url.slice(FILE_LINK_SCHEME.length)));
if (
!source ||
typeof source !== "object" ||
!("href" in source) ||
!("text" in source) ||
typeof source.href !== "string" ||
typeof source.text !== "string"
)
return null;
return { href: source.href, text: source.text, markup: "linkify", sourceInfo: "auto" };
} catch {
return null;
}
}
function renderInline(tokens: Token[], canResolveCode: (text: string) => boolean): string {
const links: { href: string; title: string | null }[] = [];
return tokens
.map((token) => {
switch (token.type) {
case "text":
return escapeText(token.content);
case "softbreak":
return "\n";
case "hardbreak":
return " \n";
case "link_open":
links.push({ href: token.attrGet("href") ?? "", title: token.attrGet("title") });
return "[";
case "link_close": {
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 =
/[^ ]/.test(token.content) &&
(/^[ `]/.test(token.content) || /[ `]$/.test(token.content));
const padding = needsPadding ? " " : "";
const code = `${token.markup}${padding}${token.content}${padding}${token.markup}`;
if (links.length > 0) return code;
if (canResolveCode(token.content)) {
return `[${code}](<${INLINE_CODE_SCHEME}${encodeURIComponent(token.content)}>)`;
}
const matches = parser.linkify.match(token.content);
const match = matches?.length === 1 ? matches[0] : undefined;
if (match?.index === 0 && match.lastIndex === token.content.length)
return `[${code}](<${match.url}>)`;
return code;
}
default:
return token.markup || token.content;
}
})
.join("");
export function handleProseLinkPress(
url: string,
open: (source: AssistantFileLinkSource) => void,
onLinkPress: (url: string) => unknown,
): void {
const source = proseLinkSource(url);
if (source) open(source);
else onLinkPress(url);
}
interface Edit {
start: number;
end: number;
text: string;
}
interface Range {
start: number;
end: number;
}
function applyEdits(source: string, edits: Edit[]): string {
let cursor = 0;
let result = "";
for (const edit of edits.sort((a, b) => a.start - b.start || a.end - b.end)) {
if (edit.start < cursor) continue;
result += source.slice(cursor, edit.start) + edit.text;
cursor = edit.end;
}
return result + source.slice(cursor);
}
function preserveNativeBlockMarker(source: string, rendered: string): string {
const marker = source.match(
/^\[(?:[ xX]|!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION))\](?=\s|$)/,
)?.[0];
if (!marker) return rendered;
const escaped = escapeText(marker);
return rendered.startsWith(escaped) ? marker + rendered.slice(escaped.length) : rendered;
}
/** Keep the existing inline dialect, including literal pipes and tappable inline paths. */
/** Source blocks are presentation's canonical inputs, never reconstructed from a joined run. */
export function prepareProse(
text: string,
blocks: readonly string[],
canResolveCode: (text: string) => boolean,
streaming = false,
visibleLength = Infinity,
): string {
// Reference definitions have the same scope as Paseo's original block rows.
const blocks = splitMarkdownBlocks(text);
return blocks
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 === blocks.length - 1),
prepareBlock(block, canResolveCode, streaming && index === visible.length - 1),
)
.join("\n\n");
}
interface InlinePreparation {
source: string;
token: Token;
trace: InlineTrace;
parser: typeof settled.parser;
env: object;
streaming: boolean;
uppercase: boolean;
canResolveCode: (text: string) => boolean;
protectedRanges: Range[];
referenceRanges: Range[];
edits: Edit[];
}
function insert(context: InlinePreparation, at: number, text: string) {
context.edits.push({ start: at, end: at, text });
}
function contains(ranges: Range[], start: number, end = start + 1): boolean {
return ranges.some((range) => start < range.end && end > range.start);
}
function codeUrl(context: InlinePreparation, content: string): string | null {
if (context.canResolveCode(content)) return INLINE_CODE_SCHEME + encodeURIComponent(content);
const matches = context.parser.linkify.match(content);
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) {
context.protectedRanges.push(span);
context.referenceRanges.push(span);
const insideLink = context.trace.spans.some(
(parent) =>
parent.start < span.start &&
parent.end >= span.end &&
parent.tokens.some((child) => child.type === "link_open"),
);
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 (!insideLink && url) {
insert(context, span.start, "[");
insert(context, span.end, `${closing}](<${url}>)`);
} else if (closing) insert(context, span.end, closing);
}
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: "" });
context.referenceRanges.push(span);
}
function preserveLinkScope(context: InlinePreparation, span: InlineSpan, raw: string, link: Token) {
const end = labelEnd(context, raw, 0);
context.protectedRanges.push({ start: span.start + end, end: span.end });
context.referenceRanges.push(span);
if (raw[end + 1] === "(") return;
// The native parser sees the joined run; bind reference destinations to their source block.
const title = link.attrGet("title");
const quotedTitle =
title === null ? "" : ` "${title.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
context.edits.push({
start: span.start + end,
end: span.end,
text: `](<${link.attrGet("href")}>${quotedTitle})`,
});
}
function prepareSpan(context: InlinePreparation, span: InlineSpan) {
const raw = context.source.slice(span.start, span.end);
const link = span.tokens.find((child) => child.type === "link_open");
const image = span.tokens.find((child) => child.type === "image");
const code = span.tokens.find((child) => child.type === "code_inline");
if (
image ||
raw.startsWith("\\") ||
/^&(?:#\w+|\w+);$/.test(raw) ||
(link && raw.startsWith("<"))
) {
context.protectedRanges.push(span);
context.referenceRanges.push(span);
} else if (link && raw.startsWith("[")) {
preserveLinkScope(context, span, raw, link);
} else if (code && raw.startsWith("`")) {
prepareCode(context, span, raw, code);
} else if (context.streaming && (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: "" });
}
}
function preserveUnresolvedReferences(context: InlinePreparation) {
// Unresolved labels stay literal even when another source block defines them.
for (const match of context.source.matchAll(/!?\[[^\]\n]+\](?:\[[^\]\n]*\])?/g)) {
if (contains(context.referenceRanges, match.index, match.index + match[0].length)) continue;
if (/^\[(?:[ xX]|!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION))\]$/.test(match[0])) continue;
for (let i = 0; i < match[0].length; i++)
if (match[0][i] === "[") insert(context, match.index + i, "\\");
}
}
function prepareText(context: InlinePreparation) {
for (let i = 0; i < context.source.length; i++) {
if (contains(context.protectedRanges, i)) continue;
const char = context.source[i]!;
if (char === "|") insert(context, i, "\\");
if (context.uppercase && char.toUpperCase() !== char)
context.edits.push({ start: i, end: i + 1, text: char.toUpperCase() });
}
}
function prepareLinkifiedFiles(context: InlinePreparation) {
for (const match of context.parser.linkify.match(context.source) ?? []) {
if (
contains(context.protectedRanges, match.index, match.lastIndex) ||
contains(context.referenceRanges, match.index, match.lastIndex) ||
!context.canResolveCode(match.text)
)
continue;
const linkified = context.token.children?.some(
(child) =>
child.type === "link_open" &&
child.markup === "linkify" &&
child.attrGet("href") === match.url,
);
if (!linkified) continue;
const payload: AssistantFileLinkSource = {
href: match.url,
text: match.text,
markup: "linkify",
sourceInfo: "auto",
};
insert(context, match.index, "[");
insert(
context,
match.lastIndex,
`](<${FILE_LINK_SCHEME}${encodeURIComponent(JSON.stringify(payload))}>)`,
);
}
}
function sourcePositions(
token: Token,
heading: Token | undefined,
lines: string[],
lineOffsets: number[],
): number[] {
const positions: number[] = [];
for (const [index, content] of token.content.split("\n").entries()) {
const row = token.map![0] + index;
const rawLine = lines[row]!;
let headingPrefix = 0;
if (heading?.type === "heading_open" && heading.markup.startsWith("#"))
headingPrefix = rawLine.match(/^(?:\s*>\s*)*\s*#{1,6}\s+/)?.[0].length ?? 0;
const column = rawLine.indexOf(content, headingPrefix);
const start = lineOffsets[row]! + Math.max(0, column);
for (let i = 0; i <= content.length; i++) positions.push(start + i);
}
return positions;
}
function prepareBlock(
text: string,
canResolveCode: (text: string) => boolean,
streaming: boolean,
): string {
const tokens = (streaming ? streamingParser : parser).parse(text, {});
const { parser, traces } = streaming ? provisional : settled;
const env = {};
const tokens = parser.parse(text, env);
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) continue;
const [start] = token.map;
const prefixes = token.content.split("\n").map((content, offset) => {
const line = lines[start + offset]!;
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) {
for (const child of token.children) {
if (child.type === "text") child.content = child.content.toUpperCase();
}
}
const rendered = preserveNativeBlockMarker(
token.content,
renderInline(token.children, canResolveCode),
)
.replace(/ $/, "&#32;")
.split("\n");
const replacement = rendered.map(
(line, offset) => `${prefixes[offset] ?? prefixes.at(-1) ?? ""}${line}`,
);
lines.splice(start, token.content.split("\n").length, ...replacement);
const lineOffsets: number[] = [];
let offset = 0;
for (const line of lines) {
lineOffsets.push(offset);
offset += line.length + 1;
}
return lines.join("\n");
const edits: Edit[] = [];
for (const [index, token] of tokens.entries()) {
if (token.type !== "inline" || !token.map) continue;
const trace = traces.get(token)!;
const heading = tokens[index - 1];
const context: InlinePreparation = {
source: token.content,
token,
trace,
parser,
env,
streaming,
uppercase: heading?.type === "heading_open" && heading.tag === "h6",
canResolveCode,
protectedRanges: [],
referenceRanges: [],
edits: [],
};
for (const span of trace.spans) prepareSpan(context, span);
preserveUnresolvedReferences(context);
prepareText(context);
prepareLinkifiedFiles(context);
if (trace.trim)
context.edits.push({
start: token.content.length - trace.trim,
end: token.content.length,
text: "",
});
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]! });
}
return applyEdits(text, edits);
}
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
export interface MarkdownProseProps {
text: string;
sourceBlocks: readonly string[];
streaming: boolean;
kind: "prose" | "quote" | "block";
onLinkPress: (url: string) => boolean;
@@ -6,7 +6,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import type { Theme } from "@/styles/theme";
import type { MarkdownProseProps } from "./props";
import { inlineCodeLinkContent, prepareProse } from "./prepare";
import { handleProseLinkPress, prepareProse } from "./prepare";
const ThemedMarkdown = withUnistyles(EnrichedMarkdownText);
const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true };
@@ -95,6 +95,7 @@ const PROSE_CONTAINER = { marginBottom: -12 };
const NativeProse = memo(function NativeProse({
text,
sourceBlocks,
streaming,
kind,
onLinkPress,
@@ -104,16 +105,11 @@ const NativeProse = memo(function NativeProse({
actions.canResolveFile({ href: content, text: content, sourceType: "inline-code" }),
);
const markdown = useMemo(
() => prepareProse(text, canResolveCode, streaming),
[text, canResolveCode, streaming],
() => prepareProse(sourceBlocks, canResolveCode, streaming, text.length),
[text.length, sourceBlocks, canResolveCode, streaming],
);
const handleLinkPress = useStableEvent(({ url }: { url: string }) => {
const content = inlineCodeLinkContent(url);
if (content !== null) {
actions.open({ href: content, text: content, sourceType: "inline-code" }, "preferred");
return;
}
onLinkPress(url);
handleProseLinkPress(url, (source) => actions.open(source, "preferred"), onLinkPress);
});
return (
<ThemedMarkdown
@@ -1,9 +1,9 @@
import { ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT } from "@/components/assistant-message-render-limit";
import MarkdownIt from "markdown-it";
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
import type Token from "markdown-it/lib/token.mjs";
import type { AssistantMessageItem } from "@/types/stream";
const parser = new MarkdownIt({ html: true });
const parser = createAssistantMarkdownParser();
const partsByBlock = new WeakMap<AssistantMessageItem, AssistantMessageItem[]>();
function splitBlock(block: AssistantMessageItem): AssistantMessageItem[] {
@@ -14,7 +14,11 @@ function splitBlock(block: AssistantMessageItem): AssistantMessageItem[] {
const preserved = tokens.some(
(token) =>
["fence", "code_block", "table_open", "html_block"].includes(token.type) ||
token.children?.some((child) => child.type === "html_inline"),
token.children?.some(
(child) =>
child.type === "html_inline" ||
(child.type === "text" && /<\/?[A-Za-z][^>]*>/.test(child.content)),
),
);
const isQuote = tokens.some((token) => token.type === "blockquote_open");
const hasImage = tokens.some((token) => token.children?.some((child) => child.type === "image"));
@@ -76,10 +80,12 @@ export function groupMarkdownProse(blocks: AssistantMessageItem[]): AssistantMes
previous.text.length + 2 + block.text.length <= ASSISTANT_MESSAGE_RENDER_CHARACTER_LIMIT
) {
previous.text += `\n\n${block.text}`;
previous.markdownProseBlocks = [...previous.markdownProseBlocks!, block.text];
continue;
}
rows.push({
...block,
markdownProseBlocks: block.markdownKind === "block" ? undefined : [block.text],
id: block.markdownKind === "prose" ? block.id.replace(":block:", ":run:") : block.id,
});
}
@@ -0,0 +1,72 @@
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
import type Token from "markdown-it/lib/token.mjs";
export interface InlineSpan {
start: number;
end: number;
tokens: Token[];
}
export interface InlineTrace {
spans: InlineSpan[];
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 });
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: [], suffix: "", trim: 0 };
parseInline(source, md, { ...env, [TRACE]: trace }, output);
inlineTraces.set(output, trace);
};
const rules = parser.inline.ruler.getRules("");
parser.inline.ruler.before("text", "prose_source_positions", (state, silent) => {
if (silent) return false;
for (const rule of rules) {
const start = state.pos;
const count = state.tokens.length;
if (rule(state, false)) {
const trace: InlineTrace = state.env[TRACE];
trace.spans.push({ start, end: state.pos, tokens: state.tokens.slice(count) });
return true;
}
}
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)
traces.set(token, inlineTraces.get(token.children)!);
}
});
return { parser, traces };
}
+3
View File
@@ -754,6 +754,7 @@ export const LiveElapsed = memo(function LiveElapsed({
interface AssistantMessageProps {
markdownKind?: "prose" | "quote" | "block";
markdownProseBlocks?: readonly string[];
renderFullContent?: boolean;
occurrenceKey: string;
message: string;
@@ -1497,6 +1498,7 @@ function MarkdownListView({
export const AssistantMessage = memo(function AssistantMessage({
markdownKind = "block",
markdownProseBlocks,
renderFullContent = false,
occurrenceKey,
message,
@@ -1999,6 +2001,7 @@ export const AssistantMessage = memo(function AssistantMessage({
streaming={phase === "streaming"}
kind={markdownKind}
text={revealedMessage}
sourceBlocks={markdownProseBlocks ?? [message]}
onLinkPress={handleMarkdownLinkPress}
>
{keyedBlocks.map(({ key, block }, index) => (
+1
View File
@@ -715,6 +715,7 @@ export interface AssistantMessageItem {
blockGroupId?: string;
blockIndex?: number;
markdownKind?: "prose" | "quote" | "block";
markdownProseBlocks?: readonly string[];
}
export interface TimelinePosition {