Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/lib/conversation-scroll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
captureAnchor,
clampScrollTop,
classifyScroll,
contentOverflows,
distanceFromEnd,
extendGesture,
glideDuration,
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions src/lib/conversation-scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
105 changes: 105 additions & 0 deletions src/ui/conversation-map.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(<Chat />);
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(<Chat />);
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();
});
});
32 changes: 22 additions & 10 deletions src/ui/conversation-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,20 @@ 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.
*
* 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<HTMLElement, ConversationMapProps>(
function ConversationMap(
Expand All @@ -109,12 +113,12 @@ export const ConversationMap = forwardRef<HTMLElement, ConversationMapProps>(
) {
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<HTMLButtonElement>(null);
const railRef = useRef<HTMLDivElement>(null);
const placedRef = useRef(false);
const reducedMotion = usePrefersReducedMotion();
const hidden = turns.length < 2;
const hidden = turns.length < 2 || !overflowing;
const cardRef = useRef<HTMLDivElement>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const [focusedId, setFocusedId] = useState<string | null>(null);
Expand Down Expand Up @@ -157,19 +161,27 @@ export const ConversationMap = forwardRef<HTMLElement, ConversationMapProps>(
}, [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;
const stop = [
...(railRef.current?.querySelectorAll<HTMLElement>(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.
Expand Down
Loading
Loading