Session switching flash / rapid-scroll on return to busy session
Reproduction
- Start a long-running agent turn in Session A (e.g. multi-step task with several tool calls).
- While it is still running, click another session in the sidebar to switch to Session B.
- Wait a few seconds so that more
replyDelta / toolArgsDelta / toolResult / ctxUsage events accumulate on Session A in the background.
- Click Session A again to return.
Expected: smooth restoration of Session A — content already visible, cursor at bottom, no visual jank.
Actual: the chat panel visibly flashes / blinks and the scrollbar rapidly jitters as a backlog of events is replayed in a tight synchronous loop, each one triggering its own requestAnimationFrame scroll-to-bottom.
This is most obvious on long sessions with many tool calls, but is reproducible on almost any busy session.
Root cause (verified via code reading)
1. Events are buffered while the session is hidden, then replayed synchronously
src/chat/provider.js _runPost(run, msg) always pushes into run.events, but only forwards to the active webview when run.sessionId === this._store.sessionId. So while the user is in Session B, all of Session A's events accumulate untouched.
When the user switches back, _loadSession(id) (around line 630) does:
await this._store.load(id, { busy: !!(run && run.busy) });
// …
if (run) for (const ev of run.events) this._post(ev); // ← tight sync loop
There is no batching, no debouncing, no async deferral. Dozens of postMessage calls fire back-to-back.
2. The webview rebuilds the DOM immediately before the flood
In media/chat.js, the sessionLoaded handler:
- calls
resetChat() to clear every .msgU / .msgA / .err / .errCard node;
- iterates
m.messages to rebuild bubbles from the persisted history.
The browser has not yet painted this rebuild when the synchronous replay loop kicks in.
3. Every event handler calls ascroll() → RAF scroll-to-bottom
function ascroll(){
if (stick) requestAnimationFrame(function(){ msgs.scrollTop = msgs.scrollHeight; });
}
N buffered events ⇒ N queued RAF callbacks ⇒ multiple reflow/paint cycles in adjacent frames ⇒ visible flash and scrollbar jitter.
Timeline
T0 sessionLoaded posted → webview receives persisted history
T1 resetChat() → DOM cleared
T2 rebuild bubbles → DOM rebuilt (not yet painted)
T3 for (ev of run.events) post() → 10–50+ posts in a tight loop
├─ replyDelta → ascroll() → RAF #1
├─ replyDelta → ascroll() → RAF #2
├─ toolResult → ascroll() → RAF #3
└─ …
T4 RAFs fire over several frames → blink + scrollbar jitter
Why GitHub Copilot Chat does not have this problem
Copilot Chat combines three techniques (any one of which already mitigates a lot):
retainContextWhenHidden: true on the webview view options — the DOM, JS heap and scroll state are kept in memory while the view is hidden, so switching back requires zero rebuild.
- Server-side aggregation —
replyDelta chunks are coalesced into a single up-to-date snapshot per message before being sent to the webview, instead of being replayed one-by-one on reconnect.
- Scroll anchoring — stick-to-bottom is suppressed during batched updates and the final scroll happens once at the end of the React commit.
We currently do none of these.
Proposed fix (incremental, low-risk)
Order is from highest payoff per LoC to lowest:
Phase 1 — one-line, biggest win
Phase 2 — defer + suppress
Phase 3 — optional, larger
Acceptance criteria
- Switching away from a running session and returning ≥ 5 seconds later shows no visible flash and no scrollbar jitter.
- Final scroll position is at the bottom (assuming the user was at the bottom when they left).
- No regression in single-session streaming (typing animation must still update smoothly).
npm run build passes, no new lint errors.
Files involved
Related
Session switching flash / rapid-scroll on return to busy session
Reproduction
replyDelta/toolArgsDelta/toolResult/ctxUsageevents accumulate on Session A in the background.Expected: smooth restoration of Session A — content already visible, cursor at bottom, no visual jank.
Actual: the chat panel visibly flashes / blinks and the scrollbar rapidly jitters as a backlog of events is replayed in a tight synchronous loop, each one triggering its own
requestAnimationFramescroll-to-bottom.This is most obvious on long sessions with many tool calls, but is reproducible on almost any busy session.
Root cause (verified via code reading)
1. Events are buffered while the session is hidden, then replayed synchronously
src/chat/provider.js_runPost(run, msg)always pushes intorun.events, but only forwards to the active webview whenrun.sessionId === this._store.sessionId. So while the user is in Session B, all of Session A's events accumulate untouched.When the user switches back,
_loadSession(id)(around line 630) does:There is no batching, no debouncing, no async deferral. Dozens of
postMessagecalls fire back-to-back.2. The webview rebuilds the DOM immediately before the flood
In
media/chat.js, thesessionLoadedhandler:resetChat()to clear every.msgU/.msgA/.err/.errCardnode;m.messagesto rebuild bubbles from the persisted history.The browser has not yet painted this rebuild when the synchronous replay loop kicks in.
3. Every event handler calls
ascroll()→ RAF scroll-to-bottomN buffered events ⇒ N queued RAF callbacks ⇒ multiple reflow/paint cycles in adjacent frames ⇒ visible flash and scrollbar jitter.
Timeline
Why GitHub Copilot Chat does not have this problem
Copilot Chat combines three techniques (any one of which already mitigates a lot):
retainContextWhenHidden: trueon the webview view options — the DOM, JS heap and scroll state are kept in memory while the view is hidden, so switching back requires zero rebuild.replyDeltachunks are coalesced into a single up-to-date snapshot per message before being sent to the webview, instead of being replayed one-by-one on reconnect.We currently do none of these.
Proposed fix (incremental, low-risk)
Order is from highest payoff per LoC to lowest:
Phase 1 — one-line, biggest win
retainContextWhenHidden: trueon the webview view options inresolveWebviewView(and in the editor panel constructor for parity).Phase 2 — defer + suppress
_loadSession, wrap thefor (const ev of run.events) this._post(ev)loop insetImmediate(...)(orsetTimeout(fn, 0)) so the DOM-rebuild paint completes before events start streaming again.media/chat.js, add a_replayingflag. While true,ascroll()is a no-op. Set it via a newreplayStart/replayEndenvelope (or detect by counting expected events). AfterreplayEnd, scroll once.Phase 3 — optional, larger
replyDelta, send a singlereplyReplace { text }event with the cumulative text since the user left. Same idea fortoolArgsDelta.Acceptance criteria
npm run buildpasses, no new lint errors.Files involved
src/chat/provider.js—_loadSession,_runPost, webview optionssrc/chat/session-store.js—load()media/chat.js—sessionLoadedhandler,resetChat,ascrollagent-loop.js)Related