From 044f55d9ca242ecdaa72c5a0db145c214f17b8c3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 10 Sep 2026 02:57:44 -0700 Subject: [PATCH 1/8] feat(server): show finished paragraphs and code blocks while the response streams Buffered output waited for the whole assistant message before it showed anything. Now the server delivers each finished paragraph and each closed code block as soon as it lands, and keeps the rest buffered. Co-Authored-By: Claude Fable 5.1 --- .../Layers/ProviderRuntimeIngestion.test.ts | 116 +++++++++++++++++- .../Layers/ProviderRuntimeIngestion.ts | 50 ++++++++ 2 files changed, 165 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 1094ab48b7ac..46115406d19d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -55,7 +55,10 @@ import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; -import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; +import { + ProviderRuntimeIngestionLive, + splitBufferedAssistantText, +} from "./ProviderRuntimeIngestion.ts"; import { DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; @@ -3129,6 +3132,86 @@ describe("ProviderRuntimeIngestion", () => { expect(finalMessage?.streaming).toBe(false); }); + it("delivers finished paragraphs while the rest of the message stays buffered", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const codex = ProviderDriverKind.make("codex"); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-paragraph-flush"); + const itemId = asItemId("item-paragraph-flush"); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-paragraph-started"), + provider: codex, + createdAt: now, + threadId, + turnId, + }); + await waitForThread( + harness.readModel, + (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === turnId, + ); + + const emitDelta = (eventId: string, delta: string) => + harness.emit({ + type: "content.delta", + eventId: asEventId(eventId), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { streamKind: "assistant_text", delta }, + }); + + emitDelta("evt-paragraph-1", "First paragraph.\n\nSecond para"); + const afterFirst = await waitForThread(harness.readModel, (thread) => + thread.messages.some( + (message: ProviderRuntimeTestMessage) => message.id === `assistant:${itemId}`, + ), + ); + expect( + afterFirst.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`), + ).toMatchObject({ + text: "First paragraph.\n\n", + streaming: true, + }); + + // An open code block holds the whole block until its closing fence lands. + emitDelta("evt-paragraph-2", "graph.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n"); + await harness.drain(); + expect( + (await harness.readModel()).threads + .find((t) => t.id === threadId) + ?.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)?.text, + ).toBe("First paragraph.\n\nSecond paragraph.\n\n"); + + emitDelta("evt-paragraph-3", "```\n\nTail without newline"); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-paragraph-completed"), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { itemType: "assistant_message", status: "completed" }, + }); + const finalThread = await waitForThread(harness.readModel, (thread) => + thread.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === `assistant:${itemId}` && !message.streaming, + ), + ); + expect( + finalThread.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`) + ?.text, + ).toBe( + "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nTail without newline", + ); + }); + it("spills oversized buffered deltas and still finalizes full assistant text", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -4367,3 +4450,34 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("runtime still processed"); }); }); + +describe("splitBufferedAssistantText", () => { + it("keeps a partial trailing line buffered", () => { + expect(splitBufferedAssistantText("one\n\ntwo")).toEqual({ ready: "one\n\n", rest: "two" }); + expect(splitBufferedAssistantText("one\ntwo")).toEqual({ ready: "", rest: "one\ntwo" }); + }); + + it("does not split inside an open fence", () => { + const open = "intro\n\n```\ncode\n\nmore\n"; + expect(splitBufferedAssistantText(open)).toEqual({ + ready: "intro\n\n", + rest: "```\ncode\n\nmore\n", + }); + expect(splitBufferedAssistantText(`${open}\`\`\`\n\nafter`)).toEqual({ + ready: `${open}\`\`\`\n\n`, + rest: "after", + }); + }); + + it("only closes a fence with the same marker of equal or greater length", () => { + const text = "````\n```\nstill code\n\n````\n\nout\n"; + expect(splitBufferedAssistantText(text)).toEqual({ + ready: "````\n```\nstill code\n\n````\n\n", + rest: "out\n", + }); + expect(splitBufferedAssistantText("~~~\n```\n\nx\n")).toEqual({ + ready: "", + rest: "~~~\n```\n\nx\n", + }); + }); +}); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 964f60d3a306..99d01636f6e8 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -180,6 +180,43 @@ function hasRenderableAssistantText(text: string | undefined): boolean { return (text?.trim().length ?? 0) > 0; } +const MARKDOWN_FENCE_PATTERN = /^(`{3,}|~{3,})/; + +/** + * Splits buffered assistant text at the last blank line that is not inside + * an open fenced code block. `ready` is safe to deliver now because the + * markdown before it will not change shape as more text arrives. `rest` stays + * buffered until the next boundary or completion. Only fully terminated lines + * count, so a trailing partial line never leaks. + */ +export function splitBufferedAssistantText(text: string): { ready: string; rest: string } { + let openFence: string | null = null; + let boundary = -1; + let lineStart = 0; + for (;;) { + const newline = text.indexOf("\n", lineStart); + if (newline === -1) { + break; + } + const line = text.slice(lineStart, newline).trimStart(); + const fence = MARKDOWN_FENCE_PATTERN.exec(line)?.[1]; + if (fence) { + if (openFence === null) { + openFence = fence; + } else if (fence[0] === openFence[0] && fence.length >= openFence.length) { + openFence = null; + } + } else if (openFence === null && line.length === 0 && lineStart > 0) { + boundary = newline + 1; + } + lineStart = newline + 1; + } + if (boundary === -1) { + return { ready: "", rest: text }; + } + return { ready: text.slice(0, boundary), rest: text.slice(boundary) }; +} + function proposedPlanIdForTurn(threadId: ThreadId, turnId: TurnId): string { return `plan:${threadId}:turn:${turnId}`; } @@ -1111,6 +1148,19 @@ const make = Effect.gen(function* () { onNone: () => delta, onSome: (text) => `${text}${delta}`, }); + + // Deliver finished paragraphs and closed code blocks early so the + // user sees progress without token-by-token repaints. + const { ready, rest } = splitBufferedAssistantText(nextText); + if (hasRenderableAssistantText(ready) && rest.length <= MAX_BUFFERED_ASSISTANT_CHARS) { + if (rest.length > 0) { + yield* Cache.set(bufferedAssistantTextByMessageId, messageId, rest); + } else { + yield* Cache.invalidate(bufferedAssistantTextByMessageId, messageId); + } + return ready; + } + if (nextText.length <= MAX_BUFFERED_ASSISTANT_CHARS) { yield* Cache.set(bufferedAssistantTextByMessageId, messageId, nextText); return ""; From ef7588f53ba131b0d51fde4f37ffa1a5561e7d62 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 11 Sep 2026 21:09:32 -0700 Subject: [PATCH 2/8] fix(server): deliver code blocks at their closing fence and accept CRLF boundaries A fence line with an info string no longer closes an open block. A closing fence is now a delivery boundary, so a finished code block does not wait for the next blank line. Whitespace-only lines, including CRLF, count as blank. Co-Authored-By: Claude Fable 5.1 --- .../Layers/ProviderRuntimeIngestion.test.ts | 18 ++++++++++++--- .../Layers/ProviderRuntimeIngestion.ts | 22 ++++++++++++------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 46115406d19d..b55e646cbae7 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -4457,18 +4457,30 @@ describe("splitBufferedAssistantText", () => { expect(splitBufferedAssistantText("one\ntwo")).toEqual({ ready: "", rest: "one\ntwo" }); }); - it("does not split inside an open fence", () => { + it("does not split inside an open fence and delivers the block at its closing fence", () => { const open = "intro\n\n```\ncode\n\nmore\n"; expect(splitBufferedAssistantText(open)).toEqual({ ready: "intro\n\n", rest: "```\ncode\n\nmore\n", }); - expect(splitBufferedAssistantText(`${open}\`\`\`\n\nafter`)).toEqual({ - ready: `${open}\`\`\`\n\n`, + expect(splitBufferedAssistantText(`${open}\`\`\`\nafter`)).toEqual({ + ready: `${open}\`\`\`\n`, rest: "after", }); }); + it("does not treat a fence with an info string as a closing fence", () => { + const text = "```\n```javascript\nstill code\n\nmore\n"; + expect(splitBufferedAssistantText(text)).toEqual({ ready: "", rest: text }); + }); + + it("treats CRLF blank lines as boundaries", () => { + expect(splitBufferedAssistantText("one\r\n\r\ntwo")).toEqual({ + ready: "one\r\n\r\n", + rest: "two", + }); + }); + it("only closes a fence with the same marker of equal or greater length", () => { const text = "````\n```\nstill code\n\n````\n\nout\n"; expect(splitBufferedAssistantText(text)).toEqual({ diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 99d01636f6e8..a7594bf01127 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -183,11 +183,11 @@ function hasRenderableAssistantText(text: string | undefined): boolean { const MARKDOWN_FENCE_PATTERN = /^(`{3,}|~{3,})/; /** - * Splits buffered assistant text at the last blank line that is not inside - * an open fenced code block. `ready` is safe to deliver now because the - * markdown before it will not change shape as more text arrives. `rest` stays - * buffered until the next boundary or completion. Only fully terminated lines - * count, so a trailing partial line never leaks. + * Splits buffered assistant text at the last blank line or closing code fence + * that is not inside an open fenced code block. `ready` is safe to deliver now + * because the markdown before it will not change shape as more text arrives. + * `rest` stays buffered until the next boundary or completion. Only fully + * terminated lines count, so a trailing partial line never leaks. */ export function splitBufferedAssistantText(text: string): { ready: string; rest: string } { let openFence: string | null = null; @@ -198,13 +198,19 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest: if (newline === -1) { break; } - const line = text.slice(lineStart, newline).trimStart(); + const line = text.slice(lineStart, newline).trim(); const fence = MARKDOWN_FENCE_PATTERN.exec(line)?.[1]; - if (fence) { + if (fence !== undefined) { if (openFence === null) { openFence = fence; - } else if (fence[0] === openFence[0] && fence.length >= openFence.length) { + } else if ( + fence[0] === openFence[0] && + fence.length >= openFence.length && + line.length === fence.length + ) { + // CommonMark: a closing fence carries no info string. openFence = null; + boundary = newline + 1; } } else if (openFence === null && line.length === 0 && lineStart > 0) { boundary = newline + 1; From 6411840f70cad81343abfa4ad588680881236273 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 11 Sep 2026 21:18:40 -0700 Subject: [PATCH 3/8] fix(server): ignore fences indented four or more spaces CommonMark allows up to three spaces before a fence. A deeper indent is code inside the block, so it must not close it. Co-Authored-By: Claude Fable 5.1 --- .../Layers/ProviderRuntimeIngestion.test.ts | 9 +++++++++ .../orchestration/Layers/ProviderRuntimeIngestion.ts | 10 ++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b55e646cbae7..b2e72b01a39d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -4474,6 +4474,15 @@ describe("splitBufferedAssistantText", () => { expect(splitBufferedAssistantText(text)).toEqual({ ready: "", rest: text }); }); + it("treats a fence indented four or more spaces as code, not a closing fence", () => { + const text = "```\n ```\n\nstill code\n"; + expect(splitBufferedAssistantText(text)).toEqual({ ready: "", rest: text }); + expect(splitBufferedAssistantText("```\n ```\nafter")).toEqual({ + ready: "```\n ```\n", + rest: "after", + }); + }); + it("treats CRLF blank lines as boundaries", () => { expect(splitBufferedAssistantText("one\r\n\r\ntwo")).toEqual({ ready: "one\r\n\r\n", diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index a7594bf01127..d14414c67437 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -180,7 +180,9 @@ function hasRenderableAssistantText(text: string | undefined): boolean { return (text?.trim().length ?? 0) > 0; } -const MARKDOWN_FENCE_PATTERN = /^(`{3,}|~{3,})/; +// CommonMark allows up to three spaces of indentation before a fence. Four or +// more means the line is content inside the block, not a fence. +const MARKDOWN_FENCE_PATTERN = /^ {0,3}(`{3,}|~{3,})/; /** * Splits buffered assistant text at the last blank line or closing code fence @@ -198,7 +200,7 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest: if (newline === -1) { break; } - const line = text.slice(lineStart, newline).trim(); + const line = text.slice(lineStart, newline).trimEnd(); const fence = MARKDOWN_FENCE_PATTERN.exec(line)?.[1]; if (fence !== undefined) { if (openFence === null) { @@ -206,13 +208,13 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest: } else if ( fence[0] === openFence[0] && fence.length >= openFence.length && - line.length === fence.length + line.trimStart().length === fence.length ) { // CommonMark: a closing fence carries no info string. openFence = null; boundary = newline + 1; } - } else if (openFence === null && line.length === 0 && lineStart > 0) { + } else if (openFence === null && line.trim().length === 0 && lineStart > 0) { boundary = newline + 1; } lineStart = newline + 1; From ade9a3f33116f6c9ea2abcdace80e0be1428977e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 12 Sep 2026 16:38:32 -0700 Subject: [PATCH 4/8] feat(web): fade in streamed blocks and hide code until highlighted Chunked delivery made each paragraph pop in. New blocks now fade in over 240ms, gated on a data-streaming attribute so opening a finished thread never replays it. Code blocks keep their space but stay invisible until Shiki has colored them, so plain text never flashes first. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/components/ChatMarkdown.tsx | 12 +++++++++++- apps/web/src/index.css | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 3aeaaa5b8443..b0209754b0d0 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -3196,7 +3196,15 @@ const CHAT_MARKDOWN_COMPONENTS = { resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]} fallback={
{children}
} > - {children}}> + {/* Reserve the block's height but stay hidden until Shiki has colored + it, so plain text never flashes before the highlighted version. */} + + {children} + + } + > diff --git a/apps/web/src/index.css b/apps/web/src/index.css index a7f6d91f2e40..082596a94dfd 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1891,6 +1891,22 @@ code { background: transparent !important; } +/* Paragraphs and code blocks arrive in chunks while a response streams. Fade + each new block in so the chunk does not pop. @starting-style only applies + when an element is first inserted, and the rule is gated on data-streaming, + so opening a finished thread never replays the fade. Opacity only, one + shot, no layout change. */ +@media (prefers-reduced-motion: no-preference) { + .chat-markdown[data-streaming] > *, + .chat-markdown[data-streaming] .chat-markdown-shiki { + transition: opacity 240ms ease-out; + + @starting-style { + opacity: 0; + } + } +} + /* Diagnostics-style tables: row separators only, uppercase headers, and a scroll-fade container for horizontal overflow. The root chat-markdown wrapping rules (overflow-wrap: anywhere) would let columns shrink to single From b370402e365fed67a587de6a6ce22f38ad50f459 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 12 Sep 2026 17:38:38 -0700 Subject: [PATCH 5/8] feat: pace streamed paragraphs and glide the timeline to the live edge Fast models finished several paragraphs a second, and each one repainted the message and jumped the list. The server now holds paragraphs that finish within 400ms of the last delivery and lands them together. While a turn is running, the timeline follows the end with a smooth scroll instead of a jump. The fade-in is slower, 600ms. Co-Authored-By: Claude Fable 5.1 --- .../Layers/ProviderRuntimeIngestion.test.ts | 53 ++++++++++++++++++- .../Layers/ProviderRuntimeIngestion.ts | 36 +++++++++++-- .../components/chat/MessagesTimeline.test.tsx | 13 +++++ .../src/components/chat/MessagesTimeline.tsx | 13 ++++- apps/web/src/index.css | 2 +- 5 files changed, 110 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index b2e72b01a39d..bce84466fa3c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -3153,12 +3153,14 @@ describe("ProviderRuntimeIngestion", () => { (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === turnId, ); + // Each delta lands well outside the pacing window of the one before. + let deltaCount = 0; const emitDelta = (eventId: string, delta: string) => harness.emit({ type: "content.delta", eventId: asEventId(eventId), provider: codex, - createdAt: now, + createdAt: new Date(Date.parse(now) + ++deltaCount * 1_000).toISOString(), threadId, turnId, itemId, @@ -3212,6 +3214,55 @@ describe("ProviderRuntimeIngestion", () => { ); }); + it("holds paragraphs that finish inside the pacing window and lands them together", async () => { + const harness = await createHarness(); + const codex = ProviderDriverKind.make("codex"); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-paced"); + const itemId = asItemId("item-paced"); + const t0 = Date.parse("2026-01-01T00:00:00.000Z"); + const at = (offsetMs: number) => new Date(t0 + offsetMs).toISOString(); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-paced-started"), + provider: codex, + createdAt: at(0), + threadId, + turnId, + }); + await waitForThread( + harness.readModel, + (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === turnId, + ); + const emitDelta = (eventId: string, delta: string, offsetMs: number) => + harness.emit({ + type: "content.delta", + eventId: asEventId(eventId), + provider: codex, + createdAt: at(offsetMs), + threadId, + turnId, + itemId, + payload: { streamKind: "assistant_text", delta }, + }); + const messageText = async () => + (await harness.readModel()).threads + .find((t) => t.id === threadId) + ?.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)?.text; + + emitDelta("evt-paced-1", "One.\n\n", 0); + emitDelta("evt-paced-2", "Two.\n\n", 100); + emitDelta("evt-paced-3", "Three.\n\n", 200); + await harness.drain(); + // The first paragraph lands right away. The next two are inside the window. + expect(await messageText()).toBe("One.\n\n"); + + emitDelta("evt-paced-4", "Four.\n\n", 500); + await harness.drain(); + expect(await messageText()).toBe("One.\n\nTwo.\n\nThree.\n\nFour.\n\n"); + }); + it("spills oversized buffered deltas and still finalizes full assistant text", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index d14414c67437..3ec564a99dca 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -107,6 +107,11 @@ const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120); const TASK_DESCRIPTION_BY_TASK_CACHE_CAPACITY = 10_000; const TASK_DESCRIPTION_BY_TASK_TTL = Duration.minutes(120); const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; +// Paragraphs that finish within this window after a delivery stay buffered +// and land together on the next one. Keeps fast models from repainting the +// message several times a second while still showing the first paragraph +// as soon as it is done. +const MIN_ASSISTANT_DELIVERY_INTERVAL_MS = 400; const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; type TurnStartRequestedDomainEvent = Extract< @@ -972,6 +977,12 @@ const make = Effect.gen(function* () { timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, lookup: () => Effect.succeed(""), }); + // Epoch millis of the last early delivery per message, for pacing. + const lastAssistantDeliveryAtByMessageId = yield* Cache.make({ + capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY, + timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, + lookup: () => Effect.succeed(0), + }); const assistantSegmentStateByTurnKey = yield* Cache.make({ capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, @@ -1148,7 +1159,7 @@ const make = Effect.gen(function* () { }); }); - const appendBufferedAssistantText = (messageId: MessageId, delta: string) => + const appendBufferedAssistantText = (messageId: MessageId, delta: string, atMillis: number) => Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( Effect.flatMap((existingText) => Effect.gen(function* () { @@ -1160,12 +1171,23 @@ const make = Effect.gen(function* () { // Deliver finished paragraphs and closed code blocks early so the // user sees progress without token-by-token repaints. const { ready, rest } = splitBufferedAssistantText(nextText); - if (hasRenderableAssistantText(ready) && rest.length <= MAX_BUFFERED_ASSISTANT_CHARS) { + const lastDeliveredAt = Option.getOrUndefined( + yield* Cache.getOption(lastAssistantDeliveryAtByMessageId, messageId), + ); + const paced = + lastDeliveredAt === undefined || + atMillis - lastDeliveredAt >= MIN_ASSISTANT_DELIVERY_INTERVAL_MS; + if ( + paced && + hasRenderableAssistantText(ready) && + rest.length <= MAX_BUFFERED_ASSISTANT_CHARS + ) { if (rest.length > 0) { yield* Cache.set(bufferedAssistantTextByMessageId, messageId, rest); } else { yield* Cache.invalidate(bufferedAssistantTextByMessageId, messageId); } + yield* Cache.set(lastAssistantDeliveryAtByMessageId, messageId, atMillis); return ready; } @@ -1191,7 +1213,9 @@ const make = Effect.gen(function* () { ); const clearBufferedAssistantText = (messageId: MessageId) => - Cache.invalidate(bufferedAssistantTextByMessageId, messageId); + Cache.invalidate(bufferedAssistantTextByMessageId, messageId).pipe( + Effect.andThen(Cache.invalidate(lastAssistantDeliveryAtByMessageId, messageId)), + ); const appendBufferedProposedPlan = (planId: string, delta: string, createdAt: string) => Cache.getOption(bufferedProposedPlanById, planId).pipe( @@ -1733,7 +1757,11 @@ const make = Effect.gen(function* () { : "buffered", ); if (assistantDeliveryMode === "buffered") { - const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); + const spillChunk = yield* appendBufferedAssistantText( + assistantMessageId, + assistantDelta, + Date.parse(now), + ); if (spillChunk.length > 0) { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.delta", diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 6991f4432594..f80a38943c6a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -901,6 +901,19 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain(" { + const entries = [buildUserTimelineEntry("Hello")]; + const working = renderToStaticMarkup( + , + ); + expect(working).toContain('data-maintain-scroll-at-end-animated="true"'); + + const idle = renderToStaticMarkup( + , + ); + expect(idle).toContain('data-maintain-scroll-at-end-animated="false"'); + }); + it("keeps reserved end space when tool work starts while reading history", () => { const turnId = TurnId.make("turn-with-active-tool"); const firstEntry = buildUserTimelineEntry("Run the command."); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f388db604450..fb8ba4d1c0f1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -227,6 +227,7 @@ import { import { createContextPresentationRegistry } from "../contextPresentationRegistry"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; import type { ChatMarkdownContextReference } from "../ChatMarkdown"; +import { useMediaQuery } from "~/hooks/useMediaQuery"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; @@ -348,6 +349,13 @@ const TIMELINE_MAINTAIN_SCROLL_AT_END = { layout: true, }, } as const satisfies MaintainScrollAtEndOptions; +// Streamed text lands a paragraph at a time. A smooth scroll to the end +// turns each landing into a short glide instead of a jump. Thread switches +// and layout settles keep the instant variant so nothing visibly travels. +const TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH = { + ...TIMELINE_MAINTAIN_SCROLL_AT_END, + animated: true, +} as const satisfies MaintainScrollAtEndOptions; // --------------------------------------------------------------------------- // Props (public API) @@ -470,6 +478,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ new Set(), ); const listIdentityKey = displayThreadKey ?? routeThreadKey; + const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); const listIdentityRef = useRef(listIdentityKey); const previousLatestTurnRef = useRef(latestTurn); let paintedExpandedTurnIds = expandedTurnIds; @@ -977,7 +986,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ !liveFollowEnabled || disclosureToggleSettling ? false - : TIMELINE_MAINTAIN_SCROLL_AT_END + : isWorking && !prefersReducedMotion + ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH + : TIMELINE_MAINTAIN_SCROLL_AT_END } maintainVisibleContentPosition={ citationPositioning ? false : maintainVisibleContentPosition diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 082596a94dfd..e7c5d919a211 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1899,7 +1899,7 @@ code { @media (prefers-reduced-motion: no-preference) { .chat-markdown[data-streaming] > *, .chat-markdown[data-streaming] .chat-markdown-shiki { - transition: opacity 240ms ease-out; + transition: opacity 600ms ease-out; @starting-style { opacity: 0; From 6c6189411994f708c9153155e890bd92d8742fe0 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 12 Sep 2026 18:09:57 -0700 Subject: [PATCH 6/8] fix(web): reorder timeline rows without restarting their transitions LegendList sorts its row containers back into DOM order with insertBefore about half a second after positions change. That counts as a fresh insert, so the streamed-block fade replayed on the first heading and it flickered. The patch uses Element.moveBefore when the browser has it, which keeps the subtree's state and does not restart @starting-style transitions. Co-Authored-By: Claude Fable 5.1 --- patches/@legendapp__list@3.3.5.patch | 162 ++++++++++++++++++++++++--- pnpm-lock.yaml | 10 +- 2 files changed, 152 insertions(+), 20 deletions(-) diff --git a/patches/@legendapp__list@3.3.5.patch b/patches/@legendapp__list@3.3.5.patch index 98784b999549..88b739824b01 100644 --- a/patches/@legendapp__list@3.3.5.patch +++ b/patches/@legendapp__list@3.3.5.patch @@ -1,5 +1,5 @@ diff --git a/keyboard.d.ts b/keyboard.d.ts -index 367945cdfa8a8c260b7a127657a75c016c9ab46f..0ac7bf8b8e9c386058b2374199e58fe8c92d39b3 100644 +index 367945c..0ac7bf8 100644 --- a/keyboard.d.ts +++ b/keyboard.d.ts @@ -279,7 +279,7 @@ type KeyboardChatComposerInsetListRef = { @@ -23,7 +23,7 @@ index 367945cdfa8a8c260b7a127657a75c016c9ab46f..0ac7bf8b8e9c386058b2374199e58fe8 } & React.RefAttributes) => React.ReactElement | null; diff --git a/keyboard.js b/keyboard.js -index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..321c4c305129dd3ce3f06127c47d51720a73fb83 100644 +index 6645bcb..321c4c3 100644 --- a/keyboard.js +++ b/keyboard.js @@ -33,19 +33,22 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. @@ -119,7 +119,7 @@ index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..321c4c305129dd3ce3f06127c47d5172 renderScrollComponent: memoList, ...rest diff --git a/keyboard.mjs b/keyboard.mjs -index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..111eb242ba9d9ade1cb912550f7df85cf4f361ee 100644 +index 87b38b9..111eb24 100644 --- a/keyboard.mjs +++ b/keyboard.mjs @@ -1,7 +1,7 @@ @@ -224,7 +224,7 @@ index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..111eb242ba9d9ade1cb912550f7df85c renderScrollComponent: memoList, ...rest diff --git a/react-native.d.ts b/react-native.d.ts -index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6aba6636e5 100644 +index ce1fe00..3ccf6f1 100644 --- a/react-native.d.ts +++ b/react-native.d.ts @@ -293,6 +293,12 @@ interface LegendListSpecificProps { @@ -241,13 +241,13 @@ index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6a * Number of columns to render items in. * @default 1 diff --git a/react-native.js b/react-native.js -index b3c5a306b293f797a8b338adfca3060c0f6db22b..5ac6bbe8fe40bf252fefa6b885c2196d0677d75f 100644 +index b3c5a30..5ac6bbe 100644 --- a/react-native.js +++ b/react-native.js @@ -717,6 +717,15 @@ function hasActiveInitialScroll(state) { return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; } - + +// Size-only changes may not emit a scroll event to refresh the edge signal. +function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) { + const { queuedInitialLayout, scroll, scrollLength } = ctx.state; @@ -809,13 +809,13 @@ index b3c5a306b293f797a8b338adfca3060c0f6db22b..5ac6bbe8fe40bf252fefa6b885c2196d recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { diff --git a/react-native.mjs b/react-native.mjs -index 40e87cda8c9bc79a889e5542f29af429a24b24d4..93aac741d2cf77ce35996362439108176f19da7a 100644 +index 40e87cd..93aac74 100644 --- a/react-native.mjs +++ b/react-native.mjs @@ -696,6 +696,15 @@ function hasActiveInitialScroll(state) { return !!(state == null ? void 0 : state.initialScroll) && !state.didFinishInitialScroll; } - + +// Size-only changes may not emit a scroll event to refresh the edge signal. +function getIsAtEnd(ctx, contentSize = getContentSize(ctx)) { + const { queuedInitialLayout, scroll, scrollLength } = ctx.state; @@ -1376,8 +1376,78 @@ index 40e87cda8c9bc79a889e5542f29af429a24b24d4..93aac741d2cf77ce3599636243910817 onScroll: onScrollHandler, recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { +diff --git a/react-native.web.js b/react-native.web.js +index 914d2da..8b88b09 100644 +--- a/react-native.web.js ++++ b/react-native.web.js +@@ -5651,6 +5651,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { + }); + + // src/utils/reordering.ts ++// Element.moveBefore keeps the moved subtree's state and does not count as an ++// insertion, so CSS @starting-style transitions and iframes inside a row do not ++// restart when the list reorders its containers. insertBefore is the fallback. ++function moveChildBefore(container, element, reference) { ++ if (typeof container.moveBefore === "function") { ++ container.moveBefore(element, reference); ++ } else if (reference) { ++ container.insertBefore(element, reference); ++ } else { ++ container.appendChild(element); ++ } ++} + function sortDOMElements(container, indexByElement) { + const elements = Array.from(container.children); + if (elements.length <= 1) return elements; +@@ -5690,9 +5702,9 @@ function sortDOMElements(container, indexByElement) { + } + } + if (nextStableElement) { +- container.insertBefore(element, nextStableElement); ++ moveChildBefore(container, element, nextStableElement); + } else { +- container.appendChild(element); ++ moveChildBefore(container, element, null); + } + } + } +diff --git a/react-native.web.mjs b/react-native.web.mjs +index 95465f2..d7cbe1c 100644 +--- a/react-native.web.mjs ++++ b/react-native.web.mjs +@@ -5630,6 +5630,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { + }); + + // src/utils/reordering.ts ++// Element.moveBefore keeps the moved subtree's state and does not count as an ++// insertion, so CSS @starting-style transitions and iframes inside a row do not ++// restart when the list reorders its containers. insertBefore is the fallback. ++function moveChildBefore(container, element, reference) { ++ if (typeof container.moveBefore === "function") { ++ container.moveBefore(element, reference); ++ } else if (reference) { ++ container.insertBefore(element, reference); ++ } else { ++ container.appendChild(element); ++ } ++} + function sortDOMElements(container, indexByElement) { + const elements = Array.from(container.children); + if (elements.length <= 1) return elements; +@@ -5669,9 +5681,9 @@ function sortDOMElements(container, indexByElement) { + } + } + if (nextStableElement) { +- container.insertBefore(element, nextStableElement); ++ moveChildBefore(container, element, nextStableElement); + } else { +- container.appendChild(element); ++ moveChildBefore(container, element, null); + } + } + } diff --git a/react.js b/react.js -index 914d2dafaafa001c9ab6791a0a0581659295e333..a2df18d450eebe451fda42bab47d1658458edbb1 100644 +index 914d2da..0c2917d 100644 --- a/react.js +++ b/react.js @@ -4702,7 +4702,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { @@ -1403,7 +1473,38 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..a2df18d450eebe451fda42bab47d1658 } return nextSize; } -@@ -6446,8 +6453,8 @@ function ScrollAdjust() { +@@ -5651,6 +5658,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { + }); + + // src/utils/reordering.ts ++// Element.moveBefore keeps the moved subtree's state and does not count as an ++// insertion, so CSS @starting-style transitions and iframes inside a row do not ++// restart when the list reorders its containers. insertBefore is the fallback. ++function moveChildBefore(container, element, reference) { ++ if (typeof container.moveBefore === "function") { ++ container.moveBefore(element, reference); ++ } else if (reference) { ++ container.insertBefore(element, reference); ++ } else { ++ container.appendChild(element); ++ } ++} + function sortDOMElements(container, indexByElement) { + const elements = Array.from(container.children); + if (elements.length <= 1) return elements; +@@ -5690,9 +5709,9 @@ function sortDOMElements(container, indexByElement) { + } + } + if (nextStableElement) { +- container.insertBefore(element, nextStableElement); ++ moveChildBefore(container, element, nextStableElement); + } else { +- container.appendChild(element); ++ moveChildBefore(container, element, null); + } + } + } +@@ -6446,8 +6465,8 @@ function ScrollAdjust() { window.getComputedStyle(contentNode)[axis.paddingEndProp] ); const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`; @@ -1414,7 +1515,7 @@ index 914d2dafaafa001c9ab6791a0a0581659295e333..a2df18d450eebe451fda42bab47d1658 scrollBy(); if (resetPaddingRafRef.current !== void 0) { diff --git a/react.mjs b/react.mjs -index 95465f2ab89ce41a10553f58af83618f7310e83c..25cf046f2c3141ddce5a0b6e28c354b865331b4f 100644 +index 95465f2..b73bfdf 100644 --- a/react.mjs +++ b/react.mjs @@ -4681,7 +4681,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { @@ -1440,7 +1541,38 @@ index 95465f2ab89ce41a10553f58af83618f7310e83c..25cf046f2c3141ddce5a0b6e28c354b8 } return nextSize; } -@@ -6425,8 +6432,8 @@ function ScrollAdjust() { +@@ -5630,6 +5637,18 @@ var ContainerSlot = typedMemo(function ContainerSlot2(props) { + }); + + // src/utils/reordering.ts ++// Element.moveBefore keeps the moved subtree's state and does not count as an ++// insertion, so CSS @starting-style transitions and iframes inside a row do not ++// restart when the list reorders its containers. insertBefore is the fallback. ++function moveChildBefore(container, element, reference) { ++ if (typeof container.moveBefore === "function") { ++ container.moveBefore(element, reference); ++ } else if (reference) { ++ container.insertBefore(element, reference); ++ } else { ++ container.appendChild(element); ++ } ++} + function sortDOMElements(container, indexByElement) { + const elements = Array.from(container.children); + if (elements.length <= 1) return elements; +@@ -5669,9 +5688,9 @@ function sortDOMElements(container, indexByElement) { + } + } + if (nextStableElement) { +- container.insertBefore(element, nextStableElement); ++ moveChildBefore(container, element, nextStableElement); + } else { +- container.appendChild(element); ++ moveChildBefore(container, element, null); + } + } + } +@@ -6425,8 +6444,8 @@ function ScrollAdjust() { window.getComputedStyle(contentNode)[axis.paddingEndProp] ); const temporaryPaddingEnd = `${(currentPaddingEnd || 0) + pad}px`; @@ -1451,7 +1583,7 @@ index 95465f2ab89ce41a10553f58af83618f7310e83c..25cf046f2c3141ddce5a0b6e28c354b8 scrollBy(); if (resetPaddingRafRef.current !== void 0) { diff --git a/reanimated.d.ts b/reanimated.d.ts -index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613dae950fe 100644 +index e504332..2ce6383 100644 --- a/reanimated.d.ts +++ b/reanimated.d.ts @@ -294,6 +294,12 @@ interface LegendListSpecificProps { @@ -1468,7 +1600,7 @@ index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613 * Number of columns to render items in. * @default 1 diff --git a/reanimated.js b/reanimated.js -index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..fc03be2190c046f743ffde45ed189ee7868d0cec 100644 +index f1265fa..fc03be2 100644 --- a/reanimated.js +++ b/reanimated.js @@ -115,8 +115,10 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( @@ -1631,7 +1763,7 @@ index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..fc03be2190c046f743ffde45ed189ee7 renderScrollComponent: renderReanimatedScrollComponent, ...IsNewArchitecture ? { stickyPositionComponentInternal } : {} diff --git a/reanimated.mjs b/reanimated.mjs -index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..6ab929250c8924e2d9919ee09a4f774238fbb90a 100644 +index 29a00d5..6ab9292 100644 --- a/reanimated.mjs +++ b/reanimated.mjs @@ -1,7 +1,7 @@ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06b0578ae057..26f13005de4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,7 +91,7 @@ patchedDependencies: '@effect/vitest@4.0.0-rc.112': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 - '@legendapp/list@3.3.5': a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b + '@legendapp/list@3.3.5': 680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806 '@pierre/diffs@1.3.0-beta.10': 0ccee155b93b63d810e2c1a40c1fd676fb6fbcfa72cf6430dcedf1a3ae475ab4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd @@ -248,7 +248,7 @@ importers: version: 57.0.14(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(expo@57.0.18)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@material/material-color-utilities': specifier: 0.3.0 version: 0.3.0 @@ -607,7 +607,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 'catalog:' - version: 3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) @@ -13997,7 +13997,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.3(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) @@ -14005,7 +14005,7 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@legendapp/list@3.3.5(patch_hash=a05e968651a1352d2f374324016d1034b763b9fb2c5bf5f95111540d6899a52b)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.3.5(patch_hash=680cc6a5c5b4a4032e467e7b3fde22f89a84c0ee2e6eac6fda737d6277cc0806)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) From aba3b66af5d16ea3bd1e218df23cb5de2f5d8808 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 13 Sep 2026 18:52:20 -0700 Subject: [PATCH 7/8] fix: pace deliveries on the server clock and snap during thread switches OpenCode stamps every delta of a part with the part's start time, so pacing on the event time never saw a gap after the first flush and stopped delivering early. Pacing now reads the server clock. The timeline also snaps to the end while a thread switch settles, even if the new thread is mid-turn, so the first pin on a fresh thread does not glide. Co-Authored-By: Claude Fable 5.1 --- .../Layers/ProviderRuntimeIngestion.test.ts | 74 +++++++++++++------ .../Layers/ProviderRuntimeIngestion.ts | 5 +- .../components/chat/MessagesTimeline.test.tsx | 74 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 21 +++++- 4 files changed, 149 insertions(+), 25 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index bce84466fa3c..4da82c8a641b 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -300,7 +300,24 @@ describe("ProviderRuntimeIngestion", () => { }); }), ).pipe(Layer.provide(projectionSnapshotLayer)); + // Real clock plus an offset the test can advance, so delivery pacing in + // ingestion can be driven without sleeping. Sleeps stay real. + let clockOffsetMs = 0; + const realClock = Effect.runSync(Effect.service(Clock.Clock)); + const shiftedClock: Clock.Clock = { + currentTimeMillisUnsafe: () => realClock.currentTimeMillisUnsafe() + clockOffsetMs, + currentTimeMillis: Effect.sync(() => realClock.currentTimeMillisUnsafe() + clockOffsetMs), + currentTimeNanosUnsafe: () => + realClock.currentTimeNanosUnsafe() + BigInt(clockOffsetMs) * 1_000_000n, + currentTimeNanos: Effect.sync( + () => realClock.currentTimeNanosUnsafe() + BigInt(clockOffsetMs) * 1_000_000n, + ), + monotonicTimeNanosUnsafe: () => realClock.monotonicTimeNanosUnsafe(), + monotonicTimeNanos: realClock.monotonicTimeNanos, + sleep: (duration) => realClock.sleep(duration), + }; const layer = ProviderRuntimeIngestionLive.pipe( + Layer.provide(Layer.succeed(Clock.Clock, shiftedClock)), Layer.provideMerge(orchestrationLayer), Layer.provideMerge(ingestionProjectionSnapshotLayer), // Single shared liveness instance across ingestion (writer), the @@ -395,6 +412,9 @@ describe("ProviderRuntimeIngestion", () => { .pipe(Effect.map(Option.getOrThrow)), ), emit: provider.emit, + advanceClock: (ms: number) => { + clockOffsetMs += ms; + }, emitAndDrain, sqlCount: sqlCounter.count, setProviderSession: provider.setSession, @@ -3154,18 +3174,19 @@ describe("ProviderRuntimeIngestion", () => { ); // Each delta lands well outside the pacing window of the one before. - let deltaCount = 0; - const emitDelta = (eventId: string, delta: string) => + const emitDelta = (eventId: string, delta: string) => { + harness.advanceClock(1_000); harness.emit({ type: "content.delta", eventId: asEventId(eventId), provider: codex, - createdAt: new Date(Date.parse(now) + ++deltaCount * 1_000).toISOString(), + createdAt: now, threadId, turnId, itemId, payload: { streamKind: "assistant_text", delta }, }); + }; emitDelta("evt-paragraph-1", "First paragraph.\n\nSecond para"); const afterFirst = await waitForThread(harness.readModel, (thread) => @@ -3220,14 +3241,15 @@ describe("ProviderRuntimeIngestion", () => { const threadId = asThreadId("thread-1"); const turnId = asTurnId("turn-paced"); const itemId = asItemId("item-paced"); - const t0 = Date.parse("2026-01-01T00:00:00.000Z"); - const at = (offsetMs: number) => new Date(t0 + offsetMs).toISOString(); + // Every delta carries the same event time, like OpenCode does for one + // part. Pacing must follow the server clock, not the event stamp. + const now = "2026-01-01T00:00:00.000Z"; harness.emit({ type: "turn.started", eventId: asEventId("evt-paced-started"), provider: codex, - createdAt: at(0), + createdAt: now, threadId, turnId, }); @@ -3235,31 +3257,37 @@ describe("ProviderRuntimeIngestion", () => { harness.readModel, (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === turnId, ); - const emitDelta = (eventId: string, delta: string, offsetMs: number) => - harness.emit({ - type: "content.delta", - eventId: asEventId(eventId), - provider: codex, - createdAt: at(offsetMs), - threadId, - turnId, - itemId, - payload: { streamKind: "assistant_text", delta }, - }); + // Emit is fire-and-forget, so drain after each delta before moving the + // clock. Otherwise the worker reads a clock that has already advanced. + let clockMs = 0; + const emitDelta = async (eventId: string, delta: string, offsetMs: number) => { + harness.advanceClock(offsetMs - clockMs); + clockMs = offsetMs; + await harness.emitAndDrain([ + { + type: "content.delta", + eventId: asEventId(eventId), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { streamKind: "assistant_text", delta }, + }, + ]); + }; const messageText = async () => (await harness.readModel()).threads .find((t) => t.id === threadId) ?.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)?.text; - emitDelta("evt-paced-1", "One.\n\n", 0); - emitDelta("evt-paced-2", "Two.\n\n", 100); - emitDelta("evt-paced-3", "Three.\n\n", 200); - await harness.drain(); + await emitDelta("evt-paced-1", "One.\n\n", 0); + await emitDelta("evt-paced-2", "Two.\n\n", 100); + await emitDelta("evt-paced-3", "Three.\n\n", 200); // The first paragraph lands right away. The next two are inside the window. expect(await messageText()).toBe("One.\n\n"); - emitDelta("evt-paced-4", "Four.\n\n", 500); - await harness.drain(); + await emitDelta("evt-paced-4", "Four.\n\n", 500); expect(await messageText()).toBe("One.\n\nTwo.\n\nThree.\n\nFour.\n\n"); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 3ec564a99dca..0545a0e10164 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -19,6 +19,7 @@ import { } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -1757,10 +1758,12 @@ const make = Effect.gen(function* () { : "buffered", ); if (assistantDeliveryMode === "buffered") { + // Pace on the server clock. OpenCode stamps every delta of a part + // with the part's start time, so the event time cannot measure gaps. const spillChunk = yield* appendBufferedAssistantText( assistantMessageId, assistantDelta, - Date.parse(now), + yield* Clock.currentTimeMillis, ); if (spillChunk.length > 0) { yield* orchestrationEngine.dispatch({ diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index f80a38943c6a..8f982064869b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -914,6 +914,80 @@ describe("MessagesTimeline", () => { expect(idle).toContain('data-maintain-scroll-at-end-animated="false"'); }); + it("snaps to the end while a thread switch settles, even mid-turn", async () => { + const frames = new Map(); + let nextFrame = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(++nextFrame, callback); + return nextFrame; + }); + vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame)); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const flushFrame = () => + act(() => { + const callbacks = [...frames.values()]; + frames.clear(); + callbacks.forEach((callback) => callback(0)); + }); + // A work entry renders without the DOM globals that message rows need + // under react-test-renderer. + const entries = [ + { + id: "entry-settle-work", + kind: "work" as const, + createdAt: MESSAGE_CREATED_AT, + entry: { + id: "work-settle", + createdAt: MESSAGE_CREATED_AT, + toolCallId: "call-settle", + label: "Run lint", + tone: "tool" as const, + itemType: "command_execution" as const, + command: "pnpm lint", + toolLifecycleStatus: "completed" as const, + }, + }, + ]; + const animatedAttr = (renderer: ReactTestRenderer) => + renderer.root.findByProps({ "data-testid": "legend-list" }).props[ + "data-maintain-scroll-at-end-animated" + ]; + let renderer!: ReactTestRenderer; + try { + act(() => { + renderer = create( + , + ); + }); + expect(animatedAttr(renderer)).toBe(true); + + act(() => { + renderer.update( + , + ); + }); + expect(animatedAttr(renderer)).toBe(false); + + // Two frames later the switch has settled and gliding resumes. + flushFrame(); + flushFrame(); + expect(animatedAttr(renderer)).toBe(true); + } finally { + act(() => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); + it("keeps reserved end space when tool work starts while reading history", () => { const turnId = TurnId.make("turn-with-active-tool"); const firstEntry = buildUserTimelineEntry("Run the command."); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index fb8ba4d1c0f1..084287080bd9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -481,12 +481,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); const listIdentityRef = useRef(listIdentityKey); const previousLatestTurnRef = useRef(latestTurn); + // The list stays mounted across thread switches. Its first end pins on the + // new thread must snap, not glide, even if that thread is mid-turn. + const [settlingListIdentity, setSettlingListIdentity] = useState(null); let paintedExpandedTurnIds = expandedTurnIds; let paintedExpandedWorkGroupIds = expandedWorkGroupIds; let paintedExpandedSpawnEntryIds = expandedSpawnEntryIds; if (listIdentityRef.current !== listIdentityKey) { listIdentityRef.current = listIdentityKey; previousLatestTurnRef.current = latestTurn; + setSettlingListIdentity(listIdentityKey); paintedExpandedTurnIds = new Set(); paintedExpandedWorkGroupIds = new Set(); paintedExpandedSpawnEntryIds = new Set(); @@ -531,6 +535,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }; }, []); + useEffect(() => { + if (settlingListIdentity === null) return; + // Two frames covers the fresh-data layout pass and the initial end pin. + let second: number | null = null; + const first = requestAnimationFrame(() => { + second = requestAnimationFrame(() => { + setSettlingListIdentity((current) => (current === settlingListIdentity ? null : current)); + }); + }); + return () => { + cancelAnimationFrame(first); + if (second !== null) cancelAnimationFrame(second); + }; + }, [settlingListIdentity]); + const suspendEndScrollMaintenanceForDisclosure = useCallback( (anchorKey: string, collapsed = false) => { disclosureAnchorKeyRef.current = anchorKey; @@ -986,7 +1005,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ !liveFollowEnabled || disclosureToggleSettling ? false - : isWorking && !prefersReducedMotion + : isWorking && !prefersReducedMotion && settlingListIdentity === null ? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH : TIMELINE_MAINTAIN_SCROLL_AT_END } From 83eff75331c275dbed6bc77bcb96074947605dde Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 13 Sep 2026 18:58:16 -0700 Subject: [PATCH 8/8] fix(server): keep list-nested fences open and ignore non-blank whitespace lines A fence indented under a list item was not recognized, so its blank lines split the block early. Fences now open at any indent and close within three extra spaces of the opener. A line of only no-break spaces is paragraph content, not a blank line. Co-Authored-By: Claude Fable 5.1 --- .../Layers/ProviderRuntimeIngestion.test.ts | 15 +++++++++ .../Layers/ProviderRuntimeIngestion.ts | 31 ++++++++++++------- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 4da82c8a641b..c22cf3e53bea 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -4562,6 +4562,21 @@ describe("splitBufferedAssistantText", () => { }); }); + it("keeps a fence nested under a list item open across its blank lines", () => { + const text = "- step\n\n ```ts\n a\n\n b\n ```\n\nafter\n"; + expect(splitBufferedAssistantText(text)).toEqual({ + ready: "- step\n\n ```ts\n a\n\n b\n ```\n\n", + rest: "after\n", + }); + }); + + it("does not treat a no-break-space line as blank", () => { + expect(splitBufferedAssistantText("para\n\u00a0\ncont\n\nnext")).toEqual({ + ready: "para\n\u00a0\ncont\n\n", + rest: "next", + }); + }); + it("treats CRLF blank lines as boundaries", () => { expect(splitBufferedAssistantText("one\r\n\r\ntwo")).toEqual({ ready: "one\r\n\r\n", diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 0545a0e10164..8126537bd9f7 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -186,9 +186,13 @@ function hasRenderableAssistantText(text: string | undefined): boolean { return (text?.trim().length ?? 0) > 0; } -// CommonMark allows up to three spaces of indentation before a fence. Four or -// more means the line is content inside the block, not a fence. -const MARKDOWN_FENCE_PATTERN = /^ {0,3}(`{3,}|~{3,})/; +// An opening fence may sit at any indentation, since fences inside list +// items are indented past the marker. A closing fence may be indented at most +// three spaces more than its opener. Deeper lines are content in the block. +const MARKDOWN_FENCE_PATTERN = /^( *)(`{3,}|~{3,})/; +// CommonMark blank lines hold only spaces and tabs. Other whitespace, such as +// a no-break space, is paragraph content. +const BLANK_LINE_PATTERN = /^[ \t]*$/; /** * Splits buffered assistant text at the last blank line or closing code fence @@ -198,7 +202,7 @@ const MARKDOWN_FENCE_PATTERN = /^ {0,3}(`{3,}|~{3,})/; * terminated lines count, so a trailing partial line never leaks. */ export function splitBufferedAssistantText(text: string): { ready: string; rest: string } { - let openFence: string | null = null; + let openFence: { marker: string; indent: number } | null = null; let boundary = -1; let lineStart = 0; for (;;) { @@ -206,21 +210,24 @@ export function splitBufferedAssistantText(text: string): { ready: string; rest: if (newline === -1) { break; } - const line = text.slice(lineStart, newline).trimEnd(); - const fence = MARKDOWN_FENCE_PATTERN.exec(line)?.[1]; - if (fence !== undefined) { + const line = text.slice(lineStart, newline).replace(/[ \t\r]+$/, ""); + const fenceMatch = MARKDOWN_FENCE_PATTERN.exec(line); + if (fenceMatch) { + const indent = fenceMatch[1]!.length; + const marker = fenceMatch[2]!; if (openFence === null) { - openFence = fence; + openFence = { marker, indent }; } else if ( - fence[0] === openFence[0] && - fence.length >= openFence.length && - line.trimStart().length === fence.length + marker[0] === openFence.marker[0] && + marker.length >= openFence.marker.length && + indent <= openFence.indent + 3 && + line.length === indent + marker.length ) { // CommonMark: a closing fence carries no info string. openFence = null; boundary = newline + 1; } - } else if (openFence === null && line.trim().length === 0 && lineStart > 0) { + } else if (openFence === null && BLANK_LINE_PATTERN.test(line) && lineStart > 0) { boundary = newline + 1; } lineStart = newline + 1;