diff --git a/src/lib/conversation-scroll.test.ts b/src/lib/conversation-scroll.test.ts index 0dde98cb..57965c51 100644 --- a/src/lib/conversation-scroll.test.ts +++ b/src/lib/conversation-scroll.test.ts @@ -6,6 +6,7 @@ import { captureAnchor, clampScrollTop, classifyScroll, + contentOverflows, distanceFromEnd, extendGesture, glideDuration, @@ -187,6 +188,25 @@ describe("distanceFromEnd", () => { }); }); +describe("contentOverflows", () => { + test("only content taller than the view overflows, reserve excluded", () => { + expect(contentOverflows({ scrollHeight: 2000, clientHeight: 600 })).toBe( + true, + ); + expect(contentOverflows({ scrollHeight: 400, clientHeight: 600 })).toBe( + false, + ); + // Content that fits, padded out by the reserve a settled item needs. + expect( + contentOverflows({ scrollHeight: 900, clientHeight: 600, reserved: 500 }), + ).toBe(false); + // Subpixel rounding is not overflow. + expect(contentOverflows({ scrollHeight: 601, clientHeight: 600 })).toBe( + false, + ); + }); +}); + describe("clampScrollTop", () => { test("keeps a target within what the container can reach", () => { expect(clampScrollTop(-20, 2000, 600)).toBe(0); diff --git a/src/lib/conversation-scroll.ts b/src/lib/conversation-scroll.ts index 17154274..ffb939ad 100644 --- a/src/lib/conversation-scroll.ts +++ b/src/lib/conversation-scroll.ts @@ -239,6 +239,21 @@ export function distanceFromEnd({ return scrollHeight - reserved - scrollTop - clientHeight; } +/** The content is taller than the view, so there is somewhere to scroll; the + * reserved space after the content does not count as content. A pixel of + * slack absorbs subpixel rounding between the two heights. */ +export function contentOverflows({ + scrollHeight, + clientHeight, + reserved = 0, +}: { + readonly scrollHeight: number; + readonly clientHeight: number; + readonly reserved?: number; +}): boolean { + return scrollHeight - reserved > clientHeight + 1; +} + /** Clamps a `scrollTop` to what the container can actually reach. */ export function clampScrollTop( value: number, diff --git a/src/ui/conversation-map.test.tsx b/src/ui/conversation-map.test.tsx index 4b2fcc6f..2133621e 100644 --- a/src/ui/conversation-map.test.tsx +++ b/src/ui/conversation-map.test.tsx @@ -63,16 +63,40 @@ class FakeIntersectionObserver { const original = globalThis.IntersectionObserver; +/** happy-dom does no layout, so every log would fit and hide the map: unless + * a test sets its own, each log is a 600px view over 10_000px of content. */ +const SIZES = { scrollHeight: 10_000, clientHeight: 600 } as const; +const happyDomSizes = Object.keys(SIZES).map( + (name) => + [ + name, + Object.getOwnPropertyDescriptor(HTMLElement.prototype, name), + ] as const, +); + beforeEach(() => { FakeIntersectionObserver.live.clear(); FakeIntersectionObserver.created = 0; // A stand-in with the members the hook calls. globalThis.IntersectionObserver = FakeIntersectionObserver as unknown as typeof IntersectionObserver; + for (const [name, size] of Object.entries(SIZES)) { + Object.defineProperty(HTMLElement.prototype, name, { + configurable: true, + get(this: HTMLElement) { + return this.getAttribute("role") === "log" ? size : 0; + }, + }); + } }); afterEach(() => { globalThis.IntersectionObserver = original; + for (const [name, descriptor] of happyDomSizes) { + if (descriptor === undefined) + Reflect.deleteProperty(HTMLElement.prototype, name); + else Object.defineProperty(HTMLElement.prototype, name, descriptor); + } }); function onlyObserver(): FakeIntersectionObserver { @@ -874,3 +898,84 @@ describe("useReadingPosition", () => { mounted.unmount(); }); }); + +describe("ConversationMap and a conversation that fits", () => { + const OriginalResizeObserver = globalThis.ResizeObserver; + let resized: (() => void)[] = []; + beforeEach(() => { + resized = []; + globalThis.ResizeObserver = class { + constructor(callback: () => void) { + resized.push(callback); + } + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + }); + afterEach(() => { + globalThis.ResizeObserver = OriginalResizeObserver; + }); + /** The log's content and view resize to these heights. */ + const resize = (log: HTMLElement, content: number, view: number) => + act(() => { + Object.defineProperty(log, "scrollHeight", { + configurable: true, + value: content, + }); + Object.defineProperty(log, "clientHeight", { + configurable: true, + value: view, + }); + for (const callback of resized) callback(); + }); + const map = (container: HTMLElement) => container.querySelector("nav"); + + test("shows only while the conversation is taller than its view", () => { + const mounted = render(); + const log = scroller(mounted.container); + const root = mounted.container.querySelector("[data-slot='conversation']"); + + // Two short turns that fit: nowhere to jump to. + resize(log, 400, 600); + expect(map(mounted.container)).toBeNull(); + expect(root?.hasAttribute("data-overflowing")).toBe(false); + + // A reply grows past the view. + resize(log, 1_200, 600); + expect(map(mounted.container)).not.toBeNull(); + expect(root?.hasAttribute("data-overflowing")).toBe(true); + + // The window grows until it all fits again. + resize(log, 1_200, 1_400); + expect(map(mounted.container)).toBeNull(); + + // The content shrinks below a view that stays the same. + resize(log, 2_000, 1_400); + expect(map(mounted.container)).not.toBeNull(); + resize(log, 900, 1_400); + expect(map(mounted.container)).toBeNull(); + mounted.unmount(); + }); + + test("a focused tick whose conversation comes to fit hands focus to the log", () => { + const mounted = render(); + const log = scroller(mounted.container); + act(() => tick(mounted.container, "Second question").focus()); + expect( + mounted.container.querySelector("[data-slot='conversation-map-preview']"), + ).not.toBeNull(); + + resize(log, 400, 600); + expect(map(mounted.container)).toBeNull(); + expect(document.activeElement).toBe(log); + + // Back again, the rail opens with no preview left over from before. + resize(log, 2_000, 600); + expect(map(mounted.container)).not.toBeNull(); + expect( + mounted.container.querySelector("[data-slot='conversation-map-preview']"), + ).toBeNull(); + mounted.unmount(); + }); +}); diff --git a/src/ui/conversation-map.tsx b/src/ui/conversation-map.tsx index 258b3171..6269e2fe 100644 --- a/src/ui/conversation-map.tsx +++ b/src/ui/conversation-map.tsx @@ -82,8 +82,11 @@ function keepInView( * * 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`. + * content's left edge, so give the content column room while it can show: + * `Conversation` carries `data-overflowing` while the conversation scrolls, + * e.g. + * `[&[data-overflowing]_[data-slot=conversation-column]]:pl-12` on + * `Conversation`. * * 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. @@ -91,7 +94,8 @@ function keepInView( * 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. + * nothing for fewer than two turns, or while the conversation fits its view + * and there is nowhere to jump to. */ export const ConversationMap = forwardRef( function ConversationMap( @@ -109,12 +113,12 @@ export const ConversationMap = forwardRef( ) { const turnIds = useMemo(() => turns.map((turn) => turn.id), [turns]); const { currentId, visibleIds, jumpTo } = useReadingPosition(turnIds); - const { scrollContainer } = useConversation(); + const { scrollContainer, overflowing } = useConversation(); const currentRef = useRef(null); const railRef = useRef(null); const placedRef = useRef(false); const reducedMotion = usePrefersReducedMotion(); - const hidden = turns.length < 2; + const hidden = turns.length < 2 || !overflowing; const cardRef = useRef(null); const [preview, setPreview] = useState(null); const [focusedId, setFocusedId] = useState(null); @@ -157,11 +161,13 @@ export const ConversationMap = forwardRef( }, [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. + // host deleted it), or the rail went (the conversation now fits): 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; + if (focusedId === null || (!hidden && turnIds.includes(focusedId))) + return; setFocusedId(null); const active = document.activeElement; if (active !== null && active !== document.body) return; @@ -169,7 +175,13 @@ export const ConversationMap = forwardRef( ...(railRef.current?.querySelectorAll(TICK) ?? []), ].find((tick) => tick.tabIndex === 0); (stop ?? scrollContainer())?.focus({ preventScroll: true }); - }, [focusedId, turnIds, scrollContainer]); + }, [focusedId, hidden, turnIds, scrollContainer]); + + // A preview open as the rail goes would come back with it, beside a tick + // nobody is pointing at. + useLayoutEffect(() => { + if (hidden) setPreview(null); + }, [hidden]); // Escape dismisses a preview wherever focus is: one the mouse opened // covers the content while focus stays in the composer or the log. diff --git a/src/ui/conversation.test.tsx b/src/ui/conversation.test.tsx index acb1aa0a..8d451761 100644 --- a/src/ui/conversation.test.tsx +++ b/src/ui/conversation.test.tsx @@ -1352,3 +1352,156 @@ describe("ConversationDivider and ConversationNotice", () => { mounted.unmount(); }); }); + +describe("overflowing", () => { + const OriginalResizeObserver = globalThis.ResizeObserver; + let resized: (() => void)[] = []; + beforeEach(() => { + resized = []; + globalThis.ResizeObserver = class { + constructor(callback: () => void) { + resized.push(callback); + } + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + }); + afterEach(() => { + globalThis.ResizeObserver = OriginalResizeObserver; + }); + const fireResize = () => + act(() => { + for (const callback of resized) callback(); + }); + const root = (container: HTMLElement) => + container.querySelector("[data-slot='conversation']"); + + test("follows whether the content is taller than the view, both ways", () => { + const { mounted, element, current } = mountConversation(); + expect(current().overflowing).toBe(false); + expect(root(mounted.container)?.hasAttribute("data-overflowing")).toBe( + false, + ); + + // A reply streams past the bottom of the view. + giveGeometry(element, 2_000, 600); + fireResize(); + expect(current().overflowing).toBe(true); + expect(root(mounted.container)?.hasAttribute("data-overflowing")).toBe( + true, + ); + + // The window grows until it all fits. + giveGeometry(element, 2_000, 2_400); + fireResize(); + expect(current().overflowing).toBe(false); + + // The content grows again, then shrinks back (a block collapses). + giveGeometry(element, 3_000, 2_400); + fireResize(); + expect(current().overflowing).toBe(true); + giveGeometry(element, 1_000, 2_400); + fireResize(); + expect(current().overflowing).toBe(false); + expect(root(mounted.container)?.hasAttribute("data-overflowing")).toBe( + false, + ); + mounted.unmount(); + }); + + test("measures at mount, without waiting for a resize", () => { + const sizes = Object.entries({ scrollHeight: 2_000, clientHeight: 600 }); + const saved = sizes.map( + ([name]) => + [ + name, + Object.getOwnPropertyDescriptor(HTMLElement.prototype, name), + ] as const, + ); + for (const [name, size] of sizes) { + Object.defineProperty(HTMLElement.prototype, name, { + configurable: true, + get(this: HTMLElement) { + return this.getAttribute("role") === "log" ? size : 0; + }, + }); + } + try { + const { mounted, current } = mountConversation(); + expect(current().overflowing).toBe(true); + mounted.unmount(); + } finally { + for (const [name, descriptor] of saved) { + if (descriptor === undefined) + Reflect.deleteProperty(HTMLElement.prototype, name); + else Object.defineProperty(HTMLElement.prototype, name, descriptor); + } + } + }); + + test("the room reserved for a settled item is not overflow", () => { + const VIEW = 500; + const { mounted, element, current, spacer, update } = mountConversation( + {}, + ["a", "b"], + ); + // Two 200px items in a 500px view: the content fits. The container's + // height includes whatever the reserve holds, as a browser's would. + const reserve = () => Number.parseFloat(spacer().style.height || "0"); + Object.defineProperty(element, "scrollHeight", { + configurable: true, + get: () => 400 + reserve(), + }); + Object.defineProperty(element, "clientHeight", { + configurable: true, + value: VIEW, + }); + element.getBoundingClientRect = () => + ({ top: 0, bottom: VIEW, height: VIEW }) as DOMRect; + ["a", "b"].forEach((id, index) => { + const item = element.querySelector(`[data-item-id='${id}']`); + if (item === null) throw new Error(`expected item ${id}`); + item.getBoundingClientRect = () => + ({ + top: index * 200 - element.scrollTop, + bottom: (index + 1) * 200 - element.scrollTop, + height: 200, + }) as DOMRect; + }); + spacer().getBoundingClientRect = () => + ({ top: 400 - element.scrollTop, bottom: 400, height: 0 }) as DOMRect; + fireResize(); + expect(current().overflowing).toBe(false); + + // Sending `b` reserves room below it so it can reach the top of the view: + // the container now scrolls, but only into the reserve. + update({ sentId: "b" }); + fireResize(); + expect(reserve()).toBe(300); + expect(element.scrollHeight).toBeGreaterThan(VIEW); + expect(current().overflowing).toBe(false); + mounted.unmount(); + }); + + test("is false once the log goes", () => { + let api: ConversationContextValue | null = null; + function Probe() { + api = useConversation(); + return null; + } + const tree = (log: boolean) => ( + + {log ? : null} + + + ); + const mounted = render(tree(true)); + giveGeometry(scroller(mounted.container), 2_000, 600); + fireResize(); + expect(api!.overflowing).toBe(true); + mounted.rerender(tree(false)); + expect(api!.overflowing).toBe(false); + mounted.unmount(); + }); +}); diff --git a/src/ui/conversation.tsx b/src/ui/conversation.tsx index a5db319d..499a882c 100644 --- a/src/ui/conversation.tsx +++ b/src/ui/conversation.tsx @@ -24,6 +24,7 @@ import { captureAnchor, clampScrollTop, classifyScroll, + contentOverflows, distanceFromEnd, extendGesture, glideDuration, @@ -95,6 +96,10 @@ export type ConversationScrollToItemOptions = { export type ConversationContextValue = { /** The end of the content is in view (within a few pixels). */ readonly atBottom: boolean; + /** The content is taller than the view, so the reader can scroll it; the + * space `settle` reserves after the content does not count. `false` while + * no `ConversationContent` is mounted, and on the server. */ + readonly overflowing: boolean; readonly items: ConversationItems; /** Glides an item's top to the top of the view. An id with no mounted item * changes nothing: a follow and any glide in flight carry on. */ @@ -274,6 +279,8 @@ function createConversationEngine() { let frame = 0; let atBottom = true; const atBottomListeners = new Set<() => void>(); + let overflowing = false; + const overflowingListeners = new Set<() => void>(); const scrollListeners = new Set< (snapshot: ConversationScrollSnapshot) => void >(); @@ -411,6 +418,25 @@ function createConversationEngine() { spacer.style.height = `${next}px`; }; + const setOverflowing = (next: boolean) => { + if (next === overflowing) return; + overflowing = next; + notifyAll(overflowingListeners, (listener) => listener()); + }; + + /** Read on resize, not per frame: only a change of size moves the content's + * height or the view's. */ + const measureOverflow = () => { + if (scroller === null) return; + setOverflowing( + contentOverflows({ + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + reserved, + }), + ); + }; + const notifyScroll = () => { const byReader = scrolledByReader; scrolled = false; @@ -584,6 +610,7 @@ function createConversationEngine() { resizedAt = performance.now(); updateReserve(); restore(); + measureOverflow(); scheduleAtBottom(); }; @@ -646,6 +673,7 @@ function createConversationEngine() { window.addEventListener("pointerup", onPointerUp); window.addEventListener("pointercancel", onPointerUp); updateReserve(); + measureOverflow(); // A remounted log opens where the last one left the reader: at the end // while following, otherwise with the anchored items where they were. restore(); @@ -676,6 +704,7 @@ function createConversationEngine() { scrolledByReader = false; scroller = null; spacer = null; + setOverflowing(false); }; }, setPolicy(next: ConversationScrollPolicy) { @@ -734,6 +763,13 @@ function createConversationEngine() { }; }, getAtBottom: () => atBottom, + subscribeOverflowing(listener: () => void) { + overflowingListeners.add(listener); + return () => { + overflowingListeners.delete(listener); + }; + }, + getOverflowing: () => overflowing, scrollContainer: () => scroller, subscribeScroll(listener: (snapshot: ConversationScrollSnapshot) => void) { scrollListeners.add(listener); @@ -772,11 +808,19 @@ function useMergedRef(own: RefCallback, theirs: Ref | undefined) { ); } +function useOverflowing(engine: ConversationEngine): boolean { + return useSyncExternalStore( + engine.subscribeOverflowing, + engine.getOverflowing, + () => false, + ); +} + /** * The scroll state shared under a `Conversation`. The functions and `items` - * keep their identity for the conversation's lifetime; `atBottom` is the only - * value that changes, and a component calling this re-renders only when it - * flips, never per scroll. + * keep their identity for the conversation's lifetime; `atBottom` and + * `overflowing` are the only values that change, and a component calling this + * re-renders only when one flips, never per scroll. * * For per-scroll work without re-rendering (a reading position, a history * window that loads near the top and holds still while the reader is up): @@ -798,9 +842,11 @@ export function useConversation(): ConversationContextValue { engine.getAtBottom, () => true, ); + const overflowing = useOverflowing(engine); return useMemo( () => ({ atBottom, + overflowing, items: engine.items, scrollToItem: engine.scrollToItem, scrollToEnd: engine.scrollToEnd, @@ -809,7 +855,7 @@ export function useConversation(): ConversationContextValue { scrollContainer: engine.scrollContainer, subscribeScroll: engine.subscribeScroll, }), - [engine, atBottom], + [engine, atBottom, overflowing], ); } @@ -833,6 +879,10 @@ export type ConversationProps = ComponentProps<"div"> & { * `ConversationItem` around each thing the host renders, and a * `ConversationScrollButton` beside it. * + * While the content is taller than the view, the root carries + * `data-overflowing`, so the host can style around what only shows then (the + * room a `ConversationMap` needs, for one). + * * Only the reader's own scrolling (wheel, touch, scroll keys, the scrollbar), * or the page moving the view away from the end (focus, find in page), breaks * a follow; content resizing never scrolls what they are reading. Jumps are @@ -847,6 +897,7 @@ export function Conversation({ }: ConversationProps) { const [engine] = useState(createConversationEngine); const reducedMotion = usePrefersReducedMotion(); + const overflowing = useOverflowing(engine); useLayoutEffect(() => { engine.setReducedMotion(reducedMotion); @@ -863,6 +914,7 @@ export function Conversation({
diff --git a/stories/chat/conversation-map.stories.tsx b/stories/chat/conversation-map.stories.tsx index 668480bc..a463a393 100644 --- a/stories/chat/conversation-map.stories.tsx +++ b/stories/chat/conversation-map.stories.tsx @@ -77,10 +77,12 @@ function MappedConversation({ }) { const turns = turnsOf(sample.messages); return ( -
- + // Drag the corner: the map shows only while the conversation scrolls, and + // the column makes room for it only then. +
+ - + {sample.messages.map((message) => ( @@ -93,7 +95,8 @@ function MappedConversation({ ); } -/** Two turns: a tick each. Point at one to preview it, click to jump. */ +/** Two turns that fit: no map, since there is nowhere to jump to. Drag the + * pane shorter until they scroll and it appears, a tick each. */ export const FewTurns = () => ; /** 150 turns: the ticks squeeze, then the rail scrolls and keeps the current