# Scrollspy without a browser

> A docs site keeps its table of contents in step with the page in fifteen lines. A GPUI chat transcript took four attempts. What the browser gives away, and what a desktop app has to rebuild.

The transcript rail is the column of small dashes down the left of a chat pane in Cydonia. It answers two questions at once: how much of the session the pane is showing, and which turn of it you are on. It took four attempts to get right. This is what the problem actually is, why the browser makes it look trivial, and why a desktop app does not get the same deal.

The general name for this is _scrollspy_: keeping a table of contents in step with a scrolling document.

## Two indicators, not one

Every implementation that works separates two things:

- **A region**: what is on screen. A set, computed by intersecting each section's rectangle with the viewport. Partial counts: a sliver of a section means that section is showing.
- **A point**: where you are reading. Exactly one, computed by a _tripwire_, not by visibility.

VS Code's minimap is the clearest statement of the split. The translucent slider is the region; the cursor line is the point. They are drawn differently because they answer different questions.

Cydonia's rail commits to the same split with three tones: reading, visible, away. That part of the design was never the problem.

## What actually went wrong

Three faults, all in the transcript view.

**The two indicators came from different sources.** The region came from `painted_turns`, which intersects last frame's row bounds against the viewport. The point came from a separate function that walked an anchor down the viewport in proportion to scroll progress, with a hard shortcut: if the list is following the tail, the point is the last turn. Two derivations of the same geometry, free to disagree, and they did. At the bottom of a session the last mark lit however many earlier turns were still on screen. Away from the bottom, the progress anchor drifted and the reading mark sat in the _middle_ of the visible run instead of at its head.

Measured before the fix, on a seven-turn session at 50, 75, 90, 95 and 100 percent scroll: the reading mark landed on turns 3, 5, 6, 6, 6 where it should have been 2, 3, 3, 4, 4.

**The floor was in the wrong place.** The composer floats over the foot of the pane, so turns behind it are painted but covered, and lighting a mark for one says the pane is showing something it is not. The subtraction was `footer_height + COMPOSER_BOTTOM + PAD`, but `PAD` is the list's own breathing room, not covered glass. The floor sat 28 pixels above the composer's actual top, so a turn fully visible in that strip was dropped from the run and dimmed.

**A boundary rule stated as a principle.** The tail shortcut survived three rewrites because it reads as reasonable: _at the end of the list, the last turn is the current one._ It is not a principle. It is a guess that happens to be right when the last turn fills the pane, and wrong the rest of the time.

## Why the tests did not catch it

This is the part worth remembering. The rail's test harness rendered its rows as plain sized boxes. The real renderer wraps every row in a canvas that records the row's bounds into a shared map, which is the only thing that feeds the region. The harness had no such canvas.

So in every test the recorded map was empty, the region was `0..0`, and **no mark was ever painted at the visible tone**. The suite could only ever observe the reading mark. Three rounds of fixes were validated against a harness structurally incapable of showing the bug.

The lesson is narrow and sharp: when a component reads geometry back from its own painted output, a harness that fakes the painting tests nothing. The harness now carries the same canvas the real renderer does.

## Why the browser has it easy

A docs site does this in about fifteen lines, and nobody writes a blog post about it. That is not because the algorithm is easier. It is the same algorithm. The browser hands you five things that a desktop UI framework does not.

**1. Layout is retained and queryable at any time.** `getBoundingClientRect()` reads a layout tree that persists between frames. You can ask where section seven is without having drawn it, at any point in your code, synchronously. In GPUI the element tree is rebuilt every frame, and geometry exists only during the frame that produced it. While building the rail this frame, the newest geometry available is last frame's. Hence the map of painted bounds, the double buffer and the extra refresh to settle: machinery that exists purely to simulate what the DOM gives away.

**2. Intersection is an engine service, off the render path.** `IntersectionObserver` runs after layout, inside the browser's own rendering update, and calls you back before the next paint with `isIntersecting` and `intersectionRatio` already computed. You never measure anything. There is no equivalent to subscribe to in GPUI; you compute it yourself, from whatever geometry you were able to capture.

