CodeNomad/dev-docs/SESSION_HISTORY_NAVIGATION.md
Pascal André 1840c71054
Some checks are pending
Publish help / build (push) Waiting to run
Publish help / deploy (push) Blocked by required conditions
fix(timeline): mirror every transcript block in order and keep clicks on the clicked marker (#1086)
## What changes for the user
The timeline now matches the transcript block for block, and a click
lands exactly where it should.

- **One marker per visible block, in transcript order:** each reply,
each thinking run, each tool (or collapsed tool group), each compaction
and each system context. Markers follow Message content: a block that is
hidden in the transcript is hidden in the timeline too.
- **The clicked marker stays active** until you scroll, and its own
block, not just the start of its message, is placed at the reading
position. This also works for history that is not loaded yet.
- **While scrolling,** the active marker is the first block below the
reading line.

## Reproduced in numbers before fixing
We replayed 45 of the user's real sessions (65,241 messages, exported
read-only) into the real `SessionView`, with the outline, windows and
previews served by the real server readers. Each session was measured at
1280×800 with two presets, using 16 real pointer clicks and 8 wheel
scrolls per run. Baseline and fix used the same seeds.

| Metric | Before, Complet | Before, Balanced | After, both presets |
|---|---|---|---|
| Sessions out of sync | 43/45 | 42/45 | 0/90 runs |
| Markers / expected | 68,949 / 130,818 | 68,949 / 98,759 | 130,818 /
130,818 and 98,759 / 98,759 |
| Click: clicked marker active | 53.6% (46.0% the one above) | 57.2% |
1,440 / 1,440 |
| Click: block at reading position | 41.5% (median off by 35 px) | 82.2%
| 1,440 / 1,440 (median 0.27 px) |
| Scroll: reading-line block active | 93.1% | 92.2% | 505 / 505 moving
wheels |

What was wrong with the markers in the Complet runs:
- 29,782 tool markers were collapsed into one marker per step.
- 31,777 thinking blocks and 282 system blocks had no marker.
- There were 10,232 order inversions.

## Causes
1. Once an outline existed, the outline projection replaced the markers
of loaded messages too (`message-section.tsx`). It collapsed a step's
tools into one marker placed before its reply, and dropped system
blocks.
2. Resident segments merged thinking into replies, dropped thinking-only
steps and skipped system parts.
3. After a click or a scroll, the active marker was the message's first
marker. The message itself came from a scroll offset that rounds a row
starting just below the top to the previous message.
4. Clicks navigated to the message, not to the block.
5. Jumps into unloaded history had no row to hold while the target was
not yet rendered.

## How
- **Server (outline):** each assistant step can now carry an optional
`layout` field. It lists the step's visible parts in order: thinking
runs, reply runs and each tool, at most 64 entries. It is computed in
the same bounded SQL. Older plugin copies don't send it, and the client
then falls back to the counts: thinking, then text, then tools.
- **Markers:** `buildTimelineSegments` emits one marker per block, keyed
`messageId:type:ordinal`. Loaded messages keep their exact markers.
Unloaded steps are placed at their native position from `layout`.
- **Click and scroll:** the list's `scrollToKey(key, selector)` holds
the selected block on every frame, survives row remounts, and follows
Virtua's estimate until the row renders. `timeline-transcript-blocks.ts`
maps markers to blocks and finds the block at the reading line.
- **Saved indexes:** outline indexes saved without `layout` are
revalidated once.
- **Docs and help:** updated in `dev-docs/SESSION_HISTORY_NAVIGATION.md`
and in the help pages for all 10 locales.

## Behaviour to be aware of
- **The timeline has more markers:** about +90% with Complet and +43%
with Balanced on this sample, because every tool, thinking and system
block now has its own marker.
- **Tool icons for unloaded steps:** a step not loaded yet shows its
first tool's icon on all of its tool markers, until its window loads.
- **Plugin requirement:** the pruning plugin must ship with the new
`layout` field.

## Validation
- **New regression test:** `tests/browser/timeline-sync.test.ts` passes
4/4 and fails 4/4 on unmodified dev. Fixtures are synthetic.
- **Measurement harness:** the opt-in qualification `npm run
qualify:browser:timeline-sync`.
- **Browser suites:**

  | Suite | Result |
  |---|---|
  | history-navigation | 65 |
  | message-scroll | 128 |
  | session-rendering | 47 |
  | render-cost | 17 |
  | full-source-copy | 12 |
  | search-highlight | 11 |
  | system-messages | 10 |
  | timeline-tool-filters | pass |
| streaming-outline (no full reprojection over 64 token deltas) | pass |
  | session-search | pass |
  | transcript-presets | pass |

- **Other checks:** UI unit tests 75/75, server session-pruning 70/70,
help 9/9 and the UI typecheck pass.
- **After rebasing on dev:** the typecheck passes, along with
timeline-sync 4/4, timeline-tool-filters, full-source-copy 12/12,
usage-metrics 5/5 and streaming-outline.

Oversized files touched: `message-section.tsx` (~1,637 lines),
`virtual-follow-list.tsx` (~1,368), `message-timeline.tsx` (~508),
`message-timeline.css` (204).
2026-10-22 05:32:43 +02:00

34 KiB
Raw Permalink Blame History

Global timeline and bounded navigation

The history-query plugin exposes three read-only navigation operations through dedicated CodeNomad routes: session-history/outline, session-history/outlinePreview and session-history/window. They use the same authenticated native RPC registration and daemon-storage identity challenge as history queries. Never expose generic RPC or SQL access through them.

Read contract

  • An outline page returns at most 16,384 structural entries: native ID, sequence, type, technical-part counts and the first tool name used for indexed tool icons, with no excerpt or body. The plugin's outline method keeps that entry shape, which backends validate strictly; its versioned outlineLayout method adds an assistant step's layout: the markers of the parts its transcript displays before any reveal, in order (R and T for each run of non-blank thinking or visible text, X per tool). That is every part up to MESSAGE_DISPLAY_LIMIT (1,000, shared with record-display-cache.ts), otherwise the first and last 500 around -, where omitted counts the thinking and text runs that start among the omitted parts (their tools follow from the tool count) and whether the first run after them continues the run before them. A layout therefore has at most 1,001 characters, and a page stops adding checkpoint ranges once its layouts reach 1 MiB (16,384 × 64 characters). The route calls outlineLayout and falls back to outline only on a declared rpc.method_not_found from a plugin copy without it. SQL projects this metadata directly; large content never enters JS/RPC/renderer just to draw the rail. Its sequence horizon excludes newly appended messages until the next refresh; known supplies up to 512 contiguous checkpoint digests. Each covers up to 512 native row headers (ID, type, sequence, update stamp and stored byte length). Only changed ranges require technical-count projection and return entries; the live tail is conservatively refreshed and grows before adding a new range. Deletions retain earlier checkpoint boundaries instead of shifting every range. Each page uses its own read transaction. Cancellation yields every 128 projected entries and between checkpoints. Cold assistant/tool indexes inspect native JSON in SQLite, so first-load cost grows with history. Larger histories use bounded pagination; the exact structural snapshot publishes once it is complete.
  • outlinePreview reads at most 12 requested IDs, in priority order. Each reply contains up to 4,096 characters of Markdown and 4,096 of tool excerpt per message; source bodies over 16 MiB return empty excerpts. Preview data never enters the transcript store or affects rail geometry. Cancellation yields between messages.
  • A window targets around, before, after, oldest or latest. It reads at most 200 messages ordered by native seq, without traversing intervening pages. Neighbor windows overlap by 16 messages so the reading anchor can survive a boundary crossing. An around window includes up to 80 predecessors and 120 messages starting at the target.
  • Windows are reconstructed from native storage within a read transaction. The isolated OpenCode fixture compares their complete payloads with native export and single-message reads. Budgets are 16 MiB per message and 24 MiB per window; oversized windows fail explicitly instead of silently omitting content.
  • Ownership includes directory, project and legacy workspace identity. Staged undo hides the same message-ID tail as the transcript. Missing/deleted targets return anchor_missing (distinct from ownership/revert conflicts); the broker rechecks location, project, revert and workspace ownership before publishing either result.

