From 3d808dc9062fe84a75a293f1fb717eedb5b226b1 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 27 Aug 2026 06:45:57 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=97=20fix:=20Anchor=20Message=20Nav=20?= =?UTF-8?q?Gestures=20to=20One=20Measured=20Rib=20Layout=20(#15272)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🧭 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. * 🧭 fix: Address Codex Round 1 on the Message Rail All three findings were real. An empty preview is not evidence of generation. `buildEntry` returns nothing for image-, tool-call- and reasoning-only content too, so a reopened thread labelled those settled rows "Generating" forever. Only the tail of a live submission earns that wording now; every other text-free row first reads the text its rendered card already puts on screen, and falls back to an explicit "No preview" rather than a blank label and an empty preview panel. A roving tab stop has to travel with focus. Deriving `tabIndex=0` from the scroll-spy's current rib alone left a second stop behind the moment an arrow key moved focus, so Tab re-entered the rail it had just left and Shift+Tab walked backwards into it instead of out. And a snap point only exists inside the range the container can reach. The rows carry `scroll-margin-top: 4rem` against `pt-14` of content padding, so the first entry's raw snap point is -8px; compared unclamped it read as "there is still something above you" at the top of every conversation. That kept the up chevron live with nowhere to go — a defect that predates this branch — and the new origin rib inherited it, rendering as out of view while the reader sat at the absolute top. `snapPointFor` now clamps, and the jump helpers share it so they answer that question the same way the chevrons do. Re-verified against the real layout: the harness now reproduces the 56px padding and 64px scroll margin exactly, where the previous run had masked the negative snap. * 🧭 fix: Address Codex Round 2 on the Message Rail Two new findings, both real, both introduced by this branch. The row fallback was reading chrome. An assistant row renders a VISIBLE `h2` naming the sender, so the fallback added for settled tool-call and image-only rows handed back "Claude" for a response that had not produced a token yet — inventing a preview and, worse, masking the pending state the previous commit had just added. It now reads the row's message body, which is empty for a freshly mounted response and carries the card text for a settled one. And the hit test went stale whenever the rail scrolled. Making the rail wheel-browsable meant the ribs can move without the pointer moving, but the pointer's position was cached already converted into the column's content space. The preview — and the id a click in the gaps follows — stayed on the rib that used to be there. The pointer is stored as a viewport coordinate now and converted at the moment it is used, with a scroll handler on the column to redo the test when the ribs move underneath it. Pending also narrowed to responses. Between sending and the reply's row mounting, the reader's own turn is the last entry, and a submission in flight is not evidence that the user's message is the thing being generated. The three findings repeated from round one were already fixed in 51180fae7b; codex re-reviews the whole diff rather than the increment. Re-verified each in a browser against the real layout, including the row chrome this round adds to the harness. --- .../components/Chat/Messages/MessageNav.tsx | 614 +++++++++++--- .../Messages/__tests__/MessageNav.spec.tsx | 770 +++++++++++++++--- client/src/locales/en/translation.json | 2 + 3 files changed, 1162 insertions(+), 224 deletions(-) diff --git a/client/src/components/Chat/Messages/MessageNav.tsx b/client/src/components/Chat/Messages/MessageNav.tsx index 7ca8178a16..58131cdbb2 100644 --- a/client/src/components/Chat/Messages/MessageNav.tsx +++ b/client/src/components/Chat/Messages/MessageNav.tsx @@ -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) { @@ -36,13 +44,37 @@ export function extractPreviewFromContent(content?: TMessageContentParts[]): str return ''; } -export function buildEntry(id: string, msg: TMessage): MessageEntry { +const PREVIEW_LIMIT = 80; + +function truncatePreview(text: string): string { + return text.slice(0, PREVIEW_LIMIT) + (text.length > PREVIEW_LIMIT ? '...' : ''); +} + +/** The row's message body, without its header or footer. An assistant row + * renders a VISIBLE `h2` naming the sender, so reading the whole row hands + * back "Claude" for a response that has not produced a token yet — chrome + * masquerading as content, and worse, masking the pending state entirely. */ +const MESSAGE_BODY_SELECTOR = '[data-testid="message-body"]'; + +/** What a row actually says on screen, for entries whose message carries no + * text part of its own. */ +function rowText(node: HTMLElement): string { + const body = node.querySelector(MESSAGE_BODY_SELECTOR); + return ((body ?? node).textContent ?? '').trim(); +} + +export function buildEntry(id: string, msg: TMessage, node?: HTMLElement): MessageEntry { const raw = msg.text?.trim() ? msg.text : extractPreviewFromContent(msg.content); const trimmed = raw.trim(); + /** Image-, tool-call- and reasoning-only messages carry no text part at all, + * so the message alone yields nothing to say. Their rendered body does say + * something, and reading it is what keeps a settled message from being + * mistaken for one that is still generating. */ + const preview = trimmed === '' && node ? rowText(node) : trimmed; return { id, isUser: !!msg.isCreatedByUser, - preview: trimmed.slice(0, 80) + (trimmed.length > 80 ? '...' : ''), + preview: truncatePreview(preview), }; } @@ -70,11 +102,10 @@ function containsEntryNode(node: HTMLElement): boolean { export function buildFallbackEntry(node: HTMLElement, id: string): MessageEntry { const isUser = node.querySelector(USER_TURN_SELECTOR) != null; - const trimmed = (node.textContent ?? '').trim(); return { id, isUser, - preview: trimmed.slice(0, 80) + (trimmed.length > 80 ? '...' : ''), + preview: truncatePreview(rowText(node)), }; } @@ -89,10 +120,39 @@ export function buildSteerEntry(node: HTMLElement, id: string): MessageEntry { return { id, isUser: true, - preview: raw.slice(0, 80) + (raw.length > 80 ? '...' : ''), + preview: truncatePreview(raw), }; } +type LocalizeFn = ReturnType; + +/** + * 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, so falling through to the raw preview there labelled + * the rib with nothing and opened an empty preview card beside it. Only the + * tail of a live submission earns the pending wording, though: an empty preview + * is not evidence of generation, and a reopened thread whose rows are settled + * would otherwise announce "Generating" forever. + */ +export function previewTextFor( + entry: MessageEntry, + localize: LocalizeFn, + isPending = false, +): string { + if (entry.isStart === true) { + return localize('com_ui_scroll_to_top'); + } + if (entry.isEnd === true) { + return localize('com_ui_scroll_to_bottom'); + } + if (entry.preview !== '') { + return entry.preview; + } + return localize(isPending ? 'com_ui_generating' : 'com_ui_message_nav_no_preview'); +} + function getMessageEntries(root: ParentNode, messagesById: Map): MessageEntry[] { const nodes = root.querySelectorAll(ENTRY_NODE_SELECTOR); const entries: MessageEntry[] = []; @@ -109,7 +169,7 @@ function getMessageEntries(root: ParentNode, messagesById: Map continue; } const msg = messagesById.get(id); - entries.push(msg ? buildEntry(id, msg) : buildFallbackEntry(node, id)); + entries.push(msg ? buildEntry(id, msg, node) : buildFallbackEntry(node, id)); } if (entries.length > 0 && root.querySelector('#' + MESSAGES_END_ID)) { entries.push({ id: MESSAGES_END_ID, isUser: false, preview: '', isEnd: true }); @@ -134,6 +194,20 @@ function readScrollMargin(el: HTMLElement | null): number { return Number.isFinite(value) ? value : 0; } +/** + * Where the container lands when an entry is snapped to the top, clamped to the + * range it can actually reach. + * + * The rows carry `scroll-margin-top: 4rem` against `pt-14` of content padding, + * so the first entry's raw snap point is -8px. Compared unclamped it reads as + * "there is still something above you" at the very top of every conversation, + * which left the up chevron live with nowhere to go and the origin rib showing + * as out of view while the reader sat at the top. + */ +function snapPointFor(top: number, scrollMargin: number, maxScrollTop: number): number { + return Math.max(0, Math.min(top - scrollMargin, maxScrollTop)); +} + function computeTargetScroll( container: HTMLElement, el: HTMLElement, @@ -175,14 +249,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 +280,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 +294,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 ( ); @@ -289,11 +400,21 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject 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 pointerYRef = useRef(null); + const measuredCountRef = useRef(-1); + const measuredCurrentRef = useRef(undefined); + /** The pointer's VIEWPORT y, converted to the column's content space only at + * the moment it is used. Caching the converted value goes stale the instant + * the rail is wheel-scrolled under a stationary pointer, leaving the preview + * — and the id a click in the gaps follows — on the rib that used to be + * there. */ + const pointerClientYRef = useRef(null); const magRafRef = useRef(null); const reducedMotionRef = useRef(false); const focusedIdRef = useRef(null); @@ -304,46 +425,95 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject(null); const [hoveredId, setHoveredId] = useState(null); + /** The rib the keyboard last landed on. A roving tab stop has to travel with + * focus, not sit on the scroll-spy's current rib: leave it behind and the + * column holds two tab stops, so Tab re-enters the rail it just left and + * Shift+Tab walks back into it instead of out. Distinct from `hoveredId`, + * which the pointer also drives — the tab order must not follow the mouse. */ + const [focusedRibId, setFocusedRibId] = useState(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(); 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 +521,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { + if (id === MESSAGES_START_ID) { + return null; + } if (id === MESSAGES_END_ID) { return scrollableRef.current?.querySelector('#' + MESSAGES_END_ID) ?? null; } @@ -395,10 +568,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { if (refreshTimerRef.current) { @@ -426,6 +598,11 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { + if (id === MESSAGES_START_ID) { + scrollTokenRef.current++; + scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); + return; + } const el = resolveEntryEl(id); if (!el) { return; @@ -458,11 +635,19 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { + 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 +660,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { @@ -496,7 +681,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { + 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 +752,22 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject= rect.bottom) { scrollToImmediate(MESSAGES_END_ID); return; } - const ribs = col.querySelectorAll('[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 +777,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { @@ -570,6 +787,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { @@ -632,34 +852,6 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject 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 +863,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { tipPosRef.current = { top, right }; @@ -732,28 +924,38 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { + /** 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) => showEndTip(e.currentTarget), - [showEndTip], + (e: React.PointerEvent) => showTerminusTip(e.currentTarget, MESSAGES_END_ID), + [showTerminusTip], ); const handleEndFocus = useCallback( - (e: React.FocusEvent) => showEndTip(e.currentTarget), - [showEndTip], + (e: React.FocusEvent) => showTerminusTip(e.currentTarget, MESSAGES_END_ID), + [showTerminusTip], ); - const handleEndBlur = useCallback( + const handleStartPointerEnter = useCallback( + (e: React.PointerEvent) => showTerminusTip(e.currentTarget, MESSAGES_START_ID), + [showTerminusTip], + ); + + const handleStartFocus = useCallback( + (e: React.FocusEvent) => showTerminusTip(e.currentTarget, MESSAGES_START_ID), + [showTerminusTip], + ); + + const handleTerminusBlur = useCallback( (e: React.FocusEvent) => { const next = e.relatedTarget as Node | null; if (next && e.currentTarget.contains(next)) { @@ -766,15 +968,17 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { magRafRef.current = null; + ensureRibLayout(); const col = columnRef.current; const layout = ribLayoutRef.current; - const py = pointerYRef.current; - if (!col || py == null || layout.length === 0) { + const clientY = pointerClientYRef.current; + if (!col || clientY == null || layout.length === 0) { return; } const reduce = reducedMotionRef.current; const colRect = col.getBoundingClientRect(); const scrollTop = col.scrollTop; + const py = clientY - colRect.top + scrollTop; let nearestId: string | null = null; let nearestD = Number.POSITIVE_INFINITY; let nearestCenter = 0; @@ -802,17 +1006,21 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { + 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) => { @@ -820,8 +1028,8 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { + if (pointerClientYRef.current == null) { + return; + } + if (magRafRef.current == null) { + magRafRef.current = requestAnimationFrame(applyMagnify); + } + }, [applyMagnify]); + const handlePointerLeave = useCallback(() => { - pointerYRef.current = null; + pointerClientYRef.current = null; + if (!isDraggingRef.current) { + interactingRef.current = false; + } if (magRafRef.current != null) { cancelAnimationFrame(magRafRef.current); magRafRef.current = null; @@ -843,12 +1066,14 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject) => { const col = columnRef.current; const target = e.target as HTMLElement; - if (!col || !target.getAttribute?.('data-msg-id')) { + const id = target.getAttribute?.('data-msg-id'); + if (!col || id == null) { return; } - const colRect = col.getBoundingClientRect(); + setFocusedRibId(id); + interactingRef.current = true; const rect = target.getBoundingClientRect(); - pointerYRef.current = rect.top - colRect.top + col.scrollTop + rect.height / 2; + pointerClientYRef.current = rect.top + rect.height / 2; if (magRafRef.current == null) { magRafRef.current = requestAnimationFrame(applyMagnify); } @@ -863,6 +1088,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject scrollTop + JUMP_EPS) { + if (offsetsTop[i] === Number.POSITIVE_INFINITY) { + continue; + } + const snap = snapPointFor(offsetsTop[i], scrollMargin, containerMaxScrollTop); + 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 +1312,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { pendingFrame = null; - const nextCurrentId = getCurrentVisibleId(); - setCurrentId((prev) => (prev === nextCurrentId ? prev : nextCurrentId)); setVisibleIds((prev) => { if (prev.size === visibleSet.size) { let same = true; @@ -1148,7 +1405,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { const observer = observerRef.current; @@ -1196,10 +1453,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { const container = scrollableRef.current; @@ -1207,6 +1463,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject scrollTop + JUMP_EPS) { + if ( + snapPointFor(entryTop(el, container), scrollMargin, maxScrollTop) > + scrollTop + JUMP_EPS + ) { scrollToStart(entries[i].id); return; } } }, [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) => { + const col = columnRef.current; + if (!col) { + return; + } + const { key } = e; + if (key !== 'ArrowUp' && key !== 'ArrowDown' && key !== 'Home' && key !== 'End') { + return; + } + const ribs = col.querySelectorAll('[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')) { @@ -1262,10 +1563,32 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject + {startEntry && ( +
+ +
+ )} +
{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, entry.id === pendingId).slice(0, 30) }, ); - const isHighlighted = - hoveredId != null ? hoveredId === entry.id : visibleIds.has(entry.id); return ( @@ -1326,14 +1673,14 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject @@ -1350,6 +1697,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject {tip && + tipText !== '' && createPortal(
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('[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 +288,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 +326,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 +343,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 +535,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 +578,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 +662,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 +670,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 +690,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 +714,114 @@ 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; + const { container } = render(); + 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('disables the up chevron at the top even when the rows out-margin the padding', () => { + // `.message-render` carries `scroll-margin-top: 4rem` against `pt-14` of + // content padding, so the first entry's raw snap point is -8px. Compared + // unclamped it reads as "there is still something above you" at the very + // top of every conversation. + const messages = [ + buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }), + buildMessage({ messageId: 'b', text: 'bravo' }), + buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }), + ]; + mockUseGetMessagesByConvoId.mockReturnValue({ data: messages }); + const { scrollable, content } = buildDom(messages); + /** Mirror the real layout: content padding 56px, row scroll margin 64px. */ + for (let i = 0; i < content.children.length; i++) { + Object.defineProperty(content.children[i], 'offsetTop', { + value: 56 + i * 200, + configurable: true, + }); + } + const marginSpy = jest + .spyOn(window, 'getComputedStyle') + .mockImplementation(() => ({ scrollMarginTop: '64px' }) as CSSStyleDeclaration); + + const scrollableRef = { current: scrollable } as RefObject; + const { container } = render(); + act(() => { + jest.advanceTimersByTime(250); + fireEvent.scroll(scrollable); + jest.advanceTimersByTime(32); + }); + + const prev = container.querySelector( + 'button[aria-label="com_ui_message_nav_previous"]', + ) as HTMLButtonElement; + expect(prev.disabled).toBe(true); + /** And the origin rib reads as reached, not as somewhere still to go. */ + const origin = container.querySelector('[data-msg-id="messages-start"] span'); + expect(origin?.className).not.toContain('bg-text-tertiary'); + marginSpy.mockRestore(); }); it('chevron buttons expose a disabled state when there is nothing to navigate to', () => { @@ -691,7 +864,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 +879,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 +914,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 +984,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 +1040,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 +1078,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 +1094,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 +1115,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 +1177,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 +1190,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 +1220,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 +1228,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 +1245,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 +1259,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 +1281,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 +1300,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 +1367,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 +1383,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 +1409,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 +1430,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 +1703,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 +1721,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 +1792,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 +1821,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 +1834,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 +1858,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 +1873,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 +1893,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 +1916,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 +2088,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 +2145,328 @@ 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', () => { + mockUseMessagesSubmission.mockReturnValue({ isSubmitting: true }); + 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', () => { + mockUseMessagesSubmission.mockReturnValue({ isSubmitting: true }); + 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(); + }); + + it('does not call a settled message generating just because it has no text', () => { + // Image-, tool-call- and reasoning-only messages carry no text part, so + // reopening a finished thread would otherwise announce "Generating" + // against them forever. + mockUseMessagesSubmission.mockReturnValue({ isSubmitting: false }); + const { container } = renderNav(streamingMessages()); + const settled = container.querySelector('[data-msg-id="d"]') as HTMLElement; + expect(settled.getAttribute('aria-label')).toBe( + 'com_ui_message_nav_go_to_assistant|{"0":"com_ui_message_nav_no_preview"}', + ); + }); + + it('only the tail of a live submission is pending, never an earlier gap', () => { + mockUseMessagesSubmission.mockReturnValue({ isSubmitting: true }); + const { container } = renderNav([ + buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }), + /** A tool-call-only response part way up the thread. */ + buildMessage({ messageId: 'b', text: '', content: [] }), + buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }), + buildMessage({ messageId: 'd', text: '', content: [] }), + ]); + expect(container.querySelector('[data-msg-id="b"]')?.getAttribute('aria-label')).toContain( + 'com_ui_message_nav_no_preview', + ); + expect(container.querySelector('[data-msg-id="d"]')?.getAttribute('aria-label')).toContain( + 'com_ui_generating', + ); + }); + + it('reads the body, not the row chrome, so a pending row stays pending', () => { + // An assistant row renders a VISIBLE h2 naming the sender before its + // first token lands. Reading the whole row hands back that name, which + // both invents a preview and masks the pending state entirely. + const node = document.createElement('div'); + node.className = 'message-render'; + const header = document.createElement('h2'); + header.textContent = 'Claude'; + const body = document.createElement('div'); + body.setAttribute('data-testid', 'message-body'); + node.append(header, body); + + const entry = buildEntry( + 'a', + asTMessage(buildMessage({ messageId: 'a', text: '', content: [] })), + node, + ); + expect(entry.preview).toBe(''); + expect(previewTextFor(entry, (k: string) => k, true)).toBe('com_ui_generating'); + }); + + it('does not call the user turn generating while the reply has yet to mount', () => { + // Between sending and the response's row mounting, the user's own turn is + // the last entry; a submission in flight is not evidence that it is the + // thing being generated. + mockUseMessagesSubmission.mockReturnValue({ isSubmitting: true }); + const { container } = renderNav([ + buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }), + buildMessage({ messageId: 'b', text: 'bravo' }), + /** An image-only turn the reader just sent: no text part at all. */ + buildMessage({ messageId: 'c', text: '', content: [], isCreatedByUser: true }), + ]); + const last = container.querySelector('[data-msg-id="c"]'); + expect(last?.getAttribute('aria-label')).toContain('com_ui_message_nav_no_preview'); + expect(last?.getAttribute('aria-label')).not.toContain('com_ui_generating'); + }); + + it('reads the rendered row when the message itself carries no text part', () => { + // A tool card still says something on screen; that is a better preview + // than any placeholder. + const node = document.createElement('div'); + node.className = 'message-render'; + node.textContent = 'Searched the web'; + const entry = buildEntry( + 'a', + asTMessage(buildMessage({ messageId: 'a', text: '', content: [] })), + node, + ); + expect(entry.preview).toBe('Searched the web'); + }); + + it('prefers the body over the header when the row has both', () => { + const node = document.createElement('div'); + node.className = 'message-render'; + const header = document.createElement('h2'); + header.textContent = 'Claude'; + const body = document.createElement('div'); + body.setAttribute('data-testid', 'message-body'); + body.textContent = 'Searched the web'; + node.append(header, body); + const entry = buildEntry( + 'a', + asTMessage(buildMessage({ messageId: 'a', text: '', content: [] })), + node, + ); + expect(entry.preview).toBe('Searched the web'); + }); + }); + + 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('moves the tab stop with arrow-key focus so the column keeps one stop', () => { + // A roving tab stop left behind on the scroll-spy's current rib gives the + // column two stops: Tab re-enters the rail it just left, and Shift+Tab + // walks back into it instead of out. + const { container } = renderRail(); + const column = getColumn(container); + const ribs = messageRibs(container); + expect(ribs[0].getAttribute('tabindex')).toBe('0'); + + ribs[0].focus(); + act(() => { + fireEvent.keyDown(column, { key: 'ArrowDown' }); + fireEvent.keyDown(column, { key: 'ArrowDown' }); + }); + + expect(document.activeElement).toBe(ribs[2]); + const stops = messageRibs(container).filter((rib) => rib.getAttribute('tabindex') === '0'); + expect(stops).toHaveLength(1); + expect(stops[0]).toBe(ribs[2]); + }); + + it('returns the tab stop to the current rib once focus leaves the rail', () => { + const { container } = renderRail(); + const column = getColumn(container); + const ribs = messageRibs(container); + + ribs[0].focus(); + act(() => { + fireEvent.keyDown(column, { key: 'End' }); + }); + expect(messageRibs(container).filter((r) => r.getAttribute('tabindex') === '0')[0]).toBe( + ribs[ribs.length - 1], + ); + + act(() => { + fireEvent.blur(column, { relatedTarget: document.body }); + }); + + const stops = messageRibs(container).filter((rib) => rib.getAttribute('tabindex') === '0'); + expect(stops).toHaveLength(1); + expect(stops[0].getAttribute('aria-current')).toBe('true'); + }); + + 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('redoes the hit test when the rail scrolls under a stationary pointer', () => { + // Wheeling an overflowing rail moves the ribs without moving the pointer. + // A cached content-space coordinate goes stale the moment it does, so the + // preview — and the id a click in the gaps follows — stays on the rib that + // used to be there. + const messages = Array.from({ length: 10 }, (_, i) => + buildMessage({ messageId: `m-${i}`, text: `message ${i}` }), + ); + const restoreLayout = stubRibLayout(messages.map((m) => m.messageId)); + const { container } = renderNav(messages); + const column = getColumn(container); + column.getBoundingClientRect = () => ({ top: 0, bottom: 40, height: 40 }) as DOMRect; + Object.defineProperty(column, 'scrollTop', { value: 0, writable: true, configurable: true }); + + act(() => { + fireEvent.pointerMove(column, { pointerId: 1, clientY: 15 }); + jest.advanceTimersByTime(80); + }); + expect(document.body.querySelector('[role="tooltip"]')).toHaveTextContent('message 1'); + + /** The pointer has not moved; the rail has scrolled two ribs under it. */ + column.scrollTop = 24; + act(() => { + fireEvent.scroll(column); + jest.advanceTimersByTime(80); + }); + expect(document.body.querySelector('[role="tooltip"]')).toHaveTextContent('message 3'); + + /** And a click in the gaps follows the rib now under the pointer. */ + const getById = jest.spyOn(document, 'getElementById'); + act(() => { + fireEvent.click(column); + }); + expect(getById.mock.calls.map((c) => c[0])).toContain('m-3'); + getById.mockRestore(); + restoreLayout(); + }); + + 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; + const { container } = render(); + 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(); + }); + }); }); diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index a9d83109e1..b581c965b9 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1590,6 +1590,7 @@ "com_ui_message_nav_go_to_assistant": "Go to assistant message: {{0}}", "com_ui_message_nav_go_to_user": "Go to user message: {{0}}", "com_ui_message_nav_next": "Navigate to next message", + "com_ui_message_nav_no_preview": "No preview", "com_ui_message_nav_previous": "Navigate to previous message", "com_ui_message_part_empty": "Message content cannot be empty.", "com_ui_method": "Method", @@ -1945,6 +1946,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",