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 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 writes | forced reflow |
applyChevronState() — O.querySelectorAll('.collapsible') plus a closest('.adjacent-task') per panel | O(panels) DOM scan |
updateVisibleTask() (and showLiveMetrics()) — full-tree selector scans | O(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.
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.
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:
| # | Severity | Finding | Fix |
|---|---|---|---|
| 1 | High | A 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. |
| 2 | Medium | A 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. |
| 3 | Low | Sibling 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). |
| 4 | Medium | The 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. |
| 5 | Low | A 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. |
test/streamTailCoalesce.test.js — 10 end-to-end jsdom
scenarios (extension and remote webview): chunk deferral and per-frame replay, panel-opening
chunks staying synchronous, thinking/text/bash parity of text and scroll state, synchronous-rAF
fallback, tab-switch debt drop, retarget-on-first-chunk-after-switch, task-end flush (no
chv-hidden ever), sibling-subpanel scroll parity, replay cancellation. Two
defensive branches that cannot occur without mocks are documented in the test header instead
of being faked.test/streamTailCoalesce.coverage.js —
requires and gets 100% line coverage (116/116) of every
streamtail-coverage region in main.js.npm run lint:ts, lint:css, typecheck, and the repo-wide
uv run check --full all pass.git add, not committed)src/kiss/agents/vscode/media/main.js — +133/−1 lines: sweep machinery
(scheduleStreamTailSweep, runStreamTailSweep,
flushStreamTailSweep, cancelStreamTailSweep), the
deferTail fast path, and the three ordering hooks.src/kiss/agents/vscode/test/streamTailCoalesce.test.js — new e2e suite.src/kiss/agents/vscode/test/streamTailCoalesce.coverage.js — new coverage gate.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.