diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index fb1a1d91b..967bf3eb5 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1941,6 +1941,7 @@ for (const snippet of [ '官方账号服务(固定)', 'runtime_config.save', "'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'", + '已载入客户端运行视图', 'async function executeRunLocal', 'function needsInitializedChatProject', "'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'", diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 954d67987..7884f201f 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -184,6 +184,7 @@ type AppProps = { metadata?: ProjectManifestSnapshotMetadata, ) => void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; + onRunNotice?: ProjectChatComponentProps['onRunNotice']; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], ) => void; @@ -216,6 +217,7 @@ export function App({ onPlayRequestHandled, onManifestChange, onPreviewChange, + onRunNotice, onAgentRuntimeSummariesChange, onAgentResultsChange, }: AppProps = {}) { @@ -812,9 +814,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() { @@ -1388,7 +1388,7 @@ export function App({ } handledPlayRequestRef.current = requestKey; onPlayRequestHandled?.(playRequest.requestId); - void executeRunLocalRef.current(true); + void executeRunLocalRef.current(); }, [localProject?.projectPath, onPlayRequestHandled, playRequest]); /** @@ -1720,20 +1720,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, @@ -1839,19 +1825,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; } @@ -1862,11 +1847,9 @@ export function App({ ); if (activePreview) { updateClientPreview(activePreview); - if (announceToChat) { - announceProjectChatMessage( - `已切换到客户端运行视图:${activePreview.url}`, - ); - } + // 运行成功的反馈走工作台壳的 toast(对话区只保留对话内容),因此不再往聊天里 + // 写一条「已切换到客户端运行视图:URL」。 + onRunNotice?.({ message: '已载入客户端运行视图' }); return; } const previewResult = await invoke( @@ -1878,16 +1861,19 @@ export function App({ if (!directProjectMode) { void refreshAgentRunTrace(nextProjectPath); } - if (announceToChat) { - announceProjectChatMessage( - `运行通过,已载入客户端运行视图:${previewResult.url}`, - ); - } + /* + * 成功与失败都走工作台壳的 toast,对话区不再承载这条过程反馈,所以这两处不受 + * `announceToChat` 约束(它是旧的「聊天播报」开关)。当前唯一调用点由播放请求驱动、 + * 恒为 true(见 `executeRunLocalRef.current(true)`)。 + */ + onRunNotice?.({ message: '运行通过,已载入客户端运行视图' }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (announceToChat) { - announceProjectChatMessage(message); - } + onRunNotice?.({ + tone: 'error', + message: `运行游戏失败:${ + error instanceof Error ? error.message : String(error) + }`, + }); } } 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..85e4cc298 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/RunNoticeToast.tsx @@ -0,0 +1,70 @@ +import { PlatformRuntimeStatusToast } from '@genarrative/shared/components'; +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; + +import type { ProjectRunNotice } from './model'; + +/** + * 成功提示一闪而过就够;失败提示要留得够久,用户得看清是什么没跑起来。 + * + * 「运行 / 预览失败」这类错误已经不再写对话区(那里只保留对话内容),所以这枚 toast 是它 + * 唯一的出口——2.6 秒对错误太短。 + */ +const RUN_NOTICE_MILLIS: Record<'success' | 'error', number> = { + success: 2600, + error: 6000, +}; + +export type RunNotice = ProjectRunNotice & { + /** 每次提示自增,保证重复触发同一个文案时也会重新弹一次。 */ + id: number; +}; + +/** + * 运行 / 预览类动作的浮层提示。 + * + * 这类过程反馈以前以 assistant 消息写进对话区,会一直堆在对话底部挡住运行画面; + * 现在统一走 toast,对话区只保留对话内容——运行页的预览地址改用顶栏的「在浏览器打开」。 + */ +export function RunNoticeToast({ + notice, + onDismiss, +}: { + notice: RunNotice | null; + onDismiss: () => void; +}) { + useEffect(() => { + if (!notice) { + return; + } + const timer = window.setTimeout( + onDismiss, + RUN_NOTICE_MILLIS[notice.tone ?? 'success'], + ); + return () => { + window.clearTimeout(timer); + }; + }, [notice, onDismiss]); + + if (!notice) { + return null; + } + return createPortal( +
+ + {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 d9c3e4a62..809e9b88c 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 @@ -42,8 +42,9 @@ import { projectPathsMatchForInvalidation } from '../project-summary/projectPath import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog'; import { useTemplateLibrary } from '../template-library/useTemplateLibrary'; import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet'; -import type { WorkspaceLauncherShellProps } from './model'; +import type { ProjectRunNotice, WorkspaceLauncherShellProps } from './model'; import { NonEmptyProjectDialog, ProjectsPage } from './ProjectCreation'; +import { type RunNotice, RunNoticeToast } from './RunNoticeToast'; import { useAccountWallet } from './useAccountWallet'; import { DESIGN_ARTIFACTS_BUILD_PROMPT, @@ -79,6 +80,13 @@ export function WorkspaceLauncherShell({ title: string; message: string; } | null>(null); + /** + * 运行 / 预览类动作的浮层提示。 + * + * 这类过程反馈不进对话区(见 `ProjectChatComponentProps.onRunNotice`), + * `id` 每次自增,保证同一句文案连续触发时也会重新弹一次。 + */ + const [runNotice, setRunNotice] = useState(null); // 「做成游戏」切换记录按项目上下文(路径 + createdAt)定位。运行模式必须随 // currentProjectContext 同步派生,不能靠 effect 后置修正:首帧挂错 lane 会先 // 以游戏运行时挂载并消耗首轮 claim,重挂后的策划实例再也发不出首轮。 @@ -600,6 +608,10 @@ export function WorkspaceLauncherShell({ ); }, []); + const handleRunNotice = useCallback((notice: ProjectRunNotice) => { + setRunNotice((current) => ({ id: (current?.id ?? 0) + 1, ...notice })); + }, []); + function showLauncherNotice(title: string) { setLauncherNotice({ title, @@ -825,6 +837,7 @@ export function WorkspaceLauncherShell({ currentProjectContext.projectPath, ) } + onNotice={handleRunNotice} onManifestChange={syncActiveProjectManifest} onHomeOpen={() => setLauncherView('home')} onProjectsOpen={() => setLauncherView('projects')} @@ -862,6 +875,7 @@ export function WorkspaceLauncherShell({ onPlayRequestHandled={handlePlayRequestHandled} onManifestChange={syncActiveProjectManifest} onPreviewChange={setActiveProjectPreview} + onRunNotice={handleRunNotice} onAgentRuntimeSummariesChange={ setActiveProjectAgentRuntimeSummaries } @@ -905,6 +919,7 @@ export function WorkspaceLauncherShell({ ) : isWindowChrome ? null : ( )} + setRunNotice(null)} /> {launcherNotice ? (
void; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; + /** + * 运行 / 预览类动作的一次性浮层提示。 + * + * 「跑起来了」「已切到运行视图」「在浏览器打开失败」属于过程反馈,不进对话区—— + * 对话区只保留对话内容。工作台壳收到后弹 toast,`tone` 决定成功还是失败观感。 + */ + onRunNotice?: (notice: ProjectRunNotice) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], ) => void; 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/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 3ef53ec8f..56345e650 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -8468,8 +8468,8 @@ iframe.preview-frame { display: grid; grid-template-rows: minmax(300px, 1fr) auto; grid-row: 2 / -1; - /* 与右上角任务锚点同格(见 resourceCanvasAssetGenerationTasksSidebar.css):显式钉第 1 列, - 避免画布被自动列放置挤到隐式列里。 */ + /* 显式钉第 1 列(见 resourceCanvasAssetGenerationTasksSidebar.css),避免画面被自动列放置 + 挤到隐式列里。 */ grid-column: 1; gap: 12px; height: 100%; @@ -8478,39 +8478,35 @@ iframe.preview-frame { background: transparent; } -/* C7 版本入口:运行模块右上角,没有版本时不渲染。 */ +/* + * C7 版本入口:住在工作台顶栏动作区(`.game-workbench-view-actions`),外观由那一组的基础 + * 规则给(描边 + secondary 填充 + 12px/700),这里只补「版本名可能很长」这一件事。 + * + * 它曾经是运行画面里的绝对定位浮层(top 20px / right 20px):压在游戏画面上,还与顶栏其他 + * 按钮分成两套皮。**不要再给它加 border / background / color / hover**——那就是第二套皮。 + */ .game-run-version-picker { - position: absolute; - top: 20px; - right: 20px; - z-index: 5; + flex: 0 0 auto; + display: inline-flex; + align-items: center; } .game-run-version-trigger { - display: inline-flex; max-width: min(18rem, 60vw); - min-height: 30px; - align-items: center; - padding: 0 12px; - border: 1px solid #e5cfc4; - border-radius: 999px; - background: rgb(255 250 246 / 92%); - color: #8a4a30; - cursor: pointer; - font-size: 12px; - font-weight: 700; + min-width: 0; +} + +/* + * 版本名单独一层才省得掉:按钮是 flex 容器,文本直接挂在按钮上时 `text-overflow` + * 落在匿名 flex item 上、不生效(见 `GameRunVersionPicker` 里的注释)。 + */ +.game-run-version-trigger-label { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.game-run-version-trigger:hover, -.game-run-version-trigger:focus-visible { - border-color: #cc8060; - outline: 0; - box-shadow: 0 3px 10px rgb(158 87 57 / 14%); -} - .game-run-version-menu { display: grid; gap: 2px; 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 edf2bc0d5..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 @@ -16,10 +16,12 @@ import { } from '@genarrative/image-canvas-react'; import { CanvasCardCornerActions } from '@genarrative/shared/components'; import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog'; +import { openUrl } from '@tauri-apps/plugin-opener'; import { AtSign, Box, Crosshair, + ExternalLink, Eye, FileCode2, FileText, @@ -104,6 +106,7 @@ import { isFloatingOverlayWheelEvent, useImageCanvasFloatingOptionDismiss, } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; +import type { ProjectRunNotice } from '../../features/app-shell/model'; import { DesignWorkspacePanel } from '../../features/project-workspace/DesignWorkspacePanel'; import { LocalGamePreviewFrame, @@ -790,6 +793,13 @@ export type ProjectDevelopmentViewProps = { onPlay?: () => void; onMakeGame?: () => void; onRevealProjectDirectory?: () => void | Promise; + /** + * 过程提示(成功 / 失败)的唯一出口。 + * + * 「运行起来了」「在浏览器打开失败」这类反馈不进对话区,交给工作台壳弹 toast; + * 本视图不自己造第二套提示位。 + */ + onNotice?: (notice: ProjectRunNotice) => void; onManifestChange?: ( projectPath: string, manifest: GameCreationAppManifest, @@ -1795,6 +1805,7 @@ export default function ProjectDevelopmentView({ onPlay, onMakeGame, onRevealProjectDirectory, + onNotice, }: ProjectDevelopmentViewProps) { const [mode, setMode] = useState('resources'); const [runtimeInspectMode, setRuntimeInspectMode] = useState(false); @@ -2709,6 +2720,33 @@ export default function ProjectDevelopmentView({ const preview = previewOverride ?? manifest.preview ?? null; const embeddedPreviewUrl = resolveEmbeddedPreviewUrl(preview); + /** + * 「在浏览器打开」:把当前预览地址交给系统默认浏览器。 + * + * 这是**用户主动**动作。预览的启动与切换仍然只走内置运行画面、不自动开外部浏览器 + * (那条边界由 `scripts/check-native-shells.mjs` 的守卫钉着);失败时把原因交给 + * `onNotice` 弹 toast,不在工具条里另造一条提示位、也不写进对话区。 + */ + const openPreviewInBrowser = useCallback(async () => { + if (!embeddedPreviewUrl) { + return; + } + try { + await openUrl(embeddedPreviewUrl); + } catch (error) { + // 没有提示通道时(测试挂载、未来宿主)至少留下排查痕迹,不静默吞掉。 + if (!onNotice) { + console.error('[agc] 在浏览器打开失败', error); + return; + } + onNotice({ + tone: 'error', + message: `在浏览器打开失败:${ + error instanceof Error ? error.message : String(error) + }`, + }); + } + }, [embeddedPreviewUrl, onNotice]); const runAvailable = embeddedPreviewUrl !== null || manifest.tasks.some( @@ -9645,6 +9683,22 @@ export default function ProjectDevelopmentView({ {runtimeInspectMode ? '退出点选' : '点选素材'} ) : null} + {/* + 当前预览地址的出口。以前这里是一条「已载入客户端运行视图:http://…」小字, + 长地址既占位又不可点:地址本身对用户没用,能一步跳浏览器才有用。 + 只在有活预览时出现,外观与其他动作按钮同款(见 `.game-workbench-view-actions button`)。 + */} + {mode === 'run' && embeddedPreviewUrl ? ( + + ) : null} {mode === 'resources' && !uiEditorRoute ? ( <>
{/* 布局状态提示**不进动作行**:它是一段随保存过程变长的文案(空 →「保存中」→ @@ -10889,11 +10958,6 @@ export default function ProjectDevelopmentView({ ) : (
-
{embeddedPreviewUrl ? ( )} {/* - 「生成任务」侧栏:常驻在**工作面右上角**(资源画布 / 运行表现层 / 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 119b1463f..1d30225a8 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 @@ -8821,7 +8821,8 @@ export function registerProjectAgentStatusTests() { ); expect(within(reopened).getByText('待提交设计图')).not.toBeNull(); - // 入口在「运行」页签下同样常驻:侧栏本体在运行态可见,入口若只在资源页签就没法再打开。 + // 运行页签下**不挂**「生成任务」:那一页要留给游戏画面(顶栏是版本入口与「在浏览器打开」)。 + // 任务不会因此丢——切回资源页签,入口与面板都回来。 fireEvent.click( within(reopened).getByRole('button', { name: '关闭生成任务' }), ); @@ -8831,8 +8832,16 @@ export function registerProjectAgentStatusTests() { expect( screen.getByRole('tab', { name: '运行' }).getAttribute('aria-selected'), ).toBe('true'); + expect( + screen.queryByRole('button', { name: /^生成任务(?: · \d+)?$/ }), + ).toBeNull(); + expect( + document.querySelector('.game-resource-generation-tasks-anchor'), + ).toBeNull(); + + fireEvent.click(screen.getByRole('tab', { name: '资源管理' })); fireEvent.click( - screen.getByRole('button', { name: /^生成任务(?: · \d+)?$/ }), + await screen.findByRole('button', { name: /^生成任务(?: · \d+)?$/ }), ); expect( await screen.findByRole('region', { name: '生成任务' }), 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..85cdf0265 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/gameRunToolbarActionsStyle.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs'; + +import { describe, expect, test } from 'vitest'; + +import { repoPath } from './repoPath'; +import { + declaration, + parseStyleSheet, + resolveDeclarations, +} from './styleCascade'; + +/** + * 运行页顶栏动作区的声明级断言。 + * + * jsdom 不加载全局样式表,所以这里按仓库既有做法(`styleCascade`)解析真实生效的声明: + * 「版本入口与「在浏览器打开」跟其他动作按钮同一套皮」只由样式决定,用例必须钉在声明上, + * 否则下一次改动给版本入口单开一套 border / background / hover 时没有任何东西会红。 + */ +const GLOBAL_CSS_PATH = repoPath('apps/ai-game-creator-shell/src/styles.css'); +const rules = parseStyleSheet(readFileSync(GLOBAL_CSS_PATH, 'utf8')); +// 同一个求值器限制:全局表里也有 `prefers-reduced-motion` 档,求值时先排除掉。 +const widthRules = rules.filter( + (rule) => !(rule.media ?? '').includes('prefers-reduced-motion'), +); + +function resolved(selectors: readonly string[]): Map { + return resolveDeclarations(widthRules, selectors, 1280); +} + +function hasRule(selector: string): boolean { + return widthRules.some((rule) => rule.selectors.includes(selector)); +} + +describe('运行页顶栏动作区样式', () => { + test('运行画面回到两行,页面里不再有预览地址状态行', () => { + const surface = resolved(['.game-run-surface']); + expect(declaration(surface, 'grid-template-rows')).toBe( + 'minmax(300px, 1fr) auto', + ); + // 状态行与那行小字整体退役:地址不再以文字常驻在页面上。 + expect(hasRule('.game-run-status-bar')).toBe(false); + expect(hasRule('.game-run-status-hint')).toBe(false); + }); + + test('版本入口住进顶栏动作区,皮由那一组的基础规则给', () => { + // 动作区的按钮共用一套基础外观(这一组是「在浏览器打开」与版本入口的共同来源)。 + const actionsButton = resolved(['.game-workbench-view-actions button']); + expect(declaration(actionsButton, 'min-height')).toBe('30px'); + expect(declaration(actionsButton, 'border-radius')).toBe('999px'); + expect(declaration(actionsButton, 'font-size')).toBe('12px'); + expect(declaration(actionsButton, 'font-weight')).toBe('700'); + + // 版本入口只补「版本名可能很长」这一件事,不再自带边框 / 底色 / 文字色 / hover。 + const trigger = resolved(['.game-run-version-trigger']); + expect(trigger.has('border')).toBe(false); + expect(trigger.has('background')).toBe(false); + expect(trigger.has('color')).toBe(false); + expect(declaration(trigger, 'min-width')).toBe('0'); + // 省略号要真的生效:按钮是 flex 容器,文本必须挂在自带 overflow 的 span 上。 + const label = resolved(['.game-run-version-trigger-label']); + expect(declaration(label, 'overflow')).toBe('hidden'); + expect(declaration(label, 'text-overflow')).toBe('ellipsis'); + expect(declaration(label, 'white-space')).toBe('nowrap'); + expect(hasRule('.game-run-version-trigger:hover')).toBe(false); + expect(hasRule('.game-run-version-trigger:focus-visible')).toBe(false); + + // 版本入口不再绝对定位压在游戏画面上。 + const picker = resolved(['.game-run-version-picker']); + expect(picker.has('position')).toBe(false); + expect(picker.has('top')).toBe(false); + expect(picker.has('right')).toBe(false); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/previewActivation.test.tsx b/apps/ai-game-creator-shell/tests/previewActivation.test.tsx index c349dea8b..68a7af95a 100644 --- a/apps/ai-game-creator-shell/tests/previewActivation.test.tsx +++ b/apps/ai-game-creator-shell/tests/previewActivation.test.tsx @@ -12,6 +12,10 @@ * 只核对内存 registry 并回传 loopback 地址,活着就复用(不重启预览服务),没活着 * (stopped / 不属于这个项目 / 权限位要求确认)才回落到 `start_local_game_preview`。 * + * 成功反馈走工作台壳的 toast(`onRunNotice`),**不再**写进对话区:那条「已载入客户端 + * 运行视图:URL」以前常驻对话底部挡住运行画面。用例因此直接断言这条通道,并反过来钉住 + * 对话容器里没有这类提示。 + * * 这条行为过去只剩 `scripts/check-native-shells.mjs` 的源码守卫与 Rust 实现,前端没有 * 用例;一旦有人再把它当死链路删掉,守卫会先报「missing await invoke」而不知道现场。 */ @@ -41,7 +45,10 @@ function createFixtureManifest(): GameCreationAppManifest { * `activate_local_game_preview` 的替身由用例给定:它决定「这条预览还活着吗」, * 其余命令沿用聊天 harness,运行入口之外的链路保持真实形状。 */ -function installTauri(activateLocalGamePreview: () => unknown) { +function installTauri( + activateLocalGamePreview: () => unknown, + options: { startFails?: string } = {}, +) { const manifest = createFixtureManifest(); const chatHarness = createProjectChatRuntimeHarness({ projectPath: PROJECT_PATH, @@ -60,6 +67,9 @@ function installTauri(activateLocalGamePreview: () => unknown) { return activateLocalGamePreview(); } if (command === 'start_local_game_preview') { + if (options.startFails) { + throw new Error(options.startFails); + } return { url: PREVIEW_URL, port: 43210, root: PROJECT_PATH }; } return chatHarness.invoke(command, args); @@ -74,20 +84,28 @@ function installTauri(activateLocalGamePreview: () => unknown) { /** 「运行」入口由工作台壳的播放请求驱动,这里直接用同一形状的请求触发。 */ function renderRunningProjectChat() { - return render( + const onRunNotice = vi.fn(); + render( , ); + return { onRunNotice }; } -/** 运行结果由聊天自己的本地消息流展示,这里按对话容器文本断言。 */ -async function expectChatAnnouncement(text: string) { +/** 运行反馈走 toast;对话容器里不该再出现这类过程提示。 */ +async function expectRunNoticeWithoutChatTip( + onRunNotice: ReturnType, + message: string, +) { + await waitFor(() => expect(onRunNotice).toHaveBeenCalledWith({ message })); const surface = await screen.findByLabelText('陶泥儿项目对话'); - await waitFor(() => expect(surface.textContent ?? '').toContain(text)); + expect(surface.textContent ?? '').not.toContain('运行通过'); + expect(surface.textContent ?? '').not.toContain('已切换到客户端运行视图'); } beforeEach(() => { @@ -103,14 +121,14 @@ describe('运行入口切到已经在跑的客户端预览', () => { root: PROJECT_PATH, })); - renderRunningProjectChat(); + const { onRunNotice } = renderRunningProjectChat(); await waitFor(() => expect(invoke).toHaveBeenCalledWith('activate_local_game_preview', { projectPath: PROJECT_PATH, }), ); - await expectChatAnnouncement(`已切换到客户端运行视图:${PREVIEW_URL}`); + await expectRunNoticeWithoutChatTip(onRunNotice, '已载入客户端运行视图'); expect(invokeCounts.get('start_local_game_preview') ?? 0).toBe(0); }); @@ -122,15 +140,16 @@ describe('运行入口切到已经在跑的客户端预览', () => { root: null, })); - renderRunningProjectChat(); + const { onRunNotice } = renderRunningProjectChat(); await waitFor(() => expect(invokeCounts.get('start_local_game_preview') ?? 0).toBeGreaterThan( 0, ), ); - await expectChatAnnouncement( - `运行通过,已载入客户端运行视图:${PREVIEW_URL}`, + await expectRunNoticeWithoutChatTip( + onRunNotice, + '运行通过,已载入客户端运行视图', ); }); @@ -139,15 +158,34 @@ describe('运行入口切到已经在跑的客户端预览', () => { throw new Error('需要先确认预览权限'); }); - renderRunningProjectChat(); + const { onRunNotice } = renderRunningProjectChat(); await waitFor(() => expect(invokeCounts.get('start_local_game_preview') ?? 0).toBeGreaterThan( 0, ), ); - await expectChatAnnouncement( - `运行通过,已载入客户端运行视图:${PREVIEW_URL}`, + await expectRunNoticeWithoutChatTip( + onRunNotice, + '运行通过,已载入客户端运行视图', ); }); + + it('启动预览失败时走失败色提示,不再写进对话区', async () => { + installTauri( + () => ({ status: 'stopped', url: null, port: null, root: null }), + { startFails: '预览端口被占用' }, + ); + + const { onRunNotice } = renderRunningProjectChat(); + + await waitFor(() => + expect(onRunNotice).toHaveBeenCalledWith({ + tone: 'error', + message: '运行游戏失败:预览端口被占用', + }), + ); + const surface = await screen.findByLabelText('陶泥儿项目对话'); + expect(surface.textContent ?? '').not.toContain('预览端口被占用'); + }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx index c30d6d682..aabf0d912 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx @@ -356,23 +356,12 @@ describe('「生成任务」侧栏', () => { expect(onToggleOpen).toHaveBeenCalledTimes(1); }); - test('锚点按工作面分档:资源画布 / 运行表现层 / UI 编辑器各挂一档', () => { - const { container, rerender } = renderSidebar([], { placement: 'run' }); + test('锚点按工作面分档:资源画布 / UI 编辑器各挂一档', () => { + const { container, rerender } = renderSidebar([], { placement: 'editor' }); const placementOf = () => container .querySelector('.game-resource-generation-tasks-anchor') ?.getAttribute('data-generation-tasks-placement'); - expect(placementOf()).toBe('run'); - - rerender( - , - ); 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 bee83446f..b6c627d0b 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/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( 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 new file mode 100644 index 000000000..3739e17bf --- /dev/null +++ b/apps/ai-game-creator-shell/tests/runNoticeToast.test.tsx @@ -0,0 +1,120 @@ +/** @vitest-environment jsdom */ +import { act, cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { RunNoticeToast } from '../src/features/app-shell/RunNoticeToast'; + +const RUN_MESSAGE = '运行通过,已载入客户端运行视图'; + +function toastElement() { + return document.querySelector('[data-project-run-notice-toast="true"]'); +} + +describe('运行 / 预览浮层提示', () => { + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it('没有提示时不渲染任何浮层', () => { + render(); + + 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); + }); + + 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(); + }); + + 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/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..029445850 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/runPreviewBrowserOpen.test.tsx @@ -0,0 +1,119 @@ +/** @vitest-environment jsdom */ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import ProjectDevelopmentView from '../src/view/project-development'; + +const openUrl = vi.fn(async () => undefined); +vi.mock('@tauri-apps/plugin-opener', () => ({ + openUrl: (url: string) => openUrl(url), +})); + +const PREVIEW_URL = 'http://127.0.0.1:4173/'; + +function installInvoke() { + window.__TAURI__ = { + core: { + invoke: vi.fn(async (command: string, args?: Record) => { + 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: '在浏览器打开', + }); + // 入口住在工作台顶栏的动作区里,与版本入口同一排;预览地址本身不再以文字出现。 + expect(entry.closest('.game-workbench-view-actions')).not.toBeNull(); + expect(document.body.textContent ?? '').not.toContain(PREVIEW_URL); + expect(document.querySelector('.game-run-status-hint')).toBeNull(); + fireEvent.click(entry); + + await waitFor(() => expect(openUrl).toHaveBeenCalledWith(PREVIEW_URL)); + // 成功不弹提示:浏览器真的起来了,用户自己看得见。 + expect(openUrl).toHaveBeenCalledTimes(1); + }); + + it('没有活预览时不渲染这枚入口', async () => { + renderRunView({ withPreview: false }); + + await screen.findByRole('tab', { name: '运行' }); + expect(screen.queryByRole('button', { name: '在浏览器打开' })).toBeNull(); + }); + + it('打开失败时把原因交给统一提示通道,而不是静默吞掉', async () => { + openUrl.mockRejectedValueOnce(new Error('permission denied')); + const { onNotice } = renderRunView(); + + fireEvent.click( + await screen.findByRole('button', { name: '在浏览器打开' }), + ); + + await waitFor(() => + expect(onNotice).toHaveBeenCalledWith({ + tone: 'error', + message: '在浏览器打开失败:permission denied', + }), + ); + }); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9d32fe1c7..bcd2bc888 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9367,6 +9367,17 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`apps/ai-game-creator-shell/src/features/{project-workspace/resourceReferences.ts,project-workspace/ResourceReferenceInput.tsx,resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx,resource-canvas/resourceCanvasAssetGenerationTaskModel.ts,resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts}`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx` 与对应 6 个定向测试文件。 - 验证:定向 `resourceCanvasAssetGenerationReferences` / `resourceCanvasAssetGenerationBackgroundClose` / `resourceCanvasBottomToolbar` / `resourceCanvasGenerationFloatingPanel(Chrome)` / `resourceReferenceInput` / `resourceReferences` / `resourceCanvasAssetGenerationTasksPanel` 全绿;全量 `npm run test -- apps/ai-game-creator-shell/tests` 只剩 `clientHttp` / `clientApi` / `clientAuthStorage` / `projectCreationDirectory` / `recentProjectsHook` 五个 jsdom `localStorage` 环境用例红(与本次改动无调用关系);TS typecheck、`check:encoding`、`git diff --check` 通过。未复核真实客户端观感。 +## 2026-09-22 运行页收口:过程提示退出对话区,顶栏统一承载运行入口 + +- 背景:点播放(以及历史上 `/preview`、`/open-preview`、生成后自动启动预览)都会往对话区写一条 assistant 提示(`运行通过,已载入客户端运行视图:http://127.0.0.1:63155/` 这类)。它常驻对话底部遮挡运行画面,也让对话区混进非对话内容;运行页本身还有三处遮挡与两套皮:预览地址是一行常驻小字(不可点)、版本入口是绝对定位压在画面右上角的浮层、右上角还叠着「生成任务」开关。 +- 决策(对话区只留对话内容):运行 / 预览的反馈不再进对话区,**成功与失败都**走工作台壳的 toast——`ProjectChatComponentProps.onRunNotice` → `RunNoticeToast`(同一句连续触发重新计时;成功 2.6 秒收起,失败 6 秒收起,`tone` 决定观感)。失败三处:启动预览报错(`运行游戏失败:…`)、缺 Tauri / 缺项目这两条前置条件、以及「在浏览器打开失败」。原先承载这些文案的 `announceProjectChatMessage` 已无调用方,连同删除。 +- 与上一条的关系(2026-09-21「预览激活回接运行入口」):那条接线决策(先问 Rust 要活体预览、命中就只切视图不重启)原样保留;被本次改掉的只是它当时为这条链路选的**播报渠道**——`经 DirectProjectChatHandle.announce 说一句「已切换到客户端运行视图:」` 按验收反馈(提示常驻对话底部遮挡运行画面)改成 toast,「复用活体预览不重启」的判据与 `tests/previewActivation.test.tsx` 的三条路径不变。 +- 决策(运行页顶栏是唯一入口):运行区域上方的状态行与预览地址小字整体退役(运行画面回到两行栅格);预览地址改成顶栏动作区里的一枚「在浏览器打开」按钮(opener 插件的 `openUrl`,只在有活预览时渲染;`scripts/check-native-shells.mjs` 另加「视图里 `openUrl(` 只有一个调用点」的判据);版本入口搬进同一个顶栏动作区,外观复用 `.game-workbench-view-actions button` 的基础规则,不再自带边框 / 底色 / hover,也不再是绝对定位浮层。版本入口在**资源画布与运行页**显示,UI 编辑器壳里不渲染(那一页是聚焦编辑某个资源的界面)。版本名改用一层 span 承载省略号——按钮是 flex 容器,文本直接挂在按钮上时 `text-overflow` 不生效。 +- 决策(运行页不挂生成任务):`ResourceCanvasAssetGenerationTasksPanelView` 只在资源画布 / UI 编辑器出现,运行页的入口、面板与锚点都不渲染;`[data-generation-tasks-placement='run']` 那一档坐标与组件 `placement` 联合类型里的 `run` 一并删除。任务不丢,切回资源页即可见。 +- 边界:预览的**启动与切换**仍然只走内置运行画面、不自动开系统浏览器——`scripts/check-native-shells.mjs` 那条负向守卫保持原样,本轮只补「浏览器入口只有顶栏这一枚按钮」的正向断言。 +- 影响面:`apps/ai-game-creator-shell/src/{App.tsx,styles.css,view/project-development/index.tsx,features/app-shell/*,features/resource-canvas/*}`;用例新增 `tests/runPreviewBrowserOpen.test.tsx`、`tests/runNoticeToast.test.tsx`、`tests/runNoticeShellWiring.test.tsx`(外壳级:替身聊天发提示 → 壳真的渲染浮层)、`tests/gameRunToolbarActionsStyle.test.ts`,改写 `tests/previewActivation.test.tsx`(原断言「聊天里出现已载入运行视图」的地方改为断言 toast 通道 + 对话容器里没有这类提示,并补一条「启动失败走失败色提示且不写对话区」)与 `tests/appSurface/project-development.suite.ts` 的生成任务入口用例。 +- 验证:`appSurface` 全量、AGC 壳目录全量、`npm run typecheck`、`check:native-shells:contract`、`check:encoding`、`git diff --check` 全绿;真机观感未复验。 + ## 2026-09-22 DirectProject 聊天状态显式化:回合三态 + 「在跑吗」唯一派生入口 + 数据流地图 - 背景:DirectProject 聊天框只有三份真相源(`project.jsonl` 历史切片、Thread Manager 运行态事件、本地乐观消息),但「这一轮在跑吗」在四层里各叫一个名字——reducer 的 `turnRunning`、controller 的 `turnBusy`、视图里手拼的 `busy`、投影里的 `active`。定位发送后空窗缺陷时,读代码无法判断某个窗口期的界面表现是否有依据,也说不清谁该信谁。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 5ed02b885..b32659b50 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6001,6 +6001,14 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` `append_application_log_line` 在落盘前对整行做 `sanitize_diagnostic_message`:行内只要出现 `token=`、`bearer `、`authorization`、`credential`、`api key` / `apikey` / `api_key` 这类标记,**整行**就被换成 ``,只留下时间戳与 `RUST module:` 前缀;同时每行还会被截到 2048 字符。于是把“身份字段 + 诊断正文”拼成一行 `app_log!` 时,正文里一个凭据词就可能让整条记录连 `eventId`、`code` 一起消失(2026-09-21 加统一错误事件的日志投影时按两行落:身份行只放程序生成与调用方常量字段,summary / hint / detail 等自由文本一律只放详情行,且自由文本先自行压平换行——裸词标记脱敏消不掉,自由文本放错行会把 eventId、code 一起带走)。 +## 2026-09-22 本地 node_modules 里的内置 Codex 原生包过旧会让 AGC build script panic + +- **现象**:`npm run agc` 编到壳 crate 的 build script 时中止,stderr 是 `panicked at build.rs:103: Codex 原生包版本、布局或架构不匹配目标 x86_64-pc-windows-msvc`,stdout 只有 `cargo:rustc-env=AGC_BUILD_TARGET=...`。 +- **原因**:`build_support/codex_bundle.rs` 把随包 Codex CLI 钉在一个固定版本,而本地 `node_modules/@openai/codex-win32-x64/vendor//codex-package.json` 还停在上一次安装的旧版本。`package-lock.json` 早就升到新版本,缺的只是本地安装;这条判据只看版本元数据,文件齐全、架构正确也照样拦。 +- **处理(现行口径)**:在仓库根目录执行 `npm ci`(不要改成子目录或单包安装),随后 `node_modules/@openai/codex/package.json` 与 vendor 的 `codex-package.json` 版本应当一致。Windows 上 `npm ci` 会先 unlink 整个 `node_modules`:RustRover 的 Tailwind language server / `oxide-helper` 进程会占住 `@tailwindcss/oxide-*.node`,报 `EPERM: operation not permitted, unlink ...` 时先结束这些 helper 再重试,否则会停在半装状态。 +- **验证**:`npm ci` 后 `cargo build --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` 通过,不再触发该 panic。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/build.rs`、`apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs`、`package-lock.json`。 + ## 策划聊天不能按可选子节点序号分配消息高度 策划与开发 Agent 共用聊天类名,但布局合同不同。策划新增状态条后,按第二个子节点分配 `1fr` 会把空白给状态条、让消息框随回复增长;只给 `.is-direct-codex` 的状态样式也不会覆盖策划。策划使用纵向 Flex,仅消息列表伸缩,阶段/待处理区域限高滚动;改共用样式时同时核对两种入口。jsdom 交互通过不代表布局正确,须匹配完整 CSS 层叠并用真实浏览器核对空/短/长消息与长待办。窄屏上下堆叠必须在固定外壳内提供工作台滚动容器,两块面板明确限高;低优先级的 `height: auto` 不能覆盖外壳后代规则的 `height: 100%`。布局夹具必须包含窗口外壳与启动器层级,并实际滚动验证输入框可见可操作,不能只检查输入框在聊天面板内部。实时正文/思考不更新持久消息数组,滚动跟随必须覆盖这些独立状态并保留用户上滚门禁;历史消息缺失的时间不得用读取时刻填补。详见 AGC 实施计划的“策划 Agent 对话显示与滚动合同”。 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 220d5f0ee..23b20818d 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -2647,7 +2647,7 @@ function assertAiGameCreatorShellUserDevBoundary() { } for (const snippet of [ "await invoke(\n 'activate_local_game_preview'", - '已切换到客户端运行视图', + '已载入客户端运行视图', ]) { if (!sourceIncludesSnippet(aiGameCreatorShellAppSource, snippet)) { throw new Error( @@ -2665,6 +2665,14 @@ function assertAiGameCreatorShellUserDevBoundary() { ); } } + /* + * 预览**不自动**开系统浏览器:启动与切换运行视图只走内置画面,两条老路必须一直挡着 + * (App.tsx 里的 `openPreviewInExternalBrowser` / 前端直调 `open_local_game_preview` / + * Rust 侧 `.open_url(`)。 + * + * 用户主动点「在浏览器打开」是另一回事:它走 opener 插件的 `openUrl`,入口只有顶栏那一枚 + * 按钮(见下面那条正向断言)。别把这条负向守卫推广成「任何地方都不许出现 openUrl」。 + */ if ( aiGameCreatorShellAppSource.includes('openPreviewInExternalBrowser') || aiGameCreatorShellAppSource.includes('open_local_game_preview') || @@ -2674,6 +2682,31 @@ function assertAiGameCreatorShellUserDevBoundary() { 'AI game creator preview must not invoke the external browser', ); } + for (const snippet of [ + "'@tauri-apps/plugin-opener'", + '在浏览器打开', + 'await openUrl(embeddedPreviewUrl)', + ]) { + if ( + !sourceIncludesSnippet(aiGameCreatorProjectDevelopmentSource, snippet) + ) { + throw new Error( + `AI game creator in-browser preview entry drifted: missing ${snippet}`, + ); + } + } + /* + * 上面那条负向守卫只盯着 App.tsx 里的两条老路,而真正的浏览器出口在运行页视图里。 + * 这里再补一条「调用点只有一个」的判据:视图里冒出第二个 `openUrl(`(自动跳浏览器、 + * 或者拿它去开任意地址)时必须先显式改这条守卫,而不是顺手加一行。 + */ + const openUrlCallCount = + aiGameCreatorProjectDevelopmentSource.split('openUrl(').length - 1; + if (openUrlCallCount !== 1) { + throw new Error( + `AI game creator must keep exactly one user-clicked browser-open call in the run view (found ${openUrlCallCount})`, + ); + } if ( aiGameCreatorShellTauriSource.includes('fn open_developer_window(') || aiGameCreatorShellTauriSource.includes(