From 3ed567d99a79f84dd2100096dc94dd03d85a9daa Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 12:07:44 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E8=A7=86=E5=9B=BE=E7=9A=84?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=88=87=E6=8D=A2=E5=8F=AA=E8=AE=A4=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=86=85=E7=A1=AE=E8=AE=A4=E8=BF=87=E7=9A=84=E9=A2=84?= =?UTF-8?q?=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index.tsx:自动切运行的那条 effect 从「合并后的预览 URL」收窄为「会话预览 URL」(resolveEmbeddedPreviewUrl(previewOverride))。manifest.preview 是"上次留下的一条落盘记录":预览服务器是进程内线程、非 Linux 端口还是随机临时端口,客户端退出后记录仍会写着 running,照着它自动切视图就是把用户丢进一个打不开的运行界面。运行入口(运行 tab、播放按钮)与 runAvailable 一行未动,PRD §4.1「运行入口不可用时仍允许点击…但不切换状态」继续成立。 - useHomeProjectCreation.ts:enterProjectDevelopment 不再把 context.manifest.preview 播种成本次会话的预览(改为 null)。会话预览此后只由活体确认(下一提交)与本次会话里 preview.start / preview.status 的返回值经 onPreviewChange 回流两处写入。 - 有意接受的代价:「回到仍在运行的预览」从自动变成用户点一下「运行」;下一提交会把"确实还在跑"这条路径按活体确认接回自动进入。 - project-development.suite.ts:新增两条用例——① 仅 manifest.preview 为 running(不传 preview)时初始仍是「资源管理」、运行 tab 仍可用、点一下仍能进运行视图;② 会话预览为 running 时自动切到运行表现层。 - 变异验证:把这条 effect 还原成读合并后的 URL(即撤掉本次收窄),用例①立即红灯(已实测)。 --- .../app-shell/useHomeProjectCreation.ts | 8 +- .../src/view/project-development/index.tsx | 15 ++- .../appSurface/project-development.suite.ts | 123 ++++++++++++++++++ 3 files changed, 143 insertions(+), 3 deletions(-) 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',