Skip to content

[UX] Session switching causes visible flash and rapid scroll when returning to a running session #143

@ZhouChaunge

Description

@ZhouChaunge

Session switching flash / rapid-scroll on return to busy session

Reproduction

  1. Start a long-running agent turn in Session A (e.g. multi-step task with several tool calls).
  2. While it is still running, click another session in the sidebar to switch to Session B.
  3. Wait a few seconds so that more replyDelta / toolArgsDelta / toolResult / ctxUsage events accumulate on Session A in the background.
  4. 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):

  1. 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.
  2. Server-side aggregationreplyDelta 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.
  3. 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

  • Set retainContextWhenHidden: true on the webview view options in resolveWebviewView (and in the editor panel constructor for parity).
    • Eliminates DOM rebuild + most of the replay traffic.
    • Cost: a few MB of memory while the view is hidden.

Phase 2 — defer + suppress

  • In _loadSession, wrap the for (const ev of run.events) this._post(ev) loop in setImmediate(...) (or setTimeout(fn, 0)) so the DOM-rebuild paint completes before events start streaming again.
  • In media/chat.js, add a _replaying flag. While true, ascroll() is a no-op. Set it via a new replayStart / replayEnd envelope (or detect by counting expected events). After replayEnd, scroll once.

Phase 3 — optional, larger

  • Backend coalescing: instead of replaying every replyDelta, send a single replyReplace { text } event with the cumulative text since the user left. Same idea for toolArgsDelta.

Acceptance criteria

  1. Switching away from a running session and returning ≥ 5 seconds later shows no visible flash and no scrollbar jitter.
  2. Final scroll position is at the bottom (assuming the user was at the bottom when they left).
  3. No regression in single-session streaming (typing animation must still update smoothly).
  4. npm run build passes, no new lint errors.

Files involved

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions