修复更早历史读取失败消息为空时挂起态与错误行一起消失

- 失败消息统一收口成非空兜底文案,空 message 不再让 historyError 变 falsy
- 新增控制器回归用例:宿主抛 new Error() 时挂起态仍成立

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 18:53:51 +08:00
parent 77401adfe8
commit 9cff7bf049
2 changed files with 60 additions and 17 deletions
@@ -10,6 +10,7 @@ type SliceRequest = {
projectPath: string;
beforeItemId: string | undefined;
resolve: (slice: HistorySlice) => void;
reject: (error: unknown) => void;
};
function userItem(itemId: string): ThreadItem {
@@ -25,7 +26,7 @@ function slice(
}
/**
* 装一个可控的 Tauri invoke:历史切片读取全部挂起,用例自己决定哪一页何时落地。
* 装一个可控的 Tauri invoke:历史切片读取全部挂起,用例自己决定哪一页何时落地 / 落空。
*
* `resolveTauriInvoke` 只读 `window.__TAURI__.core.invoke`,这里就是控制器唯一的外部接缝。
*/
@@ -34,11 +35,12 @@ function installDeferredHistoryInvoke() {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command !== 'read_direct_project_history_slice') return undefined;
return new Promise<HistorySlice>((resolve) => {
return new Promise<HistorySlice>((resolve, reject) => {
requests.push({
projectPath: String(args?.projectPath ?? ''),
beforeItemId: args?.beforeItemId as string | undefined,
resolve,
reject,
});
});
},
@@ -64,6 +66,23 @@ function installDeferredHistoryInvoke() {
};
}
function renderChatController(initialProjectPath: string) {
const allowed = async () => true;
return renderHook(
({ projectPath }: { projectPath: string }) =>
useDirectProjectChatController({
enabled: true,
ensureConversationReadAllowed: allowed,
ensureConversationWriteAllowed: allowed,
onRuntimeError: () => undefined,
projectId: 'project-1',
projectPath,
refreshManifest: () => undefined,
}),
{ initialProps: { projectPath: initialProjectPath } },
);
}
afterEach(() => {
cleanup();
delete window.__TAURI__;
@@ -72,20 +91,7 @@ afterEach(() => {
describe('更早历史加载态的项目归属', () => {
it('切项目后旧项目在飞的读取才落地,不能清掉新项目正在进行的加载态', async () => {
const tauri = installDeferredHistoryInvoke();
const allowed = async () => true;
const { result, rerender } = renderHook(
({ projectPath }: { projectPath: string }) =>
useDirectProjectChatController({
enabled: true,
ensureConversationReadAllowed: allowed,
ensureConversationWriteAllowed: allowed,
onRuntimeError: () => undefined,
projectId: 'project-1',
projectPath,
refreshManifest: () => undefined,
}),
{ initialProps: { projectPath: '/projects/a' } },
);
const { result, rerender } = renderChatController('/projects/a');
// 首屏:/a 的第一页落地,拿到「还有更早」。
const firstScreenA = await tauri.take('/projects/a');
@@ -129,3 +135,29 @@ describe('更早历史加载态的项目归属', () => {
expect(result.current.historyLoading).toBe(false);
});
});
describe('更早历史失败的挂起态', () => {
it('宿主没给出失败消息时,挂起态仍是可判定的非空值', async () => {
const tauri = installDeferredHistoryInvoke();
const { result } = renderChatController('/projects/a');
const firstScreen = await tauri.take('/projects/a');
await act(async () => {
firstScreen.resolve(slice([userItem('a2')], true, 'a2'));
});
await waitFor(() => expect(result.current.historyHasMore).toBe(true));
await act(async () => {
void result.current.loadEarlierHistory();
});
const failing = await tauri.take('/projects/a', 'a2');
await act(async () => {
failing.reject(new Error(''));
});
// `historyError` 同时是「挂起自动加载」的闸门与内联错误行的显隐开关:空串是 falsy,
// 两者会一起消失,滚到顶就变成反复重试同一个失败读取。
expect(result.current.historyError).toBeTruthy();
expect(result.current.historyLoading).toBe(false);
});
});
@@ -44,6 +44,15 @@ import { useDirectThreadChatSubscription } from './useDirectThreadChatSubscripti
export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8;
export const DIRECT_HISTORY_PAGE_SIZE = CONVERSATION_VISIBLE_STEP;
/**
* 更早历史读取失败时的兜底文案。
*
* 宿主可能抛出空 message 的错误(裸 `new Error()`、宿主侧空串)。空串是 falsy,而
* `historyError` 同时是「挂起自动加载」的闸门与内联错误行的显隐开关——空串会让两者一起
* 消失,滚到顶就变成反复重试同一个失败读取。这里在源头收口成一句稳定文案。
*/
const DIRECT_HISTORY_LOAD_ERROR_FALLBACK = '读取更早的对话历史失败';
/**
* 工作台壳注入的项目对话读权限门。返回 `false` 表示已入队确认:读取由 `onConfirmed`
* 触发,壳自己负责取消时的状态文案。
@@ -707,7 +716,9 @@ export function useDirectProjectChatController({
} catch (error) {
// 与加载成功路径同样按项目守卫:切项目后在飞的读取失败不能算到新项目头上。
if (projectPathRef.current !== nextProjectPath) return;
const message = error instanceof Error ? error.message : String(error);
const message =
(error instanceof Error ? error.message : String(error)) ||
DIRECT_HISTORY_LOAD_ERROR_FALLBACK;
historyErrorRef.current = message;
setHistoryError(message);
} finally {