From 7023d20a5b36fd489378df3b1aa30750e6f7deda Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 15 Oct 2026 05:19:55 +0200 Subject: [PATCH] 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. --- docs/agent-stream-performance.md | 7 +- .../app/src/agent-stream/presentation.test.ts | 38 ++ packages/app/src/agent-stream/presentation.ts | 22 +- packages/app/src/agent-stream/view.tsx | 1 + .../components/markdown-prose/prepare.test.ts | 191 +++++---- .../src/components/markdown-prose/prepare.ts | 364 +++++++++++++----- .../src/components/markdown-prose/props.ts | 1 + .../markdown-prose/renderer.native.tsx | 14 +- .../app/src/components/markdown-prose/runs.ts | 12 +- .../markdown-prose/source-inline.ts | 72 ++++ packages/app/src/components/message.tsx | 3 + packages/app/src/types/stream.ts | 1 + 12 files changed, 501 insertions(+), 225 deletions(-) create mode 100644 packages/app/src/components/markdown-prose/source-inline.ts diff --git a/docs/agent-stream-performance.md b/docs/agent-stream-performance.md index 86166cca46..a98e970dce 100644 --- a/docs/agent-stream-performance.md +++ b/docs/agent-stream-performance.md @@ -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 diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index c39142c5bc..24acab79c1 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -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); diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts index 4b95647cb3..95aeced4a3 100644 --- a/packages/app/src/agent-stream/presentation.ts +++ b/packages/app/src/agent-stream/presentation.ts @@ -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 diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 4032bba41a..683ad0dfd3 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -730,6 +730,7 @@ const AgentStreamViewComponent = forwardRef ( false; - -describe("native prose dialect", () => { +describe("source-preserving prose", () => { it.each([ + "A & B", + "# A & B", + "- A & B", + "> A & B", "Literal & and ©", + "Escaped \\* and \\|", + "", + "**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 ``]()", + ); + 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( - '

https://example.com

\n', + expect(prepareProse(["`https://example.com`"], noFiles)).toBe( + "[`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) &"], + noFiles, + ), + ).toBe("###### SMALL **BOLD** `lower.ts` [LINK](https://example.com/lower) &"); }); - - 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]()\n\n[ref]: https://one.example\n\n[bar]()\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( - '
SMALL BOLD lower.ts LINK
\n', - ); -}); - -it.each([false, true])( - "preserves inline semantics and leaves reference definitions untouched (streaming=%s)", - (streaming) => { - const text = - "[foo][ref] & \\* **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("()#+\-.!|~]/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, "&").replace(/"/g, """)}"`; - 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(/ $/, " ") - .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); } diff --git a/packages/app/src/components/markdown-prose/props.ts b/packages/app/src/components/markdown-prose/props.ts index 9739aed4fb..f828fe756b 100644 --- a/packages/app/src/components/markdown-prose/props.ts +++ b/packages/app/src/components/markdown-prose/props.ts @@ -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; diff --git a/packages/app/src/components/markdown-prose/renderer.native.tsx b/packages/app/src/components/markdown-prose/renderer.native.tsx index 1196f5a735..5ee6469edd 100644 --- a/packages/app/src/components/markdown-prose/renderer.native.tsx +++ b/packages/app/src/components/markdown-prose/renderer.native.tsx @@ -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 ( (); 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, }); } diff --git a/packages/app/src/components/markdown-prose/source-inline.ts b/packages/app/src/components/markdown-prose/source-inline.ts new file mode 100644 index 0000000000..2989f12385 --- /dev/null +++ b/packages/app/src/components/markdown-prose/source-inline.ts @@ -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(); + const inlineTraces = new WeakMap(); + 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 }; +} diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index bd62692246..96f6ce0e9a 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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) => ( diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 10de8997d0..d3cea1a391 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -715,6 +715,7 @@ export interface AssistantMessageItem { blockGroupId?: string; blockIndex?: number; markdownKind?: "prose" | "quote" | "block"; + markdownProseBlocks?: readonly string[]; } export interface TimelinePosition {