🔚 feat: Add Bottom Terminus Node to Message Minimap Navigation (#13853)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions

*  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.
This commit is contained in:
Danny Avila 2026-06-19 14:10:59 -04:00 committed by GitHub
parent 8969034ad1
commit f8aa45d05e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 310 additions and 76 deletions

View file

@ -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<string, TMessage>
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({
<button
type="button"
onClick={() => onSelect(entry.id)}
className={cn(indicatorButtonClasses, entry.isUser ? 'w-4' : 'w-6')}
className={cn(indicatorButtonClasses, entry.isEnd || !entry.isUser ? 'w-6' : 'w-4')}
aria-label={label}
aria-current={isCurrent ? 'true' : undefined}
data-msg-id={entry.id}
>
<span
className={cn(
'block w-full rounded-full transition-all duration-200',
isActive
? 'h-[5px] bg-gray-800 dark:bg-gray-100'
: 'h-[3px] bg-gray-400 dark:bg-gray-500',
)}
/>
{entry.isEnd ? (
<span
className={cn(
'block rounded-full transition-all duration-200',
isActive
? 'h-1.5 w-1.5 bg-gray-800 dark:bg-gray-100'
: 'h-1 w-1 bg-gray-400 dark:bg-gray-500',
)}
/>
) : (
<span
className={cn(
'block w-full rounded-full transition-all duration-200',
isActive
? 'h-[5px] bg-gray-800 dark:bg-gray-100'
: 'h-[3px] bg-gray-400 dark:bg-gray-500',
)}
/>
)}
</button>
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent side="left" sideOffset={12} className="z-[999] max-w-[280px] px-3 py-2">
<p className="line-clamp-3 text-xs text-text-secondary">{entry.preview}</p>
<p className="line-clamp-3 text-xs text-text-secondary">{hoverText}</p>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
@ -238,53 +257,69 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
refreshEntries();
}, [messagesById, refreshEntries]);
const scrollToStart = useCallback((id: string) => {
const el = document.getElementById(id);
if (!el) {
return;
}
const container = el.closest<HTMLElement>('.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<HTMLElement>('#' + 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<HTMLElement>('.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<HTMLElement>('.scrollbar-gutter-stable');
if (!container) {
return;
}
scrollTokenRef.current++;
const scrollMargin = scrollMarginRef.current || readScrollMargin(el);
container.scrollTop = computeTargetScroll(container, el, scrollMargin);
}, []);
const container = el.closest<HTMLElement>('.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<HTMLDivE
return;
}
scrollToStart(id);
focusMessage(id);
if (id !== MESSAGES_END_ID) {
focusMessage(id);
}
},
[scrollToStart, focusMessage],
);
@ -479,7 +516,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
const offsetsBottom: number[] = new Array(entries.length);
const recomputeOffsets = () => {
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<HTMLDivE
}
const scrollTop = container.scrollTop;
const containerMaxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight);
let nextCanUp = false;
let nextCanDown = false;
for (let i = 0; i < offsetsTop.length; i++) {
const snap = offsetsTop[i] - scrollMargin;
const snap = Math.min(offsetsTop[i] - scrollMargin, containerMaxScrollTop);
if (snap < scrollTop - JUMP_EPS) {
nextCanUp = true;
} else if (snap > scrollTop + JUMP_EPS) {
@ -553,7 +591,6 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
if (!col) {
return;
}
const containerMaxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight);
if (containerMaxScrollTop > 0 && scrollTop >= containerMaxScrollTop - JUMP_EPS) {
scheduleColumnBottomScroll();
return;
@ -618,7 +655,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
cancelColumnBottomScroll();
resizeObserver.disconnect();
};
}, [entries, scrollableRef]);
}, [entries, scrollableRef, resolveEntryEl]);
useEffect(() => {
const root = scrollableRef.current;
@ -691,7 +728,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
const elementByNewId = new Map<HTMLElement, string>();
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<HTMLDivE
setCurrentId(getCurrentVisibleId());
setVisibleIds(new Set(visibleSet));
}
}, [entries, getCurrentVisibleId]);
}, [entries, getCurrentVisibleId, resolveEntryEl]);
const jumpToPrevious = useCallback(() => {
const container = scrollableRef.current;
@ -742,7 +779,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
? scrollMarginRef.current
: readScrollMargin(document.getElementById(entries[0].id));
for (let i = entries.length - 1; i >= 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<HTMLDivE
}
}
container.scrollTo({ top: 0, behavior: 'smooth' });
}, [entries, scrollableRef, scrollToStart]);
}, [entries, scrollableRef, scrollToStart, resolveEntryEl]);
const jumpToNext = useCallback(() => {
const container = scrollableRef.current;
@ -765,7 +802,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
? scrollMarginRef.current
: readScrollMargin(document.getElementById(entries[0].id));
for (let i = 0; i < entries.length; i++) {
const el = document.getElementById(entries[i].id);
const el = resolveEntryEl(entries[i].id);
if (!el) {
continue;
}
@ -774,7 +811,7 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
return;
}
}
}, [entries, scrollableRef, scrollToStart]);
}, [entries, scrollableRef, scrollToStart, resolveEntryEl]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
@ -788,7 +825,9 @@ function MessageNav({ scrollableRef }: { scrollableRef: React.RefObject<HTMLDivE
return () => 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<HTMLDivE
className="flex min-h-0 cursor-grab touch-none select-none flex-col items-center gap-1.5 overflow-y-auto active:cursor-grabbing [&::-webkit-scrollbar]:hidden"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{entries.map((entry) => (
<MessageIndicator
key={entry.id}
entry={entry}
isActive={visibleIds.has(entry.id)}
isCurrent={currentId === entry.id}
onSelect={handleSelect}
label={localize(
entry.isUser ? 'com_ui_message_nav_go_to_user' : 'com_ui_message_nav_go_to_assistant',
{ 0: entry.preview.slice(0, 30) },
)}
/>
))}
{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 (
<MessageIndicator
key={entry.id}
entry={entry}
isActive={visibleIds.has(entry.id)}
isCurrent={currentId === entry.id}
onSelect={handleSelect}
label={label}
hoverText={entry.isEnd ? label : entry.preview}
/>
);
})}
</div>
<button

