From 6986c32f37f2721ed2020046c44303952a2c2853 Mon Sep 17 00:00:00 2001 From: Yayo Razo Date: Thu, 9 Jul 2026 01:47:41 -0600 Subject: [PATCH] fix(tui): batch streaming part deltas to keep TUI responsive Every message.part.delta event was applied to the Solid store synchronously, so each streamed chunk triggered reactivity and a re-render of the growing text part. Models with high chunk rates saturate the render loop and queued input (scrolling) stalls for seconds. Deltas now accumulate per part field and flush together every 40ms inside a single batch. A message.part.updated snapshot drops any buffered deltas for that part before reconciling, so the final store state is identical to the unbatched behavior. Closes #36043 --- packages/tui/src/context/sync.tsx | 58 +++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index d0511c5183e4..6b21e4df5f93 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -167,6 +167,41 @@ export const { .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) } + // Fast-streaming models emit message.part.delta faster than the renderer + // keeps up; applying every chunk synchronously starves input handling and + // freezes scrolling (#36043). Deltas accumulate per part field and flush + // together on a short interval instead. + const pendingDeltas = new Map() + let deltaTimer: ReturnType | undefined + const dropPendingDeltas = (messageID: string, partID: string) => { + if (!pendingDeltas.size) return + const prefix = `${messageID}|${partID}|` + for (const key of pendingDeltas.keys()) if (key.startsWith(prefix)) pendingDeltas.delete(key) + } + const flushDeltas = () => { + deltaTimer = undefined + if (!pendingDeltas.size) return + batch(() => { + for (const pending of pendingDeltas.values()) { + const parts = store.part[pending.messageID] + if (!parts) continue + const result = search(parts, pending.partID, (p) => p.id) + if (!result.found) continue + setStore( + "part", + pending.messageID, + produce((draft) => { + const part = draft[result.index] + const field = pending.field as keyof typeof part + const existing = part[field] as string | undefined + ;(part[field] as string) = (existing ?? "") + pending.delta + }), + ) + } + pendingDeltas.clear() + }) + } + event.subscribe((event, { directory, workspace }) => { switch (event.type) { case "server.instance.disposed": @@ -368,6 +403,8 @@ export const { break } case "message.part.updated": { + // full snapshot supersedes any buffered deltas for this part + dropPendingDeltas(event.properties.part.messageID, event.properties.part.id) touchPart(event.properties.part.sessionID, event.properties.part.id) const parts = store.part[event.properties.part.messageID] if (!parts) { @@ -395,16 +432,17 @@ export const { const result = search(parts, event.properties.partID, (p) => p.id) if (!result.found) break touchPart(event.properties.sessionID, event.properties.partID) - setStore( - "part", - event.properties.messageID, - produce((draft) => { - const part = draft[result.index] - const field = event.properties.field as keyof typeof part - const existing = part[field] as string | undefined - ;(part[field] as string) = (existing ?? "") + event.properties.delta - }), - ) + const key = `${event.properties.messageID}|${event.properties.partID}|${event.properties.field}` + const pending = pendingDeltas.get(key) + if (pending) pending.delta += event.properties.delta + else + pendingDeltas.set(key, { + messageID: event.properties.messageID, + partID: event.properties.partID, + field: event.properties.field, + delta: event.properties.delta, + }) + if (!deltaTimer) deltaTimer = setTimeout(flushDeltas, 40) break }