fix(site): keep paragraph breaks in streamed chat text (#30551)

The chat UI dropped every whitespace-only text or reasoning delta.
Providers can stream a paragraph break as its own "\n\n" delta, so the
live preview showed "writing.Like this", and headings and list items
that need a line start rendered inline, until the saved message
replaced the preview. Interrupted turns, saved with one part per
delta, kept the joined text permanently.

Whitespace-only text now extends the preceding block of the same type
and is dropped only where it would start a block. A leading
whitespace delta still leaves the stream state null, so it does not
end the "starting" phase early, which is what the old check was for.
This commit is contained in:
Mathias Fredriksson
2026-10-13 15:47:21 +03:00
committed by GitHub
parent ff5edf2c3a
commit e7b7687209
6 changed files with 78 additions and 27 deletions
@@ -41,11 +41,24 @@ describe("asNonEmptyString", () => {
// ---------------------------------------------------------------------------
describe("appendTextBlock", () => {
it("returns the same blocks when text is empty or whitespace", () => {
it("returns the same blocks for empty text", () => {
const blocks: RenderBlock[] = [{ type: "response", text: "hello" }];
expect(appendTextBlock(blocks, "response", "")).toBe(blocks);
expect(appendTextBlock(blocks, "response", " ")).toBe(blocks);
expect(appendTextBlock(blocks, "thinking", "")).toBe(blocks);
});
it("does not start a block with whitespace-only text", () => {
const blocks: RenderBlock[] = [{ type: "response", text: "hello" }];
expect(appendTextBlock(blocks, "thinking", "\n\t")).toBe(blocks);
const empty: RenderBlock[] = [];
expect(appendTextBlock(empty, "response", " ")).toBe(empty);
});
it("appends whitespace-only text to a matching block", () => {
const blocks: RenderBlock[] = [{ type: "response", text: "writing." }];
expect(appendTextBlock(blocks, "response", "\n\n")).toEqual([
{ type: "response", text: "writing.\n\n" },
]);
});
it("appends a new response block to an empty list", () => {
@@ -17,6 +17,10 @@ const textBlock = (
* Append a text or thinking block to a render block list, merging
* with the previous block when the types match. Text never merges
* across a provider-executed call or between narration and an answer.
* Whitespace-only text extends a matching block but never starts one:
* models stream paragraph breaks as separate "\n\n" deltas, and
* dropping them joins the paragraphs. Returns `blocks` unchanged when
* nothing is appended.
*/
export const appendTextBlock = (
blocks: RenderBlock[],
@@ -24,15 +28,15 @@ export const appendTextBlock = (
text: string,
narration = false,
): RenderBlock[] => {
if (!text.trim()) {
return blocks;
}
const nextBlocks = [...blocks];
const last = nextBlocks[nextBlocks.length - 1];
const last = blocks[blocks.length - 1];
const keepsApart =
last?.type === "response" &&
(last.beforeProviderTool || (last.narration ?? false) !== narration);
if (last && last.type === type && !keepsApart) {
if (!text) {
return blocks;
}
const nextBlocks = [...blocks];
nextBlocks[nextBlocks.length - 1] = textBlock(
type,
`${last.text}${text}`,
@@ -40,8 +44,10 @@ export const appendTextBlock = (
);
return nextBlocks;
}
nextBlocks.push(textBlock(type, text, narration));
return nextBlocks;
if (!text.trim()) {
return blocks;
}
return [...blocks, textBlock(type, text, narration)];
};
type ToolGroupRenderBlock = {
@@ -250,6 +250,20 @@ describe("parseMessageContent", () => {
});
});
it("keeps whitespace-only parts between text parts", () => {
// Interrupted turns are saved with one part per streamed delta.
const result = parseMessageContent([
{ type: "text", text: "\n" },
{ type: "text", text: "writing." },
{ type: "text", text: "\n\n" },
{ type: "text", text: "Like this" },
]);
expect(result.markdown).toBe("writing.\n\nLike this");
expect(result.blocks).toEqual([
{ type: "response", text: "writing.\n\nLike this" },
]);
});
it("parses a reasoning block", () => {
const result = parseMessageContent([
{ type: "reasoning", text: "Let me think..." },
@@ -19,9 +19,12 @@ import type {
StreamState,
} from "./types";
/** Concatenate text chunks, skipping whitespace-only values. */
/**
* Concatenate text chunks. A whitespace-only chunk is dropped only while the
* text is still empty, so paragraph breaks between chunks survive.
*/
const appendText = (current: string, next: string): string => {
if (!next.trim()) {
if (!current && !next.trim()) {
return current;
}
return `${current}${next}`;
@@ -377,7 +380,8 @@ export const getEditableUserMessagePayload = (
fileBlocks: readonly TypesGen.ChatMessagePart[] | undefined;
} => {
// Concatenate text parts verbatim to match the server-side string_agg in
// GetChatUserPromptsByChatID; parseMessageContent/appendText is for streaming and drops whitespace-only chunks.
// GetChatUserPromptsByChatID; parseMessageContent/appendText drops leading
// whitespace-only chunks.
const text = (message.content ?? [])
.filter((part): part is TypesGen.ChatTextPart => part.type === "text")
.map((part) => part.text)
@@ -95,6 +95,22 @@ describe("applyMessagePartToStreamState", () => {
expect(result).toBeNull();
});
it.each([
{ partType: "text", blockType: "response" },
{ partType: "reasoning", blockType: "thinking" },
] as const)(
"keeps whitespace-only $partType deltas between paragraphs",
({ partType, blockType }) => {
let state: StreamState | null = null;
for (const text of ["writing.", "\n\n", "Like this"]) {
state = applyMessagePartToStreamState(state, { type: partType, text });
}
expect(state?.blocks).toEqual([
{ type: blockType, text: "writing.\n\nLike this" },
]);
},
);
it("returns prev for workspace file reference parts", () => {
const prev = createEmptyStreamState();
const result = applyMessagePartToStreamState(prev, {
@@ -26,30 +26,28 @@ export const applyMessagePartToStreamState = (
switch (part.type) {
case "text": {
// Skip empty and whitespace-only deltas so they don't
// create a non-null StreamState with empty blocks, which
// would prematurely end the "starting" phase.
if (!part.text.trim()) {
const blocks = appendTextBlock(
nextState.blocks,
"response",
part.text,
part.narration,
);
// Keeps a leading whitespace-only delta from creating a non-null
// state with no blocks, which would end the "starting" phase early.
if (blocks === nextState.blocks) {
return prev;
}
return {
...nextState,
blocks: appendTextBlock(
nextState.blocks,
"response",
part.text,
part.narration,
),
};
return { ...nextState, blocks };
}
case "reasoning": {
if (!part.text.trim()) {
const blocks = appendTextBlock(nextState.blocks, "thinking", part.text);
if (blocks === nextState.blocks) {
return prev;
}
return {
...nextState,
startedAt: nextState.startedAt ?? part.created_at,
blocks: appendTextBlock(nextState.blocks, "thinking", part.text),
blocks,
};
}
case "tool-call": {