Merge branch 'master' into refactor/extract-dep-from-ref-inputer
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m17s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m51s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled

This commit is contained in:
2026-09-22 17:57:51 +08:00
23 changed files with 942 additions and 161 deletions
@@ -1941,6 +1941,7 @@ for (const snippet of [
'官方账号服务(固定)',
'runtime_config.save',
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
'已载入客户端运行视图',
'async function executeRunLocal',
'function needsInitializedChatProject',
"'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'",
+25 -39
View File
@@ -183,6 +183,7 @@ type AppProps = {
metadata?: ProjectManifestSnapshotMetadata,
) => void;
onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void;
onRunNotice?: ProjectChatComponentProps['onRunNotice'];
onAgentRuntimeSummariesChange?: (
summaries: ProjectAgentRuntimeSummary[],
) => void;
@@ -214,6 +215,7 @@ export function App({
onPlayRequestHandled,
onManifestChange,
onPreviewChange,
onRunNotice,
onAgentRuntimeSummariesChange,
onAgentResultsChange,
}: AppProps = {}) {
@@ -810,9 +812,7 @@ export function App({
const agentRuntimeResumeProjectPathRef = useRef<string | null>(null);
const initialProjectOpenedRef = useRef(false);
const pendingUiConfirmationActionRef = useRef<(() => void) | null>(null);
const executeRunLocalRef = useRef<(announceToChat: boolean) => void>(
() => undefined,
);
const executeRunLocalRef = useRef<() => void>(() => undefined);
const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false);
function requestRuntimeConfigOpen() {
@@ -1386,7 +1386,7 @@ export function App({
}
handledPlayRequestRef.current = requestKey;
onPlayRequestHandled?.(playRequest.requestId);
void executeRunLocalRef.current(true);
void executeRunLocalRef.current();
}, [localProject?.projectPath, onPlayRequestHandled, playRequest]);
/**
@@ -1718,20 +1718,6 @@ export function App({
}
}
/**
* 工作台壳要把一句结果说给用户在项目对话里听。
*
* DirectProject 的会话由聊天容器持有,壳只把这句话交给聊天的本地消息流;
* 立项策划路径仍写壳自己的 `messages`。
*/
function announceProjectChatMessage(text: string) {
if (directProjectMode) {
directProjectChatRef.current?.announce(text);
return;
}
setMessages((current) => [...current, { role: 'assistant', text }]);
}
async function executeChatAgentReply({
prompt,
clientTurnId: directConversationTurnId,
@@ -1837,19 +1823,18 @@ export function App({
}
}
async function executeRunLocal(announceToChat: boolean) {
async function executeRunLocal() {
const invoke = resolveTauriInvoke();
if (!invoke) {
if (announceToChat) {
announceProjectChatMessage('需要在 Tauri App 内运行。');
}
onRunNotice?.({ tone: 'error', message: '需要在 Tauri App 内运行。' });
return;
}
const nextProjectPath = resolveChatProjectPath(localProject);
if (!nextProjectPath) {
if (announceToChat) {
announceProjectChatMessage('请先用 /project 设置本地项目。');
}
onRunNotice?.({
tone: 'error',
message: '请先用 /project 设置本地项目。',
});
return;
}
@@ -1860,11 +1845,9 @@ export function App({
);
if (activePreview) {
updateClientPreview(activePreview);
if (announceToChat) {
announceProjectChatMessage(
`已切换到客户端运行视图:${activePreview.url}`,
);
}
// 运行成功的反馈走工作台壳的 toast(对话区只保留对话内容),因此不再往聊天里
// 写一条「已切换到客户端运行视图:URL」。
onRunNotice?.({ message: '已载入客户端运行视图' });
return;
}
const previewResult = await invoke<LocalPreviewResult>(
@@ -1876,16 +1859,19 @@ export function App({
if (!directProjectMode) {
void refreshAgentRunTrace(nextProjectPath);
}
if (announceToChat) {
announceProjectChatMessage(
`运行通过,已载入客户端运行视图:${previewResult.url}`,
);
}
/*
* 成功与失败都走工作台壳的 toast,对话区不再承载这条过程反馈,所以这两处不受
* `announceToChat` 约束(它是旧的「聊天播报」开关)。当前唯一调用点由播放请求驱动、
* 恒为 true(见 `executeRunLocalRef.current(true)`)。
*/
onRunNotice?.({ message: '运行通过,已载入客户端运行视图' });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (announceToChat) {
announceProjectChatMessage(message);
}
onRunNotice?.({
tone: 'error',
message: `运行游戏失败:${
error instanceof Error ? error.message : String(error)
}`,
});
}
}
@@ -0,0 +1,70 @@
import { PlatformRuntimeStatusToast } from '@genarrative/shared/components';
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import type { ProjectRunNotice } from './model';
/**
* 成功提示一闪而过就够;失败提示要留得够久,用户得看清是什么没跑起来。
*
* 「运行 / 预览失败」这类错误已经不再写对话区(那里只保留对话内容),所以这枚 toast 是它
* 唯一的出口——2.6 秒对错误太短。
*/
const RUN_NOTICE_MILLIS: Record<'success' | 'error', number> = {
success: 2600,
error: 6000,
};
export type RunNotice = ProjectRunNotice & {
/** 每次提示自增,保证重复触发同一个文案时也会重新弹一次。 */
id: number;
};
/**
* 运行 / 预览类动作的浮层提示。
*
* 这类过程反馈以前以 assistant 消息写进对话区,会一直堆在对话底部挡住运行画面;
* 现在统一走 toast,对话区只保留对话内容——运行页的预览地址改用顶栏的「在浏览器打开」。
*/
export function RunNoticeToast({
notice,
onDismiss,
}: {
notice: RunNotice | null;
onDismiss: () => void;
}) {
useEffect(() => {
if (!notice) {
return;
}
const timer = window.setTimeout(
onDismiss,
RUN_NOTICE_MILLIS[notice.tone ?? 'success'],
);
return () => {
window.clearTimeout(timer);
};
}, [notice, onDismiss]);
if (!notice) {
return null;
}
return createPortal(
<div
key={notice.id}
className="pointer-events-none fixed bottom-8 left-1/2 z-[1100] -translate-x-1/2"
data-project-run-notice-toast="true"
>
<PlatformRuntimeStatusToast
tone={notice.tone ?? 'success'}
surface="solid"
size="sm"
shape="pill"
className="shadow-lg"
>
{notice.message}
</PlatformRuntimeStatusToast>
</div>,
document.body,
);
}
@@ -42,8 +42,9 @@ import { projectPathsMatchForInvalidation } from '../project-summary/projectPath
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
import { useTemplateLibrary } from '../template-library/useTemplateLibrary';
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
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';
import {
DESIGN_ARTIFACTS_BUILD_PROMPT,
@@ -79,6 +80,13 @@ export function WorkspaceLauncherShell({
title: string;
message: string;
} | null>(null);
/**
* 运行 / 预览类动作的浮层提示。
*
* 这类过程反馈不进对话区(见 `ProjectChatComponentProps.onRunNotice`),
* `id` 每次自增,保证同一句文案连续触发时也会重新弹一次。
*/
const [runNotice, setRunNotice] = useState<RunNotice | null>(null);
// 「做成游戏」切换记录按项目上下文(路径 + createdAt)定位。运行模式必须随
// currentProjectContext 同步派生,不能靠 effect 后置修正:首帧挂错 lane 会先
// 以游戏运行时挂载并消耗首轮 claim,重挂后的策划实例再也发不出首轮。
@@ -600,6 +608,10 @@ export function WorkspaceLauncherShell({
);
}, []);
const handleRunNotice = useCallback((notice: ProjectRunNotice) => {
setRunNotice((current) => ({ id: (current?.id ?? 0) + 1, ...notice }));
}, []);
function showLauncherNotice(title: string) {
setLauncherNotice({
title,
@@ -825,6 +837,7 @@ export function WorkspaceLauncherShell({
currentProjectContext.projectPath,
)
}
onNotice={handleRunNotice}
onManifestChange={syncActiveProjectManifest}
onHomeOpen={() => setLauncherView('home')}
onProjectsOpen={() => setLauncherView('projects')}
@@ -861,6 +874,7 @@ export function WorkspaceLauncherShell({
onPlayRequestHandled={handlePlayRequestHandled}
onManifestChange={syncActiveProjectManifest}
onPreviewChange={setActiveProjectPreview}
onRunNotice={handleRunNotice}
onAgentRuntimeSummariesChange={
setActiveProjectAgentRuntimeSummaries
}
@@ -904,6 +918,7 @@ export function WorkspaceLauncherShell({
) : isWindowChrome ? null : (
<AccountWalletBar controller={accountWallet} />
)}
<RunNoticeToast notice={runNotice} onDismiss={() => setRunNotice(null)} />
{launcherNotice ? (
<div
className="launcher-dialog-backdrop"
@@ -34,6 +34,17 @@ export type WorkspaceLauncherProps = {
initialView?: LauncherView;
};
/**
* 运行 / 预览类动作的一次性浮层提示。
*
* `tone` 只区分观感(成功绿 / 失败红),文案由发出方给出:运行成功、切到运行视图、
* 在浏览器打开失败都走这一条通道。
*/
export type ProjectRunNotice = {
message: string;
tone?: 'success' | 'error';
};
export type ProjectChatComponentProps = {
initialProjectPath?: string;
initialProjectManifest?: GameCreationAppManifest;
@@ -59,6 +70,13 @@ export type ProjectChatComponentProps = {
metadata?: ProjectManifestSnapshotMetadata,
) => void;
onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void;
/**
* 运行 / 预览类动作的一次性浮层提示。
*
* 「跑起来了」「已切到运行视图」「在浏览器打开失败」属于过程反馈,不进对话区——
* 对话区只保留对话内容。工作台壳收到后弹 toast,`tone` 决定成功还是失败观感。
*/
onRunNotice?: (notice: ProjectRunNotice) => void;
onAgentRuntimeSummariesChange?: (
summaries: ProjectAgentRuntimeSummary[],
) => void;
@@ -75,7 +75,14 @@ export function GameRunVersionPicker({
aria-label={`当前版本:${formatIterationVersionLabel(currentVersion)}`}
onClick={() => setOpen((current) => !current)}
>
{formatIterationVersionLabel(currentVersion)}
{/*
版本名可能很长(`初始版本 · 2026/9/19 02:10:03`)。按钮是 flex 容器,直接放文本节点
时 `text-overflow: ellipsis` 不生效(匿名 flex item 不参与父级省略),所以套一层
span 由它省略(见样式里的 `.game-run-version-trigger-label`)。
*/}
<span className="game-run-version-trigger-label">
{formatIterationVersionLabel(currentVersion)}
</span>
</button>
{open
? createPortal(
@@ -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;
}
+21 -25
View File
@@ -8468,8 +8468,8 @@ iframe.preview-frame {
display: grid;
grid-template-rows: minmax(300px, 1fr) auto;
grid-row: 2 / -1;
/* 与右上角任务锚点同格 resourceCanvasAssetGenerationTasksSidebar.css显式钉第 1
避免画布被自动列放置挤到隐式列里 */
/* 显式钉第 1 resourceCanvasAssetGenerationTasksSidebar.css避免画面被自动列放置
挤到隐式列里 */
grid-column: 1;
gap: 12px;
height: 100%;
@@ -8478,39 +8478,35 @@ iframe.preview-frame {
background: transparent;
}
/* C7 版本入口:运行模块右上角,没有版本时不渲染。 */
/*
* C7 版本入口住在工作台顶栏动作区`.game-workbench-view-actions`外观由那一组的基础
* 规则给描边 + secondary 填充 + 12px/700这里只补版本名可能很长这一件事
*
* 它曾经是运行画面里的绝对定位浮层top 20px / right 20px压在游戏画面上还与顶栏其他
* 按钮分成两套皮**不要再给它加 border / background / color / hover**那就是第二套皮
*/
.game-run-version-picker {
position: absolute;
top: 20px;
right: 20px;
z-index: 5;
flex: 0 0 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;
min-width: 0;
}
/*
* 版本名单独一层才省得掉按钮是 flex 容器文本直接挂在按钮上时 `text-overflow`
* 落在匿名 flex item 不生效 `GameRunVersionPicker` 里的注释
*/
.game-run-version-trigger-label {
min-width: 0;
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,
@@ -104,6 +106,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,
@@ -799,6 +802,13 @@ export type ProjectDevelopmentViewProps = {
onPlay?: () => void;
onMakeGame?: () => void;
onRevealProjectDirectory?: () => void | Promise<void>;
/**
* /
*
* toast
*
*/
onNotice?: (notice: ProjectRunNotice) => void;
onManifestChange?: (
projectPath: string,
manifest: GameCreationAppManifest,
@@ -1804,6 +1814,7 @@ export default function ProjectDevelopmentView({
onPlay,
onMakeGame,
onRevealProjectDirectory,
onNotice,
}: ProjectDevelopmentViewProps) {
const [mode, setMode] = useState<WorkbenchMode>('resources');
const [runtimeInspectMode, setRuntimeInspectMode] = useState(false);
@@ -2733,6 +2744,33 @@ 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) {
// 没有提示通道时(测试挂载、未来宿主)至少留下排查痕迹,不静默吞掉。
if (!onNotice) {
console.error('[agc] 在浏览器打开失败', error);
return;
}
onNotice({
tone: 'error',
message: `在浏览器打开失败:${
error instanceof Error ? error.message : String(error)
}`,
});
}
}, [embeddedPreviewUrl, onNotice]);
const runAvailable =
embeddedPreviewUrl !== null ||
manifest.tasks.some(
@@ -9669,6 +9707,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
@@ -9760,6 +9814,21 @@ export default function ProjectDevelopmentView({
</div>
</>
) : null}
{/*
C7 /
`.game-workbench-view-actions button`
**UI **
`@`
*/}
{uiEditorRoute ? null : (
<GameRunVersionPicker
versions={projectVersions}
activeVersionId={activeVersionId}
onSelectVersion={selectActiveVersion}
/>
)}
</div>
{/*
****
@@ -10933,11 +11002,6 @@ export default function ProjectDevelopmentView({
</>
) : (
<section className="game-run-surface" aria-label="运行表现层">
<GameRunVersionPicker
versions={projectVersions}
activeVersionId={activeVersionId}
onSelectVersion={selectActiveVersion}
/>
<div className="game-run-preview">
{embeddedPreviewUrl ? (
<LocalGamePreviewFrame
@@ -10975,41 +11039,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 ? (
@@ -8821,7 +8821,8 @@ export function registerProjectAgentStatusTests() {
);
expect(within(reopened).getByText('待提交设计图')).not.toBeNull();
// 入口在「运行页签下同样常驻:侧栏本体在运行态可见,入口若只在资源页签就没法再打开
// 运行页签下**不挂**「生成任务」:那一页要留给游戏画面(顶栏是版本入口与「在浏览器打开」)
// 任务不会因此丢——切回资源页签,入口与面板都回来。
fireEvent.click(
within(reopened).getByRole('button', { name: '关闭生成任务' }),
);
@@ -8831,8 +8832,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,74 @@
// @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, 'min-width')).toBe('0');
// 省略号要真的生效:按钮是 flex 容器,文本必须挂在自带 overflow 的 span 上。
const label = resolved(['.game-run-version-trigger-label']);
expect(declaration(label, 'overflow')).toBe('hidden');
expect(declaration(label, 'text-overflow')).toBe('ellipsis');
expect(declaration(label, 'white-space')).toBe('nowrap');
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);
});
});
@@ -12,6 +12,10 @@
* 只核对内存 registry 并回传 loopback 地址,活着就复用(不重启预览服务),没活着
* stopped / 不属于这个项目 / 权限位要求确认)才回落到 `start_local_game_preview`。
*
* 成功反馈走工作台壳的 toast`onRunNotice`),**不再**写进对话区:那条「已载入客户端
* 运行视图:URL」以前常驻对话底部挡住运行画面。用例因此直接断言这条通道,并反过来钉住
* 对话容器里没有这类提示。
*
* 这条行为过去只剩 `scripts/check-native-shells.mjs` 的源码守卫与 Rust 实现,前端没有
* 用例;一旦有人再把它当死链路删掉,守卫会先报「missing await invoke」而不知道现场。
*/
@@ -41,7 +45,10 @@ function createFixtureManifest(): GameCreationAppManifest {
* `activate_local_game_preview` 的替身由用例给定:它决定「这条预览还活着吗」,
* 其余命令沿用聊天 harness,运行入口之外的链路保持真实形状。
*/
function installTauri(activateLocalGamePreview: () => unknown) {
function installTauri(
activateLocalGamePreview: () => unknown,
options: { startFails?: string } = {},
) {
const manifest = createFixtureManifest();
const chatHarness = createProjectChatRuntimeHarness({
projectPath: PROJECT_PATH,
@@ -60,6 +67,9 @@ function installTauri(activateLocalGamePreview: () => unknown) {
return activateLocalGamePreview();
}
if (command === 'start_local_game_preview') {
if (options.startFails) {
throw new Error(options.startFails);
}
return { url: PREVIEW_URL, port: 43210, root: PROJECT_PATH };
}
return chatHarness.invoke(command, args);
@@ -74,20 +84,28 @@ function installTauri(activateLocalGamePreview: () => unknown) {
/** 「运行」入口由工作台壳的播放请求驱动,这里直接用同一形状的请求触发。 */
function renderRunningProjectChat() {
return render(
const onRunNotice = vi.fn();
render(
<App
initialProjectPath={PROJECT_PATH}
initialProjectManifest={createFixtureManifest()}
onPlayRequestHandled={vi.fn()}
onRunNotice={onRunNotice}
playRequest={{ projectPath: PROJECT_PATH, requestId: 1 }}
/>,
);
return { onRunNotice };
}
/** 运行结果由聊天自己的本地消息流展示,这里按对话容器文本断言。 */
async function expectChatAnnouncement(text: string) {
/** 运行反馈走 toast;对话容器里不该再出现这类过程提示。 */
async function expectRunNoticeWithoutChatTip(
onRunNotice: ReturnType<typeof vi.fn>,
message: string,
) {
await waitFor(() => expect(onRunNotice).toHaveBeenCalledWith({ message }));
const surface = await screen.findByLabelText('陶泥儿项目对话');
await waitFor(() => expect(surface.textContent ?? '').toContain(text));
expect(surface.textContent ?? '').not.toContain('运行通过');
expect(surface.textContent ?? '').not.toContain('已切换到客户端运行视图');
}
beforeEach(() => {
@@ -103,14 +121,14 @@ describe('运行入口切到已经在跑的客户端预览', () => {
root: PROJECT_PATH,
}));
renderRunningProjectChat();
const { onRunNotice } = renderRunningProjectChat();
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('activate_local_game_preview', {
projectPath: PROJECT_PATH,
}),
);
await expectChatAnnouncement(`已切换到客户端运行视图:${PREVIEW_URL}`);
await expectRunNoticeWithoutChatTip(onRunNotice, '已载入客户端运行视图');
expect(invokeCounts.get('start_local_game_preview') ?? 0).toBe(0);
});
@@ -122,15 +140,16 @@ describe('运行入口切到已经在跑的客户端预览', () => {
root: null,
}));
renderRunningProjectChat();
const { onRunNotice } = renderRunningProjectChat();
await waitFor(() =>
expect(invokeCounts.get('start_local_game_preview') ?? 0).toBeGreaterThan(
0,
),
);
await expectChatAnnouncement(
`运行通过,已载入客户端运行视图:${PREVIEW_URL}`,
await expectRunNoticeWithoutChatTip(
onRunNotice,
'运行通过,已载入客户端运行视图',
);
});
@@ -139,15 +158,34 @@ describe('运行入口切到已经在跑的客户端预览', () => {
throw new Error('需要先确认预览权限');
});
renderRunningProjectChat();
const { onRunNotice } = renderRunningProjectChat();
await waitFor(() =>
expect(invokeCounts.get('start_local_game_preview') ?? 0).toBeGreaterThan(
0,
),
);
await expectChatAnnouncement(
`运行通过,已载入客户端运行视图:${PREVIEW_URL}`,
await expectRunNoticeWithoutChatTip(
onRunNotice,
'运行通过,已载入客户端运行视图',
);
});
it('启动预览失败时走失败色提示,不再写进对话区', async () => {
installTauri(
() => ({ status: 'stopped', url: null, port: null, root: null }),
{ startFails: '预览端口被占用' },
);
const { onRunNotice } = renderRunningProjectChat();
await waitFor(() =>
expect(onRunNotice).toHaveBeenCalledWith({
tone: 'error',
message: '运行游戏失败:预览端口被占用',
}),
);
const surface = await screen.findByLabelText('陶泥儿项目对话');
expect(surface.textContent ?? '').not.toContain('预览端口被占用');
});
});
@@ -356,23 +356,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'),
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
composerValue,
createGameCreationAppManifest,
findResourceSelectButton,
fireEvent,
@@ -368,6 +369,17 @@ function panelPromptText(panel: HTMLElement) {
return within(panel).getByLabelText('快速编辑提示词').textContent ?? '';
}
/**
* 「重开面板后提示词应当回填」不能同步读。
*
* 面板(dialog)先出现,Lexical 的编辑器值随后才落;同步读 `textContent` 在并发跑全量时
* 会偶发读到空串——CI run 2702 与本地一次全量各命中过一次(同一条用例、`expected '' to
* include '把角色头发改成红色'`)。这里等值真的落位,不再赌时序。
*/
async function expectPanelPromptContains(panel: HTMLElement, expected: string) {
await waitFor(() => expect(panelPromptText(panel)).toContain(expected));
}
/** 关掉画布浮层:Esc 与点外部同一条既有链路(`clearResourceCanvasFocus`)。 */
async function dismissPanel() {
fireEvent.keyDown(document, { key: 'Escape' });
@@ -408,7 +420,7 @@ describe('快速编辑草稿的保留与恢复', () => {
await dismissPanel();
const reopened = await openQuickEditPanel('source-art.png');
expect(panelPromptText(reopened)).toContain('把夜色改成星空');
await expectPanelPromptContains(reopened, '把夜色改成星空');
expect(
document.querySelector('[data-resource-reference-id="source-rules"]'),
).not.toBeNull();
@@ -438,13 +450,15 @@ describe('快速编辑草稿的保留与恢复', () => {
);
await dismissPanel();
expect(
panelPromptText(await openQuickEditPanel('source-art.png')),
).toContain('第一张的草稿');
await expectPanelPromptContains(
await openQuickEditPanel('source-art.png'),
'第一张的草稿',
);
await dismissPanel();
expect(
panelPromptText(await openQuickEditPanel('source-art-2.png')),
).toContain('第二张的草稿');
await expectPanelPromptContains(
await openQuickEditPanel('source-art-2.png'),
'第二张的草稿',
);
});
it('恢复入口按草稿计数,「继续编辑」重开面板并回填,丢弃后入口消失', async () => {
@@ -486,7 +500,7 @@ describe('快速编辑草稿的保留与恢复', () => {
const resumed = await screen.findByRole('dialog', {
name: '快速编辑图片',
});
expect(panelPromptText(resumed)).toContain('把角色头发改成红色');
await expectPanelPromptContains(resumed, '把角色头发改成红色');
await dismissPanel();
fireEvent.click(
@@ -539,7 +553,7 @@ describe('快速编辑草稿的保留与恢复', () => {
).not.toBeNull();
const resumed = await openQuickEditPanel('source-art.png');
expect(panelPromptText(resumed)).toContain('把夜色改成星空');
await expectPanelPromptContains(resumed, '把夜色改成星空');
});
it('正规化换掉卡片投影后,草稿跟着资源走、重开在正式素材上继续', async () => {
@@ -605,7 +619,7 @@ describe('快速编辑草稿的保留与恢复', () => {
// 旧卡片已经不在画布上:草稿按**路径**归属,新投影的正式素材卡照样命中,否则这一笔
// 就再也点不到。
const reopened = await openQuickEditPanel('task-art.png');
expect(panelPromptText(reopened)).toContain('把夜色改成星空');
await expectPanelPromptContains(reopened, '把夜色改成星空');
});
/**
@@ -664,7 +678,7 @@ describe('快速编辑草稿的保留与恢复', () => {
);
const reopened = await openQuickEditPanel('task-art.png');
expect(panelPromptText(reopened)).toContain('把夜色改成星空');
await expectPanelPromptContains(reopened, '把夜色改成星空');
});
it('原生恢复队列与本地草稿共用同一枚入口,两边都能继续', async () => {
@@ -732,7 +746,11 @@ describe('快速编辑草稿的保留与恢复', () => {
);
const reopened = await openQuickEditPanel('source-art.png');
expect(panelPromptText(reopened)).not.toContain('把夜色改成星空');
// 用 `composerValue` 读(它内部先让 Lexical 落值):同步读会在值未落时拿到空串,
// 让这条否定断言空过——那正是上面那条回填断言的同一类竞态。
expect(
await composerValue(within(reopened).getByLabelText('快速编辑提示词')),
).not.toContain('把夜色改成星空');
});
});
@@ -815,7 +833,7 @@ describe('提交中的快速编辑进「生成任务」侧栏', () => {
// 回到第一张:草稿照旧回填,但这一笔已经在跑——再点提交必须被拦下。
const reopened = await openQuickEditPanel('source-art.png');
expect(panelPromptText(reopened)).toContain('把嘴改小一点');
await expectPanelPromptContains(reopened, '把嘴改小一点');
fireEvent.click(within(reopened).getByRole('button', { name: '修改' }));
expect(
await within(reopened).findByText(
@@ -0,0 +1,158 @@
/** @vitest-environment jsdom */
/**
* 运行提示的外壳级接线:`ProjectChat` 发一条提示,工作台壳真的把它渲染成浮层。
*
* 单测(`runNoticeToast`)只证明组件本身,`previewActivation` 只证明 App 会调这条通道;
* 从「通道被调用」到「用户看到 toast」之间还差一层壳的接线(prop 名、handler、portal 挂点、
* tone 传递)。这里用替身聊天把这一层单独钉住:以后有人把 `<RunNoticeToast/>` 挪进条件
* 分支、或在传 prop 时写错名字,这条会红。
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import type { ProjectChatComponentProps } from '../src/features/app-shell/model';
import { WorkspaceLauncherShell } from '../src/features/app-shell/WorkspaceLauncher';
import {
createProjectChatRuntimeHarness,
pickProjectFromLauncher,
testAuthUser,
} from './appSurface/harness';
const PROJECT_PATH = '/tmp/run-notice-shell-project';
function StubRunNoticeChat({ onRunNotice }: ProjectChatComponentProps) {
return (
<div>
<button
type="button"
onClick={() =>
onRunNotice?.({ message: '运行通过,已载入客户端运行视图' })
}
>
</button>
<button
type="button"
onClick={() =>
onRunNotice?.({
tone: 'error',
message: '运行游戏失败:预览端口被占用',
})
}
>
</button>
</div>
);
}
/**
* 打开工作台所需的最小命令集照抄 `home.suite` 的活动清单用例:项目目录探测 + 清单 +
* 资源图 / 布局读回,其余命令沿用聊天 harness。
*/
function renderWorkbench() {
const manifest = createGameCreationAppManifest(
'run-notice-shell-project',
'运行提示接线项目',
);
const runtimeHarness = createProjectChatRuntimeHarness({
projectPath: PROJECT_PATH,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_design_agent_runtime_mode') return null;
if (command === 'inspect_local_project_directory') {
return {
projectPath: PROJECT_PATH,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: manifest.name,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'read_local_project_resource_graph') {
return {
resourceIds: [],
referenceEdges: [],
taskFlows: [],
connectionIndex: [],
producerAssignments: [],
dependencyDepths: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
return runtimeHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke: invoke as never },
event: { listen: runtimeHarness.listen as never },
};
render(
<WorkspaceLauncherShell
currentUser={testAuthUser}
initialView="projects"
onLogout={vi.fn()}
ProjectChat={StubRunNoticeChat}
/>,
);
pickProjectFromLauncher(PROJECT_PATH);
}
function toastElement() {
return document.querySelector('[data-project-run-notice-toast="true"]');
}
describe('运行提示的外壳接线', () => {
it('成功提示渲染成浮层,且不落在对话容器里', async () => {
renderWorkbench();
await screen.findByLabelText('项目开发工作台');
fireEvent.click(
await screen.findByRole('button', { name: '触发成功提示' }),
);
await waitFor(() => expect(toastElement()).not.toBeNull());
expect(toastElement()?.textContent ?? '').toContain(
'运行通过,已载入客户端运行视图',
);
// 「对话区里没有这条提示」由 `previewActivation` 用真实聊天容器断言;这里用的替身
// 聊天没有消息列表,重复断言只会自证。
});
it('失败提示按 alert 渲染', async () => {
renderWorkbench();
await screen.findByLabelText('项目开发工作台');
fireEvent.click(
await screen.findByRole('button', { name: '触发失败提示' }),
);
await waitFor(() =>
expect(toastElement()?.querySelector('[role="alert"]')).not.toBeNull(),
);
expect(toastElement()?.textContent ?? '').toContain(
'运行游戏失败:预览端口被占用',
);
});
});
@@ -0,0 +1,120 @@
/** @vitest-environment jsdom */
import { act, cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RunNoticeToast } from '../src/features/app-shell/RunNoticeToast';
const RUN_MESSAGE = '运行通过,已载入客户端运行视图';
function toastElement() {
return document.querySelector('[data-project-run-notice-toast="true"]');
}
describe('运行 / 预览浮层提示', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it('没有提示时不渲染任何浮层', () => {
render(<RunNoticeToast notice={null} onDismiss={vi.fn()} />);
expect(toastElement()).toBeNull();
expect(screen.queryByText(RUN_MESSAGE)).toBeNull();
});
it('在浮层里显示提示,并在到点后自动收起', () => {
vi.useFakeTimers();
const onDismiss = vi.fn();
render(
<RunNoticeToast
notice={{ id: 1, message: RUN_MESSAGE }}
onDismiss={onDismiss}
/>,
);
expect(toastElement()).not.toBeNull();
expect(screen.getByText(RUN_MESSAGE)).not.toBeNull();
act(() => {
vi.advanceTimersByTime(2_600);
});
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it('同一句文案连续触发时重新计时', () => {
vi.useFakeTimers();
const onDismiss = vi.fn();
const { rerender } = render(
<RunNoticeToast
notice={{ id: 1, message: RUN_MESSAGE }}
onDismiss={onDismiss}
/>,
);
rerender(
<RunNoticeToast
notice={{ id: 2, message: RUN_MESSAGE }}
onDismiss={onDismiss}
/>,
);
act(() => {
vi.advanceTimersByTime(2_000);
});
expect(onDismiss).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(600);
});
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();
});
it('失败提示比成功提示留得久:2.6 秒不该把错误收走', () => {
vi.useFakeTimers();
const onDismiss = vi.fn();
render(
<RunNoticeToast
notice={{
id: 1,
tone: 'error',
message: '运行游戏失败:预览端口被占用',
}}
onDismiss={onDismiss}
/>,
);
act(() => {
vi.advanceTimersByTime(2_600);
});
expect(onDismiss).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(3_400);
});
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,119 @@
/** @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: '在浏览器打开',
});
// 入口住在工作台顶栏的动作区里,与版本入口同一排;预览地址本身不再以文字出现。
expect(entry.closest('.game-workbench-view-actions')).not.toBeNull();
expect(document.body.textContent ?? '').not.toContain(PREVIEW_URL);
expect(document.querySelector('.game-run-status-hint')).toBeNull();
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',
}),
);
});
});
@@ -9380,6 +9380,17 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 影响面:`apps/ai-game-creator-shell/src/features/{project-workspace/resourceReferences.ts,project-workspace/ResourceReferenceInput.tsx,resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx,resource-canvas/resourceCanvasAssetGenerationTaskModel.ts,resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts}``apps/ai-game-creator-shell/src/view/project-development/index.tsx` 与对应 6 个定向测试文件。
- 验证:定向 `resourceCanvasAssetGenerationReferences` / `resourceCanvasAssetGenerationBackgroundClose` / `resourceCanvasBottomToolbar` / `resourceCanvasGenerationFloatingPanel(Chrome)` / `resourceReferenceInput` / `resourceReferences` / `resourceCanvasAssetGenerationTasksPanel` 全绿;全量 `npm run test -- apps/ai-game-creator-shell/tests` 只剩 `clientHttp` / `clientApi` / `clientAuthStorage` / `projectCreationDirectory` / `recentProjectsHook` 五个 jsdom `localStorage` 环境用例红(与本次改动无调用关系);TS typecheck、`check:encoding``git diff --check` 通过。未复核真实客户端观感。
## 2026-09-22 运行页收口:过程提示退出对话区,顶栏统一承载运行入口
- 背景:点播放(以及历史上 `/preview``/open-preview`、生成后自动启动预览)都会往对话区写一条 assistant 提示(`运行通过,已载入客户端运行视图:http://127.0.0.1:63155/` 这类)。它常驻对话底部遮挡运行画面,也让对话区混进非对话内容;运行页本身还有三处遮挡与两套皮:预览地址是一行常驻小字(不可点)、版本入口是绝对定位压在画面右上角的浮层、右上角还叠着「生成任务」开关。
- 决策(对话区只留对话内容):运行 / 预览的反馈不再进对话区,**成功与失败都**走工作台壳的 toast——`ProjectChatComponentProps.onRunNotice``RunNoticeToast`(同一句连续触发重新计时;成功 2.6 秒收起,失败 6 秒收起,`tone` 决定观感)。失败三处:启动预览报错(`运行游戏失败:…`)、缺 Tauri / 缺项目这两条前置条件、以及「在浏览器打开失败」。原先承载这些文案的 `announceProjectChatMessage` 已无调用方,连同删除。
- 与上一条的关系(2026-09-21「预览激活回接运行入口」):那条接线决策(先问 Rust 要活体预览、命中就只切视图不重启)原样保留;被本次改掉的只是它当时为这条链路选的**播报渠道**——`经 DirectProjectChatHandle.announce 说一句「已切换到客户端运行视图:<url>」` 按验收反馈(提示常驻对话底部遮挡运行画面)改成 toast,「复用活体预览不重启」的判据与 `tests/previewActivation.test.tsx` 的三条路径不变。
- 决策(运行页顶栏是唯一入口):运行区域上方的状态行与预览地址小字整体退役(运行画面回到两行栅格);预览地址改成顶栏动作区里的一枚「在浏览器打开」按钮(opener 插件的 `openUrl`,只在有活预览时渲染;`scripts/check-native-shells.mjs` 另加「视图里 `openUrl(` 只有一个调用点」的判据);版本入口搬进同一个顶栏动作区,外观复用 `.game-workbench-view-actions button` 的基础规则,不再自带边框 / 底色 / hover,也不再是绝对定位浮层。版本入口在**资源画布与运行页**显示,UI 编辑器壳里不渲染(那一页是聚焦编辑某个资源的界面)。版本名改用一层 span 承载省略号——按钮是 flex 容器,文本直接挂在按钮上时 `text-overflow` 不生效。
- 决策(运行页不挂生成任务):`ResourceCanvasAssetGenerationTasksPanelView` 只在资源画布 / UI 编辑器出现,运行页的入口、面板与锚点都不渲染;`[data-generation-tasks-placement='run']` 那一档坐标与组件 `placement` 联合类型里的 `run` 一并删除。任务不丢,切回资源页即可见。
- 边界:预览的**启动与切换**仍然只走内置运行画面、不自动开系统浏览器——`scripts/check-native-shells.mjs` 那条负向守卫保持原样,本轮只补「浏览器入口只有顶栏这一枚按钮」的正向断言。
- 影响面:`apps/ai-game-creator-shell/src/{App.tsx,styles.css,view/project-development/index.tsx,features/app-shell/*,features/resource-canvas/*}`;用例新增 `tests/runPreviewBrowserOpen.test.tsx``tests/runNoticeToast.test.tsx``tests/runNoticeShellWiring.test.tsx`(外壳级:替身聊天发提示 → 壳真的渲染浮层)、`tests/gameRunToolbarActionsStyle.test.ts`,改写 `tests/previewActivation.test.tsx`(原断言「聊天里出现已载入运行视图」的地方改为断言 toast 通道 + 对话容器里没有这类提示,并补一条「启动失败走失败色提示且不写对话区」)与 `tests/appSurface/project-development.suite.ts` 的生成任务入口用例。
- 验证:`appSurface` 全量、AGC 壳目录全量、`npm run typecheck``check:native-shells:contract``check:encoding``git diff --check` 全绿;真机观感未复验。
## 2026-09-22 DirectProject 聊天状态显式化:回合三态 + 「在跑吗」唯一派生入口 + 数据流地图
- 背景:DirectProject 聊天框只有三份真相源(`project.jsonl` 历史切片、Thread Manager 运行态事件、本地乐观消息),但「这一轮在跑吗」在四层里各叫一个名字——reducer 的 `turnRunning`、controller 的 `turnBusy`、视图里手拼的 `busy`、投影里的 `active`。定位发送后空窗缺陷时,读代码无法判断某个窗口期的界面表现是否有依据,也说不清谁该信谁。
@@ -6001,6 +6001,14 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
`append_application_log_line` 在落盘前对整行做 `sanitize_diagnostic_message`:行内只要出现 `token=``bearer ``authorization``credential``api key` / `apikey` / `api_key` 这类标记,**整行**就被换成 `<sensitive diagnostic details redacted>`,只留下时间戳与 `RUST module:` 前缀;同时每行还会被截到 2048 字符。于是把“身份字段 + 诊断正文”拼成一行 `app_log!` 时,正文里一个凭据词就可能让整条记录连 `eventId``code` 一起消失(2026-09-21 加统一错误事件的日志投影时按两行落:身份行只放程序生成与调用方常量字段,summary / hint / detail 等自由文本一律只放详情行,且自由文本先自行压平换行——裸词标记脱敏消不掉,自由文本放错行会把 eventId、code 一起带走)。
## 2026-09-22 本地 node_modules 里的内置 Codex 原生包过旧会让 AGC build script panic
- **现象**`npm run agc` 编到壳 crate 的 build script 时中止,stderr 是 `panicked at build.rs:103: Codex 原生包版本、布局或架构不匹配目标 x86_64-pc-windows-msvc`stdout 只有 `cargo:rustc-env=AGC_BUILD_TARGET=...`
- **原因**`build_support/codex_bundle.rs` 把随包 Codex CLI 钉在一个固定版本,而本地 `node_modules/@openai/codex-win32-x64/vendor/<target>/codex-package.json` 还停在上一次安装的旧版本。`package-lock.json` 早就升到新版本,缺的只是本地安装;这条判据只看版本元数据,文件齐全、架构正确也照样拦。
- **处理(现行口径)**:在仓库根目录执行 `npm ci`(不要改成子目录或单包安装),随后 `node_modules/@openai/codex/package.json` 与 vendor 的 `codex-package.json` 版本应当一致。Windows 上 `npm ci` 会先 unlink 整个 `node_modules`RustRover 的 Tailwind language server / `oxide-helper` 进程会占住 `@tailwindcss/oxide-*.node`,报 `EPERM: operation not permitted, unlink ...` 时先结束这些 helper 再重试,否则会停在半装状态。
- **验证**`npm ci``cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` 通过,不再触发该 panic。
- **关联**`apps/ai-game-creator-shell/src-tauri/build.rs``apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs``package-lock.json`
## 策划聊天不能按可选子节点序号分配消息高度
策划与开发 Agent 共用聊天类名,但布局合同不同。策划新增状态条后,按第二个子节点分配 `1fr` 会把空白给状态条、让消息框随回复增长;只给 `.is-direct-codex` 的状态样式也不会覆盖策划。策划使用纵向 Flex,仅消息列表伸缩,阶段/待处理区域限高滚动;改共用样式时同时核对两种入口。jsdom 交互通过不代表布局正确,须匹配完整 CSS 层叠并用真实浏览器核对空/短/长消息与长待办。窄屏上下堆叠必须在固定外壳内提供工作台滚动容器,两块面板明确限高;低优先级的 `height: auto` 不能覆盖外壳后代规则的 `height: 100%`。布局夹具必须包含窗口外壳与启动器层级,并实际滚动验证输入框可见可操作,不能只检查输入框在聊天面板内部。实时正文/思考不更新持久消息数组,滚动跟随必须覆盖这些独立状态并保留用户上滚门禁;历史消息缺失的时间不得用读取时刻填补。详见 AGC 实施计划的“策划 Agent 对话显示与滚动合同”。
+34 -1
View File
@@ -2647,7 +2647,7 @@ function assertAiGameCreatorShellUserDevBoundary() {
}
for (const snippet of [
"await invoke<LocalPreviewStatus>(\n 'activate_local_game_preview'",
'已切换到客户端运行视图',
'已载入客户端运行视图',
]) {
if (!sourceIncludesSnippet(aiGameCreatorShellAppSource, snippet)) {
throw new Error(
@@ -2665,6 +2665,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') ||
@@ -2674,6 +2682,31 @@ 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}`,
);
}
}
/*
* 上面那条负向守卫只盯着 App.tsx 里的两条老路,而真正的浏览器出口在运行页视图里。
* 这里再补一条「调用点只有一个」的判据:视图里冒出第二个 `openUrl(`(自动跳浏览器、
* 或者拿它去开任意地址)时必须先显式改这条守卫,而不是顺手加一行。
*/
const openUrlCallCount =
aiGameCreatorProjectDevelopmentSource.split('openUrl(').length - 1;
if (openUrlCallCount !== 1) {
throw new Error(
`AI game creator must keep exactly one user-clicked browser-open call in the run view (found ${openUrlCallCount})`,
);
}
if (
aiGameCreatorShellTauriSource.includes('fn open_developer_window(') ||
aiGameCreatorShellTauriSource.includes(