UI navigation

history-window.ts translates this contract into explicitly namespaced CodeNomad window cursors. These are never forwarded to native message.list. Native page cursors are also supported. A truncated newer-page path switches to a direct resident-message anchor rather than replaying the path from the latest page.

Timeline clicks, current-session search hits and missing restoration anchors share loadMessageAnchor. Window publication checks the request epoch, connection generation, mutation revision and cancellation. Latest-window reads also apply the stronger live-message revision check. Reading an old window never overwrites the composer's model/agent from historical messages.

The old transcript stays visible during a jump. A newer target supersedes the pending request, including when the new target is already resident. Cancellation, leaving the view and manual transcript scrolling reject late navigation. Failed jumps keep the visible page and retry the requested destination. Historical windows remain isolated from live append events; returning to latest uses the native latest-page loader.

session-outline.ts owns metadata independently of resident transcript windows. It refreshes on activation, terminal status, undo/content mutation and reconnect; hidden views cancel work. A refresh retains the last successful snapshot and errors provide an explicit retry. The initial outline scan is proportional to the session size; a subsequent jump loads only its bounded destination window.

Restoration of structural indexes

Completed indexes join the restoration graph, with separate 512-entry content-addressed leaves and no excerpts or message bodies. Limits are 16 indexes, 200,000 entries and 16 MiB across the snapshot, prioritizing selected sessions. The optional data uses a separate budget from drafts, attachments and selections. Missing/corrupt index chunks discard only that index. Frozen normalized indexes and encoded leaves are memoized so draft/scroll captures do not rehash each entry. Indexes lacking representative tool names, text counts or step layouts display immediately, then revalidate every checkpoint once before being persisted again. The text count covers non-blank text parts that are neither synthetic nor ignored. An index from an older plugin copy has no layout: its steps are drawn from the counts as thinking, then text (kept when its count is unknown), then tools. A layout's markers are exactly those its step shows once loaded, before any reveal, so folding by Message content visibility gives the same rail indexed or loaded; markers after the omitted parts take native ordinals by skipping the runs that start among them. The outline-index-v1 root extension fences older renderers rather than letting them discard session documents they cannot interpret. Hosts without partition support use a monolithic snapshot without the optional indexes.

Hydration seeds indexes before mounting the session. Durable session identity, directory, project and undo boundary must match; ephemeral runtime generations are never written to disk. Restored geometry is provisional and all checkpoints are revalidated, including old ranges that may have been edited or deleted offline. The cache never supplies native mutation authority. Clearing/disabling restoration uses the native transaction and recapture suppression. Unsupported, over-budget or unavailable indexes fall back to a cold structural read.

Revalidation scans bounded row headers in SQLite and skips replaying unchanged structure and parsing unchanged technical content. The stamps follow native message updates; pruning also changes stored byte length. The cache misses out-of-band writes that preserve both stamp and length.

The timeline uses the same standard native scrollbar as the transcript, with one ordinary gutter. Marker rectangles give up width to it; fixed-size icons and vertical geometry never scale with available width. timeline-virtual-list.tsx measures the marker height and gap primitives, computes exact offsets (including the uniform gaps between markers), and mounts only the visible range. Hidden markers are excluded from the layout. Browsing offscreen rows cannot change the scrollbar extent.

Markers and the active marker

The rail has one marker per transcript block that a reader can land on, in the transcript's order: each text run (a content row), each thinking run (a reasoning card or group), each tool, compaction and system context. Omitted parts split the runs around their placeholder and have no marker. Markers follow Message content visibility with the transcript's own row boundaries: MessageBlock and the rail's foldTimelineSegments both group through groupTranscriptRows (lib/message-part-grouping.ts). A hidden tool leaves the part sequence, so the text or thinking around it shares one row and one marker; hidden thinking and system context leave the rail but still end the row before them. Resident messages use their exact part-level markers; indexed messages outside the window take their native sequence position around them and derive their markers from the outline's layout. Markers are keyed messageId:type:ordinal, with ordinals counted over all of a message's parts, displayed or omitted, so an indexed marker keeps its identity and target when its window loads. A run the omitted-parts placeholder splits keeps its id before it and adds :continued after it. A marker stands for its whole native run: clicking one whose run starts among the omitted parts reveals them first, even when its marker shows only the run's part after them; a continuation stands for that part. A selected continuation a reveal joins to its run hands the selection to the run's marker (survivingTimelineSegmentId). A tool inside a collapsed technical group stands for that group's header.

