diff --git a/client/src/components/Chat/Messages/MessageNav.tsx b/client/src/components/Chat/Messages/MessageNav.tsx index 7ca8178a16..6bb9bd331e 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) { @@ -93,6 +101,27 @@ export function buildSteerEntry(node: HTMLElement, id: string): MessageEntry { }; } +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 — 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): MessageEntry[] { const nodes = root.querySelectorAll(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 ( ); @@ -289,10 +355,15 @@ 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 measuredCountRef = useRef(-1); + const measuredCurrentRef = useRef(undefined); const pointerYRef = useRef(null); const magRafRef = useRef(null); const reducedMotionRef = useRef(false); @@ -305,45 +376,88 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject(null); const [hoveredId, setHoveredId] = 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 +465,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 +512,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { if (refreshTimerRef.current) { @@ -426,6 +542,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 +579,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 +604,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { @@ -496,7 +625,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 +696,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 +721,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { @@ -570,6 +731,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { @@ -632,34 +796,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 +807,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { tipPosRef.current = { top, right }; @@ -732,28 +868,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,6 +912,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { 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 { + 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) => { @@ -821,6 +972,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { 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 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 { 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 { const observer = observerRef.current; @@ -1196,10 +1381,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { const container = scrollableRef.current; @@ -1246,6 +1430,43 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject) => { + 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')) { @@ -1265,7 +1486,18 @@ 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).slice(0, 30) }, ); - const isHighlighted = - hoveredId != null ? hoveredId === entry.id : visibleIds.has(entry.id); return ( @@ -1326,14 +1581,14 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject @@ -1350,6 +1605,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 +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; + 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('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; + 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 208bd2a1ee..2159758cce 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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",