From 6dde321bd1c84b1175b9aa5bbc8f771306a6f36f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 22 Sep 2026 13:51:30 +0800 Subject: [PATCH] =?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(