mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-28 21:23:30 +00:00
fix(ui): reduce streaming render work
This commit is contained in:
parent
e4b563706c
commit
411847bb0b
4 changed files with 87 additions and 13 deletions
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue