Chat webview: one sweep per frame instead of one sweep per streamed chunk

While a task runs, the chat webview (src/kiss/agents/vscode/media/main.js, used identically by the VS Code extension and the remote web app) receives a stream of events. The overwhelming majority are tiny text chunks: thinking_delta, text_delta, and system_output (live bash output). A long build or test run delivers dozens of these per second.

The problem

The chunk's text was already batched: it goes into a buffer and is written to the DOM once per animation frame (a pending requestAnimationFrame "flush" that also auto-scrolls the panels it grows). But processOutputEvent() still ran a synchronous "tail" after every single chunk, and every part of that tail walks the whole transcript or forces a layout pass:

Per-chunk tail work (before)Cost per chunk
collapseOlderPanels() via streamEnd() — collapse every top-level panel but the last (ran for every bash output chunk)O(panels) scan + class churn
autoScrollLatestEventPanel()querySelectorAll over the latest panel, then scrollHeight/clientHeight/scrollTop reads and writesforced reflow
applyChevronState()O.querySelectorAll('.collapsible') plus a closest('.adjacent-task') per panelO(panels) DOM scan
updateVisibleTask() (and showLiveMetrics()) — full-tree selector scansO(transcript)

All of this ran against layout that had not changed yet — the text was still sitting in the buffer waiting for the flush frame. With N panels in the transcript and a chunk stream, the tail costs O(N) per chunk and O(N²) over a long task: exactly when a task gets long and busy, every additional chunk gets more expensive.

The fix: defer the tail into one per-frame sweep

BEFORE — every chunk pays the full tail chunk "np" tail: scan+reflow O(N) chunk "m ru" tail: scan+reflow O(N) chunk… tail… frame: flush buffered text + autoscroll ← text appears once per frame anyway AFTER — chunks only buffer; ONE sweep per frame chunk "np" chunk "m ru" chunk "n te" … O(1) each: buffer + arm sweep frame: flush buffered text + autoscroll sweep ×1: collapse-if-debt, chevrons, scroll, panel ← runs after the flush, before paint Ordering guards (keep behavior identical) • non-chunk event (tool_call, result, …) → flush pending sweep FIRST, then process the event • task ends (setRunningState(false)) → flush pending sweep BEFORE the flip, while still "running" • transcript replaced (replay/clear) or tab switched → pending sweep and its collapse debt are dropped
Per-chunk O(N) tail work (red) is replaced by a single per-frame sweep (yellow) that runs after the existing text flush (green) and before the browser paints — so nothing the user can see changes, only when the bookkeeping runs.

A chunk takes the fast path only when it is provably invisible until the next frame: its DOM write is buffered behind a pending flush (thinkRaf/txtRaf/bashRaf) and it did not open or adopt a thoughts panel (streamBegin counted no step). Everything else — panel-opening chunks, plain system_output without a bash panel, tool_call/tool_result/result/usage_info, background-tab events, and transcript replays — keeps the exact synchronous path it had before. Test environments that stub requestAnimationFrame to run synchronously are automatically unaffected: their flush handle is 0, so every chunk stays on the old path.

Independent review (gpt-5.6-sol, read-only)

A second model reviewed the change against the repo with instructions not to invent problems ($15.14 of a $30 cap, under the 50%-of-budget limit). It confirmed the skip conditions and found 5 real defects; all are fixed and each fix is pinned by a new test:

#SeverityFindingFix
1HighA sweep still pending when the task ends ran with isRunning=false and hid the finished task's panels (chv-hidden), dropping collapse debt.flushStreamTailSweep() inside setRunningState(false), before the flip.
2MediumA stale user scroll lock was no longer cleared: the collapse now happened after the flush with no autoscroll pass behind it.The sweep runs autoScrollLatestEventPanel(O.lastElementChild) after collapsing.
3LowSibling subpanels of the latest panel (e.g. a finished .think) were no longer pinned to their end on deferred flushes.Same fix as #2 (that call scrolls every subpanel).
4MediumThe new coverage gate copied a pre-existing off-by-one from the sibling gates (n < b - 1) that silently skips each region's last line.Fixed to n < b in the new gate; the sibling gates' copies are noted as pre-existing and left untouched.
5LowA sweep retargeted across a tab switch kept its old rAF queue slot and could fire before the new tab's flush.Retargeting cancels and re-arms the rAF behind the new tab's flush.

Verification

Files changed (staged with git add, not committed)

The patched main.js was also synced to the installed live extension (~/.vscode-server/extensions/ksenxx.kiss-sorcar-2026.8.13, whose copy was byte-identical to repo HEAD before the patch), so newly opened chat webviews benefit immediately without waiting for a rebuild.