From cc8c03a40d02f9e3295a0970c3f77227581e41b3 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 21 Sep 2026 19:08:43 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=BE=80=E8=81=8A=E5=A4=A9=E5=86=99"?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E9=80=9A=E8=BF=87"=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删掉 AGC 播放/运行本地游戏成功后的聊天提示:它每次播放都会堆在对话底部遮挡运行画面,成功与否由客户端运行视图本身体现,失败仍照原样回报 - appSurface 三处用例改为断言 start_local_game_preview 真被调用且聊天里不再出现该提示 - 补排障记录:本地 node_modules 内置 Codex 原生包过旧会让 AGC build script panic,需在仓库根目录 npm ci --- apps/ai-game-creator-shell/src/App.tsx | 11 ++------ .../appSurface/project-commands.suite.ts | 12 ++++---- .../appSurface/project-development.suite.ts | 28 +++++++++++-------- .../assert-project-tools-and-preview.ts | 13 +++++---- docs/project-memory/shared-memory/pitfalls.md | 8 ++++++ 5 files changed, 41 insertions(+), 31 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 4d28f041a..354a502f4 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -9531,15 +9531,8 @@ export function App({ if (!directCodexProductRuntime) { void refreshAgentRunTrace(nextProjectPath); } - if (announceToChat) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `运行通过,已载入客户端运行视图:${previewResult.url}`, - }, - ]); - } + // 运行成功的提示不再写进聊天:它会一直堆在对话底部遮挡视野,而客户端运行视图 + // 本身就是这条动作的可见反馈。失败仍按下面的分支回报,用户需要知道为什么没跑起来。 } catch (error) { const message = error instanceof Error ? error.message : String(error); setLimitedCommandStatus(message); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts index fa54a42e3..3671dbdf8 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts @@ -3025,11 +3025,13 @@ export function registerProjectCommandTests() { ).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText( - /运行通过,已载入客户端运行视图:http:\/\/127\.0\.0\.1:3210\//, - ), - ).not.toBeNull(); + // /run 成功只切换客户端运行视图,不再往聊天里追加“运行通过”提示。 + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { + projectPath: '/tmp/authorized-game', + }), + ); + expect(screen.queryByText(/运行通过/)).toBeNull(); expect(invoke).toHaveBeenCalledWith('run_limited_local_command', { projectPath: '/tmp/authorized-game', commandId: 'game.static_smoke', diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index ed2ba1494..ec74c9aa7 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -7199,13 +7199,16 @@ export function registerProjectSupervisorSurfaceTests() { }), ); - expect( - await screen.findByText( - /运行通过,已载入客户端运行视图/, - {}, - { timeout: 3_000 }, - ), - ).not.toBeNull(); + // 顶部播放请求只负责把游戏起在运行视图里;聊天里不再新增“运行通过”提示, + // 否则每次播放都会在对话底部堆一条遮挡运行画面。 + await waitFor( + () => + expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { + projectPath, + }), + { timeout: 3_000 }, + ); + expect(screen.queryByText(/运行通过/)).toBeNull(); expect(handled).toHaveBeenCalledWith(7); expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { projectPath, @@ -7251,11 +7254,12 @@ export function registerProjectSupervisorSurfaceTests() { }), ); - expect( - await screen.findByText( - '运行通过,已载入客户端运行视图:http://127.0.0.1:43126/', - ), - ).not.toBeNull(); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { + projectPath, + }), + ); + expect(screen.queryByText(/运行通过/)).toBeNull(); expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { projectPath, }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts index 22fe7f8e6..80b3fc4c7 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts @@ -822,11 +822,14 @@ export function registerProjectToolsAndPreviewTests() { fireEvent.click(screen.getByRole('button', { name: '运行' })); expect(await screen.findByText('game.run_local')).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText( - /运行通过,已载入客户端运行视图:http:\/\/127\.0\.0\.1:3210\//, - ), - ).not.toBeNull(); + // 运行成功不再写聊天提示(会一直堆在对话底部遮挡运行画面),改成断言预览真的起来了 + // 且聊天里不再出现那条提示。 + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { + projectPath: '/tmp/authorized-game', + }), + ); + expect(screen.queryByText(/运行通过/)).toBeNull(); expect(invoke).toHaveBeenCalledWith('run_limited_local_command', { projectPath: '/tmp/authorized-game', diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 0205590b1..795ca71f6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5874,3 +5874,11 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **原因**:校验器对每个 `input_text` 单独执行 `trim().is_empty()` 并立即拒绝,混淆了结构化片段合法性和整条消息是否有实际内容。 - **处理(现行口径)**:`input_text` 允许空字符串、空格和换行,校验过程保持全部片段的原文、分段与顺序,不做合并或删除;遍历完整条消息后,只在既没有非空白文字、也没有合法 `agc_resource_reference` / `agc_runtime_region_reference` 时返回“聊天内容不能为空”。两类引用仍逐个执行原有校验,消息带正文也不能绕过非法引用。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`。 + +## 2026-09-21 本地 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 钉在 `0.155.1`(随 #439 升级),而本地 `node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc/codex-package.json` 仍是 `0.147.0`。`package-lock.json` 早就是 `0.155.1`,缺的只是本地安装;这条判据只看版本元数据,文件齐全、架构正确也照样拦。 +- **处理(现行口径)**:在仓库根目录执行 `npm ci`(不要改成子目录或单包安装),随后 `node_modules/@openai/codex/package.json` 与 vendor 的 `codex-package.json` 都应为 `0.155.1`。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` 输出 `Finished dev profile ... in 1m 26s`,不再触发该 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`。 -- 2.52.0 From e6083050a855d29d461b65ff58167dde0df6ad6f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 22 Sep 2026 10:00:38 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E8=BF=90=E8=A1=8C/=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E9=80=80=E5=87=BA=E5=AF=B9=E8=AF=9D=E5=8C=BA?= =?UTF-8?q?=EF=BC=8C=E6=94=B9=20toast=20=E4=B8=8E=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E5=8C=BA=E5=9F=9F=E5=B0=8F=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除播放、/preview、/open-preview 与生成后自动启动预览写进对话区的成功提示,对话区只保留对话内容 - 新增 onRunNotice 通道,工作台壳用 RunNoticeToast 弹 2.6 秒浮层,同一句连续触发会重新计时 - 运行表现层在预览框上方常驻小字「已载入客户端运行视图:URL」,没有活预览时不占位 - 更新 appSurface 四处用例,新增 onRunNotice、小字与 RunNoticeToast 用例 - 同步 check-config 与 check-native-shells 的字符串判据,并记录决策到 shared-memory --- .../scripts/check-config.mjs | 2 +- apps/ai-game-creator-shell/src/App.tsx | 75 ++++++++----------- .../src/features/app-shell/RunNoticeToast.tsx | 57 ++++++++++++++ .../features/app-shell/WorkspaceLauncher.tsx | 14 ++++ .../src/features/app-shell/model.ts | 7 ++ apps/ai-game-creator-shell/src/styles.css | 21 ++++++ .../src/view/project-development/index.tsx | 41 ++++++---- .../appSurface/project-commands.suite.ts | 56 ++++++++------ .../appSurface/project-development.suite.ts | 60 ++++++++++++++- .../assert-project-tools-and-preview.ts | 25 ++++--- .../appSurface/supervisor-runtime.suite.ts | 14 ++-- .../tests/runNoticeToast.test.tsx | 73 ++++++++++++++++++ .../shared-memory/decision-log.md | 7 ++ scripts/check-native-shells.mjs | 2 +- 14 files changed, 351 insertions(+), 103 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx create mode 100644 apps/ai-game-creator-shell/tests/runNoticeToast.test.tsx diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index df9518a8d..2b0cc00e2 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1876,7 +1876,7 @@ for (const snippet of [ 'runtime_config.save', "'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'", "'activate_local_game_preview'", - '已切换到客户端运行视图', + '已载入客户端运行视图', 'async function executeRunLocal', 'function needsInitializedChatProject', 'function resolvePendingCommandProjectPath', diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 354a502f4..bac8a64d2 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -497,6 +497,7 @@ type AppProps = { metadata?: ProjectManifestSnapshotMetadata, ) => void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; + onRunNotice?: ProjectSupervisorComponentProps['onRunNotice']; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], ) => void; @@ -543,6 +544,7 @@ export function App({ onPlayRequestHandled, onManifestChange, onPreviewChange, + onRunNotice, onAgentRuntimeSummariesChange, onAgentResultsChange, }: AppProps = {}) { @@ -6955,19 +6957,20 @@ export function App({ setPreviewStatus(`运行中:127.0.0.1:${previewResult.port}`); setCommandLog((current) => [...current, 'preview.start']); void refreshManifest(generatedProjectPath); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: [ - `已保存并在客户端运行视图启动预览:${previewResult.url}`, - completionSummary?.text, - ] - .filter(Boolean) - .join('\n\n'), - draftCommand: completionSummary?.draftCommand, - }, - ]); + // 启动预览的成功反馈走 toast,不再往对话区写一条「已保存并…启动预览:URL」; + // run trace 摘要本身是对话内容,继续按消息发出。 + onRunNotice?.('运行通过,已载入客户端运行视图'); + const completionText = completionSummary?.text; + if (completionText) { + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: completionText, + draftCommand: completionSummary.draftCommand, + }, + ]); + } } catch (previewError) { setMessages((current) => [ ...current, @@ -8812,15 +8815,8 @@ export function App({ setPreviewStatus(`运行中:127.0.0.1:${result.port}`); void refreshManifest(nextProjectPath); setCommandLog((current) => [...current, 'preview.start']); - if (announceToChat) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `预览已在客户端运行视图启动:${result.url}`, - }, - ]); - } + // 成功反馈走 toast + 运行区域上方的小字,不在对话区留行。 + onRunNotice?.('运行通过,已载入客户端运行视图'); } catch (error) { const message = error instanceof Error ? error.message : String(error); setPreviewStatus(message); @@ -8875,30 +8871,18 @@ export function App({ 'activate_local_game_preview', nextProjectPath ? { projectPath: nextProjectPath } : undefined, ); - if ( - result.status === 'running' && - result.url && - result.port && - result.root - ) { - updateClientPreview({ - url: result.url, - port: result.port, - root: result.root, - }); + const activatedPreview = + result.status === 'running' && result.url && result.port && result.root + ? { url: result.url, port: result.port, root: result.root } + : null; + if (activatedPreview) { + updateClientPreview(activatedPreview); setPreviewStatus(`运行中:127.0.0.1:${result.port}`); } setCommandLog((current) => [...current, 'preview.open']); - if (announceToChat) { - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: result.url - ? `已切换到客户端运行视图:${result.url}` - : '已切换到客户端运行视图。', - }, - ]); + // 只有真的切到了运行视图才给反馈;没有活预览时不编一条成功提示。 + if (activatedPreview) { + onRunNotice?.('已载入客户端运行视图'); } } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -9531,8 +9515,9 @@ export function App({ if (!directCodexProductRuntime) { void refreshAgentRunTrace(nextProjectPath); } - // 运行成功的提示不再写进聊天:它会一直堆在对话底部遮挡视野,而客户端运行视图 - // 本身就是这条动作的可见反馈。失败仍按下面的分支回报,用户需要知道为什么没跑起来。 + // 运行成功的提示不再写进聊天(会一直堆在对话底部遮挡运行画面):反馈走工作台壳的 + // toast,预览地址由运行区域上方的小字常驻。失败仍按下面的分支回报。 + onRunNotice?.('运行通过,已载入客户端运行视图'); } catch (error) { const message = error instanceof Error ? error.message : String(error); setLimitedCommandStatus(message); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx b/apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx new file mode 100644 index 000000000..e76f6de99 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx @@ -0,0 +1,57 @@ +import { PlatformRuntimeStatusToast } from '@genarrative/shared/components'; +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; + +const RUN_NOTICE_MILLIS = 2600; + +export type RunNotice = { + /** 每次提示自增,保证重复触发同一个文案时也会重新弹一次。 */ + id: number; + message: string; +}; + +/** + * 运行 / 预览类动作的浮层提示。 + * + * 这类过程反馈以前以 assistant 消息写进对话区,会一直堆在对话底部挡住运行画面; + * 现在统一走 toast,对话区只保留对话内容,预览地址另由运行区域上方的小字常驻。 + */ +export function RunNoticeToast({ + notice, + onDismiss, +}: { + notice: RunNotice | null; + onDismiss: () => void; +}) { + useEffect(() => { + if (!notice) { + return; + } + const timer = window.setTimeout(onDismiss, RUN_NOTICE_MILLIS); + return () => { + window.clearTimeout(timer); + }; + }, [notice, onDismiss]); + + if (!notice) { + return null; + } + return createPortal( +
+ + {notice.message} + +
, + document.body, + ); +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 6d016e649..655e1f0f1 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -43,6 +43,7 @@ import { } from './DeveloperAgentPanel'; import type { WorkspaceLauncherShellProps } from './model'; import { NonEmptyProjectDialog, ProjectsPage } from './ProjectCreation'; +import { type RunNotice, RunNoticeToast } from './RunNoticeToast'; import { useAccountWallet } from './useAccountWallet'; import { useDeveloperAgentPanel } from './useDeveloperAgentPanel'; import { @@ -79,6 +80,13 @@ export function WorkspaceLauncherShell({ title: string; message: string; } | null>(null); + /** + * 运行 / 预览类动作的浮层提示。 + * + * 这类过程反馈不进对话区(见 `ProjectSupervisorComponentProps.onRunNotice`), + * `id` 每次自增,保证同一句文案连续触发时也会重新弹一次。 + */ + const [runNotice, setRunNotice] = useState(null); // 「做成游戏」切换记录按项目上下文(路径 + createdAt)定位。运行模式必须随 // currentProjectContext 同步派生,不能靠 effect 后置修正:首帧挂错 lane 会先 // 以游戏运行时挂载并消耗首轮 claim,重挂后的策划实例再也发不出首轮。 @@ -521,6 +529,10 @@ export function WorkspaceLauncherShell({ ); }, []); + const handleRunNotice = useCallback((message: string) => { + setRunNotice((current) => ({ id: (current?.id ?? 0) + 1, message })); + }, []); + function showLauncherNotice(title: string) { setLauncherNotice({ title, @@ -750,6 +762,7 @@ export function WorkspaceLauncherShell({ onPlayRequestHandled={handlePlayRequestHandled} onManifestChange={syncActiveProjectManifest} onPreviewChange={setActiveProjectPreview} + onRunNotice={handleRunNotice} onAgentRuntimeSummariesChange={ setActiveProjectAgentRuntimeSummaries } @@ -795,6 +808,7 @@ export function WorkspaceLauncherShell({ )} + setRunNotice(null)} /> {launcherNotice ? (
void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; + /** + * 运行 / 预览类动作的一次性浮层提示(成功态)。 + * + * 「跑起来了」「已切到运行视图」属于过程反馈,不进对话区——对话区只保留对话内容。 + * 工作台壳收到后弹 toast;预览地址由运行区域上方的小字常驻,不再占对话位置。 + */ + onRunNotice?: (message: string) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], ) => void; diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index a33fe8eb2..b1473b56e 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -8497,6 +8497,27 @@ 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; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 6ee08fe4a..2be66f3c9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -10850,22 +10850,33 @@ export default function ProjectDevelopmentView({ activeVersionId={activeVersionId} onSelectVersion={selectActiveVersion} /> -
+
+ {/* + 运行区域上方的小字:预览地址属于过程信息,常驻在这里, + 不再以 assistant 消息堆到对话区底部。没有活预览时不占位。 + */} {embeddedPreviewUrl ? ( - setRuntimeInspectMode(false)} - /> - ) : ( -
-
- )} +

+ 已载入客户端运行视图:{embeddedPreviewUrl} +

+ ) : null} +
+ {embeddedPreviewUrl ? ( + setRuntimeInspectMode(false)} + /> + ) : ( +
+
+ )} +
diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts index 3671dbdf8..672f29c7a 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts @@ -2292,12 +2292,14 @@ export function registerProjectCommandTests() { await submitChat('/open-preview'); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText('已切换到客户端运行视图:http://127.0.0.1:3210/'), - ).not.toBeNull(); - expect(invoke).toHaveBeenCalledWith('activate_local_game_preview', { - projectPath: '/tmp/authorized-game', - }); + // 成功反馈走工作台壳的 toast(见 RunNoticeToast / onRunNotice 用例), + // 对话区不再出现「已切换到客户端运行视图:URL」。 + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('activate_local_game_preview', { + projectPath: '/tmp/authorized-game', + }), + ); + expect(screen.queryByText(/已切换到客户端运行视图/)).toBeNull(); }); it('requires project policy confirmation before opening preview from chat', async () => { @@ -2366,12 +2368,12 @@ export function registerProjectCommandTests() { fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText('已切换到客户端运行视图:http://127.0.0.1:3210/'), - ).not.toBeNull(); - expect(invoke).toHaveBeenCalledWith('activate_local_game_preview', { - projectPath: '/tmp/authorized-game', - }); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('activate_local_game_preview', { + projectPath: '/tmp/authorized-game', + }), + ); + expect(screen.queryByText(/已切换到客户端运行视图/)).toBeNull(); }); it('cancels pending preview open without opening preview', async () => { @@ -2481,11 +2483,12 @@ export function registerProjectCommandTests() { ).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText( - /预览已在客户端运行视图启动:http:\/\/127\.0\.0\.1:3210\//, - ), - ).not.toBeNull(); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { + projectPath: '/tmp/authorized-game', + }), + ); + expect(screen.queryByText(/预览已在客户端运行视图启动/)).toBeNull(); expect(screen.queryByTitle('本地游戏预览')).toBeNull(); expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { projectPath: '/tmp/authorized-game', @@ -2581,11 +2584,12 @@ export function registerProjectCommandTests() { fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText( - /预览已在客户端运行视图启动:http:\/\/127\.0\.0\.1:3210\//, - ), - ).not.toBeNull(); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { + projectPath: '/tmp/authorized-game', + }), + ); + expect(screen.queryByText(/预览已在客户端运行视图启动/)).toBeNull(); expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { projectPath: '/tmp/authorized-game', }); @@ -3025,13 +3029,17 @@ export function registerProjectCommandTests() { ).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '确认' })); - // /run 成功只切换客户端运行视图,不再往聊天里追加“运行通过”提示。 + // /run 成功只切换客户端运行视图:反馈走 toast,对话区不再追加“运行通过”提示。 await waitFor(() => expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { projectPath: '/tmp/authorized-game', }), ); - expect(screen.queryByText(/运行通过/)).toBeNull(); + const chatMessages = Array.from(document.querySelectorAll('.message')); + expect(chatMessages.length).toBeGreaterThan(0); + expect( + chatMessages.some((node) => node.textContent?.includes('运行通过')), + ).toBe(false); expect(invoke).toHaveBeenCalledWith('run_limited_local_command', { projectPath: '/tmp/authorized-game', commandId: 'game.static_smoke', diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index ec74c9aa7..196044aab 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -2353,6 +2353,61 @@ export function registerProjectWorkbenchFoundationTests() { expect(screen.getByLabelText('运行表现层')).not.toBeNull(); }); + it('shows the live preview address as small text above the run area', async () => { + const manifest = createGameCreationAppManifest( + 'workbench-run-status-hint', + '运行区域小字测试', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: '/tmp/workbench-run-status-hint', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + preview: { + status: 'running', + url: 'http://127.0.0.1:4173/', + port: 4173, + }, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + const hint = await screen.findByText( + '已载入客户端运行视图: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); + }); + it('paints the marquee selection box with the scene token root and non-empty geometry', async () => { const manifest = createGameCreationAppManifest( 'workbench-marquee-colour', @@ -7185,6 +7240,7 @@ export function registerProjectSupervisorSurfaceTests() { }, ); const handled = vi.fn(); + const runNotice = vi.fn(); window.__TAURI__ = { core: { invoke }, event: { listen: supervisorHarness.listen }, @@ -7196,11 +7252,12 @@ export function registerProjectSupervisorSurfaceTests() { projectSupervisorOnly: true, playRequest: { projectPath, requestId: 7 }, onPlayRequestHandled: handled, + onRunNotice: runNotice, }), ); // 顶部播放请求只负责把游戏起在运行视图里;聊天里不再新增“运行通过”提示, - // 否则每次播放都会在对话底部堆一条遮挡运行画面。 + // 否则每次播放都会在对话底部堆一条遮挡运行画面——反馈改由工作台壳弹 toast。 await waitFor( () => expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { @@ -7208,6 +7265,7 @@ export function registerProjectSupervisorSurfaceTests() { }), { timeout: 3_000 }, ); + expect(runNotice).toHaveBeenCalledWith('运行通过,已载入客户端运行视图'); expect(screen.queryByText(/运行通过/)).toBeNull(); expect(handled).toHaveBeenCalledWith(7); expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts index 80b3fc4c7..612088502 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts @@ -822,8 +822,8 @@ export function registerProjectToolsAndPreviewTests() { fireEvent.click(screen.getByRole('button', { name: '运行' })); expect(await screen.findByText('game.run_local')).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '确认' })); - // 运行成功不再写聊天提示(会一直堆在对话底部遮挡运行画面),改成断言预览真的起来了 - // 且聊天里不再出现那条提示。 + // 运行成功不再写聊天提示(会一直堆在对话底部遮挡运行画面):反馈走 toast, + // 预览地址改由运行区域上方的小字常驻。 await waitFor(() => expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { projectPath: '/tmp/authorized-game', @@ -898,18 +898,23 @@ export function registerProjectToolsAndPreviewTests() { fireEvent.click(screen.getByRole('button', { name: '启动预览' })); expect(await screen.findByText('preview.start')).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText( - /预览已在客户端运行视图启动:http:\/\/127\.0\.0\.1:3210\//, - ), - ).not.toBeNull(); + // 启动 / 切换运行视图的成功反馈统一走 toast,不再写进对话区。 + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { + projectPath: '/tmp/authorized-game', + }), + ); + expect(screen.queryByText(/预览已在客户端运行视图启动/)).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '打开预览' })); expect(await screen.findByText('preview.open')).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect( - await screen.findByText('已切换到客户端运行视图:http://127.0.0.1:3210/'), - ).not.toBeNull(); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('activate_local_game_preview', { + projectPath: '/tmp/authorized-game', + }), + ); + expect(screen.queryByText(/已切换到客户端运行视图/)).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '预览状态' })); expect( diff --git a/apps/ai-game-creator-shell/tests/appSurface/supervisor-runtime.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/supervisor-runtime.suite.ts index 62ee3cc86..52dcc3939 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/supervisor-runtime.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/supervisor-runtime.suite.ts @@ -1852,12 +1852,14 @@ export function registerSupervisorRuntimeTests() { await screen.findByText(/开始调用 LLM:Planner 正在整理规格。/), ).not.toBeNull(); expect(screen.getByText(/Generator 生成代码和资产清单/)).not.toBeNull(); - expect( - await screen.findByText( - /已保存并在客户端运行视图启动预览:http:\/\/127\.0\.0\.1:3210\//, - ), - ).not.toBeNull(); - expect(screen.getByText(/Run:run-chat-generate/)).not.toBeNull(); + // 启动预览的成功反馈走 toast(见 `onRunNotice` 用例),对话区只保留 run trace 摘要。 + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { + projectPath: '/tmp/authorized-game', + }), + ); + expect(screen.queryByText(/已保存并在客户端运行视图启动预览/)).toBeNull(); + expect(await screen.findByText(/Run:run-chat-generate/)).not.toBeNull(); expect( screen.getByText(/状态:passed · 2\/3 轮 · evaluator-passed/), ).not.toBeNull(); diff --git a/apps/ai-game-creator-shell/tests/runNoticeToast.test.tsx b/apps/ai-game-creator-shell/tests/runNoticeToast.test.tsx new file mode 100644 index 000000000..57e3909f2 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/runNoticeToast.test.tsx @@ -0,0 +1,73 @@ +/** @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(); + + expect(toastElement()).toBeNull(); + expect(screen.queryByText(RUN_MESSAGE)).toBeNull(); + }); + + it('在浮层里显示提示,并在到点后自动收起', () => { + vi.useFakeTimers(); + const onDismiss = vi.fn(); + + render( + , + ); + + 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( + , + ); + + rerender( + , + ); + act(() => { + vi.advanceTimersByTime(2_000); + }); + expect(onDismiss).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(600); + }); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 77d706d4b..048a40b11 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9212,3 +9212,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`.../features/resource-canvas/resourceCanvasFocusModel.ts`、`tests/{projectResourceLiveIntegration,resourceCanvasQuickEditDraft,resourceCanvasFloatingDismiss}.test.tsx`、PRD §3.10。 - 验证:定向 `projectResourceLiveIntegration`(32 条,三条断言面板留在失败态的用例按新口径改写为「重开面板再重试,身份不变」)、`resourceCanvasQuickEditDraft`(10 条)、`resourceCanvasFloatingDismiss`(18 条)全绿;`npm --prefix apps/ai-game-creator-shell run typecheck` 通过。 - 合并前复核(2026-09-21):合并 master 后按**仓库根**跑全量 `npx vitest run`,**373 个测试文件全过、4512 通过 / 34 跳过 / 0 失败**;PR #419 显示 `No Conflicts`。真实客户端观感与远程 CI 未复验(后者按用户要求不追,runner/镜像问题见 Issue #431)。 + +## 2026-09-22 运行 / 预览的过程反馈退出对话区:改 toast + 运行区域小字 + +- 背景:客户端验收反馈——点播放,以及 `/preview`、`/open-preview`、生成后自动启动预览,都会往对话区写一条 assistant 提示(`运行通过,已载入客户端运行视图:http://127.0.0.1:63155/` 这类)。它常驻在对话底部遮挡运行画面,也让对话区混进非对话内容。 +- 决策:对话区只保留对话内容(用户消息、Agent 回复、run trace 摘要、命令确认与错误)。运行 / 预览成功的反馈改由两条通道承载:① 工作台壳的 toast(`ProjectSupervisorComponentProps.onRunNotice` → `RunNoticeToast`,2.6 秒自动收起,同一句连续触发会重新计时);② 运行表现层预览框**上方**的小字(`已载入客户端运行视图:`,只在有活预览时出现,不占位)。失败仍留在对话区——用户需要知道为什么没跑起来;`/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` 通过;真机观感未复验。 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 3912eaf8a..59737f21e 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -2723,7 +2723,7 @@ function assertAiGameCreatorShellUserDevBoundary() { } for (const snippet of [ "await invoke(\n 'activate_local_game_preview'", - '已切换到客户端运行视图', + '已载入客户端运行视图', ]) { if (!sourceIncludesSnippet(aiGameCreatorShellAppSource, snippet)) { throw new Error( -- 2.52.0 From 8e6ee50ad8c08d4ab4c98a98f74d3f66d223da77 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 22 Sep 2026 10:29:53 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E9=A1=B5=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=EF=BC=9A=E7=8A=B6=E6=80=81=E8=A1=8C=E5=90=8C=E5=9C=A8?= =?UTF-8?q?=E7=94=BB=E9=9D=A2=E5=A4=96=EF=BC=8C=E7=94=9F=E6=88=90=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E4=B8=8D=E8=BF=9B=E8=BF=90=E8=A1=8C=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 运行区域上方新增状态行,预览地址小字与版本入口同排:两者都在游戏画面之外,版本入口不再绝对定位压在画面上 - 小字单行省略并用 title 给出完整地址,状态行高度恒定 30px,没有版本入口时也不塌 - 运行页不再渲染生成任务入口、面板与锚点;[data-generation-tasks-placement='run'] 那一档坐标与 placement 里的 run 一并删除 - 按新口径改写两侧用例,新增运行区域状态行的声明级守卫用例 - 记录决策到 shared-memory/decision-log.md --- ...rceCanvasAssetGenerationTasksPanelView.tsx | 2 +- ...ourceCanvasAssetGenerationTasksSidebar.css | 10 +- apps/ai-game-creator-shell/src/styles.css | 63 ++++----- .../src/view/project-development/index.tsx | 123 +++++++++--------- .../appSurface/project-development.suite.ts | 23 +++- .../tests/gameRunStatusBarStyle.test.ts | 70 ++++++++++ ...ceCanvasAssetGenerationTasksPanel.test.tsx | 15 +-- ...asAssetGenerationTasksSidebarStyle.test.ts | 19 +-- ...nvasGenerationTasksSidebarDismiss.test.tsx | 49 +++++++ .../shared-memory/decision-log.md | 7 + 10 files changed, 257 insertions(+), 124 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/gameRunStatusBarStyle.test.ts diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx index f526ea984..3399d5376 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx @@ -56,7 +56,7 @@ export type ResourceCanvasAssetGenerationTasksPanelViewProps = { * * 两页之间切换时锚点高度走样式里的 transition,不瞬移。 */ - placement?: 'canvas-overview' | 'canvas' | 'run' | 'editor'; + placement?: 'canvas-overview' | 'canvas' | 'editor'; }; /** diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css index fc6cbf3d3..cb53b2c14 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css @@ -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; } diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index b1473b56e..89fce1944 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -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; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 2be66f3c9..048a11597 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -10845,38 +10845,42 @@ export default function ProjectDevelopmentView({ ) : (
- -
- {/* - 运行区域上方的小字:预览地址属于过程信息,常驻在这里, - 不再以 assistant 消息堆到对话区底部。没有活预览时不占位。 - */} + {/* + 运行区域上方的状态行:左边预览地址小字,右边版本入口。 + 两者同层级、同在游戏画面之外——版本入口不再绝对定位压在画面上, + 小字也不再写进对话区(那是过程信息,不是对话内容)。 + */} +
{embeddedPreviewUrl ? ( -

+

已载入客户端运行视图:{embeddedPreviewUrl}

) : null} -
- {embeddedPreviewUrl ? ( - setRuntimeInspectMode(false)} - /> - ) : ( -
-
- )} -
+ +
+
+ {embeddedPreviewUrl ? ( + setRuntimeInspectMode(false)} + /> + ) : ( +
+
+ )}
@@ -10898,41 +10902,42 @@ export default function ProjectDevelopmentView({
)} {/* - 「生成任务」侧栏:常驻在**工作面右上角**(资源画布 / 运行表现层 / UI 编辑器各自一档坐标), - 可折叠、非模态。它**不**参与 `isResourceCanvasFloatingPanelOpen` 的模态遮挡判据—— - 生成在后台跑,侧栏展开时画布必须照样能看能用;折叠只影响这个视图,任务本身活在账本 - 与本地队列里。位置与开合形态照抄美术画布(开关常驻右上角 + 面板贴着它展开), - 颜色与外形仍走 AGC 的平台 token。 + 「生成任务」侧栏:常驻在**资源画布 / UI 编辑器**的右上角(各一档坐标),可折叠、非模态。 + 它**不**参与 `isResourceCanvasFloatingPanelOpen` 的模态遮挡判据——生成在后台跑, + 侧栏展开时画布必须照样能看能用;折叠只影响这个视图,任务本身活在账本与本地队列里。 + + **运行表现层不挂它**:运行页要留给游戏画面,右上是预览地址小字与版本入口, + 再叠一枚任务开关(或展开的面板)就会压在画面上。任务不会因此丢,切回资源页即可见。 */} - 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 : ( + 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' - } - /> + } + /> + )}
{!uiEditorRoute ? ( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 196044aab..8f500ec36 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -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: '生成任务' }), diff --git a/apps/ai-game-creator-shell/tests/gameRunStatusBarStyle.test.ts b/apps/ai-game-creator-shell/tests/gameRunStatusBarStyle.test.ts new file mode 100644 index 000000000..8798b4b31 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/gameRunStatusBarStyle.test.ts @@ -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 { + 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); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx index 5d01d2ac8..ce2402179 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx @@ -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( - , - ); expect(placementOf()).toBe('editor'); // 缺省 = 资源栏目画布,老调用方不传这个 prop 也落在画布右上角。 diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts index d3aea4611..f4ebcccdd 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts @@ -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([ diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx index 8cb151d57..6103be3e0 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx @@ -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'), diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 048a40b11..3acce6b0b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9219,3 +9219,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:对话区只保留对话内容(用户消息、Agent 回复、run trace 摘要、命令确认与错误)。运行 / 预览成功的反馈改由两条通道承载:① 工作台壳的 toast(`ProjectSupervisorComponentProps.onRunNotice` → `RunNoticeToast`,2.6 秒自动收起,同一句连续触发会重新计时);② 运行表现层预览框**上方**的小字(`已载入客户端运行视图:`,只在有活预览时出现,不占位)。失败仍留在对话区——用户需要知道为什么没跑起来;`/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` 通过;真机观感未复验。 -- 2.52.0 From 5a87c5f9659792639b333e3dbf48cbe245a4ee9d Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 22 Sep 2026 11:51:51 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E9=A1=B5=E9=A1=B6?= =?UTF-8?q?=E6=A0=8F=E6=94=B6=E5=8F=A3=EF=BC=9A=E9=A2=84=E8=A7=88=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E6=94=B9=E6=8C=89=E9=92=AE=EF=BC=8C=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E5=B9=B6=E5=85=A5=E9=A1=B6=E6=A0=8F=E5=90=8C?= =?UTF-8?q?=E4=B8=80=E5=A5=97=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除运行区域上方的状态行与预览地址小字,运行画面回到两行栅格 - 预览地址改成顶栏动作区里的「在浏览器打开」按钮(opener 插件 openUrl),只在有活预览时渲染 - 版本入口搬进同一个顶栏动作区,外观复用该容器的基础按钮样式,去掉自带的边框、底色与 hover - 打开失败走 onNotice 的失败色 toast:onRunNotice 通道改成带 tone 的对象,四处调用点同步 - 补顶栏入口正向守卫,删掉状态行的旧样式用例并新增顶栏与浏览器打开的用例 --- apps/ai-game-creator-shell/src/App.tsx | 8 +- .../src/features/app-shell/RunNoticeToast.tsx | 9 +- .../features/app-shell/WorkspaceLauncher.tsx | 7 +- .../src/features/app-shell/model.ts | 19 ++- apps/ai-game-creator-shell/src/styles.css | 57 ++------- .../src/view/project-development/index.tsx | 79 +++++++++--- .../appSurface/project-development.suite.ts | 34 +++--- .../tests/gameRunStatusBarStyle.test.ts | 70 ----------- .../tests/gameRunToolbarActionsStyle.test.ts | 69 +++++++++++ .../tests/runNoticeToast.test.tsx | 22 ++++ .../tests/runPreviewBrowserOpen.test.tsx | 115 ++++++++++++++++++ .../shared-memory/decision-log.md | 10 ++ scripts/check-native-shells.mjs | 21 ++++ 13 files changed, 350 insertions(+), 170 deletions(-) delete mode 100644 apps/ai-game-creator-shell/tests/gameRunStatusBarStyle.test.ts create mode 100644 apps/ai-game-creator-shell/tests/gameRunToolbarActionsStyle.test.ts create mode 100644 apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index bac8a64d2..378ad7c2a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -6959,7 +6959,7 @@ export function App({ void refreshManifest(generatedProjectPath); // 启动预览的成功反馈走 toast,不再往对话区写一条「已保存并…启动预览:URL」; // run trace 摘要本身是对话内容,继续按消息发出。 - onRunNotice?.('运行通过,已载入客户端运行视图'); + onRunNotice?.({ message: '运行通过,已载入客户端运行视图' }); const completionText = completionSummary?.text; if (completionText) { setMessages((current) => [ @@ -8816,7 +8816,7 @@ export function App({ void refreshManifest(nextProjectPath); setCommandLog((current) => [...current, 'preview.start']); // 成功反馈走 toast + 运行区域上方的小字,不在对话区留行。 - onRunNotice?.('运行通过,已载入客户端运行视图'); + onRunNotice?.({ message: '运行通过,已载入客户端运行视图' }); } catch (error) { const message = error instanceof Error ? error.message : String(error); setPreviewStatus(message); @@ -8882,7 +8882,7 @@ export function App({ setCommandLog((current) => [...current, 'preview.open']); // 只有真的切到了运行视图才给反馈;没有活预览时不编一条成功提示。 if (activatedPreview) { - onRunNotice?.('已载入客户端运行视图'); + onRunNotice?.({ message: '已载入客户端运行视图' }); } } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -9517,7 +9517,7 @@ export function App({ } // 运行成功的提示不再写进聊天(会一直堆在对话底部遮挡运行画面):反馈走工作台壳的 // toast,预览地址由运行区域上方的小字常驻。失败仍按下面的分支回报。 - onRunNotice?.('运行通过,已载入客户端运行视图'); + onRunNotice?.({ message: '运行通过,已载入客户端运行视图' }); } catch (error) { const message = error instanceof Error ? error.message : String(error); setLimitedCommandStatus(message); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx b/apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx index e76f6de99..39ad520ea 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx @@ -2,19 +2,20 @@ import { PlatformRuntimeStatusToast } from '@genarrative/shared/components'; import { useEffect } from 'react'; import { createPortal } from 'react-dom'; +import type { ProjectRunNotice } from './model'; + const RUN_NOTICE_MILLIS = 2600; -export type RunNotice = { +export type RunNotice = ProjectRunNotice & { /** 每次提示自增,保证重复触发同一个文案时也会重新弹一次。 */ id: number; - message: string; }; /** * 运行 / 预览类动作的浮层提示。 * * 这类过程反馈以前以 assistant 消息写进对话区,会一直堆在对话底部挡住运行画面; - * 现在统一走 toast,对话区只保留对话内容,预览地址另由运行区域上方的小字常驻。 + * 现在统一走 toast,对话区只保留对话内容——运行页的预览地址改用顶栏的「在浏览器打开」。 */ export function RunNoticeToast({ notice, @@ -43,7 +44,7 @@ export function RunNoticeToast({ data-project-run-notice-toast="true" > { - setRunNotice((current) => ({ id: (current?.id ?? 0) + 1, message })); + const handleRunNotice = useCallback((notice: ProjectRunNotice) => { + setRunNotice((current) => ({ id: (current?.id ?? 0) + 1, ...notice })); }, []); function showLauncherNotice(title: string) { @@ -723,6 +723,7 @@ export function WorkspaceLauncherShell({ currentProjectContext.projectPath, ) } + onNotice={handleRunNotice} onManifestChange={syncActiveProjectManifest} onHomeOpen={() => setLauncherView('home')} onProjectsOpen={() => setLauncherView('projects')} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 6db4d2a11..73837f03e 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -36,6 +36,17 @@ export type WorkspaceLauncherProps = { initialView?: LauncherView; }; +/** + * 运行 / 预览类动作的一次性浮层提示。 + * + * `tone` 只区分观感(成功绿 / 失败红),文案由发出方给出:运行成功、切到运行视图、 + * 在浏览器打开失败都走这一条通道。 + */ +export type ProjectRunNotice = { + message: string; + tone?: 'success' | 'error'; +}; + export type ProjectSupervisorComponentProps = { initialProjectPath?: string; initialProjectManifest?: GameCreationAppManifest; @@ -64,12 +75,12 @@ export type ProjectSupervisorComponentProps = { ) => void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; /** - * 运行 / 预览类动作的一次性浮层提示(成功态)。 + * 运行 / 预览类动作的一次性浮层提示。 * - * 「跑起来了」「已切到运行视图」属于过程反馈,不进对话区——对话区只保留对话内容。 - * 工作台壳收到后弹 toast;预览地址由运行区域上方的小字常驻,不再占对话位置。 + * 「跑起来了」「已切到运行视图」「在浏览器打开失败」属于过程反馈,不进对话区—— + * 对话区只保留对话内容。工作台壳收到后弹 toast,`tone` 决定成功还是失败观感。 */ - onRunNotice?: (message: string) => void; + onRunNotice?: (notice: ProjectRunNotice) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], ) => void; diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 89fce1944..456eb928a 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -8397,10 +8397,10 @@ iframe.preview-frame { .game-run-surface { position: relative; display: grid; - /* 三行:状态行(预览地址小字 + 版本入口,都在游戏画面之外)、运行画面、信息面板。 */ - grid-template-rows: auto minmax(300px, 1fr) auto; + grid-template-rows: minmax(300px, 1fr) auto; grid-row: 2 / -1; - /* 显式钉第 1 列,避免画面被自动列放置挤到隐式列里。 */ + /* 显式钉第 1 列(见 resourceCanvasAssetGenerationTasksSidebar.css),避免画面被自动列放置 + 挤到隐式列里。 */ grid-column: 1; gap: 12px; height: 100%; @@ -8410,64 +8410,25 @@ iframe.preview-frame { } /* - * 运行区域上方的状态行:左边是预览地址小字,右边是版本入口(没有版本时不渲染)。 + * C7 版本入口:住在工作台顶栏动作区(`.game-workbench-view-actions`),外观由那一组的基础 + * 规则给(描边 + secondary 填充 + 12px/700),这里只补「版本名可能很长」这一件事。 * - * 两者必须在**同一层级、同一个画外侧**:版本入口以前是绝对定位压在游戏画面上 - * (top 20px / right 20px),小字在画面外,两者既不对齐又各自压着画面。 + * 它曾经是运行画面里的绝对定位浮层(top 20px / right 20px):压在游戏画面上,还与顶栏其他 + * 按钮分成两套皮。**不要再给它加 border / background / color / hover**——那就是第二套皮。 */ -.game-run-status-bar { - display: flex; - min-width: 0; - min-height: 30px; - align-items: center; - gap: 12px; -} - -.game-run-status-hint { - flex: 1 1 auto; - min-width: 0; - margin: 0; - padding: 0 2px; - color: #96796d; - font-size: 11px; - line-height: 16px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* C7 版本入口:与预览地址小字同排,靠右;没有版本时不渲染。 */ .game-run-version-picker { flex: 0 0 auto; - /* 没有小字时也要贴右,不能因为 flex 里只剩一个条目就跑到左边。 */ - margin-left: auto; + display: inline-flex; + align-items: center; } .game-run-version-trigger { - display: inline-flex; max-width: min(18rem, 60vw); - min-height: 30px; - align-items: center; - padding: 0 12px; - border: 1px solid #e5cfc4; - border-radius: 999px; - background: rgb(255 250 246 / 92%); - color: #8a4a30; - cursor: pointer; - font-size: 12px; - font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.game-run-version-trigger:hover, -.game-run-version-trigger:focus-visible { - border-color: #cc8060; - outline: 0; - box-shadow: 0 3px 10px rgb(158 87 57 / 14%); -} - .game-run-version-menu { display: grid; gap: 2px; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 048a11597..5d158a5d7 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -16,10 +16,12 @@ import { } from '@genarrative/image-canvas-react'; import { CanvasCardCornerActions } from '@genarrative/shared/components'; import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; +import { openUrl } from '@tauri-apps/plugin-opener'; import { AtSign, Box, Crosshair, + ExternalLink, Eye, FileCode2, FileText, @@ -107,6 +109,7 @@ import { isFloatingOverlayWheelEvent, useImageCanvasFloatingOptionDismiss, } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; +import type { ProjectRunNotice } from '../../features/app-shell/model'; import { DesignWorkspacePanel } from '../../features/project-workspace/DesignWorkspacePanel'; import { LocalGamePreviewFrame, @@ -785,6 +788,13 @@ export type ProjectDevelopmentViewProps = { onPlay?: () => void; onMakeGame?: () => void; onRevealProjectDirectory?: () => void | Promise; + /** + * 过程提示(成功 / 失败)的唯一出口。 + * + * 「运行起来了」「在浏览器打开失败」这类反馈不进对话区,交给工作台壳弹 toast; + * 本视图不自己造第二套提示位。 + */ + onNotice?: (notice: ProjectRunNotice) => void; onManifestChange?: ( projectPath: string, manifest: GameCreationAppManifest, @@ -1790,6 +1800,7 @@ export default function ProjectDevelopmentView({ onPlay, onMakeGame, onRevealProjectDirectory, + onNotice, }: ProjectDevelopmentViewProps) { const professionalDagVisible = orchestrationMode === 'professional-dag'; const [mode, setMode] = useState('resources'); @@ -2703,6 +2714,28 @@ export default function ProjectDevelopmentView({ const preview = previewOverride ?? manifest.preview ?? null; const embeddedPreviewUrl = resolveEmbeddedPreviewUrl(preview); + /** + * 「在浏览器打开」:把当前预览地址交给系统默认浏览器。 + * + * 这是**用户主动**动作。预览的启动与切换仍然只走内置运行画面、不自动开外部浏览器 + * (那条边界由 `scripts/check-native-shells.mjs` 的守卫钉着);失败时把原因交给 + * `onNotice` 弹 toast,不在工具条里另造一条提示位、也不写进对话区。 + */ + const openPreviewInBrowser = useCallback(async () => { + if (!embeddedPreviewUrl) { + return; + } + try { + await openUrl(embeddedPreviewUrl); + } catch (error) { + onNotice?.({ + tone: 'error', + message: `在浏览器打开失败:${ + error instanceof Error ? error.message : String(error) + }`, + }); + } + }, [embeddedPreviewUrl, onNotice]); const runAvailable = embeddedPreviewUrl !== null || manifest.tasks.some( @@ -9611,6 +9644,22 @@ export default function ProjectDevelopmentView({ {runtimeInspectMode ? '退出点选' : '点选素材'} ) : null} + {/* + 当前预览地址的出口。以前这里是一条「已载入客户端运行视图:http://…」小字, + 长地址既占位又不可点:地址本身对用户没用,能一步跳浏览器才有用。 + 只在有活预览时出现,外观与其他动作按钮同款(见 `.game-workbench-view-actions button`)。 + */} + {mode === 'run' && embeddedPreviewUrl ? ( + + ) : null} {mode === 'resources' && !uiEditorRoute ? ( <>
{/* 布局状态提示**不进动作行**:它是一段随保存过程变长的文案(空 →「保存中」→ @@ -10845,26 +10904,6 @@ export default function ProjectDevelopmentView({ ) : (
- {/* - 运行区域上方的状态行:左边预览地址小字,右边版本入口。 - 两者同层级、同在游戏画面之外——版本入口不再绝对定位压在画面上, - 小字也不再写进对话区(那是过程信息,不是对话内容)。 - */} -
- {embeddedPreviewUrl ? ( -

- 已载入客户端运行视图:{embeddedPreviewUrl} -

- ) : null} - -
{embeddedPreviewUrl ? ( { + it('turns the live preview address into a toolbar browser-open button', async () => { const manifest = createGameCreationAppManifest( - 'workbench-run-status-hint', - '运行区域小字测试', + 'workbench-run-browser-open-entry', + '运行页浏览器入口测试', ); const invoke = vi.fn( async (command: string, args?: Record) => { @@ -2381,7 +2381,7 @@ export function registerProjectWorkbenchFoundationTests() { render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, - projectPath: '/tmp/workbench-run-status-hint', + projectPath: '/tmp/workbench-run-browser-open-entry', manifest, attachments: [], recentRunStatus: null, @@ -2397,17 +2397,15 @@ export function registerProjectWorkbenchFoundationTests() { }), ); - const hint = await screen.findByText( - '已载入客户端运行视图:http://127.0.0.1:4173/', - ); - const surface = screen.getByLabelText('运行表现层'); - const preview = surface.querySelector('.game-run-preview'); - expect(preview).not.toBeNull(); - // 小字在游戏画面**之外**,与版本入口同在运行区域上方那条状态行里。 - expect(preview?.contains(hint)).toBe(false); - const statusBar = preview?.previousElementSibling; - expect(statusBar?.classList.contains('game-run-status-bar')).toBe(true); - expect(statusBar?.contains(hint)).toBe(true); + // 地址本身不再以文字出现(长 URL 既占位又不可点),改成顶栏动作区里的入口。 + expect(document.body.textContent).not.toContain('http://127.0.0.1:4173/'); + const actions = document.querySelector('.game-workbench-view-actions'); + const entry = await screen.findByRole('button', { name: '在浏览器打开' }); + expect(actions?.contains(entry)).toBe(true); + // 运行画面内不再有状态行:画面就是这块区域里唯一的主角。 + expect(document.querySelector('.game-run-status-bar')).toBeNull(); + expect(document.querySelector('.game-run-status-hint')).toBeNull(); + expect(screen.getByLabelText('运行表现层')).not.toBeNull(); }); it('paints the marquee selection box with the scene token root and non-empty geometry', async () => { @@ -6001,7 +5999,7 @@ export function registerProjectWorkbenchFoundationTests() { /\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\s+button\s*\{/s, ); expect(styles).toMatch( - /\.game-run-surface\s*\{[^}]*grid-template-rows:\s*auto minmax\(300px, 1fr\) auto[^}]*grid-row:\s*2 \/ -1[^}]*height:\s*100%/s, + /\.game-run-surface\s*\{[^}]*grid-template-rows:\s*minmax\(300px, 1fr\) auto[^}]*grid-row:\s*2 \/ -1[^}]*height:\s*100%/s, ); expect(styles).toMatch( /\.local-game-preview-frame\s*\{[^}]*position:\s*relative[^}]*width:\s*100%[^}]*height:\s*100%[^}]*min-width:\s*0[^}]*min-height:\s*0[^}]*overflow:\s*hidden/s, @@ -7267,7 +7265,9 @@ export function registerProjectSupervisorSurfaceTests() { }), { timeout: 3_000 }, ); - expect(runNotice).toHaveBeenCalledWith('运行通过,已载入客户端运行视图'); + expect(runNotice).toHaveBeenCalledWith({ + message: '运行通过,已载入客户端运行视图', + }); expect(screen.queryByText(/运行通过/)).toBeNull(); expect(handled).toHaveBeenCalledWith(7); expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { diff --git a/apps/ai-game-creator-shell/tests/gameRunStatusBarStyle.test.ts b/apps/ai-game-creator-shell/tests/gameRunStatusBarStyle.test.ts deleted file mode 100644 index 8798b4b31..000000000 --- a/apps/ai-game-creator-shell/tests/gameRunStatusBarStyle.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -// @vitest-environment jsdom -import { readFileSync } from 'node:fs'; - -import { describe, expect, test } from 'vitest'; - -import { repoPath } from './repoPath'; -import { - declaration, - parseStyleSheet, - resolveDeclarations, -} from './styleCascade'; - -/** - * 运行区域上方那条状态行的声明级断言。 - * - * jsdom 不加载全局样式表,所以这里按仓库既有做法(`styleCascade`)解析真实生效的声明: - * 「预览地址小字与版本入口同排、同在游戏画面之外」这件事只由样式决定,用例必须钉在声明上, - * 否则下一次改动把版本入口挪回绝对定位压画面时没有任何东西会红。 - */ -const GLOBAL_CSS_PATH = repoPath('apps/ai-game-creator-shell/src/styles.css'); -const rules = parseStyleSheet(readFileSync(GLOBAL_CSS_PATH, 'utf8')); -// 同一个求值器限制:全局表里也有 `prefers-reduced-motion` 档,求值时先排除掉。 -const widthRules = rules.filter( - (rule) => !(rule.media ?? '').includes('prefers-reduced-motion'), -); - -function resolved(selectors: readonly string[]): Map { - 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); - }); -}); diff --git a/apps/ai-game-creator-shell/tests/gameRunToolbarActionsStyle.test.ts b/apps/ai-game-creator-shell/tests/gameRunToolbarActionsStyle.test.ts new file mode 100644 index 000000000..658aa1b97 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/gameRunToolbarActionsStyle.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs'; + +import { describe, expect, test } from 'vitest'; + +import { repoPath } from './repoPath'; +import { + declaration, + parseStyleSheet, + resolveDeclarations, +} from './styleCascade'; + +/** + * 运行页顶栏动作区的声明级断言。 + * + * jsdom 不加载全局样式表,所以这里按仓库既有做法(`styleCascade`)解析真实生效的声明: + * 「版本入口与「在浏览器打开」跟其他动作按钮同一套皮」只由样式决定,用例必须钉在声明上, + * 否则下一次改动给版本入口单开一套 border / background / hover 时没有任何东西会红。 + */ +const GLOBAL_CSS_PATH = repoPath('apps/ai-game-creator-shell/src/styles.css'); +const rules = parseStyleSheet(readFileSync(GLOBAL_CSS_PATH, 'utf8')); +// 同一个求值器限制:全局表里也有 `prefers-reduced-motion` 档,求值时先排除掉。 +const widthRules = rules.filter( + (rule) => !(rule.media ?? '').includes('prefers-reduced-motion'), +); + +function resolved(selectors: readonly string[]): Map { + return resolveDeclarations(widthRules, selectors, 1280); +} + +function hasRule(selector: string): boolean { + return widthRules.some((rule) => rule.selectors.includes(selector)); +} + +describe('运行页顶栏动作区样式', () => { + test('运行画面回到两行,页面里不再有预览地址状态行', () => { + const surface = resolved(['.game-run-surface']); + expect(declaration(surface, 'grid-template-rows')).toBe( + 'minmax(300px, 1fr) auto', + ); + // 状态行与那行小字整体退役:地址不再以文字常驻在页面上。 + expect(hasRule('.game-run-status-bar')).toBe(false); + expect(hasRule('.game-run-status-hint')).toBe(false); + }); + + test('版本入口住进顶栏动作区,皮由那一组的基础规则给', () => { + // 动作区的按钮共用一套基础外观(这一组是「在浏览器打开」与版本入口的共同来源)。 + const actionsButton = resolved(['.game-workbench-view-actions button']); + expect(declaration(actionsButton, 'min-height')).toBe('30px'); + expect(declaration(actionsButton, 'border-radius')).toBe('999px'); + expect(declaration(actionsButton, 'font-size')).toBe('12px'); + expect(declaration(actionsButton, 'font-weight')).toBe('700'); + + // 版本入口只补「版本名可能很长」这一件事,不再自带边框 / 底色 / 文字色 / hover。 + const trigger = resolved(['.game-run-version-trigger']); + expect(trigger.has('border')).toBe(false); + expect(trigger.has('background')).toBe(false); + expect(trigger.has('color')).toBe(false); + expect(declaration(trigger, 'text-overflow')).toBe('ellipsis'); + expect(hasRule('.game-run-version-trigger:hover')).toBe(false); + expect(hasRule('.game-run-version-trigger:focus-visible')).toBe(false); + + // 版本入口不再绝对定位压在游戏画面上。 + const picker = resolved(['.game-run-version-picker']); + expect(picker.has('position')).toBe(false); + expect(picker.has('top')).toBe(false); + expect(picker.has('right')).toBe(false); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/runNoticeToast.test.tsx b/apps/ai-game-creator-shell/tests/runNoticeToast.test.tsx index 57e3909f2..f780ce59c 100644 --- a/apps/ai-game-creator-shell/tests/runNoticeToast.test.tsx +++ b/apps/ai-game-creator-shell/tests/runNoticeToast.test.tsx @@ -70,4 +70,26 @@ describe('运行 / 预览浮层提示', () => { }); expect(onDismiss).toHaveBeenCalledTimes(1); }); + + it('失败提示用错误色调,不需要调用方再拼一套文案', () => { + render( + , + ); + + 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(); + }); }); diff --git a/apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx b/apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx new file mode 100644 index 000000000..c92e5e6ea --- /dev/null +++ b/apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx @@ -0,0 +1,115 @@ +/** @vitest-environment jsdom */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import ProjectDevelopmentView from '../src/view/project-development'; + +const openUrl = vi.fn(async () => undefined); +vi.mock('@tauri-apps/plugin-opener', () => ({ + openUrl: (url: string) => openUrl(url), +})); + +const PREVIEW_URL = 'http://127.0.0.1:4173/'; + +function installInvoke() { + window.__TAURI__ = { + core: { + invoke: vi.fn(async (command: string, args?: Record) => { + 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( + 项目总控
} + onHomeOpen={vi.fn()} + onProjectsOpen={vi.fn()} + onNotice={onNotice} + />, + ); + return { onNotice }; +} + +afterEach(() => { + document.body.innerHTML = ''; + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +describe('运行页「在浏览器打开」', () => { + it('点顶栏那枚按钮就把当前预览地址交给系统浏览器', async () => { + renderRunView(); + + const entry = await screen.findByRole('button', { + name: '在浏览器打开', + }); + fireEvent.click(entry); + + await waitFor(() => expect(openUrl).toHaveBeenCalledWith(PREVIEW_URL)); + // 成功不弹提示:浏览器真的起来了,用户自己看得见。 + expect(openUrl).toHaveBeenCalledTimes(1); + }); + + it('没有活预览时不渲染这枚入口', async () => { + renderRunView({ withPreview: false }); + + await screen.findByRole('tab', { name: '运行' }); + expect(screen.queryByRole('button', { name: '在浏览器打开' })).toBeNull(); + }); + + it('打开失败时把原因交给统一提示通道,而不是静默吞掉', async () => { + openUrl.mockRejectedValueOnce(new Error('permission denied')); + const { onNotice } = renderRunView(); + + fireEvent.click( + await screen.findByRole('button', { name: '在浏览器打开' }), + ); + + await waitFor(() => + expect(onNotice).toHaveBeenCalledWith({ + tone: 'error', + message: '在浏览器打开失败:permission denied', + }), + ); + }); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 3acce6b0b..3943acd3c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9222,7 +9222,17 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-09-22 运行页收口:状态行两条信息同在画面外、生成任务入口不进运行页 +> 状态行与预览地址小字已被同日下一条取代(地址改顶栏按钮、版本入口并入顶栏);「生成任务不进运行页」这条仍然有效。 + - 背景:运行页的版本入口是绝对定位压在游戏画面上(`top: 20px; right: 20px`),预览地址小字在画面外,两者既不对齐又各挡一块画面;右上角还叠着「生成任务」开关(或展开的面板)。 - 决策:运行区域上方新增状态行 `.game-run-status-bar`,左边预览地址小字(单行省略 + `title` 给全量地址),右边版本入口,两者同在游戏画面**之外**、同一层级(版本入口去掉绝对定位,靠 `margin-left: auto` 贴右)。运行页不再挂 `ResourceCanvasAssetGenerationTasksPanelView`:入口、面板、锚点都不渲染,`[data-generation-tasks-placement='run']` 那一档坐标与组件 `placement` 联合类型里的 `run` 一并删除;任务不丢,切回资源页即可见。 - 影响面:`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`src/styles.css`、`src/features/resource-canvas/{ResourceCanvasAssetGenerationTasksPanelView.tsx,resourceCanvasAssetGenerationTasksSidebar.css}`;用例新增 `tests/gameRunStatusBarStyle.test.ts`,并改 `tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts`、`tests/resourceCanvasAssetGenerationTasksPanel.test.tsx`、`tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx`、`tests/appSurface/project-development.suite.ts`(原来钉着「入口在运行页签常驻」的那条按新口径改写)。 - 验证:`appSurface` 全量 539 通过 / 17 跳过、0 失败;AGC 壳目录全量 170 文件 2150 通过 / 22 跳过;`npm run agc:typecheck`、`npm run check:native-shells:contract`、编码检查与 `git diff --check` 通过;真机观感未复验。 + +## 2026-09-22 运行页顶栏收口:预览地址改按钮、版本入口并入顶栏同一套皮 + +- 背景:上一条把预览地址做成画面外的一行小字、版本入口挪到它旁边,但仍是「画面外一条常驻文字 + 顶栏另一套控件皮」:长 URL 既占位又不可点,版本入口与「打开项目目录 / 点选素材」也不像同一套控件。 +- 决策:① 运行区域上方那条状态行(`.game-run-status-bar` / `.game-run-status-hint`)整体删除,运行画面回到两行栅格;② 预览地址不再以文字出现,改成顶栏动作区里的一枚「在浏览器打开」按钮(opener 插件的 `openUrl`,`opener:default` 已放行 `http://127.0.0.1:*`),只在有活预览时渲染;③ 版本入口搬进同一个顶栏动作区,外观直接由 `.game-workbench-view-actions button` 给,不再自带 border / background / color / hover——`.game-run-version-picker` 也不再是绝对定位浮层。 +- 边界:预览的**启动与切换**仍然只走内置运行画面、不自动开系统浏览器(`scripts/check-native-shells.mjs` 那条负向守卫保持原样,本轮只补「入口只有顶栏这一枚按钮」的正向断言);「在浏览器打开」失败时把原因交给 `onNotice` 弹失败色 toast,不写进对话区、也不在工具条里另造提示位。 +- 影响面:`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`src/styles.css`、`src/features/app-shell/{model.ts,RunNoticeToast.tsx,WorkspaceLauncher.tsx}`(`onRunNotice` 改成带 `tone` 的对象)、`src/App.tsx`(四处调用点);用例新增 `tests/runPreviewBrowserOpen.test.tsx`、`tests/gameRunToolbarActionsStyle.test.ts`,删除 `tests/gameRunStatusBarStyle.test.ts`,并改 `tests/runNoticeToast.test.tsx`、`tests/appSurface/project-development.suite.ts`。 +- 验证:`appSurface` 全量 539 通过 / 17 跳过、0 失败;AGC 壳目录全量 171 文件 2153 通过 / 22 跳过;`npm run typecheck`、`npm run check:native-shells:contract`、编码检查与 `git diff --check` 通过;真机观感未复验。 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 59737f21e..8f1dda5a9 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -2741,6 +2741,14 @@ function assertAiGameCreatorShellUserDevBoundary() { ); } } + /* + * 预览**不自动**开系统浏览器:启动与切换运行视图只走内置画面,两条老路必须一直挡着 + * (App.tsx 里的 `openPreviewInExternalBrowser` / 前端直调 `open_local_game_preview` / + * Rust 侧 `.open_url(`)。 + * + * 用户主动点「在浏览器打开」是另一回事:它走 opener 插件的 `openUrl`,入口只有顶栏那一枚 + * 按钮(见下面那条正向断言)。别把这条负向守卫推广成「任何地方都不许出现 openUrl」。 + */ if ( aiGameCreatorShellAppSource.includes('openPreviewInExternalBrowser') || aiGameCreatorShellAppSource.includes('open_local_game_preview') || @@ -2750,6 +2758,19 @@ function assertAiGameCreatorShellUserDevBoundary() { 'AI game creator preview must not invoke the external browser', ); } + for (const snippet of [ + "'@tauri-apps/plugin-opener'", + '在浏览器打开', + 'await openUrl(embeddedPreviewUrl)', + ]) { + if ( + !sourceIncludesSnippet(aiGameCreatorProjectDevelopmentSource, snippet) + ) { + throw new Error( + `AI game creator in-browser preview entry drifted: missing ${snippet}`, + ); + } + } if ( aiGameCreatorShellTauriSource.includes('fn open_developer_window(') || aiGameCreatorShellTauriSource.includes( -- 2.52.0 From 6dde321bd1c84b1175b9aa5bbc8f771306a6f36f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 22 Sep 2026 13:51:30 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E4=BF=AE=20review=20=E5=8F=91=E7=8E=B0?= =?UTF-8?q?=EF=BC=9A=E8=BF=90=E8=A1=8C=E5=8F=8D=E9=A6=88=E5=85=A8=E9=9D=A2?= =?UTF-8?q?=E9=80=80=E5=87=BA=E5=AF=B9=E8=AF=9D=E5=8C=BA=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E9=A1=B6=E6=A0=8F=E4=B8=8E=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - executeRunLocal 的三条失败路径(缺 Tauri / 缺项目 / 启动预览报错)也改走 onRunNotice 失败色提示,对话区不再出现过程行;随之删除已无调用方的 announceProjectChatMessage 与不再有意义的 announceToChat 参数 - RunNoticeToast 的失败提示延长到 6 秒(成功仍 2.6 秒),避免错误一闪而过 - 版本入口在 UI 编辑器壳里不渲染;版本名改由一层 span 承载省略号,长版本名不再被硬裁 - 视图拿不到 onNotice 时把「在浏览器打开失败」写进 console,不再静默吞掉 - check-native-shells 增加「运行页视图里 openUrl( 只有一个调用点」的判据,堵住自动开浏览器的口子 - 新增外壳级接线用例 runNoticeShellWiring;previewActivation 补「启动失败走失败色提示且不写对话区」;runNoticeToast 补失败时长;样式守卫补省略号 - decision-log 收敛到最终口径(成功与失败都出对话区、失败留 6 秒、编辑器不挂版本入口) --- apps/ai-game-creator-shell/src/App.tsx | 48 +++--- .../src/features/app-shell/RunNoticeToast.tsx | 16 +- .../resource-canvas/GameRunVersionPicker.tsx | 9 +- apps/ai-game-creator-shell/src/styles.css | 9 + .../src/view/project-development/index.tsx | 26 ++- .../tests/gameRunToolbarActionsStyle.test.ts | 7 +- .../tests/previewActivation.test.tsx | 26 ++- .../tests/runNoticeShellWiring.test.tsx | 158 ++++++++++++++++++ .../tests/runNoticeToast.test.tsx | 25 +++ .../shared-memory/decision-log.md | 7 +- scripts/check-native-shells.mjs | 12 ++ 11 files changed, 298 insertions(+), 45 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/runNoticeShellWiring.test.tsx diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index ee89f83bd..a0908ceb2 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -806,9 +806,7 @@ export function App({ const agentRuntimeResumeProjectPathRef = useRef(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() { @@ -1309,7 +1307,7 @@ export function App({ } handledPlayRequestRef.current = requestKey; onPlayRequestHandled?.(playRequest.requestId); - void executeRunLocalRef.current(true); + void executeRunLocalRef.current(); }, [localProject?.projectPath, onPlayRequestHandled, playRequest]); /** @@ -1641,20 +1639,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, @@ -1760,19 +1744,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; } @@ -1797,12 +1780,19 @@ export function App({ if (!directProjectMode) { void refreshAgentRunTrace(nextProjectPath); } + /* + * 成功与失败都走工作台壳的 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) + }`, + }); } } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx b/apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx index 39ad520ea..85e4cc298 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx @@ -4,7 +4,16 @@ import { createPortal } from 'react-dom'; import type { ProjectRunNotice } from './model'; -const RUN_NOTICE_MILLIS = 2600; +/** + * 成功提示一闪而过就够;失败提示要留得够久,用户得看清是什么没跑起来。 + * + * 「运行 / 预览失败」这类错误已经不再写对话区(那里只保留对话内容),所以这枚 toast 是它 + * 唯一的出口——2.6 秒对错误太短。 + */ +const RUN_NOTICE_MILLIS: Record<'success' | 'error', number> = { + success: 2600, + error: 6000, +}; export type RunNotice = ProjectRunNotice & { /** 每次提示自增,保证重复触发同一个文案时也会重新弹一次。 */ @@ -28,7 +37,10 @@ export function RunNoticeToast({ if (!notice) { return; } - const timer = window.setTimeout(onDismiss, RUN_NOTICE_MILLIS); + const timer = window.setTimeout( + onDismiss, + RUN_NOTICE_MILLIS[notice.tone ?? 'success'], + ); return () => { window.clearTimeout(timer); }; diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx index 65a739217..bc5d79f0f 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx @@ -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`)。 + */} + + {formatIterationVersionLabel(currentVersion)} + {open ? createPortal( diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index aabbbe57c..edadfe84f 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -8128,6 +8128,15 @@ iframe.preview-frame { .game-run-version-trigger { max-width: min(18rem, 60vw); + 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; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 541154570..347703b5d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -2734,7 +2734,12 @@ export default function ProjectDevelopmentView({ try { await openUrl(embeddedPreviewUrl); } catch (error) { - onNotice?.({ + // 没有提示通道时(测试挂载、未来宿主)至少留下排查痕迹,不静默吞掉。 + if (!onNotice) { + console.error('[agc] 在浏览器打开失败', error); + return; + } + onNotice({ tone: 'error', message: `在浏览器打开失败:${ error instanceof Error ? error.message : String(error) @@ -9788,13 +9793,18 @@ export default function ProjectDevelopmentView({ {/* C7 版本入口:与「打开项目目录 / 在浏览器打开」同处顶栏动作区,外观也走同一套 (基础规则在 `.game-workbench-view-actions button`)。它不再浮在运行画面上: - 以前是绝对定位压在画面右上角,挡画面且与左侧的小字不对齐。 + 以前是绝对定位压在画面右上角,挡画面且与预览地址不对齐。 + + **UI 编辑器壳里不渲染**:那一页是聚焦编辑某个资源的界面,顶栏只留通用动作; + 版本入口服务于资源画布与运行页(`@` 面板按当前版本取素材),编辑器不需要它。 */} - + {uiEditorRoute ? null : ( + + )}
{/* 布局状态提示**不进动作行**:它是一段随保存过程变长的文案(空 →「保存中」→ @@ -10989,7 +10999,7 @@ export default function ProjectDevelopmentView({ 它**不**参与 `isResourceCanvasFloatingPanelOpen` 的模态遮挡判据——生成在后台跑, 侧栏展开时画布必须照样能看能用;折叠只影响这个视图,任务本身活在账本与本地队列里。 - **运行表现层不挂它**:运行页要留给游戏画面,右上是预览地址小字与版本入口, + **运行表现层不挂它**:运行页要留给游戏画面,右上角是版本入口与「在浏览器打开」, 再叠一枚任务开关(或展开的面板)就会压在画面上。任务不会因此丢,切回资源页即可见。 */} {mode === 'run' ? null : ( diff --git a/apps/ai-game-creator-shell/tests/gameRunToolbarActionsStyle.test.ts b/apps/ai-game-creator-shell/tests/gameRunToolbarActionsStyle.test.ts index 658aa1b97..85cdf0265 100644 --- a/apps/ai-game-creator-shell/tests/gameRunToolbarActionsStyle.test.ts +++ b/apps/ai-game-creator-shell/tests/gameRunToolbarActionsStyle.test.ts @@ -56,7 +56,12 @@ describe('运行页顶栏动作区样式', () => { expect(trigger.has('border')).toBe(false); expect(trigger.has('background')).toBe(false); expect(trigger.has('color')).toBe(false); - expect(declaration(trigger, 'text-overflow')).toBe('ellipsis'); + expect(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); diff --git a/apps/ai-game-creator-shell/tests/previewActivation.test.tsx b/apps/ai-game-creator-shell/tests/previewActivation.test.tsx index 4ce3279b0..68a7af95a 100644 --- a/apps/ai-game-creator-shell/tests/previewActivation.test.tsx +++ b/apps/ai-game-creator-shell/tests/previewActivation.test.tsx @@ -45,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, @@ -64,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); @@ -164,4 +170,22 @@ describe('运行入口切到已经在跑的客户端预览', () => { '运行通过,已载入客户端运行视图', ); }); + + 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('预览端口被占用'); + }); }); diff --git a/apps/ai-game-creator-shell/tests/runNoticeShellWiring.test.tsx b/apps/ai-game-creator-shell/tests/runNoticeShellWiring.test.tsx new file mode 100644 index 000000000..04209b358 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/runNoticeShellWiring.test.tsx @@ -0,0 +1,158 @@ +/** @vitest-environment jsdom */ + +/** + * 运行提示的外壳级接线:`ProjectChat` 发一条提示,工作台壳真的把它渲染成浮层。 + * + * 单测(`runNoticeToast`)只证明组件本身,`previewActivation` 只证明 App 会调这条通道; + * 从「通道被调用」到「用户看到 toast」之间还差一层壳的接线(prop 名、handler、portal 挂点、 + * tone 传递)。这里用替身聊天把这一层单独钉住:以后有人把 `` 挪进条件 + * 分支、或在传 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 ( +
+ + +
+ ); +} + +/** + * 打开工作台所需的最小命令集照抄 `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) => { + 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( + , + ); + 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( + '运行游戏失败:预览端口被占用', + ); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/runNoticeToast.test.tsx b/apps/ai-game-creator-shell/tests/runNoticeToast.test.tsx index f780ce59c..3739e17bf 100644 --- a/apps/ai-game-creator-shell/tests/runNoticeToast.test.tsx +++ b/apps/ai-game-creator-shell/tests/runNoticeToast.test.tsx @@ -92,4 +92,29 @@ describe('运行 / 预览浮层提示', () => { screen.getByText('在浏览器打开失败:permission denied'), ).not.toBeNull(); }); + + it('失败提示比成功提示留得久:2.6 秒不该把错误收走', () => { + vi.useFakeTimers(); + const onDismiss = vi.fn(); + render( + , + ); + + act(() => { + vi.advanceTimersByTime(2_600); + }); + expect(onDismiss).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(3_400); + }); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8070d8a78..d3941ccdd 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9316,9 +9316,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-09-22 运行页收口:过程提示退出对话区,顶栏统一承载运行入口 - 背景:点播放(以及历史上 `/preview`、`/open-preview`、生成后自动启动预览)都会往对话区写一条 assistant 提示(`运行通过,已载入客户端运行视图:http://127.0.0.1:63155/` 这类)。它常驻对话底部遮挡运行画面,也让对话区混进非对话内容;运行页本身还有三处遮挡与两套皮:预览地址是一行常驻小字(不可点)、版本入口是绝对定位压在画面右上角的浮层、右上角还叠着「生成任务」开关。 -- 决策(对话区只留对话内容):运行 / 预览成功的反馈不再进对话区,改走工作台壳的 toast——`ProjectChatComponentProps.onRunNotice` → `RunNoticeToast`(2.6 秒自动收起,同一句连续触发重新计时,`tone` 决定成功 / 失败观感)。失败(启动预览失败、在浏览器打开失败)也走这条通道,不再往对话区写过程行。 -- 决策(运行页顶栏是唯一入口):运行区域上方的状态行与预览地址小字整体退役(运行画面回到两行栅格);预览地址改成顶栏动作区里的一枚「在浏览器打开」按钮(opener 插件的 `openUrl`,只在有活预览时渲染);版本入口搬进同一个顶栏动作区,外观复用 `.game-workbench-view-actions button` 的基础规则,不再自带边框 / 底色 / hover,也不再是绝对定位浮层。 +- 决策(对话区只留对话内容):运行 / 预览的反馈不再进对话区,**成功与失败都**走工作台壳的 toast——`ProjectChatComponentProps.onRunNotice` → `RunNoticeToast`(同一句连续触发重新计时;成功 2.6 秒收起,失败 6 秒收起,`tone` 决定观感)。失败三处:启动预览报错(`运行游戏失败:…`)、缺 Tauri / 缺项目这两条前置条件、以及「在浏览器打开失败」。原先承载这些文案的 `announceProjectChatMessage` 已无调用方,连同删除。 +- 与上一条的关系(2026-09-21「预览激活回接运行入口」):那条接线决策(先问 Rust 要活体预览、命中就只切视图不重启)原样保留;被本次改掉的只是它当时为这条链路选的**播报渠道**——`经 DirectProjectChatHandle.announce 说一句「已切换到客户端运行视图:」` 按验收反馈(提示常驻对话底部遮挡运行画面)改成 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/gameRunToolbarActionsStyle.test.ts`,改写 `tests/previewActivation.test.tsx`(原断言「聊天里出现已载入运行视图」的地方改为断言 toast 通道 + 对话容器里没有这类提示)与 `tests/appSurface/project-development.suite.ts` 的生成任务入口用例。 +- 影响面:`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` 全绿;真机观感未复验。 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 59e4d54fb..23b20818d 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -2695,6 +2695,18 @@ function assertAiGameCreatorShellUserDevBoundary() { ); } } + /* + * 上面那条负向守卫只盯着 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( -- 2.52.0 From 04346479789abdc459e238e4ee9045c7115e8dee Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 22 Sep 2026 16:13:10 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BF=AB=E9=80=9F?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E8=8D=89=E7=A8=BF=E5=9B=9E=E5=A1=AB=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E7=9A=84=E7=AB=9E=E6=80=81=EF=BC=9A=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E5=85=88=E5=87=BA=E7=8E=B0=E3=80=81Lexical=20=E7=9A=84?= =?UTF-8?q?=E5=80=BC=E5=90=8E=E8=90=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 同一条用例在 CI run 2702 与本地一次全量里各命中过一次「expected '' to include '把角色头发改成红色'」:面板(dialog)一出现就同步读 textContent,编辑器值还没落 - 回填类断言统一改走 expectPanelPromptContains(内部 waitFor),不再赌时序 - 「提交后重开是空面板」那条否定断言改用 composerValue 读(它先让编辑器落值),避免空串让断言空过 - 连跑该文件 9 次 + 全量 3 次(173 文件 / 1826 通过 / 18 跳过)均全绿 --- .../resourceCanvasQuickEditDraft.test.tsx | 44 +++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx index b9383a561..c98db582b 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx @@ -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( -- 2.52.0