From a3556b87118e33b8a12b10a99caffbd17b4b2765 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sun, 2 Aug 2026 01:24:45 +0800 Subject: [PATCH 1/2] fix(core): ride indivisible transcript pages over the read budget Backward history pagination dead-ended with HTTP 413 whenever a single turn exceeded the 4 MiB page budget: a turn cannot be split across pages, so the reader threw SessionTranscriptPageTooLargeError and the Web Shell latched a permanent pagination error banner. Take at least one indivisible unit per page (one aggregate record forward, one turn backward) so pagination always makes progress; the 32 MiB response serialization cap remains the hard ceiling. Co-authored-by: Qwen-Coder --- .../serve/multi-workspace-sessions.test.ts | 37 +++++++++++- .../session-transcript-reader.test.ts | 56 +++++++++++-------- .../src/services/session-transcript-reader.ts | 38 ++++--------- 3 files changed, 79 insertions(+), 52 deletions(-) diff --git a/packages/cli/src/serve/multi-workspace-sessions.test.ts b/packages/cli/src/serve/multi-workspace-sessions.test.ts index 2ea6e7eeb3c..3c5ee6b2847 100644 --- a/packages/cli/src/serve/multi-workspace-sessions.test.ts +++ b/packages/cli/src/serve/multi-workspace-sessions.test.ts @@ -3043,7 +3043,7 @@ describe('multi-workspace session dispatch', () => { sessionId, cwd: SECONDARY_CWD, timestamp: '2026-07-08T00:00:00.000Z', - prompt: 'x'.repeat(4 * 1024 * 1024), + prompt: 'x'.repeat(33 * 1024 * 1024), mtime: new Date('2026-07-08T00:00:00.000Z'), }); const { app, secondaryBridge } = makeHarness({ @@ -3058,7 +3058,7 @@ describe('multi-workspace session dispatch', () => { expect(response.body).toMatchObject({ code: 'transcript_page_too_large', sessionId, - maxBytes: 4 * 1024 * 1024, + maxBytes: 32 * 1024 * 1024, }); expect(response.body.pageBytes).toBeGreaterThan( response.body.maxBytes as number, @@ -3068,6 +3068,39 @@ describe('multi-workspace session dispatch', () => { }); }); + it('serves an indivisible record that exceeds the reader page budget', async () => { + await withRuntimeDir(async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440280'; + const prompt = 'x'.repeat(5 * 1024 * 1024); + await writeStoredSession({ + sessionId, + cwd: SECONDARY_CWD, + timestamp: '2026-07-08T00:00:00.000Z', + prompt, + mtime: new Date('2026-07-08T00:00:00.000Z'), + }); + const { app, secondaryBridge } = makeHarness({ + secondaryTrusted: false, + }); + + const response = await request(app) + .get(`/workspaces/secondary-id/session/${sessionId}/transcript`) + .set('Host', host()); + + // A single record cannot be split, so it rides over the 4 MiB reader + // budget (hard ceiling remains the 32 MiB serialization cap). + expect(response.status).toBe(200); + expect( + response.body.events.some( + (event: { data?: { content?: { text?: string } } }) => + event.data?.content?.text?.length === prompt.length, + ), + ).toBe(true); + expect(secondaryBridge.spawnCalls).toEqual([]); + expect(secondaryBridge.restoreCalls).toEqual([]); + }); + }); + it('enforces the workspace transcript cursor byte boundary', () => { expect( workspaceTranscriptCursorExceedsLimitForTesting( diff --git a/packages/core/src/services/session-transcript-reader.test.ts b/packages/core/src/services/session-transcript-reader.test.ts index 7b8d6f24d6a..0def8106a2c 100644 --- a/packages/core/src/services/session-transcript-reader.test.ts +++ b/packages/core/src/services/session-transcript-reader.test.ts @@ -31,7 +31,6 @@ import { resetSessionTranscriptIndexCacheForTest, setSessionTranscriptIndexCacheMaxBytesForTest, SessionTranscriptCursorCodec, - SessionTranscriptPageTooLargeError, SessionTranscriptSnapshotUnavailableError, SessionTranscriptReader, } from './session-transcript-reader.js'; @@ -229,7 +228,7 @@ describe('SessionTranscriptReader', () => { expect(second.hasMore).toBe(false); }); - it('rejects a single aggregate record over the page byte budget', async () => { + it('returns a single aggregate record that exceeds the page byte budget', async () => { const first = record('u1', null, 'first'); const second = record('u1', null, 'second fragment'); await writeRecords([first, second, record('a1', 'u1', 'reply')]); @@ -237,17 +236,17 @@ describe('SessionTranscriptReader', () => { Buffer.byteLength(JSON.stringify(first)) + Buffer.byteLength(JSON.stringify(second)); - await expect( - new SessionTranscriptReader(workspaceDir).readPage(sessionId, { + const page = await new SessionTranscriptReader(workspaceDir).readPage( + sessionId, + { limit: 1, maxBytes: aggregateBytes - 1, - }), - ).rejects.toMatchObject({ - name: 'SessionTranscriptPageTooLargeError', - sessionId, - pageBytes: aggregateBytes, - maxBytes: aggregateBytes - 1, - } satisfies Partial); + }, + ); + + // An indivisible record rides over budget rather than dead-ending the page. + expect(page.records.map((item) => item.uuid)).toEqual(['u1']); + expect(page.hasMore).toBe(true); }); it('pages only the active parentUuid chain and skips abandoned branches', async () => { @@ -595,7 +594,7 @@ describe('SessionTranscriptReader', () => { expect(page.hasMore).toBe(false); }); - it('rejects a backward turn that exceeds maxBytes after alignment', async () => { + it('returns a backward turn that exceeds maxBytes after alignment', async () => { const toolCall = record('a-tool', 'u1', 'call tool'); const toolResult = { ...record('t1', 'a-tool', 'tool result'), @@ -610,13 +609,23 @@ describe('SessionTranscriptReader', () => { record('u2', 'a-final', 'next prompt'), ]); - await expect( - new SessionTranscriptReader(workspaceDir).readPage(sessionId, { + const page = await new SessionTranscriptReader(workspaceDir).readPage( + sessionId, + { beforeRecordId: 'u2', limit: 2, maxBytes: Buffer.byteLength(JSON.stringify(finalAnswer)), - }), - ).rejects.toBeInstanceOf(SessionTranscriptPageTooLargeError); + }, + ); + + // The turn cannot be split across pages, so it rides over budget whole. + expect(page.records.map((item) => item.uuid)).toEqual([ + 'u1', + 'a-tool', + 't1', + 'a-final', + ]); + expect(page.hasMore).toBe(false); }); it('rejects a backward boundary outside the active chain', async () => { @@ -1009,12 +1018,15 @@ describe('SessionTranscriptReader', () => { `${gluedLine}\n${JSON.stringify(record('a1', 'u1', 'reply'))}\n`, ); - await expect( - new SessionTranscriptReader(workspaceDir).readPage(sessionId, { - limit: 1, - maxBytes: Buffer.byteLength(gluedLine) * 2 - 1, - }), - ).rejects.toBeInstanceOf(SessionTranscriptPageTooLargeError); + const page = await new SessionTranscriptReader(workspaceDir).readPage( + sessionId, + { limit: 2, maxBytes: Buffer.byteLength(gluedLine) * 2 }, + ); + + // Conservative per-fragment counting spends the whole budget on the glued + // aggregate, so the next record must wait for the following page. + expect(page.records.map((item) => item.uuid)).toEqual(['u1']); + expect(page.hasMore).toBe(true); }); it('skips non-ChatRecord JSON lines while indexing', async () => { diff --git a/packages/core/src/services/session-transcript-reader.ts b/packages/core/src/services/session-transcript-reader.ts index 25deaa125d0..8bb3d7b7f8d 100644 --- a/packages/core/src/services/session-transcript-reader.ts +++ b/packages/core/src/services/session-transcript-reader.ts @@ -460,7 +460,6 @@ function recordSegmentBytes(index: TranscriptIndex, uuid: string): number { function selectPageUuids( index: TranscriptIndex, - sessionId: string, position: number, limit: number, maxBytes: number | undefined, @@ -472,10 +471,9 @@ function selectPageUuids( let selectedBytes = 0; for (const uuid of candidates) { const bytes = recordSegmentBytes(index, uuid); - if (selected.length === 0 && bytes > maxBytes) { - throw new SessionTranscriptPageTooLargeError(sessionId, bytes, maxBytes); - } - if (selectedBytes + bytes > maxBytes) break; + // A single aggregate record may itself exceed the budget; it cannot be + // split, so always take at least one record or pagination dead-ends. + if (selected.length > 0 && selectedBytes + bytes > maxBytes) break; selected.push(uuid); selectedBytes += bytes; } @@ -489,7 +487,6 @@ function isReplayTurnStart(index: TranscriptIndex, uuid: string): boolean { function selectBackwardPageUuids( index: TranscriptIndex, - sessionId: string, position: number, limit: number, maxBytes: number | undefined, @@ -510,14 +507,15 @@ function selectBackwardPageUuids( for (let i = position - 1; i >= start; i--) { const uuid = index.activeUuids[i]!; const bytes = recordSegmentBytes(index, uuid); + // A turn cannot be split across pages; always take at least one record + // so an oversized turn cannot dead-end backward pagination. if ( - selectedStart === position && + selectedStart < position && maxBytes !== undefined && - bytes > maxBytes + selectedBytes + bytes > maxBytes ) { - throw new SessionTranscriptPageTooLargeError(sessionId, bytes, maxBytes); + break; } - if (maxBytes !== undefined && selectedBytes + bytes > maxBytes) break; selectedStart = i; selectedBytes += bytes; } @@ -530,7 +528,6 @@ function selectBackwardPageUuids( break; } } - let expandedSelection = false; if (alignedToReplayBoundary && selectedStart > 0) { let previousTurnStart = selectedStart - 1; while ( @@ -541,7 +538,6 @@ function selectBackwardPageUuids( } if (previousTurnStart < 0) { selectedStart = 0; - expandedSelection = true; } } else if (!alignedToReplayBoundary) { while ( @@ -550,19 +546,6 @@ function selectBackwardPageUuids( ) { selectedStart--; } - expandedSelection = true; - } - if (expandedSelection && maxBytes !== undefined) { - const alignedBytes = index.activeUuids - .slice(selectedStart, position) - .reduce((total, uuid) => total + recordSegmentBytes(index, uuid), 0); - if (alignedBytes > maxBytes) { - throw new SessionTranscriptPageTooLargeError( - sessionId, - alignedBytes, - maxBytes, - ); - } } return { @@ -1124,11 +1107,10 @@ export class SessionTranscriptReader { } const backwardPage = direction === 'backward' - ? selectBackwardPageUuids(index, sessionId, position, limit, maxBytes) + ? selectBackwardPageUuids(index, position, limit, maxBytes) : undefined; const pageUuids = - backwardPage?.uuids ?? - selectPageUuids(index, sessionId, position, limit, maxBytes); + backwardPage?.uuids ?? selectPageUuids(index, position, limit, maxBytes); const nextPosition = backwardPage?.nextPosition ?? position + pageUuids.length; const records = await readAggregatedRecords(index, pageUuids); From db2db81aaa642cce265ab03217df02f844b8eea8 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sun, 2 Aug 2026 01:25:03 +0800 Subject: [PATCH 2/2] feat(web-shell): add retry button for history pagination error A non-retryable transcript page failure (4xx, partial replay) latched paginationError with no in-UI recovery short of reloading the session. The banner now offers a retry that force-clears the latch and refetches the same page, whose cursor was never advanced by the failed attempt. Co-authored-by: Qwen-Coder --- .../components/MessageList.dom.test.tsx | 23 +- .../client/components/MessageList.module.css | 16 + .../client/components/MessageList.tsx | 25 +- packages/web-shell/client/i18n.tsx | 2 + .../session/DaemonSessionProvider.test.tsx | 70 ++++ .../daemon/session/DaemonSessionProvider.tsx | 298 +++++++++--------- 6 files changed, 285 insertions(+), 149 deletions(-) diff --git a/packages/web-shell/client/components/MessageList.dom.test.tsx b/packages/web-shell/client/components/MessageList.dom.test.tsx index 7f72061e5d1..89e7cd639e1 100644 --- a/packages/web-shell/client/components/MessageList.dom.test.tsx +++ b/packages/web-shell/client/components/MessageList.dom.test.tsx @@ -227,7 +227,7 @@ function mount( loadingOlderHistory?: boolean; historyCapacityReached?: boolean; historyPaginationError?: boolean; - onLoadOlderHistory?: () => Promise; + onLoadOlderHistory?: (options?: { force?: boolean }) => Promise; transcriptBlockCount?: number; transcriptActivity?: { getSnapshot(): { @@ -1705,6 +1705,27 @@ describe('MessageList — turn collapse (DOM)', () => { expect(onLoadOlderHistory).not.toHaveBeenCalled(); }); + it('retries loading older history with force when the retry button is clicked', async () => { + const onLoadOlderHistory = vi.fn().mockResolvedValue(undefined); + const c = mount([userMsg('u1')], undefined, { + historyPaginationError: true, + onLoadOlderHistory, + }); + + const button = Array.from(c.querySelectorAll('button')).find( + (el) => el.textContent === 'Retry', + ); + expect(button).toBeDefined(); + + await act(async () => { + button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + expect(onLoadOlderHistory).toHaveBeenCalledWith({ force: true }); + }); + it('does not smooth-scroll when existing session history loads after an empty render', () => { const scrollTo = vi.fn(); let scrollTop = 0; diff --git a/packages/web-shell/client/components/MessageList.module.css b/packages/web-shell/client/components/MessageList.module.css index 4a3519f0f4f..9195f7d83ff 100644 --- a/packages/web-shell/client/components/MessageList.module.css +++ b/packages/web-shell/client/components/MessageList.module.css @@ -35,6 +35,22 @@ padding: 4px 0 12px; } +.historyRetryButton { + margin-left: 8px; + padding: 2px 10px; + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + font: inherit; + font-size: 12px; + color: var(--muted-foreground); +} + +.historyRetryButton:hover { + background: var(--subtle-bg, rgba(128, 128, 128, 0.06)); +} + .list > * { width: min( 100%, diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index 1ec29cc1cf5..36c3ff16a77 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -64,7 +64,7 @@ interface MessageListProps { loadingOlderHistory?: boolean; historyCapacityReached?: boolean; historyPaginationError?: boolean; - onLoadOlderHistory?: () => Promise; + onLoadOlderHistory?: (options?: { force?: boolean }) => Promise; transcriptBlockCount?: number; transcriptActivity?: { getSnapshot(): { @@ -3186,14 +3186,14 @@ export const MessageList = memo( }, [visibleItems, headerOffset, performScrollToRow]); const loadOlderHistory = useCallback( - async (allowRetry = false) => { + async (allowRetry = false, force = false) => { const el = containerRef.current; if ( !el || !onLoadOlderHistory || loadingOlderHistory || olderHistoryLoadInFlight.current || - historyPaginationError || + (historyPaginationError && !force) || (olderHistoryRetryBlocked.current && !allowRetry) ) { return; @@ -3205,7 +3205,7 @@ export const MessageList = memo( const previousTop = el.scrollTop; followPausedByUserRef.current = true; try { - await onLoadOlderHistory(); + await onLoadOlderHistory(force ? { force: true } : undefined); setOlderHistoryAnchor({ scrollHeight: previousHeight, scrollTop: previousTop, @@ -3220,6 +3220,10 @@ export const MessageList = memo( [loadingOlderHistory, onLoadOlderHistory, historyPaginationError], ); + const retryOlderHistory = useCallback(() => { + void loadOlderHistory(true, true); + }, [loadOlderHistory]); + // Rules 2 & 3: detect scroll direction to toggle follow mode. // Runs synchronously in the scroll handler — no rAF needed since // the browser already coalesces scroll events. @@ -3846,8 +3850,17 @@ export const MessageList = memo( {historyPaginationError && !showLoadingSkeleton && !historyCapacityReached && ( -
- {t('history.paginationError')} +
+ {t('history.paginationError')} + {onLoadOlderHistory && ( + + )}
)} { expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledTimes(1); }); + it('retries a latched pagination failure when loadMore is forced', async () => { + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_transcript_pagination'], + }); + const replayEvent = (id: number, text: string): DaemonEvent => ({ + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text }, + _meta: { 'qwen.session.recordId': `record-${id}` }, + }, + }, + }); + const session = createMockSession({ + sessionId: 'session-retried-history-page', + historyHasMore: true, + replaySnapshot: { + compactedReplay: [replayEvent(2, 'recent prompt')], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage + .mockRejectedValueOnce(new DaemonHttpError(403, undefined, 'Forbidden')) + .mockResolvedValueOnce({ + v: 1, + sessionId: session.sessionId, + events: [replayEvent(1, 'older prompt')], + hasMore: false, + }); + let history: ReturnType | undefined; + function Harness() { + history = useDaemonTranscriptHistory(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + historyPageSize: 25, + }); + await act(async () => { + await expect(history?.loadMore()).rejects.toThrow('Forbidden'); + await flushPromises(); + }); + expect(history?.paginationError).toBe(true); + expect(history?.hasMore).toBe(false); + + await act(async () => { + await history?.loadMore({ force: true }); + await flushPromises(); + }); + + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledTimes(2); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenNthCalledWith( + 2, + session.sessionId, + { + beforeRecordId: 'record-2', + limit: 25, + clientId: session.clientId, + }, + ); + expect(history?.paginationError).toBe(false); + expect(history?.hasMore).toBe(false); + }); + it('skips malformed older-page events and advances by record boundary', async () => { sdkMocks.capabilities.mockResolvedValue({ workspaceCwd: '/mock-workspace', diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 94436c13251..4a4656d2e9b 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -121,7 +121,7 @@ export interface DaemonTranscriptHistory { loading: boolean; capacityReached: boolean; paginationError: boolean; - loadMore(): Promise; + loadMore(options?: { force?: boolean }): Promise; } const SESSION_TRANSCRIPT_PAGINATION_FEATURE = 'session_transcript_pagination'; @@ -2409,163 +2409,177 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { store, ], ); - const loadMoreTranscript = useCallback(async () => { - const history = transcriptHistoryRef.current; - const activeSession = sessionRef.current; - if ( - !history.hasMore || - history.loading || - history.paginationError || - !activeSession || - activeSession.sessionId !== history.sessionId - ) { - return; - } - - history.loading = true; - setTranscriptHistoryState({ - hasMore: true, - loading: true, - capacityReached: false, - paginationError: false, - }); - let terminalFailure = false; - try { - const page = await activeSession.client.getSessionTranscriptPage( - activeSession.sessionId, - { - ...(history.cursor !== undefined - ? { cursor: history.cursor } - : history.beforeRecordId !== undefined - ? { beforeRecordId: history.beforeRecordId } - : {}), - limit: historyPageSizeRef.current ?? 100, - clientId: activeSession.clientId, - }, - ); + const loadMoreTranscript = useCallback( + async (options?: { force?: boolean }) => { + const history = transcriptHistoryRef.current; + const activeSession = sessionRef.current; if ( - sessionRef.current !== activeSession || - transcriptHistoryRef.current !== history + history.loading || + !activeSession || + activeSession.sessionId !== history.sessionId ) { return; } - if (page.partial || page.replayError) { - terminalFailure = true; - throw new Error( - page.replayError ?? 'Earlier session history was only partially read', - ); + if (history.paginationError) { + if (options?.force !== true) { + return; + } + // The failed page's cursor was never advanced, so clearing the + // latched error retries that exact page. + history.paginationError = false; + history.hasMore = true; + } else if (!history.hasMore) { + return; } - const replayOpts = { - ...eventOptionsRef.current, - suppressOwnUserEcho: false, - }; - const nextBeforeRecordId = page.events - .map(getPersistedReplayRecordId) - .find((recordId): recordId is string => recordId !== undefined); - const uiEvents: DaemonUiEvent[] = []; - for (const replayEvent of page.events) { - try { - const transcriptEvents = filterDaemonUiEventsForTranscript( - replayEvent, - normalizeAndFilterEvent( - replayEvent, - activeSession.clientId, - replayOpts, - setConnection, - { updateConnection: false }, - ), - addNotice, - dismissNotice, - ); - uiEvents.push( - ...(subagentTranscriptModeRef.current === 'summary' - ? projectMainTranscriptEvents(transcriptEvents) - : transcriptEvents), + history.loading = true; + setTranscriptHistoryState({ + hasMore: true, + loading: true, + capacityReached: false, + paginationError: false, + }); + let terminalFailure = false; + try { + const page = await activeSession.client.getSessionTranscriptPage( + activeSession.sessionId, + { + ...(history.cursor !== undefined + ? { cursor: history.cursor } + : history.beforeRecordId !== undefined + ? { beforeRecordId: history.beforeRecordId } + : {}), + limit: historyPageSizeRef.current ?? 100, + clientId: activeSession.clientId, + }, + ); + if ( + sessionRef.current !== activeSession || + transcriptHistoryRef.current !== history + ) { + return; + } + if (page.partial || page.replayError) { + terminalFailure = true; + throw new Error( + page.replayError ?? + 'Earlier session history was only partially read', ); - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - addNotice({ - severity: 'warning', - category: 'protocol', - operation: 'normalize_event', - code: 'daemon.replay_event_malformed', - message: 'Skipped malformed history event', - debugMessage: message, - recoverable: true, + } + + const replayOpts = { + ...eventOptionsRef.current, + suppressOwnUserEcho: false, + }; + const nextBeforeRecordId = page.events + .map(getPersistedReplayRecordId) + .find((recordId): recordId is string => recordId !== undefined); + const uiEvents: DaemonUiEvent[] = []; + for (const replayEvent of page.events) { + try { + const transcriptEvents = filterDaemonUiEventsForTranscript( + replayEvent, + normalizeAndFilterEvent( + replayEvent, + activeSession.clientId, + replayOpts, + setConnection, + { updateConnection: false }, + ), + addNotice, + dismissNotice, + ); + uiEvents.push( + ...(subagentTranscriptModeRef.current === 'summary' + ? projectMainTranscriptEvents(transcriptEvents) + : transcriptEvents), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + addNotice({ + severity: 'warning', + category: 'protocol', + operation: 'normalize_event', + code: 'daemon.replay_event_malformed', + message: 'Skipped malformed history event', + debugMessage: message, + recoverable: true, + }); + console.warn( + '[DaemonSessionProvider] skipped malformed history event:', + error, + ); + } + } + if ( + uiEvents.length > 0 && + !prependTranscriptHistory(store, uiEvents, maxBlocks) + ) { + history.hasMore = false; + history.loading = false; + history.capacityReached = true; + setTranscriptHistoryState({ + hasMore: false, + loading: false, + capacityReached: true, + paginationError: false, }); - console.warn( - '[DaemonSessionProvider] skipped malformed history event:', - error, - ); + return; } - } - if ( - uiEvents.length > 0 && - !prependTranscriptHistory(store, uiEvents, maxBlocks) - ) { - history.hasMore = false; + const hasCapacity = store.getSnapshot().blocks.length < maxBlocks; + history.capacityReached = page.hasMore && !hasCapacity; + history.cursor = + nextBeforeRecordId === undefined ? page.nextCursor : undefined; + history.beforeRecordId = nextBeforeRecordId; + history.hasMore = page.hasMore && hasCapacity; history.loading = false; - history.capacityReached = true; setTranscriptHistoryState({ - hasMore: false, + hasMore: history.hasMore, loading: false, - capacityReached: true, + capacityReached: history.capacityReached, paginationError: false, }); - return; - } - const hasCapacity = store.getSnapshot().blocks.length < maxBlocks; - history.capacityReached = page.hasMore && !hasCapacity; - history.cursor = - nextBeforeRecordId === undefined ? page.nextCursor : undefined; - history.beforeRecordId = nextBeforeRecordId; - history.hasMore = page.hasMore && hasCapacity; - history.loading = false; - setTranscriptHistoryState({ - hasMore: history.hasMore, - loading: false, - capacityReached: history.capacityReached, - paginationError: false, - }); - } catch (error) { - if ( - sessionRef.current !== activeSession || - transcriptHistoryRef.current !== history - ) { - return; - } - const retryable = - !terminalFailure && - (!(error instanceof DaemonHttpError) || - error.status >= 500 || - error.status === 408 || - error.status === 429); - history.hasMore = retryable; - history.loading = false; - history.capacityReached = false; - history.paginationError = !retryable; - setTranscriptHistoryState({ - hasMore: retryable, - loading: false, - capacityReached: false, - paginationError: !retryable, - }); - if (retryable) { - addNotice({ - severity: 'warning', - category: 'user_action', - operation: 'load_session', - code: 'daemon.transcript_history.failed', - message: 'Failed to load earlier session history', - debugMessage: error instanceof Error ? error.message : String(error), - recoverable: retryable, + } catch (error) { + if ( + sessionRef.current !== activeSession || + transcriptHistoryRef.current !== history + ) { + return; + } + const retryable = + !terminalFailure && + (!(error instanceof DaemonHttpError) || + error.status >= 500 || + error.status === 408 || + error.status === 429); + history.hasMore = retryable; + history.loading = false; + history.capacityReached = false; + history.paginationError = !retryable; + setTranscriptHistoryState({ + hasMore: retryable, + loading: false, + capacityReached: false, + paginationError: !retryable, }); + if (retryable) { + addNotice({ + severity: 'warning', + category: 'user_action', + operation: 'load_session', + code: 'daemon.transcript_history.failed', + message: 'Failed to load earlier session history', + debugMessage: + error instanceof Error ? error.message : String(error), + recoverable: retryable, + }); + } + throw error; } - throw error; - } - }, [addNotice, dismissNotice, maxBlocks, store]); + }, + [addNotice, dismissNotice, maxBlocks, store], + ); const transcriptHistoryValue = useMemo(() => { const active = connection.sessionId === transcriptHistoryRef.current.sessionId &&