修复:更早历史读取按世代号失效旧请求
loadEarlierHistory 的成功 / 失败 / finally 守卫从「项目路径相等」改成「世代号相等」,A→B→A 之后在飞的旧读取不会再清掉新一代的加载态 新增 historyLoadTokenRef,切项目与每次读取各推进一格,旧世代落地时整段丢弃:不合并条目、不写游标、不关并发闸门 新增一例控制器用例驱动 A→B→A 与同路径新读取在飞,修正前在「旧读取落地」处确实红 Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
+55
@@ -138,6 +138,61 @@ describe('更早历史加载态的项目归属', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('更早历史读取的世代守卫', () => {
|
||||
it('同一项目切走再切回后,上一世代的读取落地不能清掉新加载态', async () => {
|
||||
const tauri = installDeferredHistoryInvoke();
|
||||
const { result, rerender } = renderChatController('/projects/a');
|
||||
|
||||
// /a 首屏:拿到「还有更早」,游标停在 a2。
|
||||
const firstScreenA = await tauri.take('/projects/a');
|
||||
await act(async () => {
|
||||
firstScreenA.resolve(slice([userItem('a2')], true, 'a2'));
|
||||
});
|
||||
await waitFor(() => expect(result.current.historyHasMore).toBe(true));
|
||||
|
||||
// 在 /a 滚到顶:第一条更早历史读取挂起。
|
||||
await act(async () => {
|
||||
void result.current.loadEarlierHistory();
|
||||
});
|
||||
const staleRead = await tauri.take('/projects/a', 'a2');
|
||||
expect(result.current.historyLoading).toBe(true);
|
||||
|
||||
// 切到 /b 再切回 /a:两次切项目都重置加载态,并各拉一次首屏。
|
||||
rerender({ projectPath: '/projects/b' });
|
||||
const firstScreenB = await tauri.take('/projects/b');
|
||||
await act(async () => {
|
||||
firstScreenB.resolve(slice([userItem('b2')], true, 'b2'));
|
||||
});
|
||||
await waitFor(() => expect(result.current.historyHasMore).toBe(true));
|
||||
|
||||
rerender({ projectPath: '/projects/a' });
|
||||
const firstScreenA2 = await tauri.take('/projects/a');
|
||||
await act(async () => {
|
||||
firstScreenA2.resolve(slice([userItem('a2')], true, 'a2'));
|
||||
});
|
||||
await waitFor(() => expect(result.current.historyHasMore).toBe(true));
|
||||
|
||||
// 回到 /a 后再滚到顶:新一代的更早历史读取挂起。
|
||||
await act(async () => {
|
||||
void result.current.loadEarlierHistory();
|
||||
});
|
||||
const currentRead = await tauri.take('/projects/a', 'a2');
|
||||
expect(result.current.historyLoading).toBe(true);
|
||||
|
||||
// 第一条 /a 读取此刻才落地:路径和现在一样,但它属于上一世代,不能收掉新一代的加载态。
|
||||
await act(async () => {
|
||||
staleRead.resolve(slice([userItem('a1')], false, 'a1'));
|
||||
});
|
||||
expect(result.current.historyLoading).toBe(true);
|
||||
|
||||
// 新一代自己的读取落地后,加载态由它自己收口。
|
||||
await act(async () => {
|
||||
currentRead.resolve(slice([userItem('a1')], false, 'a1'));
|
||||
});
|
||||
expect(result.current.historyLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('更早历史失败的挂起态', () => {
|
||||
it('宿主没给出失败消息时,挂起态仍是可判定的非空值', async () => {
|
||||
const tauri = installDeferredHistoryInvoke();
|
||||
|
||||
+19
-7
@@ -203,6 +203,13 @@ export function useDirectProjectChatController({
|
||||
const historyLoadingRef = useRef(false);
|
||||
const historyErrorRef = useRef<string | null>(null);
|
||||
const historyOldestItemIdRef = useRef<string | null>(null);
|
||||
/**
|
||||
* 更早历史读取的世代号:新起一次读取、以及切换项目都推进一格。
|
||||
*
|
||||
* 只比项目路径不够——A→B→A 之后在飞的旧 A 读取又落回同一个路径,会把新一代的加载态与游标
|
||||
* 一起改掉。世代号让任何非最新一次读取在落地时整段失效(不合并、不写游标、不关加载态)。
|
||||
*/
|
||||
const historyLoadTokenRef = useRef(0);
|
||||
const projectPathRef = useRef<string | null>(projectPath);
|
||||
projectPathRef.current = projectPath;
|
||||
const directTurnRunningRef = useRef(currentTurnRunning);
|
||||
@@ -220,6 +227,8 @@ export function useDirectProjectChatController({
|
||||
historyErrorRef.current = null;
|
||||
setHistoryError(null);
|
||||
historyOldestItemIdRef.current = null;
|
||||
// 换项目即换代:上一项目的在飞读取(停在旧世代号上)落地后整段失效。
|
||||
historyLoadTokenRef.current += 1;
|
||||
}, [projectPath]);
|
||||
|
||||
// 回合期间的平台会话保活由 Rust 持有:Direct 回合的占用登记时启动,占用释放(回合收口)即停止;
|
||||
@@ -691,6 +700,9 @@ export function useDirectProjectChatController({
|
||||
return;
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) return;
|
||||
// 本次读取的世代号:切项目或另起一次读取都会把它推进,落地时过期就读作失效。
|
||||
const loadToken = (historyLoadTokenRef.current += 1);
|
||||
const isStaleLoad = () => historyLoadTokenRef.current !== loadToken;
|
||||
historyLoadingRef.current = true;
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
@@ -705,7 +717,7 @@ export function useDirectProjectChatController({
|
||||
: { limit: DIRECT_HISTORY_PAGE_SIZE }),
|
||||
}),
|
||||
});
|
||||
if (projectPathRef.current !== nextProjectPath) return;
|
||||
if (isStaleLoad()) return;
|
||||
directThread.mergeHistoryItems(pages.items);
|
||||
if (!pages.error) setHistoryHasMore(pages.hasMore);
|
||||
historyOldestItemIdRef.current =
|
||||
@@ -714,18 +726,18 @@ export function useDirectProjectChatController({
|
||||
historyErrorRef.current = null;
|
||||
setHistoryError(null);
|
||||
} catch (error) {
|
||||
// 与加载成功路径同样按项目守卫:切项目后在飞的读取失败不能算到新项目头上。
|
||||
if (projectPathRef.current !== nextProjectPath) return;
|
||||
// 与加载成功路径同样按世代守卫:切项目后在飞的读取失败不能算到新项目头上。
|
||||
if (isStaleLoad()) return;
|
||||
const message =
|
||||
(error instanceof Error ? error.message : String(error)) ||
|
||||
DIRECT_HISTORY_LOAD_ERROR_FALLBACK;
|
||||
historyErrorRef.current = message;
|
||||
setHistoryError(message);
|
||||
} finally {
|
||||
// 与成功 / 失败路径同样按项目守卫:切项目后在飞的旧读取落地时,不能关掉新项目自己那次
|
||||
// 加载的加载态——否则加载行提前消失,`historyLoadingRef` 这道并发闸门也会被重新打开,
|
||||
// 同一个项目上就能再并起第二次读取。
|
||||
if (projectPathRef.current === nextProjectPath) {
|
||||
// 与成功 / 失败路径同样按世代守卫:旧世代读取落地时不能关掉当代的加载态——否则加载行
|
||||
// 提前消失,`historyLoadingRef` 这道并发闸门也会被重新打开,还能再并起第二次读取。
|
||||
// 只比路径不够:A→B→A 的旧读取又落回同一个路径,靠世代号才区分得开。
|
||||
if (!isStaleLoad()) {
|
||||
historyLoadingRef.current = false;
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user