运行页入口收口:过程提示退出对话区,顶栏统一承载运行入口 #457
@@ -1941,6 +1941,7 @@ for (const snippet of [
|
||||
'官方账号服务(固定)',
|
||||
'runtime_config.save',
|
||||
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
|
||||
'已载入客户端运行视图',
|
||||
'async function executeRunLocal',
|
||||
'function needsInitializedChatProject',
|
||||
"'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'",
|
||||
|
||||
@@ -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<string | null>(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<LocalPreviewResult>(
|
||||
@@ -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)
|
||||
}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
<div
|
||||
key={notice.id}
|
||||
className="pointer-events-none fixed bottom-8 left-1/2 z-[1100] -translate-x-1/2"
|
||||
data-project-run-notice-toast="true"
|
||||
>
|
||||
<PlatformRuntimeStatusToast
|
||||
tone={notice.tone ?? 'success'}
|
||||
surface="solid"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
className="shadow-lg"
|
||||
>
|
||||
{notice.message}
|
||||
</PlatformRuntimeStatusToast>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -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<RunNotice | null>(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 : (
|
||||
<AccountWalletBar controller={accountWallet} />
|
||||
)}
|
||||
<RunNoticeToast notice={runNotice} onDismiss={() => setRunNotice(null)} />
|
||||
{launcherNotice ? (
|
||||
<div
|
||||
className="launcher-dialog-backdrop"
|
||||
|
||||
@@ -34,6 +34,17 @@ export type WorkspaceLauncherProps = {
|
||||
initialView?: LauncherView;
|
||||
};
|
||||
|
||||
/**
|
||||
* 运行 / 预览类动作的一次性浮层提示。
|
||||
*
|
||||
* `tone` 只区分观感(成功绿 / 失败红),文案由发出方给出:运行成功、切到运行视图、
|
||||
* 在浏览器打开失败都走这一条通道。
|
||||
*/
|
||||
export type ProjectRunNotice = {
|
||||
message: string;
|
||||
tone?: 'success' | 'error';
|
||||
};
|
||||
|
||||
export type ProjectChatComponentProps = {
|
||||
initialProjectPath?: string;
|
||||
initialProjectManifest?: GameCreationAppManifest;
|
||||
@@ -64,6 +75,13 @@ export type ProjectChatComponentProps = {
|
||||
metadata?: ProjectManifestSnapshotMetadata,
|
||||
) => void;
|
||||
onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void;
|
||||
/**
|
||||
* 运行 / 预览类动作的一次性浮层提示。
|
||||
*
|
||||
* 「跑起来了」「已切到运行视图」「在浏览器打开失败」属于过程反馈,不进对话区——
|
||||
* 对话区只保留对话内容。工作台壳收到后弹 toast,`tone` 决定成功还是失败观感。
|
||||
*/
|
||||
onRunNotice?: (notice: ProjectRunNotice) => void;
|
||||
onAgentRuntimeSummariesChange?: (
|
||||
summaries: ProjectAgentRuntimeSummary[],
|
||||
) => void;
|
||||
|
||||
@@ -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`)。
|
||||
*/}
|
||||
<span className="game-run-version-trigger-label">
|
||||
{formatIterationVersionLabel(currentVersion)}
|
||||
</span>
|
||||
</button>
|
||||
{open
|
||||
? createPortal(
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
|
||||
*
|
||||
* 两页之间切换时锚点高度走样式里的 transition,不瞬移。
|
||||
*/
|
||||
placement?: 'canvas-overview' | 'canvas' | 'run' | 'editor';
|
||||
placement?: 'canvas-overview' | 'canvas' | 'editor';
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+3
-7
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void>;
|
||||
/**
|
||||
* 过程提示(成功 / 失败)的唯一出口。
|
||||
*
|
||||
* 「运行起来了」「在浏览器打开失败」这类反馈不进对话区,交给工作台壳弹 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<WorkbenchMode>('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 ? '退出点选' : '点选素材'}
|
||||
</button>
|
||||
) : null}
|
||||
{/*
|
||||
当前预览地址的出口。以前这里是一条「已载入客户端运行视图:http://…」小字,
|
||||
长地址既占位又不可点:地址本身对用户没用,能一步跳浏览器才有用。
|
||||
只在有活预览时出现,外观与其他动作按钮同款(见 `.game-workbench-view-actions button`)。
|
||||
*/}
|
||||
{mode === 'run' && embeddedPreviewUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
className="game-workbench-browser-open-button"
|
||||
onClick={() => void openPreviewInBrowser()}
|
||||
title={`在浏览器打开 ${embeddedPreviewUrl}`}
|
||||
>
|
||||
<ExternalLink size={15} aria-hidden="true" />
|
||||
在浏览器打开
|
||||
</button>
|
||||
) : null}
|
||||
{mode === 'resources' && !uiEditorRoute ? (
|
||||
<>
|
||||
<button
|
||||
@@ -9736,6 +9790,21 @@ export default function ProjectDevelopmentView({
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{/*
|
||||
C7 版本入口:与「打开项目目录 / 在浏览器打开」同处顶栏动作区,外观也走同一套
|
||||
(基础规则在 `.game-workbench-view-actions button`)。它不再浮在运行画面上:
|
||||
以前是绝对定位压在画面右上角,挡画面且与预览地址不对齐。
|
||||
|
||||
**UI 编辑器壳里不渲染**:那一页是聚焦编辑某个资源的界面,顶栏只留通用动作;
|
||||
版本入口服务于资源画布与运行页(`@` 面板按当前版本取素材),编辑器不需要它。
|
||||
*/}
|
||||
{uiEditorRoute ? null : (
|
||||
<GameRunVersionPicker
|
||||
versions={projectVersions}
|
||||
activeVersionId={activeVersionId}
|
||||
onSelectVersion={selectActiveVersion}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/*
|
||||
布局状态提示**不进动作行**:它是一段随保存过程变长的文案(空 →「保存中」→
|
||||
@@ -10889,11 +10958,6 @@ export default function ProjectDevelopmentView({
|
||||
</>
|
||||
) : (
|
||||
<section className="game-run-surface" aria-label="运行表现层">
|
||||
<GameRunVersionPicker
|
||||
versions={projectVersions}
|
||||
activeVersionId={activeVersionId}
|
||||
onSelectVersion={selectActiveVersion}
|
||||
/>
|
||||
<div className="game-run-preview">
|
||||
{embeddedPreviewUrl ? (
|
||||
<LocalGamePreviewFrame
|
||||
@@ -10931,41 +10995,42 @@ export default function ProjectDevelopmentView({
|
||||
</section>
|
||||
)}
|
||||
{/*
|
||||
「生成任务」侧栏:常驻在**工作面右上角**(资源画布 / 运行表现层 / UI 编辑器各自一档坐标),
|
||||
可折叠、非模态。它**不**参与 `isResourceCanvasFloatingPanelOpen` 的模态遮挡判据——
|
||||
生成在后台跑,侧栏展开时画布必须照样能看能用;折叠只影响这个视图,任务本身活在账本
|
||||
与本地队列里。位置与开合形态照抄美术画布(开关常驻右上角 + 面板贴着它展开),
|
||||
颜色与外形仍走 AGC 的平台 token。
|
||||
「生成任务」侧栏:常驻在**资源画布 / UI 编辑器**的右上角(各一档坐标),可折叠、非模态。
|
||||
它**不**参与 `isResourceCanvasFloatingPanelOpen` 的模态遮挡判据——生成在后台跑,
|
||||
侧栏展开时画布必须照样能看能用;折叠只影响这个视图,任务本身活在账本与本地队列里。
|
||||
|
||||
**运行表现层不挂它**:运行页要留给游戏画面,右上角是版本入口与「在浏览器打开」,
|
||||
再叠一枚任务开关(或展开的面板)就会压在画面上。任务不会因此丢,切回资源页即可见。
|
||||
*/}
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={resourceAssetGenerationTasks.filter(
|
||||
(task) => 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 : (
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={resourceAssetGenerationTasks.filter(
|
||||
(task) => 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'
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!uiEditorRoute ? (
|
||||
|
||||
@@ -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: '生成任务' }),
|
||||
|
||||
@@ -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<string, string> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<App
|
||||
initialProjectPath={PROJECT_PATH}
|
||||
initialProjectManifest={createFixtureManifest()}
|
||||
onPlayRequestHandled={vi.fn()}
|
||||
onRunNotice={onRunNotice}
|
||||
playRequest={{ projectPath: PROJECT_PATH, requestId: 1 }}
|
||||
/>,
|
||||
);
|
||||
return { onRunNotice };
|
||||
}
|
||||
|
||||
/** 运行结果由聊天自己的本地消息流展示,这里按对话容器文本断言。 */
|
||||
async function expectChatAnnouncement(text: string) {
|
||||
/** 运行反馈走 toast;对话容器里不该再出现这类过程提示。 */
|
||||
async function expectRunNoticeWithoutChatTip(
|
||||
onRunNotice: ReturnType<typeof vi.fn>,
|
||||
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('预览端口被占用');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[]}
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusTask={vi.fn()}
|
||||
placement="editor"
|
||||
/>,
|
||||
);
|
||||
expect(placementOf()).toBe('editor');
|
||||
|
||||
// 缺省 = 资源栏目画布,老调用方不传这个 prop 也落在画布右上角。
|
||||
|
||||
+10
-9
@@ -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([
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
/**
|
||||
* 运行提示的外壳级接线:`ProjectChat` 发一条提示,工作台壳真的把它渲染成浮层。
|
||||
*
|
||||
* 单测(`runNoticeToast`)只证明组件本身,`previewActivation` 只证明 App 会调这条通道;
|
||||
* 从「通道被调用」到「用户看到 toast」之间还差一层壳的接线(prop 名、handler、portal 挂点、
|
||||
* tone 传递)。这里用替身聊天把这一层单独钉住:以后有人把 `<RunNoticeToast/>` 挪进条件
|
||||
* 分支、或在传 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 (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onRunNotice?.({ message: '运行通过,已载入客户端运行视图' })
|
||||
}
|
||||
>
|
||||
触发成功提示
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onRunNotice?.({
|
||||
tone: 'error',
|
||||
message: '运行游戏失败:预览端口被占用',
|
||||
})
|
||||
}
|
||||
>
|
||||
触发失败提示
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开工作台所需的最小命令集照抄 `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<string, unknown>) => {
|
||||
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(
|
||||
<WorkspaceLauncherShell
|
||||
currentUser={testAuthUser}
|
||||
initialView="projects"
|
||||
onLogout={vi.fn()}
|
||||
ProjectChat={StubRunNoticeChat}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
'运行游戏失败:预览端口被占用',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(<RunNoticeToast notice={null} onDismiss={vi.fn()} />);
|
||||
|
||||
expect(toastElement()).toBeNull();
|
||||
expect(screen.queryByText(RUN_MESSAGE)).toBeNull();
|
||||
});
|
||||
|
||||
it('在浮层里显示提示,并在到点后自动收起', () => {
|
||||
vi.useFakeTimers();
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
render(
|
||||
<RunNoticeToast
|
||||
notice={{ id: 1, message: RUN_MESSAGE }}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<RunNoticeToast
|
||||
notice={{ id: 1, message: RUN_MESSAGE }}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<RunNoticeToast
|
||||
notice={{ id: 2, message: RUN_MESSAGE }}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2_000);
|
||||
});
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('失败提示用错误色调,不需要调用方再拼一套文案', () => {
|
||||
render(
|
||||
<RunNoticeToast
|
||||
notice={{
|
||||
id: 1,
|
||||
tone: 'error',
|
||||
message: '在浏览器打开失败:permission denied',
|
||||
}}
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<RunNoticeToast
|
||||
notice={{
|
||||
id: 1,
|
||||
tone: 'error',
|
||||
message: '运行游戏失败:预览端口被占用',
|
||||
}}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2_600);
|
||||
});
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3_400);
|
||||
});
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) => {
|
||||
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(
|
||||
<ProjectDevelopmentView
|
||||
projectName={manifest.name}
|
||||
projectPath="/tmp/run-preview-browser-open"
|
||||
manifest={manifest}
|
||||
attachments={[]}
|
||||
recentRunStatus={null}
|
||||
recentRunStopReason={null}
|
||||
preview={
|
||||
options.withPreview === false
|
||||
? null
|
||||
: { status: 'running', url: PREVIEW_URL, port: 4173 }
|
||||
}
|
||||
supervisor={<div>项目总控</div>}
|
||||
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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 说一句「已切换到客户端运行视图:<url>」` 按验收反馈(提示常驻对话底部遮挡运行画面)改成 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`。定位发送后空窗缺陷时,读代码无法判断某个窗口期的界面表现是否有依据,也说不清谁该信谁。
|
||||
|
||||
@@ -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` 这类标记,**整行**就被换成 `<sensitive diagnostic details redacted>`,只留下时间戳与 `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/<target>/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 对话显示与滚动合同”。
|
||||
|
||||
@@ -2647,7 +2647,7 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
}
|
||||
for (const snippet of [
|
||||
"await invoke<LocalPreviewStatus>(\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(
|
||||
|
||||
Reference in New Issue
Block a user