From 1441bc9c755e8efe9352f5540b36935b34ca9cd2 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 22 Oct 2026 00:39:31 +0530 Subject: [PATCH 01/10] feat(conversation): add ConversationMap and useReadingPosition 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. --- e2e/conversation-map.spec.ts | 247 +++++++++ e2e/fixtures.tsx | 61 +++ src/hooks/use-reading-position.ts | 226 ++++++++ src/index.ts | 2 + src/lib/reading-position.test.ts | 117 +++++ src/lib/reading-position.ts | 83 +++ src/ui/conversation-map.test.tsx | 603 ++++++++++++++++++++++ src/ui/conversation-map.tsx | 315 +++++++++++ stories/chat/conversation-map.stories.tsx | 101 ++++ 9 files changed, 1755 insertions(+) create mode 100644 e2e/conversation-map.spec.ts create mode 100644 src/hooks/use-reading-position.ts create mode 100644 src/lib/reading-position.test.ts create mode 100644 src/lib/reading-position.ts create mode 100644 src/ui/conversation-map.test.tsx create mode 100644 src/ui/conversation-map.tsx create mode 100644 stories/chat/conversation-map.stories.tsx diff --git a/e2e/conversation-map.spec.ts b/e2e/conversation-map.spec.ts new file mode 100644 index 00000000..fa646353 --- /dev/null +++ b/e2e/conversation-map.spec.ts @@ -0,0 +1,247 @@ +import { expect, test, type Page } from "@playwright/test"; + +const container = (page: Page) => + page.locator("[data-slot='conversation-content']"); + +const tick = (page: Page, name: string) => + page + .getByRole("navigation", { name: "Conversation map" }) + .getByRole("button", { name, exact: true }); + +const current = (page: Page) => + page.locator("[data-slot='conversation-map-tick'][aria-current='location']"); + +const scrollTop = (page: Page) => + container(page).evaluate((element) => element.scrollTop); + +/** Waits for the view to stop moving (a glide or a wheel scroll to land). */ +const settled = async (page: Page) => { + let previous = Number.NaN; + await expect + .poll( + async () => { + const now = await scrollTop(page); + const still = now === previous; + previous = now; + return still; + }, + { intervals: [100] }, + ) + .toBe(true); +}; + +/** How far an item's top sits below the top of the view. */ +const offsetOf = (page: Page, id: string) => + container(page).evaluate((element, itemId) => { + const item = element.querySelector(`[data-testid='${itemId}']`); + if (item === null) throw new Error(`no item ${itemId}`); + return ( + item.getBoundingClientRect().top - element.getBoundingClientRect().top + ); + }, id); + +/** The turn whose question last crossed a line a third of the way down. */ +const turnOnReadingLine = (page: Page) => + container(page).evaluate((element) => { + const view = element.getBoundingClientRect(); + const line = view.top + view.height / 3; + let reading = ""; + for (let index = 0; index < 20; index += 1) { + const start = element.querySelector(`[data-testid='t${index}']`); + if (start !== null && start.getBoundingClientRect().top <= line) { + reading = `Question ${index}`; + } + } + return reading; + }); + +const wheel = async (page: Page, deltaY: number) => { + const box = await container(page).boundingBox(); + if (box === null) throw new Error("expected the conversation on screen"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel(0, deltaY); + await settled(page); +}; + +const open = async (page: Page) => { + await page.goto("/conversation-map"); + await expect(page.getByTestId("t19a")).toBeAttached(); + await expect + .poll(() => + container(page).evaluate( + (element) => + element.scrollHeight - element.scrollTop - element.clientHeight, + ), + ) + .toBeLessThanOrEqual(1); +}; + +test.describe("conversation map", () => { + test("shows one tick per turn, the last turn current at the end", async ({ + page, + }) => { + await open(page); + await expect( + page.locator("[data-slot='conversation-map-tick']"), + ).toHaveCount(20); + await expect(current(page)).toHaveAccessibleName("Question 19"); + await expect(tick(page, "Question 18")).toHaveAttribute("data-visible", ""); + await expect(tick(page, "Question 0")).not.toHaveAttribute("data-visible"); + }); + + test("pointing at a tick previews its turn", async ({ page }) => { + await open(page); + await tick(page, "Question 3").hover(); + const card = page.getByRole("tooltip"); + await expect(card).toContainText("Question 3"); + await expect(card).toContainText("Answer 3"); + await expect(tick(page, "Question 3")).toHaveAccessibleDescription( + "Answer 3", + ); + await page.mouse.move(400, 380); + await expect(card).toBeHidden(); + }); + + test("clicking a tick brings its turn to the top of the view", async ({ + page, + }) => { + await open(page); + await tick(page, "Question 5").click(); + await settled(page); + expect(Math.abs(await offsetOf(page, "t5"))).toBeLessThanOrEqual(1); + await expect(current(page)).toHaveAccessibleName("Question 5"); + }); + + test("the keyboard reaches the current tick; arrows and Enter jump", async ({ + page, + }) => { + await open(page); + // The rail comes just before the log in the tab order. + await container(page).focus(); + await page.keyboard.press("Shift+Tab"); + await expect(tick(page, "Question 19")).toBeFocused(); + for (let step = 0; step < 9; step += 1) { + await page.keyboard.press("ArrowUp"); + } + await expect(tick(page, "Question 10")).toBeFocused(); + await page.keyboard.press("Enter"); + await settled(page); + expect(Math.abs(await offsetOf(page, "t10"))).toBeLessThanOrEqual(1); + await expect(current(page)).toHaveAccessibleName("Question 10"); + await expect(tick(page, "Question 10")).toBeFocused(); + }); + + test("the current tick follows scrolling", async ({ page }) => { + await open(page); + await wheel(page, -700); + const first = await turnOnReadingLine(page); + expect(first).not.toBe("Question 19"); + await expect(current(page)).toHaveAccessibleName(first); + + await wheel(page, -500); + const second = await turnOnReadingLine(page); + expect(second).not.toBe(first); + await expect(current(page)).toHaveAccessibleName(second); + }); + + test("keeping the current tick in view scrolls only the rail", async ({ + page, + }) => { + await page.goto("/conversation-map-tall-page"); + await expect(page.getByTestId("t79a")).toBeAttached(); + await expect(current(page)).toHaveAccessibleName("Question 79"); + const rail = page.locator("[data-slot='conversation-map-rail']"); + // The rail overflows, and has scrolled to bring the last tick into view. + await expect + .poll(() => + rail.evaluate((element) => { + const tick = element.querySelector("[aria-current='location']"); + if (tick === null) return false; + const view = element.getBoundingClientRect(); + const box = tick.getBoundingClientRect(); + return ( + element.scrollHeight > element.clientHeight && + element.scrollTop > 0 && + box.top >= view.top - 1 && + box.bottom <= view.bottom + 1 + ); + }), + ) + .toBe(true); + // Give any late scroll a few frames to land. + await page.evaluate( + () => + new Promise((resolve) => { + let frames = 10; + const step = () => { + frames -= 1; + if (frames === 0) resolve(); + else requestAnimationFrame(step); + }; + requestAnimationFrame(step); + }), + ); + expect(await page.evaluate(() => window.scrollY)).toBe(0); + }); + + test("moving along an overflowing rail keeps the preview open", async ({ + page, + }) => { + await page.goto("/conversation-map-tall-page"); + await expect(current(page)).toHaveAccessibleName("Question 79"); + const card = page.getByRole("tooltip"); + /** The focused tick, its preview showing and describing it. */ + const previewed = async (index: number) => { + const focused = tick(page, `Question ${index}`); + await expect(focused).toBeFocused(); + await expect(card).toBeVisible(); + await expect(card).toContainText(`Question ${index}`); + await expect(focused).toHaveAccessibleDescription(`Answer ${index}`); + }; + + await tick(page, "Question 79").focus(); + await previewed(79); + await page.keyboard.press("Home"); + await previewed(0); + await page.keyboard.press("End"); + await previewed(79); + for (let index = 78; index >= 0; index -= 1) { + await page.keyboard.press("ArrowUp"); + await previewed(index); + } + }); + + test("clicking inside a jumped-to turn keeps it current", async ({ + page, + }) => { + await open(page); + await wheel(page, -900); + await tick(page, "Question 18").click(); + await settled(page); + await expect(current(page)).toHaveAccessibleName("Question 18"); + const before = await scrollTop(page); + + await page.getByTestId("t18").getByText("Question 18").click(); + await settled(page); + expect(await scrollTop(page)).toBe(before); + await expect(current(page)).toHaveAccessibleName("Question 18"); + }); + + test("a turn the view cannot bring to the top stays current", async ({ + page, + }) => { + await open(page); + await wheel(page, -900); + await tick(page, "Question 18").click(); + await settled(page); + // The end clamps the glide: the turn is on screen, below the top. + expect(await offsetOf(page, "t18")).toBeGreaterThan(1); + await expect(current(page)).toHaveAccessibleName("Question 18"); + + // The reader moving on releases it to the reading position. + await wheel(page, -300); + await expect(current(page)).toHaveAccessibleName( + await turnOnReadingLine(page), + ); + }); +}); diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index efdc5af9..6a0f1bfc 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -64,6 +64,8 @@ import { MessageFooter, ConversationEarlier, useHistoryWindow, + ConversationMap, + type ConversationMapTurn, } from "@corbits/react-ui"; import { CommandPalette, @@ -1305,6 +1307,57 @@ function HistoryWindowScenario({ ); } +const mapTurns = (length: number): ConversationMapTurn[] => + Array.from({ length }, (_, index) => ({ + id: `t${index}`, + label: `Question ${index}`, + preview: `Answer ${index}`, + })); + +const MAP_TURNS = mapTurns(20); + +/** Turns of fixed height: a 24px question, then a 72px answer. Twenty by + * default. */ +function ConversationMapScenario({ + turns = MAP_TURNS, +}: { + turns?: ConversationMapTurn[]; +}) { + return ( +
+ + + + {turns.flatMap((turn) => [ + +

{turn.label}

+
, + +

{turn.preview}

+
, + ])} +
+ +
+
+ ); +} + export const scenarios: Record ReactNode> = { smoke: () => , "approval-card": () => , @@ -1366,4 +1419,12 @@ export const scenarios: Record ReactNode> = { "history-window-reach": () => ( ), + "conversation-map": () => , + // Below the fold of a tall page, with more turns than the rail can fit. + "conversation-map-tall-page": () => ( + <> +
+ + + ), }; diff --git a/src/hooks/use-reading-position.ts b/src/hooks/use-reading-position.ts new file mode 100644 index 00000000..4d1758b9 --- /dev/null +++ b/src/hooks/use-reading-position.ts @@ -0,0 +1,226 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { + currentTurn, + readingPosition, + samePosition, + type MeasuredReadingPosition, +} from "../lib/reading-position.js"; +import { useConversation } from "../ui/conversation.js"; + +export type ReadingPosition = { + /** The turn being read, or `null` before anything is on screen. */ + readonly currentId: string | null; + /** Turns with any item on screen, in document order. */ + readonly visibleIds: readonly string[]; + /** + * Glides to a turn, like `scrollToItem`, and keeps it current until the + * reader scrolls the view or it leaves the view, even where the view cannot + * bring it to the top. + */ + readonly jumpTo: (id: string) => void; +}; + +type Measured = MeasuredReadingPosition & { readonly pinnedId: string | null }; + +const NOTHING: Measured = { lineId: null, visibleIds: [], pinnedId: null }; + +/** Input on the scroll container after a jump that may move the view. */ +const READER_INPUT = ["wheel", "touchstart", "keydown", "pointerdown"] as const; + +type Pin = { + readonly id: string; + /** The turn has been on screen since the jump. */ + seen: boolean; + /** The reader has given input on the container since the jump. */ + armed: boolean; +}; + +function scrollPaddingTop(element: HTMLElement): number { + const value = Number.parseFloat(getComputedStyle(element).scrollPaddingTop); + return Number.isFinite(value) ? value : 0; +} + +/** + * Which turns of the surrounding `Conversation` are on screen and which one + * is being read: the last whose start has crossed a line a third of the way + * down the view (below any `scroll-padding-top`), or the last on screen once + * the view is at the end. + * + * `turnIds` are the item ids that start a turn; each turn spans the items up + * to the next one. Omitted, every registered item is a turn. Turns whose items + * are not mounted (a virtualised or windowed list) are never on screen. + * + * One `IntersectionObserver`, rooted on the conversation's scroll container, + * watches every registered item. The answer is measured on the + * conversation's per-frame scroll snapshot (`subscribeScroll`), on + * intersection and registry changes, so scrolling re-renders the caller only + * when the answer changes. + */ +export function useReadingPosition( + turnIds?: readonly string[], +): ReadingPosition { + const { items, atBottom, scrollToItem, scrollContainer, subscribeScroll } = + useConversation(); + const [measured, setMeasured] = useState(NOTHING); + const turnsRef = useRef | null>(null); + const pinRef = useRef(null); + const measureRef = useRef<(() => void) | null>(null); + // Ids can hold any character, so no separator is safe to join them with. + const turnsKey = turnIds === undefined ? null : JSON.stringify(turnIds); + + useLayoutEffect(() => { + turnsRef.current = turnIds === undefined ? null : new Set(turnIds); + measureRef.current?.(); + // Keyed on the ids' content: a host passes a new array every render. + }, [turnsKey]); + + useEffect(() => { + let scroller: HTMLElement | null = null; + let observer: IntersectionObserver | null = null; + const observed = new Map(); + const idOf = new Map(); + const visible = new Set(); + + const measure = () => { + let next: MeasuredReadingPosition = NOTHING; + if (scroller !== null) { + const view = scroller.getBoundingClientRect(); + const inset = scrollPaddingTop(scroller); + next = readingPosition({ + ids: items.ids(), + turnIds: turnsRef.current, + visible, + turnTop: (id) => items.get(id)?.getBoundingClientRect().top, + line: view.top + inset + (view.height - inset) / 3, + }); + } + const pin = pinRef.current; + if (pin !== null) { + if (next.visibleIds.includes(pin.id)) pin.seen = true; + else if (pin.seen) pinRef.current = null; + } + const pinnedId = pinRef.current?.id ?? null; + setMeasured((previous) => + previous.pinnedId === pinnedId && samePosition(previous, next) + ? previous + : { ...next, pinnedId }, + ); + }; + // The pin is released by the reader scrolling the view after the jump: + // input on the container arms it, and a byReader snapshot that moved the + // view releases it. Input alone (clicking text, typing in an item) keeps + // it, and arming keeps a gesture from just before the jump, which the + // engine still counts as the reader's, from releasing it mid-glide. + const arm = () => { + if (pinRef.current !== null) pinRef.current.armed = true; + }; + + const onEntries = (entries: IntersectionObserverEntry[]) => { + for (const entry of entries) { + // A late entry for an element no longer observed is dropped. + const id = idOf.get(entry.target); + if (id === undefined) continue; + if (entry.isIntersecting) visible.add(id); + else visible.delete(id); + } + measure(); + }; + + const detach = () => { + observer?.disconnect(); + observer = null; + if (scroller !== null) { + for (const type of READER_INPUT) { + scroller.removeEventListener(type, arm); + } + } + scroller = null; + observed.clear(); + idOf.clear(); + visible.clear(); + }; + + /** Follows the scroll container (`null` while `ConversationContent` is + * unmounted or remounting), then the registry: observes new elements and + * drops gone ones. */ + const sync = () => { + const root = scrollContainer(); + if (root !== scroller) { + detach(); + if (root !== null) { + scroller = root; + observer = new IntersectionObserver(onEntries, { root }); + for (const type of READER_INPUT) { + root.addEventListener(type, arm, { passive: true }); + } + } + } + if (observer !== null) { + for (const [id, element] of observed) { + if (items.get(id) === element) continue; + observer.unobserve(element); + observed.delete(id); + idOf.delete(element); + visible.delete(id); + } + for (const id of items.ids()) { + const element = items.get(id); + if (element === undefined || observed.get(id) === element) continue; + observed.set(id, element); + idOf.set(element, id); + observer.observe(element); + } + } + }; + + measureRef.current = measure; + // Items already mounted registered before this effect ran, and the + // container attached in ConversationContent's layout effect. + sync(); + measure(); + const stopItems = items.subscribe(() => { + sync(); + measure(); + }); + // Once a frame while the view moves, and once after the container + // attaches (which also picks up a remounted ConversationContent). + const stopScroll = subscribeScroll((snapshot) => { + if (scrollContainer() !== scroller) sync(); + if (pinRef.current?.armed && snapshot.byReader && snapshot.delta !== 0) { + pinRef.current = null; + } + measure(); + }); + return () => { + stopItems(); + stopScroll(); + detach(); + measureRef.current = null; + }; + }, [items, scrollContainer, subscribeScroll]); + + const jumpTo = useCallback( + (id: string) => { + pinRef.current = + items.get(id) === undefined ? null : { id, seen: false, armed: false }; + scrollToItem(id); + measureRef.current?.(); + }, + [items, scrollToItem], + ); + + const currentId = currentTurn(measured, atBottom); + const { visibleIds } = measured; + return useMemo( + () => ({ currentId, visibleIds, jumpTo }), + [currentId, visibleIds, jumpTo], + ); +} diff --git a/src/index.ts b/src/index.ts index 014d1699..323b8508 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ export * from "./hooks/use-tabs.js"; export * from "./hooks/use-tab-drag.js"; export * from "./hooks/use-copy.js"; export * from "./hooks/use-history-window.js"; +export * from "./hooks/use-reading-position.js"; export * from "./lib/artifact.js"; export * from "./lib/chart-palette.js"; export * from "./lib/chat-message.js"; @@ -150,3 +151,4 @@ export * from "./ui/message-actions.js"; export * from "./ui/message-reactions.js"; export * from "./ui/message-replies.js"; export * from "./ui/conversation-earlier.js"; +export * from "./ui/conversation-map.js"; diff --git a/src/lib/reading-position.test.ts b/src/lib/reading-position.test.ts new file mode 100644 index 00000000..a0eff77d --- /dev/null +++ b/src/lib/reading-position.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; + +import { + currentTurn, + readingPosition, + samePosition, +} from "./reading-position.js"; + +const IDS = ["intro", "q1", "a1", "q2", "a2", "q3", "a3"]; +const TURNS = new Set(["q1", "q2", "q3"]); + +function tops(values: Record) { + return (id: string) => values[id]; +} + +describe("readingPosition", () => { + test("maps items on screen to their turns, once each, in order", () => { + const position = readingPosition({ + ids: IDS, + turnIds: TURNS, + visible: new Set(["a1", "q2", "a2"]), + turnTop: tops({ q1: -200, q2: 40 }), + line: 100, + }); + expect(position.visibleIds).toEqual(["q1", "q2"]); + }); + + test("items before the first turn belong to no turn", () => { + const position = readingPosition({ + ids: IDS, + turnIds: TURNS, + visible: new Set(["intro"]), + turnTop: tops({}), + line: 100, + }); + expect(position).toEqual({ lineId: null, visibleIds: [] }); + }); + + test("without turn ids every item is a turn", () => { + const position = readingPosition({ + ids: IDS, + turnIds: null, + visible: new Set(["a1", "q2"]), + turnTop: tops({ a1: -10, q2: 300 }), + line: 100, + }); + expect(position).toEqual({ lineId: "a1", visibleIds: ["a1", "q2"] }); + }); + + test("the turn being read is the last whose start crossed the line", () => { + const position = readingPosition({ + ids: IDS, + turnIds: TURNS, + visible: new Set(["a1", "q2", "a2", "q3"]), + turnTop: tops({ q1: -400, q2: 100, q3: 101 }), + line: 100, + }); + expect(position.lineId).toBe("q2"); + }); + + test("with no start above the line the first turn on screen is read", () => { + const position = readingPosition({ + ids: IDS, + turnIds: TURNS, + visible: new Set(["q2", "a2"]), + turnTop: tops({ q2: 150 }), + line: 100, + }); + expect(position.lineId).toBe("q2"); + }); + + test("a turn whose start is not registered never crosses the line", () => { + const position = readingPosition({ + ids: ["q1", "a1", "q2", "a2"], + turnIds: TURNS, + visible: new Set(["a1", "a2"]), + turnTop: tops({ q1: -50 }), + line: 100, + }); + expect(position.lineId).toBe("q1"); + }); +}); + +describe("currentTurn", () => { + const position = { lineId: "q1", visibleIds: ["q1", "q2"], pinnedId: null }; + + test("is the turn on the reading line away from the end", () => { + expect(currentTurn(position, false)).toBe("q1"); + }); + + test("is the last turn on screen at the end", () => { + expect(currentTurn(position, true)).toBe("q2"); + }); + + test("is the turn jumped to while it is pinned", () => { + expect(currentTurn({ ...position, pinnedId: "q1" }, true)).toBe("q1"); + }); + + test("is null with nothing on screen", () => { + expect( + currentTurn({ lineId: null, visibleIds: [], pinnedId: null }, true), + ).toBeNull(); + }); +}); + +describe("samePosition", () => { + test("compares the line and the turns on screen by value", () => { + const a = { lineId: "q1", visibleIds: ["q1", "q2"] }; + expect(samePosition(a, { lineId: "q1", visibleIds: ["q1", "q2"] })).toBe( + true, + ); + expect(samePosition(a, { lineId: "q2", visibleIds: ["q1", "q2"] })).toBe( + false, + ); + expect(samePosition(a, { lineId: "q1", visibleIds: ["q1"] })).toBe(false); + }); +}); diff --git a/src/lib/reading-position.ts b/src/lib/reading-position.ts new file mode 100644 index 00000000..84791918 --- /dev/null +++ b/src/lib/reading-position.ts @@ -0,0 +1,83 @@ +/** + * Where a reader is in a conversation, worked out from which items are on + * screen and where turns start. Pure: `useReadingPosition` measures, this + * decides. + * + * A turn is named by the item that starts it and spans every item up to the + * next turn's start, in document order. + */ + +export type MeasuredReadingPosition = { + /** The turn crossing the reading line, or `null` with nothing on screen. */ + readonly lineId: string | null; + /** Turns with any item on screen, in document order. */ + readonly visibleIds: readonly string[]; +}; + +export type ReadingPositionInput = { + /** Registered item ids in document order. */ + readonly ids: readonly string[]; + /** Ids of the items that start a turn; `null` makes every item a turn. */ + readonly turnIds: ReadonlySet | null; + /** Item ids intersecting the view. */ + readonly visible: ReadonlySet; + /** The top of a turn's first item, in the same space as `line`. */ + readonly turnTop: (id: string) => number | undefined; + /** The reading line: a third of the way down the view. */ + readonly line: number; +}; + +/** + * The turns on screen, and the one being read: the last on-screen turn whose + * start has crossed the reading line, or the first on screen when none has. + * Items before the first turn's start belong to no turn. + */ +export function readingPosition({ + ids, + turnIds, + visible, + turnTop, + line, +}: ReadingPositionInput): MeasuredReadingPosition { + const visibleIds: string[] = []; + let turn: string | null = null; + for (const id of ids) { + if (turnIds === null || turnIds.has(id)) turn = id; + if (turn === null || !visible.has(id) || visibleIds.at(-1) === turn) { + continue; + } + visibleIds.push(turn); + } + let lineId: string | null = null; + for (const id of visibleIds) { + const top = turnTop(id); + if (top !== undefined && top <= line) lineId = id; + } + return { lineId: lineId ?? visibleIds[0] ?? null, visibleIds }; +} + +/** + * The current turn. A turn just jumped to stays current until the reader + * moves on, even where the view could not bring it to the top (near the + * end). Otherwise, at the end of the content the last turn on screen is + * current, since a short last turn may never reach the reading line. + */ +export function currentTurn( + position: MeasuredReadingPosition & { readonly pinnedId: string | null }, + atEnd: boolean, +): string | null { + if (position.pinnedId !== null) return position.pinnedId; + if (atEnd) return position.visibleIds.at(-1) ?? position.lineId; + return position.lineId; +} + +export function samePosition( + a: MeasuredReadingPosition, + b: MeasuredReadingPosition, +): boolean { + return ( + a.lineId === b.lineId && + a.visibleIds.length === b.visibleIds.length && + a.visibleIds.every((id, index) => id === b.visibleIds[index]) + ); +} diff --git a/src/ui/conversation-map.test.tsx b/src/ui/conversation-map.test.tsx new file mode 100644 index 00000000..1e5d953b --- /dev/null +++ b/src/ui/conversation-map.test.tsx @@ -0,0 +1,603 @@ +import { act, StrictMode, useState } from "react"; +import type { ReactElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { + Conversation, + ConversationContent, + ConversationItem, +} from "./conversation.js"; +import { + ConversationMap, + type ConversationMapTurn, +} from "./conversation-map.js"; + +/** happy-dom never intersects anything: a fake the tests drive by hand. */ +class FakeIntersectionObserver { + static live = new Set(); + static created = 0; + readonly targets = new Set(); + readonly root: Element | Document | null; + + constructor( + private readonly callback: IntersectionObserverCallback, + options: IntersectionObserverInit = {}, + ) { + this.root = options.root ?? null; + FakeIntersectionObserver.created += 1; + FakeIntersectionObserver.live.add(this); + } + + observe(target: Element) { + this.targets.add(target); + } + + unobserve(target: Element) { + this.targets.delete(target); + } + + disconnect() { + this.targets.clear(); + FakeIntersectionObserver.live.delete(this); + } + + takeRecords(): IntersectionObserverEntry[] { + return []; + } + + /** Reports every observed target, intersecting when its item id is listed. */ + report(visible: readonly string[]) { + const entries = [...this.targets].map((target) => { + const id = target.getAttribute("data-item-id") ?? ""; + // Only the two fields the hook reads; the rest of an entry is geometry + // happy-dom cannot produce. + return { + target, + isIntersecting: visible.includes(id), + } as unknown as IntersectionObserverEntry; + }); + this.callback(entries, this as unknown as IntersectionObserver); + } +} + +const original = globalThis.IntersectionObserver; + +beforeEach(() => { + FakeIntersectionObserver.live.clear(); + FakeIntersectionObserver.created = 0; + // A stand-in with the members the hook calls. + globalThis.IntersectionObserver = + FakeIntersectionObserver as unknown as typeof IntersectionObserver; +}); + +afterEach(() => { + globalThis.IntersectionObserver = original; +}); + +function onlyObserver(): FakeIntersectionObserver { + const [observer, ...rest] = FakeIntersectionObserver.live; + if (observer === undefined || rest.length > 0) { + throw new Error(`expected one observer, found ${rest.length + 1}`); + } + return observer; +} + +type Mounted = { + container: HTMLElement; + unmount: () => void; +}; + +function render(node: ReactElement): Mounted { + const container = document.createElement("div"); + document.body.appendChild(container); + let root!: Root; + act(() => { + root = createRoot(container); + root.render(node); + }); + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +const nextFrame = () => + act( + () => + new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }), + ); + +const TURNS: readonly ConversationMapTurn[] = [ + { id: "q1", label: "First question", preview: "First answer" }, + { id: "q2", label: "Second question", preview: "Second answer" }, + { id: "q3", label: "Third question" }, + { id: "q4", label: "Fourth question", preview: "Fourth answer" }, +]; + +const ITEMS = TURNS.flatMap((turn) => [turn.id, `${turn.id}-reply`]); + +function Chat({ + turns = TURNS, + items = ITEMS, + onSelect, + contentKey = "content", + showContent = true, +}: { + turns?: readonly ConversationMapTurn[]; + items?: readonly string[]; + onSelect?: (id: string) => void; + contentKey?: string; + showContent?: boolean; +}) { + return ( + + + {showContent ? ( + + {items.map((id) => ( + + {id} + + ))} + + ) : null} + + ); +} + +function tick(container: HTMLElement, name: string): HTMLButtonElement { + const found = [ + ...container.querySelectorAll( + "[data-slot='conversation-map-tick']", + ), + ].find((button) => button.getAttribute("aria-label") === name); + if (found === undefined) throw new Error(`no tick named ${name}`); + return found; +} + +function states(container: HTMLElement) { + return [ + ...container.querySelectorAll( + "[data-slot='conversation-map-tick']", + ), + ].map((button) => + button.getAttribute("aria-current") === "location" + ? "current" + : button.hasAttribute("data-visible") + ? "on screen" + : "faint", + ); +} + +function scroller(container: HTMLElement): HTMLElement { + const element = container.querySelector( + "[data-slot='conversation-content']", + ); + if (element === null) throw new Error("expected the scroll container"); + return element; +} + +/** The reader scrolling the view to `top`, just after a wheel event on it. */ +function readerScroll(element: HTMLElement, top: number) { + act(() => { + Object.defineProperty(element, "scrollHeight", { + configurable: true, + value: 10_000, + }); + Object.defineProperty(element, "clientHeight", { + configurable: true, + value: 600, + }); + element.scrollTop = top; + element.dispatchEvent(new Event("scroll")); + }); +} + +/** Puts an element at `top` (happy-dom does no layout). */ +function placeAt(element: Element, top: number, height = 40) { + Object.defineProperty(element, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(0, top, 400, height), + }); +} + +describe("ConversationMap", () => { + test("renders one tick per turn, named by what was said", () => { + const mounted = render(); + const nav = mounted.container.querySelector("nav"); + expect(nav?.getAttribute("aria-label")).toBe("Conversation map"); + expect( + [ + ...mounted.container.querySelectorAll( + "[data-slot='conversation-map-tick']", + ), + ].map((button) => button.getAttribute("aria-label")), + ).toEqual(TURNS.map((turn) => turn.label)); + mounted.unmount(); + }); + + test("renders nothing for fewer than two turns", () => { + const mounted = render(); + expect(mounted.container.querySelector("nav")).toBeNull(); + mounted.unmount(); + }); + + test("ticks are faint, on screen or current from the turns in view", async () => { + const mounted = render(); + await nextFrame(); + expect(states(mounted.container)).toEqual([ + "faint", + "faint", + "faint", + "faint", + ]); + + // At the end of the content, the last turn on screen is current. + act(() => onlyObserver().report(["q2-reply", "q3", "q3-reply"])); + await nextFrame(); + expect(states(mounted.container)).toEqual([ + "faint", + "on screen", + "current", + "faint", + ]); + mounted.unmount(); + }); + + test("away from the end, the turn crossing the reading line is current", async () => { + const mounted = render(); + const element = scroller(mounted.container); + Object.defineProperty(element, "scrollHeight", { value: 10_000 }); + Object.defineProperty(element, "clientHeight", { value: 600 }); + placeAt(element, 0, 600); + element.dispatchEvent(new WheelEvent("wheel")); + element.scrollTop = 1_000; + element.dispatchEvent(new Event("scroll")); + for (const item of element.querySelectorAll("[data-item-id]")) { + const id = item.getAttribute("data-item-id"); + placeAt(item, id === "q2" ? -50 : id === "q3" ? 300 : 900); + } + + act(() => onlyObserver().report(["q2", "q2-reply", "q3"])); + await nextFrame(); + await nextFrame(); + // The line sits at 200: q2 started above it, q3 below. + expect(states(mounted.container)).toEqual([ + "faint", + "current", + "on screen", + "faint", + ]); + mounted.unmount(); + }); + + test("clicking a tick makes it current until the reader scrolls", async () => { + const selected: string[] = []; + const mounted = render( selected.push(id)} />); + act(() => onlyObserver().report(["q3", "q3-reply", "q4", "q4-reply"])); + await nextFrame(); + expect(states(mounted.container)).toEqual([ + "faint", + "faint", + "on screen", + "current", + ]); + + act(() => tick(mounted.container, "Third question").click()); + await nextFrame(); + expect(selected).toEqual(["q3"]); + expect(states(mounted.container)).toEqual([ + "faint", + "faint", + "current", + "on screen", + ]); + + act(() => { + scroller(mounted.container).dispatchEvent(new WheelEvent("wheel")); + }); + readerScroll(scroller(mounted.container), 1_000); + await nextFrame(); + expect(states(mounted.container)[3]).toBe("current"); + mounted.unmount(); + }); + + test("clicking or typing inside an item keeps the jumped turn current", async () => { + const mounted = render( + + + + {ITEMS.map((id) => ( + +

{id}

+ +
+ ))} +
+
, + ); + const log = scroller(mounted.container); + act(() => onlyObserver().report(["q3", "q3-reply", "q4", "q4-reply"])); + await nextFrame(); + act(() => tick(mounted.container, "Third question").click()); + await nextFrame(); + expect(states(mounted.container)[2]).toBe("current"); + + const text = log.querySelector("[data-item-id='q3'] p"); + const field = log.querySelector( + "[data-item-id='q3'] input", + ); + if (text === null || field === null) throw new Error("expected q3"); + act(() => { + text.dispatchEvent(new Event("pointerdown", { bubbles: true })); + text.click(); + field.focus(); + field.dispatchEvent( + new KeyboardEvent("keydown", { key: "a", bubbles: true }), + ); + }); + // The layout moving the view is not the reader scrolling it either. + act(() => { + Object.defineProperty(log, "scrollHeight", { + configurable: true, + value: 10_000, + }); + Object.defineProperty(log, "clientHeight", { + configurable: true, + value: 600, + }); + log.scrollTop = 500; + log.dispatchEvent(new Event("scroll")); + }); + await nextFrame(); + await nextFrame(); + expect(states(mounted.container)[2]).toBe("current"); + + act(() => log.dispatchEvent(new WheelEvent("wheel"))); + readerScroll(log, 1_000); + await nextFrame(); + expect(states(mounted.container)).toEqual([ + "faint", + "faint", + "on screen", + "current", + ]); + mounted.unmount(); + }); + + test("the rail is one tab stop, and the arrow keys move along it", async () => { + const mounted = render(); + act(() => onlyObserver().report(["q2", "q2-reply"])); + await nextFrame(); + const tabStops = () => + [ + ...mounted.container.querySelectorAll( + "[data-slot='conversation-map-tick']", + ), + ] + .filter((button) => button.tabIndex === 0) + .map((button) => button.getAttribute("aria-label")); + expect(tabStops()).toEqual(["Second question"]); + + const press = (key: string) => + act(() => { + document.activeElement?.dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true }), + ); + }); + act(() => tick(mounted.container, "Second question").focus()); + press("ArrowDown"); + expect(document.activeElement?.getAttribute("aria-label")).toBe( + "Third question", + ); + expect(tabStops()).toEqual(["Third question"]); + press("End"); + expect(document.activeElement?.getAttribute("aria-label")).toBe( + "Fourth question", + ); + press("ArrowDown"); + expect(document.activeElement?.getAttribute("aria-label")).toBe( + "Fourth question", + ); + press("Home"); + press("ArrowUp"); + expect(document.activeElement?.getAttribute("aria-label")).toBe( + "First question", + ); + mounted.unmount(); + }); + + test("focusing a tick previews its turn; Escape dismisses it", () => { + const mounted = render(); + const second = tick(mounted.container, "Second question"); + act(() => second.focus()); + const card = mounted.container.querySelector("[role='tooltip']"); + expect(card?.textContent).toBe("Second questionSecond answer"); + const described = second.getAttribute("aria-describedby"); + expect(described).not.toBeNull(); + expect( + described === null + ? null + : document.getElementById(described)?.textContent, + ).toBe("Second answer"); + + act(() => { + second.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + }); + expect(mounted.container.querySelector("[role='tooltip']")).toBeNull(); + expect(second.hasAttribute("aria-describedby")).toBe(false); + mounted.unmount(); + }); +}); + +describe("useReadingPosition", () => { + test("one observer, rooted on the scroll container, watches every item", () => { + const mounted = render(); + const observer = onlyObserver(); + expect(observer.root).toBe(scroller(mounted.container)); + expect(observer.targets.size).toBe(ITEMS.length); + mounted.unmount(); + expect(FakeIntersectionObserver.live.size).toBe(0); + }); + + test("a host's own data-slot on the content does not hide the container", async () => { + const mounted = render( + + + + {ITEMS.map((id) => ( + + {id} + + ))} + + , + ); + const log = mounted.container.querySelector("[role='log']"); + expect(log?.getAttribute("data-slot")).toBe("chat-log"); + const observer = onlyObserver(); + expect(observer.root).toBe(log); + expect(observer.targets.size).toBe(ITEMS.length); + + act(() => observer.report(["q2", "q2-reply"])); + await nextFrame(); + expect(states(mounted.container)).toEqual([ + "faint", + "current", + "faint", + "faint", + ]); + mounted.unmount(); + }); + + test("under StrictMode one observer stays live and teardown is clean", () => { + const mounted = render( + + + , + ); + expect(onlyObserver().targets.size).toBe(ITEMS.length); + mounted.unmount(); + expect(FakeIntersectionObserver.live.size).toBe(0); + }); + + test("items added or removed later are observed or dropped", async () => { + let setItems!: (items: readonly string[]) => void; + function Growing() { + const [items, set] = useState(ITEMS.slice(0, 4)); + setItems = set; + return ; + } + const mounted = render(); + expect(onlyObserver().targets.size).toBe(4); + act(() => setItems(ITEMS)); + await act(() => Promise.resolve()); + expect(onlyObserver().targets.size).toBe(ITEMS.length); + act(() => setItems(ITEMS.slice(2))); + await act(() => Promise.resolve()); + expect(onlyObserver().targets.size).toBe(ITEMS.length - 2); + mounted.unmount(); + }); + + test("a remounted ConversationContent gets a new observer on its log", async () => { + let setKey!: (key: string) => void; + function Host() { + const [key, set] = useState("a"); + setKey = set; + return ; + } + const mounted = render(); + const first = onlyObserver(); + act(() => setKey("b")); + await nextFrame(); + const second = onlyObserver(); + expect(second).not.toBe(first); + expect(second.root).toBe(scroller(mounted.container)); + expect(second.targets.size).toBe(ITEMS.length); + + act(() => second.report(["q3", "q3-reply"])); + await nextFrame(); + expect(states(mounted.container)).toContain("current"); + mounted.unmount(); + expect(FakeIntersectionObserver.live.size).toBe(0); + }); + + test("removing ConversationContent disconnects; restoring observes again", async () => { + let setShow!: (show: boolean) => void; + function Host() { + const [show, set] = useState(true); + setShow = set; + return ; + } + const mounted = render(); + onlyObserver(); + act(() => setShow(false)); + await nextFrame(); + expect(FakeIntersectionObserver.live.size).toBe(0); + act(() => setShow(true)); + await nextFrame(); + expect(onlyObserver().targets.size).toBe(ITEMS.length); + mounted.unmount(); + }); + + test("every listener added to the log is removed on unmount", () => { + const added = new Map(); + const removed = new Map(); + const bump = (counts: Map, type: string) => + counts.set(type, (counts.get(type) ?? 0) + 1); + const prototype = HTMLElement.prototype; + const { addEventListener, removeEventListener } = prototype; + prototype.addEventListener = function ( + this: HTMLElement, + ...args: Parameters + ) { + if (this.getAttribute("role") === "log") bump(added, args[0]); + addEventListener.apply(this, args); + }; + prototype.removeEventListener = function ( + this: HTMLElement, + ...args: Parameters + ) { + if (this.getAttribute("role") === "log") bump(removed, args[0]); + removeEventListener.apply(this, args); + }; + try { + render().unmount(); + } finally { + prototype.addEventListener = addEventListener; + prototype.removeEventListener = removeEventListener; + } + for (const type of ["wheel", "touchstart", "keydown", "pointerdown"]) { + expect(added.get(type) ?? 0).toBeGreaterThan(0); + } + expect(removed).toEqual(added); + }); + + test("a jumped turn stays current until seen, then until it leaves", async () => { + const mounted = render(); + act(() => onlyObserver().report(["q4", "q4-reply"])); + await nextFrame(); + act(() => tick(mounted.container, "First question").click()); + await nextFrame(); + expect(states(mounted.container)[0]).toBe("current"); + // The glide has not reached it yet. + act(() => onlyObserver().report(["q3-reply", "q4"])); + await nextFrame(); + expect(states(mounted.container)[0]).toBe("current"); + act(() => onlyObserver().report(["q1", "q1-reply"])); + await nextFrame(); + expect(states(mounted.container)[0]).toBe("current"); + act(() => onlyObserver().report(["q2", "q2-reply"])); + await nextFrame(); + expect(states(mounted.container)[0]).toBe("faint"); + mounted.unmount(); + }); +}); diff --git a/src/ui/conversation-map.tsx b/src/ui/conversation-map.tsx new file mode 100644 index 00000000..2064eb53 --- /dev/null +++ b/src/ui/conversation-map.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { ComponentProps, KeyboardEvent } from "react"; + +import { useReadingPosition } from "../hooks/use-reading-position.js"; +import { usePrefersReducedMotion } from "../hooks/use-prefers-reduced-motion.js"; +import { cn } from "../lib/utils.js"; + +export type ConversationMapTurn = { + /** The id of the `ConversationItem` that starts the turn. */ + readonly id: string; + /** What was said, as plain text: the tick's accessible name. */ + readonly label: string; + /** The reply, as plain text, shown under the label when previewed. */ + readonly preview?: string; +}; + +export type ConversationMapProps = Omit< + ComponentProps<"nav">, + "children" | "onSelect" +> & { + /** Turns in document order. Each spans its items up to the next turn. */ + readonly turns: readonly ConversationMapTurn[]; + /** Called after a tick starts the jump to its turn. */ + readonly onSelect?: ((id: string) => void) | undefined; +}; + +const MAP = "[data-slot='conversation-map']"; +const TICK = "[data-slot='conversation-map-tick']"; +const KEYS = ["ArrowUp", "ArrowDown", "Home", "End"]; + +/** Where a tick's middle sits, measured from the top of the map. */ +function centerOf(tick: HTMLElement, nav: HTMLElement): number { + const box = tick.getBoundingClientRect(); + return box.top + box.height / 2 - nav.getBoundingClientRect().top; +} + +/** The rail's `scrollTop` that brings `tick` fully into view by the shortest + * move, or `null` when it is in view already. */ +function nearestTop(rail: HTMLElement, tick: HTMLElement): number | null { + const view = rail.getBoundingClientRect(); + const box = tick.getBoundingClientRect(); + if (box.top < view.top) return rail.scrollTop + box.top - view.top; + if (box.bottom > view.bottom) { + return rail.scrollTop + box.bottom - view.bottom; + } + return null; +} + +/** + * A rail of ticks, one per turn, for finding your way around a long + * conversation. Ticks are faint, longer and darker for turns on screen, and + * longest in the primary colour for the turn being read. Pointing at or + * focusing a tick previews its turn; clicking it glides there. + * + * Place it inside `Conversation` but outside `ConversationContent` (the log + * is a live region, and the rail must not scroll with it). It sits over the + * content's left edge, so give the content column room, e.g. + * `[&>[data-slot=conversation-column]]:pl-12` on `ConversationContent`. + * + * The rail is one tab stop; the arrow keys, Home and End move along it, and + * Escape dismisses the preview. When the rail overflows, it scrolls to keep + * the current tick in view; only the rail moves, never the page. Renders + * nothing for fewer than two turns. + */ +export function ConversationMap({ + turns, + onSelect, + "aria-label": label = "Conversation map", + className, + onKeyDown, + onPointerLeave, + onBlur, + ...props +}: ConversationMapProps) { + const turnIds = useMemo(() => turns.map((turn) => turn.id), [turns]); + const { currentId, visibleIds, jumpTo } = useReadingPosition(turnIds); + const currentRef = useRef(null); + const railRef = useRef(null); + const placedRef = useRef(false); + const reducedMotion = usePrefersReducedMotion(); + const hidden = turns.length < 2; + const cardRef = useRef(null); + const [preview, setPreview] = useState<{ + readonly id: string; + readonly center: number; + } | null>(null); + const [focusedId, setFocusedId] = useState(null); + const previewTextId = useId(); + + // Clamped once the card has a height, so it stays beside the rail. + useLayoutEffect(() => { + const card = cardRef.current; + const nav = card?.closest(MAP); + if (preview === null || card === null || nav === null || nav === undefined) + return; + const half = card.offsetHeight / 2; + const top = Math.min( + Math.max(preview.center, half), + nav.clientHeight - half, + ); + card.style.top = `${top}px`; + }, [preview]); + + // Scrolls the rail alone: scrollIntoView would also scroll every ancestor, + // pulling the page to a conversation below the fold. + useEffect(() => { + const rail = railRef.current; + const tick = currentRef.current; + if (rail === null) { + placedRef.current = false; + return; + } + if (tick === null) return; + const top = nearestTop(rail, tick); + // The first placement is instant; later ones glide unless reduced. + if (top !== null) { + rail.scrollTo({ + top, + behavior: placedRef.current && !reducedMotion ? "smooth" : "auto", + }); + } + placedRef.current = true; + }, [currentId, hidden, reducedMotion]); + + if (hidden) return null; + + const visible = new Set(visibleIds); + const has = (id: string | null) => + id !== null && turns.some((turn) => turn.id === id); + const stopId = has(focusedId) + ? focusedId + : has(currentId) + ? currentId + : (turns[0]?.id ?? null); + const previewed = + preview === null ? undefined : turns.find((turn) => turn.id === preview.id); + + const show = (id: string, tick: HTMLElement) => { + const nav = tick.closest(MAP); + if (nav === null) return; + setPreview({ id, center: centerOf(tick, nav) }); + }; + + // Focus moving along the rail and keep-in-view both scroll it: the card + // follows its tick, and closes once the tick has scrolled out of the rail. + const follow = () => { + const rail = railRef.current; + const nav = rail?.closest(MAP); + if (rail === null || nav === null || nav === undefined) return; + // An updater, so a close queued by a wheel this frame is not undone. + setPreview((previous) => { + if (previous === null) return null; + const tick = [...rail.querySelectorAll(TICK)].find( + (element) => element.dataset["turnId"] === previous.id, + ); + if (tick === undefined) return null; + const view = rail.getBoundingClientRect(); + const box = tick.getBoundingClientRect(); + if (box.bottom <= view.top || box.top >= view.bottom) return null; + return { id: previous.id, center: centerOf(tick, nav) }; + }); + }; + + const move = (event: KeyboardEvent) => { + if (!KEYS.includes(event.key)) return; + const ticks = [ + ...(railRef.current?.querySelectorAll(TICK) ?? []), + ]; + const index = ticks.findIndex((tick) => tick === event.target); + if (index === -1) return; + const next = + event.key === "Home" + ? 0 + : event.key === "End" + ? ticks.length - 1 + : index + (event.key === "ArrowDown" ? 1 : -1); + // Also at either end, so the keys never scroll the rail or the page. + event.preventDefault(); + ticks[next]?.focus(); + }; + + return ( + + ); +} diff --git a/stories/chat/conversation-map.stories.tsx b/stories/chat/conversation-map.stories.tsx new file mode 100644 index 00000000..668480bc --- /dev/null +++ b/stories/chat/conversation-map.stories.tsx @@ -0,0 +1,101 @@ +import type { ConversationMessage } from "../../src/lib/conversation.js"; +import { + Conversation, + ConversationContent, + ConversationItem, + ConversationScrollButton, +} from "../../src/ui/conversation.js"; +import { + ConversationMap, + type ConversationMapTurn, +} from "../../src/ui/conversation-map.js"; +import { + longChat, + shortChat, + type ConversationSample, +} from "./samples/conversations.js"; + +export default { title: "Chat / Conversation Map" }; + +/** The first line of a message's text, without markdown punctuation. */ +function firstLine(message: ConversationMessage): string { + const text = message.parts + .flatMap((part) => (part.kind === "text" ? [part.text] : [])) + .join("\n"); + const line = text.split("\n").find((candidate) => candidate.trim() !== ""); + return (line ?? "") + .replace(/[*_`#>]/g, "") + .replace(/^\s*(?:[-*]|\d+\.)\s+/, "") + .trim(); +} + +/** A person's message starts a turn; the first reply with text previews it. */ +function turnsOf( + messages: readonly ConversationMessage[], +): ConversationMapTurn[] { + const turns: ConversationMapTurn[] = []; + for (const message of messages) { + const said = firstLine(message); + const last = turns.at(-1); + if (message.author.kind === "person") { + turns.push({ + id: message.id, + label: said === "" ? "Shared a file" : said, + }); + } else if ( + last !== undefined && + last.preview === undefined && + said !== "" + ) { + turns[turns.length - 1] = { ...last, preview: said }; + } + } + return turns; +} + +/** A placeholder row: the map works with whatever the host renders. */ +function Row({ message }: { readonly message: ConversationMessage }) { + const text = firstLine(message); + return message.author.kind === "person" ? ( +

