From 411847bb0bb43d0c624290bfe58cbbedd7c37978 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:13:37 +0200 Subject: [PATCH] fix(ui): reduce streaming render work --- packages/app/src/context/server-sdk.test.ts | 41 +++++++++++++++++- packages/app/src/context/server-sdk.tsx | 46 ++++++++++++++++----- packages/ui/src/components/markdown.tsx | 1 - packages/ui/src/components/message-part.tsx | 12 +++++- 4 files changed, 87 insertions(+), 13 deletions(-) diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index 6d095697641..08390d667c6 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import { resumeStreamAfterPageShow } from "./server-sdk" +import { coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk" +import type { Event } from "@opencode-ai/sdk/v2/client" describe("resumeStreamAfterPageShow", () => { test("restarts a stream only after a back-forward cache restore", () => { @@ -12,3 +13,41 @@ describe("resumeStreamAfterPageShow", () => { expect(starts).toBe(1) }) }) + +describe("coalesceServerEvents", () => { + const delta = (value: string, field = "text") => ({ + directory: "/repo", + payload: { + type: "message.part.delta", + properties: { messageID: "msg", partID: "part", field, delta: value }, + } as Event, + }) + + test("merges adjacent deltas for the same field", () => { + const result = coalesceServerEvents([delta("hello "), delta("world")]) + + expect(result).toHaveLength(1) + expect(result[0]?.payload).toMatchObject({ properties: { delta: "hello world" } }) + }) + + test("preserves event boundaries and distinct fields", () => { + const status = { + directory: "/repo", + payload: { type: "session.status", properties: { sessionID: "ses", status: { type: "idle" } } } as Event, + } + const result = coalesceServerEvents([delta("a"), delta("b", "metadata"), status, delta("c")]) + + expect(result.map((event) => event.payload.type)).toEqual([ + "message.part.delta", + "message.part.delta", + "session.status", + "message.part.delta", + ]) + }) + + test("drops stale deltas", () => { + const result = coalesceServerEvents([delta("stale")], new Set(["/repo:msg:part"])) + + expect(result).toEqual([]) + }) +}) diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 1f71b208241..a010e688415 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -15,6 +15,39 @@ const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true +type QueuedServerEvent = { directory: string; payload: Event } + +const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}` + +export function coalesceServerEvents(events: QueuedServerEvent[], stale?: Set) { + const output: QueuedServerEvent[] = [] + const deltas = new Map() + events.forEach((event) => { + if (stale && event.payload.type === "message.part.delta") { + const props = event.payload.properties + if (stale.has(deltaKey(event.directory, props.messageID, props.partID))) return + } + if (event.payload.type !== "message.part.delta") { + deltas.clear() + output.push(event) + return + } + const props = event.payload.properties + const id = `${deltaKey(event.directory, props.messageID, props.partID)}:${props.field}` + const index = deltas.get(id) + const existing = index === undefined ? undefined : output[index] + if (!existing || existing.payload.type !== "message.part.delta") { + deltas.set(id, output.length) + output.push({ + directory: event.directory, + payload: { ...event.payload, properties: { ...props } }, + }) + return + } + existing.payload.properties.delta += props.delta + }) + return output +} export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) { if (!event.persisted) return @@ -45,7 +78,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS [key: string]: Event }>() - type Queued = { directory: string; payload: Event } + type Queued = QueuedServerEvent const FLUSH_FRAME_MS = 16 const STREAM_YIELD_MS = 8 const RECONNECT_DELAY_MS = 250 @@ -57,8 +90,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS let timer: ReturnType | undefined let last = 0 - const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}` - const key = (directory: string, payload: Event) => { if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}` if (payload.type === "lsp.updated") return `lsp.updated:${directory}` @@ -83,14 +114,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS staleDeltas.clear() last = Date.now() + const output = coalesceServerEvents(events, skip) batch(() => { - for (const event of events) { - if (skip && event.payload.type === "message.part.delta") { - const props = event.payload.properties - if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue - } - emitter.emit(event.directory, event.payload) - } + output.forEach((event) => emitter.emit(event.directory, event.payload)) }) buffer.length = 0 diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 279716f10ba..9db84b4f816 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -412,7 +412,6 @@ export function Markdown( container.querySelectorAll('[data-slot="markdown-copy-button"]').forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"), ) - if (!copyCleanup) copyCleanup = setupCodeCopy(container, () => ({ copy: i18n.t("ui.message.copy"), diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index e2eddd73e33..bbc3d2956cb 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -189,13 +189,14 @@ export type PartComponent = Component export const PART_MAPPING: Record = {} const TEXT_RENDER_PACE_MS = 24 +const TEXT_RENDER_IMMEDIATE = 512 const TEXT_RENDER_SNAP = /[\s.,!?;:)\]]/ function step(size: number) { if (size <= 12) return 2 if (size <= 48) return 4 if (size <= 96) return 8 - return Math.min(24, Math.ceil(size / 8)) + return Math.min(256, Math.ceil(size / 4)) } function next(text: string, start: number) { @@ -234,6 +235,10 @@ function createPacedValue(getValue: () => string, live?: () => boolean) { sync(text) return } + if (text.length - shown.length <= TEXT_RENDER_IMMEDIATE) { + sync(text) + return + } const end = next(text, shown.length) sync(text.slice(0, end)) if (end < text.length) timeout = setTimeout(run, TEXT_RENDER_PACE_MS) @@ -251,6 +256,11 @@ function createPacedValue(getValue: () => string, live?: () => boolean) { sync(text) return } + if (text.length - shown.length <= TEXT_RENDER_IMMEDIATE) { + clear() + sync(text) + return + } if (text.length === shown.length || timeout) return timeout = setTimeout(run, TEXT_RENDER_PACE_MS) })