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 && (