Merge branch 'codex/agc-browser-orphan-cleanup' of https://git.genarrative.world/git/GenarrativeAI/Genarrative into codex/agc-browser-orphan-cleanup
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m13s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m0s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m28s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m28s
Project CI / Backend tests (pull_request) Successful in 7m17s
Project CI / Native shell tests (pull_request) Successful in 7m49s
Project CI / Frontend tests (pull_request) Successful in 4m57s
Project CI / Repository checks (pull_request) Successful in 4m19s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m57s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m13s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m0s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m28s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m28s
Project CI / Backend tests (pull_request) Successful in 7m17s
Project CI / Native shell tests (pull_request) Successful in 7m49s
Project CI / Frontend tests (pull_request) Successful in 4m57s
Project CI / Repository checks (pull_request) Successful in 4m19s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m57s
This commit is contained in:
@@ -645,17 +645,19 @@ test('Windows remains the default and explicit Windows overrides macOS environme
|
||||
|
||||
test('no-bundle smoke skips version writes and manifest generation', async () => {
|
||||
const steps = [];
|
||||
await buildRelease(['--no-bundle', '--target=aarch64-apple-darwin'], {
|
||||
prepareVersion: () => {
|
||||
steps.push('version');
|
||||
},
|
||||
build: (_args, context) => {
|
||||
steps.push(context.channel);
|
||||
},
|
||||
generateManifest: () => {
|
||||
steps.push('manifest');
|
||||
},
|
||||
});
|
||||
await withEnv({ AGC_UPDATE_CHANNEL: undefined }, () =>
|
||||
buildRelease(['--no-bundle', '--target=aarch64-apple-darwin'], {
|
||||
prepareVersion: () => {
|
||||
steps.push('version');
|
||||
},
|
||||
build: (_args, context) => {
|
||||
steps.push(context.channel);
|
||||
},
|
||||
generateManifest: () => {
|
||||
steps.push('manifest');
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(steps, ['dev']);
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -138,7 +138,13 @@ install -m 600 deploy/container/gitea-ci-cache.config.example.json /etc/genarrat
|
||||
docker compose -f deploy/container/gitea-runner-fetch-gate.compose.yml up -d
|
||||
```
|
||||
|
||||
**首次接入或从不跟踪任务的旧网关升级,需要空闲维护窗口**:确认无活跃 CI 且暂停新 CI 触发,再备份现有 runner 配置和注册文件,在 runner 部署的 `GITEA_INSTANCE_URL` 及 `/data/.runner` 的 `address` 中改用 `http://gitea-runner-fetch-gate:8080`,保留其余注册字段。同时在 `/data/config.yaml` 的 `runner.envs` 中设置 `GENARRATIVE_GITEA_REPOSITORY_URL: "http://gitea:3000/GenarrativeAI/Genarrative.git"`,与维护器的 `repository_url` 一致,沿用现有 job 已可达的内部 Git 通道。此专用变量也覆盖旧 PR 的 checkout;不要用同名 GITHUB_SERVER_URL 环境变量代替,Runner 会再次覆盖它。按原流程重启并验证 runner 注册。
|
||||
**首次接入或从不跟踪任务的旧网关升级,需要空闲维护窗口**:确认无活跃 CI 且暂停新 CI 触发,再备份现有 runner 配置和注册文件,在 runner 部署的 `GITEA_INSTANCE_URL` 及 `/data/.runner` 的 `address` 中改用 `http://gitea-runner-fetch-gate:8080`,保留其余注册字段;runner 进程的 `NO_PROXY` / `no_proxy` 必须加入 `gitea-runner-fetch-gate`。同时在 `/data/config.yaml` 的 `runner.envs` 中设置 `GENARRATIVE_GITEA_REPOSITORY_URL: "http://genarrative-station/git/GenarrativeAI/Genarrative.git"`,与维护器的 `repository_url` 一致。当前 job 通过 `--add-host=genarrative-station:172.30.0.3` 访问现有 actions gateway,job 的代理例外保留 `genarrative-station`;runner 自身能访问的 `http://gitea:3000` 不代表内层 job 也能访问。此专用变量也覆盖旧 PR 的 checkout;不要用同名 GITHUB_SERVER_URL 环境变量代替,Runner 会再次覆盖它。按原流程重启并验证 runner 注册。
|
||||
|
||||
维护器的 `clone_url` 属于宿主网络:当前 Gitea 将容器 3000 映射到宿主 3003,因此使用 `http://127.0.0.1:3003/GenarrativeAI/Genarrative.git`,避免本机完整 clone 绕公网导致 900 秒超时;不要把这个 loopback 地址传给 job。其它部署必须按实际端口核实。`api_url` 仍使用 HTTPS 公开 API 地址。
|
||||
|
||||
内层 Docker 的默认地址池必须避开外层 `gitea-actions` 的 `172.30.0.0/24` 和 egress 的 `172.31.0.0/24`。当前 `/opt/gitea-stack/config/runner-dockerd-run` 在 dockerd 参数中设置 `--default-address-pool base=10.240.0.0/16,size=24`,每个 job 获得独立 `/24`。变更在 runner 重启后生效,只影响新网络;先逐个检查并定向删除无容器引用的 `GITEA-ACTIONS-TASK-*` 遗留网络,不能全局 prune。取消或强制停止 runner 可能留下空网络,默认 Docker `/16` 地址池累积到 `172.30.0.0/16` 会截走网关流量。
|
||||
|
||||
恢复领取前,必须在内层 Docker 新建与 job 相同的 bridge,使用现役 CI Image ID、相同 host 映射和代理环境,实际执行 `genarrative-gitea-checkout` 并访问 `https://git.genarrative.world/git/api/v1/version`;同时确认新网络使用上述 `/24` 地址池。仅宿主 `git ls-remote`、runner 注册成功或 FetchTask 路由通过,均不能替代 job 网络验收。
|
||||
|
||||
不能仅改磁盘文件却不让进程加载;维护器还会检查独立 checkout URL,并确认入口实际见到了当前容器本次启动后的 FetchTask 来源 IP。此一次接入不由维护器冒险猜测空闲,也不对运行中 CI 动手。实例 API 根地址仍使用配置中的 HTTPS Gitea 地址,不能指向只支持 runner RPC 的入口。上传脚本从独立 `GENARRATIVE_GITEA_REPOSITORY_URL` 推导真实 Gitea 地址,使用本任务临时凭据调用原生 V4 API;不依赖被网关覆盖的 `GITHUB_SERVER_URL` / `ACTIONS_RUNTIME_URL`。Gitea 1.26.4 的仓库 REST 下载接口只支持 V4,不能换回 V3 上传。宿主下载只跟随同一 HTTPS Gitea origin 的签名重定向,不转发长期 Token;如配置外部对象存储直出,需另行适配下载来源。
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"api_url": "https://git.genarrative.world/git/api/v1",
|
||||
"repository": "GenarrativeAI/Genarrative",
|
||||
"repository_url": "http://gitea:3000/GenarrativeAI/Genarrative.git",
|
||||
"clone_url": "https://git.genarrative.world/git/GenarrativeAI/Genarrative.git",
|
||||
"repository_url": "http://genarrative-station/git/GenarrativeAI/Genarrative.git",
|
||||
"clone_url": "http://127.0.0.1:3003/GenarrativeAI/Genarrative.git",
|
||||
"runner_container": "gitea-runner",
|
||||
"token_file": "/etc/genarrative-ci-cache/api-token",
|
||||
"state_dir": "/var/lib/genarrative-ci-cache",
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-09-22 release 每日调度纳入正式 Full Build 并收集结果
|
||||
|
||||
- 背景:原 `Genarrative-Scheduled-Release-Trigger` 只处理 AGC Windows/macOS,且对下游使用 `wait: false` 后立即推进 revision;正式服务端 release 仍只能人工触发,客户端构建失败也不会回传调度器。
|
||||
- 决策:release 调度同时计算 `AGC` 与 `FullBuild` 两条 scope。服务端相关路径变化时以 `DEPLOY_TARGET=release`、`CONFIRM_RELEASE_DEPLOY_AGENT=true` 触发 `Genarrative-Full-Build-And-Deploy`;客户端相关路径变化时保持统一发号并触发 Windows/macOS release。Full Build、Windows、macOS 三路均等待结果并汇总,只有成功的 lane 才推进对应 `.jenkins-last-release-full-revision` / `.jenkins-last-release-agc-revision`,失败 lane 在下一轮单独补发,避免重复部署已成功的服务端版本。`DATABASE_BACKUP_MODE` 默认 `async`,并显式提供 `STDB_API_ROLLOUT_MODE` / approvers 以保留正式维护门禁。
|
||||
- 原因:正式 release 需要与 dev 小时调度隔离,同时不能继续依赖人工触发服务端;lane-level 成功状态可区分“已成功但另一 lane 失败”的部分发布,避免下一次调度重复发布 Full Build。
|
||||
- 影响范围:`jenkins/Jenkinsfile.scheduled-release-trigger`、`jenkins/scheduled-release-trigger-job-config.xml`、`scripts/check-production-ops-guardrails.mjs`、开发运维文档与共享开发工作流。
|
||||
- 验证方式:生产运维门禁检查 Full Build release 参数、双 scope、`wait: true` 结果聚合、三路分支和成功后才写 lane revision;并用 Jenkins 具体构建验证 Full Build/AGC 的 build number 与结果能回传到调度 Job。
|
||||
|
||||
## 2026-09-22 AGC release 增加每日调度,dev 调度保持双平台
|
||||
|
||||
> 已被上一条决策取代:release 调度现已纳入正式服务端 Full Build。
|
||||
|
||||
- 背景:原有 `Genarrative-Scheduled-Revision-Trigger` 每小时跟随 revision 发布 dev 客户端,但没有对应的 release 渠道自动入口;Mac 节点此前已纳入小时 dev 调度,需要避免新增 release 调度时再退回 Windows-only。
|
||||
- 决策:新增 `Genarrative-Scheduled-Release-Trigger`,每天 04:00 检查 `SOURCE_BRANCH`,使用独立的 `.jenkins-last-release-revision` 与客户端相关路径白名单;上一轮 release 调度后有客户端变更时,先经 `Genarrative-Agc-Global-Version-Issue` 发统一总号,再以同一固定 `COMMIT_HASH` 触发 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build` 的 `AGC_UPDATE_CHANNEL=release` 分区,Mac 继续带 `SKIP_IF_SUPERSEDED=true`。小时 dev 调度保持同时触发 Windows 与 macOS dev。
|
||||
- 原因:release 与 dev 是不同渠道和发布节奏,不能靠同一个小时 Job 隐式切换;独立 Job 能分别去重、记录状态和审计发号。release 调度不触发 Full Build,避免把桌面客户端发布与线上全栈部署绑定。
|
||||
@@ -9367,6 +9377,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`。定位发送后空窗缺陷时,读代码无法判断某个窗口期的界面表现是否有依据,也说不清谁该信谁。
|
||||
|
||||
@@ -92,7 +92,7 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m
|
||||
|
||||
## Jenkins 定时版本调度
|
||||
|
||||
定时与版本比较收口到两条调度器:`Genarrative-Scheduled-Revision-Trigger` 每小时处理 dev 渠道,变化时触发 Full Build、AGC Windows/macOS dev,并让两个客户端平台共用同一个总版本号;`Genarrative-Scheduled-Release-Trigger` 每天 04:00 处理 AGC release 渠道,只在上一轮 release 调度后出现客户端相关路径变化时,经 `Genarrative-Agc-Global-Version-Issue` 发同一个总号并触发 AGC Windows/macOS release。各下游 Job 都不得自带 `triggers` / `cron`,也不得在管线内再做一套版本去重;`npm run check:production-ops` 会拦住回退。macOS 节点是日常办公机,两条调度触发它时都置 `SKIP_IF_SUPERSEDED=true`:节点离线期间排队的旧构建在恢复后会自行让位,不发布过期版本。调度状态与生效步骤见 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
定时与版本比较收口到两条调度器:`Genarrative-Scheduled-Revision-Trigger` 每小时处理 dev 渠道,变化时触发 Full Build、AGC Windows/macOS dev,并让两个客户端平台共用同一个总版本号;`Genarrative-Scheduled-Release-Trigger` 每天 04:00 按服务端与客户端两条独立 scope 处理 release,服务端变化时用 `DEPLOY_TARGET=release` 触发正式 Full Build,客户端变化时经 `Genarrative-Agc-Global-Version-Issue` 发同一个总号并触发 AGC Windows/macOS release。两条调度都等待并汇总下游,按成功 lane 推进 revision;失败 lane 下一轮单独补发。各下游 Job 都不得自带 `triggers` / `cron`,也不得在管线内再做一套版本去重;`npm run check:production-ops` 会拦住回退。macOS 节点是日常办公机,两条调度触发它时都置 `SKIP_IF_SUPERSEDED=true`:节点离线期间排队的旧构建在恢复后会自行让位,不发布过期版本。调度状态与生效步骤见 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## Gitea CI 依赖闭合
|
||||
|
||||
@@ -100,6 +100,8 @@ Gitea Rust 缓存自动维护由宿主 `genarrative-ci-cache.timer` 收集同一
|
||||
|
||||
修改 Gitea workflow 的 job 显示名称、ID 或缓存导出组时,必须同步维护器的 `JOBS` / `RUST_JOB_IDS`;`test_gitea_cache_maintenance.py` 直接对照实际 workflow 检查全集和导出映射,避免自动刷新或镜像验收因名单漂移长期等待。维护器 `Api.request` 的 `method` 是必填关键字参数,GET 也必须显式指定,不根据 body 推断请求方法。
|
||||
|
||||
Gitea 缓存部署必须区分网络:runner 的 RPC 走 `gitea-runner-fetch-gate:8080`;内层 job 的 checkout/上传走映射到 `172.30.0.3` 的 `http://genarrative-station/git`;宿主专用 clone 走 `http://127.0.0.1:3003`。不要把 runner 可达的 `gitea:3000` 配给 job。内层 Docker 使用 `10.240.0.0/16`、每 job `/24` 的默认地址池,避开外层 `172.30/172.31` 网段;恢复领取前必须在真实 job 网络里验证 checkout 与 Gitea API,不能只验证 FetchTask。具体配置与遗留空网络处理见 `deploy/container/README.md`。
|
||||
|
||||
AGC Rust 两条 lane、crates、smoke、Backend 和桌面壳测试使用镜像内可信 sccache 对象快照;Native shell release step 显式清空双 wrapper,前端/repository checks 不启用。仅首次人工 bootstrap 时,维护者通过 `scripts/build-gitea-rust-cache.sh` 从远端 master 在限额、无宿主挂载的临时容器中按实际 cwd/profile/目标预热全部测试组,仅编译、不执行测试/应用;后端 workspace 与 spacetime-module 保持独立,AGC 的三个 cwd 入口之间清理预热 target,防止 fresh 判断漏产缓存键。最终镜像只追加 sccache、对象和来源元数据,不包含源码或 target。容量上限 4 GiB,不替代宿主旧镜像/归档清理。PR 只写当前容器层、不回传,不开放 Docker API/发布权限;继续禁用 incremental。`ci-rust-cache.sh` 在快照缺失、工具链不符或 wrapper 探测失败时直接编译,并隔离远程缓存配置和 daemon。分片日志记录编译耗时,收尾输出命中统计;两个 lane 的测试和前置检查不同,耗时差不是严格 A/B。线上存在活跃 CI 时不得重启 runner 或切换标签;全组启用前须刷新完整快照并逐组验证,详见开发运维文档。
|
||||
|
||||
`.gitea/workflows/project-ci.yml` 的客户端门禁拆成 lane 与功能 job,每个 job 只预热自己会构建的那几份依赖:`AI game creator shell Rust lane 1/2`、`lane 2/2` 各自预取一次 AGC 壳 manifest,并顺序运行两片 Rust bin 单测;`AI game creator shell Rust smoke` 同样只预取 AGC 壳 manifest(`agent-run` smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`),`AI game creator shell Rust crates` 预取 `server-rs/Cargo.toml` 与独立 crate,`Native shell tests` 预取桌面壳与 AGC 壳 manifest,`AI game creator shell web tests` 不触碰 Cargo,不预热。两条 Rust lane、smoke job 与 crates job 只用 cargo 与 node 内建模块,因此不执行 `npm ci`。两个被 `server-rs/Cargo.toml` 排除、且没有提交 `Cargo.lock` 的独立 crate(`agent-runtime-core`、`agent-runtime-orchestration`)只能在 `AI game creator shell Rust crates` 里用不带锁标志的 fetch。AGC 壳的 bin target 单测(约 2466 条)由 `apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs` 编译后按 `--list` 名单分 4 片:每次分片调用用 `--shard-index=<i>` 只跑自己那片,片内保持 `--test-threads=1` 并使用独立 `TMPDIR`;两条 lane 之间并发,lane 内顺序运行两片,避免重复依赖预热和同一容器内多进程争抢。不要改回「一个 job 内多进程并行这几片」——同一容器里它们会争抢共享 `HOME`、target 目录与固定临时路径,实测比整套串行还慢。每个分片调用都会自校验「片并集等于全集且互斥」,因此改分片规则不会静默漏跑。Backend host workspace tests 使用 `cargo test --locked --workspace --exclude spacetime-module --no-fail-fast`,避免 `spacetime-module` 的 `spacetime-types` feature 统一污染普通领域 crate 的 host 测试;随后单独执行 `cargo test --locked -p spacetime-module --no-fail-fast`,由 `spacetime-module/src/active.rs` 在 host 测试构建期间提供仅测试期的 SpacetimeDB ABI 链接支持,使该 crate 的纯单元测试也纳入 Backend 门禁。`spacetime-module` 的 reducer / procedure 运行时行为仍必须通过真实 SpacetimeDB runtime/integration harness 验证,host 链接支持不得被当作运行时替身。Backend 另外执行 `cargo check --locked -p spacetime-module` 验证模块源码。AGC 壳检查还会运行 `platform-llm` 与 `shared-contracts` 的 server-rs workspace 测试,这些命令以及 AGC 壳测试必须带 `--locked`,避免在测试阶段重新解析 registry index;锁文件发生变化时应先更新受信任 CI 镜像缓存,再重跑门禁。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user