**3. Nothing is virtualized.** Every section of a docs page has a box at all times, because the whole document is in the DOM. A chat transcript cannot work that way. It virtualizes, materialising only rows near the viewport, so "which sections exist and where" stops being a question you can ask the elements and becomes a question you have to ask a measurement cache.

**4. The tripwire is declarative.** You do not compute the line. You shrink the observer's root until only a thin band near the top survives, and let the engine do the hit test:

```js
new IntersectionObserver(onCross, { rootMargin: "0px 0px -80% 0px" })
```

Everything below the top 20 percent of the viewport stops counting. Exactly one heading qualifies at a time, with no ties to break and no flicker at boundaries. Docusaurus, VitePress, Nextra and the Tailwind docs all ship a variant of this.

**5. There is no tail-following.** A web page does not scroll itself to the bottom as content streams in. That mode is the single biggest source of difficulty here, because it makes "where is the top of the viewport" ill-defined: GPUI parks the list's logical scroll top _past the end of the list_ while following the tail, and `bounds_for_item` consequently answers `None` for every real row. The one mode where you most want to know what is on screen is the one where the list will not tell you.

The flip side: a browser live-tailing a log has exactly the same problem, and browser scrollspy recipes have nothing to say about it.

The browser's advantage is not algorithmic. It hands you trustworthy geometry for free, and everything hard about scrollspy lives in getting trustworthy geometry.

## The end-of-list trilemma

One part is genuinely hard everywhere. Once the pane is scrolled to the bottom, the trailing sections can never reach the tripwire, so they can never become the point. Three answers, all in use:

| Answer | Cost |
| --- | --- |
| Clamp to the last section at the bottom | The last mark lights while earlier sections are still on screen |
| Add a viewport's worth of trailing space | Every section can reach the tripwire, at the cost of blank space under the content |
| Accept it, and make the indicator clickable | The tail is reachable by intent, not by scroll |

Bootstrap's scrollspy takes the first. Docs sites with long footers get the second by accident. Cydonia takes the third: the marks are clickable, and a press holds its selection until the list moves off it.

## What Zed does

Zed ships three things shaped like this, and solves none of them by scrollspy.

Its **outline panel**, the actual table of contents, tracks the _cursor_, not the scroll. It takes the newest selection anchor and picks the outline item containing it, tie-broken by depth, then distance. Scrolling without moving the cursor does not move the highlight. An editor has a cursor, so "where you are" is a fact rather than an inference. A transcript has no cursor, so this exit is closed.

Its **agent panel** is the direct analogue: the same list primitive, variable heights, tail-following and turn structure. It renders no rail at all. The only position it computes is `logical_scroll_top().item_ix`, the item at the top of the viewport, used for jump-to-previous-message. No progress heuristic, no tail case. For contiguous items that is the same as "the first visible one".

Its **minimap and scrollbar markers** are pure model arithmetic over scroll position, line height and total lines. Uniform line heights mean the editor knows the visible range exactly without measuring anything. That is how it draws a region with no frame lag, and it is an advantage a variable-height transcript cannot have.

So the only rule Zed uses for a variable-height list is the topmost item, and the only place it draws a region is the one place the geometry is free.

## Where it landed

One derivation. `painted_turns` computes the run of turns the pane is showing, partial slivers included, with the floor at the composer's real top. `reading_turn` takes the head of that run. The reading mark is now always one of the lit ones, because it is drawn from the same set.

The tail shortcut is gone. Reaching the end of a list is not an answer to which turn is being read.

What remains is a frame of lag, and that is not a bug in the rail. It is the cost of reconstructing retained layout from painted output. The real fix is upstream: a way to ask a tail-following list for a row's bounds. Until then, the rail reads last frame's geometry, and asks for another frame when it changes.

The code is in [`crates/gui/src/view/component/transcript.rs`](https://github.com/crabtalk/cydonia/blob/main/crates/gui/src/view/component/transcript.rs).
