diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.test.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.test.tsx index 8e41a0c5c..9fb665092 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.test.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.test.tsx @@ -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(); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 92f274dad..aa5814689 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -203,6 +203,13 @@ export function useDirectProjectChatController({ const historyLoadingRef = useRef(false); const historyErrorRef = useRef(null); const historyOldestItemIdRef = useRef(null); + /** + * 更早历史读取的世代号:新起一次读取、以及切换项目都推进一格。 + * + * 只比项目路径不够——A→B→A 之后在飞的旧 A 读取又落回同一个路径,会把新一代的加载态与游标 + * 一起改掉。世代号让任何非最新一次读取在落地时整段失效(不合并、不写游标、不关加载态)。 + */ + const historyLoadTokenRef = useRef(0); const projectPathRef = useRef(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); }