From f8aa45d05efa4543271809da03400b1940958fbd Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 19 Jun 2026 14:10:59 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=9A=20feat:=20Add=20Bottom=20Terminus?= =?UTF-8?q?=20Node=20to=20Message=20Minimap=20Navigation=20(#13853)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: Add scroll-to-bottom terminus node to MessageNav Append the chat's bottom (#messages-end) as a terminal rib in the message minimap so it is reachable by click, drag-scrub, and the down chevron like any message. Rendered as a distinct centered dot rather than a line rib, and gated on the #messages-end sentinel actually existing. Also clamp each rib's snap target to the container's max scroll so the down chevron no longer stays stuck enabled at the bottom (the terminus can never scroll its top to the container top). * 🐛 fix: Scope MessageNav terminus to its own scroll container The terminus rib stored the shared constant id 'messages-end', which is rendered once per MessagesView. With multiple navs mounted, the global document.getElementById lookups resolved the first chat's sentinel, breaking the per-instance isolation guaranteed by the existing multi-instance tests. Resolve the terminus via the nav's own scrollableRef container (querySelector), leaving the globally-unique message ids on the fast getElementById path. Adds a multi-instance test covering the terminus. --- .../components/Chat/Messages/MessageNav.tsx | 199 +++++++++++------- .../Messages/__tests__/MessageNav.spec.tsx | 187 ++++++++++++++++ 2 files changed, 310 insertions(+), 76 deletions(-) diff --git a/client/src/components/Chat/Messages/MessageNav.tsx b/client/src/components/Chat/Messages/MessageNav.tsx index 2a4cd0a57e..fd96ab9d5d 100644 --- a/client/src/components/Chat/Messages/MessageNav.tsx +++ b/client/src/components/Chat/Messages/MessageNav.tsx @@ -12,8 +12,11 @@ type MessageEntry = { id: string; isUser: boolean; preview: string; + isEnd?: boolean; }; +const MESSAGES_END_ID = 'messages-end'; + function extractPreviewFromContent(content?: TMessageContentParts[]): string { if (!content) { return ''; @@ -67,6 +70,9 @@ function getMessageEntries(root: ParentNode, messagesById: Map const msg = messagesById.get(id); entries.push(msg ? buildEntry(id, msg) : buildFallbackEntry(node, id)); } + if (entries.length > 0 && root.querySelector('#' + MESSAGES_END_ID)) { + entries.push({ id: MESSAGES_END_ID, isUser: false, preview: '', isEnd: true }); + } return entries; } @@ -109,12 +115,14 @@ const MessageIndicator = memo(function MessageIndicator({ isActive, isCurrent, label, + hoverText, onSelect, }: { entry: MessageEntry; isActive: boolean; isCurrent: boolean; label: string; + hoverText: string; onSelect: (id: string) => void; }) { return ( @@ -123,24 +131,35 @@ const MessageIndicator = memo(function MessageIndicator({ -

{entry.preview}

+

{hoverText}

@@ -238,53 +257,69 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { - const el = document.getElementById(id); - if (!el) { - return; - } - const container = el.closest('.scrollbar-gutter-stable'); - if (!container) { - el.scrollIntoView({ behavior: 'smooth', block: 'start' }); - return; - } - const token = ++scrollTokenRef.current; - const scrollMargin = scrollMarginRef.current || readScrollMargin(el); - const startScroll = container.scrollTop; - const start = performance.now(); + const resolveEntryEl = useCallback( + (id: string): HTMLElement | null => { + if (id === MESSAGES_END_ID) { + return scrollableRef.current?.querySelector('#' + MESSAGES_END_ID) ?? null; + } + return document.getElementById(id); + }, + [scrollableRef], + ); - const step = (now: number) => { - if (token !== scrollTokenRef.current) { + const scrollToStart = useCallback( + (id: string) => { + const el = resolveEntryEl(id); + if (!el) { return; } - const progress = Math.min(1, (now - start) / SCROLL_DURATION); - const current = document.getElementById(id); - if (!current) { + const container = el.closest('.scrollbar-gutter-stable'); + if (!container) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } - const clamped = computeTargetScroll(container, current, scrollMargin); - container.scrollTop = startScroll + (clamped - startScroll) * easeOutCubic(progress); - if (progress < 1) { - requestAnimationFrame(step); + const token = ++scrollTokenRef.current; + const scrollMargin = scrollMarginRef.current || readScrollMargin(el); + const startScroll = container.scrollTop; + const start = performance.now(); + + const step = (now: number) => { + if (token !== scrollTokenRef.current) { + return; + } + const progress = Math.min(1, (now - start) / SCROLL_DURATION); + const current = resolveEntryEl(id); + if (!current) { + return; + } + const clamped = computeTargetScroll(container, current, scrollMargin); + container.scrollTop = startScroll + (clamped - startScroll) * easeOutCubic(progress); + if (progress < 1) { + requestAnimationFrame(step); + } + }; + + requestAnimationFrame(step); + }, + [resolveEntryEl], + ); + + const scrollToImmediate = useCallback( + (id: string) => { + const el = resolveEntryEl(id); + if (!el) { + return; } - }; - - requestAnimationFrame(step); - }, []); - - const scrollToImmediate = useCallback((id: string) => { - const el = document.getElementById(id); - if (!el) { - return; - } - const container = el.closest('.scrollbar-gutter-stable'); - if (!container) { - return; - } - scrollTokenRef.current++; - const scrollMargin = scrollMarginRef.current || readScrollMargin(el); - container.scrollTop = computeTargetScroll(container, el, scrollMargin); - }, []); + const container = el.closest('.scrollbar-gutter-stable'); + if (!container) { + return; + } + scrollTokenRef.current++; + const scrollMargin = scrollMarginRef.current || readScrollMargin(el); + container.scrollTop = computeTargetScroll(container, el, scrollMargin); + }, + [resolveEntryEl], + ); const focusMessage = useCallback((id: string) => { const el = document.getElementById(id); @@ -304,7 +339,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { for (let i = 0; i < entries.length; i++) { - const el = document.getElementById(entries[i].id); + const el = resolveEntryEl(entries[i].id); offsetsTop[i] = el ? el.offsetTop : Number.POSITIVE_INFINITY; offsetsBottom[i] = el ? el.offsetTop + el.offsetHeight : Number.POSITIVE_INFINITY; } @@ -535,10 +572,11 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject scrollTop + JUMP_EPS) { @@ -553,7 +591,6 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject 0 && scrollTop >= containerMaxScrollTop - JUMP_EPS) { scheduleColumnBottomScroll(); return; @@ -618,7 +655,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { const root = scrollableRef.current; @@ -691,7 +728,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject(); for (let i = 0; i < entries.length; i++) { const id = entries[i].id; - const el = document.getElementById(id); + const el = resolveEntryEl(id); if (el) { elementByNewId.set(el, id); } @@ -729,7 +766,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { const container = scrollableRef.current; @@ -742,7 +779,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject= 0; i--) { - const el = document.getElementById(entries[i].id); + const el = resolveEntryEl(entries[i].id); if (!el) { continue; } @@ -752,7 +789,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { const container = scrollableRef.current; @@ -765,7 +802,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject { const onKeyDown = (e: KeyboardEvent) => { @@ -788,7 +825,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject document.removeEventListener('keydown', onKeyDown); }, [focusNav]); - if (entries.length < 3) { + const hasEnd = entries.length > 0 && entries[entries.length - 1].isEnd === true; + const messageCount = hasEnd ? entries.length - 1 : entries.length; + if (messageCount < 3) { return null; } @@ -821,19 +860,27 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject - {entries.map((entry) => ( - - ))} + {entries.map((entry) => { + const label = entry.isEnd + ? localize('com_ui_scroll_to_bottom') + : localize( + entry.isUser + ? 'com_ui_message_nav_go_to_user' + : 'com_ui_message_nav_go_to_assistant', + { 0: entry.preview.slice(0, 30) }, + ); + return ( + + ); + })}