diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 99ec9ac5d..854e8193b 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -177,7 +177,13 @@ export function useHomeProjectCreation({ function enterProjectDevelopment(context: LauncherProjectContext) { setCurrentProjectContext(context); - setActiveProjectPreview(context.manifest.preview ?? null); + /** + * 进项目时**不**把 manifest 里的 `preview` 当成本次会话的预览:那是"上次留下的一条 + * 落盘记录",预览服务器是进程内线程、非 Linux 端口还是随机临时端口,客户端退出后 + * 记录仍会写着 running。会话预览只由活体确认(下一步的 registry 核对)或本次会话里 + * 的 preview.start / preview.status 写入,见 `activeProjectPreview` 的其它赋值点。 + */ + setActiveProjectPreview(null); setAgentRuntimeSummaries([]); setAgentResults([]); setProjectPath(context.projectPath); diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 1fcda0ed4..8953c9d0b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -3421,8 +3421,19 @@ export default function ProjectDevelopmentView({ }); }, [mode, resourceSectionScrollKey, sortMode]); + /** + * 会话内确认过的预览才自动切到运行视图。 + * + * 只认 `previewOverride`:它是 launcher 按内存 registry 的**活体**确认后交给工作台的 + * 会话预览,也是本次会话里 preview.start / preview.status 的返回值。`manifest.preview` + * 只是"上次留下的一条落盘记录"——预览服务器是进程内线程、非 Linux 端口还是随机临时 + * 端口,客户端退出后那条记录仍然写着 running,照着它自动切视图就是把用户丢进一个 + * 打不开的运行界面。切视图的入口照旧齐全(运行 tab 与播放按钮,PRD §4.1),只是 + * "回到仍在运行的预览"从自动变成用户点一下。 + */ + const sessionPreviewUrl = resolveEmbeddedPreviewUrl(previewOverride); useEffect(() => { - if (embeddedPreviewUrl) { + if (sessionPreviewUrl) { canvasOpenEpochRef.current += 1; advanceFocusGeneration(); activeFocusFlowIdRef.current = null; @@ -3433,7 +3444,7 @@ export default function ProjectDevelopmentView({ setQuickEditSourceLayer(null); setMode('run'); } - }, [advanceFocusGeneration, embeddedPreviewUrl]); + }, [advanceFocusGeneration, sessionPreviewUrl]); useEffect( () => diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index ac724b4e5..8a074e1ba 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -1741,6 +1741,129 @@ export function registerProjectWorkbenchFoundationTests() { expect(screen.getByRole('region', { name: '资源总览' })).not.toBeNull(); }); + it('keeps the resource view when only the persisted preview record says running', async () => { + const manifest = createGameCreationAppManifest( + 'workbench-persisted-preview', + '落盘预览记录测试', + ); + // manifest.preview 是"上次留下的一条落盘记录":进程退出后它仍会写着 running, + // 但预览服务器是进程内线程、端口还是随机临时端口,真实情况是已经打不开了。 + manifest.preview = { + status: 'running', + url: 'http://127.0.0.1:4173/', + port: 4173, + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: '/tmp/workbench-persisted-preview', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + // 初始视图是资源管理,不是运行:落盘记录不触发自动切换。 + expect( + screen + .getByRole('tab', { name: '资源管理' }) + .getAttribute('aria-selected'), + ).toBe('true'); + expect( + screen.getByRole('tab', { name: '运行' }).getAttribute('aria-selected'), + ).toBe('false'); + expect(screen.queryByLabelText('运行表现层')).toBeNull(); + expect(document.querySelector('[data-resource-view-state]')).not.toBeNull(); + // 但运行入口照旧可用(PRD §4.1:入口不可用时仍允许点击,只是不切换状态)。 + const runTab = screen.getByRole('tab', { + name: '运行', + }) as HTMLButtonElement; + expect(runTab.disabled).toBe(false); + expect(runTab.getAttribute('data-unavailable')).toBeNull(); + // 用户点一下仍然能进运行视图("回到仍在运行的预览"从自动变手动)。 + fireEvent.click(runTab); + expect(runTab.getAttribute('aria-selected')).toBe('true'); + expect(screen.getByLabelText('运行表现层')).not.toBeNull(); + }); + + it('switches to the run view when the session confirms a live preview', async () => { + const manifest = createGameCreationAppManifest( + 'workbench-session-preview', + '会话活体预览测试', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: '/tmp/workbench-session-preview', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + // 会话预览:launcher 按内存 registry 的活体确认后交进来(或本次会话里 + // preview.start / preview.status 的返回值)。 + preview: { + status: 'running', + url: 'http://127.0.0.1:4173/', + port: 4173, + }, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + await waitFor(() => + expect( + screen.getByRole('tab', { name: '运行' }).getAttribute('aria-selected'), + ).toBe('true'), + ); + expect(screen.getByLabelText('运行表现层')).not.toBeNull(); + }); + it('paints the marquee selection box with the scene token root and non-empty geometry', async () => { const manifest = createGameCreationAppManifest( 'workbench-marquee-colour',