diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index 2733aff91..b8f141ad3 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -3,6 +3,7 @@ import { type SetStateAction, useEffect, useMemo, + useRef, useState, } from 'react'; @@ -31,6 +32,9 @@ export function useRecentProjects(setStatus: Dispatch>) { const [recentWorkspaceRefreshing, setRecentWorkspaceRefreshing] = useState(false); const [projectSearchQuery, setProjectSearchQuery] = useState(''); + const recentWorkspacesRef = useRef(recentWorkspaces); + recentWorkspacesRef.current = recentWorkspaces; + const inspectionGenerationRef = useRef(0); async function inspectRecentWorkspace( invoke: NonNullable>, @@ -62,19 +66,36 @@ export function useRecentProjects(setStatus: Dispatch>) { useEffect(() => { const invoke = resolveTauriInvoke(); if (!invoke || recentWorkspaces.length === 0) { + inspectionGenerationRef.current += 1; setRecentWorkspaceStatuses({}); setRecentWorkspaceRefreshing(false); return; } + const inspectionGeneration = ++inspectionGenerationRef.current; let disposed = false; let pendingCount = recentWorkspaces.length; - setRecentWorkspaceStatuses({}); + // 保留已完成项目的最后一个独立结果。刷新是增量投影:只有新项目或 + // 尚未完成检查的项目显示“检查中”,不能因为另一个坏目录而把整张列表 + // 清空成同一个异常状态。 + setRecentWorkspaceStatuses((current) => { + const next: Record = {}; + for (const workspace of recentWorkspaces) { + if (Object.prototype.hasOwnProperty.call(current, workspace)) { + next[workspace] = current[workspace] ?? null; + } + } + return next; + }); setRecentWorkspaceRefreshing(true); for (const workspace of recentWorkspaces) { void inspectRecentWorkspace(invoke, workspace).then( ([projectPath, status]) => { - if (disposed) { + if ( + disposed || + inspectionGeneration !== inspectionGenerationRef.current || + !recentWorkspacesRef.current.includes(projectPath) + ) { return; } setRecentWorkspaceStatuses((current) => ({ @@ -104,6 +125,9 @@ export function useRecentProjects(setStatus: Dispatch>) { return; } const [, status] = await inspectRecentWorkspace(invoke, projectPath); + if (!recentWorkspacesRef.current.includes(projectPath)) { + return; + } setRecentWorkspaceStatuses((current) => ({ ...current, [projectPath]: status, diff --git a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx new file mode 100644 index 000000000..e564a6fca --- /dev/null +++ b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; + +import { useRecentProjects } from '../src/features/app-shell/useRecentProjects'; + +const READY_PROJECT = { + projectPath: '/tmp/ready-project', + exists: true, + isDirectory: true, + isGameCreatorProject: true, + isGodotProject: false, + godotProjectRoot: null, + isCocosProject: false, + cocosProjectRoot: null, + isUnityProject: false, + unityProjectRoot: null, + projectName: '正常项目', + recentRunStatus: null, + recentRunStopReason: null, +}; + +afterEach(() => { + delete window.__TAURI__; + window.localStorage.clear(); +}); + +test('刷新坏项目时保留其它项目已确认的正常状态', async () => { + let finishPendingInspection: (() => void) | null = null; + const pendingInspection = new Promise((resolve) => { + finishPendingInspection = () => + resolve({ + ...READY_PROJECT, + projectPath: '/tmp/slow-broken-project', + projectName: '慢速坏项目', + }); + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command !== 'inspect_local_project_directory') { + throw new Error(`unexpected invoke ${command}`); + } + const projectPath = String(args?.projectPath ?? ''); + if (projectPath === '/tmp/slow-broken-project') { + return pendingInspection; + } + return READY_PROJECT; + }, + ); + window.__TAURI__ = { core: { invoke } }; + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify(['/tmp/ready-project']), + ); + + const { result } = renderHook(() => useRecentProjects(vi.fn())); + await waitFor(() => { + expect(result.current.projectRows[0]).toMatchObject({ + name: '正常项目', + status: '本地项目', + canOpen: true, + }); + }); + + act(() => { + result.current.rememberRecentWorkspace('/tmp/slow-broken-project'); + }); + + await waitFor(() => { + expect(result.current.projectRows).toHaveLength(2); + const readyRow = result.current.projectRows.find( + (row) => row.path === '/tmp/ready-project', + ); + const slowRow = result.current.projectRows.find( + (row) => row.path === '/tmp/slow-broken-project', + ); + expect(readyRow).toMatchObject({ + name: '正常项目', + status: '本地项目', + canOpen: true, + }); + expect(slowRow).toMatchObject({ + name: 'slow-broken-project', + status: '检查中', + canOpen: false, + }); + }); + + await act(async () => { + finishPendingInspection?.(); + await pendingInspection; + }); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1e2c576da..31316e6ef 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -15,6 +15,12 @@ Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和 > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径(2026-09-18):历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据。策划 Agent V1/V2 的 Runtime、专用命令、审批卡、展示适配和旧测试已删除;当前策划入口统一使用 Design Agent。如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 +## 2026-09-20 最近项目检查保持项目级隔离 + +- 背景:最近项目刷新会重新检查所有路径。若其中一个目录损坏、超时或不可读,清空整张状态表会让已确认正常的项目暂时全部显示“检查中”,用户只能移除坏项目后看到列表恢复。 +- 决策:最近项目状态按路径独立投影;刷新时保留仍在列表中的最后一次结果,只有新增或尚未检查的项目进入“检查中”。检查代次或列表成员变化后,迟到结果不得写回,单个项目的失败不能改变其它项目的可打开状态。 +- 验证:`recentProjectsHook.test.tsx` 覆盖“新增慢/坏项目刷新时保留正常项目”;`recentProjectsModel.test.ts`、`unityProjectOpen.test.tsx` 与前端类型检查一并执行。 + ## 2026-09-17 GameCreationApp 资源 kind 只保留一份词汇表:严格解析 + `app_log!` 留痕 - 背景:kind 曾经有三份实现——Rust 手写 `GAME_CREATION_APP_CANONICAL_ASSET_KINDS` + `canonical_game_creation_app_asset_kind()`(带 legacy 别名表与 `font → document` 特例)、TS 手写 `GAME_CREATION_APP_CANONICAL_ASSET_KINDS` + `GAME_CREATION_APP_LEGACY_ASSET_KINDS` + `canonicalGameCreationAppAssetKind()`、以及 ts-rs 生成的 TS union。两份手写表互相引用又各自收口,判据直接分叉(同一个 `"UI"` 一边归一成 `ui-design`、一边收口成 `unknown`),跨语言一致性只能靠正则解析源码的测试来钉。 diff --git a/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md b/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md index ba403e9e9..63fcf35f1 100644 --- a/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md +++ b/docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md @@ -29,7 +29,7 @@ ### RecentProjectInspection -每个最近项目拥有独立 operation 和结果。检查中、可打开、失败、超时、不存在、非目录、未初始化和可导入不能通过一个全局刷新 gate 互相覆盖。 +每个最近项目拥有独立 operation 和结果。检查中、可打开、失败、超时、不存在、非目录、未初始化和可导入不能通过一个全局刷新 gate 互相覆盖。刷新采用增量投影:已确认的项目结果继续保留,只有新增或尚未完成检查的项目显示“检查中”;项目被移除或检查代次变化后,迟到结果不得写回列表。 ### DevStackIdentity