feat(web-shell): persist terminal history pagination errors (#7709)

* feat(web-shell): persist terminal history pagination errors

* fix: address review feedback for pagination error state
This commit is contained in:
pratik wayase 2026-07-26 17:01:36 +05:30 committed by GitHub
parent 27927c46d9
commit 60be40e8f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 151 additions and 16 deletions

View file

@ -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(),
}),

View file

@ -7630,6 +7630,8 @@ export function App({
historyCapacityReached={
transcriptHistory.capacityReached
}
historyPaginationError={
transcriptHistory.paginationError}
onLoadOlderHistory={transcriptHistory.loadMore}
transcriptBlockCount={blocks.length}
transcriptActivity={store}

View file

@ -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(),
}),

View file

@ -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}

View file

@ -203,6 +203,7 @@ function mount(
hasOlderHistory?: boolean;
loadingOlderHistory?: boolean;
historyCapacityReached?: boolean;
historyPaginationError?: boolean;
onLoadOlderHistory?: () => Promise<void>;
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;

View file

@ -63,6 +63,7 @@ interface MessageListProps {
hasOlderHistory?: boolean;
loadingOlderHistory?: boolean;
historyCapacityReached?: boolean;
historyPaginationError?: boolean;
onLoadOlderHistory?: () => Promise<void>;
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')}
</div>
)}
{historyPaginationError &&
!showLoadingSkeleton &&
!historyCapacityReached && (
<div className={styles.historyStatus} role="status">
{t('history.paginationError')}
</div>
)}
<SessionTimeline
entries={sessionTimelineEntries}
currentTurnId={currentTimelineTurnId}

View file

@ -818,6 +818,7 @@ const EN: Messages = {
'history.loadingEarlier': 'Loading earlier messages…',
'history.capacityReached':
'History display limit reached. Earlier messages remain saved.',
'history.paginationError': 'Earlier history could not be loaded.',
'editor.shellPlaceholder': 'Enter terminal command',
'editor.send': 'Send message',
'editor.connectionDisconnected':
@ -3115,6 +3116,7 @@ const ZH: Messages = {
'editor.placeholder': '输入消息或 @ 文件路径',
'history.loadingEarlier': '正在加载更早消息…',
'history.capacityReached': '已达到历史显示上限,更早消息仍保存在会话中。',
'history.paginationError': '无法加载更早的历史记录。',
'editor.shellPlaceholder': '请输入终端命令',
'editor.send': '发送消息',
'editor.connectionDisconnected': '连接已中断,请在恢复后重试。',

View file

@ -17,6 +17,7 @@ import type {
DaemonUiSessionActions,
PromptResult,
} from '@qwen-code/sdk/daemon';
import { DaemonHttpError } from '@qwen-code/sdk/daemon';
import {
DaemonSessionProvider,
useDaemonActions,
@ -9120,6 +9121,60 @@ describe('DaemonSessionProvider', () => {
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<typeof useDaemonTranscriptHistory> | undefined;
function Harness() {
history = useDaemonTranscriptHistory();
return null;
}
await renderWithProvider(<Harness />, {
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 () => {

View file

@ -119,6 +119,7 @@ export interface DaemonTranscriptHistory {
hasMore: boolean;
loading: boolean;
capacityReached: boolean;
paginationError: boolean;
loadMore(): Promise<void>;
}
@ -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]);