Skip to content

fix(desktop): reduce streaming render jitter (webview coalescing + height-cache hygiene) - #8292

Closed
Lxiny-zy wants to merge 2 commits into
esengine:main-v2from
Lxiny-zy:fix/transcript-stream-render-jitter
Closed

fix(desktop): reduce streaming render jitter (webview coalescing + height-cache hygiene)#8292
Lxiny-zy wants to merge 2 commits into
esengine:main-v2from
Lxiny-zy:fix/transcript-stream-render-jitter

Conversation

@Lxiny-zy

Copy link
Copy Markdown
Contributor

Problem

Streaming rendering jitter on fast model streams. Two compounding sources:

  1. Per-chunk IPC floods the webview channel. The agent emits one event per model chunk (internal/agent/agent.go emits Text/Reasoning per chunk); tabEventSink.Emit forwards every event to Wails runtime.EventsEmit one by one. When the single webview event channel backs up, EventsEmit blocks, backlog latency spikes, and the frontend's per-frame batch drains a stale backlog in one jump instead of a smooth stream.
  2. Mid-stream heights poison the measurement cache. Streaming rows are measured every frame (tail growth, live code highlighting); those transient heights were written into transcriptHeightCache, so a later re-mount of the same row key (history replay, tab switch) started from a mid-stream height and settled with a second viewport jump.

Fix

  1. webviewStreamCoalescer (new, desktop/tab_stream_coalescer.go): merges consecutive same-kind text/reasoning deltas for the same tab+submission into one wire event per 16ms window (one 60Hz frame — the frontend already coalesces a second time per frame, so the merged rate never exceeds the repaint rate). Event order and the reasoning→text boundary are preserved (a kind or submission switch flushes immediately). The merge sits only on the webview exit: bot sinks, project-tree metadata, telemetry and the display buffer keep receiving every event unchanged.
  2. Streaming rows never write the height cache. createTranscriptMeasureElement accepts a skipCacheWriteWhen predicate; useTranscriptRowMeasurements matches answer rows whose item.streaming is still true. Settled rows keep writing their final measured height.

Tests

Go (tab_stream_coalescer_test.go, all pass; full go test ./desktop/... green):

  • window merge of consecutive text deltas
  • reasoning→text boundary flushes separately
  • different submissions never merge
  • non-stream events (e.g. turn_done) flush the pending delta immediately
  • window timer flushes a lone delta without further pushes
  • sink-level: bot sinks still receive every event unchanged while the webview gets the merged stream

Frontend (transcript-height-cache.test.ts, 5 assertions; tsc --noEmit and eslint clean):

  • predicate matching for streaming/settled/user/missing rows
  • integration check that only settled rows reach the cache through the measure element

Cache-impact: none - frontend rendering path + Wails event bridge only; no prompt or tool schema bytes change
Cache-guard: none
System-prompt-review: none - no prompt, config schema, memory, output style, or skill behavior changes
Documentation-impact: none - internal rendering/IPC performance fix; no CLI, docs or config surface changed

Copilot AI lite review requested due to automatic review settings August 11, 2026 02:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added v2 Go rewrite (1.x) — main-v2 branch, active development desktop Wails desktop app (desktop/**) labels Aug 11, 2026
@Lxiny-zy
Lxiny-zy force-pushed the fix/transcript-stream-render-jitter branch from eadf22d to de20c17 Compare August 11, 2026 03:02
…annel

The agent emits one event per model chunk (hundreds per second on fast
streams). Every event crosses the Wails IPC bridge and queues behind the
single webview event channel, which blocks runtime.EventsEmit when backed
up and turns burst latency into visible input lag and jitter: by the time
the frontend's per-frame batch drains, a backlog of stale deltas renders
as a jump instead of a stream.

webviewStreamCoalescer merges consecutive same-kind text/reasoning deltas
for the same tab+submission+runtimeEpoch into one wire event per 16ms
window (one 60Hz frame - the frontend already coalesces a second time per
frame, so the merged rate never exceeds the repaint rate). Event order
and the reasoning->text boundary are preserved (a kind, submission or
epoch switch flushes). The runtimeEpoch is part of the merge key so a
runtime rebuild can never ride a stale window: the frontend filters whole
events by epoch equality, and a merged event stamped with an old epoch
would silently drop new text after a rebuild. Windows are generation-
tagged so a stale timer callback that lost the lock race to a Push can
neither stop the newer timer nor drain the newer window.

The merge sits only on the webview exit: bot sinks, project-tree
metadata, telemetry and the display buffer keep receiving every event
unchanged. Flushes emit outside the mutex (the webview channel may
block), and the window is drained when a tab closes so the final tail is
not dropped.

Tests: window merge, reasoning/text boundary, per-submission isolation,
epoch-switch flush, non-stream events flush immediately, timer flush
without further pushes, reasoning merge content, stale-generation flush
ignored, and a sink-level test proving bot sinks still see per-event
delivery while the webview gets the merged stream.
Streaming rows are measured every frame (tail growth, live code
highlighting) and their height is a moving target. Writing those
intermediate heights into transcriptHeightCache poisoned later estimates
for the same row key: a re-mount (history replay, tab switch) used a
transient mid-stream height, and the settle correction then jumped the
viewport again.

createTranscriptMeasureElement now accepts a skipCacheWriteWhen
predicate; useTranscriptRowMeasurements passes one that matches answer
rows with item.streaming and reasoning rows that are still streaming
(item.streaming && !reasoningComplete, the same rule segmentHasRunningWork
uses). Settled rows keep writing their final measured height, so the
cache stays as useful as before.

Tests: predicate assertions (answer/reasoning/user/missing), an
integration check that only settled rows reach the cache through the
measure element; tsc and eslint clean.
@Lxiny-zy

Copy link
Copy Markdown
Contributor Author

Superseded alongside #8291 by #8264's unified transcript scroll/rendering rework; closing to keep the PR list clean. The Go-side webview stream coalescer (per-chunk IPC merging) remains available here if it is ever wanted.

@Lxiny-zy Lxiny-zy closed this Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

desktop Wails desktop app (desktop/**) v2 Go rewrite (1.x) — main-v2 branch, active development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants