Agent chat: the VS Code Minimap, frozen while a reply streams (3/4)

Merged
TE
10d ago
+899-0

Part 3 of 4 of the VS Code Minimap. Land order: #25275 (setting, strings, exports), #25276 (the painter), this PR (the minimap), then #25280 (the placeholder looks). Stacked on #25276; review only its two commits (+810 across 4 files).

Leo's words

minimap? i dont see any command for the vscode minimap

(2026-10-25) and earlier:

the mini map VS Code code thing, like for the prompts or whatever. I feel like we should have that. Although I don't know if that actually, like, causes potential lag.

On streaming (2026-10-26):

Just don't have it do anything when a reply is streaming. That's it. Well, or like just have some sort of alternative situation. Like you don't need to be doing things as they stream in, you know? ... the mini map ... just because something's streaming in, like you could just put skeletons or some other sort of placeholder ... You can even do, like, a cute shimmer effect.

obviously the stuff that we already know about, that's, like, just static that we're just rendering because it's already there. We don't need to worry about, like, modifying that. I'm just saying, like, the latest response or whatever.

What it does

  • Where: chatMinimap/ChatMinimap.tsx sits at the transcript's right edge, behind Settings > Agent Chat > VS Code Minimap (#25275, off by default). The thread rail at the left is unchanged and can be on at the same time.
  • Layout: one <canvas> from #25276's painter, never a node per row, plus one view box. It runs from 20 px under the header to 20 px over the composer. It shows only when the gutter fits 56 + 2×10 px, and it hides while Session Info floats over the chat (design/session-info.md).
  • Input: a click jumps there, and dragging the box scrolls from where it was grabbed. It is one focusable role=scrollbar labelled "Chat minimap", with aria-valuenow. Arrow, Page, Home and End keys scroll; Tab passes through it.
  • At rest: it draws when the rows change (at most every 250 ms, and only once a scroll has rested 150 ms), on a new size or screen scale, or on a theme change. Each draw runs in a task posted from requestAnimationFrame, after that frame's rendering, never inside it. A scroll only moves the view box by transform, and only when its rounded value changes.
  • While a reply streams (frozen):
    • Nothing reacts to tokens. Rows reach the minimap through a ref, so a token renders nothing in it.
    • Everything already in the chat stays drawn. The rows above the reply, including the prompt that started it, draw once when it starts.
    • Only the streaming reply's place is a placeholder: a grey block with a soft shimmer, sized once at the start.
    • When the reply ends, the placeholder goes and the picture draws once.
    • While a reply streams, the view box stays where the view was when the stream began; the transcript following the reply to its end never moves it. A scroll away from the end is the person's own and moves it, and a press on the box takes its grab from the box as drawn.

Cost:

  • With the setting off (the default) it does no work: ChatMinimap reads the setting and returns null; all row tracking lives in an inner component mounted only when on (test: 0 row reads with the setting off).
  • Frame cost, bench/thread-minimap-frame-cost.bench.ts (from #25280) on a dedicated idle 32-vCPU Blacksmith job (run), 2,000 rows, minimap on minus off, median p95 delta: default arms (10 rounds) scroll +0.20 ms, stream +0.35 ms; the on/off pair alone (20 rounds) scroll +0.49 ms, stream +0.18 ms. Both within the 1 ms budget. Canvas draws: 0 while scrolling, one per reply that ends while streaming; scroll work is one requestAnimationFrame per frame.
  • Size: +900 lines, about 480 of code and 420 of tests, over the ~800 guide. The keyboard scrollbar already moved to #25422 and the placeholder looks to #25280; the independent review found no duplicate coverage to cut.

Review fixes (gpt-6-astra), in the second commit, each with a test that fails on the first

  • Streaming reply found by state, not a window. It comes from the transcript's rowsChange (#25276 streamingRow), not the last 8 rows. Test: a reply followed by 12 queued prompts still freezes, with 0 draws over 40 growing tokens. On the first commit it redrew.
  • The real column decides the room. The right-gutter check uses the column the chat draws (chatColumnOf(useChatWidth(), useChatDisplay())), so with Full Width, or a Text Width that leaves too little gutter, there is no minimap over the text.
  • Only the person's scroll moves the box. While a reply streams, the transcript following it to its end leaves the view box where it reached the end. A scroll away from the end is the person's own and moves it; the transcript scrolls by itself only to follow. The 600 ms input window is gone. Test: a wheel event right before a follow step doesn't move the box, and a scroll up does. On the first commit the box moved.

10 interleaved rounds, passes the gate

Method: the landed webviews/bench/thread-minimap-frame-cost.bench.ts with the off+canvas arm from #25280. Production build of the pane, 2,000 rows, Chromium headless, 4 s per run. Arms alternate within each scenario (off, on, on, off, …), and each round pairs on with off.

p95 delta per frame, minimap on − offmedianminmaxrounds under 1 msper round
scroll+0.63−1.77+3.407/101.76 −0.88 0.64 3.40 1.28 −0.94 0.63 −1.77 −1.71 −1.04
stream+0.27−6.90+6.437/106.43 0.75 −0.55 −2.06 3.62 −6.90 3.36 0.27 −3.46 0.11

Canvas draws per 4 s run: scroll 0, stream 4. The stream's 4 are the once-per-turn draws when a reply ends; the harness's stream starts a new turn every second. There are none per token. For comparison, the previous round (no freeze) measured stream +1.89 median and 4/10.

Re-measured on the review-fix head (#25280 at 005d19a6, same method, 10 rounds):

p95 delta, minimap on − offmedianminmaxrounds under 1 msper round
scroll+0.96−7.01+3.996/100.40 2.10 −7.01 −0.96 0.59 −0.80 2.42 3.99 0.96 2.65
stream+0.59−2.20+3.656/101.37 3.65 1.38 0.44 −2.20 −1.18 −2.13 −1.08 1.46 0.59

Draws per run are still 0 while scrolling and 4 while streaming. The scroll code didn't change in the fixes; the follow check runs only while a reply streams. So scroll's 0.63 → 0.96 is run-to-run spread, but it sits at the gate's edge.

Gate (median and most rounds under 1 ms, for scroll and stream): passes, so the HOLD line is removed. I measured on the pre-split tree. Since then the only changes are formatting, a 6 → 12 px placeholder minimum, and dropping a timing counter.

Every listener, observer, timer and animation, with its stop

WhatWhyStop / cleanup
scroller scroll (passive)moves the view boxremoved on unmount
scroller wheel/keydown/touchstart/pointerdown (passive)marks the person's own scrolling during a streamremoved on unmount
window resizea new size or screen scale draws againremoved on unmount
MutationObserver on <html> class/style/data-themetheme colors read again (once per theme change)disconnect() on unmount
≤1 setTimeout (wait), ≤1 requestAnimationFrame, ≤1 posted setTimeout(0)the coalesced draw after a frameeach at most one pending; all cleared on unmount
the placeholder's shimmer (CSS animation, transform only)Leo's "cute shimmer"the element exists only while a reply streams, so the animation stops when the reply ends (the element unmounts). It is off under Reduce Motion (prefers-reduced-motion and cmux's data-cmux-motion reduced/off), which keeps the plain block.

The minimap unmounts, so everything above stops, when the setting is off, the gutter is too narrow, the chat doesn't scroll, or the chat changes. There is no per-row DOM and no per-token work.

Captures (gallery entry agent-pane.chat-minimap, from #25280)

At rest (setting on)Streaming: skeleton · shimmer (picked) · dotted · ghost lines
at restplaceholders

Before (rail only, default): rail only

At rest, prompts are accent bars at the right, replies are text lines, and the view box sits at the reader's place. While streaming, everything above the reply is drawn and the last reply's place is the placeholder; the shimmer frame is held mid-sweep. I picked shimmer: Leo named it, it moves by transform only, and Reduce Motion falls back to the skeleton.

Real app

The exact landed feature tree (e42c00159fa56bfe496238258b5d6b071fd3d002) was built on the fleet Mac as job d5dc77021d467c31fe6c2fd7 and captured on Blacksmith macOS with the real app. The tour enabled agentPane.display.vscodeMinimap, opened an agent chat, picked /tmp, seeded 200 rows, closed popovers, widened the window to 1400×1000, and streamed a reply. --showcase gives the capture a public “Studio Mac” name instead of the runner hostname.

Capture steps-1600ca9bec at e42c00159fa5 (fleet build, Blacksmith macOS capture, text checked for private data)

LightDark
lightdark

The --video run sampled 43 privacy-checked frames while the 3-second reply stream ran; all seven tour steps completed and the capture result had no privacy findings or errors. The capture route stores the checked frames alongside the stills rather than publishing a separate MP4.

Tests

webviews/test/chat-minimap.test.tsx (jsdom, the real VirtualTranscript, 600 rows). It fails on base, which has no minimap:

  • off by default: no minimap, and the rail's 300 ticks are present;
  • on: one canvas, ≤ 4 nodes, one draw, labelled and focusable;
  • streaming 200 tokens with the transcript following: 0 draws, the view box doesn't move, the placeholder's size doesn't change;
  • a wheel during the stream moves the box with no draw;
  • the stream ends: the placeholder goes, and one draw runs, after the frame and not in it;
  • a scroll at rest moves the box with no draw; a click jumps.

Also passing: chat-minimap-paint, thread-minimap-stream and agent-pane-chat-display (23 pass), tsc, and the design-token ratchet. The gallery coverage test's 2 failures are the same on base.

Risk

Low. It is off by default and lives in its own files. App.tsx gets one element (+11). ThreadMinimap.tsx is untouched.

Agent session: huge-blue

🤖 Generated with Claude Code