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(