mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-17 12:44:51 +00:00
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:
parent
27927c46d9
commit
60be40e8f9
9 changed files with 151 additions and 16 deletions
|
|
@ -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(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -7630,6 +7630,8 @@ export function App({
|
|||
historyCapacityReached={
|
||||
transcriptHistory.capacityReached
|
||||
}
|
||||
historyPaginationError={
|
||||
transcriptHistory.paginationError}
|
||||
onLoadOlderHistory={transcriptHistory.loadMore}
|
||||
transcriptBlockCount={blocks.length}
|
||||
transcriptActivity={store}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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': '连接已中断,请在恢复后重试。',
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue