运行页收口:状态行同在画面外,生成任务不进运行页

- 运行区域上方新增状态行,预览地址小字与版本入口同排:两者都在游戏画面之外,版本入口不再绝对定位压在画面上
- 小字单行省略并用 title 给出完整地址,状态行高度恒定 30px,没有版本入口时也不塌
- 运行页不再渲染生成任务入口、面板与锚点;[data-generation-tasks-placement='run'] 那一档坐标与 placement 里的 run 一并删除
- 按新口径改写两侧用例,新增运行区域状态行的声明级守卫用例
- 记录决策到 shared-memory/decision-log.md
This commit is contained in:
2026-09-22 10:29:53 +08:00
parent e6083050a8
commit 8e6ee50ad8
10 changed files with 257 additions and 124 deletions
@@ -56,7 +56,7 @@ export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
*
* 两页之间切换时锚点高度走样式里的 transition,不瞬移。
*/
placement?: 'canvas-overview' | 'canvas' | 'run' | 'editor';
placement?: 'canvas-overview' | 'canvas' | 'editor';
};
/**
@@ -23,8 +23,9 @@
* 偏移量迟早会压在工具条上(验收现场那条「生成任务和菜单栏重叠」就是这么来的)。
* 放进同一个网格单元格以后,「画布顶边 + 一小段内缩」由网格自己保证,工具条多高都不用管。
*
* 分档:资源栏目画布与 UI 编辑器第二 / 第三行运行表现层占 `2 / -1` 且它右上角
* `top: 20px; right: 20px`)被版本入口占着,所以那一档把内缩上边距加大到版本入口之下。
* 分档:资源栏目画布与 UI 编辑器各一档(第二 / 第三行)。**运行表现层没有这一档**——
* 运行页不挂这个锚点(见 `project-development/index.tsx`),画面右上角留给版本入口
* 预览地址小字。
*/
.game-resource-generation-tasks-anchor {
grid-row: 3;
@@ -67,11 +68,6 @@
margin-top: calc(2.625rem + 0.5rem);
}
.game-resource-generation-tasks-anchor[data-generation-tasks-placement='run'] {
grid-row: 2 / -1;
margin: 3.5rem 1.1rem 0.85rem 0.85rem;
}
.game-resource-generation-tasks-anchor[data-generation-tasks-placement='editor'] {
grid-row: 2;
}
+34 -29
View File
@@ -8397,10 +8397,10 @@ iframe.preview-frame {
.game-run-surface {
position: relative;
display: grid;
grid-template-rows: minmax(300px, 1fr) auto;
/* 三行:状态行(预览地址小字 + 版本入口,都在游戏画面之外)、运行画面、信息面板。 */
grid-template-rows: auto minmax(300px, 1fr) auto;
grid-row: 2 / -1;
/* 与右上角任务锚点同格 resourceCanvasAssetGenerationTasksSidebar.css显式钉第 1
避免画布被自动列放置挤到隐式列里 */
/* 显式钉第 1 列,避免画面被自动列放置挤到隐式列里。 */
grid-column: 1;
gap: 12px;
height: 100%;
@@ -8409,12 +8409,38 @@ iframe.preview-frame {
background: transparent;
}
/* C7 版本入口:运行模块右上角,没有版本时不渲染。 */
/*
* 运行区域上方的状态行左边是预览地址小字右边是版本入口没有版本时不渲染
*
* 两者必须在**同一层级同一个画外侧**版本入口以前是绝对定位压在游戏画面上
* top 20px / right 20px小字在画面外两者既不对齐又各自压着画面
*/
.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 {
position: absolute;
top: 20px;
right: 20px;
z-index: 5;
flex: 0 0 auto;
/* 没有小字时也要贴右,不能因为 flex 里只剩一个条目就跑到左边。 */
margin-left: auto;
}
.game-run-version-trigger {
@@ -8497,27 +8523,6 @@ iframe.preview-frame {
overflow: hidden;
}
/* 运行区域上方的小字:预览地址是过程信息,常驻在这里而不是写进对话区。 */
.game-run-preview-column {
display: flex;
min-height: 0;
flex-direction: column;
gap: 6px;
}
.game-run-status-hint {
margin: 0;
padding: 0 2px;
color: #96796d;
font-size: 11px;
line-height: 16px;
overflow-wrap: anywhere;
}
.game-run-preview-column > .game-run-preview {
flex: 1;
}
.game-run-preview iframe {
min-height: 0;
}
@@ -10845,38 +10845,42 @@ export default function ProjectDevelopmentView({
</>
) : (
<section className="game-run-surface" aria-label="运行表现层">
<GameRunVersionPicker
versions={projectVersions}
activeVersionId={activeVersionId}
onSelectVersion={selectActiveVersion}
/>
<div className="game-run-preview-column">
{/*
assistant
*/}
{/*
*/}
<div className="game-run-status-bar">
{embeddedPreviewUrl ? (
<p className="game-run-status-hint">
<p
className="game-run-status-hint"
title={embeddedPreviewUrl}
>
{embeddedPreviewUrl}
</p>
) : null}
<div className="game-run-preview">
{embeddedPreviewUrl ? (
<LocalGamePreviewFrame
title={`${projectName} 游戏运行画面`}
preview={preview}
inspectMode={runtimeInspectMode}
onInspectSelection={handleRuntimeInspectSelection}
onInspectExit={() => setRuntimeInspectMode(false)}
/>
) : (
<div className="game-run-preview-empty">
<Sparkles size={28} aria-hidden="true" />
<strong></strong>
<span></span>
</div>
)}
</div>
<GameRunVersionPicker
versions={projectVersions}
activeVersionId={activeVersionId}
onSelectVersion={selectActiveVersion}
/>
</div>
<div className="game-run-preview">
{embeddedPreviewUrl ? (
<LocalGamePreviewFrame
title={`${projectName} 游戏运行画面`}
preview={preview}
inspectMode={runtimeInspectMode}
onInspectSelection={handleRuntimeInspectSelection}
onInspectExit={() => setRuntimeInspectMode(false)}
/>
) : (
<div className="game-run-preview-empty">
<Sparkles size={28} aria-hidden="true" />
<strong></strong>
<span></span>
</div>
)}
</div>
<div className="game-run-panels">
<section aria-label="资源信息面板">
@@ -10898,41 +10902,42 @@ export default function ProjectDevelopmentView({
</section>
)}
{/*
**** / / UI
**** `isResourceCanvasFloatingPanelOpen`
+
AGC token
** / UI **
**** `isResourceCanvasFloatingPanelOpen`
****
*/}
<ResourceCanvasAssetGenerationTasksPanelView
tasks={resourceAssetGenerationTasks.filter(
(task) => task.projectId === manifest.projectId,
)}
resourceEditTasks={resourceCanvasResourceEdits.filter(
(task) =>
!task.restored ||
!resourceCanvasResourceEditTaskIsTerminal(task),
)}
open={resourceAssetGenerationTasksPanelOpen}
onToggleOpen={() =>
setResourceAssetGenerationTasksPanelOpen((current) => !current)
}
onFocusTask={focusResourceAssetGenerationTask}
/*
chrome
`[data-generation-tasks-placement]`
*/
placement={
uiEditorRoute
? 'editor'
: mode === 'run'
? 'run'
{mode === 'run' ? null : (
<ResourceCanvasAssetGenerationTasksPanelView
tasks={resourceAssetGenerationTasks.filter(
(task) => task.projectId === manifest.projectId,
)}
resourceEditTasks={resourceCanvasResourceEdits.filter(
(task) =>
!task.restored ||
!resourceCanvasResourceEditTaskIsTerminal(task),
)}
open={resourceAssetGenerationTasksPanelOpen}
onToggleOpen={() =>
setResourceAssetGenerationTasksPanelOpen((current) => !current)
}
onFocusTask={focusResourceAssetGenerationTask}
/*
chrome
`[data-generation-tasks-placement]`
*/
placement={
uiEditorRoute
? 'editor'
: resourceBookView === 'child'
? 'canvas'
: 'canvas-overview'
}
/>
}
/>
)}
</section>
{!uiEditorRoute ? (
@@ -2401,11 +2401,13 @@ export function registerProjectWorkbenchFoundationTests() {
'已载入客户端运行视图:http://127.0.0.1:4173/',
);
const surface = screen.getByLabelText('运行表现层');
expect(surface.contains(hint)).toBe(true);
// 小字紧贴在运行画面之上:它是预览框的前一个兄弟节点,而不是覆盖或寄生在别处。
const preview = surface.querySelector('.game-run-preview');
expect(preview).not.toBeNull();
expect(preview?.previousElementSibling).toBe(hint);
// 小字在游戏画面**之外**,与版本入口同在运行区域上方那条状态行里。
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);
});
it('paints the marquee selection box with the scene token root and non-empty geometry', async () => {
@@ -5999,7 +6001,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*minmax\(300px, 1fr\) auto[^}]*grid-row:\s*2 \/ -1[^}]*height:\s*100%/s,
/\.game-run-surface\s*\{[^}]*grid-template-rows:\s*auto 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,
@@ -13276,7 +13278,8 @@ export function registerProjectAgentStatusTests() {
);
expect(within(reopened).getByText('待提交设计图')).not.toBeNull();
// 入口在「运行页签下同样常驻:侧栏本体在运行态可见,入口若只在资源页签就没法再打开
// 运行页签下**不挂**「生成任务」:那一页要留给游戏画面(右上是预览地址小字与版本入口)
// 任务不会因此丢——切回资源页签,入口与面板都回来。
fireEvent.click(
within(reopened).getByRole('button', { name: '关闭生成任务' }),
);
@@ -13286,8 +13289,16 @@ export function registerProjectAgentStatusTests() {
expect(
screen.getByRole('tab', { name: '运行' }).getAttribute('aria-selected'),
).toBe('true');
expect(
screen.queryByRole('button', { name: /^生成任务(?: · \d+)?$/ }),
).toBeNull();
expect(
document.querySelector('.game-resource-generation-tasks-anchor'),
).toBeNull();
fireEvent.click(screen.getByRole('tab', { name: '资源管理' }));
fireEvent.click(
screen.getByRole('button', { name: /^生成任务(?: · \d+)?$/ }),
await screen.findByRole('button', { name: /^生成任务(?: · \d+)?$/ }),
);
expect(
await screen.findByRole('region', { name: '生成任务' }),
@@ -0,0 +1,70 @@
// @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);
});
});
@@ -348,23 +348,12 @@ describe('「生成任务」侧栏', () => {
expect(onToggleOpen).toHaveBeenCalledTimes(1);
});
test('锚点按工作面分档:资源画布 / 运行表现层 / UI 编辑器各挂一档', () => {
const { container, rerender } = renderSidebar([], { placement: 'run' });
test('锚点按工作面分档:资源画布 / UI 编辑器各挂一档', () => {
const { container, rerender } = renderSidebar([], { placement: 'editor' });
const placementOf = () =>
container
.querySelector('.game-resource-generation-tasks-anchor')
?.getAttribute('data-generation-tasks-placement');
expect(placementOf()).toBe('run');
rerender(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[]}
open
onToggleOpen={vi.fn()}
onFocusTask={vi.fn()}
placement="editor"
/>,
);
expect(placementOf()).toBe('editor');
// 缺省 = 资源栏目画布,老调用方不传这个 prop 也落在画布右上角。
@@ -231,7 +231,7 @@ describe('「生成任务」侧栏样式', () => {
expect(sidebar.has('left')).toBe(false);
});
test('锚点贴在工作面右上角,运行表现层让开版本入口,开关沿用次级胶囊样式', () => {
test('锚点贴在工作面右上角,运行表现层不挂这一档,开关沿用次级胶囊样式', () => {
const anchor = resolved(['.game-resource-generation-tasks-anchor']);
// 锚点是 stage 网格里与工作面同一个单元格的条目:贴右贴顶由网格给,不再用写死 top 的绝对定位
//(写死的 top 会被工具条换行顶穿,验收现场那条「生成任务和菜单栏重叠」就是这么来的)。
@@ -244,14 +244,15 @@ describe('「生成任务」侧栏样式', () => {
// 画布顶边内缩一小段,不压在工具条那一行上。
expect(declaration(anchor, 'margin')).toBe('0.85rem');
// 运行表现层右上角被版本入口(`game-run-version-picker`top 20px / right 20px)占着,
// 这一档必须把开关压到那枚版本入口下面(内缩上边距 3.5rem)
const runAnchor = resolved([
'.game-resource-generation-tasks-anchor',
".game-resource-generation-tasks-anchor[data-generation-tasks-placement='run']",
]);
expect(declaration(runAnchor, 'grid-row')).toBe('2 / -1');
expect(declaration(runAnchor, 'margin')).toContain('3.5rem');
// 运行表现层不再挂这个锚点(运行页要留给游戏画面,见 `project-development/index.tsx`):
// 「让开右上角版本入口」那一档坐标随之退役,留一条死规则在这里只会误导下一次改动
expect(
widthRules.some((rule) =>
rule.selectors.includes(
".game-resource-generation-tasks-anchor[data-generation-tasks-placement='run']",
),
),
).toBe(false);
// UI 编辑器那一档与资源画布同档(第二行),不引入第三套坐标。
const editorAnchor = resolved([
@@ -170,6 +170,55 @@ describe('「生成任务」侧栏的开合与入口位置', () => {
).toBe(true);
});
it('运行页不挂「生成任务」入口与面板,切回资源页又回来', async () => {
installInvoke();
const manifest = createGameCreationAppManifest(
'workbench-run-hides-tasks-entry',
'运行页隐藏生成任务',
);
// 有已完成的可运行原型:运行页签可用,但没有活预览,所以首屏仍停在资源管理。
manifest.tasks = manifest.tasks.map((task) =>
task.id === 'code-prototype' ? { ...task, status: 'completed' } : task,
);
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-run-hides-tasks-entry',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
// 资源页照旧有入口与锚点。
expect(
await screen.findByRole('button', { name: '生成任务' }),
).not.toBeNull();
expect(
document.querySelector('.game-resource-generation-tasks-anchor'),
).not.toBeNull();
// 运行页:入口、面板、锚点一起消失,画面右上角只留版本入口与预览地址小字。
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
await waitFor(() =>
expect(screen.getByLabelText('运行表现层')).not.toBeNull(),
);
expect(
document.querySelector('.game-resource-generation-tasks-anchor'),
).toBeNull();
expect(screen.queryByRole('button', { name: /^生成任务/ })).toBeNull();
// 切回资源页入口回来:任务只是在这页不显示,没有被丢掉。
fireEvent.click(screen.getByRole('tab', { name: '资源管理' }));
expect(
await screen.findByRole('button', { name: '生成任务' }),
).not.toBeNull();
});
it('「依赖 / 类型」与前一按钮之间的间距跟行内其他按钮一致', () => {
const styles = readFileSync(
repoPath('apps/ai-game-creator-shell/src/styles.css'),
@@ -9219,3 +9219,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 决策:对话区只保留对话内容(用户消息、Agent 回复、run trace 摘要、命令确认与错误)。运行 / 预览成功的反馈改由两条通道承载:① 工作台壳的 toast(`ProjectSupervisorComponentProps.onRunNotice``RunNoticeToast`,2.6 秒自动收起,同一句连续触发会重新计时);② 运行表现层预览框**上方**的小字(`已载入客户端运行视图:<url>`,只在有活预览时出现,不占位)。失败仍留在对话区——用户需要知道为什么没跑起来;`/preview-status` 这类命令问答也保留在对话区。
- 影响面:`apps/ai-game-creator-shell/src/App.tsx`(四处成功分支)、`src/features/app-shell/{model.ts,WorkspaceLauncher.tsx,RunNoticeToast.tsx}``src/view/project-development/index.tsx``src/styles.css`;随字符串守卫 `apps/ai-game-creator-shell/scripts/check-config.mjs``scripts/check-native-shells.mjs` 同步改判据。
- 验证:`appSurface` 全量 539 通过 / 17 跳过、0 失败;AGC 壳目录全量 169 文件 2146 通过 / 22 跳过;`tests/runNoticeToast.test.tsx` 3 条;`npm run agc:typecheck`(含 check-config)与 `npm run check:native-shells:contract` 通过;真机观感未复验。
## 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` 通过;真机观感未复验。