Skip to content

feat(conversation): add ConversationMap and useReadingPosition - #123

Merged
TheGreatAxios merged 10 commits into
conversation/06-history-windowfrom
conversation/07-conversation-map
Oct 28, 2026
Merged

TheGreatAxios merged 10 commits into
conversation/06-history-windowfrom
conversation/07-conversation-map

Conversation

@pratikbuilds

Copy link
Copy Markdown
Collaborator

ConversationMap shows one tick per turn, marked faint, on screen or current, previews a turn on hover or focus and jumps to it with the conversation's glide. The rail is one tab stop with arrow keys and Enter, and keeping the current tick in view scrolls only the rail. useReadingPosition reports the current and on-screen turns from one IntersectionObserver over the item registry, measuring on the conversation's per-frame scroll snapshots. A jumped-to turn stays current until the reader scrolls.

Stacked on #122: review only this commit. Part of #94.

Notes for review

  • useReadingPosition reads the container from useConversation().scrollContainer() and measures on subscribeScroll snapshots; works when a host overrides ConversationContent's data-slot.
  • jumpTo pins a clicked turn as current until the reader actually scrolls (a byReader snapshot with movement); clicking or typing inside the log keeps it. The keyboard preview follows its tick along an overflowing rail.
  • Keep-in-view scrolls only the rail (rail.scrollTo); the page never moves (e2e asserts window.scrollY stays 0).
  • A turn whose start item isn't mounted (windowed history) is never on screen; clicking its tick only fires onSelect.
  • (04) MessageSendStatus and MessageHeader register through Message's context, so they work at any depth (e.g. inside MessageFooter) and Message never doubles the status line. The live region belongs to Message and reads the current error (or "Not sent."). Server-rendered HTML shows the default line until hydration registers a placed one.

Verification

  • This commit passes bun run typecheck and bun run lint on its own (the stack is checked commit by commit).
  • At the top of the stack: bun run check, bun run build, bun run dep-guard, bun run contrast-test and bun run test:e2e (Playwright specs and the Storybook axe sweep in light and dark) pass.

Closes #101

ConversationMap shows one tick per turn, marked faint, on screen or
current, previews a turn on hover or focus and jumps to it with the
conversation's glide. The rail is one tab stop with arrow keys and
Enter, and keeping the current tick in view scrolls only the rail.
useReadingPosition reports the current and on-screen turns from one
IntersectionObserver over the item registry, measuring on the
conversation's per-frame scroll snapshots. A jumped-to turn stays
current until the reader scrolls.

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Automated multi-agent review: independent review + second-agent validation; only confirmed items listed (map reading-position math was verified by execution; everything here is minor/nit).

  • minor (MEDIUM) src/hooks/use-reading-position.ts:92-116 (src/lib/reading-position.ts:69) — an unseen pin survives container detach: a pin is released only on else if (pin.seen), measure() yields nothing with scroller === null, and currentTurn returns pinnedId unconditionally — so a tick clicked mid-glide followed by a fast detach (before the IntersectionObserver entry lands) leaves a stale "current" tick. Smallest fix: release the pin when the scroller detaches (or when measure yields NOTHING with !pin.seen).
  • minor (test gap) src/ui/conversation-map.tsx:115-132 — the keep-in-view effect has no resize trigger (deps [currentId, hidden, reducedMotion], no ResizeObserver): a viewport resize can leave the current tick out of view until currentId changes. Observe the rail's height.
  • nit conversation-map.tsx:250 — data-current is written every tick and read nowhere (no CSS, no test); drop it or use it (aria-current="location" already carries the state).
  • nit conversation-map.tsx:231, 255 — tick targets are 14px × 24px, below WCAG 2.5.8's 24×24 touch minimum; not an axe failure, but worth a note in the a11y lane.

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review · Request changes

Adds a conversation minimap and useReadingPosition, which pins the current turn across a jump.

