From f4789b38815dc3a3faec8f1a1351e126e5898408 Mon Sep 17 00:00:00 2001 From: Qaz Date: Sat, 22 Aug 2026 17:56:44 +0200 Subject: [PATCH 1/3] feat(web): paginate long session history --- e2e/session-rest.test.ts | 43 +++++++++ src/server/index.ts | 40 ++++++++- src/server/session/message-pagination.test.ts | 80 +++++++++++++++++ src/server/session/message-pagination.ts | 90 +++++++++++++++++++ .../plan/MessageList.continue.test.tsx | 83 ++++++++++++++++- web/src/components/plan/MessageList.tsx | 83 +++++++++++++++-- web/src/components/plan/PlanPanel.tsx | 6 ++ web/src/lib/sessionPrefetch.test.ts | 2 +- web/src/lib/sessionPrefetch.ts | 2 +- web/src/stores/session/session.test.ts | 69 ++++++++++++++ web/src/stores/session/store.ts | 43 ++++++++- web/src/stores/session/types.ts | 1 + 12 files changed, 527 insertions(+), 15 deletions(-) create mode 100644 src/server/session/message-pagination.test.ts create mode 100644 src/server/session/message-pagination.ts diff --git a/e2e/session-rest.test.ts b/e2e/session-rest.test.ts index e58ffef3b..a7dc6bb0a 100644 --- a/e2e/session-rest.test.ts +++ b/e2e/session-rest.test.ts @@ -154,6 +154,49 @@ describe('Session REST API', () => { const data: any = await response.json() expect(data.error).toBe('Session not found') }) + + it('loads recent history by complete turns and pages backwards without overlap', async () => { + const createRes = await fetch(`${server.url}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectId, title: 'Paginated session' }), + }) + const created: any = await createRes.json() + const sessionId = created.session.id as string + const { emitUserMessage, emitAssistantMessageStart, emitMessageDelta, emitMessageDone } = + await import('../src/server/events/session.js') + + for (let turn = 1; turn <= 22; turn++) { + emitUserMessage(sessionId, `User ${turn}`) + const assistantId = emitAssistantMessageStart(sessionId) + emitMessageDelta(sessionId, assistantId, `Assistant ${turn}`) + emitMessageDone(sessionId, assistantId) + } + + const recentRes = await fetch(`${server.url}/api/sessions/${sessionId}?history=recent`) + const recent: any = await recentRes.json() + expect(recent.messages).toHaveLength(20) + expect(recent.messages[0].content).toBe('User 13') + expect(recent.messages.at(-1).content).toBe('Assistant 22') + expect(recent.hiddenCount).toBe(24) + + const olderRes = await fetch( + `${server.url}/api/sessions/${sessionId}/messages?before=${encodeURIComponent(recent.messages[0].id)}`, + ) + const older: any = await olderRes.json() + expect(older.messages).toHaveLength(20) + expect(older.messages[0].content).toBe('User 3') + expect(older.messages.at(-1).content).toBe('Assistant 12') + expect(older.hiddenCount).toBe(4) + + const recentIds = new Set(recent.messages.map((entry: any) => entry.id)) + expect(older.messages.some((entry: any) => recentIds.has(entry.id))).toBe(false) + + const fullRes = await fetch(`${server.url}/api/sessions/${sessionId}?full=true`) + const full: any = await fullRes.json() + expect(full.messages).toHaveLength(44) + expect(full.hiddenCount).toBe(0) + }) }) describe('DELETE /api/sessions/:id', () => { diff --git a/src/server/index.ts b/src/server/index.ts index c3b21fd41..29c715946 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -888,11 +888,44 @@ export async function createServerHandle(config: Config): Promise } }) + app.get('/api/sessions/:id/messages', async (req, res) => { + const { getSession } = await import('./db/sessions.js') + if (!getSession(req.params.id)) { + return res.status(404).json({ error: 'Session not found' }) + } + + const { getEventStore, combineEventsWithSnapshot } = await import('./events/index.js') + const { buildMessagesFromStoredEvents } = await import('./events/folding.js') + const { paginateMessages, DEFAULT_HISTORY_PAGE_MAX_ITEMS } = await import('./session/message-pagination.js') + + const eventStore = getEventStore() + const { snapshot, events: eventsSinceSnapshot } = eventStore.getEventsSinceSnapshot(req.params.id) + const events = combineEventsWithSnapshot(req.params.id, snapshot, eventsSinceSnapshot) + const allMessages = buildMessagesFromStoredEvents(events).messages + const requestedMaxItems = Number(req.query['maxItems']) + const maxItems = + Number.isInteger(requestedMaxItems) && requestedMaxItems > 0 + ? Math.min(requestedMaxItems, DEFAULT_HISTORY_PAGE_MAX_ITEMS) + : DEFAULT_HISTORY_PAGE_MAX_ITEMS + + try { + res.json( + paginateMessages(allMessages, { + ...(typeof req.query['before'] === 'string' ? { beforeMessageId: req.query['before'] } : {}), + maxItems, + }), + ) + } catch (error) { + res.status(400).json({ error: error instanceof Error ? error.message : 'Invalid message cursor' }) + } + }) + app.get('/api/sessions/:id', async (req, res) => { const { getEventStore, combineEventsWithSnapshot } = await import('./events/index.js') const { buildMessagesFromStoredEvents, foldPendingConfirmations } = await import('./events/folding.js') const { getPendingQuestionsForSession } = await import('./tools/index.js') const { getMaxVisibleItems } = await import('./db/settings.js') + const { paginateMessages } = await import('./session/message-pagination.js') const session = sessionManager.getSession(req.params.id) if (!session) { @@ -906,8 +939,11 @@ export async function createServerHandle(config: Config): Promise const { snapshot, events: eventsSinceSnapshot } = eventStore.getEventsSinceSnapshot(req.params.id) const events = combineEventsWithSnapshot(req.params.id, snapshot, eventsSinceSnapshot) - const maxVisibleItems = req.query['full'] === 'true' ? undefined : getMaxVisibleItems() || undefined - const { messages, hiddenCount } = buildMessagesFromStoredEvents(events, maxVisibleItems) + const fullHistory = req.query['full'] === 'true' + const recentHistory = !fullHistory && req.query['history'] === 'recent' + const maxVisibleItems = fullHistory || recentHistory ? undefined : getMaxVisibleItems() || undefined + const folded = buildMessagesFromStoredEvents(events, maxVisibleItems) + const { messages, hiddenCount } = recentHistory ? paginateMessages(folded.messages) : folded const contextState = sessionManager.getContextState(req.params.id) const queueState = sessionManager.getQueueState(req.params.id) const pendingQuestions = getPendingQuestionsForSession(req.params.id) diff --git a/src/server/session/message-pagination.test.ts b/src/server/session/message-pagination.test.ts new file mode 100644 index 000000000..c6e448014 --- /dev/null +++ b/src/server/session/message-pagination.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import type { Message } from '../../shared/types.js' +import { paginateMessages } from './message-pagination.js' + +function message(id: string, role: Message['role'], content = id): Message { + return { + id, + role, + content, + timestamp: '2026-08-22T00:00:00.000Z', + } +} + +function turns(count: number): Message[] { + return Array.from({ length: count }, (_, index) => { + const turn = index + 1 + return [message(`user-${turn}`, 'user'), message(`assistant-${turn}`, 'assistant')] + }).flat() +} + +describe('paginateMessages', () => { + it('returns the ten most recent complete turns by default', () => { + const page = paginateMessages(turns(12)) + + expect(page.messages).toHaveLength(20) + expect(page.messages[0]!.id).toBe('user-3') + expect(page.messages.at(-1)!.id).toBe('assistant-12') + expect(page.hiddenCount).toBe(4) + }) + + it('does not split a turn when the item limit is reached', () => { + const page = paginateMessages(turns(5), { maxItems: 5, maxTurns: 10 }) + + expect(page.messages.map((entry) => entry.id)).toEqual(['user-4', 'assistant-4', 'user-5', 'assistant-5']) + expect(page.hiddenCount).toBe(6) + }) + + it('uses the serialized byte budget without dropping the newest complete turn', () => { + const messages = [ + message('user-1', 'user'), + message('assistant-1', 'assistant', 'a'.repeat(600_000)), + message('user-2', 'user'), + message('assistant-2', 'assistant', 'b'.repeat(600_000)), + ] + + const page = paginateMessages(messages, { maxBytes: 1_000_000 }) + + expect(page.messages.map((entry) => entry.id)).toEqual(['user-2', 'assistant-2']) + expect(page.hiddenCount).toBe(2) + }) + + it('loads the page immediately before a stable message cursor without overlap', () => { + const messages = turns(6) + const newest = paginateMessages(messages, { maxTurns: 2 }) + const older = paginateMessages(messages, { + beforeMessageId: newest.messages[0]!.id, + maxTurns: 2, + }) + + expect(newest.messages.map((entry) => entry.id)).toEqual(['user-5', 'assistant-5', 'user-6', 'assistant-6']) + expect(older.messages.map((entry) => entry.id)).toEqual(['user-3', 'assistant-3', 'user-4', 'assistant-4']) + expect(older.hiddenCount).toBe(4) + }) + + it('keeps system and tool messages attached to their surrounding turn', () => { + const messages = [ + message('system-1', 'system'), + message('user-1', 'user'), + message('assistant-1', 'assistant'), + message('tool-1', 'tool'), + message('user-2', 'user'), + message('assistant-2', 'assistant'), + ] + + const page = paginateMessages(messages, { maxTurns: 1 }) + + expect(page.messages.map((entry) => entry.id)).toEqual(['user-2', 'assistant-2']) + expect(page.hiddenCount).toBe(4) + }) +}) diff --git a/src/server/session/message-pagination.ts b/src/server/session/message-pagination.ts new file mode 100644 index 000000000..d807aaf7b --- /dev/null +++ b/src/server/session/message-pagination.ts @@ -0,0 +1,90 @@ +import { Buffer } from 'node:buffer' +import type { Message } from '../../shared/types.js' + +export const DEFAULT_HISTORY_PAGE_MAX_TURNS = 10 +export const DEFAULT_HISTORY_PAGE_MAX_ITEMS = 30 +export const DEFAULT_HISTORY_PAGE_MAX_BYTES = 1024 * 1024 + +export interface MessagePageOptions { + beforeMessageId?: string + maxTurns?: number + maxItems?: number + maxBytes?: number +} + +export interface MessagePage { + messages: Message[] + hiddenCount: number +} + +interface MessageTurn { + start: number + messages: Message[] + bytes: number +} + +function positiveInteger(value: number | undefined, fallback: number): number { + return value !== undefined && Number.isInteger(value) && value > 0 ? value : fallback +} + +function groupIntoTurns(messages: Message[]): MessageTurn[] { + const turns: MessageTurn[] = [] + + for (let index = 0; index < messages.length; index++) { + const entry = messages[index]! + let turn = turns.at(-1) + + if (!turn || entry.role === 'user') { + turn = { start: index, messages: [], bytes: 0 } + turns.push(turn) + } + + turn.messages.push(entry) + turn.bytes += Buffer.byteLength(JSON.stringify(entry), 'utf8') + } + + return turns +} + +/** + * Select a bottom-anchored page without splitting a user turn. Limits are + * soft for the newest eligible turn so one oversized response remains usable. + */ +export function paginateMessages(messages: Message[], options: MessagePageOptions = {}): MessagePage { + const maxTurns = positiveInteger(options.maxTurns, DEFAULT_HISTORY_PAGE_MAX_TURNS) + const maxItems = positiveInteger(options.maxItems, DEFAULT_HISTORY_PAGE_MAX_ITEMS) + const maxBytes = positiveInteger(options.maxBytes, DEFAULT_HISTORY_PAGE_MAX_BYTES) + + let end = messages.length + if (options.beforeMessageId !== undefined) { + end = messages.findIndex((entry) => entry.id === options.beforeMessageId) + if (end < 0) { + throw new Error(`Message cursor not found: ${options.beforeMessageId}`) + } + } + + if (end === 0) return { messages: [], hiddenCount: 0 } + + const turns = groupIntoTurns(messages.slice(0, end)) + const selected: MessageTurn[] = [] + let itemCount = 0 + let byteCount = 0 + + for (let index = turns.length - 1; index >= 0; index--) { + const turn = turns[index]! + const exceedsLimit = + selected.length >= maxTurns || itemCount + turn.messages.length > maxItems || byteCount + turn.bytes > maxBytes + + if (selected.length > 0 && exceedsLimit) break + + selected.unshift(turn) + itemCount += turn.messages.length + byteCount += turn.bytes + } + + const first = selected[0] + return { + messages: selected.flatMap((turn) => turn.messages), + hiddenCount: first?.start ?? 0, + } +} diff --git a/web/src/components/plan/MessageList.continue.test.tsx b/web/src/components/plan/MessageList.continue.test.tsx index 0be5778f3..80923ba2a 100644 --- a/web/src/components/plan/MessageList.continue.test.tsx +++ b/web/src/components/plan/MessageList.continue.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment happy-dom import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, cleanup } from '@testing-library/react' +import { act, render, screen, cleanup, waitFor } from '@testing-library/react' +import type { PropsWithChildren } from 'react' import { MessageList } from './MessageList' const mockContinueWorkflow = vi.fn() @@ -97,6 +98,7 @@ vi.mock('../../stores/settings', () => ({ showStats: true, showAgentDefinitions: true, showWorkflowBars: true, + maxVisibleItems: 300, }), })) @@ -104,19 +106,33 @@ vi.mock('./ChatFeedItems', () => ({ ChatFeedItems: () =>
ChatFeedItems
, })) -function renderMessageList() { +vi.mock('../shared/ScrollArea', () => ({ + ScrollArea: ({ children, ref: _ref, ...props }: PropsWithChildren>) => ( +
{children}
+ ), +})) + +function renderMessageList( + options: { + hiddenCount?: number + onLoadOlder?: (maxItems: number) => Promise + viewport?: HTMLDivElement + } = {}, +) { const mockOsRef = { current: { - osInstance: () => null, + osInstance: () => (options.viewport ? { elements: () => ({ viewport: options.viewport }) } : null), getElement: () => null, }, } return render( , ) } @@ -220,3 +236,62 @@ describe('MessageList continue workflow button', () => { expect(screen.getAllByTestId('workflow-run-button').length).toBeGreaterThan(0) }) }) + +describe('MessageList paginated history', () => { + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) + + beforeEach(() => { + mockState.phase = 'build' + mockState.hasWaitingWorkflow = false + mockState.criteriaPending = false + mockState.displayItems = [ + { type: 'message', message: { id: 'message-3', role: 'user', content: 'three' } }, + { type: 'message', message: { id: 'message-4', role: 'assistant', content: 'four' } }, + ] + }) + + it('loads an older page from the history control', async () => { + const onLoadOlder = vi.fn(async () => 2) + renderMessageList({ hiddenCount: 8, onLoadOlder }) + + screen.getByRole('button', { name: 'Load older history (8 remaining)' }).click() + + await waitFor(() => expect(onLoadOlder).toHaveBeenCalledWith(30)) + }) + + it('loads near the top only while the user is scrolling upward and preserves the viewport', async () => { + const viewport = document.createElement('div') + let scrollHeight = 1_000 + let scrollTop = 500 + Object.defineProperty(viewport, 'scrollHeight', { get: () => scrollHeight }) + Object.defineProperty(viewport, 'clientHeight', { get: () => 400 }) + Object.defineProperty(viewport, 'scrollTop', { + get: () => scrollTop, + set: (value: number) => { + scrollTop = value + }, + }) + const onLoadOlder = vi.fn(async () => { + scrollHeight = 1_400 + return 2 + }) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0) + return 1 + }) + + renderMessageList({ hiddenCount: 8, onLoadOlder, viewport }) + + await act(async () => { + scrollTop = 100 + viewport.dispatchEvent(new Event('scroll')) + await Promise.resolve() + }) + + await waitFor(() => expect(onLoadOlder).toHaveBeenCalledWith(30)) + expect(scrollTop).toBe(500) + }) +}) diff --git a/web/src/components/plan/MessageList.tsx b/web/src/components/plan/MessageList.tsx index dc1e6e1ad..6d6f771e7 100644 --- a/web/src/components/plan/MessageList.tsx +++ b/web/src/components/plan/MessageList.tsx @@ -65,6 +65,7 @@ interface MessageListProps { ) => void onScrollToTop?: () => void hiddenCount?: number + onLoadOlder?: (maxItems: number) => Promise onScrollbarGesture?: (kind: ScrollbarGestureKind, gapToEndPx: number | null) => void emptyState?: ReactNode } @@ -76,6 +77,7 @@ export const MessageList = memo(function MessageList({ onLaunchWorkflow, onScrollToTop, hiddenCount = 0, + onLoadOlder, onScrollbarGesture, emptyState, }: MessageListProps) { @@ -121,7 +123,7 @@ export const MessageList = memo(function MessageList({ ) const retryLLMNow = useSessionStore((state) => state.retryLLMNow) const retryLLM = useSessionStore((state) => state.retryLLM) - const { showThinking, showVerboseToolOutput, showStats, showAgentDefinitions, showWorkflowBars } = + const { showThinking, showVerboseToolOutput, showStats, showAgentDefinitions, showWorkflowBars, maxVisibleItems } = useDisplaySettings() const workflows = useAllWorkflows() @@ -143,8 +145,12 @@ export const MessageList = memo(function MessageList({ undefined, ) const [popupBlocked, setPopupBlocked] = useState(false) + const [loadingOlder, setLoadingOlder] = useState(false) + const [historyLoadError, setHistoryLoadError] = useState(false) const [isScrollable, setIsScrollable] = useState(false) const [scrolledPastTop, setScrolledPastTop] = useState(false) + const previousScrollTopRef = useRef(0) + const loadingOlderRef = useRef(false) const getViewport = useViewport(scrollContainerRef) @@ -157,7 +163,9 @@ export const MessageList = memo(function MessageList({ useEffect(() => { const el = getViewport() if (!el) return - const onScroll = () => setScrolledPastTop(el.scrollTop > 4) + const onScroll = () => { + setScrolledPastTop(el.scrollTop > 4) + } onScroll() el.addEventListener('scroll', onScroll, { passive: true }) return () => el.removeEventListener('scroll', onScroll) @@ -172,6 +180,55 @@ export const MessageList = memo(function MessageList({ } } + const canLoadOlder = + hiddenCount > 0 && onLoadOlder !== undefined && (maxVisibleItems === 0 || displayItems.length < maxVisibleItems) + const pageCapacity = maxVisibleItems === 0 ? 30 : Math.max(1, Math.min(30, maxVisibleItems - displayItems.length)) + + const loadOlder = useCallback(async () => { + if (!canLoadOlder || loadingOlderRef.current || !onLoadOlder) return + + const viewport = getViewport() + const previousHeight = viewport?.scrollHeight ?? 0 + const previousTop = viewport?.scrollTop ?? 0 + loadingOlderRef.current = true + setLoadingOlder(true) + setHistoryLoadError(false) + + try { + const loaded = await onLoadOlder(pageCapacity) + if (loaded === 0) return + requestAnimationFrame(() => { + const updatedViewport = getViewport() + if (!updatedViewport) return + updatedViewport.scrollTop = previousTop + Math.max(0, updatedViewport.scrollHeight - previousHeight) + previousScrollTopRef.current = updatedViewport.scrollTop + }) + } catch { + setHistoryLoadError(true) + } finally { + loadingOlderRef.current = false + setLoadingOlder(false) + } + }, [canLoadOlder, getViewport, onLoadOlder, pageCapacity]) + + useEffect(() => { + const viewport = getViewport() + if (!viewport || !canLoadOlder) return + previousScrollTopRef.current = viewport.scrollTop + + const onScroll = () => { + const currentTop = viewport.scrollTop + const movedUp = currentTop < previousScrollTopRef.current - 1 + previousScrollTopRef.current = currentTop + if (movedUp && currentTop <= 160) { + void loadOlder() + } + } + + viewport.addEventListener('scroll', onScroll, { passive: true }) + return () => viewport.removeEventListener('scroll', onScroll) + }, [canLoadOlder, getViewport, loadOlder, sessionId]) + const [continuing, setContinuing] = useState(false) const handleContinue = useCallback( @@ -203,11 +260,27 @@ export const MessageList = memo(function MessageList({ {hiddenCount > 0 && (
+ {canLoadOlder && ( + + )} + {historyLoadError && ( +

Could not load older history. Try again.

+ )} {popupBlocked && (

Popup blocked.{' '} diff --git a/web/src/components/plan/PlanPanel.tsx b/web/src/components/plan/PlanPanel.tsx index 668afadfb..9a48fb5fa 100644 --- a/web/src/components/plan/PlanPanel.tsx +++ b/web/src/components/plan/PlanPanel.tsx @@ -99,6 +99,7 @@ export function PlanPanel({ const sessions = useSessionStore((state) => state.sessions) const isRunning = useIsRunning(scoped ? scopedSessionId : null) const stopGeneration = useSessionStore((state) => state.stopGeneration) + const loadOlderMessages = useSessionStore((state) => state.loadOlderMessages) const messages = propRawMessages ?? storeMessages @@ -173,6 +174,10 @@ export function PlanPanel({ ) const { sendMessage, launchWorkflow } = useScrolledSend(setAutoScroll, targetSessionId) const gatedAgentSwitch = useEffortGatedAgentSwitch(targetSessionId) + const handleLoadOlder = useCallback( + (maxItems: number) => (targetSessionId ? loadOlderMessages(targetSessionId, maxItems) : Promise.resolve(0)), + [loadOlderMessages, targetSessionId], + ) useEffect(() => { const handler = () => setAutoScroll(true) @@ -357,6 +362,7 @@ export function PlanPanel({ onLaunchWorkflow={handleLaunchWorkflow} onScrollToTop={() => setAutoScroll(false)} hiddenCount={hiddenCount} + onLoadOlder={targetSessionId ? handleLoadOlder : undefined} onScrollbarGesture={handleScrollbarGesture} emptyState={ messages.length === 0 && session?.projectId ? ( diff --git a/web/src/lib/sessionPrefetch.test.ts b/web/src/lib/sessionPrefetch.test.ts index 6a4d07af0..88e093f34 100644 --- a/web/src/lib/sessionPrefetch.test.ts +++ b/web/src/lib/sessionPrefetch.test.ts @@ -29,7 +29,7 @@ describe('sessionPrefetch', () => { expect(fetch).toHaveBeenCalledTimes(1) const [url, init] = vi.mocked(fetch).mock.calls[0]! - expect(String(url)).toContain('/api/sessions/s1') + expect(String(url)).toContain('/api/sessions/s1?history=recent') expect((init!.headers as Record)['x-session-token']).toBe('test-token') const result = await consumePrefetchedSession('s1') diff --git a/web/src/lib/sessionPrefetch.ts b/web/src/lib/sessionPrefetch.ts index 115cde4c8..59be76b65 100644 --- a/web/src/lib/sessionPrefetch.ts +++ b/web/src/lib/sessionPrefetch.ts @@ -29,7 +29,7 @@ export function prefetchSession(sessionId: string): void { if (pending.has(sessionId)) return const token = localStorage.getItem('openfox_token') - const promise: Promise = fetch(appUrl(`/api/sessions/${sessionId}`), { + const promise: Promise = fetch(appUrl(`/api/sessions/${sessionId}?history=recent`), { headers: token ? { 'x-session-token': token } : undefined, }) .then(async (res): Promise => { diff --git a/web/src/stores/session/session.test.ts b/web/src/stores/session/session.test.ts index 1dc145604..0fdf83b0d 100644 --- a/web/src/stores/session/session.test.ts +++ b/web/src/stores/session/session.test.ts @@ -2319,6 +2319,75 @@ describe('cross-tab sidebar sync', () => { }) }) +describe('loadOlderMessages', () => { + beforeEach(() => { + fetchMock.mockClear() + }) + + it('prepends a bounded page and updates the remaining hidden count', async () => { + const useSessionStore = await loadSessionStore() + const currentSession = { + id: 'session-1', + projectId: 'project-1', + workdir: '/tmp/project-1', + mode: 'planner', + phase: 'plan', + isRunning: false, + criteria: [], + summary: null, + } as any + useSessionStore.setState({ + currentSession, + focusedSessionId: 'session-1', + messages: [ + { id: 'message-3', role: 'user', content: 'three', timestamp: '2026-08-22T00:00:00.000Z' }, + { id: 'message-4', role: 'assistant', content: 'four', timestamp: '2026-08-22T00:00:01.000Z' }, + ], + hiddenCount: 2, + }) + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + messages: [ + { id: 'message-1', role: 'user', content: 'one', timestamp: '2026-08-21T00:00:00.000Z' }, + { id: 'message-2', role: 'assistant', content: 'two', timestamp: '2026-08-21T00:00:01.000Z' }, + ], + hiddenCount: 0, + }), + } as never) + + const loaded = await useSessionStore.getState().loadOlderMessages('session-1', 10) + + expect(loaded).toBe(2) + expect(fetchMock).toHaveBeenCalledWith('/api/sessions/session-1/messages?before=message-3&maxItems=10', { + headers: {}, + }) + expect(useSessionStore.getState().messages.map((entry) => entry.id)).toEqual([ + 'message-1', + 'message-2', + 'message-3', + 'message-4', + ]) + expect(useSessionStore.getState().hiddenCount).toBe(0) + }) + + it('does not fetch when the current page has no older messages', async () => { + const useSessionStore = await loadSessionStore() + useSessionStore.setState({ + currentSession: { id: 'session-1' } as any, + focusedSessionId: 'session-1', + messages: [{ id: 'message-1', role: 'user', content: 'one', timestamp: '2026-08-22T00:00:00.000Z' }], + hiddenCount: 0, + }) + + const loaded = await useSessionStore.getState().loadOlderMessages('session-1') + + expect(loaded).toBe(0) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + describe('toggleFavorite', () => { beforeEach(() => { fetchMock.mockClear() diff --git a/web/src/stores/session/store.ts b/web/src/stores/session/store.ts index aa78e3535..461b6ef12 100644 --- a/web/src/stores/session/store.ts +++ b/web/src/stores/session/store.ts @@ -28,6 +28,7 @@ let isSubscribed = false let wsUnsubscribe: (() => void) | null = null const loadingSessionIds = new Set() +const loadingHistorySessionIds = new Set() const loadedSessionIds = new Set() const listingSessionsForProject = new Map>() let fullSessionListPromise: Promise | null = null @@ -263,11 +264,11 @@ export const useSessionStore = create((set, get) => { const sessionFetch: Promise = prefetched ? prefetched.then(async (result) => { if (result.ok) return result.data as unknown as SessionLoadData - const res = await authFetch(`/api/sessions/${sessionId}`) + const res = await authFetch(`/api/sessions/${sessionId}?history=recent`) if (!res.ok) return null return (await res.json()) as SessionLoadData }) - : authFetch(`/api/sessions/${sessionId}`).then(async (res) => { + : authFetch(`/api/sessions/${sessionId}?history=recent`).then(async (res) => { if (!res.ok) return null return (await res.json()) as SessionLoadData }) @@ -530,6 +531,44 @@ export const useSessionStore = create((set, get) => { await ensurePane(sessionId, true, force) }, + loadOlderMessages: async (sessionId, requestedMaxItems = 30) => { + if (loadingHistorySessionIds.has(sessionId)) return 0 + + const currentPane = paneFor(get(), sessionId) + const beforeMessageId = currentPane?.messages[0]?.id + if (!currentPane || currentPane.hiddenCount <= 0 || !beforeMessageId) return 0 + + const maxItems = Number.isInteger(requestedMaxItems) ? Math.max(1, Math.min(requestedMaxItems, 30)) : 30 + loadingHistorySessionIds.add(sessionId) + + try { + const query = new URLSearchParams({ before: beforeMessageId, maxItems: String(maxItems) }) + const res = await authFetch(`/api/sessions/${sessionId}/messages?${query.toString()}`) + if (!res.ok) throw new Error(`Failed to load older messages (${res.status})`) + + const data = (await res.json()) as { messages?: Message[]; hiddenCount?: number } + const incoming = data.messages ?? [] + let loadedCount = 0 + + set((state) => + updatePane(state, sessionId, (pane) => { + const existingIds = new Set(pane.messages.map((message) => message.id)) + const older = incoming.filter((message) => !existingIds.has(message.id)) + loadedCount = older.length + return { + ...pane, + messages: [...older, ...pane.messages], + hiddenCount: data.hiddenCount ?? pane.hiddenCount, + } + }), + ) + + return loadedCount + } finally { + loadingHistorySessionIds.delete(sessionId) + } + }, + openPane: async (sessionId, opts = {}) => { const focus = opts.focus ?? false set((s) => ({ openSessionIds: addToOrdered(s.openSessionIds, sessionId) })) diff --git a/web/src/stores/session/types.ts b/web/src/stores/session/types.ts index b6e662b63..fbd65a619 100644 --- a/web/src/stores/session/types.ts +++ b/web/src/stores/session/types.ts @@ -119,6 +119,7 @@ export interface SessionState { cancelPassword: () => void createSession: (projectId: string, title?: string) => Promise loadSession: (sessionId: string, force?: boolean) => Promise + loadOlderMessages: (sessionId: string, maxItems?: number) => Promise openPane: (sessionId: string, opts?: { focus?: boolean }) => Promise closePane: (sessionId: string) => void focusPane: (sessionId: string) => void From 8d48b87cee353f49a7ece44eefd270956da0cfe5 Mon Sep 17 00:00:00 2001 From: Qaz Date: Sat, 22 Aug 2026 18:26:22 +0200 Subject: [PATCH 2/3] perf(web): virtualize session switches --- .../components/plan/ChatFeedItems.test.tsx | 71 ++++++++++-- web/src/components/plan/ChatFeedItems.tsx | 104 ++++++++++++------ .../plan/MessageList.continue.test.tsx | 27 +++++ web/src/components/plan/MessageList.tsx | 4 +- 4 files changed, 160 insertions(+), 46 deletions(-) diff --git a/web/src/components/plan/ChatFeedItems.test.tsx b/web/src/components/plan/ChatFeedItems.test.tsx index 819722324..e5f9288a0 100644 --- a/web/src/components/plan/ChatFeedItems.test.tsx +++ b/web/src/components/plan/ChatFeedItems.test.tsx @@ -98,13 +98,13 @@ describe('ChatFeedItems stable keys', () => { }) }) -describe('ChatFeedItems default (virtualization off)', () => { +describe('ChatFeedItems automatic virtualization', () => { beforeEach(() => { useSettingsStore.setState({ settings: {} }) }) - it('mounts every item with no placeholders or sentinel by default', () => { - const items = Array.from({ length: 70 }, (_, i) => msg(`m${i}`, 'user', `Content ${i}`)) + it('mounts every item with no placeholders for a short feed by default', () => { + const items = Array.from({ length: 12 }, (_, i) => msg(`m${i}`, 'user', `Content ${i}`)) const container = document.createElement('div') document.body.appendChild(container) @@ -113,12 +113,28 @@ describe('ChatFeedItems default (virtualization off)', () => { flushSync(() => root.render()) expect(container.querySelector('[data-message-id="m0"]')).toBeTruthy() - expect(container.querySelector('[data-message-id="m69"]')).toBeTruthy() - expect(container.querySelectorAll('.feed-item')).toHaveLength(70) + expect(container.querySelector('[data-message-id="m11"]')).toBeTruthy() + expect(container.querySelectorAll('.feed-item')).toHaveLength(12) expect(container.querySelector('[data-placeholder]')).toBeNull() expect(container.querySelector('[data-testid="feed-sentinel"]')).toBeNull() expect(container.querySelector('[data-testid="feed-unmounted-hint"]')).toBeNull() }) + + it('mounts only four recent items when a feed is long', () => { + const items = Array.from({ length: 20 }, (_, i) => msg(`m${i}`, 'user', `Content ${i}`)) + + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + flushSync(() => root.render()) + + expect(container.querySelector('[data-message-id="m15"]')).toBeNull() + expect(container.querySelector('[data-message-id="m16"]')).toBeTruthy() + expect(container.querySelector('[data-message-id="m19"]')).toBeTruthy() + expect(container.querySelectorAll('.feed-item')).toHaveLength(4) + expect(container.querySelectorAll('[data-placeholder]')).toHaveLength(16) + }) }) describe('ChatFeedItems containment styling', () => { @@ -195,15 +211,40 @@ describe('ChatFeedItems progressive rendering', () => { const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) + const scrollListeners: Array<() => void> = [] + const wheelListeners: Array<(event: WheelEvent) => void> = [] + const viewport = { + scrollTop: 500, + addEventListener: (type: string, cb: (event: WheelEvent) => void) => { + if (type === 'scroll') scrollListeners.push(cb as () => void) + if (type === 'wheel') wheelListeners.push(cb) + }, + removeEventListener: () => {}, + } + const scrollContainerRef = { + current: { + osInstance: () => ({ elements: () => ({ viewport }) }), + getElement: () => null, + }, + } as never - flushSync(() => root.render()) + flushSync(() => root.render()) expect(container.querySelectorAll('.feed-item')).toHaveLength(30) expect(container.querySelector('[data-testid="feed-sentinel"]')).toBeTruthy() - // Each reveal moves the window up by 20 items + // Intersection alone must not reveal history during initial bottom anchoring. act(() => { MockIntersectionObserver.instances.at(-1)!.trigger() }) + expect(container.querySelectorAll('.feed-item')).toHaveLength(30) + + // Once the viewport moves upward, each reveal moves the window by 20 items. + act(() => { + for (const cb of wheelListeners) cb({ deltaY: -100 } as WheelEvent) + viewport.scrollTop = 400 + for (const cb of scrollListeners) cb() + MockIntersectionObserver.instances.at(-1)!.trigger() + }) expect(container.querySelectorAll('.feed-item')).toHaveLength(50) expect(container.querySelector('[data-message-id="m20"]')).toBeTruthy() @@ -282,9 +323,13 @@ describe('ChatFeedItems progressive rendering', () => { const root = createRoot(container) const scrollListeners: Array<() => void> = [] + const wheelListeners: Array<(event: WheelEvent) => void> = [] const viewport = { scrollTop: 0, - addEventListener: (_: string, cb: () => void) => scrollListeners.push(cb), + addEventListener: (type: string, cb: (event: WheelEvent) => void) => { + if (type === 'scroll') scrollListeners.push(cb as () => void) + if (type === 'wheel') wheelListeners.push(cb) + }, removeEventListener: () => {}, } const scrollContainerRef = { @@ -310,6 +355,8 @@ describe('ChatFeedItems progressive rendering', () => { act(() => { viewport.scrollTop = 500 for (const cb of scrollListeners) cb() + viewport.scrollTop = 400 + for (const cb of scrollListeners) cb() MockIntersectionObserver.instances.at(-1)!.trigger() MockIntersectionObserver.instances.at(-1)!.trigger() }) @@ -345,9 +392,13 @@ describe('ChatFeedItems progressive rendering', () => { // OS viewport mock: scrollTop > 4 means the user scrolled up const scrollListeners: Array<() => void> = [] + const wheelListeners: Array<(event: WheelEvent) => void> = [] const viewport = { scrollTop: 0, - addEventListener: (_: string, cb: () => void) => scrollListeners.push(cb), + addEventListener: (type: string, cb: (event: WheelEvent) => void) => { + if (type === 'scroll') scrollListeners.push(cb as () => void) + if (type === 'wheel') wheelListeners.push(cb) + }, removeEventListener: () => {}, } const scrollContainerRef = { @@ -370,6 +421,8 @@ describe('ChatFeedItems progressive rendering', () => { act(() => { viewport.scrollTop = 500 for (const cb of scrollListeners) cb() + viewport.scrollTop = 400 + for (const cb of scrollListeners) cb() MockIntersectionObserver.instances.at(-1)!.trigger() MockIntersectionObserver.instances.at(-1)!.trigger() }) diff --git a/web/src/components/plan/ChatFeedItems.tsx b/web/src/components/plan/ChatFeedItems.tsx index 749b8bd57..7dbe2e5a3 100644 --- a/web/src/components/plan/ChatFeedItems.tsx +++ b/web/src/components/plan/ChatFeedItems.tsx @@ -11,8 +11,13 @@ const ITEM_CONTAINMENT_STYLE = { contentVisibility: 'auto', containIntrinsicSize const PLACEHOLDER_STYLE = { contentVisibility: 'auto', containIntrinsicSize: '160px', minHeight: '160px' } as const // Bottom-anchored virtualization: only the most recent items are mounted at -// load, older items are revealed in batches as the user scrolls up. +// load, older items are revealed in batches as the user scrolls up. Long feeds +// opt into a smaller automatic window even when the experimental setting is +// disabled: a handful of tool-heavy messages can otherwise create thousands +// of DOM nodes and make session switches block the browser's main thread. const INITIAL_RENDER_COUNT = 30 +const AUTO_VIRTUALIZE_THRESHOLD = 12 +const AUTO_INITIAL_RENDER_COUNT = 4 const REVEAL_BATCH_SIZE = 20 const REVEAL_MARGIN = 10 const BULK_APPEND_THRESHOLD = 5 @@ -48,26 +53,29 @@ export const ChatFeedItems = memo(function ChatFeedItems({ }: ChatFeedItemsProps) { const totalItems = displayItems.length const { feedVirtualization } = useDisplaySettings() + const virtualizationEnabled = feedVirtualization || totalItems > AUTO_VIRTUALIZE_THRESHOLD + const initialRenderCount = feedVirtualization ? INITIAL_RENDER_COUNT : AUTO_INITIAL_RENDER_COUNT + const revealBatchSize = feedVirtualization ? REVEAL_BATCH_SIZE : AUTO_INITIAL_RENDER_COUNT // Absolute index of the first mounted item. New items appended at the end // (streaming) keep the window stable — only the reveal moves it up. - const [startIndex, setStartIndex] = useState(() => Math.max(0, totalItems - INITIAL_RENDER_COUNT)) + const [startIndex, setStartIndex] = useState(() => Math.max(0, totalItems - initialRenderCount)) const sentinelRef = useRef(null) const prevItemCountRef = useRef(displayItems.length) const userScrolledRef = useRef(false) - // Virtualization is opt-in: off by default, the full feed renders as before. - const displayStart = feedVirtualization ? startIndex : 0 + const previousScrollTopRef = useRef(0) + const displayStart = virtualizationEnabled ? startIndex : 0 // Only virtualized feeds get content-visibility containment. Off-screen it // freezes element heights at the last-known intrinsic size, so applying it to // dynamically-mutating content (streaming LLM output) leaves stale phantom // gaps below messages. Non-virtualized feeds render at natural height. - const itemContainmentStyle = feedVirtualization ? ITEM_CONTAINMENT_STYLE : undefined + const itemContainmentStyle = virtualizationEnabled ? ITEM_CONTAINMENT_STYLE : undefined // Reset the virtual window when switching sessions. useEffect(() => { - if (!feedVirtualization) return - setStartIndex(Math.max(0, displayItems.length - INITIAL_RENDER_COUNT)) + if (!virtualizationEnabled) return + setStartIndex(Math.max(0, displayItems.length - initialRenderCount)) userScrolledRef.current = false - }, [sessionId]) + }, [initialRenderCount, sessionId, virtualizationEnabled]) // Re-anchor the window when a large batch of items arrives at once (initial // history load). Single-item streaming appends keep the window stable, and @@ -76,39 +84,39 @@ export const ChatFeedItems = memo(function ChatFeedItems({ useEffect(() => { const prev = prevItemCountRef.current prevItemCountRef.current = displayItems.length - if (!feedVirtualization) return + if (!virtualizationEnabled) return if (displayItems.length - prev >= BULK_APPEND_THRESHOLD && !userScrolledRef.current) { - setStartIndex(Math.max(0, displayItems.length - INITIAL_RENDER_COUNT)) + setStartIndex(Math.max(0, displayItems.length - initialRenderCount)) } - }, [displayItems.length, feedVirtualization]) + }, [displayItems.length, initialRenderCount, virtualizationEnabled]) // Clamp when items are removed (truncation, session switch). useEffect(() => { - if (!feedVirtualization) return + if (!virtualizationEnabled) return if (startIndex > 0 && startIndex >= displayItems.length) { - setStartIndex(Math.max(0, displayItems.length - INITIAL_RENDER_COUNT)) + setStartIndex(Math.max(0, displayItems.length - initialRenderCount)) } - }, [displayItems.length, startIndex, feedVirtualization]) + }, [displayItems.length, initialRenderCount, startIndex, virtualizationEnabled]) // Reveal older items in batches while the sentinel approaches the viewport. // The bottom-expanded rootMargin triggers before the user reaches the // placeholder region, so scrolling up never exposes gaps. useEffect(() => { - if (!feedVirtualization) return + if (!virtualizationEnabled) return if (startIndex <= 0 || typeof IntersectionObserver === 'undefined') return const sentinel = sentinelRef.current if (!sentinel) return const observer = new IntersectionObserver( (entries) => { - if (entries.some((entry) => entry.isIntersecting)) { - setStartIndex((index) => Math.max(0, index - REVEAL_BATCH_SIZE)) + if (userScrolledRef.current && entries.some((entry) => entry.isIntersecting)) { + setStartIndex((index) => Math.max(0, index - revealBatchSize)) } }, { rootMargin: '0px 0px 300px 0px' }, ) observer.observe(sentinel) return () => observer.disconnect() - }, [startIndex, feedVirtualization]) + }, [revealBatchSize, startIndex, virtualizationEnabled]) // When the user reaches the very top, keep revealing until everything is // mounted — the sentinel can end up below remaining placeholders, out of the @@ -119,40 +127,64 @@ export const ChatFeedItems = memo(function ChatFeedItems({ startIndexRef.current = startIndex useEffect(() => { - if (!feedVirtualization) return - const container = scrollContainerRef?.current - if (!container) return - const viewport = container.osInstance?.()?.elements().viewport - if (!viewport) return - const onScroll = () => { - if (viewport.scrollTop > 4) { - userScrolledRef.current = true + if (!virtualizationEnabled) return + let frameId: number | undefined + let attempts = 0 + let detach: (() => void) | undefined + + const attach = () => { + const viewport = scrollContainerRef?.current?.osInstance?.()?.elements().viewport + if (!viewport) { + if (attempts++ < 10) frameId = requestAnimationFrame(attach) return } - if (startIndexRef.current > 0) { - setStartIndex((index) => Math.max(0, index - REVEAL_BATCH_SIZE)) + + previousScrollTopRef.current = viewport.scrollTop + const onWheel = (event: WheelEvent) => { + if (event.deltaY < 0) userScrolledRef.current = true } + const onScroll = () => { + const currentTop = viewport.scrollTop + const movedUp = currentTop < previousScrollTopRef.current - 1 + previousScrollTopRef.current = currentTop + if (movedUp) { + userScrolledRef.current = true + } + if (userScrolledRef.current && currentTop <= 4 && startIndexRef.current > 0) { + setStartIndex((index) => Math.max(0, index - revealBatchSize)) + } + } + viewport.addEventListener('wheel', onWheel, { passive: true }) + viewport.addEventListener('scroll', onScroll, { passive: true }) + detach = () => { + viewport.removeEventListener('wheel', onWheel) + viewport.removeEventListener('scroll', onScroll) + } + } + + attach() + return () => { + if (frameId !== undefined) cancelAnimationFrame(frameId) + detach?.() } - viewport.addEventListener('scroll', onScroll, { passive: true }) - return () => viewport.removeEventListener('scroll', onScroll) - }, [scrollContainerRef, feedVirtualization]) + }, [revealBatchSize, scrollContainerRef, sessionId, virtualizationEnabled]) useEffect(() => { - if (!feedVirtualization) return + if (!virtualizationEnabled) return if (startIndex <= 0 || !userScrolledRef.current) return const container = scrollContainerRef?.current const viewport = container?.osInstance?.()?.elements().viewport if (viewport && viewport.scrollTop <= 4) { - setStartIndex((index) => Math.max(0, index - REVEAL_BATCH_SIZE)) + setStartIndex((index) => Math.max(0, index - revealBatchSize)) } - }, [startIndex, scrollContainerRef, feedVirtualization]) + }, [revealBatchSize, startIndex, scrollContainerRef, virtualizationEnabled]) // Timeline navigation: reveal up to a target index when asked. This is the // only active reveal path — highlightedMessageId (ChatFeedItems) has no // non-null caller today, so any future highlight must reveal the target via // this event first (see PlanPanel's MessageList usage). useEffect(() => { - if (!feedVirtualization) return + if (!virtualizationEnabled) return const onRevealRequest = (event: Event) => { const index = (event as CustomEvent<{ index: number }>).detail?.index if (typeof index !== 'number') return @@ -160,7 +192,7 @@ export const ChatFeedItems = memo(function ChatFeedItems({ } window.addEventListener(FEED_REVEAL_EVENT, onRevealRequest) return () => window.removeEventListener(FEED_REVEAL_EVENT, onRevealRequest) - }, [feedVirtualization]) + }, [virtualizationEnabled]) const visibleItems = displayItems.slice(displayStart) diff --git a/web/src/components/plan/MessageList.continue.test.tsx b/web/src/components/plan/MessageList.continue.test.tsx index 80923ba2a..cfae81c12 100644 --- a/web/src/components/plan/MessageList.continue.test.tsx +++ b/web/src/components/plan/MessageList.continue.test.tsx @@ -294,4 +294,31 @@ describe('MessageList paginated history', () => { await waitFor(() => expect(onLoadOlder).toHaveBeenCalledWith(30)) expect(scrollTop).toBe(500) }) + + it('reveals locally virtualized items before requesting another server page', async () => { + const viewport = document.createElement('div') + const placeholder = document.createElement('div') + placeholder.dataset.placeholder = '' + viewport.appendChild(placeholder) + let scrollTop = 500 + Object.defineProperty(viewport, 'scrollHeight', { get: () => 1_000 }) + Object.defineProperty(viewport, 'clientHeight', { get: () => 400 }) + Object.defineProperty(viewport, 'scrollTop', { + get: () => scrollTop, + set: (value: number) => { + scrollTop = value + }, + }) + const onLoadOlder = vi.fn(async () => 2) + + renderMessageList({ hiddenCount: 8, onLoadOlder, viewport }) + + await act(async () => { + scrollTop = 100 + viewport.dispatchEvent(new Event('scroll')) + await Promise.resolve() + }) + + expect(onLoadOlder).not.toHaveBeenCalled() + }) }) diff --git a/web/src/components/plan/MessageList.tsx b/web/src/components/plan/MessageList.tsx index 6d6f771e7..c3f366119 100644 --- a/web/src/components/plan/MessageList.tsx +++ b/web/src/components/plan/MessageList.tsx @@ -220,7 +220,8 @@ export const MessageList = memo(function MessageList({ const currentTop = viewport.scrollTop const movedUp = currentTop < previousScrollTopRef.current - 1 previousScrollTopRef.current = currentTop - if (movedUp && currentTop <= 160) { + const hasUnmountedHistory = viewport.querySelector('[data-placeholder]') !== null + if (movedUp && currentTop <= 160 && !hasUnmountedHistory) { void loadOlder() } } @@ -298,6 +299,7 @@ export const MessageList = memo(function MessageList({ )} Date: Sat, 22 Aug 2026 19:11:45 +0200 Subject: [PATCH 3/3] refactor(web): scope automatic virtualization to paginated history --- web/src/components/plan/ChatFeedItems.test.tsx | 14 +++++++------- web/src/components/plan/ChatFeedItems.tsx | 11 ++++++----- .../components/plan/MessageList.continue.test.tsx | 13 ++++++++++++- web/src/components/plan/MessageList.tsx | 1 + 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/web/src/components/plan/ChatFeedItems.test.tsx b/web/src/components/plan/ChatFeedItems.test.tsx index e5f9288a0..caf0db0b3 100644 --- a/web/src/components/plan/ChatFeedItems.test.tsx +++ b/web/src/components/plan/ChatFeedItems.test.tsx @@ -98,13 +98,13 @@ describe('ChatFeedItems stable keys', () => { }) }) -describe('ChatFeedItems automatic virtualization', () => { +describe('ChatFeedItems paginated-history virtualization', () => { beforeEach(() => { useSettingsStore.setState({ settings: {} }) }) - it('mounts every item with no placeholders for a short feed by default', () => { - const items = Array.from({ length: 12 }, (_, i) => msg(`m${i}`, 'user', `Content ${i}`)) + it('preserves the full feed when virtualization is disabled', () => { + const items = Array.from({ length: 20 }, (_, i) => msg(`m${i}`, 'user', `Content ${i}`)) const container = document.createElement('div') document.body.appendChild(container) @@ -113,21 +113,21 @@ describe('ChatFeedItems automatic virtualization', () => { flushSync(() => root.render()) expect(container.querySelector('[data-message-id="m0"]')).toBeTruthy() - expect(container.querySelector('[data-message-id="m11"]')).toBeTruthy() - expect(container.querySelectorAll('.feed-item')).toHaveLength(12) + expect(container.querySelector('[data-message-id="m19"]')).toBeTruthy() + expect(container.querySelectorAll('.feed-item')).toHaveLength(20) expect(container.querySelector('[data-placeholder]')).toBeNull() expect(container.querySelector('[data-testid="feed-sentinel"]')).toBeNull() expect(container.querySelector('[data-testid="feed-unmounted-hint"]')).toBeNull() }) - it('mounts only four recent items when a feed is long', () => { + it('mounts only four recent items for paginated history', () => { const items = Array.from({ length: 20 }, (_, i) => msg(`m${i}`, 'user', `Content ${i}`)) const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) - flushSync(() => root.render()) + flushSync(() => root.render()) expect(container.querySelector('[data-message-id="m15"]')).toBeNull() expect(container.querySelector('[data-message-id="m16"]')).toBeTruthy() diff --git a/web/src/components/plan/ChatFeedItems.tsx b/web/src/components/plan/ChatFeedItems.tsx index 7dbe2e5a3..c9650158c 100644 --- a/web/src/components/plan/ChatFeedItems.tsx +++ b/web/src/components/plan/ChatFeedItems.tsx @@ -11,12 +11,11 @@ const ITEM_CONTAINMENT_STYLE = { contentVisibility: 'auto', containIntrinsicSize const PLACEHOLDER_STYLE = { contentVisibility: 'auto', containIntrinsicSize: '160px', minHeight: '160px' } as const // Bottom-anchored virtualization: only the most recent items are mounted at -// load, older items are revealed in batches as the user scrolls up. Long feeds -// opt into a smaller automatic window even when the experimental setting is -// disabled: a handful of tool-heavy messages can otherwise create thousands +// load, older items are revealed in batches as the user scrolls up. Paginated +// history uses a smaller automatic window even when the experimental setting +// is disabled: a handful of tool-heavy messages can otherwise create thousands // of DOM nodes and make session switches block the browser's main thread. const INITIAL_RENDER_COUNT = 30 -const AUTO_VIRTUALIZE_THRESHOLD = 12 const AUTO_INITIAL_RENDER_COUNT = 4 const REVEAL_BATCH_SIZE = 20 const REVEAL_MARGIN = 10 @@ -26,6 +25,7 @@ interface ChatFeedItemsProps { displayItems: DisplayItem[] highlightedMessageId?: string | null sessionId?: string | null + paginatedHistory?: boolean scrollContainerRef?: React.RefObject | null> showThinking?: boolean showVerboseToolOutput?: boolean @@ -44,6 +44,7 @@ export const ChatFeedItems = memo(function ChatFeedItems({ displayItems, highlightedMessageId = null, sessionId, + paginatedHistory = false, scrollContainerRef, showThinking = true, showVerboseToolOutput = true, @@ -53,7 +54,7 @@ export const ChatFeedItems = memo(function ChatFeedItems({ }: ChatFeedItemsProps) { const totalItems = displayItems.length const { feedVirtualization } = useDisplaySettings() - const virtualizationEnabled = feedVirtualization || totalItems > AUTO_VIRTUALIZE_THRESHOLD + const virtualizationEnabled = feedVirtualization || paginatedHistory const initialRenderCount = feedVirtualization ? INITIAL_RENDER_COUNT : AUTO_INITIAL_RENDER_COUNT const revealBatchSize = feedVirtualization ? REVEAL_BATCH_SIZE : AUTO_INITIAL_RENDER_COUNT // Absolute index of the first mounted item. New items appended at the end diff --git a/web/src/components/plan/MessageList.continue.test.tsx b/web/src/components/plan/MessageList.continue.test.tsx index cfae81c12..a1cbca5b1 100644 --- a/web/src/components/plan/MessageList.continue.test.tsx +++ b/web/src/components/plan/MessageList.continue.test.tsx @@ -103,7 +103,11 @@ vi.mock('../../stores/settings', () => ({ })) vi.mock('./ChatFeedItems', () => ({ - ChatFeedItems: () =>

ChatFeedItems
, + ChatFeedItems: ({ paginatedHistory }: { paginatedHistory?: boolean }) => ( +
+ ChatFeedItems +
+ ), })) vi.mock('../shared/ScrollArea', () => ({ @@ -260,6 +264,13 @@ describe('MessageList paginated history', () => { screen.getByRole('button', { name: 'Load older history (8 remaining)' }).click() await waitFor(() => expect(onLoadOlder).toHaveBeenCalledWith(30)) + expect(screen.getByTestId('chat-feed').getAttribute('data-paginated-history')).toBe('true') + }) + + it('does not force virtualization when the full history is already present', () => { + renderMessageList({ hiddenCount: 0 }) + + expect(screen.getByTestId('chat-feed').getAttribute('data-paginated-history')).toBe('false') }) it('loads near the top only while the user is scrolling upward and preserves the viewport', async () => { diff --git a/web/src/components/plan/MessageList.tsx b/web/src/components/plan/MessageList.tsx index c3f366119..e19eaeba1 100644 --- a/web/src/components/plan/MessageList.tsx +++ b/web/src/components/plan/MessageList.tsx @@ -303,6 +303,7 @@ export const MessageList = memo(function MessageList({ displayItems={displayItems} highlightedMessageId={highlightedMessageId} sessionId={sessionId} + paginatedHistory={hiddenCount > 0} scrollContainerRef={scrollContainerRef} showThinking={showThinking} showVerboseToolOutput={showVerboseToolOutput}