Skip to content

feat(chat): add a conversation minimap for turn navigation - #359

Merged
AprilNEA merged 7 commits into
masterfrom
xuan/code-517
Jul 31, 2026
Merged

feat(chat): add a conversation minimap for turn navigation#359
AprilNEA merged 7 commits into
masterfrom
xuan/code-517

Conversation

@AprilNEA

Copy link
Copy Markdown
Member

Summary

Adds a turn-navigation rail — a conversation minimap — in the reading column's left gutter. One tick
per turn; hovering a tick magnifies it and its neighbours and opens a preview of that turn; clicking
jumps to it. Closes CODE-517.

It is not a scrollbar and does not replace one. The native scrollbar answers "where am I in the
pixel stream"; the rail answers "what turns is this thread made of, and which one do I want". Inside
a single long turn the rail is deliberately static — the scrollbar is the only thing that moves
there, which is why it stays.

Ticks are evenly spaced, not proportional to document height. Under virtualization an unmounted
row's offset is an estimate that shifts as it measures, so mapping ticks to document position makes
them drift while you scroll. Index spacing also keeps every hit area the same size regardless of how
long a turn is.

Two facts made this much smaller than expected:

  • ConversationView already virtualizes over TurnSegment[], so a tick index is a virtualizer
    index — there is no mapping layer.
  • use-stick-to-bottom needs no coaxing: scrolling up sets escapedFromLock, so an upward jump
    unpins itself, and a downward jump only re-pins within 70px of the bottom — exactly the behaviour
    you want when clicking the last tick. Zero mitigation code.

No new dependencies. The preview card is base-ui's PreviewCard (already vendored in coss-ui), used
with createHandle() + detached triggers so one card serves every tick and only the open turn's
excerpt is ever built.

Commits

refactor(ui) Move the sidebar's side-capable preview-card fork up to preview-card-popup.tsx as SidePreviewCardPopup, rather than copy its class list a third time. Behaviour unchanged for both sidebar call sites.
feat(chat) ConversationContent exposes the virtualizer handle and a scroll callback; the conversation root becomes a named container so viewport-pinned overlays size against the pane, not the window.
feat(chat) Pure geometry: the magnification falloff and the rail's own scroll position, kept testable without layout.
feat(chat) The rail itself, plus i18n and wiring.
feat(mock) A 48-turn thread in the dev mock host, so length-dependent behaviour is reachable without a real agent.
fix(chat) Tuning pass after driving it in the desktop app.

Notes for review

  • Magnification is written straight to the DOM from a rAF-throttled pointermove, not through
    state — a continuous per-frame value across every tick would otherwise re-render the whole rail
    each frame. Resting width lives in a class rather than style so a re-render mid-hover cannot
    clobber it; that collision caused a visible snap-back before it was fixed.
  • The visible range and the rail's own scroll are resolved in one callback, so nothing watches
    either with an effect (sukka/react-no-use-effect-watching). Before the first scroll every turn is
    treated as on-screen — correct for a thread too short to scroll, and corrected immediately by the
    initial jump to bottom on one that isn't.
  • The handle is useMemo, not useSingleton. sukka/react-no-use-state-as-ref pushes toward
    useSingleton, but base-ui reads the handle during render and react-hooks/refs rejects that. The
    two rules conflict here; useMemo satisfies both.
  • Auto margins, not justify-center. A centered flex column clips its first item once the
    content outgrows the scroll container, which would make the top tick permanently unreachable.
  • The rail's column is wider than what it draws. overflow-y-auto makes the x axis clip too, so
    a column narrower than a fully magnified tick would cut it off.
  • Off-screen ticks rest at the hairline tone (bg-border). The label-* tiers are built for text
    and all sit too high for a fill this small; the front-end rules exempt fills from the
    no-ad-hoc-opacity rule, but a semantic token is still better than a raw opacity.
  • Rail hides below an 880px pane (content column + 56px each side) and for a thread with one turn,
    where it would be pure decoration.

Verification

pnpm check:ci and pnpm test (2243 passed) both green.

Driven in the desktop app against the new 48-turn mock thread — magnification feel, tick states,
preview content, click-to-jump, and the rail following the viewport were all judged there over
several tuning rounds, which is where the final shape came from.

Measured in the browser (mock webview, no daemon):

  • Rail renders in the left gutter; accessible as button "Turn N" per tick.
  • Preview card: side=right, 376px wide (the w-96 override beats the vendored w-64), anchored
    against the rail's right edge, title from the turn's user text, body rendered through Streamdown.
  • Container query hides the rail below the pane threshold.
  • Overflow contract with 61 ticks: scrollHeight 1098 > clientHeight 687; at scrollTop 0 the first
    tick's top equals the rail's top (not clipped — the justify-center trap above); the last tick
    is fully reachable at the end; the rail's own scrollbar is not rendered.
  • Transition tokens resolve to 150ms cubic-bezier(0.23, 1, 0.32, 1).

Not covered by automated checks: the magnification is rAF-driven, and the headless browser pane
produces no animation frames, so its motion was only verifiable by hand in the desktop window. The
falloff curve itself is unit-tested.

Checklist

  • pnpm check:ci and pnpm test both pass (plus cargo fmt / clippy / test for Rust changes)
  • I ran the affected surface and observed the change working
  • If a wire message changed: WIRE_PROTOCOL_VERSION is bumped — n/a, no wire change
  • New code and assets are my own work, or their origin and license compatibility are noted above
  • Docs and comments are updated where behavior changed

AprilNEA added 6 commits July 31, 2026 19:52
The chat minimap needs the same side-capable fork the sidebar already had; move it up instead of copying the popup's class list a third time.
Row offsets, imperative scrolling, and a scroll callback, so a viewport-pinned overlay can navigate the stream. The named container sizes such overlays against the pane rather than the window.
Dock-style magnification falloff and the rail's own scroll position, kept pure so the curve and the clamping are testable without layout.
One evenly spaced tick per turn in the reading column's left gutter, magnifying under the pointer, previewing the turn on hover, and jumping to it on click. Ticks are indexed rather than proportional because a virtualized row's offset is an estimate until it mounts.
Surfaces that only misbehave at length — the minimap rail's own scrolling, the virtualizer's windowing — were unreachable in dev without a real agent. Emitted in one burst; turn sizes vary so the rail shows an uneven rhythm.
Tightened after driving it in the desktop app: everything shrinks a notch, magnification acts on width alone (a 2px hairline swelling in height reads as a blob), off-screen turns rest at the hairline tone and no longer lift when the rail is hovered, and a thread with a single turn hides the rail entirely.
@linear-code

linear-code Bot commented Jul 31, 2026

Copy link
Copy Markdown

CODE-517

@AprilNEA
AprilNEA requested review from SukkaW, Zerlight and lucas77778 and removed request for Zerlight July 31, 2026 14:27

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f4cf7830fb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/presentation/ui/src/chat/conversation-minimap.tsx
Comment thread packages/presentation/ui/src/chat/conversation-minimap.tsx Outdated
Comment thread packages/presentation/ui/src/chat/conversation-minimap.tsx
Comment thread packages/presentation/ui/src/chat/conversation-minimap.tsx Outdated
Comment thread packages/presentation/ui/src/chat/conversation-minimap.tsx
@AprilNEA
AprilNEA merged commit 6e27750 into master Jul 31, 2026
9 checks passed
@AprilNEA
AprilNEA deleted the xuan/code-517 branch July 31, 2026 16:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants