🧭 fix: Anchor the Message Rail to One Measured Layout

The rail's magnifier wrote size into normal flow while its rows, flex items
in a scrolling column without `shrink-0`, compressed to their content the
moment the list overflowed. Hovering grew the column's scrollHeight 363→378
and shifted every rib below the pointer, staling the cached centres: the
centre of `m10`'s rib previewed `m12`, and a click in the gaps — half the
rail's area — followed that same wrong id.

Three more measurements came from different origins. The ribs' offsetParent
was the absolutely positioned `nav`, not the static column, so the rail's
auto-centring inherited the chevron's height (34px ≈ 3.5 ribs). `scrubTo`
mapped the pointer's viewport fraction across the whole rib list, ignoring
the column's own scroll, so no drag position landed on the rib under the
pointer. And `currentId` came from the IntersectionObserver, whose topmost
intersecting row is a rib short of the end whenever a `.steer-render`
follows its response in document order while sitting inside it — the rail
pointing above the end while the reader sits at the bottom.

Everything that answers "which rib is the pointer on" now reads one measured
layout in the column's own content space, and "you are here" is a scroll-spy
over entry spans, so at the bottom current lands on the terminus.

Also: a response's row mounts a frame before its first token, which labelled
the rib with nothing and opened an empty preview card; ribs now name the
pending state. Lighting separates the single current mark from the in-view
band instead of hover replacing both. An origin rib mirrors the terminus. And
the column takes one tab stop with arrow-key walking, rather than one per
message — 226 stops in a 200-message thread.
This commit is contained in:
Danny Avila 2026-08-26 17:38:26 -04:00
parent 2ec222f117
commit d10ca65026
3 changed files with 832 additions and 209 deletions

View file

@ -13,9 +13,17 @@ type MessageEntry = {
isUser: boolean;
preview: string;
isEnd?: boolean;
isStart?: boolean;
};
const MESSAGES_END_ID = 'messages-end';
/** The origin rib has no row of its own — it targets the top of the scroll
* container, mirroring the terminus that targets `#messages-end`. */
const MESSAGES_START_ID = 'messages-start';
function isTerminusId(id: string): boolean {
return id === MESSAGES_END_ID || id === MESSAGES_START_ID;
}
export function extractPreviewFromContent(content?: TMessageContentParts[]): string {
if (!content) {
@ -93,6 +101,27 @@ export function buildSteerEntry(node: HTMLElement, id: string): MessageEntry {
};
}
type LocalizeFn = ReturnType<typeof useLocalize>;
/**
* What a rib says it will take you to.
*
* A response enters the rail the instant its row mounts, which is one frame
* before its first token — and a run that is still thinking has no text at all.
* Falling through to the raw preview there labelled the rib with nothing and
* opened an empty preview card beside it; naming the pending state instead
* keeps the rail readable for the whole of a long generation.
*/
export function previewTextFor(entry: MessageEntry, localize: LocalizeFn): string {
if (entry.isStart === true) {
return localize('com_ui_scroll_to_top');
}
if (entry.isEnd === true) {
return localize('com_ui_scroll_to_bottom');
}
return entry.preview !== '' ? entry.preview : localize('com_ui_generating');
}
function getMessageEntries(root: ParentNode, messagesById: Map<string, TMessage>): MessageEntry[] {
const nodes = root.querySelectorAll<HTMLElement>(ENTRY_NODE_SELECTOR);
const entries: MessageEntry[] = [];
@ -175,14 +204,27 @@ type RibDims = { baseW: number; baseH: number; peakW: number; peakH: number };
const RIB_END: RibDims = { baseW: 3, baseH: 3, peakW: 4.5, peakH: 4.5 };
const RIB_MESSAGE: RibDims = { baseW: 12, baseH: 3, peakW: 39, peakH: 6 };
/** The rib you are reading is longer at rest, so the rail answers "where am I"
* from length alone — the only axis a 3px line has left once colour is spent
* on the in-view band. */
const RIB_CURRENT: RibDims = { baseW: 21, baseH: 3, peakW: 39, peakH: 6 };
/** Row height in px. `peakH` may reach it but never exceed it: the magnifier
* writes into normal flow, and a rib taller than its row would reflow every
* rib below the pointer — moving the rail out from under the pointer and
* leaving the measured centres (and so the preview and the click target)
* pointing at the wrong message. */
const RIB_ROW_HEIGHT = 6;
/** Vertical falloff radius (content-space px) over which neighbouring ribs magnify. */
const MAG_INFLUENCE = 50;
/** Delay before the shared preview first opens; subsequent moves reposition instantly. */
const TOOLTIP_OPEN_DELAY = 60;
export function ribDimsFor(entry: MessageEntry): RibDims {
return entry.isEnd ? RIB_END : RIB_MESSAGE;
export function ribDimsFor(entry: MessageEntry, isCurrent = false): RibDims {
if (entry.isEnd === true || entry.isStart === true) {
return RIB_END;
}
return isCurrent ? RIB_CURRENT : RIB_MESSAGE;
}
/** Cosine bell: 1 at the pointer, easing to 0 at the influence radius. */
@ -193,8 +235,12 @@ export function magnifyFalloff(distance: number, influence: number): number {
return 0.5 * (1 + Math.cos((Math.PI * distance) / influence));
}
/** `shrink-0` is load-bearing: the ribs are flex items in a scrolling column, so
* without it every row compresses to its content the moment the rail overflows —
* halving the hit target of every rib in exactly the long conversations the rail
* exists to navigate. */
const indicatorButtonClasses = cn(
'flex h-1.5 w-full items-center justify-end rounded-sm transition-opacity duration-300',
'flex w-full shrink-0 items-center justify-end rounded-sm transition-opacity duration-300',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy',
);
@ -203,18 +249,32 @@ const dimIndicatorClasses =
const MessageIndicator = memo(function MessageIndicator({
entry,
isHighlighted,
isInView,
isCurrent,
isFocused,
label,
tabIndex,
onSelect,
}: {
entry: MessageEntry;
isHighlighted: boolean;
/** The row intersects the viewport — the soft band around where you are. */
isInView: boolean;
/** The row you are reading: the rail's single "you are here" mark. */
isCurrent: boolean;
/** The rib the pointer or keyboard is previewing right now. */
isFocused: boolean;
label: string;
tabIndex: number;
onSelect: (id: string) => void;
}) {
const baseSize = entry.isEnd ? 'mr-[4.5px] h-[3px] w-[3px]' : 'h-[3px] w-3';
const dims = ribDimsFor(entry, isCurrent);
const isEmphasized = isCurrent || isFocused;
let tone = 'bg-text-tertiary';
if (isEmphasized) {
tone = 'bg-text-primary';
} else if (isInView) {
tone = 'bg-text-secondary';
}
return (
<button
type="button"
@ -222,17 +282,23 @@ const MessageIndicator = memo(function MessageIndicator({
e.stopPropagation();
onSelect(entry.id);
}}
className={cn(indicatorButtonClasses, isHighlighted ? 'opacity-100' : dimIndicatorClasses)}
className={cn(
indicatorButtonClasses,
isEmphasized || isInView ? 'opacity-100' : dimIndicatorClasses,
)}
style={{ height: RIB_ROW_HEIGHT }}
aria-label={label}
aria-current={isCurrent ? 'true' : undefined}
tabIndex={tabIndex}
data-msg-id={entry.id}
>
<span
className={cn(
'block rounded-full',
baseSize,
isHighlighted ? 'bg-gray-800 dark:bg-gray-100' : 'bg-gray-400 dark:bg-gray-500',
entry.isEnd === true || entry.isStart === true ? 'mr-[4.5px]' : '',
tone,
)}
style={{ width: dims.baseW, height: dims.baseH }}
/>
</button>
);
@ -289,10 +355,15 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
const dragCleanupRef = useRef<(() => void) | null>(null);
const suppressClickRef = useRef(false);
const isDraggingRef = useRef(false);
/** True while the pointer or the keyboard is working the rail; freezes the
* rail's auto-follow so the ribs stay put under an active gesture. */
const interactingRef = useRef(false);
const ribLayoutRef = useRef<
Array<{ id: string; line: HTMLElement; center: number; dims: RibDims }>
>([]);
const measuredCountRef = useRef(-1);
const measuredCurrentRef = useRef<string | null | undefined>(undefined);
const pointerYRef = useRef<number | null>(null);
const magRafRef = useRef<number | null>(null);
const reducedMotionRef = useRef(false);
@ -305,45 +376,88 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
const [tip, setTip] = useState<{ id: string; top: number; right: number } | null>(null);
const [hoveredId, setHoveredId] = useState<string | null>(null);
/** The terminus rib is pinned beside the down chevron rather than living in
* the scrolling column, so it stays reachable however far the rail scrolls.
* The origin rib is its mirror above the column, for the same reason: in a
* long thread the top of the conversation scrolls out of the rail itself.
*
* Only the terminus is ever `aria-current`: it is a real entry (`#messages-end`
* closes the thread), whereas the origin is a control with no row of its own,
* and marking both would put two current items in one nav. */
const { messageEntries, endEntry, startEntry } = useMemo(() => {
const last = entries[entries.length - 1];
const hasEnd = last?.isEnd === true;
const messageEntries = hasEnd ? entries.slice(0, -1) : entries;
return {
messageEntries,
endEntry: hasEnd ? last : null,
startEntry:
messageEntries.length > 0
? { id: MESSAGES_START_ID, isUser: false, preview: '', isStart: true }
: null,
};
}, [entries]);
const entryById = useMemo(() => {
const map = new Map<string, MessageEntry>();
for (let i = 0; i < entries.length; i++) {
map.set(entries[i].id, entries[i]);
}
if (startEntry) {
map.set(startEntry.id, startEntry);
}
return map;
}, [entries]);
}, [entries, startEntry]);
/** The terminus rib is pinned beside the down chevron rather than living in
* the scrolling column, so it stays reachable however far the rail scrolls. */
const { messageEntries, endEntry } = useMemo(() => {
const last = entries[entries.length - 1];
if (last?.isEnd === true) {
return { messageEntries: entries.slice(0, -1), endEntry: last };
/**
* Rib centres in the column's own content space, plus the resting size each
* rib returns to. Everything that answers "which rib is the pointer on" —
* the fisheye, the preview, a click in the gaps, a drag — reads this one
* layout, so they cannot disagree.
*/
const measureRibs = useCallback(() => {
const col = columnRef.current;
if (!col) {
return;
}
return { messageEntries: entries, endEntry: null };
}, [entries]);
const getCurrentVisibleId = useCallback((): string | null => {
const container = scrollableRef.current;
if (!container) {
return null;
}
let nextId: string | null = null;
let nextTop = Number.POSITIVE_INFINITY;
for (const id of visibleSetRef.current) {
const el = observedRef.current.get(id);
if (!el) {
const layout: Array<{ id: string; line: HTMLElement; center: number; dims: RibDims }> = [];
const kids = col.children;
for (let i = 0; i < kids.length; i++) {
const button = kids[i] as HTMLElement;
const id = button.getAttribute('data-msg-id');
const line = button.firstElementChild as HTMLElement | null;
const entry = id ? entryById.get(id) : undefined;
if (!id || !line || !entry) {
continue;
}
const top = entryTop(el, container);
if (top >= nextTop) {
continue;
}
nextId = id;
nextTop = top;
layout.push({
id,
line,
center: button.offsetTop + button.offsetHeight / 2,
dims: ribDimsFor(entry, id === currentId),
});
}
return nextId;
}, [scrollableRef]);
measuredCountRef.current = kids.length;
measuredCurrentRef.current = currentId;
ribLayoutRef.current = layout;
}, [entryById, currentId]);
/** Re-measures when the rib set or the current rib has changed since the last
* measurement, so a gesture that arrives before the scheduled measure still
* hit-tests against the ribs on screen and releases them to the resting size
* they are actually rendered at. */
const ensureRibLayout = useCallback(() => {
const col = columnRef.current;
if (!col) {
return;
}
if (
measuredCountRef.current !== col.children.length ||
measuredCurrentRef.current !== currentId
) {
measureRibs();
}
}, [measureRibs, currentId]);
useEffect(() => {
messagesByIdRef.current = messagesById;
@ -351,6 +465,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
const resolveEntryEl = useCallback(
(id: string): HTMLElement | null => {
if (id === MESSAGES_START_ID) {
return null;
}
if (id === MESSAGES_END_ID) {
return scrollableRef.current?.querySelector<HTMLElement>('#' + MESSAGES_END_ID) ?? null;
}
@ -395,10 +512,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
}
}
if (visibilityChanged) {
setCurrentId(getCurrentVisibleId());
setVisibleIds(new Set(visibleSet));
}
}, [resolveEntryEl, getCurrentVisibleId]);
}, [resolveEntryEl]);
const refreshEntries = useCallback(() => {
if (refreshTimerRef.current) {
@ -426,6 +542,11 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
const scrollToStart = useCallback(
(id: string) => {
if (id === MESSAGES_START_ID) {
scrollTokenRef.current++;
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
const el = resolveEntryEl(id);
if (!el) {
return;
@ -458,11 +579,19 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
requestAnimationFrame(step);
},
[resolveEntryEl],
[resolveEntryEl, scrollableRef],
);
const scrollToImmediate = useCallback(
(id: string) => {
if (id === MESSAGES_START_ID) {
scrollTokenRef.current++;
const container = scrollableRef.current;
if (container) {
container.scrollTop = 0;
}
return;
}
const el = resolveEntryEl(id);
if (!el) {
return;
@ -475,7 +604,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
const scrollMargin = scrollMarginRef.current || readScrollMargin(el);
container.scrollTop = computeTargetScroll(container, el, scrollMargin);
},
[resolveEntryEl],
[resolveEntryEl, scrollableRef],
);
const focusMessage = useCallback((id: string) => {
@ -496,7 +625,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
return;
}
scrollToStart(id);
if (id !== MESSAGES_END_ID) {
if (!isTerminusId(id)) {
focusMessage(id);
}
},
@ -525,6 +654,41 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
return document.activeElement === target;
}, []);
/**
* The rib nearest the pointer, in the column's own content space.
*
* The column scrolls independently once a thread outgrows it, so a mapping
* built from the pointer's fraction of the column's *visible* height and the
* *whole* rib list answers with a rib the reader is not pointing at — the
* error grows with the thread and, worse, disagrees with the preview, which
* has always been nearest-centre. Both now read the same measured layout, so
* the rib under the pointer, the message named in the preview, and the
* message a drag lands on are one and the same.
*/
const ribIdAt = useCallback(
(clientY: number): string | null => {
ensureRibLayout();
const col = columnRef.current;
const layout = ribLayoutRef.current;
if (!col || layout.length === 0) {
return null;
}
const contentY = clientY - col.getBoundingClientRect().top + col.scrollTop;
let nearestId: string | null = null;
let nearestD = Number.POSITIVE_INFINITY;
for (let i = 0; i < layout.length; i++) {
const d = Math.abs(contentY - layout[i].center);
if (d >= nearestD) {
continue;
}
nearestD = d;
nearestId = layout[i].id;
}
return nearestId;
},
[ensureRibLayout],
);
const scrubTo = useCallback(
(clientY: number) => {
const col = columnRef.current;
@ -532,26 +696,22 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
return;
}
const rect = col.getBoundingClientRect();
/** The terminus is pinned below the column, so the pointer reaches it by
* travelling past the bottom edge — the proportional mapping covers only
* the ribs the column actually spans, or every position lands one late. */
/** The terminus ribs are pinned outside the column, so the pointer reaches
* them by travelling past its edges. */
if (endEntry && clientY >= rect.bottom) {
scrollToImmediate(MESSAGES_END_ID);
return;
}
const ribs = col.querySelectorAll<HTMLElement>('[data-msg-id]');
const count = ribs.length;
if (count === 0) {
if (startEntry && clientY <= rect.top) {
scrollToImmediate(MESSAGES_START_ID);
return;
}
const fraction = rect.height > 0 ? (clientY - rect.top) / rect.height : 0;
const index = Math.max(0, Math.min(count - 1, Math.round(fraction * (count - 1))));
const id = ribs[index].getAttribute('data-msg-id');
const id = ribIdAt(clientY);
if (id) {
scrollToImmediate(id);
}
},
[scrollToImmediate, endEntry],
[scrollToImmediate, ribIdAt, endEntry, startEntry],
);
const handlePointerDown = useCallback(
@ -561,6 +721,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
}
dragCleanupRef.current?.();
suppressClickRef.current = false;
interactingRef.current = true;
const state = { pointerId: e.pointerId, startY: e.clientY, dragging: false };
const finish = (wasDragging: boolean) => {
@ -570,6 +731,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
window.removeEventListener('blur', onBlur);
dragCleanupRef.current = null;
isDraggingRef.current = false;
if (pointerYRef.current == null) {
interactingRef.current = false;
}
if (wasDragging) {
suppressClickRef.current = true;
window.setTimeout(() => {
@ -632,34 +796,6 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
return () => mq.removeListener(onChange);
}, []);
const measureRibs = useCallback(() => {
const col = columnRef.current;
if (!col) {
return;
}
const colRect = col.getBoundingClientRect();
const scrollTop = col.scrollTop;
const layout: Array<{ id: string; line: HTMLElement; center: number; dims: RibDims }> = [];
const kids = col.children;
for (let i = 0; i < kids.length; i++) {
const button = kids[i] as HTMLElement;
const id = button.getAttribute('data-msg-id');
const line = button.firstElementChild as HTMLElement | null;
const entry = id ? entryById.get(id) : undefined;
if (!id || !line || !entry) {
continue;
}
const rect = button.getBoundingClientRect();
layout.push({
id,
line,
center: rect.top - colRect.top + scrollTop + rect.height / 2,
dims: ribDimsFor(entry),
});
}
ribLayoutRef.current = layout;
}, [entryById]);
useEffect(() => {
const raf = requestAnimationFrame(measureRibs);
const col = columnRef.current;
@ -671,7 +807,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
cancelAnimationFrame(raf);
resize?.disconnect();
};
}, [entries, measureRibs]);
}, [entries, currentId, measureRibs]);
const positionTip = useCallback((top: number, right: number) => {
tipPosRef.current = { top, right };
@ -732,28 +868,38 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
[positionTip, revealTip],
);
/** The pinned terminus lives outside the column, so it drives the shared
* preview itself instead of through the rail's pointer magnification. */
const showEndTip = useCallback(
(el: HTMLElement) => {
/** The pinned origin and terminus live outside the column, so they drive the
* shared preview themselves instead of through the rail's magnification. */
const showTerminusTip = useCallback(
(el: HTMLElement, id: string) => {
const rect = el.getBoundingClientRect();
const left = columnRef.current?.getBoundingClientRect().left ?? rect.left;
focusTooltip(MESSAGES_END_ID, rect.top + rect.height / 2, window.innerWidth - left + 8);
focusTooltip(id, rect.top + rect.height / 2, window.innerWidth - left + 8);
},
[focusTooltip],
);
const handleEndPointerEnter = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => showEndTip(e.currentTarget),
[showEndTip],
(e: React.PointerEvent<HTMLDivElement>) => showTerminusTip(e.currentTarget, MESSAGES_END_ID),
[showTerminusTip],
);
const handleEndFocus = useCallback(
(e: React.FocusEvent<HTMLDivElement>) => showEndTip(e.currentTarget),
[showEndTip],
(e: React.FocusEvent<HTMLDivElement>) => showTerminusTip(e.currentTarget, MESSAGES_END_ID),
[showTerminusTip],
);
const handleEndBlur = useCallback(
const handleStartPointerEnter = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => showTerminusTip(e.currentTarget, MESSAGES_START_ID),
[showTerminusTip],
);
const handleStartFocus = useCallback(
(e: React.FocusEvent<HTMLDivElement>) => showTerminusTip(e.currentTarget, MESSAGES_START_ID),
[showTerminusTip],
);
const handleTerminusBlur = useCallback(
(e: React.FocusEvent<HTMLDivElement>) => {
const next = e.relatedTarget as Node | null;
if (next && e.currentTarget.contains(next)) {
@ -766,6 +912,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
const applyMagnify = useCallback(() => {
magRafRef.current = null;
ensureRibLayout();
const col = columnRef.current;
const layout = ribLayoutRef.current;
const py = pointerYRef.current;
@ -802,17 +949,21 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
} else {
clearTooltip();
}
}, [focusTooltip, clearTooltip]);
}, [focusTooltip, clearTooltip, ensureRibLayout]);
/** Restores the resting dimensions rather than clearing them: the ribs carry
* their base size inline, from the same `ribDimsFor` the magnifier reads, so
* there is one source of truth for a rib's size and no snap on release. */
const resetMagnify = useCallback(() => {
ensureRibLayout();
const layout = ribLayoutRef.current;
for (let i = 0; i < layout.length; i++) {
const line = layout[i].line;
line.style.transition = 'width 140ms ease-out, height 140ms ease-out';
line.style.width = '';
line.style.height = '';
const rib = layout[i];
rib.line.style.transition = 'width 140ms ease-out, height 140ms ease-out';
rib.line.style.width = `${rib.dims.baseW}px`;
rib.line.style.height = `${rib.dims.baseH}px`;
}
}, []);
}, [ensureRibLayout]);
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
@ -821,6 +972,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
return;
}
const rect = col.getBoundingClientRect();
interactingRef.current = true;
pointerYRef.current = e.clientY - rect.top + col.scrollTop;
if (magRafRef.current == null) {
magRafRef.current = requestAnimationFrame(applyMagnify);
@ -831,6 +983,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
const handlePointerLeave = useCallback(() => {
pointerYRef.current = null;
if (!isDraggingRef.current) {
interactingRef.current = false;
}
if (magRafRef.current != null) {
cancelAnimationFrame(magRafRef.current);
magRafRef.current = null;
@ -846,9 +1001,8 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
if (!col || !target.getAttribute?.('data-msg-id')) {
return;
}
const colRect = col.getBoundingClientRect();
const rect = target.getBoundingClientRect();
pointerYRef.current = rect.top - colRect.top + col.scrollTop + rect.height / 2;
interactingRef.current = true;
pointerYRef.current = target.offsetTop + target.offsetHeight / 2;
if (magRafRef.current == null) {
magRafRef.current = requestAnimationFrame(applyMagnify);
}
@ -1008,22 +1162,52 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
const containerMaxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight);
let nextCanUp = false;
let nextCanDown = false;
/**
* The rail's "you are here" is a scroll-spy over entry spans, not the set
* of rows the IntersectionObserver reports. A response tall enough to fill
* the viewport keeps intersecting for screens on end, and an in-thread
* steer follows its response in document order while sitting *inside* it —
* so "topmost intersecting row" lights a rib several places short of the
* end while the reader is looking at the very bottom of the thread. The
* last entry whose snap point has passed the viewport top is the one being
* read, and at the bottom every snap clamps to the maximum scroll, which
* lands current on the terminus.
*/
let currentIndex = -1;
for (let i = 0; i < offsetsTop.length; i++) {
if (offsetsTop[i] === Number.POSITIVE_INFINITY) {
continue;
}
const snap = Math.min(offsetsTop[i] - scrollMargin, containerMaxScrollTop);
if (snap < scrollTop - JUMP_EPS) {
nextCanUp = true;
} else if (snap > scrollTop + JUMP_EPS) {
if (snap > scrollTop + JUMP_EPS) {
nextCanDown = true;
break;
}
currentIndex = i;
if (snap < scrollTop - JUMP_EPS) {
nextCanUp = true;
}
}
if (containerMaxScrollTop <= 0) {
currentIndex = 0;
}
const nextCurrentId = entries[currentIndex < 0 ? 0 : currentIndex]?.id ?? null;
setCanGoUp((prev) => (prev === nextCanUp ? prev : nextCanUp));
setCanGoDown((prev) => (prev === nextCanDown ? prev : nextCanDown));
setCurrentId((prev) => (prev === nextCurrentId ? prev : nextCurrentId));
const col = columnRef.current;
if (!col) {
return;
}
/** While the pointer or the keyboard is working the rail, the rail holds
* still. Re-centring under an active pointer slides the ribs out from
* under it mid-gesture, and it also discards any wheel scroll the reader
* did to reach a distant part of a thread too long for one column. */
if (interactingRef.current) {
cancelColumnBottomScroll();
return;
}
if (containerMaxScrollTop > 0 && scrollTop >= containerMaxScrollTop - JUMP_EPS) {
scheduleColumnBottomScroll();
return;
@ -1056,6 +1240,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
if (!firstInd || !lastInd) {
return;
}
/** `offsetTop` is content-space only because the column is a positioned
* ancestor; without that it resolves against the absolutely positioned
* `nav` and every centring decision inherits the chevron's height. */
const mid = (firstInd.offsetTop + lastInd.offsetTop + lastInd.offsetHeight) / 2;
const target = mid - col.clientHeight / 2;
const columnMaxScrollTop = Math.max(0, col.scrollHeight - col.clientHeight);
@ -1102,8 +1289,6 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
const flush = () => {
pendingFrame = null;
const nextCurrentId = getCurrentVisibleId();
setCurrentId((prev) => (prev === nextCurrentId ? prev : nextCurrentId));
setVisibleIds((prev) => {
if (prev.size === visibleSet.size) {
let same = true;
@ -1148,7 +1333,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
cancelAnimationFrame(pendingFrame);
}
};
}, [getCurrentVisibleId, scrollableRef]);
}, [scrollableRef]);
useEffect(() => {
const observer = observerRef.current;
@ -1196,10 +1381,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
}
if (visibilityChanged) {
setCurrentId(getCurrentVisibleId());
setVisibleIds(new Set(visibleSet));
}
}, [entries, getCurrentVisibleId, resolveEntryEl]);
}, [entries, resolveEntryEl]);
const jumpToPrevious = useCallback(() => {
const container = scrollableRef.current;
@ -1246,6 +1430,43 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
}
}, [entries, scrollableRef, scrollToStart, resolveEntryEl]);
/**
* Arrow keys walk the ribs; only one of them is ever in the tab order.
* A rail that made every rib a tab stop put the whole transcript between the
* reader and the next control — hundreds of stops in a long thread.
*/
const handleColumnKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
const col = columnRef.current;
if (!col) {
return;
}
const { key } = e;
if (key !== 'ArrowUp' && key !== 'ArrowDown' && key !== 'Home' && key !== 'End') {
return;
}
const ribs = col.querySelectorAll<HTMLElement>('[data-msg-id]');
if (ribs.length === 0) {
return;
}
let index = -1;
for (let i = 0; i < ribs.length; i++) {
if (ribs[i] === document.activeElement) {
index = i;
break;
}
}
let next = 0;
if (key === 'End') {
next = ribs.length - 1;
} else if (key === 'ArrowUp') {
next = index <= 0 ? 0 : index - 1;
} else if (key === 'ArrowDown') {
next = index < 0 ? 0 : Math.min(ribs.length - 1, index + 1);
}
e.preventDefault();
ribs[next].focus();
}, []);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.altKey && e.shiftKey && (e.code === 'KeyM' || e.key.toLowerCase() === 'm')) {
@ -1265,7 +1486,18 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
const tipEntry = tip ? entryById.get(tip.id) : undefined;
let tipText = '';
if (tipEntry) {
tipText = tipEntry.isEnd ? localize('com_ui_scroll_to_bottom') : tipEntry.preview;
tipText = previewTextFor(tipEntry, localize);
}
/** One tab stop for the whole column; the arrows move within it. The current
* rib carries it so Tab lands where the reader already is — and at the very
* bottom, where current is the pinned terminus, on the last message rather
* than back at the top of the thread. */
let rovingId = messageEntries[0].id;
if (currentId === MESSAGES_END_ID) {
rovingId = messageEntries[messageEntries.length - 1].id;
} else if (currentId != null && entryById.has(currentId)) {
rovingId = currentId;
}
return (
@ -1288,6 +1520,27 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
<ChevronUp className="h-4 w-4" />
</button>
{startEntry && (
<div
className="flex w-14 cursor-pointer touch-none select-none flex-col items-stretch"
onPointerDown={handlePointerDown}
onPointerEnter={handleStartPointerEnter}
onPointerLeave={clearTooltip}
onFocus={handleStartFocus}
onBlur={handleTerminusBlur}
>
<MessageIndicator
entry={startEntry}
isInView={!canGoUp}
isCurrent={false}
isFocused={hoveredId === startEntry.id}
tabIndex={0}
onSelect={handleSelect}
label={localize('com_ui_scroll_to_top')}
/>
</div>
)}
<div
ref={columnRef}
onPointerDown={handlePointerDown}
@ -1296,22 +1549,24 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
onFocus={handleColumnFocus}
onBlur={handleColumnBlur}
onClick={handleColumnClick}
className="flex min-h-0 w-14 cursor-pointer touch-none select-none flex-col items-stretch gap-1.5 overflow-y-auto [&::-webkit-scrollbar]:hidden"
onKeyDown={handleColumnKeyDown}
data-message-nav-column=""
className="relative flex min-h-0 w-14 cursor-pointer touch-none select-none flex-col items-stretch gap-1.5 overflow-y-auto [&::-webkit-scrollbar]:hidden"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{messageEntries.map((entry) => {
const label = localize(
entry.isUser ? 'com_ui_message_nav_go_to_user' : 'com_ui_message_nav_go_to_assistant',
{ 0: entry.preview.slice(0, 30) },
{ 0: previewTextFor(entry, localize).slice(0, 30) },
);
const isHighlighted =
hoveredId != null ? hoveredId === entry.id : visibleIds.has(entry.id);
return (
<MessageIndicator
key={entry.id}
entry={entry}
isHighlighted={isHighlighted}
isInView={visibleIds.has(entry.id)}
isCurrent={currentId === entry.id}
isFocused={hoveredId === entry.id}
tabIndex={entry.id === rovingId ? 0 : -1}
onSelect={handleSelect}
label={label}
/>
@ -1326,14 +1581,14 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
onPointerEnter={handleEndPointerEnter}
onPointerLeave={clearTooltip}
onFocus={handleEndFocus}
onBlur={handleEndBlur}
onBlur={handleTerminusBlur}
>
<MessageIndicator
entry={endEntry}
isHighlighted={
hoveredId != null ? hoveredId === endEntry.id : visibleIds.has(endEntry.id)
}
isInView={visibleIds.has(endEntry.id)}
isCurrent={currentId === endEntry.id}
isFocused={hoveredId === endEntry.id}
tabIndex={0}
onSelect={handleSelect}
label={localize('com_ui_scroll_to_bottom')}
/>
@ -1350,6 +1605,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
<ChevronDown className="h-4 w-4" />
</button>
{tip &&
tipText !== '' &&
createPortal(
<div
ref={tipElRef}

View file

@ -191,6 +191,50 @@ function renderNav(messages: TestMessage[]) {
return { ...result, scrollable, content, scrollableRef };
}
/** The rail pins an origin rib above the scrolling column and a terminus rib
* below it, so `nav > div` is no longer the column and a nav-wide
* `[data-msg-id]` sweep includes both pinned ribs. */
const NAV_COLUMN_SELECTOR = '[data-message-nav-column]';
function getColumn(container: HTMLElement): HTMLDivElement {
return container.querySelector(NAV_COLUMN_SELECTOR) as HTMLDivElement;
}
function messageRibs(container: HTMLElement): HTMLElement[] {
return Array.from(getColumn(container).querySelectorAll<HTMLElement>('[data-msg-id]'));
}
/**
* Lay the ribs out on a fixed pitch.
*
* The rail hit-tests the pointer against the ribs' own `offsetTop`/`offsetHeight`
* — content-space values, since the column is their offset parent — so the rib
* under the pointer, the message named in the preview and the message a drag
* lands on all come from one measurement. jsdom reports 0 for both, which would
* collapse every rib onto the same point, so any test that drives pointer
* geometry has to declare the layout. Rows keep their own `offsetTop` from
* `buildDom`; own properties win over these prototype getters.
*/
function stubRibLayout(ids: string[], pitch = 12, height = 6): () => void {
const indexById = new Map(ids.map((id, i) => [id, i]));
const top = jest.spyOn(HTMLElement.prototype, 'offsetTop', 'get').mockImplementation(function (
this: HTMLElement,
) {
const id = this.getAttribute('data-msg-id');
const index = id != null ? indexById.get(id) : undefined;
return index != null ? index * pitch : 0;
});
const size = jest
.spyOn(HTMLElement.prototype, 'offsetHeight', 'get')
.mockImplementation(function (this: HTMLElement) {
return this.getAttribute('data-msg-id') != null ? height : 0;
});
return () => {
top.mockRestore();
size.mockRestore();
};
}
function clearDom() {
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
@ -243,7 +287,7 @@ describe('MessageNav', () => {
expect(nav).not.toBeNull();
expect(nav).toHaveAttribute('aria-label', 'com_ui_message_nav');
const indicators = container.querySelectorAll('[data-msg-id]');
const indicators = messageRibs(container);
expect(indicators).toHaveLength(3);
expect(Array.from(indicators).map((el) => el.getAttribute('data-msg-id'))).toEqual([
'a',
@ -281,7 +325,7 @@ describe('MessageNav', () => {
jest.advanceTimersByTime(250);
});
const indicators = container.querySelectorAll('[data-msg-id]');
const indicators = messageRibs(container);
expect(Array.from(indicators).map((el) => el.getAttribute('data-msg-id'))).toEqual([
'a',
'b',
@ -298,11 +342,36 @@ describe('MessageNav', () => {
buildMessage({ messageId: 'u2', text: 'more user', isCreatedByUser: true }),
];
const { container } = renderNav(messages);
const [userInd, assistantInd] = container.querySelectorAll('[data-msg-id]');
const userLine = userInd.querySelector('span');
const assistantLine = assistantInd.querySelector('span');
expect(userLine?.className).toContain('w-3');
expect(assistantLine?.className).toContain('w-3');
const [, assistantInd, userInd] = messageRibs(container);
const userLine = assistantInd.querySelector('span') as HTMLElement;
const assistantLine = userInd.querySelector('span') as HTMLElement;
expect(userLine.style.width).toBe('12px');
expect(assistantLine.style.width).toBe('12px');
});
it('gives the current rib a longer resting width than its neighbours', () => {
const messages = [
buildMessage({ messageId: 'u', text: 'user msg', isCreatedByUser: true }),
buildMessage({ messageId: 'a', text: 'assistant msg' }),
buildMessage({ messageId: 'u2', text: 'more user', isCreatedByUser: true }),
];
const { container } = renderNav(messages);
const [current, next] = messageRibs(container);
expect(current.getAttribute('aria-current')).toBe('true');
const currentWidth = parseFloat((current.querySelector('span') as HTMLElement).style.width);
const nextWidth = parseFloat((next.querySelector('span') as HTMLElement).style.width);
expect(currentWidth).toBeGreaterThan(nextWidth);
});
it('holds every rib row at a fixed height so the column cannot compress them', () => {
const messages = Array.from({ length: 6 }, (_, i) =>
buildMessage({ messageId: `m-${i}`, text: `message ${i}` }),
);
const { container } = renderNav(messages);
for (const rib of messageRibs(container)) {
expect(rib.className).toContain('shrink-0');
expect(rib.style.height).toBe('6px');
}
});
it('lights up only the in-viewport ribs at rest (no hover)', () => {
@ -465,9 +534,7 @@ describe('MessageNav', () => {
jest.advanceTimersByTime(250);
});
const ids = Array.from(container.querySelectorAll('[data-msg-id]')).map((el) =>
el.getAttribute('data-msg-id'),
);
const ids = messageRibs(container).map((el) => el.getAttribute('data-msg-id'));
expect(ids).toEqual(['u1', 'a1', 'steer-s1', 'u2', 'a2']);
const steerRib = container.querySelector('[data-msg-id="steer-s1"]');
@ -510,17 +577,16 @@ describe('MessageNav', () => {
jest.advanceTimersByTime(250);
});
const io = MockIntersectionObserver.last();
// Park the viewport between the response (300) and the steer's true
// content-space top (420). Read in content space the reader is inside a1;
// read with the steer's raw local offset (40) it is the last entry to have
// passed the viewport top, and the steer hijacks the current indicator.
(scrollable as HTMLElement).scrollTop = 350;
act(() => {
io!.trigger([
{ target: document.getElementById('a1')!, isIntersecting: true },
{ target: document.getElementById('steer-s1')!, isIntersecting: true },
]);
fireEvent.scroll(scrollable);
jest.advanceTimersByTime(32);
});
// Topmost-by-content-space is the response, not the steer with the smaller
// local offset. Before the chain-walk this landed on 'steer-s1'.
const current = container.querySelectorAll('[aria-current="true"]');
expect(current).toHaveLength(1);
expect(current[0]).toHaveAttribute('data-msg-id', 'a1');
@ -595,7 +661,7 @@ describe('MessageNav', () => {
buildMessage({ messageId: 'u2', text: 'follow-up', isCreatedByUser: true }),
];
const { container } = renderNav(messages);
const [userInd, assistantInd] = container.querySelectorAll('[data-msg-id]');
const [userInd, assistantInd] = messageRibs(container);
expect(userInd.getAttribute('aria-label')).toMatch(/^com_ui_message_nav_go_to_user\|/);
expect(userInd.getAttribute('aria-label')).toContain('hi there');
expect(assistantInd.getAttribute('aria-label')).toMatch(
@ -603,19 +669,18 @@ describe('MessageNav', () => {
);
});
it('sets aria-current on the active indicator after IntersectionObserver fires', () => {
it('sets aria-current on the entry the viewport has reached', () => {
const messages = [
buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }),
buildMessage({ messageId: 'b', text: 'bravo' }),
buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }),
];
const { container } = renderNav(messages);
const io = MockIntersectionObserver.last();
expect(io).toBeDefined();
const { container, scrollable } = renderNav(messages);
const target = document.getElementById('b');
/** Rows sit at 100/300/500; parking at 350 puts the reader inside 'b'. */
(scrollable as HTMLElement).scrollTop = 350;
act(() => {
io!.trigger([{ target: target!, isIntersecting: true }]);
fireEvent.scroll(scrollable);
jest.advanceTimersByTime(32);
});
@ -624,7 +689,7 @@ describe('MessageNav', () => {
expect(active).toHaveAttribute('data-msg-id', 'b');
});
it('sets aria-current only on the topmost visible indicator', () => {
it('marks exactly one entry current, and it is not every row on screen', () => {
const messages = [
buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }),
buildMessage({ messageId: 'b', text: 'bravo' }),
@ -648,7 +713,73 @@ describe('MessageNav', () => {
expect(current[0]).toHaveAttribute('data-msg-id', 'a');
const activeLine = container.querySelector('[aria-current="true"] span');
expect(activeLine?.className).toContain('bg-gray-800');
expect(activeLine?.className).toContain('bg-text-primary');
/** The other two rows are on screen, so they read as the in-view band —
* lit, but plainly not the mark that says where the reader is. */
const band = messageRibs(container)
.slice(1)
.map((rib) => rib.querySelector('span')?.className ?? '');
expect(band.every((c) => c.includes('bg-text-secondary'))).toBe(true);
});
it('lands current on the terminus once the thread is scrolled to the bottom', () => {
const messages = Array.from({ length: 4 }, (_, i) =>
buildMessage({ messageId: `m-${i}`, text: `message ${i}` }),
);
mockUseGetMessagesByConvoId.mockReturnValue({ data: messages });
const scrollable = document.createElement('div');
scrollable.className = 'scrollbar-gutter-stable';
Object.defineProperty(scrollable, 'clientHeight', { value: 600, configurable: true });
Object.defineProperty(scrollable, 'scrollHeight', { value: 3000, configurable: true });
Object.defineProperty(scrollable, 'scrollTop', {
value: 0,
writable: true,
configurable: true,
});
const content = document.createElement('div');
scrollable.appendChild(content);
for (let i = 0; i < messages.length; i++) {
const div = document.createElement('div');
div.id = messages[i].messageId;
div.className = 'message-render';
div.textContent = messages[i].text ?? '';
Object.defineProperty(div, 'offsetTop', { value: 100 + i * 200, configurable: true });
Object.defineProperty(div, 'offsetHeight', { value: 150, configurable: true });
content.appendChild(div);
}
/** A steer absorbed into the last response follows it in document order
* while sitting inside it, so before the scroll-spy the rail lit a rib
* two places short of the end while the reader sat at the very bottom. */
const steer = document.createElement('div');
steer.id = 'steer-s1';
steer.className = 'steer-render';
steer.textContent = 'mid-run steer';
Object.defineProperty(steer, 'offsetTop', { value: 60, configurable: true });
Object.defineProperty(steer, 'offsetHeight', { value: 40, configurable: true });
(content.lastElementChild as HTMLElement).appendChild(steer);
const end = document.createElement('div');
end.id = 'messages-end';
Object.defineProperty(end, 'offsetTop', { value: 2400, configurable: true });
Object.defineProperty(end, 'offsetHeight', { value: 0, configurable: true });
content.appendChild(end);
document.body.appendChild(scrollable);
const scrollableRef = { current: scrollable } as RefObject<HTMLDivElement>;
const { container } = render(<MessageNav scrollableRef={scrollableRef} />);
act(() => {
jest.advanceTimersByTime(250);
});
(scrollable as HTMLElement).scrollTop = 2400;
act(() => {
fireEvent.scroll(scrollable);
jest.advanceTimersByTime(32);
});
const current = container.querySelectorAll('[aria-current="true"]');
expect(current).toHaveLength(1);
expect(current[0]).toHaveAttribute('data-msg-id', 'messages-end');
});
it('chevron buttons expose a disabled state when there is nothing to navigate to', () => {
@ -691,7 +822,7 @@ describe('MessageNav', () => {
const { container } = renderNav(messages);
const rafSpy = jest.spyOn(window, 'requestAnimationFrame');
const target = container.querySelectorAll('[data-msg-id]')[2] as HTMLButtonElement;
const target = messageRibs(container)[2] as HTMLButtonElement;
act(() => {
fireEvent.click(target);
});
@ -706,7 +837,7 @@ describe('MessageNav', () => {
buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }),
];
const { container } = renderNav(messages);
const indicators = container.querySelectorAll('[data-msg-id]');
const indicators = messageRibs(container);
const steps: Array<(ts: number) => void> = [];
const rafSpy = jest
@ -741,7 +872,7 @@ describe('MessageNav', () => {
}),
);
const { container, scrollable } = renderNav(messages);
const column = container.querySelector('nav > div') as HTMLDivElement;
const column = getColumn(container);
let scrollHeightReads = 0;
Object.defineProperty(column, 'clientHeight', { value: 30, configurable: true });
@ -811,7 +942,7 @@ describe('MessageNav', () => {
jest.advanceTimersByTime(250);
});
const column = container.querySelector('nav > div') as HTMLDivElement;
const column = getColumn(container);
Object.defineProperty(column, 'clientHeight', { value: 30, configurable: true });
Object.defineProperty(column, 'scrollHeight', { value: 180, configurable: true });
Object.defineProperty(column, 'scrollTop', { value: 0, writable: true, configurable: true });
@ -867,8 +998,8 @@ describe('MessageNav', () => {
return steps.length;
});
const indA = navA.querySelectorAll('[data-msg-id]')[2] as HTMLButtonElement;
const indB = navB.querySelectorAll('[data-msg-id]')[2] as HTMLButtonElement;
const indA = messageRibs(navA)[2] as HTMLButtonElement;
const indB = messageRibs(navB)[2] as HTMLButtonElement;
act(() => {
fireEvent.click(indA);
@ -905,7 +1036,7 @@ describe('MessageNav', () => {
];
const { container } = renderNav(messages);
const indicator = container.querySelectorAll('[data-msg-id]')[1] as HTMLButtonElement;
const indicator = messageRibs(container)[1] as HTMLButtonElement;
act(() => {
fireEvent.click(indicator);
});
@ -921,11 +1052,11 @@ describe('MessageNav', () => {
buildMessage({ messageId: 'b', text: 'bravo' }),
buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }),
];
const { container } = renderNav(messages);
const { container, scrollable } = renderNav(messages);
const io = MockIntersectionObserver.last();
(scrollable as HTMLElement).scrollTop = 350;
act(() => {
io!.trigger([{ target: document.getElementById('b')!, isIntersecting: true }]);
fireEvent.scroll(scrollable);
jest.advanceTimersByTime(32);
});
@ -942,11 +1073,11 @@ describe('MessageNav', () => {
buildMessage({ messageId: 'b', text: 'bravo' }),
buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }),
];
const { container } = renderNav(messages);
const { container, scrollable } = renderNav(messages);
const io = MockIntersectionObserver.last();
(scrollable as HTMLElement).scrollTop = 350;
act(() => {
io!.trigger([{ target: document.getElementById('b')!, isIntersecting: true }]);
fireEvent.scroll(scrollable);
jest.advanceTimersByTime(32);
});
@ -1004,7 +1135,7 @@ describe('MessageNav', () => {
fireEvent.keyDown(document, { code: 'KeyM' });
});
const navButtons = container.querySelectorAll('[data-msg-id]');
const navButtons = messageRibs(container);
expect(Array.from(navButtons)).not.toContain(document.activeElement);
});
});
@ -1017,7 +1148,7 @@ describe('MessageNav', () => {
buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }),
];
const { container, scrollable } = renderNav(messages);
const column = container.querySelector('nav > div') as HTMLDivElement;
const column = getColumn(container);
column.getBoundingClientRect = () => ({ top: 0, bottom: 50, height: 50 }) as DOMRect;
const writes: number[] = [];
@ -1047,7 +1178,7 @@ describe('MessageNav', () => {
buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }),
];
const { container } = renderNav(messages);
const column = container.querySelector('nav > div') as HTMLDivElement;
const column = getColumn(container);
column.getBoundingClientRect = () => ({ top: 0, bottom: 50, height: 50 }) as DOMRect;
act(() => {
@ -1055,8 +1186,10 @@ describe('MessageNav', () => {
jest.advanceTimersByTime(20);
});
const ribs = Array.from(container.querySelectorAll('[data-msg-id]'));
const white = ribs.filter((r) => r.querySelector('span')?.className.includes('bg-gray-800'));
const ribs = messageRibs(container);
const white = ribs.filter((r) =>
r.querySelector('span')?.className.includes('bg-text-primary'),
);
expect(white).toHaveLength(1);
expect(white[0]).toHaveAttribute('data-msg-id', 'a');
});
@ -1070,7 +1203,7 @@ describe('MessageNav', () => {
buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }),
];
const result = renderNav(messages);
const column = result.container.querySelector('nav > div') as HTMLDivElement;
const column = getColumn(result.container);
column.getBoundingClientRect = () => ({ top: 0, bottom: 50, height: 50 }) as DOMRect;
return { ...result, column };
}
@ -1084,7 +1217,7 @@ describe('MessageNav', () => {
jest.advanceTimersByTime(80);
});
expect(ribA.querySelector('span')?.className).toContain('bg-gray-800');
expect(ribA.querySelector('span')?.className).toContain('bg-text-primary');
const tip = document.body.querySelector('[role="tooltip"]');
expect(tip).not.toBeNull();
expect(tip).toHaveTextContent('alpha');
@ -1106,10 +1239,14 @@ describe('MessageNav', () => {
});
expect(document.body.querySelector('[role="tooltip"]')).toBeNull();
const white = Array.from(container.querySelectorAll('[data-msg-id] span')).filter((s) =>
s.className.includes('bg-gray-800'),
/** Only the current rib keeps the strong tone; the previewed rib returns
* to the band. The rail must never lose its "you are here" mark just
* because focus left it. */
const strong = messageRibs(container).filter((rib) =>
rib.querySelector('span')?.className.includes('bg-text-primary'),
);
expect(white).toHaveLength(0);
expect(strong.map((rib) => rib.getAttribute('data-msg-id'))).toEqual(['a']);
expect(strong[0].getAttribute('aria-current')).toBe('true');
});
});
@ -1121,7 +1258,7 @@ describe('MessageNav', () => {
buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }),
];
const { container } = renderNav(messages);
const column = container.querySelector('nav > div') as HTMLDivElement;
const column = getColumn(container);
column.getBoundingClientRect = () => ({ top: 0, bottom: 50, height: 50 }) as DOMRect;
act(() => {
@ -1188,8 +1325,9 @@ describe('MessageNav', () => {
isCreatedByUser: i % 2 === 0,
}),
);
const restoreLayout = stubRibLayout(messages.map((m) => m.messageId));
const result = renderNav(messages);
const column = result.container.querySelector('nav > div') as HTMLDivElement;
const column = getColumn(result.container);
column.getBoundingClientRect = () => ({ top: 0, bottom: 50, height: 50 }) as DOMRect;
const ribs = Array.from(column.querySelectorAll('[data-msg-id]')) as HTMLElement[];
@ -1203,7 +1341,7 @@ describe('MessageNav', () => {
configurable: true,
});
return { ...result, column, ribs, writes };
return { ...result, column, ribs, writes, restoreLayout };
}
it('scrubs the conversation while dragging past the threshold', () => {
@ -1229,8 +1367,12 @@ describe('MessageNav', () => {
expect(writes.length).toBeGreaterThan(0);
});
it('maps the pointer proportionally across the full set of messages', () => {
const { column } = setupDraggableNav();
it('lands on the rib under the pointer, not a proportional index', () => {
// Ribs sit on a 12px pitch, so 25 is inside m-2 and 50 inside m-4. A
// mapping built from the pointer's fraction of the column's *visible*
// height across the *whole* rib list answers with neither once the rail
// scrolls, and disagrees with the preview, which is nearest-centre.
const { column, restoreLayout } = setupDraggableNav();
const getById = jest.spyOn(document, 'getElementById');
act(() => {
@ -1246,6 +1388,29 @@ describe('MessageNav', () => {
expect(getById.mock.calls.map((c) => c[0])).toContain('m-4');
getById.mockRestore();
restoreLayout();
});
it('keeps scrubbing honest once the rail has scrolled under the pointer', () => {
// The column carries its own scroll in a long thread. Reading the pointer
// in the column's content space is what keeps a scrolled rail pointing at
// the rib the reader can actually see.
const { column, restoreLayout } = setupDraggableNav();
Object.defineProperty(column, 'scrollTop', { value: 24, writable: true, configurable: true });
const getById = jest.spyOn(document, 'getElementById');
act(() => {
fireEvent.pointerDown(column, { pointerId: 1, button: 0, buttons: 1, clientY: 0 });
fireEvent.pointerMove(document, { pointerId: 1, buttons: 1, clientY: 15 });
});
/** contentY = 15 + 24 = 39, the centre of rib 3. Read without the rail's
* own scroll it would be 15 — the centre of rib 1, two messages off. */
const scrubbed = getById.mock.calls.map((c) => c[0]);
expect(scrubbed).toContain('m-3');
expect(scrubbed).not.toContain('m-1');
getById.mockRestore();
restoreLayout();
});
it('does not scrub for movement under the threshold', () => {
@ -1496,7 +1661,7 @@ describe('MessageNav', () => {
buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }),
];
const { container, scrollable } = renderNav(messages);
expect(container.querySelectorAll('[data-msg-id]')).toHaveLength(3);
expect(messageRibs(container)).toHaveLength(3);
const newMsg = document.createElement('div');
newMsg.id = 'd';
@ -1514,7 +1679,7 @@ describe('MessageNav', () => {
await Promise.resolve();
});
expect(container.querySelectorAll('[data-msg-id]')).toHaveLength(4);
expect(messageRibs(container)).toHaveLength(4);
expect(container.querySelector('[data-msg-id="d"]')).not.toBeNull();
});
});
@ -1585,15 +1750,17 @@ describe('MessageNav', () => {
it('appends a terminus indicator as the last rib when #messages-end exists', () => {
const { container } = renderNavWithEnd(threeMessages());
const ribs = container.querySelectorAll('[data-msg-id]');
expect(ribs).toHaveLength(4);
expect(ribs[3].getAttribute('data-msg-id')).toBe('messages-end');
expect(ribs[3].getAttribute('aria-label')).toBe('com_ui_scroll_to_bottom');
expect(ribs).toHaveLength(5);
expect(ribs[0].getAttribute('data-msg-id')).toBe('messages-start');
expect(ribs[0].getAttribute('aria-label')).toBe('com_ui_scroll_to_top');
expect(ribs[4].getAttribute('data-msg-id')).toBe('messages-end');
expect(ribs[4].getAttribute('aria-label')).toBe('com_ui_scroll_to_bottom');
});
it('pins the terminus outside the scrolling column, between it and the next chevron', () => {
const { container } = renderNavWithEnd(threeMessages());
const nav = container.querySelector('nav') as HTMLElement;
const column = container.querySelector('nav > div') as HTMLDivElement;
const column = getColumn(container);
expect(column.querySelector('[data-msg-id="messages-end"]')).toBeNull();
expect(nav.querySelector('[data-msg-id="messages-end"]')).not.toBeNull();
@ -1612,7 +1779,7 @@ describe('MessageNav', () => {
buildMessage({ messageId: `m-${i}`, text: `message ${i}`, isCreatedByUser: i % 2 === 0 }),
);
const { container } = renderNavWithEnd(messages);
const column = container.querySelector('nav > div') as HTMLDivElement;
const column = getColumn(container);
expect(column.children).toHaveLength(messages.length);
for (let i = 0; i < messages.length; i++) {
@ -1625,7 +1792,7 @@ describe('MessageNav', () => {
buildMessage({ messageId: `m-${i}`, text: `message ${i}`, isCreatedByUser: i % 2 === 0 }),
);
const { container, scrollable } = renderNavWithEnd(messages);
const column = container.querySelector('nav > div') as HTMLDivElement;
const column = getColumn(container);
Object.defineProperty(column, 'clientHeight', { value: 30, configurable: true });
Object.defineProperty(column, 'scrollHeight', { value: 200, configurable: true });
@ -1649,8 +1816,9 @@ describe('MessageNav', () => {
const messages = Array.from({ length: 5 }, (_, i) =>
buildMessage({ messageId: `m-${i}`, text: `message ${i}`, isCreatedByUser: i % 2 === 0 }),
);
const restoreLayout = stubRibLayout(messages.map((m) => m.messageId));
const { container } = renderNavWithEnd(messages);
const column = container.querySelector('nav > div') as HTMLDivElement;
const column = getColumn(container);
column.getBoundingClientRect = () => ({ top: 0, bottom: 50, height: 50 }) as DOMRect;
const getById = jest.spyOn(document, 'getElementById');
@ -1663,35 +1831,19 @@ describe('MessageNav', () => {
expect(scrubbed).toContain('m-2');
expect(scrubbed).not.toContain('m-3');
getById.mockRestore();
restoreLayout();
});
it('peaks the fisheye and preview on the rib under the pointer', () => {
const messages = Array.from({ length: 6 }, (_, i) =>
buildMessage({ messageId: `m-${i}`, text: `message ${i}` }),
);
const asRect = (top: number, height: number): DOMRect =>
({
top,
bottom: top + height,
height,
left: 200,
right: 214,
width: 14,
x: 200,
y: top,
toJSON: () => ({}),
}) as DOMRect;
/** Rib i occupies [i*12, i*12+6] — a 6px rib on a 6px gap. */
const rectSpy = jest
.spyOn(Element.prototype, 'getBoundingClientRect')
.mockImplementation(function (this: Element) {
const id = this.getAttribute?.('data-msg-id');
const index = id != null ? messages.findIndex((m) => m.messageId === id) : -1;
return index >= 0 ? asRect(index * 12, 6) : asRect(0, messages.length * 12);
});
const restoreLayout = stubRibLayout(messages.map((m) => m.messageId));
const { container } = renderNavWithEnd(messages);
const column = container.querySelector('nav > div') as HTMLDivElement;
const column = getColumn(container);
column.getBoundingClientRect = () =>
({ top: 0, bottom: messages.length * 12, height: messages.length * 12 }) as DOMRect;
act(() => {
fireEvent.pointerMove(column, { pointerId: 1, clientY: 3 * 12 + 3 });
@ -1699,19 +1851,22 @@ describe('MessageNav', () => {
});
expect(document.body.querySelector('[role="tooltip"]')).toHaveTextContent('message 3');
const highlighted = Array.from(container.querySelectorAll('[data-msg-id]')).filter((r) =>
r.querySelector('span')?.className.includes('bg-gray-800'),
const emphasized = messageRibs(container).filter((r) =>
r.querySelector('span')?.className.includes('bg-text-primary'),
);
expect(highlighted.map((r) => r.getAttribute('data-msg-id'))).toEqual(['m-3']);
rectSpy.mockRestore();
/** The previewed rib joins the current one at full strength; it does not
* replace it, so the rail never loses its "you are here" mark on hover. */
expect(emphasized.map((r) => r.getAttribute('data-msg-id'))).toEqual(['m-0', 'm-3']);
restoreLayout();
});
it('starts a scrub drag from the pinned terminus', () => {
const messages = Array.from({ length: 5 }, (_, i) =>
buildMessage({ messageId: `m-${i}`, text: `message ${i}`, isCreatedByUser: i % 2 === 0 }),
);
const restoreLayout = stubRibLayout(messages.map((m) => m.messageId));
const { container, scrollable } = renderNavWithEnd(messages);
const column = container.querySelector('nav > div') as HTMLDivElement;
const column = getColumn(container);
column.getBoundingClientRect = () => ({ top: 0, bottom: 50, height: 50 }) as DOMRect;
const wrapper = container.querySelector('[data-msg-id="messages-end"]')!
.parentElement as HTMLElement;
@ -1719,14 +1874,81 @@ describe('MessageNav', () => {
act(() => {
fireEvent.pointerDown(wrapper, { pointerId: 1, button: 0, buttons: 1, clientY: 60 });
fireEvent.pointerMove(document, { pointerId: 1, buttons: 1, clientY: 0 });
fireEvent.pointerMove(document, { pointerId: 1, buttons: 1, clientY: 3 });
});
expect(getById.mock.calls.map((c) => c[0])).toContain('m-0');
getById.mockRestore();
restoreLayout();
expect(scrollable).toBeDefined();
});
it('pins an origin rib above the column that scrolls the thread to the top', () => {
const { container, scrollable } = renderNavWithEnd(threeMessages());
const nav = container.querySelector('nav') as HTMLElement;
const column = getColumn(container);
const origin = container.querySelector('[data-msg-id="messages-start"]') as HTMLElement;
expect(column.querySelector('[data-msg-id="messages-start"]')).toBeNull();
const kids = Array.from(nav.children);
const originIndex = kids.findIndex((k) => k.querySelector('[data-msg-id="messages-start"]'));
const prevIndex = kids.findIndex(
(k) => k.getAttribute('aria-label') === 'com_ui_message_nav_previous',
);
expect(originIndex).toBe(prevIndex + 1);
expect(originIndex).toBe(kids.indexOf(column) - 1);
const scrollTo = jest.fn();
(scrollable as HTMLElement).scrollTo = scrollTo;
act(() => {
fireEvent.click(origin);
});
expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
});
it('previews the origin on hover', () => {
const { container } = renderNavWithEnd(threeMessages());
const wrapper = container.querySelector('[data-msg-id="messages-start"]')!
.parentElement as HTMLElement;
act(() => {
fireEvent.pointerEnter(wrapper, { pointerId: 1, clientY: 5 });
jest.advanceTimersByTime(80);
});
expect(document.body.querySelector('[role="tooltip"]')).toHaveTextContent(
'com_ui_scroll_to_top',
);
});
it('leaves the origin out of the current mark, so only one entry is current', () => {
const { container } = renderNavWithEnd(threeMessages());
expect(container.querySelectorAll('[aria-current="true"]')).toHaveLength(1);
expect(
container.querySelector('[data-msg-id="messages-start"]')?.getAttribute('aria-current'),
).toBeNull();
});
it('moves the tab stop to the last message once the reader reaches the bottom', () => {
const { container, scrollable } = renderNavWithEnd(
Array.from({ length: 5 }, (_, i) =>
buildMessage({ messageId: `m-${i}`, text: `message ${i}` }),
),
);
(scrollable as HTMLElement).scrollTop = 2400;
act(() => {
fireEvent.scroll(scrollable);
jest.advanceTimersByTime(32);
});
expect(container.querySelector('[aria-current="true"]')?.getAttribute('data-msg-id')).toBe(
'messages-end',
);
const stops = messageRibs(container).filter((rib) => rib.getAttribute('tabindex') === '0');
expect(stops).toHaveLength(1);
expect(stops[0].getAttribute('data-msg-id')).toBe('m-4');
});
it('previews the terminus on hover even though it sits outside the column', () => {
const { container } = renderNavWithEnd(threeMessages());
const wrapper = container.querySelector('[data-msg-id="messages-end"]')!
@ -1824,7 +2046,7 @@ describe('MessageNav', () => {
}),
);
const { container, scrollable } = renderNavWithEnd(messages);
const column = container.querySelector('nav > div') as HTMLDivElement;
const column = getColumn(container);
column.getBoundingClientRect = () => ({ top: 0, bottom: 50, height: 50 }) as DOMRect;
const qs = jest.spyOn(scrollable, 'querySelector');
@ -1881,4 +2103,148 @@ describe('MessageNav', () => {
qsB.mockRestore();
});
});
describe('a pending response', () => {
function streamingMessages(): TestMessage[] {
return [
buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }),
buildMessage({ messageId: 'b', text: 'bravo' }),
buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }),
/** A response mounts its row a frame before its first token. */
buildMessage({ messageId: 'd', text: '', content: [] }),
];
}
it('names the pending state instead of labelling the rib with nothing', () => {
const { container } = renderNav(streamingMessages());
const pending = container.querySelector('[data-msg-id="d"]') as HTMLElement;
expect(pending.getAttribute('aria-label')).toBe(
'com_ui_message_nav_go_to_assistant|{"0":"com_ui_generating"}',
);
});
it('never opens an empty preview beside a rib with no text yet', () => {
const messages = streamingMessages();
const restoreLayout = stubRibLayout(messages.map((m) => m.messageId));
const { container } = renderNav(messages);
const column = getColumn(container);
column.getBoundingClientRect = () => ({ top: 0, bottom: 50, height: 50 }) as DOMRect;
act(() => {
fireEvent.pointerMove(column, { pointerId: 1, clientY: 3 * 12 + 3 });
jest.advanceTimersByTime(80);
});
const tip = document.body.querySelector('[role="tooltip"]');
expect(tip).not.toBeNull();
expect(tip).toHaveTextContent('com_ui_generating');
restoreLayout();
});
});
describe('rail navigation', () => {
function renderRail(count = 6) {
const messages = Array.from({ length: count }, (_, i) =>
buildMessage({ messageId: `m-${i}`, text: `message ${i}`, isCreatedByUser: i % 2 === 0 }),
);
const result = renderNav(messages);
return { ...result, messages };
}
it('puts exactly one rib of the column in the tab order, and it is the current one', () => {
const { container } = renderRail();
const ribs = messageRibs(container);
const stops = ribs.filter((rib) => rib.getAttribute('tabindex') === '0');
expect(stops).toHaveLength(1);
expect(stops[0].getAttribute('aria-current')).toBe('true');
});
it('walks the ribs with the arrow keys and jumps to the ends with Home and End', () => {
const { container } = renderRail();
const column = getColumn(container);
const ribs = messageRibs(container);
ribs[0].focus();
act(() => {
fireEvent.keyDown(column, { key: 'ArrowDown' });
});
expect(document.activeElement).toBe(ribs[1]);
act(() => {
fireEvent.keyDown(column, { key: 'ArrowUp' });
});
expect(document.activeElement).toBe(ribs[0]);
act(() => {
fireEvent.keyDown(column, { key: 'End' });
});
expect(document.activeElement).toBe(ribs[ribs.length - 1]);
act(() => {
fireEvent.keyDown(column, { key: 'Home' });
});
expect(document.activeElement).toBe(ribs[0]);
});
it('does not run off either end of the rail', () => {
const { container } = renderRail();
const column = getColumn(container);
const ribs = messageRibs(container);
ribs[0].focus();
act(() => {
fireEvent.keyDown(column, { key: 'ArrowUp' });
});
expect(document.activeElement).toBe(ribs[0]);
ribs[ribs.length - 1].focus();
act(() => {
fireEvent.keyDown(column, { key: 'ArrowDown' });
});
expect(document.activeElement).toBe(ribs[ribs.length - 1]);
});
it('holds the rail still while the pointer is working it, and follows again after', () => {
const messages = Array.from({ length: 10 }, (_, i) =>
buildMessage({ messageId: `m-${i}`, text: `message ${i}`, isCreatedByUser: i % 2 === 0 }),
);
const restoreLayout = stubRibLayout(messages.map((m) => m.messageId));
mockUseGetMessagesByConvoId.mockReturnValue({ data: messages });
const { scrollable } = buildDom(messages);
const scrollableRef = { current: scrollable } as RefObject<HTMLDivElement>;
const { container } = render(<MessageNav scrollableRef={scrollableRef} />);
act(() => {
jest.advanceTimersByTime(250);
});
const column = getColumn(container);
column.getBoundingClientRect = () => ({ top: 0, bottom: 30, height: 30 }) as DOMRect;
Object.defineProperty(column, 'clientHeight', { value: 30, configurable: true });
Object.defineProperty(column, 'scrollHeight', { value: 200, configurable: true });
Object.defineProperty(column, 'scrollTop', { value: 0, writable: true, configurable: true });
/** The reader browses the rail itself to reach a distant part of a thread
* too long for one column. */
act(() => {
fireEvent.pointerMove(column, { pointerId: 1, clientY: 10 });
});
column.scrollTop = 90;
(scrollable as HTMLElement).scrollTop = 400;
act(() => {
fireEvent.scroll(scrollable);
jest.advanceTimersByTime(32);
});
expect(column.scrollTop).toBe(90);
act(() => {
fireEvent.pointerLeave(column, { pointerId: 1 });
});
(scrollable as HTMLElement).scrollTop = 600;
act(() => {
fireEvent.scroll(scrollable);
jest.advanceTimersByTime(32);
});
expect(column.scrollTop).not.toBe(90);
restoreLayout();
});
});
});

View file

@ -1945,6 +1945,7 @@
"com_ui_scroll_left": "Scroll left",
"com_ui_scroll_right": "Scroll right",
"com_ui_scroll_to_bottom": "Scroll to bottom",
"com_ui_scroll_to_top": "Scroll to top",
"com_ui_search": "Search",
"com_ui_search_above_to_add": "Search above to add users or groups",
"com_ui_search_above_to_add_all": "Search above to add users, groups, or roles",