运行页顶栏收口:预览地址改按钮,版本入口并入顶栏同一套样式

- 删除运行区域上方的状态行与预览地址小字,运行画面回到两行栅格
- 预览地址改成顶栏动作区里的「在浏览器打开」按钮(opener 插件 openUrl),只在有活预览时渲染
- 版本入口搬进同一个顶栏动作区,外观复用该容器的基础按钮样式,去掉自带的边框、底色与 hover
- 打开失败走 onNotice 的失败色 toast:onRunNotice 通道改成带 tone 的对象,四处调用点同步
- 补顶栏入口正向守卫,删掉状态行的旧样式用例并新增顶栏与浏览器打开的用例
This commit is contained in:
2026-09-22 11:51:51 +08:00
parent 8e6ee50ad8
commit 5a87c5f965
13 changed files with 350 additions and 170 deletions
+4 -4
View File
@@ -6959,7 +6959,7 @@ export function App({
void refreshManifest(generatedProjectPath);
// 启动预览的成功反馈走 toast,不再往对话区写一条「已保存并…启动预览:URL」;
// run trace 摘要本身是对话内容,继续按消息发出。
onRunNotice?.('运行通过,已载入客户端运行视图');
onRunNotice?.({ message: '运行通过,已载入客户端运行视图' });
const completionText = completionSummary?.text;
if (completionText) {
setMessages((current) => [
@@ -8816,7 +8816,7 @@ export function App({
void refreshManifest(nextProjectPath);
setCommandLog((current) => [...current, 'preview.start']);
// 成功反馈走 toast + 运行区域上方的小字,不在对话区留行。
onRunNotice?.('运行通过,已载入客户端运行视图');
onRunNotice?.({ message: '运行通过,已载入客户端运行视图' });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setPreviewStatus(message);
@@ -8882,7 +8882,7 @@ export function App({
setCommandLog((current) => [...current, 'preview.open']);
// 只有真的切到了运行视图才给反馈;没有活预览时不编一条成功提示。
if (activatedPreview) {
onRunNotice?.('已载入客户端运行视图');
onRunNotice?.({ message: '已载入客户端运行视图' });
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -9517,7 +9517,7 @@ export function App({
}
// 运行成功的提示不再写进聊天(会一直堆在对话底部遮挡运行画面):反馈走工作台壳的
// toast,预览地址由运行区域上方的小字常驻。失败仍按下面的分支回报。
onRunNotice?.('运行通过,已载入客户端运行视图');
onRunNotice?.({ message: '运行通过,已载入客户端运行视图' });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setLimitedCommandStatus(message);
@@ -2,19 +2,20 @@ import { PlatformRuntimeStatusToast } from '@genarrative/shared/components';
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import type { ProjectRunNotice } from './model';
const RUN_NOTICE_MILLIS = 2600;
export type RunNotice = {
export type RunNotice = ProjectRunNotice & {
/** 每次提示自增,保证重复触发同一个文案时也会重新弹一次。 */
id: number;
message: string;
};
/**
* 运行 / 预览类动作的浮层提示。
*
* 这类过程反馈以前以 assistant 消息写进对话区,会一直堆在对话底部挡住运行画面;
* 现在统一走 toast,对话区只保留对话内容,预览地址另由运行区域上方的小字常驻
* 现在统一走 toast,对话区只保留对话内容——运行页的预览地址改用顶栏的「在浏览器打开」
*/
export function RunNoticeToast({
notice,
@@ -43,7 +44,7 @@ export function RunNoticeToast({
data-project-run-notice-toast="true"
>
<PlatformRuntimeStatusToast
tone="success"
tone={notice.tone ?? 'success'}
surface="solid"
size="sm"
shape="pill"
@@ -41,7 +41,7 @@ import {
DeveloperAgentDialogs,
DeveloperAgentPanel,
} from './DeveloperAgentPanel';
import type { WorkspaceLauncherShellProps } from './model';
import type { ProjectRunNotice, WorkspaceLauncherShellProps } from './model';
import { NonEmptyProjectDialog, ProjectsPage } from './ProjectCreation';
import { type RunNotice, RunNoticeToast } from './RunNoticeToast';
import { useAccountWallet } from './useAccountWallet';
@@ -529,8 +529,8 @@ export function WorkspaceLauncherShell({
);
}, []);
const handleRunNotice = useCallback((message: string) => {
setRunNotice((current) => ({ id: (current?.id ?? 0) + 1, message }));
const handleRunNotice = useCallback((notice: ProjectRunNotice) => {
setRunNotice((current) => ({ id: (current?.id ?? 0) + 1, ...notice }));
}, []);
function showLauncherNotice(title: string) {
@@ -723,6 +723,7 @@ export function WorkspaceLauncherShell({
currentProjectContext.projectPath,
)
}
onNotice={handleRunNotice}
onManifestChange={syncActiveProjectManifest}
onHomeOpen={() => setLauncherView('home')}
onProjectsOpen={() => setLauncherView('projects')}
@@ -36,6 +36,17 @@ export type WorkspaceLauncherProps = {
initialView?: LauncherView;
};
/**
* 运行 / 预览类动作的一次性浮层提示。
*
* `tone` 只区分观感(成功绿 / 失败红),文案由发出方给出:运行成功、切到运行视图、
* 在浏览器打开失败都走这一条通道。
*/
export type ProjectRunNotice = {
message: string;
tone?: 'success' | 'error';
};
export type ProjectSupervisorComponentProps = {
initialProjectPath?: string;
initialProjectManifest?: GameCreationAppManifest;
@@ -64,12 +75,12 @@ export type ProjectSupervisorComponentProps = {
) => void;
onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void;
/**
* 运行 / 预览类动作的一次性浮层提示(成功态)
* 运行 / 预览类动作的一次性浮层提示。
*
* 「跑起来了」「已切到运行视图」属于过程反馈,不进对话区——对话区只保留对话内容。
* 工作台壳收到后弹 toast;预览地址由运行区域上方的小字常驻,不再占对话位置
* 「跑起来了」「已切到运行视图」「在浏览器打开失败」属于过程反馈,不进对话区——
* 对话区只保留对话内容。工作台壳收到后弹 toast`tone` 决定成功还是失败观感
*/
onRunNotice?: (message: string) => void;
onRunNotice?: (notice: ProjectRunNotice) => void;
onAgentRuntimeSummariesChange?: (
summaries: ProjectAgentRuntimeSummary[],
) => void;
+9 -48
View File
@@ -8397,10 +8397,10 @@ iframe.preview-frame {
.game-run-surface {
position: relative;
display: grid;
/* 三行:状态行(预览地址小字 + 版本入口,都在游戏画面之外)、运行画面、信息面板。 */
grid-template-rows: auto minmax(300px, 1fr) auto;
grid-template-rows: minmax(300px, 1fr) auto;
grid-row: 2 / -1;
/* 显式钉第 1 列,避免画面被自动列放置挤到隐式列里。 */
/* 显式钉第 1 resourceCanvasAssetGenerationTasksSidebar.css避免画面被自动列放置
挤到隐式列里 */
grid-column: 1;
gap: 12px;
height: 100%;
@@ -8410,64 +8410,25 @@ iframe.preview-frame {
}
/*
* 运行区域上方的状态行左边是预览地址小字右边是版本入口没有版本时不渲染
* C7 版本入口住在工作台顶栏动作区`.game-workbench-view-actions`外观由那一组的基础
* 规则给描边 + secondary 填充 + 12px/700这里只补版本名可能很长这一件事
*
* 两者必须在**同一层级同一个画外侧**版本入口以前是绝对定位压在游戏画面上
* top 20px / right 20px小字在画面外两者既不对齐又各自压着画面
* 它曾经是运行画面里的绝对定位浮层top 20px / right 20px压在游戏画面上还与顶栏其他
* 按钮分成两套皮**不要再给它加 border / background / color / hover**那就是第二套皮
*/
.game-run-status-bar {
display: flex;
min-width: 0;
min-height: 30px;
align-items: center;
gap: 12px;
}
.game-run-status-hint {
flex: 1 1 auto;
min-width: 0;
margin: 0;
padding: 0 2px;
color: #96796d;
font-size: 11px;
line-height: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* C7 版本入口:与预览地址小字同排,靠右;没有版本时不渲染。 */
.game-run-version-picker {
flex: 0 0 auto;
/* 没有小字时也要贴右,不能因为 flex 里只剩一个条目就跑到左边。 */
margin-left: auto;
display: inline-flex;
align-items: center;
}
.game-run-version-trigger {
display: inline-flex;
max-width: min(18rem, 60vw);
min-height: 30px;
align-items: center;
padding: 0 12px;
border: 1px solid #e5cfc4;
border-radius: 999px;
background: rgb(255 250 246 / 92%);
color: #8a4a30;
cursor: pointer;
font-size: 12px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-run-version-trigger:hover,
.game-run-version-trigger:focus-visible {
border-color: #cc8060;
outline: 0;
box-shadow: 0 3px 10px rgb(158 87 57 / 14%);
}
.game-run-version-menu {
display: grid;
gap: 2px;
@@ -16,10 +16,12 @@ import {
} from '@genarrative/image-canvas-react';
import { CanvasCardCornerActions } from '@genarrative/shared/components';
import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
import { openUrl } from '@tauri-apps/plugin-opener';
import {
AtSign,
Box,
Crosshair,
ExternalLink,
Eye,
FileCode2,
FileText,
@@ -107,6 +109,7 @@ import {
isFloatingOverlayWheelEvent,
useImageCanvasFloatingOptionDismiss,
} from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
import type { ProjectRunNotice } from '../../features/app-shell/model';
import { DesignWorkspacePanel } from '../../features/project-workspace/DesignWorkspacePanel';
import {
LocalGamePreviewFrame,
@@ -785,6 +788,13 @@ export type ProjectDevelopmentViewProps = {
onPlay?: () => void;
onMakeGame?: () => void;
onRevealProjectDirectory?: () => void | Promise<void>;
/**
* /
*
* toast
*
*/
onNotice?: (notice: ProjectRunNotice) => void;
onManifestChange?: (
projectPath: string,
manifest: GameCreationAppManifest,
@@ -1790,6 +1800,7 @@ export default function ProjectDevelopmentView({
onPlay,
onMakeGame,
onRevealProjectDirectory,
onNotice,
}: ProjectDevelopmentViewProps) {
const professionalDagVisible = orchestrationMode === 'professional-dag';
const [mode, setMode] = useState<WorkbenchMode>('resources');
@@ -2703,6 +2714,28 @@ export default function ProjectDevelopmentView({
const preview = previewOverride ?? manifest.preview ?? null;
const embeddedPreviewUrl = resolveEmbeddedPreviewUrl(preview);
/**
*
*
* ****
* `scripts/check-native-shells.mjs`
* `onNotice` toast
*/
const openPreviewInBrowser = useCallback(async () => {
if (!embeddedPreviewUrl) {
return;
}
try {
await openUrl(embeddedPreviewUrl);
} catch (error) {
onNotice?.({
tone: 'error',
message: `在浏览器打开失败:${
error instanceof Error ? error.message : String(error)
}`,
});
}
}, [embeddedPreviewUrl, onNotice]);
const runAvailable =
embeddedPreviewUrl !== null ||
manifest.tasks.some(
@@ -9611,6 +9644,22 @@ export default function ProjectDevelopmentView({
{runtimeInspectMode ? '退出点选' : '点选素材'}
</button>
) : null}
{/*
http://…」小字,
`.game-workbench-view-actions button`
*/}
{mode === 'run' && embeddedPreviewUrl ? (
<button
type="button"
className="game-workbench-browser-open-button"
onClick={() => void openPreviewInBrowser()}
title={`在浏览器打开 ${embeddedPreviewUrl}`}
>
<ExternalLink size={15} aria-hidden="true" />
</button>
) : null}
{mode === 'resources' && !uiEditorRoute ? (
<>
<button
@@ -9702,6 +9751,16 @@ export default function ProjectDevelopmentView({
</div>
</>
) : null}
{/*
C7 /
`.game-workbench-view-actions button`
*/}
<GameRunVersionPicker
versions={projectVersions}
activeVersionId={activeVersionId}
onSelectVersion={selectActiveVersion}
/>
</div>
{/*
****
@@ -10845,26 +10904,6 @@ export default function ProjectDevelopmentView({
</>
) : (
<section className="game-run-surface" aria-label="运行表现层">
{/*
*/}
<div className="game-run-status-bar">
{embeddedPreviewUrl ? (
<p
className="game-run-status-hint"
title={embeddedPreviewUrl}
>
{embeddedPreviewUrl}
</p>
) : null}
<GameRunVersionPicker
versions={projectVersions}
activeVersionId={activeVersionId}
onSelectVersion={selectActiveVersion}
/>
</div>
<div className="game-run-preview">
{embeddedPreviewUrl ? (
<LocalGamePreviewFrame
@@ -2353,10 +2353,10 @@ export function registerProjectWorkbenchFoundationTests() {
expect(screen.getByLabelText('运行表现层')).not.toBeNull();
});
it('shows the live preview address as small text above the run area', async () => {
it('turns the live preview address into a toolbar browser-open button', async () => {
const manifest = createGameCreationAppManifest(
'workbench-run-status-hint',
'运行区域小字测试',
'workbench-run-browser-open-entry',
'运行页浏览器入口测试',
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
@@ -2381,7 +2381,7 @@ export function registerProjectWorkbenchFoundationTests() {
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-run-status-hint',
projectPath: '/tmp/workbench-run-browser-open-entry',
manifest,
attachments: [],
recentRunStatus: null,
@@ -2397,17 +2397,15 @@ export function registerProjectWorkbenchFoundationTests() {
}),
);
const hint = await screen.findByText(
'已载入客户端运行视图:http://127.0.0.1:4173/',
);
const surface = screen.getByLabelText('运行表现层');
const preview = surface.querySelector('.game-run-preview');
expect(preview).not.toBeNull();
// 小字在游戏画面**之外**,与版本入口同在运行区域上方那条状态行里。
expect(preview?.contains(hint)).toBe(false);
const statusBar = preview?.previousElementSibling;
expect(statusBar?.classList.contains('game-run-status-bar')).toBe(true);
expect(statusBar?.contains(hint)).toBe(true);
// 地址本身不再以文字出现(长 URL 既占位又不可点),改成顶栏动作区里的入口。
expect(document.body.textContent).not.toContain('http://127.0.0.1:4173/');
const actions = document.querySelector('.game-workbench-view-actions');
const entry = await screen.findByRole('button', { name: '在浏览器打开' });
expect(actions?.contains(entry)).toBe(true);
// 运行画面内不再有状态行:画面就是这块区域里唯一的主角。
expect(document.querySelector('.game-run-status-bar')).toBeNull();
expect(document.querySelector('.game-run-status-hint')).toBeNull();
expect(screen.getByLabelText('运行表现层')).not.toBeNull();
});
it('paints the marquee selection box with the scene token root and non-empty geometry', async () => {
@@ -6001,7 +5999,7 @@ export function registerProjectWorkbenchFoundationTests() {
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\s+button\s*\{/s,
);
expect(styles).toMatch(
/\.game-run-surface\s*\{[^}]*grid-template-rows:\s*auto minmax\(300px, 1fr\) auto[^}]*grid-row:\s*2 \/ -1[^}]*height:\s*100%/s,
/\.game-run-surface\s*\{[^}]*grid-template-rows:\s*minmax\(300px, 1fr\) auto[^}]*grid-row:\s*2 \/ -1[^}]*height:\s*100%/s,
);
expect(styles).toMatch(
/\.local-game-preview-frame\s*\{[^}]*position:\s*relative[^}]*width:\s*100%[^}]*height:\s*100%[^}]*min-width:\s*0[^}]*min-height:\s*0[^}]*overflow:\s*hidden/s,
@@ -7267,7 +7265,9 @@ export function registerProjectSupervisorSurfaceTests() {
}),
{ timeout: 3_000 },
);
expect(runNotice).toHaveBeenCalledWith('运行通过,已载入客户端运行视图');
expect(runNotice).toHaveBeenCalledWith({
message: '运行通过,已载入客户端运行视图',
});
expect(screen.queryByText(/运行通过/)).toBeNull();
expect(handled).toHaveBeenCalledWith(7);
expect(invoke).toHaveBeenCalledWith('start_local_game_preview', {
@@ -1,70 +0,0 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { describe, expect, test } from 'vitest';
import { repoPath } from './repoPath';
import {
declaration,
parseStyleSheet,
resolveDeclarations,
} from './styleCascade';
/**
* 运行区域上方那条状态行的声明级断言。
*
* jsdom 不加载全局样式表,所以这里按仓库既有做法(`styleCascade`)解析真实生效的声明:
* 「预览地址小字与版本入口同排、同在游戏画面之外」这件事只由样式决定,用例必须钉在声明上,
* 否则下一次改动把版本入口挪回绝对定位压画面时没有任何东西会红。
*/
const GLOBAL_CSS_PATH = repoPath('apps/ai-game-creator-shell/src/styles.css');
const rules = parseStyleSheet(readFileSync(GLOBAL_CSS_PATH, 'utf8'));
// 同一个求值器限制:全局表里也有 `prefers-reduced-motion` 档,求值时先排除掉。
const widthRules = rules.filter(
(rule) => !(rule.media ?? '').includes('prefers-reduced-motion'),
);
function resolved(selectors: readonly string[]): Map<string, string> {
return resolveDeclarations(widthRules, selectors, 1280);
}
describe('运行区域状态行样式', () => {
test('状态行排在运行画面上方,画面自己占满剩下的一行', () => {
const surface = resolved(['.game-run-surface']);
// 三行:状态行(auto)、运行画面(至少 300px 且吃满剩余高度)、信息面板(auto)。
expect(declaration(surface, 'grid-template-rows')).toBe(
'auto minmax(300px, 1fr) auto',
);
expect(declaration(surface, 'gap')).toBe('12px');
});
test('小字与版本入口同排:一个弹性占左,一个贴右', () => {
const bar = resolved(['.game-run-status-bar']);
expect(declaration(bar, 'display')).toBe('flex');
expect(declaration(bar, 'align-items')).toBe('center');
// 状态行要有下界高度,否则没有版本入口时它会被压成 0,小字会贴着画面。
expect(declaration(bar, 'min-height')).toBe('30px');
const hint = resolved(['.game-run-status-hint']);
expect(declaration(hint, 'flex')).toBe('1 1 auto');
// 长预览地址单行省略,不把状态行撑成两行、不让它跑进画面。
expect(declaration(hint, 'white-space')).toBe('nowrap');
expect(declaration(hint, 'text-overflow')).toBe('ellipsis');
const picker = resolved(['.game-run-version-picker']);
// 版本入口不再绝对定位压在游戏画面上:它是状态行里的普通条目,靠 margin 贴右。
expect(picker.has('position')).toBe(false);
expect(picker.has('top')).toBe(false);
expect(picker.has('right')).toBe(false);
expect(picker.has('z-index')).toBe(false);
expect(declaration(picker, 'margin-left')).toBe('auto');
});
test('预览地址小字不再有自己的列容器,也不再寄生在预览框里', () => {
expect(
widthRules.some((rule) =>
rule.selectors.includes('.game-run-preview-column'),
),
).toBe(false);
});
});
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { describe, expect, test } from 'vitest';
import { repoPath } from './repoPath';
import {
declaration,
parseStyleSheet,
resolveDeclarations,
} from './styleCascade';
/**
* 运行页顶栏动作区的声明级断言。
*
* jsdom 不加载全局样式表,所以这里按仓库既有做法(`styleCascade`)解析真实生效的声明:
* 「版本入口与「在浏览器打开」跟其他动作按钮同一套皮」只由样式决定,用例必须钉在声明上,
* 否则下一次改动给版本入口单开一套 border / background / hover 时没有任何东西会红。
*/
const GLOBAL_CSS_PATH = repoPath('apps/ai-game-creator-shell/src/styles.css');
const rules = parseStyleSheet(readFileSync(GLOBAL_CSS_PATH, 'utf8'));
// 同一个求值器限制:全局表里也有 `prefers-reduced-motion` 档,求值时先排除掉。
const widthRules = rules.filter(
(rule) => !(rule.media ?? '').includes('prefers-reduced-motion'),
);
function resolved(selectors: readonly string[]): Map<string, string> {
return resolveDeclarations(widthRules, selectors, 1280);
}
function hasRule(selector: string): boolean {
return widthRules.some((rule) => rule.selectors.includes(selector));
}
describe('运行页顶栏动作区样式', () => {
test('运行画面回到两行,页面里不再有预览地址状态行', () => {
const surface = resolved(['.game-run-surface']);
expect(declaration(surface, 'grid-template-rows')).toBe(
'minmax(300px, 1fr) auto',
);
// 状态行与那行小字整体退役:地址不再以文字常驻在页面上。
expect(hasRule('.game-run-status-bar')).toBe(false);
expect(hasRule('.game-run-status-hint')).toBe(false);
});
test('版本入口住进顶栏动作区,皮由那一组的基础规则给', () => {
// 动作区的按钮共用一套基础外观(这一组是「在浏览器打开」与版本入口的共同来源)。
const actionsButton = resolved(['.game-workbench-view-actions button']);
expect(declaration(actionsButton, 'min-height')).toBe('30px');
expect(declaration(actionsButton, 'border-radius')).toBe('999px');
expect(declaration(actionsButton, 'font-size')).toBe('12px');
expect(declaration(actionsButton, 'font-weight')).toBe('700');
// 版本入口只补「版本名可能很长」这一件事,不再自带边框 / 底色 / 文字色 / hover。
const trigger = resolved(['.game-run-version-trigger']);
expect(trigger.has('border')).toBe(false);
expect(trigger.has('background')).toBe(false);
expect(trigger.has('color')).toBe(false);
expect(declaration(trigger, 'text-overflow')).toBe('ellipsis');
expect(hasRule('.game-run-version-trigger:hover')).toBe(false);
expect(hasRule('.game-run-version-trigger:focus-visible')).toBe(false);
// 版本入口不再绝对定位压在游戏画面上。
const picker = resolved(['.game-run-version-picker']);
expect(picker.has('position')).toBe(false);
expect(picker.has('top')).toBe(false);
expect(picker.has('right')).toBe(false);
});
});
@@ -70,4 +70,26 @@ describe('运行 / 预览浮层提示', () => {
});
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it('失败提示用错误色调,不需要调用方再拼一套文案', () => {
render(
<RunNoticeToast
notice={{
id: 1,
tone: 'error',
message: '在浏览器打开失败:permission denied',
}}
onDismiss={vi.fn()}
/>,
);
const toast = toastElement()?.querySelector(
'.platform-runtime-status-toast',
);
// `PlatformRuntimeStatusToast` 的失败态就是 alert + assertive,成功态是 status + polite。
expect(toast?.getAttribute('role')).toBe('alert');
expect(
screen.getByText('在浏览器打开失败:permission denied'),
).not.toBeNull();
});
});
@@ -0,0 +1,115 @@
/** @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import ProjectDevelopmentView from '../src/view/project-development';
const openUrl = vi.fn(async () => undefined);
vi.mock('@tauri-apps/plugin-opener', () => ({
openUrl: (url: string) => openUrl(url),
}));
const PREVIEW_URL = 'http://127.0.0.1:4173/';
function installInvoke() {
window.__TAURI__ = {
core: {
invoke: vi.fn(async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return {
resourceIds: [],
referenceEdges: [],
taskFlows: [],
categories: [],
diagnostics: [],
};
}
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}`);
}),
},
} as unknown as typeof window.__TAURI__;
}
function renderRunView(options: { withPreview?: boolean } = {}) {
installInvoke();
const manifest = createGameCreationAppManifest(
'run-preview-browser-open',
'运行页浏览器入口',
);
const onNotice = vi.fn();
render(
<ProjectDevelopmentView
projectName={manifest.name}
projectPath="/tmp/run-preview-browser-open"
manifest={manifest}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
preview={
options.withPreview === false
? null
: { status: 'running', url: PREVIEW_URL, port: 4173 }
}
supervisor={<div></div>}
onHomeOpen={vi.fn()}
onProjectsOpen={vi.fn()}
onNotice={onNotice}
/>,
);
return { onNotice };
}
afterEach(() => {
document.body.innerHTML = '';
vi.clearAllMocks();
vi.restoreAllMocks();
});
describe('运行页「在浏览器打开」', () => {
it('点顶栏那枚按钮就把当前预览地址交给系统浏览器', async () => {
renderRunView();
const entry = await screen.findByRole('button', {
name: '在浏览器打开',
});
fireEvent.click(entry);
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(PREVIEW_URL));
// 成功不弹提示:浏览器真的起来了,用户自己看得见。
expect(openUrl).toHaveBeenCalledTimes(1);
});
it('没有活预览时不渲染这枚入口', async () => {
renderRunView({ withPreview: false });
await screen.findByRole('tab', { name: '运行' });
expect(screen.queryByRole('button', { name: '在浏览器打开' })).toBeNull();
});
it('打开失败时把原因交给统一提示通道,而不是静默吞掉', async () => {
openUrl.mockRejectedValueOnce(new Error('permission denied'));
const { onNotice } = renderRunView();
fireEvent.click(
await screen.findByRole('button', { name: '在浏览器打开' }),
);
await waitFor(() =>
expect(onNotice).toHaveBeenCalledWith({
tone: 'error',
message: '在浏览器打开失败:permission denied',
}),
);
});
});
@@ -9222,7 +9222,17 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
## 2026-09-22 运行页收口:状态行两条信息同在画面外、生成任务入口不进运行页
> 状态行与预览地址小字已被同日下一条取代(地址改顶栏按钮、版本入口并入顶栏);「生成任务不进运行页」这条仍然有效。
- 背景:运行页的版本入口是绝对定位压在游戏画面上(`top: 20px; right: 20px`),预览地址小字在画面外,两者既不对齐又各挡一块画面;右上角还叠着「生成任务」开关(或展开的面板)。
- 决策:运行区域上方新增状态行 `.game-run-status-bar`,左边预览地址小字(单行省略 + `title` 给全量地址),右边版本入口,两者同在游戏画面**之外**、同一层级(版本入口去掉绝对定位,靠 `margin-left: auto` 贴右)。运行页不再挂 `ResourceCanvasAssetGenerationTasksPanelView`:入口、面板、锚点都不渲染,`[data-generation-tasks-placement='run']` 那一档坐标与组件 `placement` 联合类型里的 `run` 一并删除;任务不丢,切回资源页即可见。
- 影响面:`apps/ai-game-creator-shell/src/view/project-development/index.tsx``src/styles.css``src/features/resource-canvas/{ResourceCanvasAssetGenerationTasksPanelView.tsx,resourceCanvasAssetGenerationTasksSidebar.css}`;用例新增 `tests/gameRunStatusBarStyle.test.ts`,并改 `tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts``tests/resourceCanvasAssetGenerationTasksPanel.test.tsx``tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx``tests/appSurface/project-development.suite.ts`(原来钉着「入口在运行页签常驻」的那条按新口径改写)。
- 验证:`appSurface` 全量 539 通过 / 17 跳过、0 失败;AGC 壳目录全量 170 文件 2150 通过 / 22 跳过;`npm run agc:typecheck``npm run check:native-shells:contract`、编码检查与 `git diff --check` 通过;真机观感未复验。
## 2026-09-22 运行页顶栏收口:预览地址改按钮、版本入口并入顶栏同一套皮
- 背景:上一条把预览地址做成画面外的一行小字、版本入口挪到它旁边,但仍是「画面外一条常驻文字 + 顶栏另一套控件皮」:长 URL 既占位又不可点,版本入口与「打开项目目录 / 点选素材」也不像同一套控件。
- 决策:① 运行区域上方那条状态行(`.game-run-status-bar` / `.game-run-status-hint`)整体删除,运行画面回到两行栅格;② 预览地址不再以文字出现,改成顶栏动作区里的一枚「在浏览器打开」按钮(opener 插件的 `openUrl``opener:default` 已放行 `http://127.0.0.1:*`),只在有活预览时渲染;③ 版本入口搬进同一个顶栏动作区,外观直接由 `.game-workbench-view-actions button` 给,不再自带 border / background / color / hover——`.game-run-version-picker` 也不再是绝对定位浮层。
- 边界:预览的**启动与切换**仍然只走内置运行画面、不自动开系统浏览器(`scripts/check-native-shells.mjs` 那条负向守卫保持原样,本轮只补「入口只有顶栏这一枚按钮」的正向断言);「在浏览器打开」失败时把原因交给 `onNotice` 弹失败色 toast,不写进对话区、也不在工具条里另造提示位。
- 影响面:`apps/ai-game-creator-shell/src/view/project-development/index.tsx``src/styles.css``src/features/app-shell/{model.ts,RunNoticeToast.tsx,WorkspaceLauncher.tsx}``onRunNotice` 改成带 `tone` 的对象)、`src/App.tsx`(四处调用点);用例新增 `tests/runPreviewBrowserOpen.test.tsx``tests/gameRunToolbarActionsStyle.test.ts`,删除 `tests/gameRunStatusBarStyle.test.ts`,并改 `tests/runNoticeToast.test.tsx``tests/appSurface/project-development.suite.ts`
- 验证:`appSurface` 全量 539 通过 / 17 跳过、0 失败;AGC 壳目录全量 171 文件 2153 通过 / 22 跳过;`npm run typecheck``npm run check:native-shells:contract`、编码检查与 `git diff --check` 通过;真机观感未复验。
+21
View File
@@ -2741,6 +2741,14 @@ function assertAiGameCreatorShellUserDevBoundary() {
);
}
}
/*
* 预览**不自动**开系统浏览器:启动与切换运行视图只走内置画面,两条老路必须一直挡着
* App.tsx 里的 `openPreviewInExternalBrowser` / 前端直调 `open_local_game_preview` /
* Rust 侧 `.open_url(`)。
*
* 用户主动点「在浏览器打开」是另一回事:它走 opener 插件的 `openUrl`,入口只有顶栏那一枚
* 按钮(见下面那条正向断言)。别把这条负向守卫推广成「任何地方都不许出现 openUrl」。
*/
if (
aiGameCreatorShellAppSource.includes('openPreviewInExternalBrowser') ||
aiGameCreatorShellAppSource.includes('open_local_game_preview') ||
@@ -2750,6 +2758,19 @@ function assertAiGameCreatorShellUserDevBoundary() {
'AI game creator preview must not invoke the external browser',
);
}
for (const snippet of [
"'@tauri-apps/plugin-opener'",
'在浏览器打开',
'await openUrl(embeddedPreviewUrl)',
]) {
if (
!sourceIncludesSnippet(aiGameCreatorProjectDevelopmentSource, snippet)
) {
throw new Error(
`AI game creator in-browser preview entry drifted: missing ${snippet}`,
);
}
}
if (
aiGameCreatorShellTauriSource.includes('fn open_developer_window(') ||
aiGameCreatorShellTauriSource.includes(