Merge pull request '运行页签补齐全屏预览与可收起的信息栏' (#497) from feat/run-view-chrome-cleanup into master
Project CI / Backend tests (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled

Reviewed-on: #497
This commit was merged in pull request #497.
This commit is contained in:
2026-09-23 20:50:42 +08:00
16 changed files with 633 additions and 120 deletions
+21 -6
View File
@@ -459,7 +459,11 @@ export interface AgentRuntimeResult {
}
export type AgentRuntimeResponseStreamStatus =
'streaming' | 'ready' | 'committed' | 'discarded' | 'failed';
| 'streaming'
| 'ready'
| 'committed'
| 'discarded'
| 'failed';
export interface AgentRuntimeResponseStream {
schemaVersion: string;
@@ -565,13 +569,22 @@ export interface GameCreatorAgentLlmConfigStatus {
}
export type GameCreatorLlmApiKind =
'openai_responses' | 'openai_chat' | 'anthropic';
| 'openai_responses'
| 'openai_chat'
| 'anthropic';
export type GameCreatorAgentMode =
'codex_app_server' | 'codex_cli' | 'provider';
| 'codex_app_server'
| 'codex_cli'
| 'provider';
export type RuntimeLlmProviderPresetId =
'custom' | 'openai' | 'deepseek' | 'anthropic' | 'ark';
| 'custom'
| 'openai'
| 'deepseek'
| 'anthropic'
| 'ark';
export type RuntimeAgentLlmProviderPresetId =
'inherit' | RuntimeLlmProviderPresetId;
| 'inherit'
| RuntimeLlmProviderPresetId;
export interface GameCreatorLlmConfig {
customEnabled?: boolean;
@@ -931,7 +944,9 @@ export type GameCreatorDirectToolCallKind =
| 'other';
export type GameCreatorDirectToolCallStatus =
'running' | 'completed' | 'failed';
| 'running'
| 'completed'
| 'failed';
export interface GameCreatorDirectToolCallChange {
path: string;
@@ -186,7 +186,23 @@ export function resolveLocalGamePreviewFitLayout(
): LocalGamePreviewFitLayout {
const containerWidth = Math.max(1, container.width);
const containerHeight = Math.max(1, container.height);
if (!content) {
// 上报的「内容尺寸」等于它被接受时的容器尺寸,说明这个页面没有超出视口的固有内容——自适应的
// 全屏游戏(Phaser `Scale.RESIZE` 那类)就是这样,桥把视口原样报回来。这份数字不携带固有尺寸,
// 不能当高水位:否则运行视口一放大(例如全屏预览)就把画布钉在那个尺寸上,退出全屏后画布仍按
// 全屏比例被缩进小容器、两边留出黑边,且再也回不去(iframe 视口不变 ⇒ 桥不会再报新尺寸)。
// 这种页面继续让画布跟着容器走;真的比容器高的页面(内容 ≠ 视口)仍按原生尺寸缩放显示。
//
// 已知残余(不修,因为它与上面这条在数据上不可区分):某个**固定尺寸**页面恰好等于它被接受时
// 的容器(1px 内),且缩小容器后上报的内容尺寸再不变,就会一直按容器取画布——页面自身溢出被
// `overflow: hidden` 裁掉。改成「内容尺寸没变也把这条记录改认新容器」会反过来让上面那种自适应
// 页面的过渡期上报(内容仍是放大前的旧值、视口已是新容器)被当成固有尺寸,全屏那类问题原样
// 复现(实测过)。AGC 的桥对溢出文档才报出更大的内容尺寸,实测自适应与固定画布两种页面都报
// 「内容 = 视口」,所以按自适应优先。
if (
!content ||
(Math.abs(content.contentWidth - content.viewportWidth) < 1 &&
Math.abs(content.contentHeight - content.viewportHeight) < 1)
) {
return { width: containerWidth, height: containerHeight, scale: 1 };
}
const width = Math.max(containerWidth, content.contentWidth);
@@ -0,0 +1,75 @@
import {
type RefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
export type ElementFullscreenController<T extends HTMLElement> = {
/** 要全屏的那一格;挂在它的 `ref` 上。 */
ref: RefObject<T | null>;
isFullscreen: boolean;
isSupported: boolean;
toggleFullscreen: () => void;
};
/**
* 运行画面「全屏预览」用的**元素级**全屏。
*
* 只走标准 Fullscreen API:宿主是浏览器还是客户端 WebView 都是同一份实现。**不接 Tauri 的
* 窗口级全屏**——那是把整块工作台(连对话栏)放大,语义是「全屏应用」,不是「全屏预览画面」。
*
* 支持判据是两层:`requestFullscreen` 真的在,且没有被宿主显式关掉
* `fullscreenEnabled === false`,例如被权限策略挡住)。任一不成立就不渲染这枚按钮——
* 一枚点了没反应的全屏按钮比没有按钮更糟。
*
* 全屏元素被移除时浏览器按规范自己退出全屏(切回资源页签、切项目都走这条),所以这里不为
* 卸载补退出逻辑;按钮态只认 `fullscreenchange`Esc 与宿主自己退出都会回落。
*/
export function useElementFullscreen<
T extends HTMLElement,
>(): ElementFullscreenController<T> {
const elementRef = useRef<T | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const isSupported =
typeof document !== 'undefined' &&
document.fullscreenEnabled !== false &&
typeof document.documentElement?.requestFullscreen === 'function';
useEffect(() => {
if (typeof document === 'undefined') {
return undefined;
}
// 事件挂在全局 `document`、在回调里读 ref:运行画面是条件挂载的,按 ref 订监听会在
// 「进运行页签之前」就订不上,退出全屏(Esc、F11、宿主)再也收不回来。
const sync = () => {
const element = elementRef.current;
setIsFullscreen(
element !== null && document.fullscreenElement === element,
);
};
document.addEventListener('fullscreenchange', sync);
sync();
return () => document.removeEventListener('fullscreenchange', sync);
}, []);
const toggleFullscreen = useCallback(() => {
const element = elementRef.current;
if (!element) {
return;
}
const ownerDocument = element.ownerDocument;
// 已经有人在全屏(本元素,或页面里别的东西)时这一步只负责退出:退出本身幂等,
// 不需要先判断当前全屏的是不是自己。
if (ownerDocument.fullscreenElement) {
void ownerDocument.exitFullscreen?.()?.catch(() => {});
return;
}
// 失败(用户手势丢失、权限策略拒绝)不改按钮状态,界面回到「还是没全屏」的原样;
// 拒绝的 Promise 必须接住,否则会冒成未处理拒绝。
void element.requestFullscreen?.()?.catch(() => {});
}, []);
return { ref: elementRef, isFullscreen, isSupported, toggleFullscreen };
}
+88 -22
View File
@@ -8731,6 +8731,43 @@ iframe.preview-frame {
min-height: 0;
}
/*
* 画面右下角的全屏预览压在游戏画面上所以用深色半透明底 + 白图标任何游戏配色下都
* 看得清也不在画面中间抢位置全屏那一格还是它自己`:fullscreen` 铺满屏幕所以这枚按钮
* 在全屏里照旧可用用户点它就能退出来
*/
.game-run-preview-fullscreen {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid rgb(255 255 255 / 28%);
border-radius: 10px;
background: rgb(12 14 20 / 62%);
color: #fff;
cursor: pointer;
backdrop-filter: blur(6px);
}
.game-run-preview-fullscreen:hover,
.game-run-preview-fullscreen:focus-visible {
background: rgb(12 14 20 / 84%);
outline: 0;
}
/* 全屏里这一格就是整块屏幕:舞台自己的虚线边框与圆角让位给画面。 */
.game-run-preview:fullscreen {
min-height: 0;
border: 0;
border-radius: 0;
background: #05070b;
}
.game-run-preview-empty {
display: grid;
align-content: center;
@@ -8753,13 +8790,61 @@ iframe.preview-frame {
overflow-wrap: anywhere;
}
/*
* 运行页签的底部信息栏没有内容时整栏不渲染判据在 `project-development/index.tsx`
* 收起态只剩上面那一行开合按钮条目卡片的 156px 最小高度不会再变成一片空白色块
*
* 卡片只在**有内容**时渲染一栏也铺满整行不留半张空位原先数值微调那一栏没有登记表
* 前端没有数据源按用户口径没有功能就先不渲染它的字段样式label / input随这一栏一起
* 删掉登记表接进来时样式与卡片一起回来
*/
.game-run-panels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.game-run-panels-controls {
display: flex;
justify-content: flex-end;
}
.game-run-panels-toggle {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 26px;
padding: 0 10px;
border: 1px solid #eadbd4;
border-radius: 999px;
background: #fff;
color: #62483d;
cursor: pointer;
font-size: 11px;
font-weight: 700;
}
.game-run-panels-toggle:hover,
.game-run-panels-toggle:focus-visible {
border-color: #dfb59f;
background: #fdf1ea;
outline: 0;
}
.game-run-panels-toggle-chevron {
transition: transform 120ms ease;
}
.game-run-panels-toggle-chevron.is-collapsed {
transform: rotate(-90deg);
}
.game-run-panels-body {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
gap: 10px;
}
.game-run-panels > section {
.game-run-panels-body > section {
display: grid;
align-content: start;
gap: 8px;
@@ -8781,7 +8866,6 @@ iframe.preview-frame {
}
.game-run-panels p,
.game-run-panels label,
.game-run-panels dt,
.game-run-panels dd {
margin: 0;
@@ -8809,24 +8893,6 @@ iframe.preview-frame {
overflow-wrap: anywhere;
}
.game-run-panels label {
display: grid;
grid-template-columns: minmax(0, 1fr) 88px;
align-items: center;
gap: 8px;
}
.game-run-panels input {
min-width: 0;
height: 30px;
padding: 0 8px;
border: 1px solid #eadbd4;
border-radius: 8px;
background: #faf7f5;
color: #8f7d75;
font-size: 10px;
}
.game-workbench-chat {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
@@ -10173,7 +10239,7 @@ iframe.preview-frame {
min-width: 460px;
}
.game-run-panels {
.game-run-panels-body {
grid-template-columns: 1fr;
}
@@ -20,6 +20,7 @@ import { openUrl } from '@tauri-apps/plugin-opener';
import {
AtSign,
Box,
ChevronDown,
Crosshair,
ExternalLink,
Eye,
@@ -34,6 +35,7 @@ import {
LayoutGrid,
ListFilter,
Maximize2,
Minimize2,
Minus,
Music2,
PackageOpen,
@@ -141,6 +143,7 @@ import {
resourceLabelResolver,
resourceReferenceCategoryLabel,
} from '../../features/project-workspace/resourceReferences';
import { useElementFullscreen } from '../../features/project-workspace/useElementFullscreen';
import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker';
import {
type ResourceCanvasAssetGenerationPanelDraft,
@@ -1818,6 +1821,9 @@ export default function ProjectDevelopmentView({
}: ProjectDevelopmentViewProps) {
const [mode, setMode] = useState<WorkbenchMode>('resources');
const [runtimeInspectMode, setRuntimeInspectMode] = useState(false);
// 运行画面的「全屏预览」:全屏的是画面那一格(`.game-run-preview`),不是整块工作台,
// 所以按钮与 ref 都归运行表现层自己持有。
const runPreviewFullscreen = useElementFullscreen<HTMLDivElement>();
const [resourceBookState, dispatchResourceBook] = useReducer(
resourceBookReducer,
initialResourceBookState,
@@ -3000,6 +3006,31 @@ export default function ProjectDevelopmentView({
const selectedResource =
canvasResources.find((resource) => resource.id === selectedResourceId) ??
null;
/**
* `.game-run-panels`
*
* ****
* /
* /
*
* effect** id** /
*
* effect
*
*
*
*/
const runPanelsHaveContent = selectedResource !== null;
const [runPanelsManualState, setRunPanelsManualState] = useState<{
resourceId: string | null;
expanded: boolean;
} | null>(null);
const runPanelsExpanded =
runPanelsManualState !== null &&
runPanelsManualState.resourceId === selectedResourceId
? runPanelsManualState.expanded
: runPanelsHaveContent;
const runPanelsBodyId = useId();
/**
*
* - 1
@@ -11002,7 +11033,7 @@ export default function ProjectDevelopmentView({
</>
) : (
<section className="game-run-surface" aria-label="运行表现层">
<div className="game-run-preview">
<div className="game-run-preview" ref={runPreviewFullscreen.ref}>
{embeddedPreviewUrl ? (
<LocalGamePreviewFrame
title={`${projectName} 游戏运行画面`}
@@ -11018,24 +11049,81 @@ export default function ProjectDevelopmentView({
<span></span>
</div>
)}
{/*
****
宿 Fullscreen API `useElementFullscreen`
*/}
{embeddedPreviewUrl && runPreviewFullscreen.isSupported ? (
<button
type="button"
className="game-run-preview-fullscreen"
aria-label={
runPreviewFullscreen.isFullscreen
? '退出全屏预览'
: '全屏预览'
}
aria-pressed={runPreviewFullscreen.isFullscreen}
title={
runPreviewFullscreen.isFullscreen
? '退出全屏预览'
: '全屏预览游戏画面'
}
onClick={runPreviewFullscreen.toggleFullscreen}
>
{runPreviewFullscreen.isFullscreen ? (
<Minimize2 size={16} aria-hidden="true" />
) : (
<Maximize2 size={16} aria-hidden="true" />
)}
</button>
) : null}
</div>
<div className="game-run-panels">
<section aria-label="资源信息面板">
<header>
<Info size={16} aria-hidden="true" />
</header>
{selectedResource ? (
<ResourceInfoFieldsView resource={selectedResource} />
{/*
156px
*/}
{runPanelsHaveContent ? (
<div className="game-run-panels">
<div className="game-run-panels-controls">
<button
type="button"
className="game-run-panels-toggle"
aria-expanded={runPanelsExpanded}
aria-controls={runPanelsBodyId}
onClick={() =>
setRunPanelsManualState({
resourceId: selectedResourceId,
expanded: !runPanelsExpanded,
})
}
>
<ChevronDown
size={14}
aria-hidden="true"
className={`game-run-panels-toggle-chevron${
runPanelsExpanded ? '' : ' is-collapsed'
}`}
/>
{runPanelsExpanded ? '收起信息栏' : '展开信息栏'}
</button>
</div>
{runPanelsExpanded ? (
<div id={runPanelsBodyId} className="game-run-panels-body">
<section aria-label="资源信息面板">
<header>
<Info size={16} aria-hidden="true" />
</header>
{selectedResource ? (
<ResourceInfoFieldsView resource={selectedResource} />
) : null}
</section>
</div>
) : null}
</section>
<section aria-label="数值微调面板">
<header>
<SlidersHorizontal size={16} aria-hidden="true" />
</header>
</section>
</div>
</div>
) : null}
</section>
)}
{/*
@@ -198,7 +198,7 @@ export function PlanningChatView({
: undefined;
const streaming = Boolean(
animation &&
(!animation.persisted || animation.visible !== animation.target),
(!animation.persisted || animation.visible !== animation.target),
);
return (
<div
@@ -1716,7 +1716,8 @@ export function registerHomeProjectCreationTests() {
'卡住的自动建项',
);
let resolveAutomaticProject:
((result: Record<string, unknown>) => void) | null = null;
| ((result: Record<string, unknown>) => void)
| null = null;
const invoke = vi.fn(async (command: string) => {
if (command === 'preflight_web_game_creation') return { status: 'ready' };
if (command === 'create_automatic_local_game_project') {
@@ -3883,6 +3883,11 @@ export function registerProjectWorkbenchFoundationTests() {
fireEvent.click(runTab);
const runInfoPanel = screen.getByLabelText('资源信息面板');
expect(resourceInfoFieldRows(runInfoPanel)).toEqual(expectedRows);
// 有内容就自动展开;手动收起后条目卡片整体让位,只剩那一行开合按钮。
fireEvent.click(screen.getByRole('button', { name: '收起信息栏' }));
expect(screen.queryByLabelText('资源信息面板')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '展开信息栏' }));
expect(screen.getByLabelText('资源信息面板')).not.toBeNull();
// 画布浮层只属于画布:切到运行视图后不再渲染。
expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull();
});
@@ -6453,8 +6458,10 @@ export function registerProjectWorkbenchFoundationTests() {
'allow-scripts allow-same-origin allow-forms allow-pointer-lock',
);
expect(screen.queryByLabelText('测试切片控件')).toBeNull();
expect(screen.getByLabelText('资源信息面板')).not.toBeNull();
expect(screen.getByLabelText('数值微调面板')).not.toBeNull();
// 底部信息栏没有内容就整栏不渲染:此时没有选中资源,「信息展示」拿不到字段,「数值微调」的
// 登记表也还没接,于是条目卡片与开合行都不该出现——留着就是验收现场那半条「空信息栏白占一块高度」。
expect(screen.queryByLabelText('资源信息面板')).toBeNull();
expect(screen.queryByRole('button', { name: '展开信息栏' })).toBeNull();
fireEvent.click(screen.getByRole('tab', { name: '资源管理' }));
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
@@ -6,77 +6,151 @@ import { describe, expect, it, vi } from 'vitest';
import {
LOCAL_GAME_PREVIEW_SIZE_MESSAGE,
type LocalGamePreviewContentSize,
LocalGamePreviewFrame,
parseLocalGamePreviewContentSize,
resolveLocalGamePreviewContentSizeUpdate,
resolveLocalGamePreviewFitLayout,
} from '../src/features/project-workspace/LocalGamePreviewFrame';
/**
* 运行画面用 `getBoundingClientRect` 量容器、用 `ResizeObserver` 跟踪;jsdom 两者都不给,这里补一份
* 最小可驱动的:能改容器矩形、能手放 ResizeObserver 回调、能送跨窗口尺寸上报。
*/
function renderFittedFrame(initialContainer: {
width: number;
height: number;
}) {
let containerRect = initialContainer;
let resizeCallback: ResizeObserverCallback | null = null;
const rectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(
() =>
({
...containerRect,
x: 0,
y: 0,
top: 0,
right: containerRect.width,
bottom: containerRect.height,
left: 0,
toJSON: () => ({}),
}) as DOMRect,
);
const previousResizeObserver = window.ResizeObserver;
window.ResizeObserver = class {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
};
const view = render(
createElement(LocalGamePreviewFrame, {
preview: { status: 'running', url: 'http://127.0.0.1:1234/' },
title: 'preview',
}),
);
const iframe = view.getByTitle('preview') as HTMLIFrameElement;
return {
iframe,
resizeTo(next: { width: number; height: number }) {
containerRect = next;
act(() => {
if (!resizeCallback) {
throw new Error('ResizeObserver was not registered');
}
resizeCallback([], {} as ResizeObserver);
});
},
reportSize(size: LocalGamePreviewContentSize) {
act(() => {
window.dispatchEvent(
new MessageEvent('message', {
origin: 'http://127.0.0.1:1234',
source: iframe.contentWindow,
data: { type: LOCAL_GAME_PREVIEW_SIZE_MESSAGE, ...size },
}),
);
});
},
cleanup() {
view.unmount();
window.ResizeObserver = previousResizeObserver;
rectSpy.mockRestore();
},
};
}
describe('local game preview viewport fitting', () => {
it('does not reset the fitted iframe to native size while its container resizes', () => {
let containerRect = { width: 800, height: 500 };
let resizeCallback: ResizeObserverCallback | null = null;
const rectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(
() =>
({
...containerRect,
x: 0,
y: 0,
top: 0,
right: containerRect.width,
bottom: containerRect.height,
left: 0,
toJSON: () => ({}),
}) as DOMRect,
);
const previousResizeObserver = window.ResizeObserver;
window.ResizeObserver = class {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
};
const view = render(
createElement(LocalGamePreviewFrame, {
preview: { status: 'running', url: 'http://127.0.0.1:1234/' },
title: 'preview',
}),
);
const iframe = view.getByTitle('preview') as HTMLIFrameElement;
act(() => {
window.dispatchEvent(
new MessageEvent('message', {
origin: 'http://127.0.0.1:1234',
source: iframe.contentWindow,
data: {
type: LOCAL_GAME_PREVIEW_SIZE_MESSAGE,
contentWidth: 800,
contentHeight: 835,
viewportWidth: 800,
viewportHeight: 500,
},
}),
);
const frame = renderFittedFrame({ width: 800, height: 500 });
frame.reportSize({
contentWidth: 800,
contentHeight: 835,
viewportWidth: 800,
viewportHeight: 500,
});
expect(iframe.style.height).toBe('835px');
expect(frame.iframe.style.height).toBe('835px');
containerRect = { width: 1000, height: 600 };
act(() => {
if (!resizeCallback) throw new Error('ResizeObserver was not registered');
resizeCallback([], {} as ResizeObserver);
frame.resizeTo({ width: 1000, height: 600 });
expect(frame.iframe.style.width).toBe('1000px');
expect(frame.iframe.style.height).toBe('835px');
frame.cleanup();
});
it('returns the fitted iframe to the container after the host viewport shrinks', () => {
// 全屏预览把运行视口放大到 1416x808,自适应游戏把视口原样报回来;退出全屏后画布必须跟着
// 缩回容器尺寸,不能把全屏那一帧的尺寸钉在画布上(改前这里会一直是 1416x808 + 缩放到 0.72)。
const frame = renderFittedFrame({ width: 1416, height: 808 });
frame.reportSize({
contentWidth: 1416,
contentHeight: 808,
viewportWidth: 1416,
viewportHeight: 808,
});
expect(iframe.style.width).toBe('1000px');
expect(iframe.style.height).toBe('835px');
expect(frame.iframe.style.width).toBe('1416px');
expect(frame.iframe.style.height).toBe('808px');
view.unmount();
window.ResizeObserver = previousResizeObserver;
rectSpy.mockRestore();
frame.resizeTo({ width: 1015, height: 660 });
expect(frame.iframe.style.width).toBe('1015px');
expect(frame.iframe.style.height).toBe('660px');
frame.cleanup();
});
it('refits to the reported content size after the container shrinks', () => {
// 容器缩小后先按容器取画布(上一次的内容尺寸已不能代表当前容器),页面在新容器上重新量出
// 更大的内容(真的溢出)时,画布回到 `max(容器, 内容)` 并把整幅内容等比缩小。
const frame = renderFittedFrame({ width: 1015, height: 660 });
frame.reportSize({
contentWidth: 1015,
contentHeight: 660,
viewportWidth: 1015,
viewportHeight: 660,
});
expect(frame.iframe.style.width).toBe('1015px');
frame.resizeTo({ width: 800, height: 520 });
expect(frame.iframe.style.width).toBe('800px');
frame.reportSize({
contentWidth: 1200,
contentHeight: 900,
viewportWidth: 800,
viewportHeight: 520,
});
expect(frame.iframe.style.width).toBe('1200px');
expect(frame.iframe.style.height).toBe('900px');
expect(
Number(/scale\(([\d.]+)\)/u.exec(frame.iframe.style.transform)?.[1]),
).toBeCloseTo(520 / 900, 10);
frame.cleanup();
});
it('keeps the current fit while the iframe reports its first host-applied viewport measurement', () => {
@@ -0,0 +1,142 @@
/** @vitest-environment jsdom */
import { fireEvent, render, screen } 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 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() {
installInvoke();
const manifest = createGameCreationAppManifest(
'run-preview-fullscreen',
'运行页全屏预览',
);
render(
<ProjectDevelopmentView
projectName={manifest.name}
projectPath="/tmp/run-preview-fullscreen"
manifest={manifest}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
preview={{ status: 'running', url: PREVIEW_URL, port: 4173 }}
supervisor={<div></div>}
onHomeOpen={vi.fn()}
onProjectsOpen={vi.fn()}
onNotice={vi.fn()}
/>,
);
}
/**
* jsdom 完全没有 Fullscreen API`document.fullscreenEnabled` / `requestFullscreen` /
* `fullscreenElement` 都是 undefined),所以这一组用例必须自己把宿主那一份补出来:
* 全屏状态、元素身份与 `fullscreenchange` 都按规范最小实现,只用于验按钮的真实行为。
*/
function installFullscreenHost() {
let fullscreenElement: Element | null = null;
const requestFullscreen = vi.fn(function (this: Element) {
// 这枚桩就是要记录调用方传进来的 `this`:全屏元素身份正是本用例的断言对象(画面那一格)。
// eslint-disable-next-line @typescript-eslint/no-this-alias
fullscreenElement = this;
document.dispatchEvent(new Event('fullscreenchange'));
return Promise.resolve();
});
const exitFullscreen = vi.fn(() => {
fullscreenElement = null;
document.dispatchEvent(new Event('fullscreenchange'));
return Promise.resolve();
});
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => fullscreenElement,
});
Object.defineProperty(document, 'exitFullscreen', {
configurable: true,
value: exitFullscreen,
});
Object.defineProperty(Element.prototype, 'requestFullscreen', {
configurable: true,
writable: true,
value: requestFullscreen,
});
return { requestFullscreen, exitFullscreen };
}
afterEach(() => {
document.body.innerHTML = '';
Reflect.deleteProperty(document, 'fullscreenElement');
Reflect.deleteProperty(document, 'exitFullscreen');
Reflect.deleteProperty(Element.prototype, 'requestFullscreen');
vi.clearAllMocks();
vi.restoreAllMocks();
});
describe('运行页「全屏预览」', () => {
it('点画面右下角那枚按钮就把画面那一格送进全屏,再点退出', async () => {
const { requestFullscreen, exitFullscreen } = installFullscreenHost();
renderRunView();
const button = await screen.findByRole('button', { name: '全屏预览' });
// 按钮住在画面那一格里(右下角由样式给),全屏的也是那一格——不是整块工作台。
const stage = document.querySelector('.game-run-preview');
expect(stage).not.toBeNull();
expect(button.closest('.game-run-preview')).toBe(stage);
expect(button.getAttribute('aria-pressed')).toBe('false');
fireEvent.click(button);
expect(requestFullscreen).toHaveBeenCalledTimes(1);
// 全屏元素就是画面那一格:按钮自己也算得出来(状态来自 fullscreenchange,不是乐观值)。
expect(document.fullscreenElement).toBe(stage);
const exitButton = await screen.findByRole('button', {
name: '退出全屏预览',
});
expect(exitButton.getAttribute('aria-pressed')).toBe('true');
fireEvent.click(exitButton);
expect(exitFullscreen).toHaveBeenCalledTimes(1);
expect(
(await screen.findByRole('button', { name: '全屏预览' })).getAttribute(
'aria-pressed',
),
).toBe('false');
});
it('宿主没有 Fullscreen API 时不渲染这枚按钮,而不是留一个点了没反应的入口', async () => {
renderRunView();
await screen.findByTitle('运行页全屏预览 游戏运行画面');
expect(screen.queryByRole('button', { name: '全屏预览' })).toBeNull();
});
});
@@ -75,7 +75,8 @@
- 运行视窗必须占满中央工作区为游戏保留的可用区域。loopback 预览页通过客户端本地 preview server 注入的只读尺寸桥上报文档实际宽高;宿主只接受当前 iframe、当前 loopback origin 的固定版本消息,并将完整游戏文档等比缩放、居中放入视窗。iframe 首次适配后发生的真实内容增高或缩短仍必须被接受;仅浏览上下文宽高回灌或内容宽高未变化时保持当前状态,不触发重复渲染。
- 窗口或中央区域尺寸变化后必须重新测量和适配;内容已经放得下时保持 `1:1`,不得无故放大。游戏文档宽高超过视窗时缩小整体画面,不显示 iframe 横向或纵向滚动条,也不得用单纯裁切替代完整展示。尺寸桥以根布局 `ResizeObserver` 为主,并在页面可见时每 `500ms` 至多探测 `512` 个元素作为绝对定位溢出的低频兜底;探测截断时不得用部分样本下调尺寸,viewport 耦合的 `100vh / 100% / bottom / right` 布局也不得形成自反馈。相同测量结果去重,不监听整页属性、文本或子节点突变;桥不读取项目正文、不修改 manifest、游戏文件或运行业务状态。桥脚本只能注入到真实 HTML 标签上下文,不能把脚本、样式、模板或注释中的 `</body>` / `</html>` 文本误判为结束标签;省略结束标签的 UTF-8 HTML 仍需安全注入。
- 运行视窗下方继续保留“信息展示”和“数值微调”区域标题及原有面板高度;没有真实资源信息或已登记微调项时,内容区域保持空白,不显示示例字段、默认数值、未载入控件或功能说明,也不得因内容为空压缩两个面板。Agent 对话标题栏不显示头像图标,“与陶泥儿的对话”及副标题按标题栏左侧对齐,钱包和审批入口继续位于右侧
- 运行视窗右下角提供“全屏预览”:只把游戏画面那一格送进全屏,顶部页签、右侧对话和底部信息栏不跟着放大;再次点击该入口、按 `Esc` 或由宿主退出全屏都回到原布局。宿主没有 Fullscreen API 时整枚入口不渲染,不留点了没反应的按钮
- 运行视窗下方的信息栏只在**有真实内容**时存在(当前判据是**资源选中态**:在资源画布或浮层资源面板里选中一张资源后切到运行页签仍保留,信息栏渲染它的只读字段;运行画面上的“点选素材”只往对话插入引用,不改选中):没有内容时整栏不渲染,有内容时自动展开并可手动收起到只剩一行开合按钮;不显示示例字段、默认数值、未载入控件或功能说明。暂时没有数据源的区域(「数值微调」的登记表)不渲染区域标题与卡片,等编辑态登记表接进来后与内容一起出现。Agent 对话标题栏不显示头像图标,“与陶泥儿的对话”及副标题按标题栏左侧对齐,钱包和审批入口继续位于右侧。
- 数值修改立即写入当前项目的编辑态配置。
- 当前已拉起的体验预览和测试切片不热更新;必须重新拉起后才能消费新值。
@@ -1,5 +1,15 @@
# 决策记录
## 2026-09-23 运行视窗:右下角全屏预览 + 没有内容就自动收起的信息栏
- 背景:运行页右下角缺一个把游戏画面放大到整屏的入口;运行视窗下方常驻「信息展示 / 数值微调」两张卡片,没有选中资源时就是两块空白,验收现场提出「没有功能就暂时隐藏」。
- 决策一:新增 `useElementFullscreen``apps/ai-game-creator-shell/src/features/project-workspace/`),用标准**元素级** Fullscreen API 把**画面那一格**`.game-run-preview`)送进全屏——不接 Tauri 窗口级全屏,那是把整块工作台连对话栏一起放大的「全屏应用」,不是「全屏预览画面」。入口贴在画面右下角,全屏后仍在原位可点退出;按钮态只认 `fullscreenchange`Esc、宿主退出都会回落。`requestFullscreen` 不存在或被 `fullscreenEnabled === false` 关掉时整枚入口不渲染,不留点了没反应的按钮。
- 决策二:`.game-run-panels` 改成「有内容才存在」的可收起信息栏。判据只有「有没有内容」(当前 = 存在资源选中态——在资源画布或浮层资源面板里选中一张资源后切到运行页签仍保留,信息展示渲染它的只读字段;运行画面上的「点选素材」只往对话插入引用,不改选中):内容从无到有自动展开、从有到无自动收起,同一段内容里用户手动收 / 展不被别的渲染重开。收起态只剩一行「收起信息栏 / 展开信息栏」按钮,条目卡片的 `156px` 最小高度不再变成空白色块;只有一栏内容时卡片铺满整行。手动态按资源 id 在渲染期派生(不挂 effect 回写):手动收 / 展只对做出动作时的那张资源有效,换到别的资源回到默认(有内容即展开),同一张资源即使清空选中后再选回也仍记得上一次的手动状态;这样「刚有内容」的那一帧就已经是展开态,不会先画一帧收起态再展开。
- 决策三:「数值微调」暂时没有登记表(前端没有数据源),按用户口径在没有功能时先不渲染它的区域标题与卡片,对应 `label / input` 声明一并删除;登记表接进来时与内容一起回归。这一条覆盖 PRD §3.4 原先「保留两个面板标题、不得因空内容压缩」的口径,PRD 与技术方案已同步改写。
- 决策四(同日收口全屏回归):用户报「退出全屏后画布仍保持全屏比例」。根因不在全屏本身,而在运行画面的尺寸上报回灌——自适应页面把视口原样报回(内容尺寸 = 容器尺寸),宿主把它当成「内容高水位」,`resolveLocalGamePreviewFitLayout``max(容器, 内容)` 就把画布钉在全屏那一帧的尺寸上;退出后 iframe 视口不再变化,桥也不会再上报,于是永远回不去(实测 1015×660 → 全屏 1416×808 → 退出仍是 1416×808、缩放到 0.72,画面按全屏比例缩成一条带黑边的窄幅)。修法:内容尺寸与它被接受时的容器尺寸在两个轴上都相等(<1px)时不算高水位,直接按容器尺寸给画布;真比容器高的页面(内容 ≠ 视口,桥注入的原始动机)仍按原生尺寸缩放显示。回归用例 `tests/localGamePreviewFrame.test.ts``returns the fitted iframe to the container after the host viewport shrinks`(改前必红,实测 1416px vs 1015px)。
- 决策四的残余边界(明确不修):若某个**固定尺寸**页面恰好等于它被接受时的容器尺寸,且缩小容器后它上报的内容尺寸再不变,就会一直按容器取画布(页面自身溢出被裁)。评审提过「内容尺寸没变也把这条记录改认新容器」,我实现后又**实测回退**了:那条过渡期上报(内容还是放大前的旧值、视口已是缩小后的容器)会被当成固有尺寸,全屏那类问题原样复现且同样永久(iframe 回到旧尺寸后桥不再上报)。两者在宿主拿到的数据上不可区分,按 AGC 常态(桥对自适应与「固定画布但自适应文档」两类页面实测都报「内容 = 视口」)选自适应优先;页面报告新内容尺寸时立即回到 `max(容器, 内容)` 等比缩小(用例 `refits to the reported content size after the container shrinks`)。根治方向在桥 / 协议侧:尺寸消息再带一个「本页是否视口耦合」的布尔(桥内部已有逐元素耦合采样与排除耦合后的边界),拟合直接按它判定,不必用两个数字相等去猜——属桥与协议的独立变更,本 PR 不做。
- 验证:新增 `tests/runPreviewFullscreen.test.tsx`(补出 jsdom 缺失的 Fullscreen API:按钮住在画面那一格里、点击 → `requestFullscreen` → 退出全屏,以及宿主没有该 API 时不渲染);`tests/localGamePreviewFrame.test.ts` 抽出 `renderFittedFrame` 夹具并补上面两条用例;AGC 子集补「信息栏有内容自动展开 / 手动收起 / 再展开」,并把「没有内容时运行页仍渲染两张卡片」的旧断言改成整栏不渲染(`数值微调面板` 这条已随删除面消失的 label 断言同步删掉,避免恒真)。`apps/ai-game-creator-shell:check:web` 全量通过(`tsc` + 1812 项,合并上游退役提交后的口径)、编码检查与 `git diff --check` 通过;并用真实 Chromium(挂同一份组件 + 客户端真实注入的尺寸桥脚本,`fullbleed``fixed` 两种游戏页)冒烟:右下角按钮只把画面那一格送进全屏且可退出、退出后画布缩回容器尺寸、选中资源后信息栏自动展开(190px)、收起后画面变高(26px→636px)、再展开恢复。
## 2026-09-23 自绘标题栏是窗口边框:弹层从它下方开始,焦点陷阱放行它
- 背景:AGC 打开任意一个 `ThemedModal` 弹窗(发布面板、发布进度、资源预览、账本、错误报告等)后,右上角「最小化 / 最大化 / 关闭」点击没有任何反应,标题栏拖拽也不能移动窗口;关掉弹窗立刻恢复。原因是标题栏在模态之外,而 `focus-trap-react` 在 document 捕获阶段监听 `mousedown`/`touchstart`/`click`,模态外的点击被 `preventDefault()``click` 直接 `stopImmediatePropagation()` —— React 的监听在更内层,事件到不了它,所以表现是「点了没反应」而不是报错。另有 `.app-update-overlay``inset: 0` 真的把标题栏盖住了。
@@ -8425,6 +8435,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 背景:项目开发工作台的中央运行视窗尺寸小于部分生成游戏的页面布局高度时,滚动条来自 loopback iframe 内部;宿主只隐藏 overflow 会直接裁掉标题、Canvas 或控制区,不能满足完整试玩。
- 决策:客户端本地 preview server 为 UTF-8 HTML 注入固定同源尺寸桥;注入器按真实 HTML tokenizer 边界保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,省略结束标签时只在已证明安全的文档位置注入。桥通过根节点 `ResizeObserver`、页面 load、窗口 resize 与字体就绪重新测量;页面可见时以 `500ms` 低频兜底探测至多 `512` 个元素边界,探测截断时不采用可能低估的部分样本,并排除随 viewport 同步变化的布局自反馈。它不订阅整页 DOM 突变,并只在尺寸元组真实变化时上报文档与浏览上下文宽高。宿主只接受当前 iframe source 与当前授权 loopback origin 的固定版本消息,按实际内容和可用容器计算最大为 `1` 的等比缩放并居中显示;宿主把最近一次合法上报的 viewport 与正式内容尺寸分开保存,首次收到自身 fit 切换产生的新 viewport 测量时只推进观察值、不反向改写 fit,viewport 稳定后的真实内容增减仍可重新适配。容器 resize 期间保留当前内容尺寸和已观察 viewport,只按新的可用空间连续重算缩放,避免拖动窗口时在原生尺寸与 fit 之间闪烁;preview URL 变化时才清空两者并重新测量。陈旧 viewport、重复内容尺寸和首次宿主回灌均不更新状态。运行视窗不再提供 iframe 横纵滚动条,内容适配不改游戏文件、manifest、PreviewRegistry 或运行业务状态,非 UTF-8 HTML 保持原样。
- 验证:前端组件测试锁定容器 resize 时 iframe 不恢复原生尺寸;纯函数覆盖无需缩放、纵向超高缩放、宿主首次应用 viewport 时保持当前 fit、容器 resize 后保持当前 fit、稳定 viewport 下内容增高 / 缩短、重复内容尺寸去重、过期 viewport 与非法消息;Rust preview server 测试锁定尺寸去重、无全页 MutationObserver、低频有界探测、截断保护、固定 body 与 viewport 耦合布局不振荡、真实 HTML 上下文注入、注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text / template / plaintext / foreign content、省略结束标签、大小写结束标签、重复 `src` 和幂等注入;再以 Issue #250 附件的 `min-height: 100vh` 页面在桌面最小窗口和更高窗口人工确认完整画面、无循环缩放、拖动窗口时无原生尺寸闪切、动态内容变化后仍适配、无纵向滚动条且指针 / 键盘交互仍可用。
- 补充(2026-09-23):上面「容器 resize 期间保留内容尺寸」只对**真比容器高 / 宽**的页面成立。上报的内容尺寸恰好等于它被接受时的容器尺寸时(自适应页面把视口原样报回来)不算内容高水位——容器缩小后画布必须跟着缩回。否则运行画面被放大一次(例如「全屏预览」)就会把画布钉在那个尺寸上,退出后仍按全屏比例缩进小容器,且 iframe 视口不变 ⇒ 桥不再上报 ⇒ 永远回不去。判据与回归用例见 2026-09-23 两条条目(`resolveLocalGamePreviewFitLayout` 的回退分支与 `returns the fitted iframe to the container after the host viewport shrinks`)。
## 2026-08-23 Direct Codex 显式重生成与切片一等资源
@@ -979,7 +979,7 @@ game-project/
- 中间主视窗提供 `resource-overview / resource-editor / ui-editor / run` 四种状态。2026-08-10 起普通用户“新增资源”显示为禁用态且处理函数拒绝 create;所有现役资源从聚焦态“编辑资源”进入非破坏性派生:静态图片走 `derive + editKind='image-reference'`,SVG、视频、音频、文档/代码、Agent 回执和项目版本进入统一资源编辑壳并按能力分流。编辑面板只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled``aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执、已导入附件和已完成任务明确登记的产物派生资源,固定按 manifest 资产功能分类 `category``UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类`)加末尾独立的「项目版本」栏目分区;未知任务产物不再兜底为版本,未完成任务或未在 `artifacts` 中登记的任意本地音频也不冒充正式资源。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源总览卡片拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供通用工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态。2026-08-30 视觉验收修正:资源总览所有栏目初次适配与复位最多以 `1.5` 倍缩放卡片,避免单个低尺寸卡片被插值放大成糊图;用户主动缩放仍沿用通用画布倍率,并按“排序模式 + 栏目”保留当前会话内的平移和缩放。美术资源聚焦态改为视口级大预览,保留原始资源读取与元数据,不生成第二份缩略图,图片 / 视频预览按弹窗可用高度展示并允许正文滚动。该资源总览边界不限制后续素材创作无限画布内的图片图层移动/缩放、生成和正式回写。
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并保留素材信息和数值微调面板;两个面板保持原有 `156px` 最小高度,没有真实数据时只让正文为空,不渲染预设字段、默认数值、未载入控件或自然语言功能占位,也不随空内容收缩`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `</body>` / `</html>`。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;宿主单独记录最近一次合法上报的 iframe viewport,首次收到由自身 fit 切换产生的新 viewport 测量时只确认该 viewport、不反向改写内容尺寸,待 viewport 稳定后仍接受真实内容宽高变化,从而阻断 `100vh` / 百分比布局在两个适配尺寸之间回灌振荡。重复内容尺寸不更新 React 状态,陈旧 viewport 消息继续忽略。容器 resize 期间保留内容尺寸与已观察 viewport,只按新容器尺寸连续重算缩放,避免拖动窗口时在原生尺寸和 fit 之间闪烁;preview URL 变化时才清空状态并重新测量。放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,画面右下角提供“全屏预览”(元素级 Fullscreen API,只把画面那一格送进全屏;宿主没有该 API 时整枚入口不渲染)。其下的信息栏只在有真实内容(判据是资源画布或浮层资源面板的资源选中态,切到运行页签后仍保留;信息栏渲染该资源的只读信息)时存在:没有内容时整栏不渲染,有内容时自动展开并可手动收起到只剩一行开合按钮;「数值微调」的登记表尚未接入,因此暂时不渲染它的区域标题与卡片,不渲染预设字段、默认数值、未载入控件或自然语言功能占位。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;参数调整首版仍只保留本地 UI 草稿,不修改代码或 manifest。preview server 对 UTF-8 HTML 响应注入固定同源尺寸桥脚本;注入点通过真实 HTML tokenizer 边界定位,保守处理注释异常结束、DOCTYPE 引号、script escaped / double-escaped、raw-text、template、plaintext、foreign content 与重复 `src`,并支持省略 `</body>` / `</html>`。桥以 `ResizeObserver` 观察 `documentElement / body` 根布局,结合页面 load、窗口 resize 与字体就绪重新测量;页面可见时另以 `500ms` 低频兜底探测至多 `512` 个元素的实际边界,探测截断时保留 body / scroll 上界,并按连续测量排除随 viewport 同步变化的 `100vh / 100% / bottom / right` 自反馈。相同尺寸元组去重后才以固定版本 `postMessage` 上报,不订阅整页 `MutationObserver`。宿主同时校验消息 origin 和 `event.source`,以实际内容宽高与当前容器宽高计算不超过 `1` 的等比缩放;宿主单独记录最近一次合法上报的 iframe viewport,首次收到由自身 fit 切换产生的新 viewport 测量时只确认该 viewport、不反向改写内容尺寸,待 viewport 稳定后仍接受真实内容宽高变化,从而阻断 `100vh` / 百分比布局在两个适配尺寸之间回灌振荡。重复内容尺寸不更新 React 状态,陈旧 viewport 消息继续忽略。容器 resize 期间保留内容尺寸与已观察 viewport,只按新容器尺寸连续重算缩放,避免拖动窗口时在原生尺寸和 fit 之间闪烁;但上报的内容尺寸恰好等于它被接受时的容器尺寸时(自适应页面把视口原样报回来)不算内容高水位——容器缩小后画布必须跟着缩回,否则全屏预览退出后画布会被钉在全屏那一帧的尺寸上。preview URL 变化时才清空状态并重新测量。放得下时保持 `1:1`,超出时完整缩小并居中,iframe 禁止横纵滚动条,不能以 `overflow: hidden` 直接裁掉超出内容。非 UTF-8 HTML 原样返回,不因适配桥破坏已有预览。
- 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。
- 底部状态栏默认展示策划、美术、程序 3 组,并允许在同一栏展开数值、音频、发布组;状态来自 manifest 与当前 Supervisor run 的 Runtime,悬停显示当前任务与进度。累计泥点必须等待后端计费归因投影;Agent.md 编辑和自定义 Skill 在来源审核、版本、权限、sandbox 与回滚合同完备前不向普通用户开放。
- 当前 run 专业状态与项目历史成果分离:状态继续严格匹配当前 `parentRunId`;已有文本成果从专业 Agent 持久对话中合法的 `agent-finalization-<32 lower hex>` assistant 恢复,并以“历史成果”来源投影到资源管理文档区。新 run 失败、待确认、候选为空或持久对话瞬时读取失败不得清除已恢复的旧成功回执,普通失败 assistant 也不得被当作成果。
+5 -4
View File
@@ -23116,15 +23116,15 @@
"@genarrative/agc-plugin-sdk": "0.1.0"
}
},
"plugins/agc-unity-editor": {
"name": "@genarrative/agc-plugin-unity-editor",
"plugins/agc-godot-editor": {
"name": "@genarrative/agc-plugin-godot-editor",
"version": "0.1.0",
"dependencies": {
"@genarrative/agc-plugin-sdk": "0.1.0"
}
},
"plugins/agc-godot-editor": {
"name": "@genarrative/agc-plugin-godot-editor",
"plugins/agc-unity-editor": {
"name": "@genarrative/agc-plugin-unity-editor",
"version": "0.1.0",
"dependencies": {
"@genarrative/agc-plugin-sdk": "0.1.0"
@@ -26558,6 +26558,7 @@
"@types/three": "^0.184.1",
"@vitejs/plugin-react": "^5.0.4",
"focus-trap-react": "^12.0.3",
"jszip": "^3.10.1",
"lexical": "^0.47.0",
"lucide-react": "^0.546.0",
"phaser": "^4.2.1",
@@ -1,14 +1,26 @@
import type { ReactNode } from 'react';
export type PlatformSegmentedTabsColumns =
'one' | 'two' | 'three' | 'four' | 'threeToSix';
| 'one'
| 'two'
| 'three'
| 'four'
| 'threeToSix';
export type PlatformSegmentedTabsGap = 'sm' | 'md';
export type PlatformSegmentedTabsRadius = 'md' | 'lg' | 'xl';
export type PlatformSegmentedTabsSize =
'sm' | 'md' | 'compact' | 'choice' | 'tab';
| 'sm'
| 'md'
| 'compact'
| 'choice'
| 'tab';
export type PlatformSegmentedTabsSurface = 'default' | 'soft' | 'transparent';
export type PlatformSegmentedTabsTone =
'neutral' | 'warm' | 'rose' | 'accent' | 'underline';
| 'neutral'
| 'warm'
| 'rose'
| 'accent'
| 'underline';
export type PlatformSegmentedTabsFrame = 'panel' | 'bare';
export type PlatformSegmentedTabsSemantics = 'segment' | 'tabs';
export type PlatformSegmentedTabsLayout = 'grid' | 'scroll';
@@ -31,7 +31,9 @@ export type GameDistributionDeviceSupport = {
export type GameDistributionInputMode = 'keyboard' | 'mouse' | 'touch';
export type GameDistributionOrientation =
'landscape' | 'portrait' | 'responsive';
| 'landscape'
| 'portrait'
| 'responsive';
export type GameDistributionAuthor = {
id: string;
@@ -52,7 +54,9 @@ export type GameDistributionVersionStatus =
| 'revoked';
export type GameDistributionGameVisibility =
'unpublished' | 'published' | 'suspended';
| 'unpublished'
| 'published'
| 'suspended';
export type GameDistributionVersionSummary = {
id: string;