fix(app): group native assistant prose into selectable runs

This commit is contained in:
Mohamed Boudra
2026-10-15 15:32:01 +02:00
parent 80badb3b8f
commit d8f79925d1
4 changed files with 181 additions and 3 deletions
@@ -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")];
+29 -3
View File
@@ -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<AssistantMessageItem, AssistantMessageItem[]>();
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
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<StreamItem>(
nativeBlocks,
presentRows,
);
historySource = input.tail;
historyTransform = input.transform;
@@ -155,7 +181,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 = 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));
@@ -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<AssistantMessageItem, AssistantMessageItem[]>();
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;
}
+1
View File
@@ -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 {