A click (or a user-message jump) makes its marker active until the reader scrolls: the key jump holds that marker's own block at the top, through row remounts and window loads, rather than the message row. A send, the first/latest jumps and an activation restore also hand the active marker back to the reading line. Otherwise the active marker is the first marked block reaching more than 4 px below the transcript top, re-read once per frame on scroll and content rendering from the mounted rows only. Previous/next user-message jumps advance from that highlighted marker's message, including a selected marker, while it has a timeline marker or remains in the visible resident order. A vanished reference, or the first jump before a highlight, uses the same reading-line probe (readingLinePosition / readingLineSegment), which returns both marker and message identity. A pending jump's target stays the reference while paging or shown, so repeated jumps advance even near a clamped bottom; deferred jumps retain their requested direction until the index arrives. tests/browser/timeline-sync.test.ts covers ordering, identity, click placement and the reading line on a synthetic 320-record session. The opt-in tests/browser/timeline-sync.qualification.ts replays native session stores ({ sessionID, path, count } entries in the shape of createSessionStore) and reports per-preset marker agreement, active-marker and placement accuracy for random rail clicks and wheel scrolls.

Manual rail browsing suppresses active-marker reveals until an explicit transcript gesture. Reveals wait one layout frame and are cancelled by a newer selection or manual rail interaction. Intermediate transcript positions during window loading cannot move the rail. Native transcript scrollbar drags escape following at press, retain ownership beyond the ordinary wheel/key deadline, and postpone boundary paging until release. A wheel gesture at an already-clamped boundary can request the adjacent window even when no DOM scroll event is emitted. First/latest controls account for off-window messages as well as the local scroll offset.

Transcript scroll ownership and trace

VirtualFollowList.writeScroll(reason, op) opens the programmatic window and records synchronous targets. Every write is a DOM write: the list never issues Virtua's imperative operations (scrollTo/scrollBy/scrollToIndex). In Virtua 0.53 such an operation reapplies its fixed target on every item or viewport measurement until 150 ms after the last one; the rows a gesture mounts, or a streaming tail, keep re-arming it, and nothing public cancels it, so it would undo any gesture or later write made meanwhile. Key jumps (always to the row's top) and anchor restoration therefore write the offset Virtua would target for the row (writeIndex, from its own item offsets) and let a list stabilizer settle the row once measured, both cancelled by every gesture: a jump's row is held like an in-place reveal (below), for the same bounded window, and then released, so a later focus, Tab or scrollIntoView is never pulled back to it; the restoration keeps its own loop. A restored anchor that has shrunk since its capture is kept partly visible: Virtua mounts no overscan above the viewport after a downward scroll, so a row held entirely above it would be unmounted and re-placed in a loop. A rolling window (the store keeps the latest 200 messages, so each agent step in a long session drops the oldest) never enters Virtua's shift mode, which compensates every later measurement until scrolling idles and can only be left through an imperative operation. Rows leaving the head are instead retired in place: they stay in Virtua's data as an empty prefix (a key and a size, no content), so Virtua only sees the data grow, measures them at zero, and its ordinary resize anchoring keeps the reader in place. Callers never see them: renderItem receives live indexes, and key lookups, the active row and anchors skip them; a remap never carries a retired row's zero to the same message brought back by a page (live rows are remapped by key, a retained retired prefix by position), and a retired key coming back without a page resets the list. When idle or scrolling up, Virtua compensates only a shrinking row entirely above the viewport (a retiring row across the top stays uncompensated), so a roll records an escaped reader's place, its first visible surviving row and offset. Layout notifications come from the list's row ResizeObserver. ResizeObserver delivers a pass in observer creation order, so that observer is recreated after each Virtua instance (every measurement reset remounts one) and reports after Virtua's measurement, before paint; each roll re-observes a rendered row, so the pass in which Virtua measures the retiring rows at zero reaches it, with or without a net append. It puts the reader's row back and drops the record once every retired row is measured at zero: no painted frame shows the retiring row's height, and nothing outlives the measurement to pull back a later programmatic scroll, a viewport resize (which stays bottom-anchored, clamping included) or a middle-button autoscroll. A held native scrollbar thumb is corrected the same way: Chromium continues a thumb drag from the current offset. Until that measurement Virtua keeps every row where it was, so a write or reveal in the roll's own task, whose scroll event arrives before the measurement, moves the recorded place with it. A write from an animation-frame callback of the roll's frame is undone: its scroll event arrives only after the measurement, when the correction has already run (list-owned reveals supersede the correction instead).

The content above the rows (renderBeforeItems: permission receipts, the older history controls, the empty, loading and load-error states) is Virtua's row 0, ahead of the retired prefix (Virtua's sizes are positional, so a header after the prefix would change index, and size, with every roll), and always mounted. Row index of the list's data is Virtua's row index + 1; the translation stays inside the list (writeIndex, findLiveItemIndex, the retired watermark, the remapped size cache, which keeps the header's size by position, and keepMounted). The header carries no data-virtual-follow-key: it is never an anchor, the active row, a reveal or restoration target, a message or a renderItem call. Like every row it is absolutely positioned, so its resizes move nothing until Virtua measures them and compensates them in the same pass, by the same rule as any row: a header wholly above the viewport keeps the reader's rows in place; one cut by the viewport top is compensated only while scrolling down, and otherwise the rows below it move with its resize (the reader inside it stays put). Near the bottom the browser clamps the offset as Virtua shrinks its content; Virtua then compensates from its recorded offset, so the rows stay in place there too. A held native scrollbar thumb is compensated like a roll: the content stays in place and the offset moves. The row observer also watches the header, so its growth re-pins a following bottom. Its element is created once: a remount moves it into the new row 0 rather than recreating it. The move drops focus inside it; the row observer restores it after Virtua's measurement, before paint, since a remount without a carried size cache (a disjoint window: older page, first/latest, jumps) hides the header until measured.

The place is kept from roll to roll while nothing else moves the reader by more than 2 px, so Virtua's rounded compensations (a fraction of a pixel per roll, more at fractional zoom) never accumulate; returning to follow forgets it. Growth above an escaped reader is Virtua's own compensation and can drift it by a few pixels over many updates. The row observer never watches Virtua's container, which Virtua resizes during its own measurement: a shallower observation changing mid-pass would be deferred to the next frame (a ResizeObserver loop completed with undelivered notifications window error). Only rows that Virtua itself mounts during that pass, after an uncompensated shrink such as a retiring row across the viewport top, raise that error; they lie in the overscan and are measured on the next frame. Retired rows stay mounted only until Virtua has measured them at zero (a watermark advanced on every items change and row resize), so the DOM stays bounded; their keys and sizes accumulate until the next reset (page navigation, a disjoint window, an emptied list or a session change), a few bytes per step. In a hidden (display:none) list the retired rows are measured, and compensated, when it is shown again. Follow-mode changes go through syncControllerResult with a reason as well. Outside it remain user gestures (including wheel re-emission and middle-button autoscroll), Virtua's own size compensation, browser clamping and search-result reveals inside nested scrollers. List positions are read from the DOM, never from Virtua's scrollOffset, which lags a write until its scroll event; a following list is captured at the bottom (without an anchor) wherever its pin's latest write stands.

A search navigation reveals its occurrence once, after placing its message. Inside nested code/tool scrollers the reveal writes them directly and emits writer: "external" with reason search-reveal, without changing the list's programmatic marking. The transcript step goes through the list's alignElement (reason align-element), which holds the occurrence's element at its centred offset for the alignment window against the list's placement corrections; only a transcript outside a list (no alignment) is written directly as search-reveal. SessionView's activation/submission handles and MessageSection's paging, restoration and message navigation (timeline and search alike, through navigateToMessage) use the list API and therefore reach writeScroll.

Enable the trace with codenomadLogger.enableLogger("scroll") (persisted until disabled), then read DevTools or the codenomad.inspect console diagnostics. Each line is write, mode or observe JSON with session, offsets, scroll height and mode. An observe move that no write claimed is unowned; intent distinguishes a user gesture from Virtua/browser movement. A recent synchronous write claims only its actual landing offset, not unrelated moves during its 120ms programmatic window (during retains that recent reason); smooth scrolls are attributed by that window and must be correlated with their preceding write. Synchronous no-op writes are omitted; smooth requests report now and target, never a pre-landing to. Never treat the trace as proof of native/virtualizer ownership. While enabled, .message-stream also carries data-scroll-write (last position-write reason), cleared with its attribution state on the next list action/observation after disabling. data-follow-mode is always present. Disabled, the trace costs only its scroll operation literals and follow-mode binding, with no layout reads or diagnostic-entry allocations.

Each measurement reset remounts Virtua with the key-remapped size cache. The remount keeps the carried default row size as itemSize: Virtua's one-time median re-estimate would otherwise fire later (once a streaming reply outgrows the viewport, or after the long-reply hold escapes) and resize every never-measured history row at once; near the bottom the browser clamps before Virtua compensates, an unowned backward jump of many rows. When every retained row is measured, the remap refreshes that default to their median, so a 40px fallback carried by an instance that remounted before estimating lasts only until the first remount whose retained rows are all measured. A new instance reads offset 0 until the next scroll event, so a non-zero offset is seeded by one scroll event (the remount has already recreated every row). The long-reply hold scrolls nothing; the section persists its escaped position as a follow-mode change and leaves it unsettled, so a state capture's flush records it again after growth below it. An escaped reader keeps its anchor even when captured at the bottom (a hold triggered there), and returns to that anchor rather than to a bottom that has grown while it was away; the restore loads that anchor's window like any other anchor. Gestures and explicit navigation supersede restoration, alignment and bottom pins through one function; deactivation, session reset and unmount stop every list-owned positioning operation (including a restoration in flight) through another. Middle-button autoscroll over a nested output deliberately releases the transcript's follow as well: a transcript still re-pinning its bottom would move that output under the held pointer.

One bottom loop (bottomPin) keeps the bottom, and only while following: leaving follow ends it. Ordinary following re-pins for two frames after each bottom write; an explicit pin (send intent, latest paging, restoration at the bottom) lasts until enough rows exist and the bottom stays put, within a frame deadline, then hands the bottom back to ordinary following. Layout changes apply restoration, then an explicit pin, then the long-reply hold, then follow. A restoration requested during a pin supersedes it (the pin settles interrupted), except that a restoration to the bottom joins an active explicit pin and keeps its row count; it reports onApplied only after its own landing, and onCancelled when superseded or when its pin loses follow without a gesture. A new send intent also cancels MessageSection's pending window paging, which would otherwise leave follow after the send, and its pending activation restore: a send before hydration keeps the bottom over a saved passage, and does so even if the prompt is then rejected (the saved passage is not restored).

In-place reveals (alignElement) are reader-settlement holds on the revealed element; key jumps hold their row the same way at offset 0, or the block their optional selector finds in it on that frame (a timeline marker's own block). While a jump's row is not rendered, the hold rewrites Virtua's current estimate of its offset instead, since measuring the rows around a distant estimate can carry the row out of the rendered range; once rendered it is aligned. Both last a fixed 30-frame window counted from the request, which layout never extends, re-measuring and re-aligning the element on every frame, and are released afterwards. Within it, viewport and header resizes re-align the element instead of dropping it, and its requested offset is kept and clamped into the current viewport only when applied. Gestures, navigation and focus moving to another element inside the transcript scroller (Tab, assistive technology) end it at once, and so does losing the element (an unmounted row); focus leaving the transcript (e.g. to the composer) keeps it. Any other scroll during the window is corrected like layout. A hold whose row rolls out of the window ends; nothing retains a reader after a hold expires. Settlement, alignment and anchor restoration share one anchor measurement. The long-reply hold is decided before each bottom write, against the offset that write would reach, so it engages with the reply top still below its line: once that top is above the viewport, Virtua (still scrolling down) would compensate the reply's next growth and push it further up. Known limitations: a search reveal is aligned one frame after its row's highlights are painted, so that frame can show the message top; and Virtua renders the rows of a jump's new offset only at its next scroll event, so a session switch within that frame finds no anchor to capture and keeps the position from before the jump. A hold whose reply grows while its pane is hidden lands at the top only in the switch fixture: native events for an inactive session invalidate its message load, and that fixture's message.list omits the in-flight reply, so the reload drops the hold's anchor and restoration waits ANCHOR_RESTORE_MAX_FRAMES before its pixel fallback. With the reply listed, the hold returns exactly; but the latest button's reload then re-engages the long-reply hold at once, a product decision left open. A following list shown again after display:none is pinned before its first painted frame: a gap read in the animation frame that shows it predates layout and the ResizeObserver-driven pin, and a browser check measures 0 px once painted.

Browser checks load the complete stylesheet and cover overflow, hover, keyboard focus, RTL and 125% zoom, mixed text/tool/idle records, actual native thumb dragging, and viewport anchor stability during streaming and distant jumps.

Hover/focus previews escape raw HTML and do no syntax highlighting; moving elsewhere cancels obsolete excerpt work. An unloaded/empty excerpt shows no popup; resident selected content can supply it directly. A separate 512-excerpt cache survives view switches; entries older than 30 seconds revalidate on demand, without timers/polling. The opaque surface wraps text and stays inside the viewport; Escape, rail scrolling and viewport resizing dismiss it.

Automatic restoration of a missing anchor recovers once through the latest visible page and replaces its saved anchor/cursor only on success; explicit navigation reports the missing destination. Active-view initial reads cancel on hiding.

Outline scans retain accepted pages and their sequence-horizon cursor when a view is hidden or unmounted. Up to 16 structural snapshots (200,000 entries across the retained LRU, except a single larger active index) are retained in the renderer, keyed by instance/session, connection generation, content-mutation revision and undo boundary. Returning reuses the completed rail immediately. An unchanged return performs no index read. Message changes observed on activation/status transitions refresh the last 32 indexed rows and new arrivals, preserving the prefix and last completed display. Destructive edits/reconnect/undo invalidate the full index. A status transition during an incomplete scan cannot discard its progress.

Validation

  • Server SQL and route tests cover distant windows, sequence gaps, overlap, cancellation, payload limits, staged undo and ownership/mutation races.
  • history-navigation.test.ts exercises the real SessionView, transcript store, native event dispatcher and SQL readers on a 1,500-message Chromium fixture; timeline-sync.test.ts checks marker order, identity, placement and the reading line with the same readers on mixed native step shapes, the rail against the rendered rows under every preset and thinking/tool/system visibility, steps beyond the part display window (indexed markers equal to loaded ones, runs starting among or split by omitted parts) and a send after a jump. session-outline-projection.test.ts compares the plugin's layouts, projected, with the markers buildTimelineSegments builds for the same native steps; streaming-outline.test.ts counts full-history projections and rail folds per token, including after a hidden tool under Nothing.
  • timeline-sync.qualification.ts (opt-in, npm run qualify:browser:timeline-sync) measures marker agreement and click accuracy on replayed native session stores.
  • test-session-navigation-native.mjs, run by the isolated pruning fixture, checks a 1,501-message session against a real Windows daemon. These fixtures cover Chromium and Windows daemons only, never an installed Electron/Tauri app, WSL, Linux or macOS.

Agent conventions

  • Timeline markers mirror the transcript: one marker per marked block, in transcript order (each text run, thinking run, tool or collapsed tool group, compaction and system context). They follow Message content: a tool marker is hidden when the tool type it shows (its icon, the outline's representative tool for indexed history) is hidden there, thinking markers when thinking is hidden and system markers when system messages are; tool markers awaiting a permission or form stay visible, as their tools do. Marker visibility reads only preferences, permission state and the value-diffed pending-form targets, never streamed transcript data, so tokens never refold the whole rail (streaming-outline.test.ts). Folding follows the transcript's rows through the shared groupTranscriptRows (foldTimelineSegments): text or thinking around a hidden tool keeps one marker, and hidden thinking or system context still ends a row. A hidden active marker hands its highlight and reveal to its message's first visible marker (activeVisibleId) and never reappears just because it is active. Message content is the only control: never add a timeline-tools preference, View menu item, palette command or shortcut. The Nothing preset hides every tool marker. Covered by tests/browser/timeline-tool-filters.test.ts and tests/browser/timeline-sync.test.ts.
  • Session timeline placement spans the transcript and composer via the session-owned mount; keep its rail layout in styles/messaging/session-timeline-rail.css. Hide the rail and force conversation-header actions into their overflow menu below 420 CSS px of the session-center container on all platforms. Keep that width-only force separate from measured header density so trial layouts can expand again above the breakpoint; filters anchor and return focus to the visible menu. Docked drawers reserve a minimum conversation width of 390 CSS px before overlaying. The composer worktree selector becomes a compact arrow-only cell below 460 CSS px of that conversation container (never the remaining composer width); keep its accessible label and styles in styles/components/prompt-context-controls.css. Timeline visibility must not depend on measured header density, changing status controls or viewport height; preserve the saved visibility preference.
  • Timeline geometry uses the cached structural index independently of transcript/excerpt loading. Resident messages keep their exact markers (buildTimelineSegments); indexed messages outside the window take their native sequence position around them, with markers from the outline's layout (exactly the markers of the parts the loaded step displays before any reveal, around omitted run counts beyond MESSAGE_DISPLAY_LIMIT; never an invented order) or, from older plugin copies, its counts ordered as thinking, text (kept when unknown), tools. Layouts come only from the versioned outlineLayout RPC; outline keeps the strict legacy entry shape for backends sharing the plugin, and the route falls back to it only on rpc.method_not_found. Markers are keyed messageId:type:ordinal by native ordinal over all of a message's parts (:continued after the omitted-parts placeholder), so an indexed marker keeps its identity and target once its window loads, a click reveals omitted parts its run starts in (even when its marker exists), and a selected continuation a reveal removes hands over to its run's marker; resident messages the transcript hides have no markers. Indexed tool markers retain the outline's representative tool name so their icon does not fall back to other; preserve that metadata through projection and persisted-index revalidation. A clicked marker or user-message jump stays active until the reader scrolls, sends, jumps to the first/latest message or an activation restore runs, and its own block is held at the top by the list's key jump (scrollToKey with a block selector, which survives row remounts and follows Virtua's estimate until the row renders); otherwise the active marker is the first marked block reaching below the transcript's reading line, refreshed once per frame on scroll and content rendering. Marker-to-block lookup and the reading line live in components/timeline-transcript-blocks.ts. Hover/focus previews render bounded Markdown, prefetch visible-nearby excerpts and prioritize the hovered marker through stores/timeline-previews.ts; never mount whole message/tool cards or fetch transcript windows for hover. Keep the viewport-bounded surface in styles/messaging/timeline-preview.css.
  • Transcript rows and the header row (.virtual-follow-list-before) retain pointer hit testing during virtualizer scrolling (styles/messaging/virtual-follow-list.css), so nested code/tool scrollers, message and history controls receive gestures at their visible target.
  • The transcript header (renderBeforeItems) is Virtua's row 0, ahead of the retired prefix: Virtua compensates its resizes like any row's, so never add list-side header corrections. Keep its index translation inside virtual-follow-list.tsx; it is never an anchor, a message or a renderItem call.
  • Every list-owned transcript position write goes through VirtualFollowList.writeScroll with a reason from the closed SCROLL_REASONS list (components/virtual-scroll-trace.ts); never add a direct scrollTop/Virtua write beside it. User-gesture routing (wheel re-emission and middle-button autoscroll) also writes the transcript outside it; search reveals inside nested scrollers are the explicitly traced external exception (search-reveal); their transcript step goes through the list's alignElement. Diagnose jumps with the scroll logger namespace.