Skip to content

fix(app): make the turn navigator easier to hit and land on the right turn - #57572

Merged
kitlangton merged 3 commits into
betafrom
turn-navigator-feel
Oct 21, 2026
Merged

kitlangton merged 3 commits into
betafrom
turn-navigator-feel

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes the experimental turn navigator from Kit's review: it was hard to hit, clicking a tick could land on the wrong turn, and previews showed the wrong reply.

Causes

  • Wrong landing and duplicated text. The rail sorted turns by message ID. Some clients (the iOS app) mint uppercase UUID IDs, which sort after every time-based ID, so those old turns became the bottom ticks. Clicking "the latest" tick paged in history and jumped to an old turn, and that turn's preview asked for the newest reply before a non-existent next turn: the session's latest reply, the same text already at the bottom of the transcript. The rail now keeps the server's timeline order, and an unloaded turn's reply is read back from the next turn and stops at its own prompt, with no ID comparisons.
  • Beta flake in timeline-history.spec.ts ("keeps visible content through …", "limit": 9007199254740991). The rail's user-message index fired on a 1s timer from mount, so on slow runners it landed in that test's exact log of transcript pages. The index load now waits until the timeline's own history requests have been idle for 1s, reads newest-first with a 1,000-turn cap, and is aborted on unmount. Because that test logs every transcript read, it also turns the rail off.
  • Several previews while scrolling. The preview only closed on pointerleave, so scrolling kept it open over turns that had moved. Now there is one always-mounted preview card. Scrolling over the rail closes it, and the next pointer move reopens it on the turn under the pointer.

Feel

  • The whole 32px rail column is the hit area, and the nearest tick by Y wins, so you can't fall off between ticks.
  • Ticks within 48px of the pointer swell towards it like a dock. One pointer-position spring (0.12/0) and one wave spring (0.2/0) drive every tick through frameLoop.addMeasured.
  • The hovered tick lights at once, brighter than the visible-turn ticks, and cools over 180ms when the pointer moves on.
  • The preview's prompt line sits level with its tick and the card grows downward as the reply loads. It is clamped once against its maximum height, so loading a reply never moves it. Moving between ticks glides it on a 0.25/0 spring, and it opens and closes on a 0.18/0 opacity spring.
  • The preview shows the prompt in a user-bubble tint, then the start of the reply in muted text. Both end in a soft mask instead of an ellipsis.
  • Reduced motion drops the travel and keeps the fade.
  • Clicking the last tick reaches the bottom the way the jump-to-latest button does. A tick whose prompt is already mounted glides there; any other tick jumps through the virtualizer as before.

Proof

Before (left) and after (right), in the mocked web app. The cursor sweeps the rail, clicks a middle turn, clicks the bottom tick, then scrolls over the rail:

before-after-clip.mp4

Tests

  • timeline-history.spec.ts, new keeper: the rail keeps timeline order when message IDs from other clients don't sort by time. An unloaded turn previews exactly its prompt and its own reply, and clicking it lands on that turn after paging. Scrolling closes the single preview. The last tick lands on the latest turn. It fails on beta (the preview shows "Prompt number 6" for the sixth tick) and passes with the change, along with the existing navigator keeper. The whole timeline-history.spec.ts and the settings navigator keeper pass on a built app.
  • Nearest-tick hit-testing has no unit test of its own: the keeper hovers and clicks ticks along the rail, which exercises it.
  • The mock server now honours a before anchor without a type filter, matching the server.

LOC: code +275/−123 (turn-navigator.tsx, plus a few lines in interaction.ts, use-session-hash-scroll.ts and message-timeline.tsx), tests +52/−3.

bun typecheck (app) passes, and lint:changed reports nothing in the changed files. The pre-push check fails on an existing gui-extensions/src/details/popover.tsx lint error from beta, unrelated to this change.
Review follow-ups

  • Reverted turns are cut at the revert message's position in the ordered list, not compared by ID. This switches to messagesBefore from fix(app): order messages by position, not by ID string #57581 once that lands.
  • Reply loads are aborted on unmount, like the index load.
  • A closed preview keeps its last content for the fade-out and stops following a streaming reply.
  • Hover state is set in a microtask after the frame's writes, not inside the write phase.
  • A tick's transform is written only when its scale changes.
  • The rail reads layout only while the pointer is on it.
  • The turn list keeps its identity across streaming deltas.

Performance

session-timeline-benchmark streaming with 320 history turns, 160 deltas and 30× CPU throttle on the Pro. I ran six rounds, alternating the merge-base build and this head on one machine. Medians:

completion deltas/s long tasks long-task time rAF gap p95 missed frame budgets
beta 14,664 ms 10.9 62 5,239 ms 192 ms 691
this PR 14,197 ms 11.3 60.5 5,112 ms 200 ms 677

No difference beyond noise. An earlier, non-alternating run had pointed to a regression: the rail read layout on every streamed frame. That is fixed above. The session-entry benchmark times out before reporting, on beta as well as on this branch.

Overlap: this stays in the navigator and its click path. It doesn't touch virtualizer.tsx (the timeline-motion rework) or grouped user messages (#57552).

@kitlangton
kitlangton merged commit 1e581ee into beta Oct 21, 2026
11 of 19 checks passed
@kitlangton
kitlangton deleted the turn-navigator-feel branch October 21, 2026 04:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant