运行视图的自动切换只认会话内确认过的预览

- 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(即撤掉本次收窄),用例①立即红灯(已实测)。
This commit is contained in:
2026-09-11 12:07:44 +08:00
parent 95c5418c0f
commit 3ed567d99a
3 changed files with 143 additions and 3 deletions
@@ -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);
@@ -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(
() =>
@@ -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<string, unknown>) => {
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<string, unknown>) => {
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',