renderBoard() starts with `board.innerHTML = ''` and rebuilds every
column, so each column's scrolling .drop was a brand-new node on every
pass — and a new node's scrollTop is 0. render() runs on every SSE frame
and a working agent emits an event per tool use, so a column being read
snapped back to the top several times a minute: worst exactly when there
is most to read. The same wipe threw away #board's horizontal position,
and the same demolition costs the session timeline, the sessions rail,
the Focus view, the drawer, and the activity log's place whenever it was
not stuck to the bottom.
Rather than a reconciling render — the real cure, and a far larger change
to the way the page works — the position is paid for separately: note
where each scroller was before the wipe, put it back once the new nodes
are in, both halves inside the same synchronous render so nothing
flashes. Keys are stable names rather than nodes (the stage slug for a
column, the session id for a timeline), because the node is what does not
survive. Restoring clamps, so a column whose cards moved on lands at its
new bottom instead of past it, and one now shorter than its own viewport
lands at the top instead of at a negative offset.
A `v:` prefix marks a key belonging to a view, and switching views drops
those: coming back is a fresh look, not a stale offset from a previous
visit. The activity log spans every view, so its key carries no prefix
and outlives the switch. The two behaviours that were already right are
left alone and now have tests holding them there — the log still follows
the newest line when it was stuck to the bottom (that reading runs after
the restore, so it wins), and the drawer still opens at the top when you
select a different card, which falls out of keying the drawer per
document rather than being fought for.
The helpers run for real under node, as tests/test_drawer_markdown.py
already does with md(); the source-level invariants beside them hold the
mark/restore pairs in order around each wipe, and one of them fails if a
new scrolling element is added to the CSS without a renderer keeping its
place.
python3 -m unittest discover -s tests → 854 tests, ok