From ee0dea9534a43ff8a08f0abc8b08180a036c837e Mon Sep 17 00:00:00 2001 From: umi008 Date: Thu, 9 Jul 2026 08:46:23 -0600 Subject: [PATCH] fix(tui): batch streaming delta updates to prevent scroll freeze --- packages/tui/src/context/data.tsx | 68 +++++++++++++++++++++++++------ 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index dce20ed3551f..4b8467b69916 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -26,6 +26,7 @@ import type { SkillInfo, V2Event, } from "@opencode-ai/sdk/v2" +import { batch } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" @@ -176,6 +177,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return created } + // Delta batching: buffer incoming delta events for a short window and flush + // them in a single batched store update. This prevents UI freeze when large + // responses arrive in rapid bursts — each individual delta no longer triggers + // a separate re-render. + type DeltaEntry = { apply: () => void } + const deltaBuffer: DeltaEntry[] = [] + let deltaTimer: ReturnType | undefined + + function flushDeltaBuffer() { + deltaTimer = undefined + if (deltaBuffer.length === 0) return + const entries = deltaBuffer.splice(0) + batch(() => { + for (const entry of entries) entry.apply() + }) + } + + function bufferDelta(entry: DeltaEntry) { + deltaBuffer.push(entry) + if (!deltaTimer) { + deltaTimer = setTimeout(flushDeltaBuffer, 16) + } + } + // Walk parentID upward through loaded session info to the family root. When a // parent's info is missing, that missing ID is the furthest-known ancestor and // is returned so orphan subtrees group under it until the parent arrives. A @@ -475,9 +500,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.text.delta": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID)) - if (match) match.text += event.data.delta + bufferDelta({ + apply() { + message.update(event.data.sessionID, (draft, index) => { + const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID)) + if (match) match.text += event.data.delta + }) + }, }) break case "session.text.ended": @@ -498,12 +527,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.tool.input.delta": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.callID, - ) - if (match?.state.status === "streaming") match.state.input += event.data.delta + bufferDelta({ + apply() { + message.update(event.data.sessionID, (draft, index) => { + const match = message.latestTool( + message.assistant(draft, index, event.data.assistantMessageID), + event.data.callID, + ) + if (match?.state.status === "streaming") match.state.input += event.data.delta + }) + }, }) break case "session.tool.input.ended": @@ -589,9 +622,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.reasoning.delta": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID)) - if (match) match.text += event.data.delta + bufferDelta({ + apply() { + message.update(event.data.sessionID, (draft, index) => { + const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID)) + if (match) match.text += event.data.delta + }) + }, }) break case "session.reasoning.ended": @@ -1118,6 +1155,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }), ) + onCleanup(() => { + if (deltaTimer) { + clearTimeout(deltaTimer) + flushDeltaBuffer() + } + }) + return result }, })