fix(ui): reduce streaming render work

This commit is contained in:
LukeParkerDev 2026-06-15 14:13:37 +02:00
parent e4b563706c
commit 411847bb0b
4 changed files with 87 additions and 13 deletions

View file

@ -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([])
})
})

View file

@ -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<string>) {
const output: QueuedServerEvent[] = []
const deltas = new Map<string, number>()
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<typeof setTimeout> | 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

View file

@ -412,7 +412,6 @@ export function Markdown(
container.querySelectorAll<HTMLButtonElement>('[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"),

View file

@ -189,13 +189,14 @@ export type PartComponent = Component<MessagePartProps>
export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
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)
})