Skip to content

feat(app): add session turn navigator - #54088

Closed
opencode-agent[bot] wants to merge 7 commits into
devfrom
turn-navigator
Closed

opencode-agent[bot] wants to merge 7 commits into
devfrom
turn-navigator

Conversation

@opencode-agent

@opencode-agent opencode-agent Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds a turn navigator to the left edge of the session timeline: one tick per user turn. Hovering a tick previews the prompt and the end of the agent's latest reply. Clicking a tick jumps to the turn. The tick for the turn you're reading is highlighted as you scroll. Hovering the rail widens it: the hover area grows from 20px to 36px, every tick doubles in length, and the hovered tick is longest. Tick spacing stays fixed, so the tick under the cursor doesn't move away.

image

Data loading

  • Turns come from GET /api/session/:id/message?type=user (asc, 200 per page). The filter runs in SQL on the existing (session_id, type, seq) index, so the rail lists every turn without fetching the whole transcript. When the loaded tail already reaches the start of the session, no index request is made.
  • For loaded turns, the reply preview reads the existing assistantMessagesByParent projection.
  • For turns outside the loaded history, resting the pointer on a tick for 150 ms fetches only that turn's latest reply: type=assistant&before=<next turn's user message>&limit=1. Replies are cached per turn, and sweeping across the rail fetches nothing.
  • New before query parameter on GET /api/session/:id/message: it returns the newest-first page of messages preceding a message (exclusive). The store already supports this through its cursor anchor, so the handler maps before onto the existing { id, direction: "next" } cursor. Combining it with cursor or order=asc returns InvalidCursorError. The client is regenerated. packages/protocol/openapi.json is not regenerated because it is already about 1,100 lines out of date on v2 and regenerating it would add unrelated churn.
  • Nothing is fetched or rendered until 1 s after the timeline mounts, and never on touch-only devices. This keeps the index request and the rail's reactive state off session entry and tab switches. The rail then appears once it lists every turn, so it never grows under the pointer. A failed index request falls back to the loaded turns.
  • Loaded history is one contiguous tail, so jumping to an older turn pages history with the existing loadMore until the turn arrives, then scrolls to it. This is the same cost as scrolling there today. It needs no new server API.

Notes

  • Mouse only: the rail is aria-hidden and does nothing without a mouse. Keyboard users already have the previous/next message commands (mod+alt+[ / mod+alt+]).
  • The virtualizer now exposes activeUserMessageID. It's computed from the row at a reading line just below the sticky header, on scroll, with one getVirtualItemForOffset binary search.
  • The e2e mock server now supports type-filtered message lists, with before, through a new messageIndex hook. Without the hook, typed lists return nothing, so existing paging assertions are unaffected.

Possible follow-ups

  • A message-list anchor such as around=<messageID>, plus a client store that can hold separate loaded ranges, would let a jump to an old turn skip the pages in between.
  • A lightweight turn-summary response would avoid sending each user message's inline base64 files[].data with the index.

Testing

  • bun run typecheck and bun test ./src/session in packages/app pass.
  • packages/server/test/message.test.ts: added cases for before with type filters, the continuation cursor, an anchor with no earlier matches, and rejection when combined with cursor or order=asc.
  • e2e: a new case in the timeline-history keeper suite covers:
    • the index lists all 40 turns without paging the transcript;
    • hovering an unloaded turn shows its own reply (not the next turn's) without paging the transcript;
    • clicking an unloaded turn pages history and scrolls it into view, and its tick becomes active.
  • The full timeline-history suite passes on the final revision. The timeline-cache, timeline-geometry, timeline-stream, session-timeline-accessibility, mobile-timeline-scroll, session-header and command-palette suites passed on an earlier revision.
  • Checked screenshots with 6, 240 and 30 turns (the last at 820px wide), hovering both loaded and unloaded turns.
  • Benchmarks (measured before the hover-fetch commit, which only adds work on hover): bench:entry and bench:tabs scenarios, built app, interleaved base/after runs, 12 repeats each, on a shared, noisy host. Final medians, firstCorrectObservedMs:
    • cold entry: 378 ms vs 367 ms base
    • cold tab switch, review closed: 198 vs 191
    • cold tab switch, review open: 222 vs 215
    • warm tab switch, review closed: 43 vs 41
    • The differences are within run-to-run variance on this host (base medians moved by up to about 20 ms between runs).
    • An earlier eager version cost about 30–40 ms on cold entry and about 15 ms on warm-switch stability, which is why loading and rendering are deferred. Benchmark medians with the final 1 s delay, firstCorrectObservedMs then stableObservedMs: cold entry 363 / 444 ms, cold tab switch 181 / 220 ms, warm tab switch with review open 64 / 115 ms. The interleaved base runs above were 367 / 453, 191 / 220 and 61–67 / 110–128 ms.

Requested by: @Brendonovich (Brendan via Slack)

@usrnk1

usrnk1 commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Pushed 9b633a9647 with visual & interaction polish for the turn navigator:

  • Hover interaction: Replaced uniform rail-wide tick expansion with a local cosine-proximity wave around the pointer (hoverRadius = 6) using GPU scaleX transforms, plus a 100ms initial preview delay and 100ms smooth translateY glide between turns.
  • Tick geometry & styling: Updated tick widths (default: 7px → 12px, active: 12px → 20px, hovered: 14px → 22px), 10px row height (8px visible gap), 10px inline-start inset, 16px → 32px rail width, sharp tick corners, and a 180ms ease-out fade entrance.
  • Theme tokens: Default ticks use --v2-background-bg-layer-04 (70% opacity in dark mode); active ticks use --v2-icon-icon-muted in light mode and --v2-icon-icon-base in dark mode; hovered ticks use --v2-icon-icon-faint in light mode and --v2-icon-icon-muted in dark mode. Active ticks retain their active color when hovered.
  • Tooltip typography: Set reply preview line height to 20px (leading-text-base).
  • Visible-turn tracking: Highlight all visible user prompt bubbles in the viewport, falling back to the preceding turn when only a reply is in view, coalesced per animation frame with fast-path item bounds checks and late-render resize updates.
  • Filtered turns: Exclude queued inbox items that do not have timeline rows yet.

@Brendonovich
Brendonovich enabled auto-merge (squash) October 14, 2026 13:57
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.

2 participants