+ {text} +

+ ) : ( +
+

+ {message.author.name} +

+

{text === "" ? "Worked on it." : text}

+
+ ); +} + +function MappedConversation({ + sample, +}: { + readonly sample: ConversationSample; +}) { + const turns = turnsOf(sample.messages); + return ( +
+ + + + {sample.messages.map((message) => ( + + + + ))} + + + +
+ ); +} + +/** Two turns: a tick each. Point at one to preview it, click to jump. */ +export const FewTurns = () => ; + +/** 150 turns: the ticks squeeze, then the rail scrolls and keeps the current + * one in view. */ +export const ManyTurns = () => ; From dcc3dcbd1b7bb5b8e2fbdcc05471cfb663a91374 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 27 Oct 2026 19:25:08 +0545 Subject: [PATCH 02/10] fix(conversation): end unseen map jumps and keep the tick in view A tick clicked mid-glide pinned its turn as current until the turn had been seen. If ConversationContent went away first, or the turn was removed, the pin was never released and a stale tick stayed current. The pin now ends with the view it was gliding, and with its item. Keeping the current tick in view ran only when the current turn changed, so a rail that shrank could clip it. A ResizeObserver on the rail now places it again at once. The tick's data-current attribute is dropped: nothing read it, and aria-current already carries the state. --- e2e/conversation-map.spec.ts | 56 ++++++++++++++++++++++--------- src/hooks/use-reading-position.ts | 7 ++++ src/ui/conversation-map.test.tsx | 45 +++++++++++++++++++++++++ src/ui/conversation-map.tsx | 40 ++++++++++++++++------ 4 files changed, 122 insertions(+), 26 deletions(-) diff --git a/e2e/conversation-map.spec.ts b/e2e/conversation-map.spec.ts index fa646353..dcda7ecf 100644 --- a/e2e/conversation-map.spec.ts +++ b/e2e/conversation-map.spec.ts @@ -63,6 +63,22 @@ const wheel = async (page: Page, deltaY: number) => { await settled(page); }; +/** Whether the rail overflows, has scrolled, and shows the current tick. */ +const railState = (page: Page) => + page.locator("[data-slot='conversation-map-rail']").evaluate((element) => { + const tick = element.querySelector("[aria-current='location']"); + const view = element.getBoundingClientRect(); + const box = tick?.getBoundingClientRect(); + return { + overflows: element.scrollHeight > element.clientHeight, + scrolled: element.scrollTop > 0, + showsCurrent: + box !== undefined && + box.top >= view.top - 1 && + box.bottom <= view.bottom + 1, + }; + }); + const open = async (page: Page) => { await page.goto("/conversation-map"); await expect(page.getByTestId("t19a")).toBeAttached(); @@ -150,24 +166,10 @@ test.describe("conversation map", () => { await page.goto("/conversation-map-tall-page"); await expect(page.getByTestId("t79a")).toBeAttached(); await expect(current(page)).toHaveAccessibleName("Question 79"); - const rail = page.locator("[data-slot='conversation-map-rail']"); // The rail overflows, and has scrolled to bring the last tick into view. await expect - .poll(() => - rail.evaluate((element) => { - const tick = element.querySelector("[aria-current='location']"); - if (tick === null) return false; - const view = element.getBoundingClientRect(); - const box = tick.getBoundingClientRect(); - return ( - element.scrollHeight > element.clientHeight && - element.scrollTop > 0 && - box.top >= view.top - 1 && - box.bottom <= view.bottom + 1 - ); - }), - ) - .toBe(true); + .poll(() => railState(page)) + .toEqual({ overflows: true, scrolled: true, showsCurrent: true }); // Give any late scroll a few frames to land. await page.evaluate( () => @@ -184,6 +186,28 @@ test.describe("conversation map", () => { expect(await page.evaluate(() => window.scrollY)).toBe(0); }); + test("a rail that shrinks keeps the current tick in view", async ({ + page, + }) => { + await open(page); + await expect(current(page)).toHaveAccessibleName("Question 19"); + expect(await railState(page)).toEqual({ + overflows: false, + scrolled: false, + showsCurrent: true, + }); + // The host's layout shrinks the map's box below the rail's height. + await page + .getByRole("navigation", { name: "Conversation map" }) + .evaluate((element) => { + element.style.height = "100px"; + }); + await expect(current(page)).toHaveAccessibleName("Question 19"); + await expect + .poll(() => railState(page)) + .toEqual({ overflows: true, scrolled: true, showsCurrent: true }); + }); + test("moving along an overflowing rail keeps the preview open", async ({ page, }) => { diff --git a/src/hooks/use-reading-position.ts b/src/hooks/use-reading-position.ts index 4d1758b9..113e329d 100644 --- a/src/hooks/use-reading-position.ts +++ b/src/hooks/use-reading-position.ts @@ -146,6 +146,8 @@ export function useReadingPosition( observed.clear(); idOf.clear(); visible.clear(); + // A jump does not outlive the view it was gliding, seen or not. + pinRef.current = null; }; /** Follows the scroll container (`null` while `ConversationContent` is @@ -179,6 +181,11 @@ export function useReadingPosition( observer.observe(element); } } + // A jumped-to turn removed before it was seen can never release. + const pin = pinRef.current; + if (pin !== null && items.get(pin.id) === undefined) { + pinRef.current = null; + } }; measureRef.current = measure; diff --git a/src/ui/conversation-map.test.tsx b/src/ui/conversation-map.test.tsx index 1e5d953b..9ad3c3d7 100644 --- a/src/ui/conversation-map.test.tsx +++ b/src/ui/conversation-map.test.tsx @@ -581,6 +581,51 @@ describe("useReadingPosition", () => { expect(removed).toEqual(added); }); + test("a jump not yet seen ends when ConversationContent goes away", async () => { + let setShow!: (show: boolean) => void; + function Host() { + const [show, set] = useState(true); + setShow = set; + return ; + } + const mounted = render(); + act(() => onlyObserver().report(["q4", "q4-reply"])); + await nextFrame(); + act(() => tick(mounted.container, "First question").click()); + await nextFrame(); + expect(states(mounted.container)[0]).toBe("current"); + // Gone before the glide brought q1 on screen, then back showing q4. + act(() => setShow(false)); + await nextFrame(); + act(() => setShow(true)); + await nextFrame(); + act(() => onlyObserver().report(["q4", "q4-reply"])); + await nextFrame(); + expect(states(mounted.container)[0]).toBe("faint"); + expect(states(mounted.container)[3]).toBe("current"); + mounted.unmount(); + }); + + test("a jump not yet seen ends when its turn is removed", async () => { + let setItems!: (items: readonly string[]) => void; + function Host() { + const [items, set] = useState(ITEMS); + setItems = set; + return ; + } + const mounted = render(); + act(() => onlyObserver().report(["q4", "q4-reply"])); + await nextFrame(); + act(() => tick(mounted.container, "First question").click()); + await nextFrame(); + expect(states(mounted.container)[0]).toBe("current"); + act(() => setItems(ITEMS.filter((id) => !id.startsWith("q1")))); + await nextFrame(); + expect(states(mounted.container)[0]).toBe("faint"); + expect(states(mounted.container)[3]).toBe("current"); + mounted.unmount(); + }); + test("a jumped turn stays current until seen, then until it leaves", async () => { const mounted = render(); act(() => onlyObserver().report(["q4", "q4-reply"])); diff --git a/src/ui/conversation-map.tsx b/src/ui/conversation-map.tsx index 2064eb53..3675759e 100644 --- a/src/ui/conversation-map.tsx +++ b/src/ui/conversation-map.tsx @@ -55,6 +55,18 @@ function nearestTop(rail: HTMLElement, tick: HTMLElement): number | null { return null; } +/** Scrolls the rail alone to bring `tick` into view: scrollIntoView would + * also scroll every ancestor, pulling the page to a conversation below the + * fold. */ +function keepInView( + rail: HTMLElement, + tick: HTMLElement, + behavior: ScrollBehavior, +): void { + const top = nearestTop(rail, tick); + if (top !== null) rail.scrollTo({ top, behavior }); +} + /** * A rail of ticks, one per turn, for finding your way around a long * conversation. Ticks are faint, longer and darker for turns on screen, and @@ -110,8 +122,6 @@ export function ConversationMap({ card.style.top = `${top}px`; }, [preview]); - // Scrolls the rail alone: scrollIntoView would also scroll every ancestor, - // pulling the page to a conversation below the fold. useEffect(() => { const rail = railRef.current; const tick = currentRef.current; @@ -120,17 +130,28 @@ export function ConversationMap({ return; } if (tick === null) return; - const top = nearestTop(rail, tick); // The first placement is instant; later ones glide unless reduced. - if (top !== null) { - rail.scrollTo({ - top, - behavior: placedRef.current && !reducedMotion ? "smooth" : "auto", - }); - } + keepInView( + rail, + tick, + placedRef.current && !reducedMotion ? "smooth" : "auto", + ); placedRef.current = true; }, [currentId, hidden, reducedMotion]); + // A rail that shrinks (the host's layout, the window) can clip the current + // tick: bring it back at once. + useEffect(() => { + const rail = railRef.current; + if (rail === null) return; + const observer = new ResizeObserver(() => { + const tick = currentRef.current; + if (tick !== null) keepInView(rail, tick, "auto"); + }); + observer.observe(rail); + return () => observer.disconnect(); + }, [hidden]); + if (hidden) return null; const visible = new Set(visibleIds); @@ -247,7 +268,6 @@ export function ConversationMap({ data-slot="conversation-map-tick" data-turn-id={turn.id} data-visible={onScreen ? "" : undefined} - data-current={current ? "" : undefined} aria-label={turn.label} aria-current={current ? "location" : undefined} aria-describedby={described ? previewTextId : undefined} From 506ab0e9416c4fc7940ea13c07febd965c3a1cfd Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 27 Oct 2026 19:47:08 +0545 Subject: [PATCH 03/10] fix(conversation): give map ticks a 24px target on touch screens The map's ticks shrink so the whole conversation fits on the rail: 14px tall at most, 6px on a long chat. That is fine for a mouse but below the 24px target WCAG 2.5.8 asks for, and hard to hit with a finger. On a coarse pointer each tick now keeps a 24px height and the rail scrolls, which already keeps the current tick in view. A mouse still gets the dense rail. --- e2e/conversation-map.spec.ts | 31 +++++++++++++++++++++++++++++++ src/ui/conversation-map.tsx | 5 ++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/e2e/conversation-map.spec.ts b/e2e/conversation-map.spec.ts index dcda7ecf..19393d84 100644 --- a/e2e/conversation-map.spec.ts +++ b/e2e/conversation-map.spec.ts @@ -269,3 +269,34 @@ test.describe("conversation map", () => { ); }); }); + +const tickSizes = (page: Page) => + page.locator("[data-slot='conversation-map-tick']").evaluateAll((ticks) => + ticks.map((element) => { + const box = element.getBoundingClientRect(); + return { width: box.width, height: box.height }; + }), + ); + +test("with a mouse the rail fits every turn", async ({ page }) => { + await open(page); + expect((await railState(page)).overflows).toBe(false); +}); + +test.describe("on a touch screen", () => { + test.use({ hasTouch: true, isMobile: true }); + + test("every tick is a 24px target and the rail scrolls instead", async ({ + page, + }) => { + await open(page); + for (const size of await tickSizes(page)) { + expect(size.width).toBeGreaterThanOrEqual(24); + expect(size.height).toBeGreaterThanOrEqual(24); + } + // The current tick, the last turn's, is scrolled into the rail's view. + await expect + .poll(() => railState(page)) + .toEqual({ overflows: true, scrolled: true, showsCurrent: true }); + }); +}); diff --git a/src/ui/conversation-map.tsx b/src/ui/conversation-map.tsx index 3675759e..e5812c00 100644 --- a/src/ui/conversation-map.tsx +++ b/src/ui/conversation-map.tsx @@ -272,7 +272,10 @@ export function ConversationMap({ aria-current={current ? "location" : undefined} aria-describedby={described ? previewTextId : undefined} tabIndex={turn.id === stopId ? 0 : -1} - className="group flex min-h-1.5 w-full shrink grow-0 basis-3.5 cursor-pointer items-center rounded-sm focus-visible:-outline-offset-2" + // A mouse gets the dense rail, the whole conversation at a + // glance. A finger needs a 24px target (WCAG 2.5.8), so on a + // touch screen each tick keeps that height and the rail scrolls. + className="group flex min-h-1.5 w-full shrink grow-0 basis-3.5 cursor-pointer items-center rounded-sm focus-visible:-outline-offset-2 pointer-coarse:min-h-6 pointer-coarse:shrink-0 pointer-coarse:basis-6" onPointerEnter={(event) => { if (event.pointerType === "mouse") { show(turn.id, event.currentTarget); From c1c2ebfc2f379a848b4b3a5f21e31be938559dde Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 16:53:19 +0545 Subject: [PATCH 04/10] fix(conversation): end a map jump cut short before its turn is seen 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. --- src/hooks/use-reading-position.ts | 18 ++++- src/ui/conversation-map.test.tsx | 125 ++++++++++++++++++++++++++++++ src/ui/conversation-map.tsx | 7 +- 3 files changed, 146 insertions(+), 4 deletions(-) diff --git a/src/hooks/use-reading-position.ts b/src/hooks/use-reading-position.ts index 113e329d..932dd4f4 100644 --- a/src/hooks/use-reading-position.ts +++ b/src/hooks/use-reading-position.ts @@ -23,7 +23,8 @@ export type ReadingPosition = { /** * Glides to a turn, like `scrollToItem`, and keeps it current until the * reader scrolls the view or it leaves the view, even where the view cannot - * bring it to the top. + * bring it to the top. A glide cut short before the turn came on screen + * ends the jump. Only a mounted turn can be jumped to. */ readonly jumpTo: (id: string) => void; }; @@ -216,9 +217,20 @@ export function useReadingPosition( const jumpTo = useCallback( (id: string) => { - pinRef.current = + const pin: Pin | null = items.get(id) === undefined ? null : { id, seen: false, armed: false }; - scrollToItem(id); + pinRef.current = pin; + scrollToItem(id, { + // Cut short (the reader's input or momentum, another glide) before + // the turn came on screen: nothing keeps it current any more. + onEnd: (arrived) => { + if (arrived || pin === null || pinRef.current !== pin || pin.seen) { + return; + } + pinRef.current = null; + measureRef.current?.(); + }, + }); measureRef.current?.(); }, [items, scrollToItem], diff --git a/src/ui/conversation-map.test.tsx b/src/ui/conversation-map.test.tsx index 9ad3c3d7..badc1e50 100644 --- a/src/ui/conversation-map.test.tsx +++ b/src/ui/conversation-map.test.tsx @@ -207,6 +207,32 @@ function placeAt(element: Element, top: number, height = 40) { }); } +const sleep = (ms: number) => + act(() => new Promise((resolve) => setTimeout(resolve, ms))); + +/** A 600px view over 10_000px at `scrollTop`, with `q1` at 1_000 in scroll + * coordinates, so a jump to it glides for real. */ +function layOutFarFromQ1(container: HTMLElement, scrollTop: number) { + const log = scroller(container); + Object.defineProperty(log, "scrollHeight", { + configurable: true, + value: 10_000, + }); + Object.defineProperty(log, "clientHeight", { + configurable: true, + value: 600, + }); + placeAt(log, 0, 600); + log.scrollTop = scrollTop; + const q1 = log.querySelector("[data-item-id='q1']"); + if (q1 === null) throw new Error("expected q1"); + Object.defineProperty(q1, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(0, 1_000 - log.scrollTop, 400, 40), + }); + return log; +} + describe("ConversationMap", () => { test("renders one tick per turn, named by what was said", () => { const mounted = render(); @@ -626,6 +652,105 @@ describe("useReadingPosition", () => { mounted.unmount(); }); + /** Jumps to q1 from far below it, after `before`, and runs `interrupt` + * mid-glide. */ + async function jumpCutShort( + interrupt: (log: HTMLElement) => void, + before: (log: HTMLElement) => void = () => {}, + ) { + const mounted = render(); + const log = layOutFarFromQ1(mounted.container, 5_000); + act(() => onlyObserver().report(["q4", "q4-reply"])); + await nextFrame(); + act(() => before(log)); + await nextFrame(); + act(() => tick(mounted.container, "First question").click()); + await nextFrame(); + await nextFrame(); + expect(states(mounted.container)[0]).toBe("current"); + act(() => interrupt(log)); + const stoppedAt = log.scrollTop; + await sleep(900); + expect(log.scrollTop).toBe(stoppedAt); + expect(stoppedAt).toBeGreaterThan(1_500); + act(() => onlyObserver().report(["q3-reply", "q4"])); + await nextFrame(); + return mounted; + } + + test("a jump cut short by momentum before q1 is seen ends", async () => { + const mounted = await jumpCutShort( + (log) => { + // A momentum frame: no input of its own. + log.scrollTop += 60; + log.dispatchEvent(new Event("scroll")); + }, + (log) => { + // A flick, lifted just before the click. + log.dispatchEvent(new Event("touchstart")); + log.dispatchEvent(new Event("touchmove")); + log.scrollTop = 5_100; + log.dispatchEvent(new Event("scroll")); + log.dispatchEvent(new Event("touchend")); + }, + ); + expect(states(mounted.container)[0]).toBe("faint"); + expect(states(mounted.container)[3]).toBe("current"); + mounted.unmount(); + }); + + test("a jump stopped by a wheel that moves nothing ends", async () => { + const mounted = await jumpCutShort((log) => { + log.dispatchEvent(new WheelEvent("wheel")); + }); + expect(states(mounted.container)[0]).toBe("faint"); + expect(states(mounted.container)[3]).toBe("current"); + mounted.unmount(); + }); + + test("a jump stopped by a tap on a message ends", async () => { + const mounted = await jumpCutShort((log) => { + const q4 = log.querySelector("[data-item-id='q4']"); + q4?.dispatchEvent(new Event("touchstart", { bubbles: true })); + q4?.dispatchEvent(new Event("touchend", { bubbles: true })); + }); + expect(states(mounted.container)[0]).toBe("faint"); + expect(states(mounted.container)[3]).toBe("current"); + mounted.unmount(); + }); + + test("clicking the same tick mid-glide keeps the jump", async () => { + const mounted = render(); + const log = layOutFarFromQ1(mounted.container, 5_000); + act(() => onlyObserver().report(["q4", "q4-reply"])); + await nextFrame(); + act(() => tick(mounted.container, "First question").click()); + await nextFrame(); + await nextFrame(); + // The second click replaces the glide in flight with its own. + act(() => tick(mounted.container, "First question").click()); + await nextFrame(); + expect(states(mounted.container)[0]).toBe("current"); + await sleep(900); + expect(log.scrollTop).toBe(1_000); + expect(states(mounted.container)[0]).toBe("current"); + mounted.unmount(); + }); + + test("a jump left alone lands on its turn and keeps it current", async () => { + const mounted = render(); + const log = layOutFarFromQ1(mounted.container, 5_000); + act(() => onlyObserver().report(["q4", "q4-reply"])); + await nextFrame(); + act(() => tick(mounted.container, "First question").click()); + await sleep(900); + expect(log.scrollTop).toBe(1_000); + act(() => onlyObserver().report(["q1", "q1-reply"])); + await nextFrame(); + expect(states(mounted.container)[0]).toBe("current"); + mounted.unmount(); + }); + test("a jumped turn stays current until seen, then until it leaves", async () => { const mounted = render(); act(() => onlyObserver().report(["q4", "q4-reply"])); diff --git a/src/ui/conversation-map.tsx b/src/ui/conversation-map.tsx index e5812c00..d4c0d883 100644 --- a/src/ui/conversation-map.tsx +++ b/src/ui/conversation-map.tsx @@ -27,7 +27,9 @@ export type ConversationMapProps = Omit< ComponentProps<"nav">, "children" | "onSelect" > & { - /** Turns in document order. Each spans its items up to the next turn. */ + /** Turns in document order. Each spans its items up to the next turn. + * Each turn's `ConversationItem` must be mounted: under a history window, + * pass the turns of the windowed items, not the whole conversation. */ readonly turns: readonly ConversationMapTurn[]; /** Called after a tick starts the jump to its turn. */ readonly onSelect?: ((id: string) => void) | undefined; @@ -78,6 +80,9 @@ function keepInView( * content's left edge, so give the content column room, e.g. * `[&>[data-slot=conversation-column]]:pl-12` on `ConversationContent`. * + * Map only mounted turns. A tick for a turn whose item is not mounted (a + * windowed or virtualised list) does not jump, even when `onSelect` mounts it. + * * The rail is one tab stop; the arrow keys, Home and End move along it, and * Escape dismisses the preview. When the rail overflows, it scrolls to keep * the current tick in view; only the rail moves, never the page. Renders From 1311b048cbab25a0e0f2cf85d7eb6b5077141ac7 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:44:54 +0545 Subject: [PATCH 05/10] fix(conversation): keep focus on the map when a focused turn goes 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. --- src/ui/conversation-map.test.tsx | 26 ++++++++++++++++++++++++++ src/ui/conversation-map.tsx | 17 +++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/ui/conversation-map.test.tsx b/src/ui/conversation-map.test.tsx index badc1e50..0e014c21 100644 --- a/src/ui/conversation-map.test.tsx +++ b/src/ui/conversation-map.test.tsx @@ -85,6 +85,7 @@ function onlyObserver(): FakeIntersectionObserver { type Mounted = { container: HTMLElement; + rerender: (node: ReactElement) => void; unmount: () => void; }; @@ -98,6 +99,7 @@ function render(node: ReactElement): Mounted { }); return { container, + rerender: (node) => act(() => root.render(node)), unmount: () => { act(() => root.unmount()); container.remove(); @@ -462,6 +464,30 @@ describe("ConversationMap", () => { expect(second.hasAttribute("aria-describedby")).toBe(false); mounted.unmount(); }); + + test("a focused tick whose turn goes keeps focus on the rail", () => { + const mounted = render(); + act(() => tick(mounted.container, "First question").focus()); + const rest = TURNS.slice(1); + mounted.rerender( + !id.startsWith("q1"))} />, + ); + expect(document.activeElement?.getAttribute("aria-label")).toBe( + "Second question", + ); + mounted.unmount(); + }); + + test("a focused tick whose rail goes hands focus to the log", () => { + const mounted = render(); + act(() => tick(mounted.container, "First question").focus()); + mounted.rerender(); + expect( + mounted.container.querySelector("[data-slot='conversation-map']"), + ).toBeNull(); + expect(document.activeElement).toBe(scroller(mounted.container)); + mounted.unmount(); + }); }); describe("useReadingPosition", () => { diff --git a/src/ui/conversation-map.tsx b/src/ui/conversation-map.tsx index d4c0d883..62060640 100644 --- a/src/ui/conversation-map.tsx +++ b/src/ui/conversation-map.tsx @@ -13,6 +13,7 @@ import type { ComponentProps, KeyboardEvent } from "react"; import { useReadingPosition } from "../hooks/use-reading-position.js"; import { usePrefersReducedMotion } from "../hooks/use-prefers-reduced-motion.js"; import { cn } from "../lib/utils.js"; +import { useConversation } from "./conversation.js"; export type ConversationMapTurn = { /** The id of the `ConversationItem` that starts the turn. */ @@ -100,6 +101,7 @@ export function ConversationMap({ }: ConversationMapProps) { const turnIds = useMemo(() => turns.map((turn) => turn.id), [turns]); const { currentId, visibleIds, jumpTo } = useReadingPosition(turnIds); + const { scrollContainer } = useConversation(); const currentRef = useRef(null); const railRef = useRef(null); const placedRef = useRef(false); @@ -144,6 +146,21 @@ export function ConversationMap({ placedRef.current = true; }, [currentId, hidden, reducedMotion]); + // The focused tick's turn left `turns` (a history window trimmed it, the + // host deleted it): its button is gone, focus fell to the body, and React + // reports no blur for it. Hand focus to the rail's tab stop, or to the log + // once the rail itself is gone. + useLayoutEffect(() => { + if (focusedId === null || turnIds.includes(focusedId)) return; + setFocusedId(null); + const active = document.activeElement; + if (active !== null && active !== document.body) return; + const stop = [ + ...(railRef.current?.querySelectorAll(TICK) ?? []), + ].find((tick) => tick.tabIndex === 0); + (stop ?? scrollContainer())?.focus({ preventScroll: true }); + }, [focusedId, turnIds, scrollContainer]); + // A rail that shrinks (the host's layout, the window) can clip the current // tick: bring it back at once. useEffect(() => { From d68391172c2430644a9471c107cbe8b25b648638 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:45:07 +0545 Subject: [PATCH 06/10] fix(conversation): let Escape close a map preview the mouse opened 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. --- src/ui/conversation-map.test.tsx | 28 ++++++++++++++++++++++++++++ src/ui/conversation-map.tsx | 12 ++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/ui/conversation-map.test.tsx b/src/ui/conversation-map.test.tsx index 0e014c21..0027172a 100644 --- a/src/ui/conversation-map.test.tsx +++ b/src/ui/conversation-map.test.tsx @@ -465,6 +465,34 @@ describe("ConversationMap", () => { mounted.unmount(); }); + test("Escape dismisses a preview the mouse opened while focus is elsewhere", () => { + const mounted = render(); + const input = document.createElement("input"); + document.body.appendChild(input); + input.focus(); + act(() => { + tick(mounted.container, "Second question").dispatchEvent( + new PointerEvent("pointerover", { + bubbles: true, + pointerType: "mouse", + }), + ); + }); + expect( + mounted.container.querySelector("[role='tooltip']")?.textContent, + ).toBe("Second questionSecond answer"); + + act(() => { + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + }); + expect(mounted.container.querySelector("[role='tooltip']")).toBeNull(); + expect(document.activeElement).toBe(input); + input.remove(); + mounted.unmount(); + }); + test("a focused tick whose turn goes keeps focus on the rail", () => { const mounted = render(); act(() => tick(mounted.container, "First question").focus()); diff --git a/src/ui/conversation-map.tsx b/src/ui/conversation-map.tsx index 62060640..caaeedac 100644 --- a/src/ui/conversation-map.tsx +++ b/src/ui/conversation-map.tsx @@ -161,6 +161,18 @@ export function ConversationMap({ (stop ?? scrollContainer())?.focus({ preventScroll: true }); }, [focusedId, turnIds, scrollContainer]); + // Escape dismisses a preview wherever focus is: one the mouse opened + // covers the content while focus stays in the composer or the log. + const previewing = preview !== null; + useEffect(() => { + if (!previewing) return; + const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.key === "Escape") setPreview(null); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [previewing]); + // A rail that shrinks (the host's layout, the window) can clip the current // tick: bring it back at once. useEffect(() => { From 52e425740105e0ca747692c275e626d50387bf6b Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:45:12 +0545 Subject: [PATCH 07/10] refactor(conversation): measure the map preview outside its updater 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. --- src/ui/conversation-map.test.tsx | 32 +++++++++++++++++++++++++++++++ src/ui/conversation-map.tsx | 33 +++++++++++++++++--------------- 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/ui/conversation-map.test.tsx b/src/ui/conversation-map.test.tsx index 0027172a..def5ec51 100644 --- a/src/ui/conversation-map.test.tsx +++ b/src/ui/conversation-map.test.tsx @@ -516,6 +516,38 @@ describe("ConversationMap", () => { expect(document.activeElement).toBe(scroller(mounted.container)); mounted.unmount(); }); + + test("scrolling the rail measures the previewed tick in the handler, not in render", () => { + const mounted = render( + + + , + ); + const second = tick(mounted.container, "Second question"); + act(() => second.focus()); + let reads = 0; + Object.defineProperty(second, "getBoundingClientRect", { + configurable: true, + value: () => { + reads += 1; + return new DOMRect(0, 40, 24, 14); + }, + }); + const rail = mounted.container.querySelector( + "[data-slot='conversation-map-rail']", + ); + placeAt(rail ?? document.body, 0, 600); + act(() => { + rail?.dispatchEvent(new Event("scroll")); + }); + // One measurement reads the tick twice. StrictMode runs a state updater + // twice, so a measurement made in one would read it four times. + expect(reads).toBe(2); + expect( + mounted.container.querySelector("[role='tooltip']")?.textContent, + ).toBe("Second questionSecond answer"); + mounted.unmount(); + }); }); describe("useReadingPosition", () => { diff --git a/src/ui/conversation-map.tsx b/src/ui/conversation-map.tsx index caaeedac..39eb92c2 100644 --- a/src/ui/conversation-map.tsx +++ b/src/ui/conversation-map.tsx @@ -36,6 +36,9 @@ export type ConversationMapProps = Omit< readonly onSelect?: ((id: string) => void) | undefined; }; +/** The previewed turn, and where its tick's middle sits in the map. */ +type Preview = { readonly id: string; readonly center: number }; + const MAP = "[data-slot='conversation-map']"; const TICK = "[data-slot='conversation-map-tick']"; const KEYS = ["ArrowUp", "ArrowDown", "Home", "End"]; @@ -108,10 +111,7 @@ export function ConversationMap({ const reducedMotion = usePrefersReducedMotion(); const hidden = turns.length < 2; const cardRef = useRef(null); - const [preview, setPreview] = useState<{ - readonly id: string; - readonly center: number; - } | null>(null); + const [preview, setPreview] = useState(null); const [focusedId, setFocusedId] = useState(null); const previewTextId = useId(); @@ -210,19 +210,22 @@ export function ConversationMap({ const follow = () => { const rail = railRef.current; const nav = rail?.closest(MAP); - if (rail === null || nav === null || nav === undefined) return; - // An updater, so a close queued by a wheel this frame is not undone. - setPreview((previous) => { - if (previous === null) return null; - const tick = [...rail.querySelectorAll(TICK)].find( - (element) => element.dataset["turnId"] === previous.id, - ); - if (tick === undefined) return null; + if (preview === null || rail === null || nav === null || nav === undefined) + return; + const { id } = preview; + const tick = [...rail.querySelectorAll(TICK)].find( + (element) => element.dataset["turnId"] === id, + ); + let measured: Preview | null = null; + if (tick !== undefined) { const view = rail.getBoundingClientRect(); const box = tick.getBoundingClientRect(); - if (box.bottom <= view.top || box.top >= view.bottom) return null; - return { id: previous.id, center: centerOf(tick, nav) }; - }); + if (box.bottom > view.top && box.top < view.bottom) { + measured = { id, center: centerOf(tick, nav) }; + } + } + // An updater, so a close queued by a wheel this frame is not undone. + setPreview((previous) => (previous?.id === id ? measured : previous)); }; const move = (event: KeyboardEvent) => { From 7c5bddb327f7ea27fbfce9e4d7b27a8b880d4111 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:46:22 +0545 Subject: [PATCH 08/10] docs(conversation): record which target-size rule the mouse rail uses 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. --- src/ui/conversation-map.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ui/conversation-map.tsx b/src/ui/conversation-map.tsx index 39eb92c2..0ce703bf 100644 --- a/src/ui/conversation-map.tsx +++ b/src/ui/conversation-map.tsx @@ -310,8 +310,11 @@ export function ConversationMap({ aria-describedby={described ? previewTextId : undefined} tabIndex={turn.id === stopId ? 0 : -1} // A mouse gets the dense rail, the whole conversation at a - // glance. A finger needs a 24px target (WCAG 2.5.8), so on a - // touch screen each tick keeps that height and the rail scrolls. + // glance, with ticks under the 24px of WCAG 2.5.8. That rests on + // its "equivalent" exception: scrolling the conversation reaches + // every turn a tick jumps to. A finger gets the 24px target, so + // on a touch screen each tick keeps that height and the rail + // scrolls. className="group flex min-h-1.5 w-full shrink grow-0 basis-3.5 cursor-pointer items-center rounded-sm focus-visible:-outline-offset-2 pointer-coarse:min-h-6 pointer-coarse:shrink-0 pointer-coarse:basis-6" onPointerEnter={(event) => { if (event.pointerType === "mouse") { From 1ea7fe159864b54510a6c92cfffee4852fd718fc Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:46:22 +0545 Subject: [PATCH 09/10] test(conversation): share the conversation e2e helpers with the map 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. --- e2e/conversation-map.spec.ts | 46 +++++++----------------------------- 1 file changed, 8 insertions(+), 38 deletions(-) diff --git a/e2e/conversation-map.spec.ts b/e2e/conversation-map.spec.ts index 19393d84..09c18e04 100644 --- a/e2e/conversation-map.spec.ts +++ b/e2e/conversation-map.spec.ts @@ -1,7 +1,12 @@ import { expect, test, type Page } from "@playwright/test"; -const container = (page: Page) => - page.locator("[data-slot='conversation-content']"); +import { + container, + offsetOf, + settled, + view, + wheel, +} from "./conversation-helpers.js"; const tick = (page: Page, name: string) => page @@ -11,34 +16,7 @@ const tick = (page: Page, name: string) => const current = (page: Page) => page.locator("[data-slot='conversation-map-tick'][aria-current='location']"); -const scrollTop = (page: Page) => - container(page).evaluate((element) => element.scrollTop); - -/** Waits for the view to stop moving (a glide or a wheel scroll to land). */ -const settled = async (page: Page) => { - let previous = Number.NaN; - await expect - .poll( - async () => { - const now = await scrollTop(page); - const still = now === previous; - previous = now; - return still; - }, - { intervals: [100] }, - ) - .toBe(true); -}; - -/** How far an item's top sits below the top of the view. */ -const offsetOf = (page: Page, id: string) => - container(page).evaluate((element, itemId) => { - const item = element.querySelector(`[data-testid='${itemId}']`); - if (item === null) throw new Error(`no item ${itemId}`); - return ( - item.getBoundingClientRect().top - element.getBoundingClientRect().top - ); - }, id); +const scrollTop = async (page: Page) => (await view(page)).scrollTop; /** The turn whose question last crossed a line a third of the way down. */ const turnOnReadingLine = (page: Page) => @@ -55,14 +33,6 @@ const turnOnReadingLine = (page: Page) => return reading; }); -const wheel = async (page: Page, deltaY: number) => { - const box = await container(page).boundingBox(); - if (box === null) throw new Error("expected the conversation on screen"); - await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); - await page.mouse.wheel(0, deltaY); - await settled(page); -}; - /** Whether the rail overflows, has scrolled, and shows the current tick. */ const railState = (page: Page) => page.locator("[data-slot='conversation-map-rail']").evaluate((element) => { From 9e56da92770077d1a898777429c90e07ce19e697 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:47:28 +0545 Subject: [PATCH 10/10] fix(conversation): forward ConversationMap's ref under React 18 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. --- src/ui/conversation-map.test.tsx | 19 +- src/ui/conversation-map.tsx | 549 ++++++++++++++++--------------- 2 files changed, 302 insertions(+), 266 deletions(-) diff --git a/src/ui/conversation-map.test.tsx b/src/ui/conversation-map.test.tsx index def5ec51..4b2fcc6f 100644 --- a/src/ui/conversation-map.test.tsx +++ b/src/ui/conversation-map.test.tsx @@ -1,4 +1,4 @@ -import { act, StrictMode, useState } from "react"; +import { act, createRef, StrictMode, useState } from "react"; import type { ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; @@ -250,6 +250,23 @@ describe("ConversationMap", () => { mounted.unmount(); }); + test("forwards its ref to the nav", () => { + const ref = createRef(); + const mounted = render( + + + + , + ); + expect( + mounted.container.querySelector( + "[data-slot='conversation-map']", + ), + ).toBe(ref.current); + expect(ref.current).not.toBeNull(); + mounted.unmount(); + }); + test("renders nothing for fewer than two turns", () => { const mounted = render(); expect(mounted.container.querySelector("nav")).toBeNull(); diff --git a/src/ui/conversation-map.tsx b/src/ui/conversation-map.tsx index 0ce703bf..258b3171 100644 --- a/src/ui/conversation-map.tsx +++ b/src/ui/conversation-map.tsx @@ -1,6 +1,7 @@ "use client"; import { + forwardRef, useEffect, useId, useLayoutEffect, @@ -92,287 +93,305 @@ function keepInView( * the current tick in view; only the rail moves, never the page. Renders * nothing for fewer than two turns. */ -export function ConversationMap({ - turns, - onSelect, - "aria-label": label = "Conversation map", - className, - onKeyDown, - onPointerLeave, - onBlur, - ...props -}: ConversationMapProps) { - const turnIds = useMemo(() => turns.map((turn) => turn.id), [turns]); - const { currentId, visibleIds, jumpTo } = useReadingPosition(turnIds); - const { scrollContainer } = useConversation(); - const currentRef = useRef(null); - const railRef = useRef(null); - const placedRef = useRef(false); - const reducedMotion = usePrefersReducedMotion(); - const hidden = turns.length < 2; - const cardRef = useRef(null); - const [preview, setPreview] = useState(null); - const [focusedId, setFocusedId] = useState(null); - const previewTextId = useId(); +export const ConversationMap = forwardRef( + function ConversationMap( + { + turns, + onSelect, + "aria-label": label = "Conversation map", + className, + onKeyDown, + onPointerLeave, + onBlur, + ...props + }, + ref, + ) { + const turnIds = useMemo(() => turns.map((turn) => turn.id), [turns]); + const { currentId, visibleIds, jumpTo } = useReadingPosition(turnIds); + const { scrollContainer } = useConversation(); + const currentRef = useRef(null); + const railRef = useRef(null); + const placedRef = useRef(false); + const reducedMotion = usePrefersReducedMotion(); + const hidden = turns.length < 2; + const cardRef = useRef(null); + const [preview, setPreview] = useState(null); + const [focusedId, setFocusedId] = useState(null); + const previewTextId = useId(); - // Clamped once the card has a height, so it stays beside the rail. - useLayoutEffect(() => { - const card = cardRef.current; - const nav = card?.closest(MAP); - if (preview === null || card === null || nav === null || nav === undefined) - return; - const half = card.offsetHeight / 2; - const top = Math.min( - Math.max(preview.center, half), - nav.clientHeight - half, - ); - card.style.top = `${top}px`; - }, [preview]); + // Clamped once the card has a height, so it stays beside the rail. + useLayoutEffect(() => { + const card = cardRef.current; + const nav = card?.closest(MAP); + if ( + preview === null || + card === null || + nav === null || + nav === undefined + ) + return; + const half = card.offsetHeight / 2; + const top = Math.min( + Math.max(preview.center, half), + nav.clientHeight - half, + ); + card.style.top = `${top}px`; + }, [preview]); - useEffect(() => { - const rail = railRef.current; - const tick = currentRef.current; - if (rail === null) { - placedRef.current = false; - return; - } - if (tick === null) return; - // The first placement is instant; later ones glide unless reduced. - keepInView( - rail, - tick, - placedRef.current && !reducedMotion ? "smooth" : "auto", - ); - placedRef.current = true; - }, [currentId, hidden, reducedMotion]); + useEffect(() => { + const rail = railRef.current; + const tick = currentRef.current; + if (rail === null) { + placedRef.current = false; + return; + } + if (tick === null) return; + // The first placement is instant; later ones glide unless reduced. + keepInView( + rail, + tick, + placedRef.current && !reducedMotion ? "smooth" : "auto", + ); + placedRef.current = true; + }, [currentId, hidden, reducedMotion]); - // The focused tick's turn left `turns` (a history window trimmed it, the - // host deleted it): its button is gone, focus fell to the body, and React - // reports no blur for it. Hand focus to the rail's tab stop, or to the log - // once the rail itself is gone. - useLayoutEffect(() => { - if (focusedId === null || turnIds.includes(focusedId)) return; - setFocusedId(null); - const active = document.activeElement; - if (active !== null && active !== document.body) return; - const stop = [ - ...(railRef.current?.querySelectorAll(TICK) ?? []), - ].find((tick) => tick.tabIndex === 0); - (stop ?? scrollContainer())?.focus({ preventScroll: true }); - }, [focusedId, turnIds, scrollContainer]); + // The focused tick's turn left `turns` (a history window trimmed it, the + // host deleted it): its button is gone, focus fell to the body, and React + // reports no blur for it. Hand focus to the rail's tab stop, or to the log + // once the rail itself is gone. + useLayoutEffect(() => { + if (focusedId === null || turnIds.includes(focusedId)) return; + setFocusedId(null); + const active = document.activeElement; + if (active !== null && active !== document.body) return; + const stop = [ + ...(railRef.current?.querySelectorAll(TICK) ?? []), + ].find((tick) => tick.tabIndex === 0); + (stop ?? scrollContainer())?.focus({ preventScroll: true }); + }, [focusedId, turnIds, scrollContainer]); - // Escape dismisses a preview wherever focus is: one the mouse opened - // covers the content while focus stays in the composer or the log. - const previewing = preview !== null; - useEffect(() => { - if (!previewing) return; - const onKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.key === "Escape") setPreview(null); - }; - document.addEventListener("keydown", onKeyDown); - return () => document.removeEventListener("keydown", onKeyDown); - }, [previewing]); + // Escape dismisses a preview wherever focus is: one the mouse opened + // covers the content while focus stays in the composer or the log. + const previewing = preview !== null; + useEffect(() => { + if (!previewing) return; + const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.key === "Escape") setPreview(null); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [previewing]); - // A rail that shrinks (the host's layout, the window) can clip the current - // tick: bring it back at once. - useEffect(() => { - const rail = railRef.current; - if (rail === null) return; - const observer = new ResizeObserver(() => { - const tick = currentRef.current; - if (tick !== null) keepInView(rail, tick, "auto"); - }); - observer.observe(rail); - return () => observer.disconnect(); - }, [hidden]); + // A rail that shrinks (the host's layout, the window) can clip the current + // tick: bring it back at once. + useEffect(() => { + const rail = railRef.current; + if (rail === null) return; + const observer = new ResizeObserver(() => { + const tick = currentRef.current; + if (tick !== null) keepInView(rail, tick, "auto"); + }); + observer.observe(rail); + return () => observer.disconnect(); + }, [hidden]); - if (hidden) return null; + if (hidden) return null; - const visible = new Set(visibleIds); - const has = (id: string | null) => - id !== null && turns.some((turn) => turn.id === id); - const stopId = has(focusedId) - ? focusedId - : has(currentId) - ? currentId - : (turns[0]?.id ?? null); - const previewed = - preview === null ? undefined : turns.find((turn) => turn.id === preview.id); + const visible = new Set(visibleIds); + const has = (id: string | null) => + id !== null && turns.some((turn) => turn.id === id); + const stopId = has(focusedId) + ? focusedId + : has(currentId) + ? currentId + : (turns[0]?.id ?? null); + const previewed = + preview === null + ? undefined + : turns.find((turn) => turn.id === preview.id); - const show = (id: string, tick: HTMLElement) => { - const nav = tick.closest(MAP); - if (nav === null) return; - setPreview({ id, center: centerOf(tick, nav) }); - }; + const show = (id: string, tick: HTMLElement) => { + const nav = tick.closest(MAP); + if (nav === null) return; + setPreview({ id, center: centerOf(tick, nav) }); + }; - // Focus moving along the rail and keep-in-view both scroll it: the card - // follows its tick, and closes once the tick has scrolled out of the rail. - const follow = () => { - const rail = railRef.current; - const nav = rail?.closest(MAP); - if (preview === null || rail === null || nav === null || nav === undefined) - return; - const { id } = preview; - const tick = [...rail.querySelectorAll(TICK)].find( - (element) => element.dataset["turnId"] === id, - ); - let measured: Preview | null = null; - if (tick !== undefined) { - const view = rail.getBoundingClientRect(); - const box = tick.getBoundingClientRect(); - if (box.bottom > view.top && box.top < view.bottom) { - measured = { id, center: centerOf(tick, nav) }; + // Focus moving along the rail and keep-in-view both scroll it: the card + // follows its tick, and closes once the tick has scrolled out of the rail. + const follow = () => { + const rail = railRef.current; + const nav = rail?.closest(MAP); + if ( + preview === null || + rail === null || + nav === null || + nav === undefined + ) + return; + const { id } = preview; + const tick = [...rail.querySelectorAll(TICK)].find( + (element) => element.dataset["turnId"] === id, + ); + let measured: Preview | null = null; + if (tick !== undefined) { + const view = rail.getBoundingClientRect(); + const box = tick.getBoundingClientRect(); + if (box.bottom > view.top && box.top < view.bottom) { + measured = { id, center: centerOf(tick, nav) }; + } } - } - // An updater, so a close queued by a wheel this frame is not undone. - setPreview((previous) => (previous?.id === id ? measured : previous)); - }; + // An updater, so a close queued by a wheel this frame is not undone. + setPreview((previous) => (previous?.id === id ? measured : previous)); + }; - const move = (event: KeyboardEvent) => { - if (!KEYS.includes(event.key)) return; - const ticks = [ - ...(railRef.current?.querySelectorAll(TICK) ?? []), - ]; - const index = ticks.findIndex((tick) => tick === event.target); - if (index === -1) return; - const next = - event.key === "Home" - ? 0 - : event.key === "End" - ? ticks.length - 1 - : index + (event.key === "ArrowDown" ? 1 : -1); - // Also at either end, so the keys never scroll the rail or the page. - event.preventDefault(); - ticks[next]?.focus(); - }; + const move = (event: KeyboardEvent) => { + if (!KEYS.includes(event.key)) return; + const ticks = [ + ...(railRef.current?.querySelectorAll(TICK) ?? []), + ]; + const index = ticks.findIndex((tick) => tick === event.target); + if (index === -1) return; + const next = + event.key === "Home" + ? 0 + : event.key === "End" + ? ticks.length - 1 + : index + (event.key === "ArrowDown" ? 1 : -1); + // Also at either end, so the keys never scroll the rail or the page. + event.preventDefault(); + ticks[next]?.focus(); + }; - return ( - + ); + }, +);