From 663aa85e1a5e9c2ccffa3263c502480b52277237 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 3 Oct 2026 05:53:10 +0800 Subject: [PATCH] perf: window the sidebar, cache Markdown trees and window chat scrolling once per frame The chat pane decides re-windowing once per frame from scrollTop and cached row heights instead of reading layout per scroll event. Rendered Markdown hast trees are cached, so remounted rows skip parsing. Sidebar lists of 60+ conversations mount only rows near the viewport. The perf driver scrolls the windowed sidebar to reach a row. --- src/renderer/src/ChatHistoryPane.test.tsx | 11 +- src/renderer/src/ChatHistoryPane.tsx | 299 ++++++++++----- src/renderer/src/ConversationSidebar.test.tsx | 334 +++++++++++++++++ src/renderer/src/ConversationSidebar.tsx | 68 +++- .../src/MarkdownRenderer.cache.test.tsx | 344 ++++++++++++++++++ src/renderer/src/MarkdownRenderer.tsx | 128 +++++-- src/renderer/src/chat-message-window.test.ts | 11 + src/renderer/src/chat-message-window.ts | 28 ++ src/renderer/src/markdown-render-cache.ts | 268 ++++++++++++++ src/renderer/src/styles.css | 21 ++ .../src/use-conversation-list-window.ts | 310 ++++++++++++++++ tests/support/app-perf-driver.mjs | 39 +- 12 files changed, 1726 insertions(+), 135 deletions(-) create mode 100644 src/renderer/src/ConversationSidebar.test.tsx create mode 100644 src/renderer/src/MarkdownRenderer.cache.test.tsx create mode 100644 src/renderer/src/markdown-render-cache.ts create mode 100644 src/renderer/src/use-conversation-list-window.ts diff --git a/src/renderer/src/ChatHistoryPane.test.tsx b/src/renderer/src/ChatHistoryPane.test.tsx index 3aa3860e..0c2fa7cc 100644 --- a/src/renderer/src/ChatHistoryPane.test.tsx +++ b/src/renderer/src/ChatHistoryPane.test.tsx @@ -183,7 +183,7 @@ afterEach(() => { }) describe('ChatHistoryPane windowing', () => { - it('keeps the DOM bounded for a 2,000-message conversation at any scroll position', () => { + it('keeps the DOM bounded for a 2,000-message conversation at any scroll position', async () => { const messages = makeMessages(2_000) const { container, chat } = setup(messages) measure() @@ -199,6 +199,7 @@ describe('ChatHistoryPane windowing', () => { chat.scrollTop = scrollTop fireEvent.scroll(chat) }) + await frame() measure() const count = container.querySelectorAll('.message').length expect(count).toBeLessThanOrEqual(maxRenderedMessageCount + 1) @@ -246,11 +247,12 @@ describe('ChatHistoryPane windowing', () => { expect(chat.scrollTop + viewport).toBe(chat.scrollHeight) }) - it('keeps the reader in place while rows above change height or mount', () => { + it('keeps the reader in place while rows above change height or mount', async () => { const messages = makeMessages(2_000) const { chat, container } = setup(messages) measure() act(() => { chat.scrollTop = 120_050; fireEvent.scroll(chat) }) + await frame() measure() const anchorId = renderedIds(container).find((id) => rowTop(container, id) + 100 > 0)! const before = rowTop(container, anchorId) @@ -268,6 +270,7 @@ describe('ChatHistoryPane windowing', () => { if (index % 3 === 0) rowHeights.set(`m${index}`, 250) } act(() => { chat.scrollTop -= 3_000; fireEvent.scroll(chat) }) + await frame() const anchorAfterScroll = renderedIds(container).find((id) => rowTop(container, id) + (rowHeights.get(id) ?? 100) > 0)! const topAfterScroll = rowTop(container, anchorAfterScroll) measure() @@ -321,14 +324,16 @@ describe('ChatHistoryPane windowing', () => { Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView') }) - it('restores the reader position across hide/show and from a saved snapshot', () => { + it('restores the reader position across hide/show and from a saved snapshot', async () => { const messages = makeMessages(2_000) const onScrollSnapshotChange = vi.fn() const { chat, container, rerender, props, unmount } = setup(messages, { onScrollSnapshotChange }) measure() act(() => { chat.scrollTop = 80_000; fireEvent.scroll(chat) }) + await frame() measure() act(() => { chat.scrollTop += 10; fireEvent.scroll(chat) }) + await frame() const scrollTop = chat.scrollTop const anchorId = renderedIds(container).find((id) => rowTop(container, id) + 100 > 0)! const anchorTop = rowTop(container, anchorId) diff --git a/src/renderer/src/ChatHistoryPane.tsx b/src/renderer/src/ChatHistoryPane.tsx index bb433aa1..71418d11 100644 --- a/src/renderer/src/ChatHistoryPane.tsx +++ b/src/renderer/src/ChatHistoryPane.tsx @@ -25,6 +25,7 @@ import { rangeAround, rangeCovers, rowAtOffset, + rowAtViewportTop, rowHeightsFor, tailRange, type MessageRowHeights, @@ -63,13 +64,22 @@ type ViewState = const bottomView: ViewState = { mode: "bottom" }; -type ScrollAnchor = { id: string; top: number }; +/** + * A row and its top relative to the reader top, as it was when the scroll + * container was at `scrollTop`. Until the next commit or resize nothing but + * scrollTop moves rows, so the row's current position is + * `top - (container.scrollTop - scrollTop)`: the scroll handler derives the + * viewport position from this and the cached row heights without reading + * layout. + */ +type ScrollAnchor = { id: string; top: number; scrollTop: number }; type PaneModel = { messages: Message[]; windowStart: number; range: MessageWindowRange; view: ViewState; + keptMessageIds: readonly string[]; }; function sizeAtFor( @@ -113,22 +123,20 @@ function scrollRenderOverscan(viewportHeight: number): number { return Math.max(800, Math.round(viewportHeight * 1.5)); } -function hasLayout(element: HTMLElement): boolean { - return element.getBoundingClientRect().height > 0; +function sameView(left: ViewState, right: ViewState): boolean { + if (left.mode === "bottom" || right.mode === "bottom") return left.mode === right.mode; + return left.start === right.start && left.end === right.end && left.toEnd === right.toEnd; } -/** - * Top of the reading area: the scroller's top plus the content's top padding. - * Frosted glass moves the scroller under the topbar and grows the padding by - * the same amount, so anchoring to this edge keeps rows where the reader saw - * them when the effect is toggled. - */ -function readerTop(container: HTMLElement): number { - const top = container.getBoundingClientRect().top; +function sameIds(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((id, index) => id === right[index]); +} + +function contentPaddingTop(container: HTMLElement): number { const content = container.querySelector(".chat-content"); - if (!content) return top; + if (!content) return 0; const padding = Number.parseFloat(getComputedStyle(content).paddingTop); - return Number.isFinite(padding) ? top + padding : top; + return Number.isFinite(padding) ? padding : 0; } export const ChatHistoryPane = memo(function ChatHistoryPane({ @@ -226,9 +234,18 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ const observerRef = useRef(undefined); const previousMessageCountRef = useRef(total); const modelRef = useRef(undefined); + // Pending rAF that processes the scroll events of one frame. + const scrollFrameRef = useRef(undefined); + // Top padding of .chat-content; read once per layout change (resize + // observer, activation) instead of getComputedStyle per scroll event. + const paddingTopRef = useRef(undefined); + // Layout of the rendered segments at the last positioning pass; commits + // that leave it unchanged (streaming text below the reader) skip layout reads. + const layoutKeyRef = useRef(undefined); const [showScrollToBottom, setShowScrollToBottom] = useState( scrollSnapshot ? !scrollSnapshot.pinnedToBottom : false, ); + const showScrollToBottomRef = useRef(showScrollToBottom); // Bumped when new row heights change spacer sizes or the window size. const [, setMeasureVersion] = useState(0); // Rows kept mounted outside the range: the focused row, the first message @@ -324,47 +341,104 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ } rendered.sort((left, right) => left - right); const segments = buildSegments(heights, messages, windowStart, total, rendered); + // Everything in the scroll content above or between rows whose size the + // pane controls; equal keys mean mounted rows only moved through their own + // resizes, which the resize observer reports. + const layoutKey = `${hiddenMessageCount > 0 ? 1 : 0}|${isUnusedConversation(conversation) ? 1 : 0}|${segments + .map((segment) => (segment.kind === "row" ? segment.index : `s${segment.height}`)) + .join(",")}`; useLayoutEffect(() => { - modelRef.current = { messages, windowStart, range, view }; + modelRef.current = { messages, windowStart, range, view, keptMessageIds }; }); const scrollToBottomNow = useCallback((container: HTMLElement): void => { container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); }, []); - /** First rendered row reaching into the viewport, relative to its top. */ - const captureAnchor = useCallback((): void => { - const container = scrollRef.current; - const list = listRef.current; - if (!container || !list || !hasLayout(container)) return; - const viewportTop = readerTop(container); - let fallback: ScrollAnchor | undefined; - for (const row of list.querySelectorAll(".message-window-row")) { - const id = row.dataset.messageId; - if (!id) continue; - const rect = row.getBoundingClientRect(); - const anchor = { id, top: rect.top - viewportTop }; - fallback = anchor; - if (rect.bottom > viewportTop) { - anchorRef.current = anchor; - return; - } - } - anchorRef.current = fallback; + /** Content top padding, cached until the next layout change. */ + const readerPadding = useCallback((container: HTMLElement): number => { + paddingTopRef.current ??= contentPaddingTop(container); + return paddingTopRef.current; }, []); - /** Moves the scroll position so the anchor row stays where the reader saw it. */ - const correctAnchor = useCallback((): void => { + /** + * Re-anchors on the row at the reader top, given that row `id` currently + * sits `top` px below the reader top. Pure arithmetic on cached heights. + * Returns the new anchor's index, or undefined when `id` is not loaded. + */ + const anchorFrom = useCallback( + (id: string, top: number, scrollTop: number): number | undefined => { + const model = modelRef.current; + if (!model) return undefined; + const { messages: current, windowStart: start } = model; + const index = current.findIndex((message) => message.id === id); + if (index < start) { + anchorRef.current = undefined; + return undefined; + } + const row = rowAtViewportTop(sizeAtFor(heights, current), start, current.length, index, top); + const rowId = current[row.index]?.id; + anchorRef.current = rowId ? { id: rowId, top: row.top, scrollTop } : undefined; + return rowId ? row.index : undefined; + }, + [heights], + ); + + /** + * Captures the anchor from layout: one rect pair (scroller and one mounted + * row near the viewport), then cached heights to the row at the reader top. + */ + const captureAnchor = useCallback( + (preferredId?: string): void => { + const container = scrollRef.current; + const model = modelRef.current; + if (!container || !model) return; + const rows = rowElementsRef.current; + let id: string | undefined; + for (const candidate of [preferredId, anchorRef.current?.id, model.messages[model.range.start]?.id]) { + if (candidate && rows.has(candidate)) { + id = candidate; + break; + } + } + id ??= rows.keys().next().value; + const element = id ? rows.get(id) : undefined; + if (!id || !element) return; + const containerRect = container.getBoundingClientRect(); + if (containerRect.height <= 0) return; + // Captures are rare (unpin, restore, mount), so refresh the padding + // here: a style change (frosted glass) may not have reached the resize + // observer yet. + paddingTopRef.current = undefined; + const top = element.getBoundingClientRect().top - containerRect.top - readerPadding(container); + anchorFrom(id, top, container.scrollTop); + }, + [anchorFrom, readerPadding], + ); + + /** + * Moves the scroll position so the anchor row stays where the reader saw + * it (net of their own scrolling since), then re-anchors. Reads one rect + * pair: the scroller and the anchor row. + */ + const syncAnchor = useCallback((): void => { const container = scrollRef.current; const anchor = anchorRef.current; - if (!container || !anchor) return; - const element = rowElementsRef.current.get(anchor.id); - if (!element || !hasLayout(container)) return; - const delta = - element.getBoundingClientRect().top - readerTop(container) - anchor.top; - if (Math.abs(delta) >= 0.5) container.scrollTop += delta; - }, []); + const element = anchor ? rowElementsRef.current.get(anchor.id) : undefined; + if (!container || !anchor || !element) { + captureAnchor(); + return; + } + const containerRect = container.getBoundingClientRect(); + if (containerRect.height <= 0) return; + const scrollTop = container.scrollTop; + const expected = anchor.top - (scrollTop - anchor.scrollTop); + const actual = element.getBoundingClientRect().top - containerRect.top - readerPadding(container); + if (Math.abs(actual - expected) >= 0.5) container.scrollTop = scrollTop + actual - expected; + const nextScrollTop = container.scrollTop; + anchorFrom(anchor.id, actual - (nextScrollTop - scrollTop), nextScrollTop); + }, [anchorFrom, captureAnchor, readerPadding]); useLayoutEffect(() => { const context = contextRef.current; @@ -386,6 +460,9 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ // Hidden panes have no observer, so they never measure rows. const observer = new ResizeObserver((entries) => { measure(); + // Frosted glass, the context strip or a window resize may have changed + // the content padding; re-read it lazily on the next anchor pass. + paddingTopRef.current = undefined; let layoutChanged = false; for (const entry of entries) { const target = entry.target as HTMLElement; @@ -408,11 +485,11 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ return; } // A row above the reader changed height (image, Mermaid, KaTeX). - correctAnchor(); - captureAnchor(); + syncAnchor(); }); observer.observe(context); - observer.observe(content); + // Border box, so a padding change (frosted glass) is reported too. + observer.observe(content, { box: 'border-box' }); observer.observe(scrollContainer); for (const row of rowElementsRef.current.values()) observer.observe(row); observerRef.current = observer; @@ -420,7 +497,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ observer.disconnect(); observerRef.current = undefined; }; - }, [active, captureAnchor, correctAnchor, heights, scrollToBottomNow]); + }, [active, heights, scrollToBottomNow, syncAnchor]); useEffect(() => { if (!active || !navigationTarget) return; @@ -435,7 +512,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ element.focus({ preventScroll: true }); // Keep the target mounted while it has focus, and remember the place. setKeptMessageIds([navigationTarget.messageId]); - captureAnchor(); + captureAnchor(navigationTarget.messageId); }); return () => cancelAnimationFrame(frame); }, [active, captureAnchor, navigationTarget, view, visibleMessageCount]); @@ -475,10 +552,13 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ scrollContainer.clientHeight; const pinnedToBottom = distanceFromBottom <= chatBottomProximity; const anchor = anchorRef.current; + const scrollTop = scrollContainer.scrollTop; latestScrollSnapshotRef.current = { pinnedToBottom, - scrollTop: scrollContainer.scrollTop, - ...(anchor ? { anchorMessageId: anchor.id, anchorOffset: anchor.top } : {}), + scrollTop, + ...(anchor + ? { anchorMessageId: anchor.id, anchorOffset: anchor.top - (scrollTop - anchor.scrollTop) } + : {}), }; return pinnedToBottom; }, @@ -502,48 +582,54 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ [conversation.id, onScrollSnapshotChange, saveScrollPosition], ); - const updateScrollPosition = useCallback((): void => { + /** + * Once per frame: decides whether the mounted rows still cover the + * viewport. The viewport position comes from scrollTop and the cached row + * heights relative to the last anchor, so a steady scroll reads no element + * geometry and commits only when it re-windows. + */ + const processScroll = useCallback((): void => { + scrollFrameRef.current = undefined; const scrollContainer = scrollRef.current; const model = modelRef.current; - if (!scrollContainer || !model) { - return; - } - captureAnchor(); - const atBottom = saveScrollPosition(scrollContainer); - pinnedToBottomRef.current = atBottom; - setShowScrollToBottom(!atBottom); - if (heights.setViewport(scrollContainer.clientHeight)) { + if (!scrollContainer || !model) return; + const clientHeight = scrollContainer.clientHeight; + if (heights.setViewport(clientHeight)) { setMeasureVersion((version) => version + 1); } - const { messages: current, windowStart: start, range: currentRange } = model; + const { messages: current, windowStart: start, range: currentRange, view: currentView } = model; let next: ViewState | undefined; - if (atBottom) { + if (pinnedToBottomRef.current) { next = bottomView; + // Content keeps moving under a pinned reader; capture afresh on unpin. + anchorRef.current = undefined; } else { - const topSpacer = listRef.current?.querySelector('[data-window-spacer="top"]'); - if (!topSpacer || !hasLayout(scrollContainer)) { + const scrollTop = scrollContainer.scrollTop; + if (!anchorRef.current && clientHeight > 0) captureAnchor(); + const anchor = anchorRef.current; + const anchorIndex = anchor && clientHeight > 0 + ? anchorFrom(anchor.id, anchor.top - (scrollTop - anchor.scrollTop), scrollTop) + : undefined; + const viewportAnchor = anchorRef.current; + if (anchorIndex === undefined || !viewportAnchor) { // No layout (hidden pane, tests): keep the mounted rows. - next = model.view.mode === "bottom" + next = currentView.mode === "bottom" ? { mode: "range", start: currentRange.start, end: currentRange.end, toEnd: true } : undefined; } else { - const viewportOffset = - scrollContainer.getBoundingClientRect().top - - topSpacer.getBoundingClientRect().top; - const row = rowAtOffset(sizeAtFor(heights, current), start, current.length, viewportOffset); + // The anchor is relative to the reader top; windowing covers the + // whole scroller viewport. + const offset = viewportAnchor.top + readerPadding(scrollContainer); // Hysteresis: re-window only when the viewport plus a small margin // leaves the mounted rows, then mount a wider margin, so a steady // scroll remounts rows every few screens instead of every event. - const needed = viewAround( - heights, current, start, row.index, row.top - viewportOffset, - scrollRewindowMargin, - ); + const needed = viewAround(heights, current, start, anchorIndex, offset, scrollRewindowMargin); if (needed.mode === "range" && !rangeCovers(currentRange, needed)) { next = viewAround( - heights, current, start, row.index, row.top - viewportOffset, + heights, current, start, anchorIndex, offset, scrollRenderOverscan(heights.viewport), ); - } else if (model.view.mode === "bottom") { + } else if (currentView.mode === "bottom") { next = { mode: "range", start: currentRange.start, @@ -552,8 +638,9 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ }; } } + saveScrollPosition(scrollContainer); } - if (next) setView(next); + if (next && !sameView(next, currentView)) setView(next); // Keep the focused row mounted while it scrolls out of the range. const focused = document.activeElement; const focusedRow = focused instanceof Element @@ -563,13 +650,38 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ focusedRow && listRef.current?.contains(focusedRow) ? focusedRow.dataset.messageId : undefined; - setKeptMessageIds((kept) => { - const nextKept = focusedId ? [focusedId] : []; - return kept.length === nextKept.length && kept.every((id, index) => id === nextKept[index]) - ? kept - : nextKept; - }); - }, [captureAnchor, heights, saveScrollPosition]); + const nextKept = focusedId ? [focusedId] : []; + if (!sameIds(nextKept, model.keptMessageIds)) { + setKeptMessageIds((kept) => (sameIds(kept, nextKept) ? kept : nextKept)); + } + }, [anchorFrom, captureAnchor, heights, readerPadding, saveScrollPosition]); + + /** + * The scroll event: only cheap scroller metrics and state that changes + * rarely (the bottom pin). Windowing runs at most once per frame. + */ + const updateScrollPosition = useCallback((): void => { + const scrollContainer = scrollRef.current; + if (!scrollContainer) return; + const distanceFromBottom = + scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight; + if (distanceFromBottom <= chatBottomProximity) { + // Content keeps moving under a pinned reader; an old anchor is stale. + anchorRef.current = undefined; + } else if (pinnedToBottomRef.current || !anchorRef.current) { + // Leaving the bottom: anchor from layout once, so a snapshot taken + // before the next frame (switching away) records where the reader is. + anchorRef.current = undefined; + captureAnchor(); + } + const atBottom = saveScrollPosition(scrollContainer); + pinnedToBottomRef.current = atBottom; + if (showScrollToBottomRef.current === atBottom) { + showScrollToBottomRef.current = !atBottom; + setShowScrollToBottom(!atBottom); + } + scrollFrameRef.current ??= requestAnimationFrame(processScroll); + }, [captureAnchor, processScroll, saveScrollPosition]); // Runs before the positioning effect below, so a sent message pins first. useLayoutEffect(() => { @@ -598,8 +710,12 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ } const activated = !wasActiveRef.current; wasActiveRef.current = true; + // Styles may have changed while hidden (frosted glass toggled). + if (activated) paddingTopRef.current = undefined; const measuredLayout = measuredLayoutRef.current; measuredLayoutRef.current = false; + const layoutChanged = layoutKeyRef.current !== layoutKey; + layoutKeyRef.current = layoutKey; if ( navigationTarget && navigationIndex >= 0 && @@ -617,14 +733,17 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ const anchorElement = snapshot.anchorMessageId ? rowElementsRef.current.get(snapshot.anchorMessageId) : undefined; - if (anchorElement && hasLayout(scrollContainer)) { + const containerRect = anchorElement ? scrollContainer.getBoundingClientRect() : undefined; + if (anchorElement && containerRect && containerRect.height > 0) { const delta = anchorElement.getBoundingClientRect().top - - scrollContainer.getBoundingClientRect().top - + containerRect.top - + readerPadding(scrollContainer) - (snapshot.anchorOffset ?? 0); if (Math.abs(delta) >= 0.5) scrollContainer.scrollTop += delta; } - captureAnchor(); + anchorRef.current = undefined; + captureAnchor(snapshot.anchorMessageId); }; if (restorePendingRef.current) { restorePendingRef.current = false; @@ -655,13 +774,16 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ ?.focus({ preventScroll: true }); } if (!pinnedToBottomRef.current) { + // The reader's row is still mounted; read where it landed. captureAnchor(); return; } } if (!pinnedToBottomRef.current) { - correctAnchor(); - captureAnchor(); + // Only commits that moved rows (mount/unmount, spacer sizes) need the + // one rect pair; size changes of mounted rows arrive through the + // resize observer, and streaming text below the reader changes nothing. + if (layoutChanged || measuredLayout || activated || !anchorRef.current) syncAnchor(); return; } if (activated || measuredLayout) { @@ -690,6 +812,10 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ cancelAnimationFrame(followFrameRef.current); followFrameRef.current = undefined; } + if (scrollFrameRef.current !== undefined) { + cancelAnimationFrame(scrollFrameRef.current); + scrollFrameRef.current = undefined; + } }, [], ); @@ -728,6 +854,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ return; } pinnedToBottomRef.current = true; + showScrollToBottomRef.current = false; setShowScrollToBottom(false); setView(bottomView); const reduceMotion = diff --git a/src/renderer/src/ConversationSidebar.test.tsx b/src/renderer/src/ConversationSidebar.test.tsx new file mode 100644 index 00000000..564880b4 --- /dev/null +++ b/src/renderer/src/ConversationSidebar.test.tsx @@ -0,0 +1,334 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { ComponentProps } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Conversation } from './chat-conversation' +import { ConversationSidebar, type ConversationListRowHandlers } from './ConversationSidebar' +import { createConversationStores, type ConversationStore } from './conversation-store' +import { conversationListWindowThreshold, maxRenderedConversationRows } from './use-conversation-list-window' + +// --- A tiny fake layout: jsdom has none. ------------------------------------ +// The list is 400 px tall at viewport top 0. Its children stack vertically: +// rows take their height from `rowHeights` (default 40), spacers their inline +// height. +const viewport = 400 +let rowHeights = new Map() +const scrollTops = new WeakMap() + +function childHeight(child: HTMLElement): number { + const id = child.dataset.conversationWindowRow + if (id) return rowHeights.get(id) ?? 40 + if (child.classList.contains('conversation-list__spacer')) return Number.parseFloat(child.style.height) || 0 + return 0 +} + +function contentHeight(list: HTMLElement): number { + return [...list.children].reduce((sum, child) => sum + childHeight(child as HTMLElement), 0) +} + +function rect(top: number, height: number): DOMRect { + return { top, bottom: top + height, height, left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect +} + +function listChildOf(element: HTMLElement, list: HTMLElement): HTMLElement | null { + let item: HTMLElement | null = element + while (item && item.parentElement !== list) item = item.parentElement + return item +} + +function fakeRect(this: HTMLElement): DOMRect { + if (this.classList.contains('conversation-list')) return rect(0, viewport) + const list = this.closest('.conversation-list') + if (!list) return rect(0, 0) + const item = listChildOf(this, list) + if (!item) return rect(0, 0) + let offset = 0 + for (const child of list.children) { + if (child === item) break + offset += childHeight(child as HTMLElement) + } + return rect(offset - list.scrollTop, childHeight(item)) +} + +const isList = (element: Element): boolean => element.classList.contains('conversation-list') + +class FakeResizeObserver { + static instances: FakeResizeObserver[] = [] + readonly observed = new Set() + constructor(private readonly callback: ResizeObserverCallback) { + FakeResizeObserver.instances.push(this) + } + observe(element: Element): void { this.observed.add(element) } + unobserve(element: Element): void { this.observed.delete(element) } + disconnect(): void { this.observed.clear() } + fire(): void { + const entries = [...this.observed].map((target) => ({ target }) as ResizeObserverEntry) + this.callback(entries, this as unknown as ResizeObserver) + } +} + +/** Delivers row measurements to the list's observer. */ +function measure(): void { + act(() => { + for (const observer of FakeResizeObserver.instances) { + if ([...observer.observed].some(isList)) observer.fire() + } + }) +} + +const descriptors = ['clientHeight', 'scrollHeight', 'scrollTop'] as const +const originalDescriptors = new Map() + +function installGeometry(): void { + for (const name of descriptors) { + originalDescriptors.set(name, Object.getOwnPropertyDescriptor(HTMLElement.prototype, name)) + } + Object.defineProperties(HTMLElement.prototype, { + clientHeight: { configurable: true, get(this: HTMLElement) { return isList(this) ? viewport : 0 } }, + scrollHeight: { configurable: true, get(this: HTMLElement) { return isList(this) ? contentHeight(this) : 0 } }, + scrollTop: { + configurable: true, + get(this: HTMLElement) { return scrollTops.get(this) ?? 0 }, + set(this: HTMLElement, value: number) { + const max = isList(this) ? Math.max(0, contentHeight(this) - viewport) : 0 + scrollTops.set(this, Math.min(Math.max(0, value), max)) + }, + }, + }) +} + +function restoreGeometry(): void { + for (const name of descriptors) { + const original = originalDescriptors.get(name) + if (original) Object.defineProperty(HTMLElement.prototype, name, original) + else Reflect.deleteProperty(HTMLElement.prototype, name) + } +} + +// --- Fixtures ---------------------------------------------------------------- + +const title = (index: number): string => `Conversation ${String(index).padStart(4, '0')}` +const idOf = (index: number): string => `c${index}` + +function makeConversations(count: number): Conversation[] { + // Newest first: row i is conversation i. + return Array.from({ length: count }, (_, index) => ({ + id: idOf(index), title: title(index), updatedAt: 1_000_000 - index, messages: [], + }) as unknown as Conversation) +} + +const handlers: ConversationListRowHandlers = { + toggleTasks: vi.fn(), select: vi.fn(), toggleActions: vi.fn(), registerActionTrigger: vi.fn(), + closeActions: vi.fn(), pin: vi.fn(), branch: vi.fn(), startRename: vi.fn(), copy: vi.fn(), + exportConversation: vi.fn(), cancelDelete: vi.fn(), confirmDelete: vi.fn(), requestDelete: vi.fn(), + saveTitle: vi.fn(), cancelRename: vi.fn(), openTask: vi.fn(), viewAllTasks: vi.fn(), +} + +type SidebarProps = ComponentProps + +function props(store: ConversationStore, overrides: Partial = {}): SidebarProps { + return { + store, + activeId: '', + activeProjectId: '', + activeProjectKind: undefined, + searchQuery: '', + conversationStoreReady: false, + loadError: undefined, + locale: 'zh-CN', + sidebarOpen: true, + tasksByConversation: new Map(), + expandedTaskConversationIds: new Set(), + activityByConversationId: new Map(), + activeConversationIds: new Set(), + queuedConversationIds: new Set(), + unreadConversationIds: new Set(), + conversationActionsId: '', + confirmingConversationId: '', + deletingConversationId: '', + renamingConversationId: '', + branchingConversationId: '', + pinningConversationId: '', + assistantSchedules: [], + selectedAssistantTaskId: undefined, + actionsRef: { current: null }, + handlers, + actions: { newConversation: vi.fn(), clearSearch: vi.fn(), retryLoad: vi.fn(), notify: vi.fn() }, + ...overrides, + } +} + +function setup(count: number, overrides: Partial = {}) { + const store = createConversationStores(makeConversations(count), { flushIntervalMs: 1_000 }).conversations + const view = render() + const list = view.container.querySelector('.conversation-list')! + measure() + const update = (next: Partial): void => { + view.rerender() + } + return { ...view, list, store, update } +} + +function renderedIds(list: HTMLElement): string[] { + return [...list.querySelectorAll('[data-conversation-window-row]')] + .map((row) => row.dataset.conversationWindowRow ?? '') +} + +function scrollList(list: HTMLElement, top: number): void { + act(() => { + list.scrollTop = top + fireEvent.scroll(list) + }) + measure() +} + +/** Rows whose box intersects the viewport. */ +function visibleIds(list: HTMLElement): string[] { + return [...list.querySelectorAll('[data-conversation-window-row]')].filter((row) => { + const box = row.getBoundingClientRect() + return box.bottom > 0 && box.top < viewport + }).map((row) => row.dataset.conversationWindowRow ?? '') +} + +beforeEach(() => { + rowHeights = new Map() + FakeResizeObserver.instances = [] + vi.stubGlobal('ResizeObserver', FakeResizeObserver) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(fakeRect) + installGeometry() +}) + +afterEach(() => { + cleanup() + restoreGeometry() + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('ConversationSidebar windowing', () => { + it('renders every row without spacers below the threshold', () => { + const { list } = setup(conversationListWindowThreshold - 1) + expect(renderedIds(list)).toHaveLength(conversationListWindowThreshold - 1) + expect(list.querySelector('.conversation-list__spacer')).toBeNull() + expect(list).not.toHaveClass('conversation-list--windowed') + }) + + it('keeps the DOM bounded for 1,000 conversations while the full list stays scrollable', () => { + const { list } = setup(1_000) + expect(list).toHaveClass('conversation-list--windowed') + expect(list).toHaveAttribute('role', 'list') + const rows = renderedIds(list) + expect(rows.length).toBeGreaterThan(0) + expect(rows.length).toBeLessThanOrEqual(40) + expect(rows[0]).toBe('c0') + expect(list.scrollHeight).toBe(1_000 * 40) + const first = list.querySelector('[data-conversation-window-row="c0"]')! + expect(first).toHaveAttribute('role', 'listitem') + expect(first).toHaveAttribute('aria-posinset', '1') + expect(first).toHaveAttribute('aria-setsize', '1000') + }) + + it('mounts the rows at the scroll position and keeps them in place', () => { + const { list } = setup(1_000) + for (const top of [20_000, 39_600, 8_000, 0]) { + scrollList(list, top) + const rows = renderedIds(list) + expect(rows.length).toBeLessThanOrEqual(maxRenderedConversationRows) + const firstVisible = Math.floor(list.scrollTop / 40) + expect(visibleIds(list)).toEqual( + Array.from({ length: Math.min(10, 1_000 - firstVisible) }, (_, offset) => idOf(firstVisible + offset)), + ) + } + scrollList(list, 20_000) + expect(screen.getByText(title(500))).toBeInTheDocument() + expect(list.querySelector('[data-conversation-window-row="c500"]')).toHaveAttribute('aria-posinset', '501') + expect(screen.queryByText(title(0))).toBeNull() + }) + + it('measures variable row heights and keeps the visible row steady', () => { + rowHeights = new Map(Array.from({ length: 1_000 }, (_, index) => [idOf(index), index % 3 === 0 ? 80 : 40])) + const { list } = setup(1_000) + scrollList(list, 12_000) + const firstVisible = visibleIds(list)[0]! + const before = list.querySelector(`[data-conversation-window-row="${firstVisible}"]`)!.getBoundingClientRect().top + measure() + const after = list.querySelector(`[data-conversation-window-row="${firstVisible}"]`)!.getBoundingClientRect().top + expect(after).toBeCloseTo(before, 0) + }) + + it('keeps the active, menu-open, renaming and focused rows mounted when scrolled away', () => { + const { list, update } = setup(1_000, { activeId: idOf(900) }) + expect(renderedIds(list)).toContain('c900') + update({ conversationActionsId: idOf(3) }) + expect(screen.getByRole('menu')).toBeInTheDocument() + scrollList(list, 20_000) + expect(renderedIds(list)).toEqual(expect.arrayContaining(['c3', 'c900', 'c500'])) + expect(screen.getByRole('menu')).toBeInTheDocument() + // Kept rows sit at their real position, so DOM (and Tab) order follows the list. + const rows = renderedIds(list).map((id) => Number(id.slice(1))) + expect(rows).toEqual([...rows].sort((left, right) => left - right)) + + update({ conversationActionsId: '', renamingConversationId: idOf(510) }) + scrollList(list, 32_000) + expect(screen.getByLabelText(`重命名会话 ${title(510)}`)).toBeInTheDocument() + expect(renderedIds(list)).not.toContain('c3') + + scrollList(list, 0) + act(() => screen.getByLabelText(`更多会话操作 ${title(4)}`).focus()) + update({ renamingConversationId: '' }) + scrollList(list, 30_000) + expect(renderedIds(list)).toContain('c4') + expect(screen.getByLabelText(`更多会话操作 ${title(4)}`)).toHaveFocus() + expect(renderedIds(list)).not.toContain('c510') + }) + + it('resets the window to the top when search results change', async () => { + const { list, update } = setup(1_000) + scrollList(list, 20_000) + expect(renderedIds(list)).toContain('c500') + // "conversation 07" matches Conversation 0700-0799. + update({ searchQuery: 'conversation 07' }) + await act(async () => { await Promise.resolve() }) + measure() + expect(list.scrollTop).toBe(0) + const rows = renderedIds(list) + expect(rows[0]).toBe('c700') + expect(rows.every((id) => Number(id.slice(1)) >= 700 && Number(id.slice(1)) < 800)).toBe(true) + expect(list.querySelector('[data-conversation-window-row="c700"]')).toHaveAttribute('aria-setsize', '100') + + scrollList(list, 2_000) + expect(renderedIds(list)).toContain('c750') + update({ searchQuery: 'conversation 001' }) + await act(async () => { await Promise.resolve() }) + // Ten matches: below the threshold, all rendered. + expect(renderedIds(list)).toEqual(Array.from({ length: 10 }, (_, index) => idOf(10 + index))) + expect(list.querySelector('.conversation-list__spacer')).toBeNull() + }) + + it('still reorders on the 5 s tick and pauses while hovered', () => { + vi.useFakeTimers() + const { list, store } = setup(1_000) + const bump = (index: number, time: number): void => act(() => store.set((current) => current.map((item) => + item.id === idOf(index) ? { ...item, messageSummary: { count: 1, latestMessageAt: time } } : item))) + bump(700, 2_000_000) + expect(renderedIds(list)[0]).toBe('c0') + fireEvent.mouseEnter(list) + act(() => { vi.advanceTimersByTime(10_000) }) + expect(renderedIds(list)[0]).toBe('c0') + fireEvent.mouseLeave(list) + act(() => { vi.advanceTimersByTime(5_000) }) + measure() + expect(renderedIds(list).slice(0, 2)).toEqual(['c700', 'c0']) + expect(screen.getByText(title(700)).closest('[role="listitem"]')).toHaveAttribute('aria-posinset', '1') + + // Scrolling pauses the tick for 200 ms like before windowing. + bump(800, 3_000_000) + act(() => { vi.advanceTimersByTime(4_900) }) + scrollList(list, 40) + act(() => { vi.advanceTimersByTime(100) }) + expect(renderedIds(list)).not.toContain('c800') + scrollList(list, 0) + act(() => { vi.advanceTimersByTime(5_000) }) + expect(renderedIds(list)[0]).toBe('c800') + }) +}) diff --git a/src/renderer/src/ConversationSidebar.tsx b/src/renderer/src/ConversationSidebar.tsx index 1968a1fd..ac04536c 100644 --- a/src/renderer/src/ConversationSidebar.tsx +++ b/src/renderer/src/ConversationSidebar.tsx @@ -32,6 +32,7 @@ import { OverflowMarquee } from "./OverflowMarquee"; import { findTaskSchedule } from "./TaskScheduleActions"; import { formatConversationListTime, type TimeFormatLocale } from "./time-format"; import { useConversationListOrder } from "./use-conversation-list-order"; +import { useConversationListWindow } from "./use-conversation-list-window"; import { DestructiveConfirmActions } from "./WorkspacePrimitives"; /** Delay before searching message text, so typing does not scan every keystroke. */ @@ -105,6 +106,11 @@ type ConversationListRowProps = { listTimeDay: string; actionsRef: React.RefObject; handlers: ConversationListRowHandlers; + /** Windowed lists only: measures the row; stable per conversation. */ + rowRef?: (element: HTMLElement | null) => void; + /** Windowed lists only: 1-based position, since not every row is in the DOM. */ + position?: number; + setSize?: number; }; // Rows skip re-rendering unless their own props change, so streaming into one @@ -132,13 +138,23 @@ const ConversationListRow = memo(function ConversationListRow({ listTimeDay, actionsRef, handlers, + rowRef, + position, + setSize, }: ConversationListRowProps): React.JSX.Element { void listTimeDay; const { t } = useTranslation("app"); const { t: tWorkspace } = useTranslation("workspace"); const branchDisabledReasonId = `conversation-branch-disabled-${conversation.id}`; return ( -
+
{conversationTasks.length > 0 && (