diff --git a/e2e/conversation-map.spec.ts b/e2e/conversation-map.spec.ts new file mode 100644 index 00000000..09c18e04 --- /dev/null +++ b/e2e/conversation-map.spec.ts @@ -0,0 +1,272 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { + container, + offsetOf, + settled, + view, + wheel, +} from "./conversation-helpers.js"; + +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 = 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) => + 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; + }); + +/** 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(); + 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"); + // The rail overflows, and has scrolled to bring the last tick into view. + await expect + .poll(() => railState(page)) + .toEqual({ overflows: true, scrolled: true, showsCurrent: 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("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, + }) => { + 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), + ); + }); +}); + +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/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..932dd4f4 --- /dev/null +++ b/src/hooks/use-reading-position.ts @@ -0,0 +1,245 @@ +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. 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; +}; + +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(); + // A jump does not outlive the view it was gliding, seen or not. + pinRef.current = null; + }; + + /** 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); + } + } + // 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; + // 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) => { + const pin: Pin | null = + items.get(id) === undefined ? null : { id, seen: false, armed: false }; + 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], + ); + + 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..4b2fcc6f --- /dev/null +++ b/src/ui/conversation-map.test.tsx @@ -0,0 +1,876 @@ +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"; + +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; + rerender: (node: ReactElement) => void; + 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, + rerender: (node) => act(() => root.render(node)), + 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), + }); +} + +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(); + 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("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(); + 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(); + }); + + 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()); + 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(); + }); + + 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", () => { + 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 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(); + }); + + /** 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"])); + 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..258b3171 --- /dev/null +++ b/src/ui/conversation-map.tsx @@ -0,0 +1,397 @@ +"use client"; + +import { + forwardRef, + 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"; +import { useConversation } from "./conversation.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. + * 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; +}; + +/** 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"]; + +/** 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; +} + +/** 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 + * 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`. + * + * 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 + * nothing for fewer than two turns. + */ +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]); + + 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]); + + // 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]); + + 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 ( + 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)); + }; + + 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 = () => ;