From bb91e8909db23ea39a078f536dde6a0b293bdef3 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 12 Sep 2026 18:23:02 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=80=89=E4=B8=AD?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=B7=A5=E5=85=B7=E6=9D=A1=E5=87=BA=E7=8E=B0?= =?UTF-8?q?=E4=B8=A4=E6=9D=A1=E7=9B=B8=E9=82=BB=E5=88=86=E9=9A=94=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 成因:共享工具条在 showQuickEdit 分支于「快速编辑」后输出一条分隔线,同时为 extraActions 自动生成前置分隔线(extraActionDivider);AGC 资源卡是「快速编辑可用 + 中间动作未接通不渲染」的组合,两条因此相邻 - 修法:样式层去重(相邻的两条只显示一条),不改 JSX 结构,对美术画布与资源画布两端都成立,也不会隐藏任何真正起分隔作用的那一条 - 新增契约用例 selectedLayerToolbarDividerDedupe.test.ts 钉住去重规则与它的成因两端 - 验证:该用例 3 passed;变异验证(删掉去重规则)→ 1 failed,还原后 3 passed --- .../selectedLayerToolbarDividerDedupe.test.ts | 56 +++++++++++++++++++ src/index.css | 12 ++++ 2 files changed, 68 insertions(+) create mode 100644 apps/ai-game-creator-shell/tests/selectedLayerToolbarDividerDedupe.test.ts diff --git a/apps/ai-game-creator-shell/tests/selectedLayerToolbarDividerDedupe.test.ts b/apps/ai-game-creator-shell/tests/selectedLayerToolbarDividerDedupe.test.ts new file mode 100644 index 000000000..feca4d761 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/selectedLayerToolbarDividerDedupe.test.ts @@ -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', + ); + }); +}); diff --git a/src/index.css b/src/index.css index 0c81a2eb7..6d7f49b7c 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } From b498c2780ef8349072ccb6b7bb50523bdf5dbd89 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 12 Sep 2026 18:48:04 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=94=B9=E5=AE=8C?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E6=A0=87=E7=AD=BE=E5=90=8E=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E4=BE=A7=E6=A0=87=E7=AD=BE=E7=BB=9F=E8=AE=A1=E4=B8=8E=E5=80=99?= =?UTF-8?q?=E9=80=89=E4=B8=8D=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App.tsx:新增跟随工作台壳清单快照的 effect(`manifestRef` + 同一 `projectId` 且内容确实变了才 `setManifest`), 让聊天侧 `@` 选择器的标签计数与候选在资源命令写入后跟上画布那份清单 - tests/resourceTagStatsRefresh.test.tsx(新增):真宿主链路用例 —— 资源画布「编辑标签」保存后, 聊天 `@` 选择器出现该标签 chip 与计数、并按它收窄候选;反向用例钉住未标注标签时不渲染标签 chip - docs/project-memory/shared-memory/decision-log.md:记录标签派生数据的三处来源与各自数据源、 根因(`initialProjectManifest` 只是 `useState` 初值,壳里换清单不会再进来)、修复口径与已知未覆盖项 --- apps/ai-game-creator-shell/src/App.tsx | 24 ++ .../tests/resourceTagStatsRefresh.test.tsx | 303 ++++++++++++++++++ .../shared-memory/decision-log.md | 10 + 3 files changed, 337 insertions(+) create mode 100644 apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 32813437e..f7f4a6ef6 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -628,6 +628,30 @@ export function App({ const [manifest, setManifest] = useState( 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 ? '已初始化' : '未初始化', ); diff --git a/apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx b/apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx new file mode 100644 index 000000000..b21591830 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx @@ -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 ( + undefined} + onProjectsOpen={() => undefined} + onManifestChange={onManifestChange} + supervisor={ + + } + /> + ); +} + +function installHostTauri() { + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath: PROJECT_PATH, + }); + const classificationWrites: Array> = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + 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(); + + // 先在画布上打开「编辑素材标签」面板改标签:与用户的操作路径一致。 + 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(); + + await openChatPicker(); + expect(pickerTagChips()).toEqual([]); + expect(pickerCandidateNames()).toEqual(['hero', 'bg']); + }); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index fcba1ad8f..1c7117181 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 或后端口径变化)。 From 0749241908f5dbf17010ec50936e538d9a1bcab6 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 12 Sep 2026 18:48:31 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=E3=80=8C=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E6=9B=BF=E6=8D=A2=E7=B4=A0=E6=9D=90=E3=80=8D=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E9=9D=A2=E6=9D=BF=E9=80=8F=E6=98=8E=EF=BC=9A=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E5=BC=B9=E7=AA=97=E5=A4=96=E5=A3=B3=E8=A7=84=E5=88=99?= =?UTF-8?q?=E7=A7=BB=E5=85=A5=E5=85=B1=E4=BA=AB=E6=A0=B7=E5=BC=8F=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:弹窗面板用的 `platform-modal-shell`(遮罩 `platform-overlay`)规则原先只写在网页端整站样式表 src/index.css;AGC 只加载 packages/shared/src/components/styles.css 与 theme.css,类名挂上了但一条声明都没命中,面板因此无底色、无边框、无阴影,背后模糊的资源画布直接透出 - 把 platform-modal-shell / platform-modal-backdrop / platform-overlay 三条宿主类规则从 src/index.css 移进 packages/shared/src/components/styles.css:两个宿主都 import 这张表,规则只保留一份,不在 AGC 业务样式里复制平行副本;三处声明逐字照搬,网页端层叠结果不变 - 新增 apps/ai-game-creator-shell/tests/projectAssetPickerDialogShellStyle.test.tsx:从 src/main.tsx 按真实 import 关系解析出「AGC 到底加载了哪些样式表」,断言清单里有这三条规则、背景引用主题 token、light 主题下 --platform-modal-fill 各色停 alpha ≥ 0.9;DOM 契约断言面板挂 platform-modal-shell、遮罩带 platform-overlay 与 platform-theme--light;并断言这三条规则只在共享表里各定义一次(禁止平行拷贝) - docs/project-memory/shared-memory/pitfalls.md 记录「共享弹窗的宿主类必须落在所有宿主都会加载的样式表里」的排障经验 --- ...rojectAssetPickerDialogShellStyle.test.tsx | 367 ++++++++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 8 + packages/shared/src/components/styles.css | 29 ++ src/index.css | 16 - 4 files changed, 404 insertions(+), 16 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/projectAssetPickerDialogShellStyle.test.tsx diff --git a/apps/ai-game-creator-shell/tests/projectAssetPickerDialogShellStyle.test.tsx b/apps/ai-game-creator-shell/tests/projectAssetPickerDialogShellStyle.test.tsx new file mode 100644 index 000000000..c5d833cf5 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/projectAssetPickerDialogShellStyle.test.tsx @@ -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; +}; + +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; + }; + // `@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+'(?[^']+\.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+'(?[^']+)'/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 { + const declarations = new Map(); + 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?\((?[^)]*)\)/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( + {}} + 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 边框与投影。 + */ diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 60bec3e9d..dc0938ba1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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 已交付新 revision,Project Supervisor 的固定试玩已通过全部业务交互断言,但双视口可见性等外围门禁仍失败;下一轮 Provider 被要求直接 `file.patch`,随后又被 `orchestratorOnlyAfterDelegation` 正确拦截,格式修复耗尽后父 run 失败且没有最终回复。 diff --git a/packages/shared/src/components/styles.css b/packages/shared/src/components/styles.css index 9b1cd337a..2b6ae71a6 100644 --- a/packages/shared/src/components/styles.css +++ b/packages/shared/src/components/styles.css @@ -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; diff --git a/src/index.css b/src/index.css index 0c81a2eb7..322c9e7ea 100644 --- a/src/index.css +++ b/src/index.css @@ -9511,12 +9511,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 +9574,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;