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",