diff --git a/packages/app/src/agent-stream/presentation.test.ts b/packages/app/src/agent-stream/presentation.test.ts index 70ed8862ae..be4d09c547 100644 --- a/packages/app/src/agent-stream/presentation.test.ts +++ b/packages/app/src/agent-stream/presentation.test.ts @@ -123,6 +123,88 @@ function rows(result: { tail: StreamItem[]; head: StreamItem[] }): StreamItem[] return [...result.tail, ...result.head]; } +describe("native prose runs", () => { + it("splits inline workspace images out of prose without mistaking code for images", () => { + const source = hydrateStreamState([ + { + event: assistant("Before `![literal](code.png)` ![Screenshot](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 `![literal](code.png)`", markdownKind: "prose" }, + { text: "![Screenshot](shots/app.png)", markdownKind: "block" }, + { text: "after.", markdownKind: "prose" }, + ]); + }); + + it.each([ + { middle: "```ts\nconst a = 1;\n```", kind: "block" }, + { middle: "> Quoted paragraph.", kind: "quote" }, + { middle: "![Image](image.png)", kind: "block" }, + ])("keeps $kind between prose in a separate row", ({ middle, kind }) => { + const source = hydrateStreamState([ + { event: assistant(`First.\n\n${middle}\n\nLast.`), timestamp: new Date(1000) }, + ]); + const result = createStreamPresentation({ markdown: "prose-runs" })({ + ...presentationOptions, + tail: source, + head: [], + transform: undefined, + }); + expect(result.tail).toMatchObject([ + { id: `${source[0]!.id}:run:0`, text: "First.", markdownKind: "prose" }, + { id: `${source[0]!.id}:block:1`, text: middle, markdownKind: kind }, + { id: `${source[0]!.id}:run:2`, text: "Last.", markdownKind: "prose" }, + ]); + }); + + it("grows the live run and retains promoted rows", () => { + 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."); + 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." }]); + }); + + it("keeps consecutive paragraphs in one selectable row", () => { + const source = hydrateStreamState([ + { event: assistant("First paragraph.\n\nSecond paragraph."), timestamp: new Date(1000) }, + ]); + const result = createStreamPresentation({ markdown: "prose-runs" })({ + ...presentationOptions, + tail: source, + head: [], + transform: undefined, + }); + expect(result.tail).toMatchObject([ + { + id: `${source[0]!.id}:run:0`, + text: "First paragraph.\n\nSecond paragraph.", + markdownKind: "prose", + blockGroupId: source[0]!.id, + }, + ]); + }); +}); + describe("stream presentation through installed plugins", () => { it("offers every source tool call to an installed transformer in Overview mode", () => { const calls = [toolCall("call-1", "bash"), toolCall("call-2", "read")]; diff --git a/packages/app/src/agent-stream/presentation.ts b/packages/app/src/agent-stream/presentation.ts index b89f91f6c0..ed2346f874 100644 --- a/packages/app/src/agent-stream/presentation.ts +++ b/packages/app/src/agent-stream/presentation.ts @@ -9,6 +9,8 @@ import { type ToolCallDetailLevel, } from "@/tool-calls/detail-level/projection"; +import { groupMarkdownProse } from "@/components/markdown-prose/runs"; + interface PresentationInput { tail: StreamItem[]; head: StreamItem[]; @@ -53,7 +55,10 @@ function isReusableBlock(block: AssistantMessageItem, source: AssistantMessageIt } /** Source messages reach plugins before any Markdown splitting or Overview grouping. */ -export function createStreamPresentation() { +export function createStreamPresentation({ + markdown = "blocks", +}: { markdown?: "blocks" | "prose-runs" } = {}) { + const runsBySource = new WeakMap(); const userMessageCache = new WeakMap(); function presentUserMessage(item: UserMessageItem): UserMessageItem { @@ -139,6 +144,27 @@ export function createStreamPresentation() { return blocks; } + function presentRows(item: StreamItem): StreamItem[] { + const blocks = nativeBlocks(item); + if (markdown === "blocks" || item.kind !== "assistant_message") return blocks; + const cached = runsBySource.get(item); + if (cached) return cached; + const previousSource = liveSources.get(item.id); + const previous = previousSource && runsBySource.get(previousSource); + const previousById = new Map(previous?.map((row) => [row.id, row])); + const assistantBlocks = blocks.filter( + (block): block is AssistantMessageItem => block.kind === "assistant_message", + ); + const runs = groupMarkdownProse(assistantBlocks).map((row) => { + const existing = previousById.get(row.id); + return existing?.text === row.text && isReusableBlock(existing, item, row.id) + ? existing + : row; + }); + runsBySource.set(item, runs); + return runs; + } + return (input: PresentationInput) => { // Retained history is not reprojected or regrouped on each live text update. if (historySource !== input.tail || historyTransform !== input.transform) { @@ -146,7 +172,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( - nativeBlocks, + presentRows, ); historySource = input.tail; historyTransform = input.transform; @@ -155,7 +181,7 @@ export function createStreamPresentation() { const promoted: StreamItem[] = []; const nextLiveSources = new Map(); for (const item of projectPluginTimelineItems(input.head, input.transform, "streaming")) { - const blocks = nativeBlocks(item); + const blocks = presentRows(item); if (item.kind === "assistant_message") nextLiveSources.set(item.id, item); if (item.kind === "assistant_message" && blocks.length > 1) { promoted.push(...blocks.slice(0, -1)); diff --git a/packages/app/src/components/markdown-prose/runs.ts b/packages/app/src/components/markdown-prose/runs.ts new file mode 100644 index 0000000000..08de6be718 --- /dev/null +++ b/packages/app/src/components/markdown-prose/runs.ts @@ -0,0 +1,69 @@ +import MarkdownIt from "markdown-it"; +import type { AssistantMessageItem } from "@/types/stream"; + +const parser = new MarkdownIt({ html: true }); +const partsByBlock = new WeakMap(); + +function splitBlock(block: AssistantMessageItem): AssistantMessageItem[] { + const cached = partsByBlock.get(block); + if (cached) return cached; + const tokens = parser.parse(block.text, {}); + const preserved = tokens.some( + (token) => + ["fence", "code_block", "table_open", "html_block"].includes(token.type) || + token.children?.some((child) => child.type === "html_inline"), + ); + const isQuote = tokens.some((token) => token.type === "blockquote_open"); + const hasImage = tokens.some((token) => token.children?.some((child) => child.type === "image")); + let parts: AssistantMessageItem[]; + if (preserved || (isQuote && hasImage)) { + parts = [{ ...block, markdownKind: "block" }]; + } else if (hasImage) { + parts = splitParagraphImages(block); + } else { + parts = [{ ...block, markdownKind: isQuote ? "quote" : "prose" }]; + } + partsByBlock.set(block, parts); + return parts; +} + +function splitParagraphImages(block: AssistantMessageItem): AssistantMessageItem[] { + const parts: AssistantMessageItem[] = []; + const state = new parser.inline.State(block.text, parser, {}, []); + let cursor = 0; + function append(text: string, markdownKind: "prose" | "block", offset: number) { + if (!text.trim()) return; + parts.push({ ...block, text: text.trim(), markdownKind, id: `${block.id}:part:${offset}` }); + } + while (state.pos < state.posMax) { + const start = state.pos; + parser.inline.skipToken(state); + const candidate = block.text.slice(start, state.pos); + if (!candidate.includes("![")) continue; + const inline = parser.parseInline(candidate, {})[0]; + if (!inline?.children?.some((child) => child.type === "image")) continue; + append(block.text.slice(cursor, start), "prose", cursor); + append(candidate, "block", start); + cursor = state.pos; + } + append(block.text.slice(cursor), "prose", cursor); + if (parts.length === 1) parts[0] = { ...parts[0]!, id: block.id }; + return parts; +} + +/** Native selection belongs to a text view, so adjacent prose shares one display row. */ +export function groupMarkdownProse(blocks: AssistantMessageItem[]): AssistantMessageItem[] { + const rows: AssistantMessageItem[] = []; + for (const block of blocks.flatMap(splitBlock)) { + const previous = rows.at(-1); + if (block.markdownKind === "prose" && previous?.markdownKind === "prose") { + previous.text += `\n\n${block.text}`; + continue; + } + rows.push({ + ...block, + id: block.markdownKind === "prose" ? block.id.replace(":block:", ":run:") : block.id, + }); + } + return rows; +} diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 56cf14558b..10de8997d0 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -714,6 +714,7 @@ export interface AssistantMessageItem { /** Display-only fields, assigned after source-item plugin transforms. */ blockGroupId?: string; blockIndex?: number; + markdownKind?: "prose" | "quote" | "block"; } export interface TimelinePosition {