diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 8201acaa41..749fee1695 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -253,6 +253,7 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ hasMore: false, loading: false, capacityReached: false, + paginationError: false, loadMore: vi.fn(), release: vi.fn(), }), diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index bd94367388..a24617a42e 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -7630,6 +7630,8 @@ export function App({ historyCapacityReached={ transcriptHistory.capacityReached } + historyPaginationError={ + transcriptHistory.paginationError} onLoadOlderHistory={transcriptHistory.loadMore} transcriptBlockCount={blocks.length} transcriptActivity={store} diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index 20da69f543..6e7d4e2e57 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -72,6 +72,7 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ hasMore: false, loading: false, capacityReached: false, + paginationError: false, loadMore: vi.fn(), release: vi.fn(), }), diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index 7df9f69357..1252404b2d 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -590,6 +590,7 @@ export function ChatPane({ hasOlderHistory={transcriptHistory.hasMore} loadingOlderHistory={transcriptHistory.loading} historyCapacityReached={transcriptHistory.capacityReached} + historyPaginationError={transcriptHistory.paginationError} onLoadOlderHistory={transcriptHistory.loadMore} transcriptBlockCount={blocks.length} transcriptActivity={store} diff --git a/packages/web-shell/client/components/MessageList.dom.test.tsx b/packages/web-shell/client/components/MessageList.dom.test.tsx index cfaf92627f..a4a9754ce5 100644 --- a/packages/web-shell/client/components/MessageList.dom.test.tsx +++ b/packages/web-shell/client/components/MessageList.dom.test.tsx @@ -203,6 +203,7 @@ function mount( hasOlderHistory?: boolean; loadingOlderHistory?: boolean; historyCapacityReached?: boolean; + historyPaginationError?: boolean; onLoadOlderHistory?: () => Promise; transcriptBlockCount?: number; transcriptActivity?: { @@ -243,6 +244,7 @@ function mount( hasOlderHistory={opts.hasOlderHistory} loadingOlderHistory={opts.loadingOlderHistory} historyCapacityReached={opts.historyCapacityReached} + historyPaginationError={opts.historyPaginationError} onLoadOlderHistory={opts.onLoadOlderHistory} transcriptBlockCount={opts.transcriptBlockCount} transcriptActivity={opts.transcriptActivity} @@ -1578,6 +1580,50 @@ describe('MessageList — turn collapse (DOM)', () => { ); }); + it('shows a persistent error when history pagination fails', () => { + const c = mount([userMsg('u1')], undefined, { + historyPaginationError: true, + }); + expect(c.querySelector('[role="status"]')?.textContent).toBe( + 'Earlier history could not be loaded.', + ); + }); + + it('does not auto-load older history when a pagination error is present', async () => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + value: 300, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 600, + }); + const onLoadOlderHistory = vi.fn().mockResolvedValue(undefined); + // historyPaginationError is true, hasOlderHistory is true + const c = mount([userMsg('u1')], undefined, { + hasOlderHistory: true, + historyPaginationError: true, + onLoadOlderHistory, + }); + + const list = c.querySelector( + '[data-web-shell-message-list]', + ) as HTMLElement; + Object.defineProperty(list, 'scrollTop', { + configurable: true, + writable: true, + value: 0, + }); + + await act(async () => { + list.dispatchEvent(new Event('scroll')); + await Promise.resolve(); + }); + + // It should NOT call loadMore because paginationError blocks it + expect(onLoadOlderHistory).not.toHaveBeenCalled(); + }); + 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.tsx b/packages/web-shell/client/components/MessageList.tsx index 2184e5cf0e..af314b45ef 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -63,6 +63,7 @@ interface MessageListProps { hasOlderHistory?: boolean; loadingOlderHistory?: boolean; historyCapacityReached?: boolean; + historyPaginationError?: boolean; onLoadOlderHistory?: () => Promise; transcriptBlockCount?: number; transcriptActivity?: { @@ -2184,6 +2185,7 @@ export const MessageList = memo( hasOlderHistory = false, loadingOlderHistory = false, historyCapacityReached = false, + historyPaginationError = false, onLoadOlderHistory, transcriptBlockCount = 0, transcriptActivity, @@ -3171,6 +3173,7 @@ export const MessageList = memo( !onLoadOlderHistory || loadingOlderHistory || olderHistoryLoadInFlight.current || + historyPaginationError || (olderHistoryRetryBlocked.current && !allowRetry) ) { return; @@ -3194,7 +3197,7 @@ export const MessageList = memo( setSuppressOlderHistoryLoadingStatus(false); } }, - [loadingOlderHistory, onLoadOlderHistory], + [loadingOlderHistory, onLoadOlderHistory, historyPaginationError], ); // Rules 2 & 3: detect scroll direction to toggle follow mode. @@ -3811,6 +3814,13 @@ export const MessageList = memo( {t('history.capacityReached')} )} + {historyPaginationError && + !showLoadingSkeleton && + !historyCapacityReached && ( +
+ {t('history.paginationError')} +
+ )} { expect(history?.hasMore).toBe(false); }); + it('latches a non-retryable transcript page failure', 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-forbidden-history-page', + historyHasMore: true, + replaySnapshot: { + compactedReplay: [replayEvent(2, 'recent prompt')], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(session); + sdkMocks.getSessionTranscriptPage.mockRejectedValue( + new DaemonHttpError(403, undefined, 'Forbidden'), + ); + 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(); + await flushPromises(); + }); + expect(sdkMocks.getSessionTranscriptPage).toHaveBeenCalledTimes(1); + }); + it('skips malformed older-page events and advances the cursor', async () => { sdkMocks.capabilities.mockResolvedValue({ workspaceCwd: '/mock-workspace', @@ -9283,11 +9338,8 @@ describe('DaemonSessionProvider', () => { blocks.map((block) => ('text' in block ? block.text : undefined)), ).toEqual(['recent prompt']); expect(history?.hasMore).toBe(false); - expect(notices.at(-1)).toMatchObject({ - code: 'daemon.transcript_history.failed', - message: 'Failed to load earlier session history', - debugMessage: 'Replay conversion failed for this page', - }); + expect(history?.paginationError).toBe(true); + expect(notices.at(-1)).toBeUndefined(); }); it('keeps an oversized initial replay intact and stops older pagination', async () => { diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index bbfb99d4f5..e983658740 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -119,6 +119,7 @@ export interface DaemonTranscriptHistory { hasMore: boolean; loading: boolean; capacityReached: boolean; + paginationError: boolean; loadMore(): Promise; } @@ -443,11 +444,18 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { hasMore: boolean; loading: boolean; capacityReached: boolean; - }>({ hasMore: false, loading: false, capacityReached: false }); + paginationError: boolean; + }>({ + hasMore: false, + loading: false, + capacityReached: false, + paginationError: false, + }); const [transcriptHistoryState, setTranscriptHistoryState] = useState({ hasMore: false, loading: false, capacityReached: false, + paginationError: false, }); const eventStreamRef = useRef< | { @@ -1150,11 +1158,13 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { hasMore: historyHasMore, loading: false, capacityReached: false, + paginationError: false, }; setTranscriptHistoryState({ hasMore: historyHasMore, loading: false, capacityReached: false, + paginationError: false, }); const replayInjected = shouldInjectReplaySnapshot && replayEvents.length > 0; @@ -1247,6 +1257,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { hasMore: false, loading: false, capacityReached: true, + paginationError: false, }); } for (const replayEvent of replayEvents) { @@ -2345,6 +2356,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { if ( !history.hasMore || history.loading || + history.paginationError || !activeSession || activeSession.sessionId !== history.sessionId ) { @@ -2356,6 +2368,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { hasMore: true, loading: true, capacityReached: false, + paginationError: false, }); let terminalFailure = false; try { @@ -2437,6 +2450,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { hasMore: false, loading: false, capacityReached: true, + paginationError: false, }); return; } @@ -2450,6 +2464,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { hasMore: history.hasMore, loading: false, capacityReached: history.capacityReached, + paginationError: false, }); } catch (error) { if ( @@ -2467,20 +2482,24 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { history.hasMore = retryable; history.loading = false; history.capacityReached = false; + history.paginationError = !retryable; setTranscriptHistoryState({ hasMore: retryable, loading: false, capacityReached: false, + paginationError: !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, - }); + 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; } }, [addNotice, dismissNotice, maxBlocks, store]); @@ -2492,6 +2511,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { hasMore: active && transcriptHistoryState.hasMore, loading: active && transcriptHistoryState.loading, capacityReached: active && transcriptHistoryState.capacityReached, + paginationError: active && transcriptHistoryState.paginationError, loadMore: loadMoreTranscript, }; }, [connection.sessionId, loadMoreTranscript, transcriptHistoryState]);