From c2c582ccb8a1acbd24268c1533f0f725dfda0971 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 22 Oct 2026 00:39:31 +0530 Subject: [PATCH] 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 e31c7738..96ad1f22 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -60,6 +60,8 @@ import { MessageFooter, ConversationEarlier, useHistoryWindow, + ConversationMap, + type ConversationMapTurn, } from "@corbits/react-ui"; import { CommandPalette, @@ -1051,6 +1053,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": () => , @@ -1106,4 +1159,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 e47d7140..4dbc9108 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,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"; @@ -149,3 +150,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 = () => ;