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(