Findings

  • src/hooks/use-reading-position.ts:202-206 — A jump that never arrives leaves that turn pinned as aria-current. jumpTo sets the pin with armed: false (use-reading-position.ts:219-220). A reader gesture still inside its quiet window cancels the glide (conversation.tsx:493-497, and openGesture at conversation.tsx:472-474 cancels even when scrollTop does not move). The pin only drops when it is armed and the snapshot is byReader with delta !== 0, so a cancel from momentum that started before the click, or a wheel/touch that stops the glide without moving, keeps the highlight on the target while the view stays put. currentTurn trusts pinnedId (reading-position.ts:69). Clear the pin when the glide is cancelled before the target is seen, or release it on the same input that cancels the glide.

  • src/hooks/use-reading-position.ts:220 — A tick for a turn that is not mounted yet does not jump, including when onSelect mounts it. jumpTo sets the pin to null when items.get(id) is missing, and scrollToItem glides to itemTarget, which is null until the row is registered (conversation.tsx:656-658). The tick calls jumpTo before onSelect (conversation-map.tsx:288-291), so a host that mounts the row from onSelect (the history window) gets no second attempt. Keep the pin and scroll when that id appears in the registry the hook already subscribes to.

@pratikbuilds
pratikbuilds force-pushed the conversation/07-conversation-map branch from d3e99fb to 9b71d32 Compare October 28, 2026 08:41
A jump pins its turn as current until the reader scrolls or the turn
leaves the view. When momentum, a tap or a wheel that moved nothing
stopped the glide before the turn came on screen, nothing released the
pin, and the map marked a turn that was not on screen as current until
the reader next scrolled. The jump now hears from the glide whether it
arrived, and drops a pin whose turn was never seen.

Document that the map's turns must be mounted: a tick for a turn that
is not mounted does not jump, even when onSelect mounts it.
@pratikbuilds

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. The first point is fixed. The second is now documented.

1. A jump that was cut short left its turn marked current

  • The fix: jumpTo passes an onEnd callback to scrollToItem. The callback is a small addition to the Conversation engine in feat(conversation): add Conversation and its scroll anchoring #114. If the glide ends without arriving and the turn was never seen, the pin is dropped and the position is measured again.
    • The check compares the pin itself, not its id. So clicking the same tick again mid-glide keeps the new jump.
    • A turn that was seen keeps the existing rule: it's released when it leaves the view.
  • Tests:
    • "a jump cut short by momentum before q1 is seen ends", "a jump stopped by a wheel that moves nothing ends" and "a jump stopped by a tap on a message ends". All three failed before the fix.
    • "a jump left alone lands on its turn and keeps it current" and "clicking the same tick mid-glide keeps the jump" guard the cases that should not change.

2. A tick for a turn that isn't mounted doesn't jump

  • Docs only. The docs on ConversationMap, its turns prop and jumpTo now say that each turn passed to the map must be mounted. Under a history window, pass the windowed items' turns. A tick for an unmounted turn doesn't jump, even when onSelect mounts it.
  • Why not retry the jump: no host in this stack mounts a row from onSelect. useHistoryWindow can't reveal a given message, only reach the start. The full-chat story already maps just the windowed turns. A retry makes more sense once the history window can reveal a given message.

Commit: fix(conversation): end a map jump cut short before its turn is seen (the onEnd callback is in #114's commit)

When the turn of the focused tick left `turns`, as when a history
window trims it or the host deletes it, its button unmounted and focus
fell to the page body; the next Tab started from the top of the page.

A layout effect now notices the focused turn is gone and, if focus is
on the body, moves it to the rail's tab stop (the current tick, or the
first), or to the conversation's log when the rail itself is gone.
Escape was handled only by the map's own keydown, so a preview card
opened by resting the mouse on a tick stayed over the content while
focus was in the composer or the log. Content shown on hover has to be
dismissable without moving the pointer or focus.

While a preview is open, Escape anywhere in the document now closes it.
When the rail scrolled, the preview card's position was measured inside
a setPreview updater. React runs a queued updater during the next
render, so the DOM was read during render, and StrictMode ran the reads
twice.

The scroll handler now measures the tick of the preview it sees and
passes a pure updater that applies the result only if that preview is
still open, so a close queued by a wheel in the same frame still wins.
With a mouse, map ticks are under 24px tall, and the comment said only
that this was fine for a mouse. It now names the WCAG 2.5.8 exception
the dense rail relies on: scrolling the conversation is an equivalent
way to reach every turn a tick jumps to. Touch keeps its 24px ticks.
The map spec declared its own container, settled, offsetOf and wheel,
copies of the helpers the other conversation specs import. It now
imports them from the shared module.
ConversationMap took its props as the nav's, ref included, and spread
them onto the nav. React 19 passes ref as a prop, but React 18 strips
it, so a ref never reached the nav on React 18, the lower end of the
peer range. It is now wrapped in forwardRef and the ref set on the nav.
@pratikbuilds
pratikbuilds force-pushed the conversation/07-conversation-map branch from e85396c to 246f9ce Compare October 28, 2026 14:47
@pratikbuilds

Copy link
Copy Markdown
Collaborator Author

Round 3 fixes

A fresh review pass over the stack turned up the points below; each is fixed in a new commit at the end of this branch, and the stack is rebased onto it.

  • dccda3e: When the focused tick's turn left turns (a history-window trim or a deleted turn), focus fell to the body. Focus now moves to the rail's tab stop, or to the conversation log if the rail is gone.
  • f1b3d58: A preview opened by hovering could not be dismissed with Escape while focus was elsewhere (WCAG 1.4.13). Escape now closes it from anywhere.
  • f59877a: follow measured the DOM inside a setPreview updater. It now measures first and passes a pure updater.
  • 8b372b7: Mouse tick size is unchanged; a comment records why: scrolling the conversation reaches every turn (the WCAG 2.5.8 "equivalent" exception), and touch keeps a 24px target.
  • d5312f4: The map spec imports the shared helpers from e2e/conversation-helpers.ts instead of redeclaring them.
  • 246f9ce: React 18 refs: components that read ref from props only receive it on React 19, while the peer range allows ^18.2.0. They are now wrapped in forwardRef, so a host ref reaches the element on both; the peer range is unchanged. Covers ConversationMap.

bun run check and dep-guard pass on every branch of the stack, and the full e2e suite passes at the top of the stack (163/163, including the stories axe sweep). Each behaviour fix comes with a test that fails without it; the forwardRef tests can only guard, since the suite runs on React 19.

@TheGreatAxios
TheGreatAxios removed this pull request from stack #130 October 28, 2026 21:38
@TheGreatAxios
TheGreatAxios added this pull request to stack #139 October 28, 2026 21:38
@TheGreatAxios
TheGreatAxios force-pushed the conversation/07-conversation-map branch from 246f9ce to 9e56da9 Compare October 28, 2026 21:42
@corbits-triage

Copy link
Copy Markdown
Contributor

Triage: Fail

Waiting on:

  • checks are still running: check, policy-check

Stacked on #122: only this pull request's own changes were checked.

Notes:

  • added code matches a code pattern: src/ui/conversation-map.test.tsx:172 Nested ternary; use if statements or a lookup., src/ui/conversation-map.test.tsx:309 Nested ternary; use if statements or a lookup., src/ui/conversation-map.tsx:169 Invented fallback `?? []`; handle the missing value instead., src/ui/conversation-map.tsx:251 Invented fallback `?? []`; handle the missing value instead., src/ui/conversation-map.tsx:204 Nested ternary; use if statements or a lookup., src/ui/conversation-map.tsx:256 Nested ternary; use if statements or a lookup.
  • added code matches a code pattern: stories/chat/conversation-map.stories.tsx:26 Invented fallback (an empty string, || [] or || 0); handle the missing value instead.

@TheGreatAxios
TheGreatAxios dismissed their stale review October 28, 2026 21:49

Superseded by the completed stacked review; blocking fixes were addressed and the rebased stack is being landed.

@TheGreatAxios
TheGreatAxios merged commit 8d30380 into main Oct 28, 2026
1 of 3 checks passed
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.

Add ConversationMap

2 participants