合并资源工作台分支:卡片从所属摞浮现与既有三条修复并存
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled

- 把远端最新 05612c46a(弹窗宿主类样式迁共享表 + 标签统计刷新 + 工具条分隔线去重)合入本分支 b41b26cab(卡片全部从所属摞浮现)
- 冲突零:仅 pitfalls.md 两侧追加记录自动合并,两条都保留
- 验证:npm run typecheck exit 0;resourceBookController / resourceBookLayout / resourceTagStatsRefresh / projectAssetPickerDialogShellStyle / selectedLayerToolbarDividerDedupe 五文件全绿
This commit is contained in:
2026-09-12 18:58:52 +08:00
8 changed files with 809 additions and 16 deletions
+24
View File
@@ -628,6 +628,30 @@ export function App({
const [manifest, setManifest] = useState<GameCreationAppManifest>(
initialProjectManifest ?? seedManifest,
);
const manifestRef = useRef(manifest);
manifestRef.current = manifest;
/**
*
*
* / / /
* manifest CAS `currentProjectContext` prop
* `useState` ****`@`
* `@`
*
*
*
*
* + CAS
*
*/
useEffect(() => {
const snapshot = initialProjectManifest;
const current = manifestRef.current;
if (!snapshot || snapshot === current) return;
if (snapshot.projectId !== current.projectId) return;
if (JSON.stringify(snapshot) === JSON.stringify(current)) return;
setManifest(snapshot);
}, [initialProjectManifest]);
const [projectStatus, setProjectStatus] = useState(
eagerSupervisorProject ? '已初始化' : '未初始化',
);
@@ -0,0 +1,367 @@
// @vitest-environment jsdom
import { existsSync, readFileSync } from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { ImageCanvasProjectAssetPickerDialog } from '../../../src/components/image-editor/ImageCanvasProjectAssetPickerDialog';
/**
* 「选择替换素材」弹窗在 AGC 的面板底色契约。
*
* 现象:AGC 资源工作台里这个弹窗的面板本体全透明,背后模糊的资源画布直接透出来。
* 面板用的是共享 `UnifiedModal` 产出的 `platform-modal-shell`,而这条规则原先只写在网页端
* 整站样式表 `src/index.css` 里;AGC 是独立宿主,只引入 `@genarrative/shared/styles.css`
* 与 `theme.css`,于是这个类一条声明都命中不到——没有底色、没有边框、没有阴影。
*
* jsdom 不计算外部 CSS,所以这里不做"渲染出来看颜色",而是把两半分别钉死:
* 1) DOM 契约:组件真的把 `platform-modal-shell` / `platform-overlay` 挂上去了,且遮罩
* 自带 `platform-theme--light`(否则 `var(--platform-modal-fill)` 解析为空,同样透明);
* 2) 样式表契约:从 AGC 入口文件按真实 import 关系解析出"AGC 到底加载了哪些样式表",
* 断言这批表里有该类规则、声明引用主题 token,且 token 在 light 块里是接近不透明的暖白。
*
* 这是**弱验证**(声明级,不是真机观感)。真机判据见文件末尾注释。
*/
const REPO_ROOT = process.cwd();
const AGC_ROOT = resolve(REPO_ROOT, 'apps/ai-game-creator-shell');
const PLATFORM_MODAL_SHELL = '.platform-modal-shell';
const PLATFORM_MODAL_BACKDROP = '.platform-modal-backdrop';
const PLATFORM_OVERLAY = '.platform-overlay';
/**
* 宿主的样式表清单:webkit 端整站表、AGC 自己的业务表与宿主 chrome 表、以及共享表。
* 「同一个类只准在一处定义」这条契约要跨这几张表比。
*/
const HOST_STYLE_SHEETS = [
'src/index.css',
'apps/ai-game-creator-shell/src/styles.css',
'apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css',
'packages/shared/src/components/styles.css',
];
const SHARED_STYLE_SHEET = 'packages/shared/src/components/styles.css';
/** 由 Vite/Tailwind 插件接管的样式入口,没有对应文件。 */
const PLUGIN_STYLE_ENTRIES = new Set(['tailwindcss']);
type CssRule = {
file: string;
selector: string;
declarations: Map<string, string>;
};
function absoluteSheetPath(sheetPath: string) {
return resolve(REPO_ROOT, sheetPath);
}
/** 清单与断言一律用仓库相对的正斜杠路径,免得 Windows 上比对不上。 */
function toRepoPath(absolutePath: string) {
return relative(REPO_ROOT, absolutePath).replace(/\\/gu, '/');
}
/* ============================================================
AGC 实际加载了哪些样式表:按入口文件的 import 关系解析,不手写清单。
============================================================ */
function resolveStyleSpecifier(specifier: string, fromFile: string): string {
if (specifier.startsWith('.')) {
return resolve(dirname(fromFile), specifier);
}
const match = /^(@[^/]+\/[^/]+|[^@/][^/]*)(\/.*)?$/u.exec(specifier);
expect(match, `样式入口无法解析:${specifier}`).not.toBeNull();
const packageName = match![1]!
.replace(/^@genarrative\//u, '')
.replace(/\//gu, '-');
const packageDir = resolve(REPO_ROOT, 'packages', packageName);
const manifestPath = resolve(packageDir, 'package.json');
if (!existsSync(manifestPath)) {
throw new Error(
`AGC 新增了未知样式入口「${specifier}」:请把它的解析方式补进本用例的清单。`,
);
}
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
exports?: Record<string, string>;
};
// `@genarrative/shared/styles.css` → 包名 + exports 的键形态 `./styles.css`。
const subpath = match![2] ? `.${match![2]}` : '.';
const target = manifest.exports?.[subpath];
if (!target) {
throw new Error(
`样式入口「${specifier}」在 packages/${packageName}/package.json 的 exports 里没有对应项。`,
);
}
return resolve(packageDir, target);
}
/** 从 AGC 入口(`src/main.tsx`)出发,按 import / @import 关系收集它加载的样式表。 */
function collectAgcLoadedStyleSheets(): string[] {
const entryFile = resolve(AGC_ROOT, 'src/main.tsx');
const pending = [...readFileSync(entryFile, 'utf8').matchAll(
/import\s+'(?<specifier>[^']+\.css)'/gu,
)].map((match) =>
resolveStyleSpecifier(match.groups!.specifier!, entryFile),
);
const loaded: string[] = [];
while (pending.length > 0) {
const sheet = pending.pop()!;
if (loaded.includes(sheet)) {
continue;
}
loaded.push(sheet);
const source = readFileSync(sheet, 'utf8');
for (const match of source.matchAll(/@import\s+'(?<specifier>[^']+)'/gu)) {
const specifier = match.groups!.specifier!;
if (PLUGIN_STYLE_ENTRIES.has(specifier)) {
continue;
}
pending.push(resolveStyleSpecifier(specifier, sheet));
}
}
return loaded.sort();
}
/* ============================================================
声明级求值:够读本用例关心的类规则(顶层 + 一层媒体查询容器)。
============================================================ */
function splitSelectorList(selectorText: string): string[] {
const selectors: string[] = [];
let depth = 0;
let current = '';
for (const char of selectorText) {
if (char === '(' || char === '[') {
depth += 1;
} else if (char === ')' || char === ']') {
depth -= 1;
}
if (char === ',' && depth === 0) {
selectors.push(current.trim().replace(/\s+/gu, ' '));
current = '';
continue;
}
current += char;
}
if (current.trim()) {
selectors.push(current.trim().replace(/\s+/gu, ' '));
}
return selectors.filter(Boolean);
}
function parseDeclarations(body: string): Map<string, string> {
const declarations = new Map<string, string>();
for (const chunk of body.split(';')) {
const separator = chunk.indexOf(':');
if (separator < 0) {
continue;
}
const property = chunk.slice(0, separator).trim();
const value = chunk.slice(separator + 1).trim().replace(/\s+/gu, ' ');
if (property) {
declarations.set(property, value);
}
}
return declarations;
}
function readRules(file: string): CssRule[] {
const rules: CssRule[] = [];
const source = readFileSync(file, 'utf8').replace(
/\/\*[\s\S]*?\*\//gu,
(comment) => comment.replace(/[^\n]/gu, ' '),
);
const readBlock = (text: string, start: number) => {
let depth = 1;
let index = start;
while (index < text.length && depth > 0) {
if (text[index] === '{') {
depth += 1;
} else if (text[index] === '}') {
depth -= 1;
}
index += 1;
}
return { body: text.slice(start, index - 1), end: index };
};
const walk = (text: string) => {
let cursor = 0;
let buffer = '';
while (cursor < text.length) {
const char = text[cursor]!;
if (char === '{') {
const prelude = buffer.trim().replace(/\s+/gu, ' ');
buffer = '';
const { body, end } = readBlock(text, cursor + 1);
cursor = end;
if (prelude.startsWith('@')) {
// 媒体查询/关键帧等容器:继续往里找声明块。
walk(body);
continue;
}
rules.push({
file,
selector: prelude,
declarations: parseDeclarations(body),
});
continue;
}
if (char === ';' && !buffer.includes('{')) {
// `@import …;` / `@source …;` / `@charset …;` 这类语句。
buffer = '';
cursor += 1;
continue;
}
buffer += char;
cursor += 1;
}
};
walk(source);
return rules;
}
/** 找"某个类自己就是一条独立选择器"的规则(不带后代/伪类限定)。 */
function findClassRule(rules: CssRule[], className: string): CssRule | null {
return (
rules.find((rule) => splitSelectorList(rule.selector).includes(className)) ??
null
);
}
/** 取颜色值里每个色停的 alpha`rgb(...)` 视为 1。 */
function colorStopAlphas(value: string): number[] {
return [...value.matchAll(/rgba?\((?<body>[^)]*)\)/gu)].map((match) => {
const parts = match.groups!.body!.split(/[\s,/]+/u).filter(Boolean);
return parts.length >= 4 ? Number(parts[3]) : 1;
});
}
afterEach(() => {
cleanup();
});
describe('「选择替换素材」弹窗在 AGC 的面板底色', () => {
const loadedSheets = collectAgcLoadedStyleSheets();
const loadedRules = loadedSheets.flatMap(readRules);
const loadedLabels = loadedSheets.map((sheet) => toRepoPath(sheet));
it('AGC 真的加载到了共享样式表与主题表(清单本身可信)', () => {
expect(loadedLabels).toContain(SHARED_STYLE_SHEET);
expect(loadedLabels).toContain('packages/shared/src/theme.css');
expect(loadedLabels).toContain(
'apps/ai-game-creator-shell/src/styles.css',
);
// 整站样式表不在 AGC 的加载清单里——这正是这个类原先"有类名没样式"的原因。
expect(loadedLabels).not.toContain('src/index.css');
// 反向对照:清单里确实有别的共享类规则,说明解析没有落空。
expect(
findClassRule(loadedRules, '.platform-button'),
'AGC 加载的样式表解析结果缺少共享 .platform-button,说明清单解析不可信',
).not.toBeNull();
});
it('AGC 加载的样式表带 platform-modal-shell 的面板底色、边框与阴影', () => {
const rule = findClassRule(loadedRules, PLATFORM_MODAL_SHELL);
expect(
rule,
`${PLATFORM_MODAL_SHELL} 不在 AGC 加载的样式表里(${loadedLabels.join(', ')}):面板会变透明`,
).not.toBeNull();
expect(rule!.declarations.get('background')).toBe(
'var(--platform-modal-fill)',
);
expect(rule!.declarations.get('border')).toBe(
'1px solid var(--platform-modal-border)',
);
expect(rule!.declarations.get('box-shadow')).toBeTruthy();
// 依赖的 token 必须由同一批样式表提供,否则 var() 解析为空、面板依旧透明。
const lightTokens = findClassRule(loadedRules, '.platform-theme--light');
expect(
lightTokens?.declarations.get('--platform-modal-fill'),
'AGC 加载的主题表里必须有 light 主题的 --platform-modal-fill',
).toBeTruthy();
// 「不透明暖白底」是这次的验收判据:所有色停的 alpha 都要接近 1。
const alphas = colorStopAlphas(
lightTokens!.declarations.get('--platform-modal-fill')!,
);
expect(alphas.length).toBeGreaterThan(0);
expect(Math.min(...alphas)).toBeGreaterThanOrEqual(0.9);
});
it('遮罩 platform-overlay 在 AGC 也有底色,不再只剩一层 blur', () => {
const rule = findClassRule(loadedRules, PLATFORM_OVERLAY);
expect(
rule,
`${PLATFORM_OVERLAY} 不在 AGC 加载的样式表里:遮罩没有底色`,
).not.toBeNull();
expect(rule!.declarations.get('background')).toBe(
'var(--platform-overlay-fill)',
);
const lightTokens = findClassRule(loadedRules, '.platform-theme--light');
expect(
lightTokens?.declarations.get('--platform-overlay-fill'),
).toBeTruthy();
});
it('组件挂上的宿主类与上面的样式表契约同源,且遮罩自带主题类', () => {
render(
<ImageCanvasProjectAssetPickerDialog
open
assets={[]}
selectedAssetIds={[]}
singleSelect
selectionNoun="替换素材"
onCancel={() => {}}
onConfirm={() => {}}
/>,
);
const dialog = screen.getByRole('dialog', { name: '选择替换素材' });
expect(dialog.className).toContain(PLATFORM_MODAL_SHELL.slice(1));
// 弹窗 portal 到 body:主题类必须挂在遮罩上,面板才拿得到 --platform-modal-fill。
const overlay = dialog.parentElement;
expect(overlay, '弹窗面板应当挂在遮罩里').not.toBeNull();
expect(overlay!.className).toContain(PLATFORM_OVERLAY.slice(1));
expect(overlay!.className).toMatch(/platform-theme--(light|dark)/u);
});
it('没有平行拷贝:外壳三条规则只在共享表里定义一次', () => {
const hostRules = HOST_STYLE_SHEETS.filter(existsSync).flatMap((sheet) =>
readRules(absoluteSheetPath(sheet)),
);
for (const className of [
PLATFORM_MODAL_SHELL,
PLATFORM_MODAL_BACKDROP,
PLATFORM_OVERLAY,
]) {
const definingSheets = [
...new Set(
hostRules
.filter((rule) =>
splitSelectorList(rule.selector).includes(className),
)
.map((rule) => toRepoPath(rule.file)),
),
];
expect(
definingSheets,
`${className} 只应定义在共享样式表里,别在宿主业务样式里再抄一份`,
).toEqual([SHARED_STYLE_SHEET]);
}
});
});
/**
* 真机判据(弱验证之外的确认方式):
* 打开 AGC 资源工作台 → 卡片工具条「替换素材」→「选择替换素材」弹窗:
* 面板本体应当是不透明的暖白底(与「标签」「重命名」等平台弹窗一致),
* 背后模糊的画布不应该透过卡片、搜索框、底部操作条;同时面板有 1px 边框与投影。
*/
@@ -0,0 +1,303 @@
/** @vitest-environment jsdom */
import { useState } from 'react';
import { beforeEach } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
App,
createGameCreationAppManifest,
createProjectSupervisorRuntimeHarness,
expect,
findResourceSelectButton,
fireEvent,
ProjectDevelopmentView,
render,
screen,
vi,
waitFor,
within,
} from './appSurface/harness';
const PROJECT_PATH = '/tmp/tag-stats-refresh-project';
const PROJECT_ID = 'tag-stats-refresh-project';
/**
* 磁盘清单夹具:资源画布与聊天侧都从 `get_local_game_manifest` 读它,
* 素材标签写入命令按 Rust `update_manifest_asset_classification_at` 的语义
* 只改目标条目的 `category` / `tags` 并推进一次 revision。
*/
const disk = {
manifest: null as GameCreationAppManifest | null,
revision: 3,
};
function createFixtureManifest(): GameCreationAppManifest {
const manifest = createGameCreationAppManifest(
PROJECT_ID,
'标签统计刷新项目',
);
manifest.assets = [
{
id: 'asset-hero',
kind: 'character',
category: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
source: { kind: 'generated', resourceId: 'hero-resource' },
},
{
id: 'asset-bg',
kind: 'scene',
category: 'scene',
mediaType: 'image/png',
localPath: 'assets/bg.png',
source: { kind: 'generated', resourceId: 'bg-resource' },
},
];
return manifest;
}
function graphFor(manifest: GameCreationAppManifest) {
const resourceIds = manifest.assets.map((asset) => `asset:${asset.id}`);
return {
resourceIds,
referenceEdges: [],
taskFlows: [],
connectionIndex: resourceIds.map((resourceId) => ({
resourceId,
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: [],
referenceEdgeIds: [],
taskFlowIds: [],
})),
producerAssignments: [],
dependencyDepths: resourceIds.map((resourceId) => ({
resourceId,
dependencyDepth: 0,
})),
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
/**
* 真宿主接线:与 `WorkspaceLauncherShell` 完全同形 —— 壳持有清单状态,
* 画布与聊天(`ProjectSupervisor`)都是它的消费者,写入后用
* `onManifestChange(projectPath, next)` 回写这一份状态。
*
* 这里只把壳换成用例自己的 `useState`(壳那份 CAS 归并由
* `workspaceLauncherManifestMerge.test.tsx` 单独钉住),画布、聊天输入区、
* 标签统计与候选全部是被测的真实实现。
*/
function TagStatsHost({ planningStartMode }: { planningStartMode: boolean }) {
const [manifest, setManifest] = useState(() => {
const initial = createFixtureManifest();
disk.manifest = initial;
return initial;
});
disk.manifest = manifest;
const onManifestChange = (_path: string, next: GameCreationAppManifest) =>
setManifest(next);
return (
<ProjectDevelopmentView
projectName={manifest.name}
projectPath={PROJECT_PATH}
manifest={manifest}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
onHomeOpen={() => undefined}
onProjectsOpen={() => undefined}
onManifestChange={onManifestChange}
supervisor={
<App
initialProjectPath={PROJECT_PATH}
initialProjectManifest={manifest}
projectSupervisorOnly
planningStartMode={planningStartMode}
onManifestChange={onManifestChange}
/>
}
/>
);
}
function installHostTauri() {
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath: PROJECT_PATH,
});
const classificationWrites: Array<Record<string, unknown>> = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_project_revision') {
return { revision: disk.revision };
}
if (command === 'get_local_game_manifest') {
if (!disk.manifest) throw new Error('missing manifest fixture');
return disk.manifest;
}
if (command === 'update_local_project_resource_classification') {
const input = (args?.input ?? {}) as {
assetId?: string;
category?: string;
tags?: string[];
};
classificationWrites.push(structuredClone(input));
const base = disk.manifest;
if (!base) throw new Error('missing manifest fixture');
disk.revision += 1;
const nextManifest: GameCreationAppManifest = {
...base,
assets: base.assets.map((asset) =>
asset.id === input.assetId
? ({
...asset,
category: input.category,
tags: input.tags ?? [],
} as (typeof base.assets)[number])
: asset,
),
};
disk.manifest = nextManifest;
const asset = nextManifest.assets.find(
(entry) => entry.id === input.assetId,
);
if (!asset) throw new Error(`missing asset ${String(input.assetId)}`);
return { asset, committedProjectRevision: disk.revision };
}
if (
command === 'read_local_project_resource_graph' ||
command === 'read_local_project_resource_canvas_layout' ||
command === 'read_local_project_resource_document' ||
command === 'read_local_project_image_preview' ||
command === 'read_local_project_text_preview' ||
command === 'list_pending_local_project_resource_edits' ||
command === 'update_local_project_resource_canvas_layout'
) {
if (command === 'read_local_project_resource_graph') {
return graphFor(disk.manifest!);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: PROJECT_ID,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'list_pending_local_project_resource_edits') {
return [];
}
if (command === 'read_local_project_resource_document') {
return {
path: String(args?.relativePath ?? ''),
mediaType: 'text/markdown',
byteLen: 1,
content: '',
};
}
return {
path: String(args?.relativePath ?? ''),
mediaType: 'image/png',
byteLen: 1,
dataUrl: 'data:image/png;base64,AA==',
};
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke: invoke as never },
event: { listen: supervisorHarness.listen as never },
};
return { invoke, classificationWrites };
}
/** 读出 `@` 选择器里的标签 chip:`标签名 + 计数` 两个文本节点拼在一起。 */
function pickerTagChips() {
const group = document.querySelector('[aria-label="素材筛选标签"]');
if (!group) return [];
return Array.from(group.querySelectorAll('button')).map(
(chip) => chip.textContent ?? '',
);
}
function pickerCandidateNames() {
return Array.from(
document.querySelectorAll(
'.resource-reference-picker-list [role="option"]',
),
).map(
(option) =>
option.querySelector('strong')?.textContent ?? option.textContent,
);
}
async function openChatPicker() {
fireEvent.click(screen.getByRole('button', { name: '插入素材引用' }));
return screen.findByRole('dialog', { name: '选择素材' });
}
describe('改完素材标签后聊天 @ 选择器的标签统计与候选跟着刷新', () => {
beforeEach(() => {
disk.manifest = null;
disk.revision = 3;
});
it('在资源画布改完标签保存后,聊天 @ 选择器出现该标签及其计数,并按它收窄候选', async () => {
const { classificationWrites } = installHostTauri();
render(<TagStatsHost planningStartMode />);
// 先在画布上打开「编辑素材标签」面板改标签:与用户的操作路径一致。
fireEvent.click(
await screen.findByRole('button', { name: '打开角色与对象' }),
);
fireEvent.click(await findResourceSelectButton('hero.png'));
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' }));
const dialog = await screen.findByRole('dialog', {
name: '编辑素材标签',
});
fireEvent.change(
within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'),
{ target: { value: '主角' } },
);
fireEvent.click(within(dialog).getByRole('button', { name: '添加' }));
await waitFor(() => expect(classificationWrites).toHaveLength(1));
expect(classificationWrites[0]).toMatchObject({
assetId: 'asset-hero',
tags: ['主角'],
});
// 保存不关窗是既有约定(「添加」可连续执行),关窗走头部 ×。
fireEvent.click(
within(dialog).getByRole('button', { name: '关闭编辑素材标签' }),
);
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '编辑素材标签' })).toBeNull(),
);
// 保存后打开聊天输入区的 `@` 选择器:标签行就是依赖 manifest `tags` 的统计。
const picker = await openChatPicker();
await waitFor(() => expect(pickerTagChips()).toEqual(['主角1']));
expect(pickerCandidateNames()).toEqual(['hero', 'bg']);
// 点选这个标签:候选要按新标签收窄(统计值 1 与候选集合同源)。
fireEvent.click(within(picker).getByRole('button', { name: '主角' }));
await waitFor(() => expect(pickerCandidateNames()).toEqual(['hero']));
});
it('未标注标签时聊天 @ 选择器不渲染任何标签 chip', async () => {
installHostTauri();
render(<TagStatsHost planningStartMode />);
await openChatPicker();
expect(pickerTagChips()).toEqual([]);
expect(pickerCandidateNames()).toEqual(['hero', 'bg']);
});
});
@@ -0,0 +1,56 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
/**
* 选中资源工具条上的「两条分隔线」回归钉子。
*
* 成因:共享工具条 `ImageCanvasSelectedLayerToolbarView.tsx` 会在 `showQuickEdit` 时于
* 「快速编辑」之后输出一条分隔线,同时为 `extraActions` 自动生成一条前置分隔线
* `const extraActionDivider = extraActions ? … : null`)。AGC 资源卡是"快速编辑可用 +
* 中间那些动作未接通不渲染"的组合,两条于是直接相邻,用户看到两条竖线。
*
* 修法是样式层去重(相邻的两条只显示一条)。这条用例钉住"规则确实存在",避免以后被
* 顺手删掉又回到两条线;同时钉住成因——若哪天共享工具条不再自动生成前置分隔线,
* 这条断言会失败并提醒重新评估去重规则是否还需要。
*/
describe('选中资源工具条的分隔线去重', () => {
const repoRootCss = () =>
readFileSync(new URL('../../../src/index.css', import.meta.url), 'utf8');
const sharedToolbarSource = () =>
readFileSync(
new URL(
'../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx',
import.meta.url,
),
'utf8',
);
it('相邻的两条分隔线只显示一条', () => {
const css = repoRootCss().replace(/\s+/g, ' ');
expect(css).toContain(
'.image-canvas-editor__floating-toolbar-divider + .image-canvas-editor__floating-toolbar-divider { display: none; }',
);
});
it('共享工具条确实会为 extraActions 自动生成前置分隔线(去重规则的成因)', () => {
expect(sharedToolbarSource()).toContain(
'const extraActionDivider = extraActions ?',
);
});
it('「快速编辑」之后那条分隔线仍由共享工具条自己输出(去重的另一端)', () => {
const source = sharedToolbarSource();
expect(source).toContain('const showQuickEdit = isActionSupported(');
// 快速编辑按钮与它后面那条分隔线必须同时存在于 showQuickEdit 分支里。
const quickEditBlock = source.slice(
source.indexOf('{showQuickEdit ? ('),
source.indexOf('{canRasterEdit && isActionSupported('),
);
expect(quickEditBlock).toContain('label="快速编辑"');
expect(quickEditBlock).toContain(
'image-canvas-editor__floating-toolbar-divider',
);
});
});
@@ -8537,3 +8537,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 影响范围:`src-tauri/src/project/manifest.rs``src-tauri/src/project/manifest/version_binding_rewrite_tests.rs`(新增)、`src-tauri/src/project/version_resource_replacement.rs``src-tauri/src/tests/version_resource_replacement.rs``src/features/resource-canvas/resourceVersionReplacement{Model,Transport}.ts``view/project-development/index.tsx``src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx``apps/ai-game-creator-shell/src/styles.css`
- 验证方式:放行通道定向 7 条 + 替换定向 8 条 + `shared-contracts` 20 条;**变异验证四条**(去掉"未放行版本整条相等"→两周转红;放行集合改成整个版本数组→"未放行版本"转红;去掉长度检查→"不增不删"转红;准入删掉 category→硬门禁与候选两条转红,均已实测并还原)。前端 13 条(模型 9 + 真链路 4)。门禁:AGC 全量 1231 passed / 4 skipped / 0 failed、共享美术画布组件 1385 passed、`ai-game-creator-shell:typecheck`(含 check-config)、`cargo check --locked --all-targets``check:encoding``git diff --check` 全绿。
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`(§3.2 / §5.3 / §5.4 存储边界 / §7.8 验收)、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`(同章节)、`docs/technical/【技术方案】AGC资源派生与非破坏性编辑合同-2026-09-09.md``docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`、Issue #309
## 2026-09-12 改完素材标签聊天侧不刷新:聊天宿主跟随工作台壳的清单快照
- 背景:用户报「对素材修改标签之后统计信息不会更新(例如聊天框里的点选)」。核实后的现状:依赖 manifest `assets[].tags` 的派生数据只有三处 —— `@` 选择器的标签 chip 计数与候选(`ResourceReferenceInput``scopeTagItems` / `pickerReferences``resourceReferences.ts::resourceReferenceTagLibrary` ← 共享 `buildGameCreationAppAssetTagLibrary`)、资源画布筛选浮层的标签库(`resourceCanvasFilterModel.ts::buildResourceFilterTagOptions`)、资源卡与信息浮层的小字(`resourceCanvasInfoModel.ts`)。后两者都由 `ProjectDevelopmentView``manifest` prop 派生,写入后重读即生效;**只有聊天侧停在旧清单上**。
- 根因(机制 + 证据):`App`(聊天宿主,由壳以 `ProjectSupervisor` 传入)持有自己的一份 `manifest` state,初值取自 `initialProjectManifest`。壳(`WorkspaceLauncherShell`)在资源命令(改标签 / 改类型 / 重命名 / 删素材)后确实重读一次 manifest 并按 CAS 归并进 `currentProjectContext`,也把归并结果继续以 `initialProjectManifest` 传下来 —— 但那是 `useState` 的初值,**壳里换了新清单不会再进来**:`App` 里该 prop 只出现在两处 `useState(... ?? seedManifest)` 初始化上,没有任何跟随它的同步,`manifest` 只由聊天侧自己的动作(`refreshManifest` / 自身命令回执)改写;而资源画布那条写入既不经过聊天侧,`update_local_project_resource_classification` 也不发 `game-creator-manifest-invalidated`(该事件只由 `agent/runtime_driver/entrypoints.rs` 的 Runtime emitter 发送)。于是 `chatProjectAssets` 每帧都是同一份旧 `manifest.assets``assetReferences` / `scopeReferences` / `scopeTagItems` 逐层命中旧值,标签 chip 与候选都不动。证据:新增用例在资源画布改完标签保存后,聊天 `@` 选择器的标签行为空(`expected [] to deeply equal ['主角1']`),而画布侧同一份清单已是 `tags: ['主角']`
- 决策:在 `App` 里给这份壳快照补一条同步(`useEffect` + `manifestRef`):同一 `projectId`、内容确实变了才 `setManifest`;聊天侧自己的写入会被壳原样回传,因此必须按内容短路(只比身份会让两边无意义地互相推一轮)。不新增第二份标签统计口径、不在业务页再造一份清单状态、不靠整页刷新或强制重挂载;壳那份快照始终来自磁盘重读 + CAS 归并,所以不会把聊天侧带到更旧的版本上。
- 已知未覆盖(**本轮有意不做**):`?supervisor-chat` 独立聊天窗口(`main.tsx` 仅在 `import.meta.env.DEV` 下开)没有壳、拿不到这份 prop,仍只靠 Tauri 失效事件刷新。要覆盖它得在 Rust 写入命令后补发 `game-creator-manifest-invalidated`(另一条因果点,且无法在 vitest 里先红后绿)。
- 影响范围:`apps/ai-game-creator-shell/src/App.tsx`(新增跟随 effect + `manifestRef`)、`apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx`(新增真宿主链路用例)。manifest 契约、Rust、SpacetimeDB 均不动。
- 验证方式:`npm run test -- apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx`(先红:`expected [] to deeply equal ['主角1']`;修复后 2 条绿)+ 11 个资源相关套件 124 条 + `appSurface.test.ts` 415 条 + `npm run typecheck` + `npm --prefix apps/ai-game-creator-shell run typecheck` + `npm run check:encoding` + `git diff --check`。变异验证(已实测并还原):把跟随 effect 改回「不跟随壳快照」→ 新增用例第一条变红(`expected [] to deeply equal ['主角1']`)。
- 关联文档:无(本轮不涉及跨端契约、PRD 或后端口径变化)。
@@ -4063,6 +4063,14 @@
- 验证:在真实 `AuthUiContext.platformTheme="dark"` Provider 下打开 portal 弹窗,断言 auto 弹窗的 overlay 携带暗色主题类,固定浅色弹窗只携带浅色主题类,panel 与遮罩的 computed background 均非透明;同时断言 `portalTheme="none"` 的黑底预览不被平台 remap。
- 关联:`src/components/project/ProjectGalleryView.tsx``src/components/common/PlatformToolModalShell.tsx``src/components/common/UnifiedModal.tsx`
## 共享弹窗的宿主类必须落在所有宿主都加载的样式表里
- 现象:AGC 资源工作台点「替换素材」弹出的「选择替换素材」弹窗,面板本体完全透明,背后模糊的资源画布直接透过卡片、搜索框和底部操作条;网页端同一个弹窗正常。
- 原因:面板用的是共享 `UnifiedModal` 产出的 `platform-modal-shell`(遮罩是 `platform-overlay`),而这两条规则只写在网页端整站样式表 `src/index.css` 里。AGC 是独立宿主,只加载 `@genarrative/shared/styles.css`(即 `packages/shared/src/components/styles.css`)与 `packages/shared/src/theme.css`;类名挂上去了,规则一条都没命中,于是面板没有底色、边框和阴影。
- 处理:把 `platform-modal-shell` / `platform-modal-backdrop` / `platform-overlay` 三条宿主类规则从 `src/index.css` 移进 `packages/shared/src/components/styles.css`——两个宿主都 import 这张表,规则只需要存在一份。共享组件产出的宿主类一律放进"所有宿主都会加载"的共享表;只在某个宿主里缺样式时,不要顺手写进它的业务样式表。`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css` 那种逐条照抄只适用于确实只属于该宿主的 `image-canvas-editor__*` 类。
- 验证:`apps/ai-game-creator-shell/tests/projectAssetPickerDialogShellStyle.test.tsx``src/main.tsx` 按真实 import 关系解析出 AGC 加载的样式表清单,断言清单里有 `platform-modal-shell` / `platform-overlay` 规则、声明引用主题 token、token 在 light 块里 alpha ≥ 0.9,并断言这三条规则只在一张表里各定义一次。把规则删掉、或搬回 `src/index.css`,用例都会变红;jsdom 不计算外部 CSS,属声明级的弱验证。真机判据:弹窗面板是不透明暖白底,背后画布不透出,与其它平台弹窗底色一致。
- 关联:`packages/shared/src/components/styles.css``src/index.css``src/components/common/UnifiedModal.tsx``apps/ai-game-creator-shell/tests/projectAssetPickerDialogShellStyle.test.tsx`
## 自主试玩失败后的修复责任不能同时落给总控和专业 Agent
- 现象:专业 Agent 已交付新 revisionProject Supervisor 的固定试玩已通过全部业务交互断言,但双视口可见性等外围门禁仍失败;下一轮 Provider 被要求直接 `file.patch`,随后又被 `orchestratorOnlyAfterDelegation` 正确拦截,格式修复耗尽后父 run 失败且没有最终回复。
+29
View File
@@ -974,6 +974,35 @@ textarea.genarrative-ui-text-field__control {
overflow: auto;
}
/*
* 平台弹窗外壳:共享弹窗组件(网页端 `src/components/common/UnifiedModal`、
* `PlatformMudPointConfirmDialog` 等)产出的宿主类。
*
* 这三条原先只写在网页端整站样式表 `src/index.css`。AGC 是独立宿主,只引入本文件与
* `theme.css`,不引入那张整站样式表,于是弹窗面板拿到 `platform-modal-shell` 却没有任何
* `background` / `border` / `box-shadow` 声明,面板变透明、背后画布直接透出来(AGC
* 「选择替换素材」弹窗即此例)。放在这里,凡是引入本文件的宿主就一起命中;写进任一宿主的
* 业务样式里都会变成下一份平行拷贝。
*
* 依赖 `theme.css` 的 token,由祖先的 `.platform-theme--light` / `.platform-theme--dark`
* 提供:弹窗 portal 到 `document.body` 时主题类必须挂在遮罩上(`UnifiedModal.portalTheme`)。
*/
.platform-modal-shell {
border: 1px solid var(--platform-modal-border);
background: var(--platform-modal-fill);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.16);
}
.platform-modal-backdrop {
background: var(--platform-overlay-fill);
color: var(--platform-text-strong);
backdrop-filter: blur(12px);
}
.platform-overlay {
background: var(--platform-overlay-fill);
}
.genarrative-ui-empty-state {
display: grid;
min-height: 10rem;
+12 -16
View File
@@ -5090,6 +5090,18 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
height: 1.125rem;
}
/* 紧挨着的两条分隔线只保留一条
共享工具条会在快速编辑之后输出一条`ImageCanvasSelectedLayerToolbarView.tsx`
showQuickEdit 分支并为 `extraActions` 再自动生成一条前置分隔线
同文件 `extraActionDivider`AGC 资源卡恰好是"快速编辑可用 + 中间那些动作未接通
不渲染"的组合于是这两条直接相邻用户看到两条竖线两条相邻的分隔线在视觉上
本来就没有意义所以在样式层去重不改任何 JSX 结构对两端美术画布与资源画布
都成立也不会隐藏任何真正起分隔作用的那一条 */
.image-canvas-editor__floating-toolbar-divider
+ .image-canvas-editor__floating-toolbar-divider {
display: none;
}
.image-canvas-editor__bottom-toolbar-option-wrap {
display: inline-flex;
}
@@ -9511,12 +9523,6 @@ button.image-canvas-editor__reference-chip:disabled {
color: var(--platform-bottom-nav-primary-text);
}
.platform-modal-shell {
border: 1px solid var(--platform-modal-border);
background: var(--platform-modal-fill);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.16);
}
.platform-mobile-home-welcome-overlay {
background: radial-gradient(
circle at 50% 18%,
@@ -9580,16 +9586,6 @@ button.image-canvas-editor__reference-chip:disabled {
line-height: 1.75;
}
.platform-modal-backdrop {
background: var(--platform-overlay-fill);
color: var(--platform-text-strong);
backdrop-filter: blur(12px);
}
.platform-overlay {
background: var(--platform-overlay-fill);
}
.platform-desktop-shell {
position: relative;
min-width: 0;