View file

@ -1098,4 +1098,191 @@ describe('MessageNav', () => {
expect(container.querySelector('[data-msg-id="d"]')).not.toBeNull();
});
});
describe('end-of-conversation indicator', () => {
function buildDomWithEnd(
messages: TestMessage[],
opts: { endOffsetTop?: number; scrollTop?: number } = {},
) {
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: opts.scrollTop ?? 0,
writable: true,
configurable: true,
});
const content = document.createElement('div');
content.className = 'flex flex-col';
scrollable.appendChild(content);
for (let i = 0; i < messages.length; i++) {
const m = messages[i];
const div = document.createElement('div');
div.id = m.messageId;
div.className = 'message-render';
div.textContent = m.text ?? '';
Object.defineProperty(div, 'offsetTop', { value: 100 + i * 200, configurable: true });
Object.defineProperty(div, 'offsetHeight', { value: 150, configurable: true });
content.appendChild(div);
}
const end = document.createElement('div');
end.id = 'messages-end';
Object.defineProperty(end, 'offsetTop', {
value: opts.endOffsetTop ?? 100 + messages.length * 200,
configurable: true,
});
Object.defineProperty(end, 'offsetHeight', { value: 0, configurable: true });
content.appendChild(end);
document.body.appendChild(scrollable);
return { scrollable, content, end };
}
function renderNavWithEnd(
messages: TestMessage[],
opts?: { endOffsetTop?: number; scrollTop?: number },
) {
mockUseGetMessagesByConvoId.mockReturnValue({ data: messages });
const dom = buildDomWithEnd(messages, opts);
const scrollableRef = { current: dom.scrollable } as RefObject<HTMLDivElement>;
const result = render(<MessageNav scrollableRef={scrollableRef} />);
act(() => {
jest.advanceTimersByTime(250);
});
return { ...result, ...dom, scrollableRef };
}
const threeMessages = (): TestMessage[] => [
buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }),
buildMessage({ messageId: 'b', text: 'bravo' }),
buildMessage({ messageId: 'c', text: 'charlie', isCreatedByUser: true }),
];
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');
});
it('omits the terminus indicator and the nav when there are fewer than 3 messages', () => {
const messages = [
buildMessage({ messageId: 'a', text: 'alpha', isCreatedByUser: true }),
buildMessage({ messageId: 'b', text: 'bravo' }),
];
const { container } = renderNavWithEnd(messages);
expect(container.querySelector('nav')).toBeNull();
});
it('scrolls toward the bottom without moving focus when the terminus is clicked', () => {
const { container } = renderNavWithEnd(threeMessages());
const rafSpy = jest.spyOn(window, 'requestAnimationFrame');
const endRib = container.querySelector('[data-msg-id="messages-end"]') as HTMLButtonElement;
act(() => {
fireEvent.click(endRib);
});
expect(rafSpy).toHaveBeenCalled();
const end = document.getElementById('messages-end');
expect(document.activeElement).not.toBe(end);
expect(end?.hasAttribute('tabindex')).toBe(false);
rafSpy.mockRestore();
});
it('keeps the next chevron disabled at the bottom even when the terminus sits below max scroll', () => {
const { container, scrollable } = renderNavWithEnd(threeMessages(), { endOffsetTop: 2900 });
Object.defineProperty(scrollable, 'scrollTop', {
value: 2400,
writable: true,
configurable: true,
});
act(() => {
fireEvent.scroll(scrollable);
jest.advanceTimersByTime(32);
});
const prev = container.querySelector(
'button[aria-label="com_ui_message_nav_previous"]',
) as HTMLButtonElement;
const next = container.querySelector(
'button[aria-label="com_ui_message_nav_next"]',
) as HTMLButtonElement;
expect(next.disabled).toBe(true);
expect(prev.disabled).toBe(false);
});
it('scrubs to the terminus when dragging to the bottom of the column', () => {
const messages = Array.from({ length: 5 }, (_, i) =>
buildMessage({
messageId: `m-${i}`,
text: `message ${i}`,
isCreatedByUser: i % 2 === 0,
}),
);
const { container, scrollable } = renderNavWithEnd(messages);
const column = container.querySelector('nav > div') as HTMLDivElement;
column.getBoundingClientRect = () => ({ top: 0, bottom: 50, height: 50 }) as DOMRect;
const qs = jest.spyOn(scrollable, 'querySelector');
act(() => {
fireEvent.pointerDown(column, { pointerId: 1, button: 0, buttons: 1, clientY: 0 });
fireEvent.pointerMove(document, { pointerId: 1, buttons: 1, clientY: 50 });
});
expect(qs.mock.calls.some((c) => String(c[0]).includes('messages-end'))).toBe(true);
qs.mockRestore();
});
it('resolves the terminus within its own container across multiple mounted navs', () => {
const messagesA = [
buildMessage({ messageId: 'a1', text: 'one', isCreatedByUser: true }),
buildMessage({ messageId: 'a2', text: 'two' }),
buildMessage({ messageId: 'a3', text: 'three', isCreatedByUser: true }),
];
const messagesB = [
buildMessage({ messageId: 'b1', text: 'alpha', isCreatedByUser: true }),
buildMessage({ messageId: 'b2', text: 'beta' }),
buildMessage({ messageId: 'b3', text: 'gamma', isCreatedByUser: true }),
];
mockUseGetMessagesByConvoId.mockReturnValue({ data: messagesA });
const domA = buildDomWithEnd(messagesA);
render(
<MessageNav scrollableRef={{ current: domA.scrollable } as RefObject<HTMLDivElement>} />,
);
act(() => {
jest.advanceTimersByTime(250);
});
mockUseGetMessagesByConvoId.mockReturnValue({ data: messagesB });
const domB = buildDomWithEnd(messagesB);
const { container: navB } = render(
<MessageNav scrollableRef={{ current: domB.scrollable } as RefObject<HTMLDivElement>} />,
);
act(() => {
jest.advanceTimersByTime(250);
});
const qsA = jest.spyOn(domA.scrollable, 'querySelector');
const qsB = jest.spyOn(domB.scrollable, 'querySelector');
const endRibB = navB.querySelector('[data-msg-id="messages-end"]') as HTMLButtonElement;
act(() => {
fireEvent.click(endRibB);
});
expect(qsB.mock.calls.some((c) => String(c[0]).includes('messages-end'))).toBe(true);
expect(qsA.mock.calls.some((c) => String(c[0]).includes('messages-end'))).toBe(false);
qsA.mockRestore();
qsB.mockRestore();
});
});
});