AGC 资源画布与替换改造 V3.0 #316

Open
suzmii wants to merge 323 commits from feat/agc-canvas-resource-workbench-v3 into master
Member

AGC 资源工作台 V3.0

关联 Tracking Issue:#309(范围、方案与进度的唯一权威)
分支:feat/agc-canvas-resource-workbench-v3
状态:draft(保持 WIP)


一、交付范围与完成情况

编号 子项 状态
C1 游戏项目资源使用记录 已完成(恒等绑定口径,运行期观察侧车已撤)
C2 资源分类与自定义标签 已完成
C3 美术画布功能迁移 已完成(转场动画 + 交互 1:1 复刻 + 画布基础能力;生成入口已接 video / sound-effect / background-music 三类,「信息」只读浮层已回归)
C4 聊天通用引用 已完成
C5 素材模型与生命周期 已完成
C6 替换关系与替换队列 版本级替换已按 PRD 实现(替换周边取消,见下)
C7 运行模块版本切换 已完成
C8 AI 润色与发送前提醒 已完成
C9 参考图弹窗 已完成
C10 asset-canvas 彻底删除 已完成
C11 PR #283 收口 已完成

本次改动要点

  • C1versions[].resourceBindings 即"该版本用了哪些资源"的权威记录,slotId 恒为 asset:{assetId};运行期资源观察方案(代码注入 / Phaser wrapper / Vite 虚拟模块 / 脚手架哈希校验 / .agent 使用侧车)整体撤回,project/asset_usage.rs 与其 16 条测试按四不写删除。
  • C5delete_local_project_asset 新增 deleteReferencedVersions,三分支语义(无引用直接删 / 有引用且不勾选只删素材并保留悬空绑定 / 勾选则连带删除引用它的版本)在同一次 manifest 写入内原子完成;新增只读命令 read_local_project_asset_read_references(列出被哪些版本使用)驱动二次确认弹窗。版本数组"只允许追加"的唯一例外由 mutate_manifest_at_allowing_version_removals 承担,放行集合按写入前 manifest 求值、被放行版本不得出现在追加段。
  • C3:资源卡点击 = 选中并在卡片旁浮出选中工具条(复用美术画布 ImageCanvasSelectedLayerToolbarView,支持 Shift/Ctrl/Meta 多选);快速编辑走浮层面板,先 normalize_local_project_raster_resource 正规化再 derive_local_project_resource(editKind='image-reference') 产出新素材;删除可编辑「资源详情」面板与「编辑资源」全屏路由——口径澄清:只读「信息」浮层已回归(工具条「信息」动作,与运行页签共用一份只读字段),取消的仍只是可编辑详情面板与全屏编辑路由;分类与标签、重命名入口搬到工具条;资源卡的 @ 引用入口也挪进工具条(卡片右上角圆钮退役);「当前使用」的资源卡改为真发光;新增撤销重做(只作用于卡片布局,不回滚素材)、框选、独立资源面板(预览 / 上传 / 下载 / 多选)。
  • C6(版本级替换):入口在资源卡选中工具条「替换素材」,只对「manifest 资产 + 被当前版本绑定」的素材放行(判据复用 isResourceUsedByCurrentVersion未动共享 union 与 supportedActions);后端新增 read_local_project_version_resource_replacement_candidatesreplace_local_project_version_resource 两项命令。落盘 = 追加下一迭代版本parentVersionId + createdReason: 'resource-replacement'),既有版本记录一个字节不改;三项兼容性(categoryEqual / subtypeEqual / sizeSpecEqual)由后端权威计算,前端只呈现不重算,候选弹窗列全部候选、不兼容项渲染但禁用并给出不等维度。不新增字段:替换前后身份按 parentVersionId + createdReason 推导(父−子 = {源素材},子−父 = {替换素材})。如实声明降级sizeSpecEqual 在真实数据上退化为「媒体格式相等」(manifest 资产表没有 width / height / durationMs,写入侧几乎全部写 imageSequenceFrames: None),因此 png ↔ webp 会被拒——刻意接受的最小实现。成功后切到新版本并重读 manifest,不重载预览(守 PRD §3.2)。落在分支 feat/agc-resource-replace(4 提交 / 18 文件 / +2771 −37,HEAD b1377572d),待并入本 PR。
  • C4@ 面板两个页签(当前版本素材 / 全部画布素材)各自独立筛选与搜索;改名后引用芯片与候选列表刷新;跨会话草稿恢复后光标落到文本末尾。
  • C7:版本状态唯一一份放在 WorkspaceLauncherShell,运行模块右上角 GameRunVersionPicker(无版本不渲染);切换版本时资源卡"当前使用"高亮与 @ 面板同步,并重载当前预览。
  • C8polish_local_project_prompt(入参有界 4000/1000 字符、输出 2048 token),润色回填 / 恢复原文 / 失败保留原文 / 可重复润色;发送前提醒为独立 portal 弹窗(AI 润色 / 使用原文提交 / 关闭 / 不再提醒),偏好存本机。计费不自建:泥点扣费仍走 server-rs 的 /api/llm/* 路由,本次 server-rs 零改动。
  • C9:独立参考图弹窗(网格、多选、筛选、搜索、清空、确认 / 取消),筛选条抽到 packages/shared 共享层并与画布共用同一组件、各自独立状态;提交时把参考图素材 ID 作为快照落进既有 generationInputs.references,零后端改动。
  • WP7 收尾agc_list_registered_assets 投影暴露 category/tags(不在投影层重新派生,避免覆盖用户显式分类);删除 normalize_local_project_raster_resource 的过渡 allowlist 条目(解除条件已由 C3 快速编辑满足)。

本批新增可交付项(均为已提交事实)

  • 替换素材入口与命令:见上 C6 条(分支 feat/agc-resource-replace)。
  • @引用 挪进工具条:删除资源卡右上角的 @ 引用圆钮(连同 44×44 热区伪元素与全部样式一起退役),选中资源工具条新增「@引用」动作,出站负载与来源逐字未变(source: 'resource-card');键盘通路(聚焦后回车)与点击通路负载逐字相同、每次激活只派发一次。
  • 「信息」只读浮层:工具条新增「信息」动作(排在「@引用」之后、「编辑标签」之前,非模态,再点一次收起,aria-pressed 反映开合),与运行页签「信息展示」共用 ResourceInfoFieldsView、字段清单只在 resolveResourceInfoFieldRows 单点定义;字段为名称 / 路径 / 类型 + 只读分类与标签,刻意不含来源任务、资产 ID 等 manifest 内部标识。
  • 当前版本资源发光.game-resource-card.is-current-version 从描边改为真发光(外向扩散层 3px/32% + 20px/34%)并新增呼吸关键帧(prefers-reduced-motion 下关动画但保留可见档位);顺带修掉两处既有缺陷——「点击版本卡后高亮绑定资源」这条规则因卡片本体 border: 0 从未生效,以及当前版本卡被选中 / hover 同权重规则盖住看不出选中。
  • 素材润色两处:新增 ResourcePromptPolishSlot生成素材弹窗快速编辑浮层共用(取数走 AGC 侧 usePromptPolish,与聊天框同款失败态:失败保留原文、可重试);请求身份绑定铸造时的那句提示词(resolveResourceEditRequestIdentityrequest_fingerprint 含 prompt,提示词变了就重铸 operationId / 幂等键);超上限先按类型截断(背景音乐 140 / 音效 1900 / 视频与角色动画 4000 / 其余 32000)再回填并给出「已按长度上限截断」,不静默丢内容。
  • 预览门禁双通路IntersectionObserver 之外新增 sweepVisiblePreviews() 可见性兜底扫描,按与 observer 对齐的视口几何判据(含 160px 余量)独立复核登记表,只补 idleloading/queued/loaded/failed 一律不碰),仍走同一条队列与同一套去重 / 优先级 / 全局 3 槽 / 4864 MiB 预算,不退化成全量预读;收口了 2a157ea6f 引入的回归(observer 可能永不建出,导致整栏 51 项只剩占位图标)。
  • DirectProject 历史格式读侧兼容 + 写侧统一:读侧白名单兼容格式切换前的旧行(只认 schemaVersion=game-creator-conversation.v1、无 type、role 落在写入器自己的角色集合内、content 为非空字符串的行;tool 旧行已识别但不进 Codex 上下文),其余形状继续失败关闭;写侧不再往共用的 project.jsonl 投影旧格式行,两条对话链互读对方的行、混合历史不再双向失败关闭;历史格式类失败给出专门提示并按不可重试处理(打开 / 读取类 IO 失败仍可重试)。
  • 端到端验收文档docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md(新增 257 行)。

二、门禁实测

门禁 结果
npm run ai-game-creator-shell:typecheck(含 check-configskill-pack exit 0
npm run test -- apps/ai-game-creator-shell/tests 74 files / 1092 passed / 4 skipped / 0 failed
npm run test -- src/components/image-editor 88 files / 1385 passed / 0 failed
cargo check --all-targets exit 0
定向 cargo test --test-threads=1 asset_export 5、asset_rename 9、asset_delete 7、bridge_registered_resource 2 —— 全 0 failed
npm run check:encoding 4374 文件通过
git diff --check exit 0

既有环境失败(非本次引入,已用纯净基线证明)cargo test 并行跑 project:: 约 10 条环境依赖用例失败;单线程纯净基线同样有 4 条(npm 构建 2 + 本地 preview 2)。仓库正式门禁口径是 --test-threads=1

三、明确未做(如实列出,未留任何半成品入口)

  1. 画布「生成入口」只接了能真正落地的三类:video / sound-effect / background-music(RESOURCE_CANVAS_GENERATION_OPTIONS 与 Rust resolve_resource_edit_source 的 create 分支一一对应)。图片无源生成仍被后端硬拦(「当前资源类型不支持无源生成」),所以入口不提供图片选项——宁可少一个入口,也不给一个提交必然失败、或拿图片参数糊弄用户的按钮。工具条上未接通的 7 个动作(重绘 / 裁剪扩图 / 去背景 / 像素完美 / 切图集 / 提取 UI 素材 / 角色动画 / 下载已改为显式保存)按 opt-in 机制不渲染按钮
  2. C7 只做记录层 + UI 层:切换版本即重载当前预览。要做到"历史版本独有的资源集合被运行时按版本加载",需要新增版本化资源解析机制(版本化清单 + 预览进程按版本解析路径),本轮未实现。
  3. "首轮进度投影":仅在交付范围的"应该"清单出现,无编码级判据;现有底部 Agent 状态栏已覆盖该可见性。
  4. 浮出工具条的视觉位置需要一次真机核验:jsdom 不执行 Web Animations API,自动化只能覆盖结构与 class;"贴卡片浮出、层级、可点、窄屏不溢出"需人工确认。

四、已知边界与后续项

  • 共享工具条 ImageCanvasSelectedLayerToolbarViewImageCanvasGenerationModel →(值导入 ApiClientError)→ src/services/apiClienthostBridge 的跨端耦合会把主仓 service 层编进 AGC bundle。已核实该链在 Tauri 中无导入即执行副作用,并在 AGC vite-env.d.ts 补了跨端全局声明;彻底解耦的正解是把 canOpenRedrawPanel / isQuickEditSupportedLayer 两个谓词下沉到 packages/image-canvas-core
  • 素材重命名不改游戏源码里对旧 assets/<name> 的引用(未做引用扫描与自动替换),后果由用户自负。
  • cargo fmt --check 对未改动文件也存在既有格式漂移,未在本次顺手全仓格式化。
# AGC 资源工作台 V3.0 > 关联 Tracking Issue:#309(范围、方案与进度的唯一权威) > 分支:`feat/agc-canvas-resource-workbench-v3` > 状态:draft(保持 WIP) --- ## 一、交付范围与完成情况 | 编号 | 子项 | 状态 | |-|-|-| | **C1** | 游戏项目资源使用记录 | **已完成**(恒等绑定口径,运行期观察侧车已撤) | | **C2** | 资源分类与自定义标签 | **已完成** | | **C3** | 美术画布功能迁移 | **已完成**(转场动画 + 交互 1:1 复刻 + 画布基础能力;生成入口已接 video / sound-effect / background-music 三类,「信息」只读浮层已回归) | | **C4** | 聊天通用引用 | **已完成** | | **C5** | 素材模型与生命周期 | **已完成** | | **C6** | 替换关系与替换队列 | **版本级替换已按 PRD 实现**(替换周边取消,见下) | | **C7** | 运行模块版本切换 | **已完成** | | **C8** | AI 润色与发送前提醒 | **已完成** | | **C9** | 参考图弹窗 | **已完成** | | **C10** | asset-canvas 彻底删除 | **已完成** | | **C11** | PR #283 收口 | **已完成** | ### 本次改动要点 - **C1**:`versions[].resourceBindings` 即"该版本用了哪些资源"的权威记录,`slotId` 恒为 `asset:{assetId}`;运行期资源观察方案(代码注入 / Phaser wrapper / Vite 虚拟模块 / 脚手架哈希校验 / `.agent` 使用侧车)整体撤回,`project/asset_usage.rs` 与其 16 条测试按四不写删除。 - **C5**:`delete_local_project_asset` 新增 `deleteReferencedVersions`,三分支语义(无引用直接删 / 有引用且不勾选只删素材并保留悬空绑定 / 勾选则连带删除引用它的版本)在**同一次 manifest 写入**内原子完成;新增只读命令 `read_local_project_asset_read_references`(列出被哪些版本使用)驱动二次确认弹窗。版本数组"只允许追加"的唯一例外由 `mutate_manifest_at_allowing_version_removals` 承担,放行集合按写入前 manifest 求值、被放行版本不得出现在追加段。 - **C3**:资源卡点击 = 选中并在卡片旁浮出选中工具条(复用美术画布 `ImageCanvasSelectedLayerToolbarView`,支持 Shift/Ctrl/Meta 多选);快速编辑走浮层面板,先 `normalize_local_project_raster_resource` 正规化再 `derive_local_project_resource(editKind='image-reference')` 产出新素材;**删除可编辑「资源详情」面板与「编辑资源」全屏路由**——**口径澄清:只读「信息」浮层已回归**(工具条「信息」动作,与运行页签共用一份只读字段),**取消的仍只是可编辑详情面板与全屏编辑路由**;分类与标签、重命名入口搬到工具条;资源卡的 `@` 引用入口也挪进工具条(卡片右上角圆钮退役);「当前使用」的资源卡改为真发光;新增撤销重做(只作用于卡片布局,不回滚素材)、框选、独立资源面板(预览 / 上传 / 下载 / 多选)。 - **C6(版本级替换)**:入口在资源卡选中工具条「替换素材」,只对「manifest 资产 + 被当前版本绑定」的素材放行(判据复用 `isResourceUsedByCurrentVersion`;**未动**共享 union 与 `supportedActions`);后端新增 `read_local_project_version_resource_replacement_candidates` 与 `replace_local_project_version_resource` 两项命令。**落盘 = 追加下一迭代版本**(`parentVersionId` + `createdReason: 'resource-replacement'`),既有版本记录一个字节不改;三项兼容性(`categoryEqual` / `subtypeEqual` / `sizeSpecEqual`)由后端权威计算,前端只呈现不重算,候选弹窗列全部候选、不兼容项渲染但禁用并给出不等维度。**不新增字段**:替换前后身份按 `parentVersionId` + `createdReason` 推导(父−子 = {源素材},子−父 = {替换素材})。**如实声明降级**:`sizeSpecEqual` 在真实数据上退化为「媒体格式相等」(manifest 资产表没有 `width / height / durationMs`,写入侧几乎全部写 `imageSequenceFrames: None`),因此 `png ↔ webp` 会被拒——刻意接受的最小实现。成功后切到新版本并重读 manifest,**不重载预览**(守 PRD §3.2)。落在分支 `feat/agc-resource-replace`(4 提交 / 18 文件 / +2771 −37,HEAD `b1377572d`),待并入本 PR。 - **C4**:`@` 面板两个页签(当前版本素材 / 全部画布素材)各自独立筛选与搜索;改名后引用芯片与候选列表刷新;跨会话草稿恢复后光标落到文本末尾。 - **C7**:版本状态唯一一份放在 `WorkspaceLauncherShell`,运行模块右上角 `GameRunVersionPicker`(无版本不渲染);切换版本时资源卡"当前使用"高亮与 `@` 面板同步,并重载当前预览。 - **C8**:`polish_local_project_prompt`(入参有界 4000/1000 字符、输出 2048 token),润色回填 / 恢复原文 / 失败保留原文 / 可重复润色;发送前提醒为独立 portal 弹窗(AI 润色 / 使用原文提交 / 关闭 / 不再提醒),偏好存本机。**计费不自建**:泥点扣费仍走 server-rs 的 `/api/llm/*` 路由,本次 server-rs 零改动。 - **C9**:独立参考图弹窗(网格、多选、筛选、搜索、清空、确认 / 取消),筛选条抽到 `packages/shared` 共享层并与画布共用同一组件、各自独立状态;提交时把参考图素材 ID 作为快照落进既有 `generationInputs.references`,零后端改动。 - **WP7 收尾**:`agc_list_registered_assets` 投影暴露 `category`/`tags`(不在投影层重新派生,避免覆盖用户显式分类);删除 `normalize_local_project_raster_resource` 的过渡 allowlist 条目(解除条件已由 C3 快速编辑满足)。 ### 本批新增可交付项(均为已提交事实) - **替换素材入口与命令**:见上 **C6** 条(分支 `feat/agc-resource-replace`)。 - **`@引用` 挪进工具条**:删除资源卡右上角的 @ 引用圆钮(连同 44×44 热区伪元素与全部样式一起退役),选中资源工具条新增「@引用」动作,出站负载与来源逐字未变(`source: 'resource-card'`);键盘通路(聚焦后回车)与点击通路负载逐字相同、每次激活只派发一次。 - **「信息」只读浮层**:工具条新增「信息」动作(排在「@引用」之后、「编辑标签」之前,非模态,再点一次收起,`aria-pressed` 反映开合),与运行页签「信息展示」共用 `ResourceInfoFieldsView`、字段清单只在 `resolveResourceInfoFieldRows` 单点定义;字段为名称 / 路径 / 类型 + 只读分类与标签,**刻意不含**来源任务、资产 ID 等 manifest 内部标识。 - **当前版本资源发光**:`.game-resource-card.is-current-version` 从描边改为真发光(外向扩散层 `3px/32%` + `20px/34%`)并新增呼吸关键帧(`prefers-reduced-motion` 下关动画但保留可见档位);顺带修掉两处既有缺陷——「点击版本卡后高亮绑定资源」这条规则因卡片本体 `border: 0` 从未生效,以及当前版本卡被选中 / hover 同权重规则盖住看不出选中。 - **素材润色两处**:新增 `ResourcePromptPolishSlot`,**生成素材弹窗**与**快速编辑浮层**共用(取数走 AGC 侧 `usePromptPolish`,与聊天框同款失败态:失败保留原文、可重试);请求身份绑定铸造时的那句提示词(`resolveResourceEditRequestIdentity`,`request_fingerprint` 含 prompt,提示词变了就重铸 `operationId` / 幂等键);超上限先按类型截断(背景音乐 140 / 音效 1900 / 视频与角色动画 4000 / 其余 32000)再回填并给出「已按长度上限截断」,不静默丢内容。 - **预览门禁双通路**:`IntersectionObserver` 之外新增 `sweepVisiblePreviews()` 可见性兜底扫描,按与 observer 对齐的视口几何判据(含 160px 余量)独立复核登记表,**只补 `idle`**(`loading`/`queued`/`loaded`/`failed` 一律不碰),仍走同一条队列与同一套去重 / 优先级 / 全局 3 槽 / `48` 项 `64 MiB` 预算,**不退化成全量预读**;收口了 `2a157ea6f` 引入的回归(observer 可能永不建出,导致整栏 51 项只剩占位图标)。 - **DirectProject 历史格式读侧兼容 + 写侧统一**:读侧白名单兼容格式切换前的旧行(只认 `schemaVersion=game-creator-conversation.v1`、无 `type`、role 落在写入器自己的角色集合内、content 为非空字符串的行;`tool` 旧行已识别但不进 Codex 上下文),其余形状继续失败关闭;写侧不再往共用的 `project.jsonl` 投影旧格式行,两条对话链互读对方的行、混合历史不再双向失败关闭;历史格式类失败给出专门提示并按不可重试处理(打开 / 读取类 IO 失败仍可重试)。 - **端到端验收文档**:`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`(新增 257 行)。 ## 二、门禁实测 | 门禁 | 结果 | |---|---| | `npm run ai-game-creator-shell:typecheck`(含 `check-config`、`skill-pack`) | exit 0 | | `npm run test -- apps/ai-game-creator-shell/tests` | **74 files / 1092 passed / 4 skipped / 0 failed** | | `npm run test -- src/components/image-editor` | **88 files / 1385 passed / 0 failed** | | `cargo check --all-targets` | exit 0 | | 定向 `cargo test --test-threads=1` | `asset_export` 5、`asset_rename` 9、`asset_delete` 7、`bridge_registered_resource` 2 —— 全 0 failed | | `npm run check:encoding` | 4374 文件通过 | | `git diff --check` | exit 0 | **既有环境失败(非本次引入,已用纯净基线证明)**:`cargo test` 并行跑 `project::` 约 10 条环境依赖用例失败;单线程纯净基线同样有 4 条(npm 构建 2 + 本地 preview 2)。仓库正式门禁口径是 `--test-threads=1`。 ## 三、明确未做(如实列出,未留任何半成品入口) 1. **画布「生成入口」只接了能真正落地的三类**:video / sound-effect / background-music(`RESOURCE_CANVAS_GENERATION_OPTIONS` 与 Rust `resolve_resource_edit_source` 的 create 分支一一对应)。**图片无源生成仍被后端硬拦**(「当前资源类型不支持无源生成」),所以入口不提供图片选项——宁可少一个入口,也不给一个提交必然失败、或拿图片参数糊弄用户的按钮。工具条上未接通的 7 个动作(重绘 / 裁剪扩图 / 去背景 / 像素完美 / 切图集 / 提取 UI 素材 / 角色动画 / 下载已改为显式保存)按 opt-in 机制**不渲染按钮**。 2. **C7 只做记录层 + UI 层**:切换版本即重载当前预览。要做到"历史版本独有的资源集合被运行时按版本加载",需要新增版本化资源解析机制(版本化清单 + 预览进程按版本解析路径),本轮未实现。 3. **"首轮进度投影"**:仅在交付范围的"应该"清单出现,无编码级判据;现有底部 Agent 状态栏已覆盖该可见性。 4. **浮出工具条的视觉位置需要一次真机核验**:jsdom 不执行 Web Animations API,自动化只能覆盖结构与 class;"贴卡片浮出、层级、可点、窄屏不溢出"需人工确认。 ## 四、已知边界与后续项 - 共享工具条 `ImageCanvasSelectedLayerToolbarView` → `ImageCanvasGenerationModel` →(值导入 `ApiClientError`)→ `src/services/apiClient` → `hostBridge` 的跨端耦合会把主仓 service 层编进 AGC bundle。已核实该链在 Tauri 中**无导入即执行副作用**,并在 AGC `vite-env.d.ts` 补了跨端全局声明;彻底解耦的正解是把 `canOpenRedrawPanel` / `isQuickEditSupportedLayer` 两个谓词下沉到 `packages/image-canvas-core`。 - 素材重命名**不改游戏源码里对旧 `assets/<name>` 的引用**(未做引用扫描与自动替换),后果由用户自负。 - `cargo fmt --check` 对未改动文件也存在既有格式漂移,未在本次顺手全仓格式化。
suzmii self-assigned this 2026-09-09 21:47:43 +08:00
suzmii added 73 commits 2026-09-09 21:47:43 +08:00
实现资源画本统一节点转场
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
Project CI / Frontend tests (pull_request) Has been cancelled
f339b383f5
重构主画布与子画布为共享资源卡和标题栏节点

补充缩略堆叠、进入返回动画、遮罩与定位适配

同步更新资源画本 UI 技术方案文档
修复资源画本首次布局错位
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
019b1761f4
首帧测量未完成时隐藏共享场景,避免标题和卡片使用默认坐标

在首帧和资源数量变化后重新校准缩略卡布局
修复从项目入口首次进入主画布的错位
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) Failing after 10s
f883d4bf2e
移除主画布入场缩放动画避免测量和终态不一致

无效缩略卡测量结果不再直接展示共享场景
Merge branch 'master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Frontend tests (pull_request) Failing after 2m29s
Project CI / Repository checks (pull_request) Failing after 2m49s
Project CI / Backend tests (pull_request) Successful in 6m39s
Project CI / Native shell tests (pull_request) Failing after 5m51s
8323b13fd3
拆分子画布标题与本体hover高亮
Project CI / Repository checks (pull_request) Failing after 2m52s
Project CI / Frontend tests (pull_request) Failing after 2m17s
Project CI / Native shell tests (pull_request) Failing after 6m15s
Project CI / Backend tests (pull_request) Successful in 6m37s
816844065b
标题栏与本体分别作为独立对象绑定同一hover源

标题栏底边与本体顶边接缝保持默认色不高亮
调整资源画本hover高亮仅用于未展开主画布
Project CI / Repository checks (pull_request) Failing after 2m58s
Project CI / Frontend tests (pull_request) Failing after 2m33s
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
9c85a9501e
子画布展开态不再显示hover高亮线

未展开缩略卡hover时标题栏三边高亮,底边接缝保持默认色
资源卡拖拽使用模型坐标换算,消除阻力和回弹

子画布空白区域支持画布平移,保持进入退出动画连贯
美术资源初始适配不再放大过头

scene层支持Ctrl滚轮锚点缩放,普通滚轮保留翻页
统一主画布与子画布的可见渲染层和 viewport 状态管理。

将子画布改为固定视口加无限平移 world,并保证首次 fit 的可读比例。

补充转场、滚轮、viewport 和资源画本回归测试,更新技术方案文档。
新增 returning-main 阶段,区分返回主画布与进入子画布的 transform 过渡。

返回期间取消残留分页事件和进行中的子画布切换,避免返回流程被重新改写。

补充状态机与 appSurface 回归测试,并同步技术方案文档。
重构资源画本独立转场与中断处理
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
ba718e3d4b
使用独立FLIP快照层和Web Animations统一动画收尾
统一分类导航并保留滚轮节流和各画布视口
修复快照缩放与悬停导致的终点偏移
补充动画回归测试并更新资源画本技术文档
Merge branch 'master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Frontend tests (pull_request) Failing after 2m23s
Project CI / Repository checks (pull_request) Successful in 2m34s
Project CI / Native shell tests (pull_request) Failing after 4m58s
Project CI / Backend tests (pull_request) Successful in 6m51s
41c9ee7db4
分离固定交互视口与可平移缩放的内容层
支持主画布滚轮平移并保留缩放及主子画布视口记忆
补充越界拖动和滚轮回归测试并同步技术文档
修复资源分页类别缓存
Project CI / Frontend tests (pull_request) Failing after 2m47s
Project CI / Repository checks (pull_request) Successful in 3m4s
Project CI / Native shell tests (pull_request) Failing after 5m49s
Project CI / Backend tests (pull_request) Successful in 7m12s
cac3e19632
资源数组变化时重新计算资源分页类别\n避免资源总数不变但类别更新后继续使用旧分页状态
完善资源分页依赖
Project CI / Frontend tests (pull_request) Failing after 2m32s
Project CI / Repository checks (pull_request) Successful in 2m54s
Project CI / Native shell tests (pull_request) Failing after 6m19s
Project CI / Backend tests (pull_request) Successful in 6m39s
879e732b04
按资源类别签名刷新分页类别\n避免资源数量不变时使用过期类别,同时保持稳定引用避免无关编辑面板重置
修复资源保存后的自动聚焦
Project CI / Frontend tests (pull_request) Successful in 3m21s
Project CI / Repository checks (pull_request) Successful in 6m18s
Project CI / Backend tests (pull_request) Successful in 6m46s
Project CI / Native shell tests (pull_request) Successful in 17m19s
ccb3a3ab6a
将保存后资源卡的 DOM 查找范围改为资源画本管理器\n确保新派生资源和恢复资源在布局就绪后能够进入详情聚焦
资源分页复用完整资源签名以识别同数量类别变化

补充同长度类别变化回归测试
Merge remote-tracking branch 'origin/master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Repository checks (pull_request) Successful in 2m27s
Project CI / Native shell tests (pull_request) Successful in 18m40s
Project CI / Frontend tests (pull_request) Successful in 2m55s
Project CI / Backend tests (pull_request) Successful in 6m32s
0a5080dbd9
资源总览、栏目入口、返回与复位文案移除主画布和子画布概念

资源栏目可访问名称改为设计文档、美术资源等名称

同步更新相关界面测试断言
去除资源区滚轮切换栏目
Project CI / Frontend tests (pull_request) Failing after 1m49s
Project CI / Native shell tests (pull_request) Failing after 4m32s
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Backend tests (pull_request) Failing after 9s
1347b4a743
删除普通滚轮切页状态、队列与节流控制逻辑

资源区普通滚轮只平移当前视图,缩放继续由 Ctrl 或 Cmd 配合滚轮触发

同步更新 PRD、技术方案与控制器测试
Merge branch 'master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Repository checks (pull_request) Failing after 1m23s
Project CI / Native shell tests (pull_request) Failing after 4m7s
Project CI / Backend tests (pull_request) Successful in 6m28s
Project CI / Frontend tests (pull_request) Failing after 2m7s
6de536ecbd
资源依赖图同时索引远端资源身份和 local-asset 本地资源身份

本地 canonical 引用与远端候选去重后再建立依赖连线

补充本地身份解析与候选不歧义回归测试

更新资源自由画板技术方案的依赖身份合同
共享契约新增 canonical 资源类型目录和历史类型读时映射

角色动画派生结果写入 character-animation 主类型

补充角色动画落盘与 legacy 类型映射回归测试

更新资源自由画板技术方案的类型边界说明
删除project-development.suite.ts中未使用的resetButton变量,修复ESLint unused-vars错误

将projectResourceLiveIntegration.test.tsx中过期的复位资源画布断言更新为复位资源视图
Merge remote-tracking branch 'origin/master' into feat/game-agent-canvas-resource-workbench-v2
Project CI / Native shell tests (pull_request) Failing after 15m58s
Project CI / Frontend tests (pull_request) Successful in 3m48s
Project CI / Backend tests (pull_request) Successful in 8m0s
Project CI / Repository checks (pull_request) Successful in 11m18s
8a47cdfc1b
- 总览每个资源类型最多铺 3 张卡片,其余数量由栏目标题栏的“N 项”表达
- 总览摞旋转角改为固定小角度,素材变多后不再随堆叠下标无限增大
- 总览摞下标小的卡片压在上层,不再被模糊卡遮住最上面那张清晰卡
- 截断前先按搜索可见性过滤,避免搜索结果落在深处时总览显示空摞
- 资源卡片可见性观察改用真正包含卡片的容器,恢复滚动时的预览懒加载
- 补充资源画本布局单测、预览观察根回归用例和总览摞上限用例
- 新增通用素材引用编辑器与结构化引用协议

- 三个聊天入口接入 @ 候选和素材选择面板

- 资源卡新增 @引用 入口

- 运行画面新增区域点选与 runtime-region 引用

- Rust 按 manifest 二次校验并生成安全投影

- 补充前端与 Rust 定向测试
- 固定候选弹层宽度并限制高度

- 候选名称和类型标签改为单行省略

- 增加候选弹层层级和滚动条样式
- 每个素材缩略图使用独立预览 scope,避免开发模式取消后无法恢复

- 调整模型选择、@ 按钮和发送按钮的底部布局

- 聊天引用候选改为使用完整已登记素材列表

- 补充图片缩略图和 StrictMode 回归测试
- 覆盖直接 Codex 输入区的全局按钮绝对定位

- @ 按钮改为 actions 容器内静态网格项,与发送按钮保持同一基线
- 直接 Codex 输入区改为输入框下方单一控制行

- 模型选择、@ 和发送按钮使用同一 flex 布局

- 通用引用编辑器支持外部触发素材面板

- 移除依赖多个绝对定位拼接的对齐方式
- form 作为完整背景框承载输入区和底部控制行

- 输入编辑器在框内透明显示

- 模型选择、@ 和发送按钮位于同一框内底部行
- 输入区外框统一承载编辑器和底部控制行

- @ 放左侧,模型选择和发送放右侧

- 恢复输入编辑器滚动条样式

- 消息列表为底部输入框保留滚动条空间
- 类型提示改为固定定位并按输入框宽度约束

- 根据上下可用空间选择展开方向

- 避免弹层越过聊天栏覆盖其它区域
- 模型选择设置稳定最小宽度,避免被右侧 flex 压缩

- 提高模型名称文字对比度

- 固定 @ 和发送按钮宽度,保持控制行布局稳定
- 模型选择改为按内容宽度靠右排列

- 取消状态点、名称和箭头之间的异常拉伸

- 拉开模型选择与发送按钮间距
- 移除常驻滚动条和标准 scrollbar 属性

- 保留 WebKit 细滚动条样式,仅在内容超出时出现
- 输入编辑器默认高度提高到 96px,超过 140px 后滚动

- 加深并加宽滚动条拇指,避免溢出时不可见

- 同步调整消息列表底部空间
共享契约新增 canonical 资源类型目录和历史类型读时映射

角色动画派生结果写入 character-animation 主类型

补充角色动画落盘与 legacy 类型映射回归测试

更新资源自由画板技术方案的类型边界说明
资源依赖图同时索引远端资源身份和 local-asset 本地资源身份

本地 canonical 引用与远端候选去重后再建立依赖连线

补充本地身份解析与候选不歧义回归测试

更新资源自由画板技术方案的依赖身份合同
自 #283(V2.0 资源画本)迁入 .game-resource-book-* 视觉层共 523 行
该层作为 V3 资源画布的样式基础,JSX 待画布重写时接入
不包含 V2 主/子画布的行为代码
解决 #283 迁移与 master 的冲突时,.conversation-model-select [role='status'] 规则丢失了闭合大括号
导致 Tailwind 生成阶段报 Missing closing },Vite 错误覆盖层阻断页面打开
补齐闭合括号后 postcss 解析与 Vite 转换均恢复正常
用 postcss 精确比对 #283 与当前分支的 CSS 规则集合,补齐唯一缺失的 .project-supervisor-surface.is-direct-codex .conversation-model-trigger
补齐后 #283 的 1057 条规则在当前分支全部存在,postcss 解析通过
合入 feat/game-agent-canvas-resource-workbench-v2 的完整画布实现:资源画本控制器/模型/视口/布局、转场层、资源分页缓存与自动聚焦修复、资源总览卡片预览懒加载
保留本分支已有的聊天素材引用改动(引用输入区、@ 引用按钮、运行画面点选)
解决 index.tsx 状态声明与 pitfalls.md 文档冲突;styles.css 去重后仅保留一份画本视觉层
快照克隆原先直接复制 blob: 对象 URL,源卡片卸载或预览缓存驱逐后该 URL 被 revoke,克隆里的图片随即失效,表现为进入/退出子画布时卡片框在动而图片不跟着展开和复原
对 blob: 来源且已解码的图片在克隆时栅格化为 data URL 冻结,与既有视频快照处理保持一致
补充 resourceBookController 快照图片冻结回归用例
进入子画布时若视口停在空白处,目标卡片不在画面内,FLIP 快照找不到终点会整段退化成淡入淡出,只剩标题栏有动画
新增 ensureResourceBookContentVisible:视口内没有可见卡片时,把离画面中心最近的卡片平移到画面中心,保持当前缩放不变
openResourceBookChild 计算入场视口后应用该修正,保证动画终点落在真实卡片上
补充 resourceBookViewport 用例覆盖已可见、就近居中与空栏目三种情况
总览原先按资源数组顺序取前 3 张,和进入子画布后视口内最近的卡片不是同一批,FLIP 找不到对应终点就退化成淡入淡出,返回总览时还会跳成另一张图
新增 selectResourceBookOverviewCards:按卡片中心到该栏目当前视口中心的距离选最近的卡片,仍受筛选可见性约束
每栏目视口中心换算到画布坐标后传入资源画本场景
补充 resourceBookLayout 用例覆盖无视图中心、就近排序与筛选过滤
转场层里的克隆只按自身 DOM 顺序叠放,而静态场景中卡片是 25、标题栏是 30,导致动画期间卡片盖住标题栏、结束后又瞬间跳回下方
快照记录元素自身或最近定位祖先的 z-index,克隆按该值设置层级,保持静态层叠关系
补充 resourceBookController 层级回归用例
进入资源画布到首屏预览落地之间画布是空的,新增居中加载态:Loader2 转圈加文案,role=status 可被读屏播报
加载判定抽到 resourceBookIsLoading:有资源且布局未就绪或首屏预览一张都没落地时显示;预览失败同样视为落地,避免一直转圈
补充 resourceBookModel 加载态用例
撤销全屏遮罩,改为每个功能画布框内各自转圈:该栏目一张预览都没落地时显示,本栏目加载完立即消失
版本、占位与音频卡片本来不请求卡片预览,不计入等待,避免一直转圈
加载态 pointer-events 为 none,不拦截点击,未加载完也能直接进入栏目
判定函数改为按栏目,补充 resourceBookModel 用例
层级相同的卡片由 DOM 顺序决定遮挡,而转场层原先按来源 key 在前追加克隆,和动画结束后的目标场景顺序不一致,结束瞬间会跳变
改为先按目标场景 DOM 顺序排列,再补只存在于来源里的 key
补充 resourceBookController 用例覆盖来源与目标顺序不同时的克隆顺序
两个场景的 z-index 数值本身不同(总览 1-3、子画布 25),只搬数值无法保证顺序一致;且只存在于来源里的淡出克隆被排在最后会盖住存活卡片,消失时表现为跳变
改为按目标场景的最终绘制顺序(z-index 升序,同值再按 DOM 顺序)给克隆分配单调层级,淡出项统一压到最底层
快照记录 DOM 序号,补充 resourceBookController 用例断言层级单调与标题栏在卡片之上
卡片真正重叠的是堆叠那一侧:进入时重叠在总览摞(来源),返回时重叠在摞(目标)。原先统一按目标顺序排,进入时就用错了顺序,堆叠里下面两张飞到上面,结束才回到下方
play 增加 stacking 参数:进入用来源顺序,返回用目标顺序;不在该侧的卡片压到最底层
补充 resourceBookController 用例覆盖来源顺序
原先只在「该栏目有可预览素材且一张都没落地」时转圈;版本、音频这类不请求预览的栏目既不转圈,卡片又要等依赖图就绪才渲染,表现为美术资源转完后项目版本才一起出现
判定改为:有资源且(卡片尚未渲染 或 需要预览但一张都没落地)就转圈,每个栏目各自独立结束
补充 resourceBookModel 用例覆盖卡片未渲染与无需预览两种情况
动画期间卡片尺寸/位置一变(子画布卡片尺寸要等预览图尺寸到位才算准,通常正好落在动画末尾),原先直接 settle 掉动画,真实场景瞬间接管,表现为结束瞬间跳一下
改为从当前叠加层几何重定向:begin 捕获当前飞行位置,再平滑续到新的目标位置,动画不再被掐断
补充 effect 依赖 resourceBookState.phase
上一版在布局变化时重启整段动画,导致此前修好的遮挡顺序与加载态全部复现,已回滚该做法
控制器新增 retarget:临时解除场景隐藏后重新测量真实卡片,只把运行中动画的结束帧改成映射到新几何;克隆几何、动画进度与叠加层都不重建
布局变化 effect 在动画期间调用 retarget,空闲时才 settle
补充 resourceBookController 用例:重定向不新建动画,结束帧映射到新目标
转圈的判定与状态联动引入了多处副作用,按要求整体移除
删除 ResourceBookThumbnail 的 loading 属性与框内转圈、resourceBookIsLoading 判定函数、对应样式和用例
资源画布恢复为不显示加载态
- 合入 origin/master 67 个提交(含 #285 无限画布图标模糊修复、#307 客户端常用设置读写卡顿修复等)
- index.tsx 冲突按 V3 画本结构解决:保留 resourceBook 画布场景,未采用 master 的 CanvasWorld 包装
- 原因:game-resource-page-world 现仅承载依赖连线层,CanvasWorld 使用固定 12000 世界尺寸,会破坏按内容自适应的边界
- 保留 AtSign 图标导入(V3 引用输入区使用)
- 合并后 npm run ai-game-creator-shell:typecheck 通过
- 删除 apps/ai-game-creator-shell/src/features/asset-canvas 全部四个文件(AssetCanvasSurface.tsx、assetCanvasNaming.ts、assetCanvasSurface.css、tauriImageCanvasHostAdapter.ts)
- index.tsx 删除 assetCanvasRoute 状态与 ref、beginAssetCanvas/openAssetCanvas/normalizeRasterSourceAndOpen、cancelAssetCanvas/handleAssetCanvasSaveAttempt/handleAssetCommitted 与 game-creator-local-asset-committed 事件监听、中央主视窗渲染分支与 resources.asset-canvas 视图状态
- index.tsx 把 assetCanvasNotice 改名为通用 resourceWorkbenchNotice,保留非草稿流程文案与资源定位按钮
- resourceEditModel.ts 取消 image-canvas 路由,PNG/JPEG/WebP 统一走 derive + image-reference(复用 resource_editor.rs 的 /editor/images/edits)
- styles.css 删除 resources.asset-canvas 两条画布选择器,保留 resources.ui-editor 规则
- check-config.mjs 把 normalize_local_project_raster_resource 加入 native-only 白名单(临时项:C3 快速编辑重建后会重新获得前端调用方,届时删除该 allowlist 条目)
- index.tsx 移除 useMemo 依赖数组里模块级常量 visibleCategoryOrder(基线遗留的 react-hooks/exhaustive-deps warning,会阻断 pre-commit,属通过门禁所需的最小修正)
- 删除 project/asset_canvas.rs(4784 行,草稿 sidecar、媒体 staging、正式提交事务与 journal)
- 删除 project/asset_canvas/generation.rs(9514 行,草稿私有生成账本、参考图上传、服务身份确认、候选落盘与内联 63 个测试)
- 删除 project/asset_canvas_tests.rs(2356 行)
- project.rs 移除 mod asset_canvas 与 pub(crate) use asset_canvas::*
- commands.rs 移除 851-1088 共 18 个 Tauri 命令(含 commit_local_project_asset、commit_local_project_asset_canvas_candidate)
- main.rs 从 generate_handler! 移除对应 18 行注册
- 保留 canvas.asset_generate 命令契约(packages/shared 与 shared-contracts 的正式权限合同,Agent Runtime 仍在使用)
- 保留 server-rs 的 asset-canvas-references 上传票据白名单契约(后续项,本次不动服务端)
- 删除 tests/assetCanvasSurface.test.tsx(4310 行,63 个用例全部属于草稿画布 Surface)
- projectResourceLiveIntegration.test.tsx 删除 AssetCanvasSurface mock、discover/recover 两个命令 stub、normalize_local_project_raster_resource stub、TaskImageWorkbench 与 4 个草稿精修用例;保留文档资源走资源编辑的用例
- project-development.suite.ts 把 showRunUnavailableHint 源码护栏正则去掉 !assetCanvasRoute,并把"in-place refine 预览失效"用例改写为仍有效的 preview version 装配与项目切换清理
- resourceEditModel.test.ts 把 PNG/WebP 两例从 image-canvas 改为 derive + image-reference,并移除 normalizeSource 断言
- resourceBookController 重写为真实元素 FLIP:begin 在提交前记录 First,play 测 Last 后给同一节点加反向 transform,sync 按布局签名做可中断重基
- 转场进行中重基用剩余时长(下限 80ms),空闲布局变化用完整 420ms,预览图尺寸晚到的二次布局变化不再瞬移
- 宿主节点几何基准改存 world 局部坐标,平移和缩放不会让上一次的干净矩形过期
- 不可见节点钉在 First 淡出、挂载节点淡入;搜索/排序导致的卸载保留一帧淡出;返回总览时源栏目未回到摞上的卡片按子画布布局淡出
- 新增 buildResourceBookScenePlan 渲染计划,渲染、转场签名与淡出集合共用同一份数据
- 删除 ResourceBookTransitionLayer 与克隆快照逻辑,清理 styles.css 的转场层和 data-book-motion 隐藏规则
- 重写 resourceBookController.test.ts,补 resourceBookLayout/resourceBookModel 用例与 appSurface 同节点断言
- 同步技术方案转场段与 pitfalls 经验条目
- 新增 docs/technical/【技术方案】AGC资源派生与非破坏性编辑合同-2026-09-09.md,承接阶段一合同第 16 节与第 5 节的现役条款,改写为 derive + editKind='image-reference' 口径
- 图片画布编辑器前端拆分计划新增"共享画布合同"一节,承接共享包目录与依赖方向、禁止复制的验收门、Host Port 边界、共享 chrome 与主题、图片能力与后续分期
- GameAgent资源自由画板与快速编辑删除精修草稿、最终图事务、精修来源规范化、事务恢复对账、批量导入与失败任务归档等退役章节,改写目标与操作边界,新增"编辑结果投影与焦点竞态"一节,并更新验证命令
- App实施计划删除阶段一至五的草稿画布描述,改写为共享画布源码与资源派生闭环现状,修正主视窗状态、适配器与派生提交条款
- 项目开发工作台 PRD 更新主视窗状态机与 ProjectWorkbenchViewState,移除 asset-canvas 状态
- decision-log 追加 2026-09-09 asset-canvas 整体退役决策(含保留项、路由口径、临时白名单与存量数据处置)
- pitfalls 删除草稿 CAS、素材保存区、正式素材提交原子性、生成账本与 upload ticket 五条退役条目,修正仍有效条目的关联文件与 legacyPrefix 描述
- `docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md` 整篇退役:该文档描述的中央 asset-canvas 状态、草稿 sidecar、Tauri 草稿/媒体/staging 命令、正式资产提交事务、提交事件、画布状态机与图片生成账本已随机制删除,按四不写不留墓碑文档。
- 仍有效内容已迁往:共享画布包目录与依赖方向、禁止复制的验收门、Host Port 边界、共享 chrome 与主题、图片能力与后续分期迁入 `docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md`;成功投影顺序与焦点守卫迁入 `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`;共享源码与资源派生闭环现状迁入 `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
- 全类型现有资源非破坏性编辑的现成权威条款为 `docs/technical/【技术方案】AGC资源派生与非破坏性编辑合同-2026-09-09.md`,它是 `project/resource_editor.rs` 的现行合同,已改写为 derive + editKind='image-reference' 口径。
- decision-log 的 2026-09-09 条目改写为决策文体:写清退役理由与现权威口径所在文档,历史条目保留原样。
- 过渡项删除条件:资源画布重建快速编辑后,`normalize_local_project_raster_resource` 重新获得前端调用方,届时删除 `apps/ai-game-creator-shell/scripts/check-config.mjs` 中该 allowlist 条目。
- manifest 资源条目末尾新增 category(单值 6 类,默认 unclassified)与 tags(字符串数组,默认空数组),两字段始终序列化
- shared-contracts 新增 GameCreationAppAssetCategory 枚举、GAME_CREATION_APP_ASSET_CATEGORIES 与穷举 16 个 canonical kind 的默认映射表 GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND
- 历史 manifest 缺少字段时按 kind 派生分类、tags 取空数组;显式合法分类原样保留,未知分类字符串按前向兼容退回 kind 派生
- packages/shared 同步 TS 契约:分类常量与类型、映射表、gameCreationAppAssetCategory / gameCreationAppAssetTags 取值 helper 与 tags 归一化 helper
- 收编壳内 17 处 Rust 构造点:新建条目写 kind 派生默认值,更新既有条目保留已有 category / tags
- 补契约测试:字段 round-trip、默认值、legacy 兼容、未知分类前向兼容、16 项映射穷举、tags 归一化
- 同步 docs/technical 技术方案与 docs/project-memory decision-log
- 合入 feat/agc-asset-canvas-removal 5 个提交(前端解耦 / Rust 删除 / 测试 / 文档 / 合同文档退役)
- 冲突解决:asset_canvas.rs、asset_canvas/generation.rs、asset_canvas_tests.rs 按 C10 删除(C2 在其中新增的 category/tags 构造点随之消失)
- 冲突解决:decision-log.md 保留 C2 与 C10 两条 2026-09-09 决策条目
- 该合并吸收 C2 对被删文件的字段改动,无需单独处理
- 合入 feat/agc-canvas-real-flip(resourceBookController 重写为同节点 FLIP、新增 sync 可中断重基、删除 ResourceBookTransitionLayer)
- 冲突解决:index.tsx 的 resourceBookLayout 导入取 C3 版(buildResourceBookScenePlan 等),去掉已被收进内部的旧 helper 导入避免未使用
- 冲突解决:资源自由画板文档的验证命令合并两侧并去掉已删除的 assetCanvasSurface.test.tsx
- begin 只记录当时可见的节点:不可见节点(内联 opacity 0)的屏幕矩形不是可信的 First
- 非活动栏目标题栏在子画布态保留总览布局坐标,却被子画布 world 变换推到屏幕外,返回总览时不再以它作为动画起点
- 没有 First 的节点只走淡入,位置保持不动;活动栏目标题栏仍按原有观感收回到总览位置
- 进入栏目方向不受影响:标题栏当时可见,仍钉在原地淡出
- 新增回归用例:不可见标题栏几何首末相等、活动标题栏仍有几何动画、进入方向标题栏原地淡出
- 同步技术方案转场段与 pitfalls 经验条目
合并最新 master 到 AGC V3 资源画布分支
Project CI / Repository checks (pull_request) Failing after 7m37s
Project CI / Frontend tests (pull_request) Failing after 8m52s
Project CI / Backend tests (pull_request) Failing after 10m24s
Project CI / Native shell tests (pull_request) Failing after 10m36s
3f5d3b43af
- 合入 origin/master 6 个提交(#313 项目写锁残留回收与启动诊断、#315 AGC Ctrl+C 残留后端修复)
- 冲突解决:decision-log.md 保留 V3 两条 2026-09-09 决策与 master 的写锁回收条目
- 冲突解决:pitfalls.md 保留 V3 转场经验条目与 master 的 Ctrl+C 后端残留条目
suzmii added 9 commits 2026-09-10 11:55:51 +08:00
- 新增 project/asset_usage.rs:运行槽位到资源身份的稀疏归属清单,落在 .agent/workbench/asset-usage/usage.json
- 只保留 manifest 没有的事实(slotId/loadSite/attribution);已登记资源只存身份引用,不复制 path/sha256/kind/mediaType,避免第二份资源清单
- 引用了不存在的资源 ID、未登记素材、文件缺失、槽位重复注册一律只产出 warning 诊断,不阻断运行
- revision 只允许前进;同 revision 重放幂等;projectId 不匹配按未登记处理
- 写入沿用 resource-layouts 同款 flock 独占锁 + 原子替换
- 补 11 条定向测试:稀疏引用、未登记/未知资源 ID、缺文件、重复槽位、无槽位引用、幂等、revision 回退拒绝、projectId 隔离、落盘不含 manifest 字段
- 新增 game_iteration_resource_bindings:优先使用使用清单里的 slotId 到 resourceId 绑定,再为未被槽位覆盖的资源补 asset:{id} 恒等绑定
- 恒等绑定保留是有意的:历史版本与既有校验语义不受影响,同一资源被多个槽位使用时只取首个槽位,不产生冲突语义
- ensure_initial_game_iteration_version_at 与 append_agent_game_iteration_version_at 共用该构造函数
- 补测试:无使用清单时保持恒等绑定;登记运行槽位后槽位绑定优先且未覆盖资源仍有兜底
- appSurface/harness 新增 getResourceDetailButton / findResourceDetailButton / queryResourceDetailButton,按产品可访问名模板「打开资源详情:<分类标签> <资源文件名>」精确匹配,不再用会同时命中 @ 引用按钮的文件名正则
- project-development.suite 的 23 处资源卡按钮查询改用精确查询助手
- home.suite 的 7 处资源卡按钮查询改用精确查询助手
- projectResourceLiveIntegration.test 的 /rules\.md/ 按钮查询改用精确查询助手
- 未改动 src 下任何产品代码,未放宽任何断言
- 删除 resourceReferences.ts 中 mediaType 优先的 7 桶筛选口径与 resourceReferenceFilterKind,改由 resourceReferenceCategory / resourceReferenceMatchesCategoryFilter 以 manifest 资产 category 为唯一权威筛选口径
- RESOURCE_REFERENCE_FILTERS 改为 6 类 + 全部(全部 / UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类),并新增 resourceReferenceCategoryLabel 让中文名只定义一处
- ResourceReference 新增 category / tags,由 resourceReferenceFromAsset 经 gameCreationAppAssetCategory / gameCreationAppAssetTags 从 manifest 资产投影;资源画布 ProjectResource 同步新增 assetCategory / assetTags 投影与 projectResourceAssetCategory 待归类兜底
- 新增 Tauri 命令 update_local_project_resource_classification 与 UpdateLocalProjectResourceClassificationInput/Result,实现落在 project/manifest.rs 的 update_manifest_asset_classification_at,复用既有 mutate_manifest_at manifest 写锁与原子写入
- 命令持项目写锁后按 expectedProjectId / expectedProjectRevision 做 CAS,冲突分别返回 project-identity-conflict 与 project-revision-conflict;category 非 6 个合法值即报错且不回退到 kind 派生;tags 走 normalize_game_creation_app_asset_tags;只改目标条目的 category / tags 并在成功后推进一次项目 revision
- 新增 Rust 定向测试 project/manifest/classification_tests.rs:更新成功、非法分类被拒、tags 归一化、未引用资产更新不影响其它字段、revision/CAS 与既有写入一致、输入拒绝未知字段
- 前端新增 ResourceClassificationPanel(ThemedModal + PlatformSegmentedTabs + PlatformTextField),资源详情面板以「分类与标签」按钮打开独立面板,保存后重读 manifest 并经 onManifestChange 反映到筛选与投影
- @ 面板与资源画布共用 packages/shared 的 PlatformSegmentedTabs 渲染同一组功能分类标签;画布筛选状态与画布视口一样按「按依赖 / 按类型」分别保存,复用既有 sortMode 状态形状
- 更新 resourceReferenceInput.test.tsx 与 projectResourceProjectionModel.test.ts 覆盖新口径,新增 resourceClassificationPanel.test.tsx 覆盖写入命令的输入归一化与失败不回退
- 同步技术方案与 decision-log:记录按 mediaType 分类到按 category 分类的语义变化(图片 / 视频桶消失,image / video / code / publication-material 归待归类)与新写入命令契约
- project/asset_usage.rs 增加 write_local_project_asset_usage_at:就地改写引用与诊断,保持身份与 revision 不变
- assets.rs 新增 delete_manifest_asset_at:素材不可变,只摘掉 manifest 登记、保留磁盘文件,并清理使用清单里指向它的悬空引用
- 删除判定只承认真实运行槽位绑定:恒等绑定是版本创建时的兼容兜底,若把它也算作被引用,任何在版本创建前登记过的素材都将永远删不掉
- CAS 口径与资源分类更新一致:持项目写锁后校验 expectedProjectId 与当前 revision,失败时 manifest 与 revision 均不变;成功后 revision 推进一格
- 新增 delete_local_project_asset 命令并注册;内联测试换成带 CAS 的公开入口
- 补 4 条删除测试:恒等绑定不阻止删除、真实槽位绑定拒绝删除并报出版本与槽位、删除清理悬空引用、陈旧 revision 与跨项目身份被拒且状态不变
- ResourceClassificationPanel 增加删除动作:首次点击进入确认态,二次点击才执行,避免误触不可逆操作
- 面板标题与按钮明确写出只移除登记、素材文件保留在磁盘
- 删除失败时把客户端的拒绝原因原样呈现(例如已被运行槽位绑定),并退出确认态
- index.tsx 抽出 reloadManifestAfterAssetCommand:资源分类更新与素材删除共用同一条重载路径,提交标识分别用 asset-classification / asset-delete 前缀
- 补 2 条面板测试:两次点击才删除且入参带 expectedProjectRevision、被运行槽位拒绝时不触发 onDeleted
- appSurface/harness 补齐三处 jsdom 缺口:ClipboardEvent、DragEvent、Range.prototype.getBoundingClientRect;否则 Lexical 的粘贴通路直接抛 ReferenceError / TypeError
- appSurface/harness 的 submitChat 改为 async:全选并让编辑器吸收选区、清空、走产品真实 paste 通路写入,再让出一帧让 React 追平 draft,最后点发送
- 新增 composerText / composerValue / composerDisabled / setComposerText 助手,按原生控件与 Lexical contenteditable 两种 DOM 口径读写输入区
- 15 个测试文件中 116 处 toHaveProperty('value', …) 断言等义改写为 await composerText() / await composerValue();1 处 placeholder 断言改查输入区占位文案;2 处 disabled 断言改用 composerDisabled(同时覆盖 data-disabled 与 contenteditable=false);9 处 fireEvent.change 写入改用 setComposerText
- 328 处 submitChat 调用补 await,9 个非 async 用例补 async
- Godot 输入区键盘语义用例按 Lexical 实际行为改写:Shift+Enter 与组合态 Enter 由编辑器消化并插入换行、不发送、草稿保留
- 未改动 src 下任何产品代码,未放宽或删除断言
Merge branch 'fix/agc-test-selector-cleanup' into feat/agc-canvas-resource-workbench-v3
Project CI / Repository checks (pull_request) Failing after 18m28s
Project CI / Native shell tests (pull_request) Successful in 25m45s
Project CI / Frontend tests (pull_request) Successful in 53m14s
Project CI / Backend tests (pull_request) Successful in 57m10s
912254afa0
suzmii added 49 commits 2026-09-10 19:20:27 +08:00
- 整体删除运行期资源使用侧车模块 project/asset_usage.rs 与 tests/asset_usage.rs(含 16 条测试),并清理 project.rs、tests/mod.rs 里的 mod / use
- game_iteration_resource_bindings 收敛为纯恒等映射 slotId=asset:{assetId}、resourceId=assetId,不再读取侧车、不再做槽位优先
- assets.rs 删除 GAME_ITERATION_IDENTITY_BINDING_PREFIX、asset_is_bound_to_runtime_slot 与 remove_asset_usage_references,删除不再判定运行槽位、不再清理侧车引用
- manifest 写入层把“版本数组只允许追加”校验改为显式接收放行删除的版本 ID:新增 mutate_manifest_at_allowing_version_removals,其余写入路径继续传空放行集合
- delete_manifest_asset_at 改为三分支语义:无任何版本引用直接删登记、被引用且未确认时只删登记并原样保留悬空绑定、被引用且确认时素材与相关版本在同一次 manifest 写入里一起删除
- DeleteLocalProjectAssetInput 增加 deleteReferencedVersions 入参,commands.rs 与 main.rs 同步
- 新增只读命令 read_local_project_asset_references,列出引用了该素材的版本标识、revision 与创建时间
- 新增 tests/asset_delete.rs,覆盖恒等绑定、无引用删除、未确认保留悬空绑定、确认连带删除、引用投影、只允许追加校验仍拒绝其它路径与 CAS
- 新增 ResourceAssetDeleteDialog:列出使用该素材的游戏版本(标识 + 创建时间),给出「把相关游戏版本一并删除」勾选,默认不勾
- ResourceClassificationPanel 删除入口改为先读 read_local_project_asset_references,再打开独立确认弹窗,不再用面板内二次确认态
- 确认删除时按勾选状态传 deleteReferencedVersions;未被任何版本引用时不显示连带删除勾选
- styles.css 补删除弹窗、版本列表与勾选行样式
- 重写并扩充 resourceClassificationPanel.test.tsx 的删除用例:确认弹窗才发起删除、未引用时不出现勾选、列出被引用版本且默认不勾、勾选后传 true、客户端拒绝时不报告删除
- agent/direct_tool_bridge.rs:bridge_registered_resource 投影新增 category 与 tags,位置固定在 mediaType 之后,保持稳定字段顺序
- agent/direct_tool_bridge.rs:category 直接取 manifest 条目已解析的功能分类(显式合法值原样保留,历史缺字段时已按 kind 派生),tags 缺失时为空数组,不改变 manifest 结构与对外契约
- agent/direct_tools_mcp.rs:agc_list_registered_assets 工具描述补上功能分类与自定义标签,与新增输出字段保持一致
- agent/direct_tool_bridge.rs:既有序列帧投影测试补充 category 与 tags 默认值断言
- agent/direct_tool_bridge.rs:新增 bridge_registered_resource_projects_manifest_classification_verbatim,锁定显式分类与标签原样透出且不泄漏 prompt / model
- PRD §5.4 版本记录补充"只允许追加"的唯一例外:用户显式确认的连带删除,并写明放行集合按写入前 manifest 求值、被放行版本不得进入追加段
- decision-log 新增三条 2026-09-10 定稿:版本绑定恒等化并撤回运行期资源观察侧车;素材删除新口径(三分支 + 可选连带删除版本);资源画布交互 1:1 复刻美术画布(取消资源详情面板与全屏编辑路由、复用共享组件以可选 prop opt-in、AGC 宿主自备 chrome 样式)
- pitfalls 新增四条:新工作树的 cargo 必须在 npm ci 之后跑;残留 dev stack 会拖死 Rust 门禁;改共享美术画布组件时 AGC 门禁覆盖不到它自己的测试;AGC 侧没有 image-canvas-editor__* chrome 样式
- @ 面板新增「当前版本素材 / 全部画布素材」两个页签,两个页签各自持有独立的搜索与分类筛选状态
- ResourceReferenceInput 新增可选 prop activeVersionId 与 versions,并把两者透传到 ProjectWorkspaceChatPane、ProjectSupervisorView、SupervisorChatOnlyView 和 App
- 未传 / null 的 activeVersionId 回退到 manifest versions[] 中最新的版本;版本不存在或没有绑定时页签显示空态,不报错也不合成资源卡
- 「当前版本素材」按该版本 resourceBindings 取仍登记在 manifest 的资产,悬空绑定按资源 id 过滤掉
- 资源改名后按资源 id 刷新编辑区已有引用 chip 与候选列表的显示名,并把新显示名同步回父级草稿
- 程序化重建草稿后把光标收回草稿末尾,跨会话恢复草稿时后续引用不再插到已失效的位置
- 新增 Tauri 命令 polish_local_project_prompt(复用短文本生成通道,计费仍由平台 LLM 路由侧完成,未改 server-rs)
- 输入区新增「AI 润色 / 恢复原文」按钮、润色中状态与失败可重试提示,失败、超时或未配置模型时保留原文
- 原文快照只在首次润色成功时落下,反复润色只覆盖结果,「恢复原文」始终回到最初原文
- 新增发送前提醒独立面板:AI 润色先润色再发送、使用原文提交、关闭取消发送、不再提醒
- 提醒判据:纯文本 trim 后不短于 40 字符、不是 / 开头的命令、本轮草稿未确认过、且用户未关闭提醒
- 「不再提醒」偏好写入本机 localStorage,不进 manifest、不进后端
- 补充润色按钮、状态提示、素材页签与提醒面板样式
- 新增 chatPromptPolish.test.tsx:覆盖提醒判据、草稿指纹、本机偏好读写与润色命令调用
- 覆盖主动润色回填、可重复润色覆盖结果、恢复原文回到最初原文
- 覆盖润色失败保留原文并在提醒面板内给出重试提示
- 覆盖长文本提交被提醒面板拦下、使用原文提交、关闭取消发送、AI 润色后发送
- 覆盖不再提醒偏好落本机 localStorage 后不再拦截,以及挂载时读取该偏好
- 覆盖短文本与命令不触发提醒
- resourceReferenceInput.test.tsx 新增:两个页签的独立搜索与筛选状态
- 新增:activeVersionId、版本回退、悬空绑定过滤与空态
- 新增:资源改名后 chip 与候选列表显示名刷新
- 新增:跨会话恢复草稿后光标落在文本末尾,引用追加在文本之后
- 新增 Tauri 命令 rename_local_project_asset(输入 projectPath / assetId / newFileName),并在 main.rs 的 generate_handler! 中注册
- 新增 project/asset_rename.rs:重命名语义为“磁盘文件改名 + manifest localPath 更新”,资产 id / kind / mediaType / source / category / tags 全部不变,imageSequenceFrames 里指向该文件的本地帧一并对齐
- 校验判据:新名非空、不含路径分隔符(同时保证不跨目录)、不含 ..、通过可移植路径组件校验、扩展名与原文件一致、同目录不得已存在同名文件、assetId 与磁盘文件必须存在
- 事务与回滚:持既有项目写锁,manifest 写入走既有 write_manifest 边界(版本数组校验 + 安装后回读一致性);写失败把文件改回原名,回滚也失败时两个错误都报出并标记 reconciliation-required;同名重命名按空操作处理
- 新增 tests/asset_rename.rs 9 条用例:成功改名与身份不变、同名空操作、空名 / 分隔符 / .. / 跨目录 / 扩展名不一致 / 目标同名冲突 / 资产与文件缺失拒绝、manifest 写失败回滚、同目录帧对齐
- decision-log 记录素材重命名语义、命令边界,以及“前端调用方落地前 ai-game-creator-shell:typecheck 必然失败”的已知中间状态
- 更新【功能说明】AGC聊天素材引用:补充素材面板双页签、activeVersionId 版本回退与空态口径
- 更新【功能说明】AGC聊天素材引用:把「改名刷新显示名」「跨会话恢复光标位置」从未完成移到已完成
- 新增【功能说明】AGC聊天AI润色与发送前提醒:写明润色回填、原文快照、失败保留原文与提醒面板行为
- 新增文档同时记录提醒判据、不再提醒偏好存本机 localStorage、以及计费仍走平台 LLM 路由的边界
- 解决 main.rs / project.rs / tests/mod.rs 的相邻插入冲突:main.rs 保留 read_local_project_asset_references 与 rename_local_project_asset 两条注册,project.rs 与 tests/mod.rs 只保留 mod asset_rename(asset_usage 已由 WS-A 整体删除)
- 解决 decision-log.md 末尾追加冲突:保留双方条目(WS-A/口径收敛、删除新口径、C3 交互、Agent 投影 + WS-D 素材重命名)
- 新增 gameCreationAppAssetTagLibrary:从 manifest assets[].tags 派生去重、稳定排序、含使用计数的标签库

- 标签归一化沿用写入路径 normalizeGameCreationAppAssetTags,同素材重复标签只计一次

- 新增 assetTagsMatchSelection / gameCreationAppAssetMatchesTags 标签筛选判据(AND、空选择不过滤)

- 新增 5 条纯模块测试覆盖去重计数、顺序稳定、空标签与筛选判据
- pitfalls 新增一条:AGC 引 src/components/image-editor 组件会经 ImageCanvasGenerationModel 的 ApiClientError 值导入链到 src/services/apiClient.ts 与 host-bridge/hostBridge.ts,而它读的 wx / ReactNativeWebView / WeixinJSBridge 只在主仓 vite-env.d.ts 声明
- 记录处置口径:在 apps/ai-game-creator-shell/src/vite-env.d.ts 的 Window 上补同名同形字段(纯类型声明零运行时影响),不要关闭 AGC 的 noImplicitAny,也不要另写平行组件
- 记录已核实的运行时结论:hostBridge.ts 模块作用域只有常量与缓存声明、无导入即执行副作用,window.wx 仅在函数体内访问且有 typeof window 守卫,故该依赖链编入 AGC bundle 是惰性的
- 新增 2026-09-10 条目,写清版本绑定恒等化与撤回运行期资源观察侧车的边界(删 asset_usage 及其 16 条测试、绑定退回恒等映射)
- 写清素材删除三分支语义、deleteReferencedVersions 入参、read_local_project_asset_references 只读命令,以及版本追加唯一例外的放行集合按写入前 manifest 求值
- 写清 agc_list_registered_assets 投影新增 category/tags 且不在投影层重新派生分类
- 写清 rename_local_project_asset 的语义(文件改名 + localPath 更新、id 不变)、校验、写失败回滚与 reconciliation-required 口径,以及"不改游戏源码引用"的已知边界
- 写清 @ 面板两个页签与 activeVersionId/versions 契约、polish_local_project_prompt 的入参与计费口径(不自建计费,走 server-rs 的 LLM 路由)、发送前提醒判据与本机偏好存储
- 记录本次全部门禁数字
- 新增 packages/shared 的 PlatformResourceFilterBar:搜索(可选)+ 功能分类 + 标签的受控筛选条,只承载表现与交互

- 资源画布筛选改为复用该组件,去掉画布内联的分类 PlatformSegmentedTabs,搜索框保持原样不动

- 画布新增标签筛选状态与派生标签库,标签与功能分类共存可叠加

- 新增 3 条组件测试覆盖受控渲染、回调转发与缺省行隐藏
- 新增 ImageCanvasProjectAssetPickerDialog:独立弹窗,网格卡片、多选、筛选+搜索、清空/确认/取消,筛选状态自持

- 新增 projectAssetReferencePickerModel:素材分类与筛选判据、素材 id 快照引用构造、弹窗引用前缀判定

- 生成 composer 参考图来源菜单新增「从项目素材中选择」,由宿主决定是否出现,不改变原有从画布选择与上传两条路径

- 新增 replaceProjectAssetPickerReferences:确认时整组替换素材库来源的参考图,保留画布点选与上传的参考图

- 素材 id 经 sourceAssetId 走既有 createGenerationInputReference 落进 generationInputs.references,不新增后端接口

- 新增 13 条测试覆盖弹窗交互、筛选叠加、快照落库与整组替换语义
新增 features/resource-canvas:资源卡到美术画布 CanvasLayer 的适配、工具条动作可用性判定、C5 当前版本绑定判定、快速编辑草稿工厂与 AGC 宿主 chrome 样式

资源卡点击改为选中(Shift/Ctrl/Meta 多选),选中后在卡片旁复用 ImageCanvasSelectedLayerToolbarView 浮出工具条

快速编辑复用 ImageCanvasQuickEditPanelView 浮层面板,先调 normalize_local_project_raster_resource 正规化任务产物,再走 derive_local_project_resource(editKind=image-reference) 产出新素材,失败重试复用同一 operationId/idempotencyKey

删除 resourceEditorRoute 全链路(ResourceEditSurface 路由、openResourceEditor、handleResourceCardOpenEditor、cancelResourceEditor、submitResourceEdit)与资源详情面板(focusedResource 状态/引用/浮层 JSX/CSS 断言)

分类与标签入口搬到选中工具条;UI 资源保留「UI 编辑器」入口并从工具条打开

移除 apps/ai-game-creator-shell/scripts/check-config.mjs 中 normalize_local_project_raster_resource 的过渡 allowlist 条目

为共享工具条组件新增 opt-in 的 supportedActions/extraActions(默认 null/undefined,网页端行为不变)

AGC vite-env.d.ts 补 wx/ReactNativeWebView/WeixinJSBridge 跨端全局声明;hostBridge fail 回调补显式 unknown 类型

测试:资源卡查询 helper 由资源详情按钮改为选中按钮,清理详情面板断言,画布空白左键改为框选后平移用例改用中键
- 新增 ResourceRenameDialog:独立重命名面板,只输入新文件名,规则交给 Rust 侧强校验

- 资源详情面板新增「重命名」动作,严格按 deny_unknown_fields 只传 projectPath/assetId/newFileName

- 改名成功后复用 reloadManifestAfterAssetCommand 这条 manifest 重载路径刷新资源卡与 @ 面板显示名

- 删除 check-config.mjs 过渡 allowlist 里的 normalize_local_project_raster_resource,并移除已无调用方的同名 Tauri 命令与注册

- 新增 2 条前端测试覆盖严格入参、manifest 重载与改名后资源卡显示名刷新
删除用例 keeps the resource toolbar and canvas interactive while a non-modal detail card is open:该用例断言的「非模态详情卡」已被 C3 合同取消,按四不写删除

新增用例「资源卡 chrome 只保留选中按钮与当前版本边框」,接住被删用例里仍然有效的卡片 chrome 断言(无基础描边、is-current-version 边框、选中按钮样式)

版本卡与 Agent 回执卡没有美术画布动作,断言改为选中反馈(aria-pressed)而不是工具条

blocked.md 的策略失败提示原本渲染在已删除的详情面板里,断言随之移除

快速编辑测试的 fixture 增加栅格美术资产(includeArt 参数),不改动其它用例的 manifest
新增 features/resource-canvas/resourceCanvasHistoryModel:纯函数有界快照栈(capture/push/undo/redo/clear + 只回写差异位置),快照只含资源卡布局坐标,不回滚素材内容

新增 tests/resourceCanvasHistoryModel.test.ts 覆盖排序稳定、相同快照不入栈、栈上限、push 清空重做分支、撤销重做互逆、空栈返回 null、差异回写与已删除资源不重建

资源卡拖动持久化成功前先落快照,撤销/重做复用现役 commitPosition 手动 CAS 写链

画布缩放集群新增撤销/重做按钮(不可用时禁用),并支持 Ctrl/Cmd+Z 与 Ctrl/Cmd+Shift+Z、Ctrl+Y(输入框内不拦截)

项目切换时清空布局历史,避免跨项目回滚
- 合并 feat/agc-canvas-resource-workbench-v3:资源卡改为选中+浮出工具条、支持多选、删除资源详情面板与 resourceEditorRoute

- index.tsx 冲突以 C3 新结构为底:保留本包的素材重命名回调,丢弃被 C3 取消的 focusedResource 详情面板派生状态与浮层 JSX

- 重命名入口从已删除的详情面板搬到选中工具条 extraActions,与「分类与标签」并列

- check-config.mjs 的 allowlist 保持主干版本(normalize_local_project_raster_resource 一行已删)

- 还原 normalize_local_project_raster_resource Tauri 命令与注册:C3 已在前端接回调用方

- C7:新增 GameRunVersionPicker 运行模块右上角版本入口,无版本不渲染

- C7:版本状态收敛到 WorkspaceLauncherShell 一份,透传给资源画布与 @ 面板;换项目或版本消失时清回最新版本

- C7:currentVersionResourceBindingIds 接入 activeVersionId,复用 resolveActiveIterationVersion 口径,资源卡「当前使用」高亮随版本切换

- C7:切换版本时复用既有 onPlay 预览入口重载画面,不新增运行时资源重映射

- 新增 10 条测试覆盖版本判定口径、版本入口与切换、当前使用高亮、素材重命名链路
- 实施计划追加 2026-09-11 条目:标签库派生口径、共享筛选条、C7 版本切换、C9 参考图弹窗、素材重命名 UI 与 allowlist 收敛

- 写明版本切换不做运行时资源重映射的理由与参考图 ID 快照的落库路径

- 记录本次门禁实际数字与 5 条待 C3 侧更新的既有失败用例
新增 features/resource-canvas/resourceCanvasAssetTransferModel:面板网格模型、选中集合解析、默认导出文件名、上传白名单(纯函数)

新增 features/resource-canvas/ResourceCanvasPanelView:独立浮层面板承载预览网格/上传/下载/全选/清空,选中状态直接读写画布同一份 selectedResourceIds,不造第二套选择

下载改为显式保存链路:前端 @tauri-apps/plugin-dialog save() 取目标路径,再调新命令 save_local_project_asset_file 复制素材文件,避免依赖不可靠的 <a download>

Rust 新增 project/asset_export.rs 与命令 save_local_project_asset_file:校验项目根与相对路径、源必须是真实普通文件(拒绝符号链接与目录)、目标路径非空且父目录存在、分块流式复制不进整文件内存,返回目标路径与字节数

Rust 单测覆盖成功复制(内容一致 + byteLen + 源不变)、缺失源拒绝、目录源拒绝、空目标与父目录缺失拒绝、目标为目录拒绝

上传复用现役 upload_local_asset(落 assets/uploads 并登记 manifest 条目)后回读 manifest 刷新,只接受图片/音频/视频

capabilities/main.json 增加 dialog:allow-save;资源画布 chrome 样式补资源面板一套;index.tsx 只做导入/一处按钮/一处渲染/状态透传
- project.rs 冲突:同时保留 mod asset_export(WS-B 下载命令)与 mod asset_rename(WS-D 重命名命令),丢弃已在 WS-A 退役的 asset_usage
- index.tsx 冲突:同时保留 GameRunVersionPicker(C7 版本入口)与资源面板/历史模型导入(WP4)
- decision-log 新增两条 2026-09-10 决策:资源画布基础能力收口(撤销重做只作用于卡片布局、独立资源面板、上传走 upload_local_asset、下载改为显式保存链路 save_local_project_asset_file + dialog:allow-save)
- decision-log 新增:运行模块版本切换(版本状态唯一一份放在 WorkspaceLauncherShell、运行模块右上角 GameRunVersionPicker、切换只重载预览不做运行时资源重映射)、参考图弹窗与共享筛选条、标签库纯派生口径、素材重命名前端接线
- 同步修正上文"重命名只落 Rust 侧"的已知中间状态:前端调用方已落地,该中间状态结束
- resourceCanvasChrome.css:移除我自行添加的 max-width 与 flex-wrap: wrap,按网页端美术画布原始规则改回单行不换行(容器 nowrap + 溢出横向兜底),按钮统一 2.25rem 方块与 0.45rem 圆角,文字键 padding 改回 0 0.62rem,分隔线改回 1.125rem,消除动作变多时折叠成多层与按钮高矮不齐
- resource_editor.rs:新增 editor_api_rejection_reason,在 HTTP 400 分支把服务端响应体的可读原因(兼容 error.message / error 字符串 / details.message / 顶层 message,截断 200 字符)拼进用户可见错误,替换原先只有状态码的笼统提示
- 新增 resourceCanvasGenerationModel:按本地契约锁定无源生成放行的视频 / 音效 / 背景音乐三类,提供 create:<operationId> 源标识、入口渲染门禁与请求身份工厂
- 新增 ResourceCanvasGenerationPanelView:独立浮层面板承载类型选择、草稿与失败重试,失败后锁定原请求并复用同一 operationId / idempotencyKey
- 浮层只呈现三类真实可用类型,不出现图片专属的模型 / 比例 / 尺寸 / 像素艺术与泥点价
- resourceCanvasChrome.css 补浮层宿主样式
- 新增 resourceCanvasGenerationEntry 定向测试 6 条
- index.tsx 只做导入、一个生成入口按钮、一处浮层渲染与提交回调;无 invoke 桥或项目未就绪时入口不渲染
- 提交走 derive_local_project_resource 的 create 模式:sourceResourceId 为 create:<operationId>,sourceAssetId / sourcePath / sourceSubtype / producerTaskId / sourceVersionId 全为 null
- 产出后经 pendingResourceFocusRef 定位并选中新素材卡,并写入生成资源已保存提示
- projectResourceLiveIntegration 补入口可见性、create 入参、重试复用 operationId 与新卡定位 2 条用例
- 实施计划文档补 C3 生成入口口径、图片无源生成未完成项与本轮验证数字
- editor_api_rejection_reason 额外读取响应体 error.code,过滤掉等价于通用文案的 BAD_REQUEST 后拼成「原因|错误码 CODE」
- 背景:http_error.rs 把所有 4xx 兜底为同一条「请求参数不合法」,只透 message 会丢掉唯一可用于定位的信息
- pitfalls.md 追加「AGC 走不了网页端 /api/editor 的取数链」:Tauri asset 协议 + CSP connect-src 未放行平台域名,且 dev 的 vite /api 代理会造成「能跑」假象
- pitfalls.md 追加「ImageCanvasStageView 是硬组合容器」:132 props(57 数据 + 75 回调)、无条件渲染 8 个子视图、83/132 需宿主伪造编辑器状态,且不透传 supportedActions
- pitfalls.md 追加「AGC 资源画布真正自制的交互面」:resourceBookViewport.ts、resourceCanvasLayoutModel.ts 的 fit/normalize、index.tsx 3656-4090 指针段,并记录两条平移路径守卫不一致与右键菜单、chrome 样式缺口
- pitfalls.md 追加「资源画布工具条改造/角色动画按钮点了没反应」:空回调仍留在 supportedActions 里,按钮渲染但无人接
- resolveResourceCanvasToolbarActions 不再把 redraw 与 character-animation 放进支持集合:这两个动作在宿主编排层仍是空回调,放进去会让工具条渲染出点了没反应的按钮
- 判据收敛为一条:放进来就必须真能跑通,当前真实接通的只有快速编辑(normalize → derive(editKind='image-reference'))
- 补注释写明接上真实链路后再放回来
- project-development.suite.ts 的音频工具条断言由「必须存在改造按钮」改为「不得存在改造按钮」+「按钮集合精确等于分类与标签、重命名」

- fed17ea08 已按共享记忆 pitfalls.md 的判据(渲染出来的动作必须真的能跑通)把 redraw 与 character-animation 移出 supportedActions,音频分支只剩宿主编排层真实接通的分类与标签、重命名,原断言断言的是被该提交推翻的旧合同

- 新断言比旧断言更严:除钉住音频分支仍在渲染,还钉住整条工具条的按钮集合,避免再出现点了没反应的假按钮
- 新增 tests/resourceCanvasToolbarModel.test.ts:直接覆盖 resolveResourceCanvasToolbarActions,此前该模型零直接覆盖

- 逐条钉住当前真实接通的动作集合:栅格图片与角色资源只放行 quick-edit,音频/视频不再放行 redraw,角色资源不再放行 character-animation

- 覆盖派生门禁:未登记 manifest 资产且无已完成任务产物背书不放行、只认已完成任务的栅格产物、非栅格图片不放行

- 判据出处为 docs/project-memory/shared-memory/pitfalls.md 的「渲染出来的动作必须真的能跑通」
- resourceCanvasAssetTransferModel.ts 新增 isResourceCanvasExportable:只有拿得到可落盘本地文件路径的资源(version 为空且 path 非空)才允许保存

- resolveResourceCanvasPanelEntries 的 downloadable 改为调用该判据,去掉内联的同一段表达式

- 纯重构,资源面板行为不变;目的是让画布工具条的下载放行判据与资源面板使用同一个权威口径,不再写第二份
- index.tsx 把资源面板的保存流程抽成共用 saveProjectResourcesToDisk(原生保存对话框 + save_local_project_asset_file),资源面板改为委派调用,不再有第二份保存实现

- index.tsx 工具条 onDownloadLayer 由空回调改为调用该共用 handler,回执写进 resourceWorkbenchNotice;不可导出的资源仍直接返回

- resourceCanvasToolbarModel.ts 把 download 加回 supportedActions,判据是 isResourceCanvasExportable(拿不到本地文件路径就不渲染),不按媒体类型放行

- resourceCanvasToolbarModel.test.ts 期望值随判据重算为 [quick-edit, download] 或 [download],并补 download 放行与拒绝两侧用例(空 path、虚拟版本条目)

- project-development.suite.ts 音频工具条按钮集合断言更新为 [分类与标签, 重命名, 下载按钮],并新增两条真链路断言:工具条下载打到 save_local_project_asset_file、资源面板下载仍打到同一命令

- appSurface.test.ts 在入口文件 mock 原生对话框,只替代用户选择目标路径这一步;放别处注册会晚于真实模块实例化
- 新增 resourceCanvasFocusModel:把「这次 pointerdown 要不要清画布焦点」「快速编辑浮层是否接管点外部关闭」「宿主 Escape 是否归资源画布」抽成纯判据,逐条对齐美术画布 clearCanvasFocus() / closeTransientEditorPanels() / clearCanvasSelection()。
- index.tsx 画布空白左键 pointerdown 调 clearResourceCanvasFocus():清空选中(选中工具条随之收起)并关闭快速编辑浮层,与美术画布 useImageCanvasStageInteractions 的空白分支一致;中键/空格平移分支不清,避免平移顺手丢选中。
- 快速编辑浮层复用共享 hook useImageCanvasFloatingOptionDismiss:document 上接管 click 与 Escape,点整个资源画布以外即关,点边界内(含模型/比例下拉 .image-canvas-editor__portal-menu)保留;宿主弹窗(资源面板/生成素材/分类与标签/重命名/恢复队列)开着时不接管,避免清掉资源面板共用的多选。
- 选中工具条补 window 级 Escape:与美术画布 clearCanvasSelection() 同口径清空选中;下拉弹层打开时共享 hook 在 document 上截断,浮层优先收内层。
- 生成为中的快速编辑面板不关(status === 'generating'),保留「修改中」状态与失败重试入口。
- 新增定向测试:交互目标判据、生成中不关、宿主弹窗让位、点边界外关闭、Esc 两态关闭、只有工具条时点外部不清选中、模型/比例下拉点外部与 Esc 关闭。
- resourceCanvasChrome.css:删掉手写的近似样式,从网页端 src/index.css 逐条搬入 AGC 实际用到的 image-canvas-editor__* 规则——生成 composer 基座(grid 布局、42rem 宽、1.45rem 圆角、双阴影)、generation-prompt(--auto-grow-editor-* 变量 + 聚焦态)、composer-footer 五列网格(修掉底部一行挤在一起、对齐混乱)、option-cluster / option-popover-anchor 及其 --dimensions / --model 变体、option-popover 与它的 sections / section / title / items / items--card / items--model、option-popover-choice 及 --ratio / --model 与 aria-pressed 选中态、ratio-wireframe 六个比例线框、option-selected-check、mud-point-inline、generation-submit 三态、quick-edit-panel / --quick-edit / quick-edit-footer / quick-edit-prompt / quick-edit-submit 这一组面板专属覆盖。
- resourceCanvasChrome.css:补入提示词输入框依赖的 auto-grow-text-area 一族规则(外层尺寸变量、content 内边距、placeholder、viewport 与 OverlayScrollbars 竖条),原来只给了裸边框和 padding,所以输入框看着很素。
- resourceCanvasChrome.css:保留/新增的宿主规则只有美术画布没有的那几项——工具条与面板的层级、锚点 transform、窄屏宽度;面板的 overflow 由 auto 改成 visible,因为 AGC 的下拉弹层是就地渲染、没有 portal,滚动容器会把它裁掉。
- styles.css:新增 @source 指向 src/components/image-editor 与 src/components/common。这两个目录不在 AGC app 根下,Tailwind 自动探测扫不到,组件自带的工具类(h-3 / rounded-full / inline-flex / text-slate-700 …)在 AGC 里根本没生成,是「样式和美术画布对不齐」的另一半根因。
- 选中工具条上一轮已按真实规则改好的部分原样保留,未回退。
- `is_game_creator_resource_editor_generation` 增加 `GAME_CREATOR_CLIENT_GENERATION_SOURCE` 别名,与 `from_job` 的队列 consumer 判定同口径
- AGC 客户端发送前会把 `generationInputs.source` 改写成 `ai-game-creator-client`,原来只认 `game-creator-resource-editor`,导致该分支对真实请求永远不生效
- `ensure_editor_image_edit_source_kind_allowed` 的白名单换成静态图类型表 `EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS`(canonical 静态图类型 + 合法 legacy 类型)
- 保留 `mediaType == image` 的 AND 门,视频 / 音频 / 序列帧等非静态媒体照旧拒绝
- 覆盖实测被拒的 `art-spritesheet` / `ui`,以及 canonical 映射输入域 `art-spritesheet-slice` / `ui-prototype` / `game-art` / `game-background` / `character-art` / `illustration`
- 服务端白名单用例改为按静态图集合断言,新增 `art-spritesheet` / `ui` / `ui-prototype` / `game-art` / `game-background` / `art-spritesheet-slice` 正向用例
- 保留 `future-kind` / `video` / `audio` / `image-sequence` / `character-animation` / `document` / `code` 负向用例,并补 mediaType AND 门用例
- 新增请求级回归用例 `game_creator_client_quick_edit_accepts_local_manifest_static_image_kinds`,按 AGC 客户端真实请求体(source 已改写为 ai-game-creator-client)覆盖放行与拒绝两侧
- 新增 `game_creator_client_source_is_recognized_as_game_creator_resource_editor`,把 source 别名与队列 consumer 判定钉在同口径
- 修正 icon 相关旧断言:`icon` 已是共享契约静态图类型,改断言 non-static 媒体仍被拒
- `editor_api_rejection_reason` 取值顺序改为 `error.details.message` → `details.message` → `error.message` → `error` 文本 → 顶层 `message`
- 平台把所有 4xx 兜底成「请求参数不合法」,真实原因写在 `details.message`,原实现只读 `error.message` 导致用户永远看不到原因
- `details` 有值时附带 `provider`,并透出 `assetKind` / `mediaType`,非 BAD_REQUEST 的稳定错误码照旧透出
- 抽出同步函数 `editor_api_rejection_reason_from_body` 并补定向用例,钉住取值顺序、截断与兜底行为
AGC 输入区三处观感修复:AI 润色归位、@ 面板筛选条单行、引用 chip 原子化
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 3m5s
Project CI / Native shell tests (pull_request) Successful in 18m26s
cb76aadb48
- styles.css:把 direct-codex composer 下广播式的 `.project-supervisor-composer button` 规则
  收窄到 `.project-supervisor-submit-button`(含 svg 那条与 media query 里那条),
  并给 `.resource-reference-input-actions` 补 `grid-column: 1 / -1` 让输入区操作排独占一行
- styles.css:`.resource-reference-input-actions` / `.resource-reference-input-at` 撤掉绝对定位,
  操作排回到网格文档流,与输入框右下角对齐,不再浮在文本区中间
- styles.css:`.resource-reference-input-polish` 与 `.resource-reference-input-restore` 并入
  `@` 按钮同一条尺寸规则(28px 方钮、8px 圆角、同 hover/disabled 观感)
- styles.css:输入区状态行(润色中/润色失败)改为与应用排同一行,不再单独占一行顶高 22.8px
- styles.css:direct-codex 输入框 padding 收为 `12px 15px 6px`,窄屏 `10px 12px 6px`
- styles.css:`.resource-reference-chip` 的 `×` 删除按钮不再被 broadcast 规则摘出 chip
- PlatformSegmentedTabs.tsx:分段页签 item 加 `whitespace-nowrap`,中文不再逐字竖排
- packages/shared/styles.css:补 `.scrollbar-hide` 实现(该类名一直被 `layout="scroll"` 引用但仓库未定义)
- ResourceReferenceNode.tsx:`getTextContent()` 改为单个 U+FFFC 占位符并显式实现
  `getTextContentSize()`,引用成为真正的原子内联节点(1 个引用 = 1 个字符)
- tests/resourceReferenceInput.test.tsx:新增「1 个引用 = 1 个字符」与「chip 自带删除、一次删净」两条断言
- tests/appSurface/project-development.suite.ts:把 composer 按钮样式断言收窄到提交按钮,
  并新增一条反向断言,钉住「广播到 composer 下所有 button」的规则不再出现
suzmii added 38 commits 2026-09-11 11:38:26 +08:00
- agentSwarmTestEntry.test.ts:readGameCreatorWizardConfigState 的三处调用补传 persistenceOptions,读路径不再拉起真实 powershell.exe 去加固私有 DACL。
- agentSwarmTestEntry.test.ts:在 persistenceOptions 定义处补注释,说明读路径同样会加固、读写都要带桩,避免后续再漏。
- 失败形态:单跑该文件稳定 1 failed | 124 passed,报错为 Test timed out in 5000ms,不是 expected / actual 断言不符。
- 根因:该用例只校验配置分层优先级(local 覆盖 primary → 向导写入后剥离 local 的 llm → GUI 后写 primary 获胜),但三处读路径都没传桩;每次 readGameCreatorWizardConfigState 会加固目录、主配置、本地配置各一次,三处读累计 9 次 powershell.exe 子进程。逐段实测 5474.9ms / 5494.9ms,正好压过 vitest 默认 5000ms。
- 基线定性:8f96b05f6 上同一用例同样失败(连续两次 1 failed | 124 passed,同样报 5000ms 超时),且该用例与 game-creator-config-wizard.mjs、agent-swarm-test-chat.mjs 在本批前后逐字节一致(blob 相同),故不是本批引入的回归。
- 未放宽任何断言:补桩后该用例 5500ms → 39ms,本文件 13.6s → 8.7s,断言与断言语义均未改动。
- pitfalls.md:在「终端真实测试不能混用配置参数、stdin EOF 和持续预览」下补一条测试超时补充,紧接既有的 Windows PowerShell 参数条目。
- 内容:配置向导读路径与写路径都会加固私有目录与配置文件,没拿到 secureWindowsPath 桩时 Windows 上每次调用各拉起一个真实 powershell.exe(本机实测约 0.6s / 次);只桩写不桩读会累计 9 次加固、约 5.5s,稳定撞上 vitest 默认 5000ms 并报 Test timed out in 5000ms。
- 结论口径:只校验配置分层优先级、不校验 ACL 的用例,读写路径必须带同一份 persistenceOptions;确实要覆盖私有 ACL 的用例才保留真实加固并自行留足超时预算。
- agent-swarm-test-chat.mjs:secureWindowsPrivateRuntimePath 增加可选第三参 secureWindowsPath,传入时直接调用它,不传时完整保留原有的 await import + secureWindowsGameCreatorPathForCurrentUser 调用。
- agent-swarm-test-chat.mjs:copyPrivateRuntimeConfigEntry 增加可选第五参 secureWindowsPath,并透传给上面的加固函数。
- agent-swarm-test-chat.mjs:prepareSwarmTestRuntimeConfig 增加可选第三参 { secureWindowsPath = null } = {},透传给运行配置目录加固与两次配置文件复制。
- 生产路径行为逐字不变:默认值为 null 时 if 分支不进入,执行的原语句与改动前完全一致;唯一生产调用点仍按单参调用(prepareSwarmTestRuntimeConfig(sourceConfigDir))。
- 加这个接缝的原因:Windows 上每次私有路径加固都要拉起一个真实 powershell.exe(本机实测约 0.6 秒 / 次),一次 prepareSwarmTestRuntimeConfig 在双配置文件场景最多加固 3 次,只校验隔离与清理语义的用例不该为此耗掉 vitest 的 5000ms 默认预算。
- agentSwarmTestEntry.test.ts:新增模块级 skippedWindowsAclOptions,仅 win32 下为 { secureWindowsPath: async () => {} },其它平台为空对象。
- agentSwarmTestEntry.test.ts:atomically replaces a private AppData config file 的两次 writeGameCreatorConfigAtomically 传入该桩,3663ms → 24ms,不再只剩 27% 超时余量。
- agentSwarmTestEntry.test.ts:moves the default LLM to primary config and lets later GUI saves win 原本的局部 persistenceOptions 收敛为同一份模块级常量,去掉重复声明。
- agentSwarmTestEntry.test.ts:isolated Swarm runtime config 的三处 prepareSwarmTestRuntimeConfig 传入该桩,privately copies 1948ms → 84ms,refuses to delete 1331ms → 79ms。
- 断言零改动:这几条用例在 win32 下本就不校验 ACL(mode / dev / ino 断言全部在 process.platform !== 'win32' 分支内),非 win32 下该常量为空对象、行为逐字不变。
- 实测:该文件 13.59s → 1.35s,文件内最慢用例从 3663ms 降到 84ms。
- pitfalls.md:把「测试超时补充」里引用的 persistenceOptions 改为当前实际使用的 skippedWindowsAclOptions,避免文档指向已不存在的名称。
- pitfalls.md:补一句 prepareSwarmTestRuntimeConfig 暴露的可选 secureWindowsPath 注入点、默认 null 走真实实现,以及单次调用最多加固 3 次的事实。
- 契约层新增 PROJECT_RESOURCE_CANVAS_SECTIONS:6 类资产分类加末尾独立的项目版本栏目,并补齐分区类型
- 契约层新增旧四 / 五栏目取值白名单、Persisted 联合类型与两个类型守卫
- Rust 契约 ProjectResourceCanvasSection 增补 7 个现行 variant 并保留 Code / Art 旧值,新增同名分区常量
- Rust 契约不升 game-creator-resource-layout.v1 版本、不给未知 section 加兜底,损坏 payload 继续失败关闭
- 新增 resourceCanvasSectionMapping.ts:旧栏目到现行分区的映射表、回落栏目与读时归一化
- 读时归一化只改写 section,x / y / manuallyPlaced 原样保留,无法归并时沿用既有丢弃行为
- resourceProjectionModel 把扩展名分类器更名为 projectedResourceKind 并收敛为准入与显示类型
- resourceProjectionModel 新增 projectResourceCanvasCategory:项目版本独立成栏、Agent 回执归文档、其余按资产分类
- Rust resource_layout 测试夹具改用现行分区值,并新增既有旧 section sidecar 仍可读取的用例
- 新增 resourceCanvasSectionMapping 测试:旧值乘目标栏目矩阵覆盖坐标保留、section 改写、changed 判定与回落列不变量
- projectResourceProjectionModel 测试新增 7 栏分区投影、项目版本独立成栏与只登记游戏代码项目落在待归类
- 同步 PRD、GameAgent 资源自由画板技术方案、AGC 实施计划与共享记忆决策记录的分区口径
- styles.css:`.game-resource-search` 的 `display: none` 恢复成 `display: flex`(原始提交 3a8dff140 里就是 flex,PR #193 重排工作台时被改成 none),并补 `position: relative; z-index: 40`、常态 `align-self: center`、与右下角缩放 Dock 同款的浮层阴影。
- styles.css:`.game-resource-live-notice` 同样补 `position: relative; z-index: 40`;「清除搜索并定位」按钮只在这条提示条里,提示条被画本场景盖住时搜索链路依旧是断的。
- 根因(堆叠顺序):资源卡与栏目标题栏都画在 `.game-resource-book-scene`(absolute + z-index 20,子画布态还有一层不透明点阵底)里,而搜索框与提示条是 `.game-resource-book-manager` 的流式子节点;定位元素按绘制顺序整体盖在流内内容之上,所以只放开 display 等于"画了但看不见、点不到"。
- 可见性口径:AGC 的 vitest 没开 `css: true`、不加载 styles.css,`toBeVisible()` 在这里是恒真的假守卫;新增的守卫按本仓既有口径读 CSS 源文件做声明级断言(不是 display:none、z-index 高于画本场景)。
- 行为用例:总览态输入即过滤总览摞上的卡片;分页画布态同一搜索条件继续生效、清空后卡片恢复,证明这条链路确实由搜索框驱动。
- 端到端用例:搜索条件挡掉刚提交的新素材时保留条件并提示,点「清除搜索并定位」后清空搜索并定位选中新素材。
- index.tsx:`ResourceBookScene` 的场景根 `.game-resource-book-scene` 补上 `genarrative-image-canvas` 类,让共享 `SelectionOverlay` 的框选 token 有定义它的祖先。
- 根因:共享选择框的 `border: 1px solid var(--genarrative-image-canvas-selection-border)` / `background: var(--genarrative-image-canvas-selection-fill)` 没有 fallback,这两个 token 只声明在 `packages/image-canvas-react/src/styles.css` 的 `.genarrative-image-canvas` 根类里,而该根类由共享 `CanvasViewport` 渲染;AGC 只渲染 `SelectionOverlay`、从不渲染根类,声明在计算值阶段被整条丢弃,用户框选时看不到任何选择框。
- 安全性:该根类只有 3 条自定义属性声明、无布局副作用,加 className 不改布局。
- 测试:新增真实 `pointerdown` / `pointermove` 框选用例,断言选择框存在、内联几何非空,且最近的 `.genarrative-image-canvas` 祖先就是画本场景根;去掉根类后该用例失败(已实测)。
- 测试:新增 CSS 声明级断言,钉住根类的描边/底色 token 存在且非透明,消费端仍按 var() 取值(jsdom 拿不到计算色,这里只能钉声明)。
- pitfalls.md:补记"复用共享画布子组件要自己渲染根类"的排查口径。
- spacetime-editor-idempotency-smoke.test.ts:terminates a live standalone-shaped child process 补第三参 20_000,并加注释写明这是负载敏感的局部超时。
- 性质说明:这是局部有界超时,不是放宽判据。断言 child.exitCode !== null || child.signalCode !== null 一字未动,全局 testTimeout 与 maxThreads 也未动。
- 为什么不加注入桩:该用例的存在意义就是验真实杀进程路径(spawn 真实常驻 node → stopStandalone → Windows 上 spawn taskkill /PID /T /F 杀进程树并等退出)。加桩等于把被测对象换掉,比放宽断言更隐蔽,因此明确不采用注入桩。
- 为什么需要:这两个真实进程的启动与回收在全并行负载下会明显超过默认 5000ms;单独跑实测 167ms,全仓并行时曾撞满 5000ms 上限。
- 上限仍具约束力:stopStandalone 自身对 SIGTERM → SIGKILL 只有 5s 有界等待,而 runTaskKill 与 SIGKILL 之后的 once(child, 'exit') 都没有内部超时,20s 外层上限仍能抓住真实挂死。
- 已实测第三参在当前 vitest 0.34.6 生效:临时把该值改成 1 时复现 Test timed out in 1ms,改回 20_000 后该文件 4 passed。
- styles.css:`.game-resource-outline` 常态从 `opacity: 0.68` + `scale(0.86)` + 透明底 + 无阴影,改回完整尺寸(`opacity: 1`、`transform: translateY(-50%)`)并给现有视觉语言的 Dock 底板(`--platform-subpanel-border` 边框 + `--platform-nav-fill` 背景 + 与右下角缩放 Dock 同款阴影 + `blur(6px)`)。
- styles.css:`.game-resource-outline button` 常态列宽由 `0 minmax(0,1fr)` 改为 `18px minmax(0,1fr)`、`gap: 6px`,栏目图标常态 width 14px / opacity 1,不再只在悬停时才出现。
- styles.css:栏目文字 11px → 12px;当前栏目(`.is-active`)常态就带 `--platform-nav-active-*` 的边框、底色和阴影,用户一眼能认出当前分区。
- styles.css:悬停/键盘聚焦保留为同一套 token 的加强(更深的边框、`--platform-nav-active-shadow`、`blur(10px)`),不再靠"常态缩到只剩文字"表达层级。
- 不改交互语义:大纲仍然是 `openResourceBookChild` 的入口,按钮 key、aria-label、aria-current 与未读小圆点全部保持原样。
- 测试:新增 CSS 声明级断言,钉住常态不降透明度、不 scale、有底板,图标常态 14px 且文字 12px(jsdom 不加载 styles.css,可见性只能钉声明)。
- 测试:新增 DOM 用例,断言每条栏目入口同时具备图标与栏目文字,且切换栏目后当前项带 `aria-current="page"`。
- 文档:同步《GameAgent资源自由画板与快速编辑》的「操作边界」与「验收」两条,把"常态缩小、只露栏目文字"改成"常态即可读可点",并同步 pitfalls 里的对应口径。
- 契约层把 ProjectResourceCanvasPosition.section 加宽为 PersistedProjectResourceCanvasSection 并删除旧 ProjectResourceCanvasSection 联合
- 七个分区类型消费点改用 ProjectResourceCanvasCategory:投影、布局模型、布局 Hook、分区高度、依赖图层与画布历史
- RESOURCE_CANVAS_SECTION_ORDER 改为 PROJECT_RESOURCE_CANVAS_SECTIONS,删除 RESOURCE_CANVAS_VISIBLE_SECTION_ORDER
- reconcileResourceCanvasLayout 接入读时归一化:旧栏目值按资源当前分区改写 section,坐标与手动标记原样保留
- 归并后的布局与原布局逐项不同,由既有协调路径判定 changed 并写回一次新分区,不引入迁移脚本
- 投影层五个资源入表点统一走 projectResourceCanvasCategory:项目版本独立成栏、Agent 回执归文档、其余按资产分类
- 新增 projectResourceDisplayKind 收敛 manifest 资产 subtype 即 kind 的显示类型口径,projectResourceTypeLabel 不再依赖旧栏目
- 卡片预览、编辑分流、媒体工具条、预览文案与画布历史快照改用显示类型与新分区,历史快照入栈时收窄到现行分区
- index.tsx 删除可见栏目导入与 visibleCategoryOrder,栏目标签由资源筛选唯一中文口径派生并补齐 7 栏
- index.tsx 补齐 7 栏默认视口与 7 个栏目图标(新增 LayoutGrid、Users、PackageOpen,移除已无用的 Code2)
- index.tsx 删除 canvasResources 的 code 过滤:只登记游戏代码的项目现在落在待归类栏目并正常显示卡片
- 新增只登记游戏代码项目的 UI 回归用例,覆盖栏目大纲、默认落地栏目与代码卡片渲染
- 测试夹具按新分区轴迁移,新增旧栏目 sidecar 读时归并用例与栏目顺序用例
- 共享测试 harness 的资源卡查询正则允许栏目标签自带空格(UI 交互)
- 同步 PRD 与共享记忆决策记录的栏目口径描述
- 新增 projectResourceMediaPreviewCategory:从卡片预览类型派生原生侧线上 category(art / audio),与画布分区栏目轴解耦
- read_local_project_media_preview 调用点不再传 job.resource.category:该字段自 6 类资产分类轴落地后是画布栏目(unclassified / ui-interaction / …),传给原生侧会被 read_local_project_media_preview_at 以「媒体预览类别只支持 art 或 audio」直接拒绝
- 该回归导致所有走媒体分支的卡片(GIF / SVG / AVIF / BMP / MP4 / WebM / MOV 与音频)读不出预览,只显示占位图标
- previewReadErrorMessage 与 resourceReadKindLabel 改用卡片预览类型判定文案,不再依赖已换语义的 category
- 废弃 useProjectResourceCardPreviews 对 projectResourceDisplayKind 的引用,错误文案与读取路由共用同一分类口径
- 预览 Hook 用例补「扩展名图片按 art 分支发出请求」与「音频仅在播放意图后按 audio 分支发出请求」两条断言,直接钉住原生侧线上取值
- 应用层媒体用例补 icon.svg 的 category 必须是 art 的断言,并修正原先描述「媒体读取改传画布栏目」的过期注释
- 写入侧真实单位是 Unix 秒:project/manifest.rs 的 GameIterationVersion.created_at 赋值为 unix_timestamp(),该函数返回 duration.as_secs()
- formatIterationVersionLabel 原来把秒值直接交给 new Date(),被当成毫秒解释,真机因此显示 1970/1/22(1788075047 毫秒 = 1970/1/22 00:41:15,与截图逐秒吻合)
- 显示侧改为 new Date(version.createdAt * 1000),与 ResourceAssetDeleteDialog 和待确认资源编辑创建时间标签的既有秒口径一致
- 给 GameIterationVersion.createdAt 补契约注释,写明单位是 Unix 秒及换算要求,避免后续再按毫秒消费
- resourceCanvasVersionBindingModel 夹具由毫秒改为真实秒值,并用真机取值 1788075047 补断言:年月日必须是 2026/8/30 而不是 1970
- resourceVersionSwitch 夹具同步改为真实秒值,避免继续沿用误导性的毫秒夹具
- PRD 明确队列身份用的 category 是资源分区栏目、只用于前端去重与布局,不作为 IPC 入参
- PRD 明确 read_local_project_media_preview 的线上 category 是原生读取分支,只接受 art / audio,须由卡片预览类型派生
- pitfalls 新增「媒体预览 category 是读取分支而非栏目」:记录阶段二把用例一起改成错误行为导致门禁失守,并写明现有守卫位置与实测失败口径
- pitfalls 新增「versions[].createdAt 是 Unix 秒」:记录夹具与实现一起错导致门禁失真,以及已知秒值断言年月日的验收口径
- styles.css:新增 `.game-resource-book-tools` 工具条带,搜索框、筛选条与状态提示在里面按流式排布;`.game-resource-search` 去掉 `margin: 10px 12px 0`,尺寸与间距交给工具条带。
- styles.css:`.game-resource-manager` 加 `padding-top: var(--game-resource-book-tools-height, 0px)`,`.game-resource-book-scene` 的 `inset: 0` 改成同一变量让出顶边;管理区与画本场景共用一条实测高度,两者不再落在同一条排布带上(总览态标题栏与分页态栏目标题栏都整体下移)。
- styles.css:`@media (max-width: 560px)` 下搜索框独占整行,窄屏筛选条换行时工具条带变高、场景按实测高度继续下移,不靠 z-index 硬压。
- styles.css:更新工具条带注释,说明 `z-index: 40` 仍保留(高于场景的 20),作用只剩"工具条内容溢出时不被画本盖住"。
- index.tsx:把画本场景以外的流式工具条包进 `game-resource-book-tools`,搜索框挂 `resourceBookToolsRef`,用 ResizeObserver 实测 `offsetTop + offsetHeight` 写入 `--game-resource-book-tools-height`;搜索、筛选、「清除搜索并定位」与 `resourceSearchRef` 全部零改动。
- index.tsx:新增 `resourceCanvasSceneSize()`,视口居中、「适应内容」、总览入场中心、滚轮与缩放锚点都按"管理区盒子减掉工具条带"计算,不再按"场景一直顶到管理区顶端"排布;`handleResourceBookWheel` 与 `zoomResourceBookBy` 补上新增依赖。
- index.tsx:`measureResourceBookOverview()` 改用画本场景自己的 rect 作为总览栏目矩形原点,场景盒子上移后栏目矩形与世界坐标仍严格同源。
- project-development.suite.ts:在既有 CSS 声明级守卫里补断言——管理区 `padding-top` 与场景 `inset` 必须指向同一个 `--game-resource-book-tools-height` 变量,工具条带是绝对定位的独立排布行,栏目标题栏 z-index 低于工具条带;选择器加行首断言,避免误取 `.game-workbench-stage > .game-resource-manager`。
- project-development.suite.ts:在栏目大纲用例里补真实渲染的 DOM 归属断言——搜索框的父链不得命中画本场景或资源总览层,工具条带是管理区直接子节点;该断言在改动前失败、改动后通过,已用"把工具条带类名改掉"的变异验证它不是恒真假守卫。
- TS 侧 GAME_CREATION_APP_LEGACY_ASSET_KINDS 补 5 条别名:ui → ui-design、game-entry/game-script/game-style → code、animation → character-animation、asset → image
- Rust 侧 canonical_game_creation_app_asset_kind 同步补齐同一批别名,两侧必须成对维护
- 按拍板口径不往 GAME_CREATION_APP_CANONICAL_ASSET_KINDS 加新 kind,避免出现 ui 与 ui-design 两个同义 kind
- 保持 art-spritesheet-slice → icon → ui-interaction 不动
- 补注释写明别名表的职责是「线上值 → canonical」而非仅收历史遗留,画板导出等现役写入侧同样要在这里收口
- 新增 assetKindCanonicalMapping.test.ts:解析 Rust 源码交叉钉住两侧 canonical 目录与别名映射逐条一致,并逐条断言已拍板的 kind → canonical → 栏目口径
- 新增 resourceCardPreviewRealManifest.test.ts:按真机 manifest 逐条形状可执行地数出预览分支与栏目分布(raster-image 52 / placeholder 8 / code 1;unclassified 58 / ui-interaction 2 / scene 1)
- 真机取证用例同时钉住「别名只救新登记、不救存量」:57 条 ui 的落盘 category 仍是 unclassified,需读时重派生或一次性回填才能归位
- 修正两条按旧错误栏目断言的既有用例:kind=ui 的上传图归「UI 交互」、animation 视频归「角色与对象」
- 补记 ui 资产落待归类的完整成因链:kind 兜底 image 再经 image → unclassified 误分
- 写明 register_local_asset_entry 命中同路径时不重算 category,故存量 57 条无法自愈
- 写明读取侧必须信任落盘 category 的原因:存在用户手动改分类的正式链路
- 列出三个根治选项供拍板:读时重派生 / 一次性回填 / 只改写入侧
- 记录别名表在 TS 与 Rust 各有一份实现、必须成对维护,以及交叉守卫位置
- 写入侧:infer_canvas_export_asset_kind 直接返回 canonical kind(animation → character-animation、ui → ui-design、asset → image),不再让别名表替写入侧兜底
- 写入侧补防御性归一,并新增 canvas_export_asset_kind_is_always_canonical 逐分支钉死返回值为 canonical、且不再写回 ui / animation / asset
- 读取侧:gameCreationAppAssetCategory 加收窄覆盖规则——落盘 category 为 unclassified 且 kind 能派生出明确非 unclassified 分类时采用派生值
- 该规则不写迁移脚本、永久自愈:真机 57 条 ui 资产待归类从 58 降到 1,UI 交互从 2 升到 59,仅剩 code 类 game-entry 在待归类(分类表设计口径)
- 收窄条件把覆盖窗口压到最小:落盘值本身是明确分类时仍信任落盘值(保留用户在分类与标签面板手动设置的权威性)
- kind 派生结果本身即 unclassified 的 image / video / code / publication-material 不受影响,补断言钉住
- resourceCardPreviewRealManifest 由「记录缺陷」改为「守住修复」:断言归位后的 58 → 1 分布与 57 条 ui 全部落 UI 交互
- PRD 与 pitfalls 补记分类取值优先级、读时自愈规则、唯一盲区(手动把可明确分类的资产设为待归类会被覆盖)及写入侧 canonical 化
- index.tsx:删掉画布侧 PlatformResourceFilterBar 的用法(「全部 / UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类」分类 chip 与画布标签 chip);@ 素材面板与参考图弹窗继续用同一个共享组件,共享组件本身不动。
- index.tsx:随筛选条一起摘掉只为它存在的画布筛选轴——resourceCanvasCategoryFilters / showResourceCategoryFilter / resourceCanvasActiveTags / resourceTagLibrary,并把 visibleResources 里「分类不等于 all 才过滤」「标签必须命中已选标签」两个此后恒真的条件删掉,画布可见资源只由搜索框收窄。
- index.tsx:移除只被筛选条用到的导入(PlatformResourceFilterBar、RESOURCE_REFERENCE_FILTERS、type ResourceReferenceFilter、assetTagsMatchSelection、buildGameCreationAppAssetTagLibrary)与 ResourceCanvasCategoryFilterBySortMode 类型及其默认值构造函数。
- styles.css:删除只服务画布筛选条的 .game-resource-category-filter 规则。
- 不动分区栏目:栏目大纲、栏目标题栏、categoryOrder、projectResourcesByCategory 与「资源总览」入口保持原样。
- index.tsx:搜索框不再常驻。常态只在右下角缩放 Dock 末尾留一颗搜索按钮(复用 `game-resource-book-zoom-button`),`Ctrl/Cmd+F` 或点它把浮层叫出来;关闭走本仓既有浮层口径——直接复用美术画布的 `useImageCanvasFloatingOptionDismiss`(Esc / 点外部,Escape 在 document 上截断,不会连带触发画布清选中)。
- index.tsx:浮层打开后自动聚焦搜索框,`resourceSearchRef` 不再是死 ref(PRD「焦点落到资源搜索框」的入口接上了)。
- index.tsx:搜索语义零改动——`setSearchText`、`visibleResources` 过滤、「清除搜索并定位」原样保留;浮层收起不清条件(`type=search` 的原生 Esc 清空被 preventDefault 挡掉),条件生效时 Dock 搜索按钮带 `is-active` 高亮,用户看得出"当前有搜索"。
- index.tsx:撤掉常驻搜索条连带的那条排布带——删除 `resourceBookToolsRef`、`resourceBookToolsHeight`、`resourceCanvasSceneSize()`、ResizeObserver 测高 effect,以及缩放锚点 / 滚轮锚点里减掉工具条带的两处算式;`resourceBookSceneSize` 重新等于管理区盒子。
- index.tsx:状态提示与附件导入失败条移出排布带,落到新的浮层容器 `game-resource-book-notices`(顶部绝对定位,不占流内高度)。
- styles.css:`.game-resource-manager` 去掉 `padding-top: var(--game-resource-book-tools-height)`,`.game-resource-book-scene` 恢复 `inset: 0`,删除 `.game-resource-book-tools` 规则与窄屏 560px 那条只为常驻搜索框写的宽度规则;搜索框改为贴右下角 Dock 向上展开的浮层(`bottom: 58px` = Dock 的 14px + 34px 高 + 10px 间隙,`z-index: 40`)。
- styles.css:新增 `.game-resource-book-notices` 浮层与 `.game-resource-book-zoom-button.is-active`(复用 Dock 按钮的 hover 色,不新增配色 token)。
- tests/appSurface/harness.ts:新增 `openResourceSearch()`——按产品入口打开浮层再返回输入框,`getByLabelText` 在浮层未打开时直接抛错,堵掉"元素在但点不到"那类假绿。
- tests/appSurface/project-development.suite.ts:重写搜索可见性守卫为「浮层不落在标题栏那条带上」(管理区不再有 padding-top、场景 `inset: 0`、浮层底边 ≥ Dock 底边 + Dock 高度、浮层不带 top、提示层顶边 ≥ 标题栏高度),新增浮层开合用例(按钮 / Ctrl+F 打开、焦点落在输入框、Esc 与点外部收起都不清条件),并把既有 5 处搜索用例改为先叫出浮层。
- tests/projectResourceLiveIntegration.test.tsx:「清除搜索并定位」用例改为重新叫出浮层读条件与清空后的值。
- 验证:新增的两条断言已用变异实测不是恒真守卫——去掉自动聚焦、或让关闭顺手清条件,对应用例都会红灯。
- 资源卡预览不再计算 SHA-256:`AgentRuntimeInspectionImage.sha256` 改为 `Option<String>`,读取函数新增 `include_sha256` 开关,门禁、分块读取、漂移与重开身份复核、签名与尺寸校验仍只有一份实现,只在末尾按调用方决定是否算摘要。预览路径传 `false`(它的 `LocalProjectImagePreview` 从来不消费摘要),Agent `image.inspect` 路径继续传 `true`。
- 取摘要改为 `sha256_digest() -> Result`,缺摘要失败关闭;`project_gates.rs` 提前算成 `image_sha256` 再进判定闭包,避免退化成「空摘要 == 记录里的空摘要」而静默通过视觉检查复核;`runtime_tools/media.rs` 改用仓库既有的 `match { Err => return failed observation }` 风格,不引入新的 panic 面。
- 新增 `read_manifest_cached_for_preview`,只接到 `read_local_project_image_preview_at`、`read_local_project_text_preview_at`、`read_local_project_media_preview_at` 三个预览命令,消掉一次画布装载中「每张卡片各读并解析一遍同一份 manifest」的重复开销。`read_manifest` 本体、`read_manifest_for_project` 和写入路径的安装后回读一字未动。
- 缓存命中判据每次重新获取当前文件身份:路径复核 + 非符号链接或 reparse point + 普通文件 + 长度 + mtime + 文件身份(Windows `(volume serial, file index)`,Unix `(dev, ino)`);失败一律不入缓存,错误语义与 `read_manifest` 完全一致。为此放开 `metadata_is_windows_reparse_point` 为 `pub(crate)` 并新增跨平台 `open_file_identity_key`。
- 写入侧硬做失效:`write_manifest_with_lock_hook` 成功写入后调用 `forget_preview_manifest`。只靠长度 + mtime + 身份堵不住同长度原地改写,显式失效后正确性不再依赖时间戳粒度,读取侧身份复核保留为兜底。
- 收益如实记录:真机项目实测 52 次 manifest 读加解析约 10 ms 量级,并省下约 3.15 MiB 重复磁盘读取。这是消除重复工作的正确改动,但不是性能救命项;用户感知的加载耗时大头仍在资源卡图片解码。
- 决策记录补「AGC 资源卡预览维持 data URL 加纯内存 LRU,不引入 Tauri asset 协议」:正式否决该候选路径,写明原始合同原文(不向 WebView 暴露任意本机文件协议或绝对路径)、打通所需配置(`assetProtocol` 加 `protocol-asset` feature 加 scope 只能 `**/*`)、会绕过的全部门禁(`file.read` 权限、项目边界、登记复核、敏感路径、父目录链接、硬链接、读取漂移与重开身份、按魔术字类型校验、尺寸上限、取消语义与全局 3 permit),以及收益与代价不成比例;同时否决磁盘缩略图缓存。
- `pitfalls.md` 补同题排障口径,含判据陷阱:把「每次启动重读」当成本瓶颈是错的,真机 52 张 PNG 分层实测 Rust 加 JS 合计仅约 0.3 s。
- 成因回填:分页态标题栏右端的「资源总览」(回到资源总览)按钮点不动,是常驻工具条带 `.game-resource-book-tools` 造成的——它是横跨整行、`z-index: 40` 的绝对定位层,正好盖在栏目标题栏那条带(`z-index: 30`)上,把按钮的命中区域整段吃掉。上一提交撤掉这条带后问题已消失,本次只补守卫,不改实现。
- project-development.suite.ts:新增声明级守卫,钉住浮层提示层(`.game-resource-book-notices`)与返回按钮所在标题栏的层级关系——提示层 `z-index` 高于标题栏(提示要看得见),因此容器必须 `pointer-events: none`、只有提示条自己 `pointer-events: auto`;提示条顶边 ≥ 标题栏 `min-height`,连视觉重叠都不发生;并反向断言常驻工具条带在 styles.css 与 index.tsx 里都已不存在(变异实测:把容器改回 `pointer-events: auto` 该用例立即红灯)。
- project-development.suite.ts:新增渲染级守卫——附件导入失败条在场(提示层挂载)时进入分页态,断言返回按钮不在提示层容器内、不是 disabled,点击后 `data-resource-book-view` 回到 `main` 且「资源总览」region 在场。
- 说明:jsdom 不做布局命中判定,像素级"是否被盖住"只能由声明级的层级/命中声明 + 真机复核确认;渲染级用例钉的是"提示层在场时返回入口仍然渲染、可点、并真的接在返回总览上"。
- 根因:`.project-supervisor-surface.is-direct-codex` 下消息列表与输入区是两只各自带边框、
  各自绝对定位的盒子——列表 `bottom: 104px`、输入区 `bottom: 0; left/right: 0`,两盒上下
  紧贴。输入区自己那只盒子的左右描边与列表的左右描边接成两条贯穿整栏的竖线,加上列表
  只留 24px 底部内边距,最后一条消息还会被不透明的输入区盖住。观感就是"输入框紧挨着
  对话框、两侧有突兀竖条"。
- styles.css:消息列表(direct-codex)改为铺满整个会话区(`bottom: 104px` → `0`),它自己
  那只带描边和圆角的盒子就是"对话框";`padding-bottom` 与 `scroll-padding-bottom` 由 24px
  提到 256px,给框内输入区留出位置(256 ≈ 输入区最高 230px + 12px 内缩 + 约 14px 间距)。
- styles.css:输入区(direct-codex,含同类选择器共两条绝对定位规则)由贴边改为框内内聚
  区块:左右下三边各内缩 12px,圆角 14px 收到与外框同一档 12px;边框、底色、阴影继续用
  既有 token(`--platform-surface-border` / `--platform-input-fill`),不新增配色。
- 不改交互语义:输入区仍是同一棵 DOM(编辑器、@ 引用、AI 润色、模型选择、提交按钮全部
  原位),只改它在对话框里的落点与留白。
- project-development.suite.ts:新增守卫——对话框盒子四边归 0、输入区三边内缩同一个值、
  仍是绝对定位且保留边框与底色、消息列表留白 ≥ 输入区最高高度 + 内缩且与
  `scroll-padding-bottom` 同值、编辑器 min/max 高度就是留白算式的来源;并同步既有那条
  钉 `bottom: 104px` 的断言为 `bottom: 0`。
- 说明:本机无无头浏览器、vitest 未开 `css: true`,像素级观感只能靠真机复核;这里钉的是
  声明级几何关系与"输入区仍在同一棵渲染树里"。
- 资源卡 `<img>` 补 `decoding="async"`,把真机 37.2 MPx 量级的 PNG 解码移出主线程。刻意不加 `loading="lazy"`:卡片已经由资源画本的 `IntersectionObserver`(`rootMargin: 160px`,root 指向真正包含卡片的容器)精确门禁,只对进入可见区及预取边界的卡片发 IPC;再叠一层浏览器视口懒加载会让「预取边界 + 热预取 12 张」读到的视口外卡片被压住不解码,等于白预取。`decoding="async"` 不改变何时加载,与既有调度正交。
- 卡片预览的热预取改为只喂当前分页栏目:原先传的是 61 项全量投影,前 12 张可能落在别的栏目,用户看不到的卡片先占满全局 3 个物理读取槽,看得见的卡片反而要排队。
- 为此把 `projectResourcesByCategory`、`resourcePageCategoryKey`、`resourcePageCategories`、`activePageCategory`、`activePageResources` 整块上移到 `useProjectResourceCardPreviews` 之前并删除原位置定义;`projectResourcesByCategory` 只依赖 `canvasResources` 与 `categoryOrder`,其余只在既有依赖之上派生,上移不引入前向引用。`activePageResources` 合并为单一声明(原先在 `activePageLayoutReady` 之前另有一份同定义局部变量)。
- 已知副作用并接受:Hook 的 `identityByResourceId` 只覆盖当前栏目,因此「A 栏目 → B 栏目 → 回到 A」会重新读取 A 的可见卡。这正是「栏目级热预取」的定义,也符合 PRD §3.3.2「项目 / mode 切换必须清理预览缓存」的口径;代价是来回切换栏目各重读一次可见卡,单张实测 1–7 ms 且仍受全局 3 槽限制。
- 删除 `resourcePreviewVersionByResourceId` 的三处引用与 `useState` 声明。它从未被写入过:只被 `new Map()` 初始化和清空,`previewVersionByResourceId` 恒为空串,属死代码。
- 选择「删」而不是「填真值」:填真 manifest revision 会让每次 manifest 变更把全部缓存 identity 失效并触发全量重读;而现有 identity(`projectPath` / `projectId` / `id` / `category` / `path` / `mediaType`)对不可变素材已经足够——路径变了就是另一个资源。本轮不新增失效判据。
- 遗留:`appSurface > keeps the resource preview version cache wired and cleared on project switch` 断言的是刚被删除的 prop 接线,需要同步删掉该断言;该测试文件当时正被另一个 Agent 在途编辑,按分工不在本次改动内,已上报由主人处理。
- pitfalls.md 新增「资源搜索栏改成临时叫出的浮层」条目:写清"回到资源总览点不动"的两层成因(横跨整行、z-index 40 的透明工具条带盖在 z-index 30 的栏目标题栏上吃掉命中区域;管理区 padding-top 与场景 inset 只为给搜索框腾带子)、浮层的几何口径(bottom: 58px = Dock 14px + 34px 高 + 10px 间隙,刻意不写 top;提示层 top: 58px = 标题栏 42px + 16px)、必须钉住的两条(提示层 pointer-events: none + 提示条 auto;收起不清搜索条件、由 Dock 按钮 is-active 表达),以及门禁为什么抓不到(jsdom 不做命中判定)。
- pitfalls.md 新增「画布侧分类筛选条已移除」条目:记录用户口径、只移除画布这一处用法而保留共享组件、随它删掉的画布筛选轴,以及"不要顺手改分区、将来恢复要重新设计入口"。
- pitfalls.md 新增「项目对话输入区必须留在对话框内部」条目:记录两只绝对定位盒子上下紧贴的成因、对话框=消息列表盒子 + 输入区三边内缩 12px 的做法,以及列表留白 256px 与编辑器 min/max 高度必须成对维护。
- 本次只补文档,不改代码;编码检查 4374 文件通过,文件保持 LF。
- appSurface > renders one body-first card system:把原先钉「全局只读 1 次」的断言收窄成「同一身份不因重复请求新增物理读取」——热预取之后先让 IntersectionObserver 再报一次可见、再打开一次详情,读取次数必须与快照一致;并先钉住快照非空且读取目标都是 assets/hero.png,避免空集恒真。60d8b8fbb 把热预取改成只喂当前栏目后,用例里每进出一次「角色与对象」都会重读该栏目的可见卡(该 commit 已明确接受这个代价),全局总次数会随进出次数漂移;「同一身份同时只读一次」是 PRD 里真实存在的合同,比总次数更精确,没有放宽。
- appSurface > keeps the resource preview version cache wired and cleared on project switch:60d8b8fbb 删掉了从未被写入的 resourcePreviewVersionByResourceId 死接线(只被 new Map() 初始化与清空,previewVersionByResourceId 恒为空串),断言守的 prop 与其清空语句都已不存在,按「四不写」删除这条形态断言;用例改名 clears the resource preview cache and cancels the old preview scope on project switch。
- 同用例把意图从源码正则换成可执行行为断言(原断言只证明接线存在,不证明缓存真被清):渲染 A 项目并等卡片预览落到 Blob URL 后切到 B 项目,断言 ① 旧卡片预览的 Blob URL 被撤销、② 旧 scope 的在途读取按旧 scopeId 取消、③ 同一资源以新项目身份与新 scopeId 重新读取。
- 变异验证在 %TEMP% 下的独立 worktree 沙箱里做(未改本工作树产品代码):去掉预览 Hook 里 loaded 早退 → 第二条用例失败(同身份读取 6→7);去掉 cancelLocalProjectResourcePreviewScope(previousScopeId) → 第三条 ② 失败;切项目不复用新 scopeId → ③ 失败;拆掉两条撤销 Blob URL 的路径 → ① 失败。
- 未改 uses one full-page canvas per resource section with dependency-only guide lines:沙箱实验证明只把预览 Hook 入参从 activePageResources 换回 canvasResources,该用例即整体通过。它守的「总览逐栏绘制各栏目真实卡片」是被 60d8b8fbb 意外收窄的(renderResourceBookCard 在 identityByResourceId 缺该资源时返回 null,而该 map 从该 commit 起只覆盖当前栏目;其 commit message 只声明了预取口径与重读代价,未声明总览不再绘制其它栏目卡片),属产品回归,已上报,不以改断言掩盖。
- 门禁:ai-game-creator-shell:typecheck exit 0;apps/ai-game-creator-shell/tests 1 failed / 1170 passed / 4 skipped(唯一失败即上面上报的回归;改前 3 failed / 1168 passed);src/components/image-editor 1385 passed;check:encoding 4374 文件;git diff --check 干净。
前一次改动(60d8b8fbb)把 `useProjectResourceCardPreviews` 的 `resources` 从全量投影收窄成当前分页栏目,造成产品回归:该入参不只喂热预取,它同时决定返回的 `identityByResourceId`,而 `renderResourceBookCard` 在身份缺失时 `return null`,于是资源总览(main 态)只有当前分页栏目画出真实卡片,其余栏目只剩栏目标题栏加空的层叠占位。

- 拆成两个入参:`resources` 恢复全量资源投影语义(决定 `identityByResourceId`、缓存与卡片挂载),新增 `eagerResources` 只用于 `eagerPreviewLimit` 计数的热预取,缺省回退 `resources`。两个入参的语义写进 Hook 参数注释,调用方 `index.tsx` 在传参处注明「收窄 `resources` 会让其它栏目只剩标题栏」。
- 纠正前一次提交的说法:**identity map 必须保持全量**,不能跟着热预取一起收窄。热预取可以只喂当前栏目(上一个提交的意图保留、`A → B → 回 A` 会重读 A 可见卡的已知代价也保留),但身份 / 渲染口径收窄会让总览其它栏目不再挂载卡片——技术方案要求「主画布展示各子画布的缩略入口,内部按资源类型显示有限层叠卡片……主画布预览和子画布展开态复用同一套卡片视觉」,这是产品回归而非可接受的行为变更。
- 不使用「身份未知时用 idle 占位兜底渲染」这条备选:它会让总览卡片失去真实预览,观感更差。
- `pitfalls.md` 追加同题排障口径,含通用规则:凡 Hook 入参同时参与「身份 / 缓存 key」与「调度 / 预热」,就不能为优化调度去收窄它,应先确认它还有没有别的消费者。
- 回归用例 `appSurface > uses one full-page canvas per resource section with dependency-only guide lines` 已自行变绿(未改该断言)。验证:`npm run test -- apps/ai-game-creator-shell/tests` 1171 passed / 4 skipped / 0 failed;`appSurface.test.ts` + `useProjectResourceCardPreviews.test.ts` 415 passed;`src/components/image-editor` 1385 passed;typecheck exit 0;check:encoding 4374 文件;`git diff --check` 干净。
上一轮把「总览其它栏目不画卡片」的回归结论建立在推理上(identity map 由全量投影构建 ⇒ 不会有卡片因身份缺失而不挂载)。本轮把它变成实测。

- 新增 `appSurface > mounts a real card body in every non-empty resource section overview`:自带 manifest,把 `category` 显式写死,让 `character` / `audio` / `document` / `unclassified` / `version` 五个栏目同时非空(外加空的 `ui-interaction` / `scene`),再逐栏目断言 `.game-resource-book-scene-card[data-resource-book-category=…]` 内存在 `.game-resource-card` 本体,并用 `.game-resource-card` 的 `data-resource-card-id` 回查投影结果,证明卡片确实落在被断言的栏目(不是靠「有子节点」这种恒真守卫蒙过去)。`audio` 刻意保持 `idle`(音频不接受可见性预取),用来证明卡片挂载不依赖预览是否已就绪。
- 自带 manifest 而不是改共用 fixture:`addSectionResources` 的四个资产被同一测试后续多处断言引用(例如 `选中资源:待归类 section.md`),给它补 `category` 会把 `section.md` 从「待归类」挪进「文档」,连带打挂那些既有断言。新用例因此只新增、不改动既有断言。
- 分类字段名踩坑记录:manifest 资产上的字段名是 `category`,不是 `assetCategory`(`assetCategory` 是前端投影后的 `ProjectResource` 字段)。先写成 `assetCategory` 时它被静默忽略,`gameCreationAppAssetCategory` 只能按 `kind` 派生,`design-document` / `game-code` 都不是 canonical kind,于是 `section.md` 落在「待归类」、`document` 栏目为空。
- 变异验证(本断言的价值所在):把 `resources` 改回 `activePageResources`(即回到回归状态)后,本用例与既有 `uses one full-page canvas per resource section with dependency-only guide lines` **双双失败**(`expected null not to be null`);恢复后两者通过。断言确实守住了这条回归。
- 验证:`npm run test -- apps/ai-game-creator-shell/tests` 81 files passed,1172 passed / 4 skipped / 0 failed;typecheck exit 0;eslint 与 prettier 干净;check:encoding 4374 文件;`git diff --check` 干净。本轮只改测试文件,`src/**` 与 HEAD 逐字节一致(变异验证后已还原并核对)。
- `pitfalls.md` 新增「manifest 资产分类字段写错名会被静默忽略,跨层字段名相近时尤其危险」:manifest 上的字段名是 `category`,而 `assetCategory` 是前端投影后 `ProjectResource` 的字段名,写错层不报错也不告警,本文档记录现象、原因、处理与通用规则(「设置无效但没有任何报错」时第一优先怀疑字段名或层级错配)。
- 同文件「预览 Hook 的资源入参收窄会连带收窄 identity map」那条的验证行改为以新用例 `mounts a real card body in every non-empty resource section overview` 为首选证据:它逐栏目覆盖五个非空栏目并含变异验证;既有 `uses one full-page canvas per resource section with dependency-only guide lines` 降为补充(只覆盖一两个栏目)。
- 只追加与就地更正该条验证行,未重排任何既有条目。
- 新增 RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS = 6 与槽位宽高比常量,作为层内网格列数上限
- 新增 normalizedDependencyDepth:落位、层带与层内网格共用同一深度归一,避免同一深度被当成两层
- 新增 dependencyLayerColumnCount:列数 = clamp(floor(sqrt(卡数 × 槽位宽高比)), 1, 6),是卡片数量的纯函数
- 新增 dependencyLayerGrid:层内网格几何,行高统一取该层最高卡,使层高与层内顺序无关(中位数扫描重排后几何不抖)
- 用 dependencyColumnGeometryByDepth 取代 dependencyColumnXByDepth:x 仍只由 dependencyDepth 决定层带基址(depth 仍是唯一横向层级权威),层带宽度 = 列数 × 列间距;全部层为单列时与旧值逐值一致
- 层内按行优先落位,层内排序(稳定 ID 初序 + 两轮左至右/右至左中位数扫描)与深度层级语义不变;网格之外仍按既有向下找空位回避手动坐标与饱和层带
- 移除 dependencyBucketHeight,簇高、簇行数与层内 rank 改用层内网格行数
- 同步 resourceCanvasLayoutModel 测试:新增层内网格平面(12 张同深度 → 4 列 3 行)、重复计算与输入顺序稳定、层带随深度单调且不与换行层重叠、列数封顶 6、手动坐标在自动网格重派生前后逐项不变;同层 3 张卡的环连续性用例改为行优先网格口径
- 影响面:既有项目里已落盘的自动坐标(manuallyPlaced=false)会在下次协调时按新口径一次性重排成平面;manuallyPlaced=true 的历史手动坐标不删除、不迁移、不被自动排序覆盖
- 不新增持久化字段、sidecar schema 不升版,ProjectResourceCanvasSection/Position 契约与 RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE 均未改动
- useProjectResourceCanvasLayout 新增 AutomaticPositionPolicy(rederive / preserve),取代原先裸布尔 rederiveAutomaticPositions 传参
- 拖拽(manual 意图)写链固定用 preserve:不再丢弃其它自动坐标,其余自动卡原地不动,也不再补位到被拖走的空位
- 资源集合与拓扑身份签名变化仍按 PRD 用 rederive 重派生,关系图就绪后的深度修正不受影响
- 新增 automaticCoordinatesMatch(忽略数组顺序的坐标比较):手动写入后只在坐标真的变化或有新资源需要落位时才继续排一次资源同步,避免只由排序归一触发的第二次 CAS 写入再次把其它卡补位
- 写入后的 updated / conflict 分支统一按 writePolicy 计算 reconciledAfterWrite,并据此判定是否需要资源同步
- rebuildOptimisticLayout 改为接收策略参数,手动写入后的会话布局同样保持其它自动卡不动
- 新增用例:拖走一张后其余自动卡在所有写入里坐标逐项不变、视图里也不补位;变异验证(把拖拽路径改回 rederive)后该断言失败
- 副作用(已确认接受):把卡正好丢在另一张卡上时不再自动挪开它,直到下一次资源集合或拓扑变化触发重派生
- 门禁:AGC 1178 passed / 4 skipped / 0 failed,共享组件 1385 passed,typecheck exit 0,编码检查 4374 通过
- 根因:`.project-supervisor-message-list` 在文件末尾还留着一条同选择器、同权重的
  `bottom: 156px`("列表在上、输入区在下"那版两盒布局的残留)。它按"后写胜出"把上一轮
  bdb07c141 改的 `bottom: 0` 悄悄顶掉,外框底边落在输入区腰上、输入区下半截露在框外,
  真机上看到的就是"消息区外框底边与输入区压在一起、外框没把输入区完整包住"。
- styles.css:把那条残留规则换成对话框盒子的最终几何(top/right/bottom/left 全 0,
  `padding-bottom` 与 `scroll-padding-bottom` 都是 256px)。它位置在文件靠后,同权重后写
  胜出,顺带把 `:has(.plan-gdd-surface, .planning-lane-runtime-strip)` 给列表留的
  `padding-bottom: 12px` 压回去,滚到底时最后一条消息不会被输入区盖住。
- styles.css:输入区补上第四条边——`max-height: calc(100% - 24px)`(24px = 上下各 12px)。
  左右下三边仍显式内缩 12px,上边由 max-height 兜底,于是外框完整包住输入区:四条边都不
  与外框描边重合,底边留出 12px 边距(外框底边 0 / 输入区底边 12px)。
- styles.css:窄屏(≤760px)改成一个模型——外框画在列表与输入区共同的父节点
  `.project-supervisor-conversation` 上,列表撤掉自己那只框(框里不套框),输入区回到
  文档流,靠外框 12px 内边距四周留白、随列高增长不溢出。原来那条窄屏输入区规则权重低于
  `.project-supervisor-composer.is-direct-codex` 那条,其实一直是死声明,这次才真正生效。
- tests/chatDialogFrameLayout.test.ts:新增用例,先按真实层叠(媒体查询是否命中 -> 特异性
  -> 源码顺序)求生效值,再断言外框四边归 0、输入区四边内缩同一个值、底边与左右留白严格
  大于 0、留白 256 = 12px 内缩 + 228px 输入区最高高度 + 16px 间距(228 由编辑器
  max-height 140、输入框行距 8、操作排 28px 方钮、输入框下内边距 4、输入区上下内边距 16、
  操作条 2 + 30 方钮逐项从真实声明里算出来),以及窄屏下外框/列表/输入区各自落位、输入区
  仍在外框节点子树里且交互控件原位。
- tests/appSurface/project-development.suite.ts:把那条按"第一条匹配规则"取值、用 230 估
  算的留白断言同步成按声明算出的 228 与实际间距 16,并注明层叠生效值的守卫在新用例里。
- 变异验证:输入区 `bottom: 12px -> 0`(还原"贴死重合")时新断言失败;外框
  `bottom: 0 -> 156px`(还原上一轮残留)时"外框底边必须严格低于输入区底边"失败(-144)。
- 说明:本机无无头浏览器、vitest 未开 `css: true`,像素级观感只能真机复核;这里钉的是
  层叠生效的声明级几何关系与"输入区仍在外框节点的子树里"。
- PRD §3.3:栏目页改为纵向滚动网格,顺序由 sidecar 记录的 (y, x, resourceId) 读序推出,新增列数控件与栏目内拖拽排序条文
- PRD §3.3.1:三阶段改写为当前形态(本体卡保留;阶段二改为栏目分页网格;阶段三改为未排序栏目的初始顺序派生)
- PRD §3.3.2:可见性预取口径改为以栏目滚动容器为交叉根
- PRD §5.2:实现状态与字段语义改为「x / y 只承载栏目内顺序」,新增顺序阶梯、已排序栏目新资源追加末尾、列数偏好与关系说明条文
- PRD §5.2.4:删除 viewport、wheel、拖动改写坐标等画布条文,改为网格、列数与拖拽排序
- PRD §5.2.5:手动拖动合同改为拖拽排序合同
- PRD §5.2.6:删除资源依赖关系图层整节
- PRD §7.2:验收改为顺序持久化;§7.3 依赖关系图验收整节删除;§7.5 重写为栏目分页纵向网格验收,新增「纵向滚动无横向溢出」与「列数被记住」两条判据
- PRD §6/§8:分阶段范围与非目标同步
- 技术方案:原《GameAgent资源自由画板与快速编辑》改名为《AGC资源管理页纵向网格与排序》,重写为当前形态(页面形态、栏目内顺序、列数偏好、拖拽排序、关系说明),保留权威数据、焦点竞态与派生编辑条文
- 实施计划:资源卡与资源管理页边界改为栏目网格、顺序落盘、列数偏好与滚动容器可见性
- decision-log:新增 2026-09-11 决策(含用户明确判断优于 PRD 的事实依据,以及有意推翻 1da7dc62a)
- pitfalls:整体改写「AGC 资源管理页真正自制的交互面」条目
- docs/README.md 与 document-map.md:更新改名后的专题文档入口
This reverts commit c2b851cc36.
- 渲染层新增 `RESOURCE_BOOK_ALL_TARGET = 'all'` 特殊项:跨端契约 `ProjectResourceCanvasCategory` 不加 `'all'`,sidecar schema 不升版,落盘布局的 `section` 也不会出现它
- 资源总览第 0 张卡即「所有资源」:排在全部栏目卡之前,复用同一套缩略图类名/圆角/底色/阴影与栏目标题栏组件,不新增卡片样式
- 该卡的计数徽标取全量资源数,口径等于各栏目计数之和(同一份画布投影,不另算一套)
- 画本场景的栏目顺序与资源分组各多一个渲染层入口;`buildResourceBookScenePlan` 对特殊项只出计数与标题栏,不铺卡片,避免同一张资源卡在总览里挂载两份(`data-resource-card-id` 查询与 FLIP 的 `card:*` 键都会撞车)
- 新增「所有资源」页:栏目大纲 + 收起资源按钮 + 按栏目分组的滚动卡片网格;卡片复用栏目页同一颗渲染器与卡宽高,容器与网格样式补齐在 styles.css
- 该页的卡片显式关掉拖拽(卡片坐标不属于任何栏目画布,写回会污染布局 sidecar),保留选中/播放/菜单交互,并把光标与 `touch-action: pan-y` 交给滚动容器
- 栏目大纲抽成 `ResourceBookOutlineNav`,栏目页与全部资源页共用同一条入口带;渲染出的 DOM 与抽取前一致
- `renderResourceBookCard` 增加可选 `draggable` 参数,`ResourceCard` 的三个移动端指针回调改为可选
- 断言:总览存在「所有资源」卡且为第 0 张、计数等于全量资源数且等于各栏目计数之和、点它进入的页面按栏目分组且每组都挂真实资源卡、场景计划里特殊项不出卡片且整份计划无重复卡片键、全部资源页分组只保留非空栏目且保持总览顺序
- 既有「N 项」徽标断言从全文档文本查找收窄到 character 栏自己的标题栏(新增第 0 张卡后同文案会撞车,收窄而非放宽)
- 删除资源总览标题栏里那颗副标题的**两个分支**:`${canvasResources.length} 项正式与候选资源` 与空项目时的 `从资源栏目开始整理资源`
- 位置:`apps/ai-game-creator-shell/src/view/project-development/index.tsx` 的 `<header className="game-resource-book-main-heading">`(基线 364492de3 的 5867-5871 行那颗 `<span>`,标题栏自 5863 行起)
- 同步删除只服务于它的 `.game-resource-book-main-heading > span` 样式,模块内不再留墓碑规则
- 该文案没有承载 `aria-label` 等语义(区域语义一直在容器的 `aria-label="资源总览"` 上),因此不需要把语义迁移到容器,也没有改成"更短的说明"
- 全量计数没有丢:同一次改动后由总览第 0 张「所有资源」卡的计数徽标承载,仍是同一份画布投影
- 卡片内部的说明/装饰文案(「打开 UI 交互」「打开角色与对象」「1 个直接子版本」等)保持不动
- 断言:资源总览标题栏整段 `textContent` 恰为「资源总览」(精确相等,不放宽成 `not.toContain`),并复核总览第 0 张卡与「文档」栏目卡的内部文案仍在
- 既有用例没有任何一条断言过这段副标题文案(全仓检索只命中源文件),因此没有"按文案已删更新"或"按四不写删除"的对象;本用例是补上的正向合同断言
合并 master(#318 写锁等待与可诊断)入 V3 分支
Project CI / Repository checks (pull_request) Failing after 1m3s
Project CI / Frontend tests (pull_request) Failing after 2m48s
Project CI / Backend tests (pull_request) Failing after 4m27s
Project CI / Native shell tests (pull_request) Successful in 17m21s
95c5418c0f
- 三个文档冲突按两边都保留解决:decision-log.md、pitfalls.md、实施计划技术方案;两边追加的是不同主题(资源画布/预览/聊天区 vs 写锁),语义无冲突

- Rust 侧 direct_runtime.rs、direct_tool_bridge.rs、project_gates.rs、project.rs 自动合并成功,无手工介入

- 冲突处仅移除 Git 标记行,未删改任何一方内容(每个文件恰好减少 3 行)
suzmii added 11 commits 2026-09-11 12:53:35 +08:00
- index.tsx:自动切运行的那条 effect 从「合并后的预览 URL」收窄为「会话预览 URL」(resolveEmbeddedPreviewUrl(previewOverride))。manifest.preview 是"上次留下的一条落盘记录":预览服务器是进程内线程、非 Linux 端口还是随机临时端口,客户端退出后记录仍会写着 running,照着它自动切视图就是把用户丢进一个打不开的运行界面。运行入口(运行 tab、播放按钮)与 runAvailable 一行未动,PRD §4.1「运行入口不可用时仍允许点击…但不切换状态」继续成立。
- useHomeProjectCreation.ts:enterProjectDevelopment 不再把 context.manifest.preview 播种成本次会话的预览(改为 null)。会话预览此后只由活体确认(下一提交)与本次会话里 preview.start / preview.status 的返回值经 onPreviewChange 回流两处写入。
- 有意接受的代价:「回到仍在运行的预览」从自动变成用户点一下「运行」;下一提交会把"确实还在跑"这条路径按活体确认接回自动进入。
- project-development.suite.ts:新增两条用例——① 仅 manifest.preview 为 running(不传 preview)时初始仍是「资源管理」、运行 tab 仍可用、点一下仍能进运行视图;② 会话预览为 running 时自动切到运行表现层。
- 变异验证:把这条 effect 还原成读合并后的 URL(即撤掉本次收窄),用例①立即红灯(已实测)。
- `ensure_editor_image_edit_source_kind_allowed` 的 `is_none_or(...)` 折叠为仓库 rustfmt 口径的换行
- 白名单用例里 `ensure_editor_image_edit_source_kind_allowed(Some("art-spritesheet"), Some("video"))` 断行
- `is_game_creator_resource_editor_generation(Some(&json!({})))` 断言断行
- `let queued_payload: Value = serde_json::from_str(...)` 折行
- 仅 `server-rs/crates/api-server/src/editor_project.rs` 一个文件,无其他文件被 rustfmt 改动
- 删除 `ensure_editor_image_edit_source_kind_allowed_for_request` 里 `AGC && assetKind=="icon" → Ok` 的例外分支:`icon` 已进静态图类型表被全局放行,该分支恒不改变判定结果
- 连带删除因此不再被引用的 `is_game_creator_resource_editor_generation` 与其定向用例(同一 scope 无人调用);AGC 来源别名口径仍由 `EditorGenerationQueueResultContext::from_job` 的 queue consumer 判定承担并有既有用例覆盖
- 两处调用点改为直接调 `ensure_editor_image_edit_source_kind_allowed`,删掉没有行为的 `_for_request` 包装层与 `generation_inputs` 形参
- 旧用例 `game_creator_resource_editor_bypasses_quick_edit_whitelist_only_for_its_source` 改名 `quick_edit_source_kind_policy_ignores_generation_source`,改为断言来源类型判据与 generation source 无关
- 修正 master 侧旧断言 `editor_image_edit_target_binding_prefers_object_id_and_fails_closed`:反例 effective_asset_kind 从 `icon` 换成仍被拒的 `character-animation`,断言保持 Err,守卫意图(目标图层不能借壳成允许来源)不变
- `ensure_editor_image_edit_target_matches_source` 的 `generation_inputs` 形参在来源类型判据改为静态图集合、且 AGC 例外分支删除后再无任何使用,函数体内已无引用
- 同步删除该形参,并更新 1 处生产调用点与 8 处用例调用点传入的多余实参
- 该形参此前只用于把 generationInputs 透传给已删除的 `_for_request` 包装层,删除后判据与请求体字段彻底解耦
- 修正 resolves asset category and tags with legacy and forward compatibility 中 { kind: 'character', category: 'unclassified' } 的期望:由 'unclassified' 改为 'character'
- 该断言是 09-09 引入的旧口径,与 2026-09-10 落地的分类自愈规则冲突;按拍板保留自愈规则(它修复真机 57 条 kind:ui 资产落待归类),因此精确更新断言而非放宽为 not.toBe
- 在断言处补注释说明这是本批有意行为,并写明已知盲区:用户手动把可明确分类的资产设为待归类时会被覆盖
- 新增用例「自愈规则覆盖落盘 unclassified:这是显式接受的盲区」,覆盖 character / ui / art-spritesheet 三条具体映射
- 新增用例「派生结果本身就是 unclassified 的 kind 不受自愈规则影响,保持落盘值」,覆盖 image / video / code / publication-material 与未知 kind
- 新增用例「落盘为明确分类时信任落盘值,不被 kind 派生结果覆盖」,覆盖 image+scene / character+audio / ui+document
- 变异验证:临时移除自愈规则后上述 2 条断言失败(expected 'unclassified' to be 'character'),恢复后全部通过
- `editor_api_rejection_reason_from_body` 用例里 3 处 `serde_json::json!(...).to_string().as_str()` 实参按 rustfmt 口径断行
- `editor_api_rejection_reason_from_body("<html>502</html>")` 断言折行
- 连带收掉 `remote-terminal-failed` 的 match 分支:该 arm 因上一提交改宽而超行宽,按 rustfmt 折成块
- `resource_editor.rs` 现在通过 `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check`
- `check:rustfmt` 跑 server-rs 与 AGC src-tauri 两条命令,CI 在 server-rs 那条就退出,AGC 侧欠账从未被跑到;本提交只做格式化,不改任何语义
- `src/agent.rs`:`mod` 声明与 `pub(crate) use` 按 rustfmt 排序口径调整 `direct_codex_references` 的位置(声明顺序无语义影响)
- `src/assets.rs`:`infer_canvas_export_asset_kind` 的 `else if` 条件行重排;两处 `assert!` 参数按 use_small_heuristics 折叠;两个返回 `Result<..>` 的函数签名断行
- `src/project/asset_export.rs`:`resolve_export_source_file` 签名折成单行,`File::open`/`File::create` 两行重排,用例里 `SaveLocalProjectAssetFileInput` 实参折叠
- `src/tests/asset_rename.rs`:两处用例断行/折叠
- `src/tests/asset_delete.rs`:一处 `assert!` 实参折叠及连带重排
- 已用 `cargo fmt -p genarrative-ai-game-creator-shell -- --check <这 5 个文件>` 与整 crate `cargo fmt --all -- --check` 双重验证通过
- 新增 features/app-shell/sessionPreview.ts 的 resolveSessionPreviewOnProjectOpen:进入项目时问一次 get_local_game_preview_status(内存 registry 是活体的唯一真相),确实 running 才把它作为本次会话的预览;记录写着 running 而 registry 没有活体时判为陈旧,复用现役 stop_local_game_preview 把落盘记录对齐成 stopped,并同时把会话 manifest 按真相投影成 stopped(否则运行入口会继续拿着打不开的 URL 渲染运行画面)。
- useHomeProjectCreation.ts:enterProjectDevelopment 改为 async,先做这次核对再进工作台,并按核对结果决定会话预览与 manifest 投影;三个调用点(新建项目、创建项目、打开项目)都改为 await。
- preview.rs:同一个项目重启预览时,旧 registry 身份的收尾不再覆盖刚落盘的 running——只有当旧预览属于**另一个项目**时才替它收尾。此前同项目重启会把刚写进去的 running 覆盖成 stopped,下次进项目便丢掉"预览在跑"这条事实(记录反方向失真)。
- preview.rs:stop_local_game_preview_for_root 在没有活体可停、但落盘记录仍是 running 时也把记录收尾成 stopped(stop 的语义变成"真的停干净,包括记录"),因此陈旧记录既可由进入项目触发对齐,也可由用户显式停止对齐。
- tests/project.rs:新增三条 Rust 定向用例——同项目重启后记录仍为 running 且端口是新预览的;换项目启动时旧项目记录写 stopped、新项目写 running;registry 无活体而记录写着 running 时 stop 会把记录对齐成 stopped。
- tests/sessionPreview.test.ts(新增):五条用例覆盖活体优先、陈旧记录对齐并投影、registry 不可读按没有在跑处理、记录本来不是 running 时不发多余停止命令、无 invoke 时零请求。变异验证:把活体核对去掉(直接信落盘记录)→「registry 里没有活体时不给会话预览」与「registry 不可读」两条立即红灯(已实测)。
- project-development.suite.ts:把「打开项目不查预览状态」那条断言改为「恰好核对一次预览活体」,并保留邻近的 start_local_game_preview 必须为 0 的断言——被新行为取代的是"从不核验","开项目不偷偷启动预览"这条原意一字未改。
- PRD §4.1:状态机按当前实现改写(进入项目一律落到 resource-overview;run 的自动进入只认内存 registry 的活体确认;落盘 preview 是记录而非活体,陈旧记录不触发切换也不参与渲染),并把退出收尾一并写进该节说明。
- 新增「裸 rustfmt 会把一个文件放大成整个模块树」:现象是本想改 1 个文件实际改了 src/agent/ 下 50 个;原因是裸 rustfmt 把传入文件当模块根并跟随 mod 递归;处理口径是改格式前先 git diff --stat 确认范围、先备份,精确到文件用 cargo fmt -p <pkg> -- <文件...>,并行工作树里禁止盲跑 cargo fmt --all,误伤后用 git apply -R 反向补丁还原而不是 git checkout --
- 新增「build-gate 的警告门被代理环境变量误伤」:NODE_USE_ENV_PROXY=1 / HTTP_PROXY 会让 Node 打印 UNDICI EnvHttpProxyAgent experimental 警告,而 build-gate.mjs 只忽略含 ExperimentalWarning 字样的行,于是构建成功却判红;判据是同一 commit 清空这些变量后 npm run build 即 exit 0;CI 无这些变量不会触发,若要让本地带代理也能跑需在忽略规则补 EnvHttpProxyAgent / UNDICI-EHPA(属改门禁规则,本次未改)
- 新增「worktree 里 MSYS bash 跑 git 脚本会失效」:check-repository-ci.sh 这类脚本在 worktree 里第一步就报比较基线不可用,根因是 bash 用 MSYS 的 /usr/bin/git、而 worktree 的 .git 存 Windows 绝对路径,解析成 /mnt/c/.../C:/Users/...;口径是按脚本同序同命令用 Windows git 逐条等价执行
- 只追加,未重排既有条目
- preview.rs:新增 stop_local_game_preview_on_exit——退出时若 registry 里确有 running 预览,就用 stop_local_game_preview_for_root 把它真正停掉并写 stopped(顺带落 preview.log 与 trace 收尾)。预览服务器是进程内线程,进程一退 URL 就永久失效;不在这里收尾,.agent/manifest.json 会一直写着 running。
- main.rs:handle_game_creator_gui_run_event 的 RunEvent::Exit 分支先做预览收尾,再做既有的 Agent server / runner 关闭;失败按 `preview.gui_exit.stop_failed` 记日志,不阻断退出。
- 语义变更(用户已确认接受):关窗不再保留预览——退出即停止预览并把记录写成 stopped,下次进项目一律落到资源管理。与"进入项目时按 registry 活体核对"配套:退出路径覆盖正常关闭,强杀 / 断电那类不走退出路径的情况由进入时的核对自愈。不引入 stale DTO 字段(判定仍要活体探测,且会把状态扩到跨端契约与全部消费方)。
- tests/project.rs:新增 gui_exit_stops_the_preview_and_clears_the_stale_record——退出收尾后 registry 归零且 manifest 记录为 stopped。
- tests/sessionPreview.test.ts:补两条 Rust 侧结构性守卫(本仓既有做法:直接解析源码)。① RunEvent::Exit 分支必须出现 preview::stop_local_game_preview_on_exit——这条接线没有别的行为测试覆盖(注册全局 registry 的用例会互相打架);② 同项目重启预览的替换分支里,收尾必须排在"旧预览是否属于另一个项目"的判定之后。变异验证:把 main.rs 的退出收尾摘掉 → 守卫①立即红灯(已实测)。
- decision-log.md:新增 2026-09-11 决策条目,逐条写下背景(偶发进运行界面的成因链:进程内预览线程 + 随机临时端口 + 退出零清理 + 进门不核对 + 同项目重启覆盖刚写的 running)、A/B/C 三项决策、关窗不再保留预览这条语义变更及"为什么两者都做而不加 stale 字段"、不做什么与验证方式。同文件被 prettier 补了几处标题前空行(格式规范化,无内容变化)。
- 验证:cargo check --locked --all-targets exit 0;Rust 定向用例 restarting_preview_for_the_same_project / replacing_preview_for_another_project / stopping_without_a_live_preview / gui_exit_stops_the_preview 各 1 passed;AGC 子集 1198 passed / 4 skipped / 0 failed;共享组件 1385 passed;typecheck exit 0;编码 4378 文件;git diff --check 干净。
按 rustfmt 折行退出收尾用例:纯格式,无逻辑变更
Project CI / Frontend tests (pull_request) Successful in 7m44s
Project CI / Backend tests (pull_request) Successful in 10m11s
Project CI / Repository checks (pull_request) Successful in 23m14s
Project CI / Native shell tests (pull_request) Successful in 38m54s
cd1aceb3d9
- tests/project.rs:gui_exit_stops_the_preview_and_clears_the_stale_record 里 init_existing_html_project_at 那行超过 rustfmt 行宽(中文按双宽计),按 `cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` 的结果折成两行。
- 用 cargo fmt(不是裸 rustfmt):裸 rustfmt 会把文件当模块根、跟随 mod 递归格式化整个模块树,本次已有人因此误改 50 个文件。
- 范围核对:`git diff --stat` 只有 apps/ai-game-creator-shell/src-tauri/src/tests/project.rs(1 file changed, 2 insertions(+), 1 deletion(-)),crate 里其余文件本来就是 rustfmt 干净的,未被牵连。
- 门禁:`cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` 与 `... --manifest-path server-rs/Cargo.toml -- --check` 均 exit 0;`cargo check --locked --all-targets` exit 0;AGC 子集 1198 passed / 4 skipped / 0 failed;四条预览定向用例仍各 1 passed。
suzmii added 6 commits 2026-09-11 14:09:02 +08:00
占位分支对「还没读 / 读失败 / 压根不适用」给的是同一个图标,卡面也没有任何文字提示,现场只能看到「没有图」,无法区分是调度没发请求、原生拒绝了读取,还是这类资源本来就没有预览。本轮排查「图片加载不出来」时,这个不可观测性正是反复误判的来源。

- `index.tsx` 的 `ResourceCard` 根节点新增 `data-preview-status`(`idle` / `loading` / `loaded` / `failed`)与 `data-preview-error`(仅失败态给出原因)。排障从此只需读一个 DOM 属性,而不必去猜调度层。纯新增属性,不改变任何渲染分支与调度行为。
- 新增 `tests/resourcePreviewPredicateContract.test.ts`:把「派发决策必须由可验证的内容证据(mediaType,缺失时退路径扩展名)决定,登记标签 `kind` 只能在两者都给不出结论时兜底」钉成不变量。用 7 组「mediaType + 同通道扩展名」× 20 个 `kind` 的全量笛卡尔积断言 `kind` 不能改写预览分支,并断言组合总数等于笛卡尔积(防止将来收窄枚举让断言悄悄变弱)。另附本轮缺陷形状的最小复现(`application/json` + 任意图片类 `kind` 必须落 `document` 而不是 `placeholder`)、mediaType 缺失时由扩展名决定、以及扩展名与 mediaType 冲突时按既有设计由扩展名决定这三组显式合同。
- 该用例在**变异验证**下确实会红:把 `kind` 判定移回第一趟(还原本轮的分叉)后,本文件 4 条用例失败(不变量报出 56 处违规),恢复后全绿。断言不是恒真假守卫。
- 验证:`npm run test -- apps/ai-game-creator-shell/tests` 84 files passed / 1202 passed / 4 skipped / 0 failed;`src/components/image-editor` 1385 passed;typecheck exit 0;prettier 与 eslint 干净;check:encoding 4378 文件;`git diff --check` 干净。
**口径更正(后加的)**:本条提交**不是**"修复真机那 8 张卡"。经用真源码复算真机 manifest 确认,当前 HEAD 上这 8 张 `.json` 本就正确落 `document`、角标「文档」,该现象不复现——用户看到的是更早构建的行为。本条的真实价值是**消除 `kind` 与内容证据同权的脆弱点,并把「派发决策由内容证据决定」钉成不变量**,见下。

- 背景:真机「待归类」栏目曾出现 8 张卡——角标写「图片」、卡面却只有占位图标。该现象在当前 HEAD 上不复现(见上),但它对应一个**真实存在且此前只是碰巧没被踩中的脆弱点**:`projectedResourceKind` 里 `kind` 与内容证据**同权**,而 `artKind` 含 `ui`;一旦界面规格类资产丢了 `.json` 扩展名,预览派发就会翻车。原先能通过**完全依赖 `.json` 恰好在那张扩展名表里**,属巧合而非设计。
- 机制(旧行为):`kind:"UI"` 被 `artKind` 命中 → 判定为 `art`;预览调度再按 `art` 走图像分支,又因 `mediaType:"application/json"` 不是图像而兜底成 `placeholder`,而 `requestPreview` 对 `placeholder` 直接返回——**一次 IPC 都不发**,卡片既不报错也没有图。
- `resourceProjectionModel.ts` 的 `projectedResourceKind` 改为两趟判定:第一趟只看 `mediaType` 与路径扩展名(文件内容的可验证证据),第二趟才看 `kind`(登记标签,可能过时或宽泛)。`kind` 不再与内容证据同权,因此 `kind:"ui"` 无法再把 `application/json` 改写成 `art`。
- 不改 `artKind` 的取值(`ui` 仍在表内),只调整判定顺序:最小改动面,`kind:"ui"` + `image/png` 这类正常资产行为不变(仍由 `mediaType` 命中)。
- 界面规格 JSON 现在落 `document` 分支,走 `read_local_project_text_preview`(Rust 侧 `.json` 与 `application/json` 本就在支持范围内且这些条目已登记),卡面渲染 JSON 文本摘要,角标随 `projectResourceTypeLabel` 变为「文档」。预览链条数、缓存、并发与门禁一律未动。
- `tests/resourceCardPreviewRealManifest.test.ts` 中记录旧行为的用例同步跟上新合同:`placeholder: 8` 改为 `document: 8`,用例名与注释改写为「52 张 PNG 走图片分支,8 条 UI 规格走文档分支,无一落占位」,并新增「角标与预览分支必须说同一件事」的逐条断言。旧断言记录的是缺陷本身,按「四不写」直接更新为当前权威行为,不做兼容保留;其余既有断言一条未放宽。
- 验证:`npm run test -- apps/ai-game-creator-shell/tests` 1202 passed / 4 skipped / 0 failed;`src/components/image-editor` 1385 passed;typecheck exit 0;check:encoding 通过;`git diff --check` 干净。
本轮「资源卡图片加载不出来」连续两轮按现役缺陷排查,最终发现真机数据在当前 HEAD 上判定全对、现象来自更早的客户端构建。把这次的方法教训与两条廉价判据固化成文档,避免后续重复投入。

- `pitfalls.md` 新增「用真机现象定性之前必须先确认客户端构建版本」:现象与「数据在 HEAD 上的实际判定」不一致时,第一个怀疑对象是构建版本而不是代码;两条廉价判据为真机 manifest 的 `category` 分布(旧构建 `unclassified:58`,新构建 `unclassified:1 / ui-interaction:59 / scene:1`)与控制台读 `[data-preview-kind]` 分布(新构建应为 `raster-image 52 / document 8 / code 1` 且无 `placeholder`)。同时记下方法教训:不要用 PowerShell 或手抄正则去复算 TS 判定表,誊本会引入假结论——本轮第一轮取证正是因此得出了不存在的「角标与预览分支分叉」,要跑就用真实源码跑。
- `decision-log.md` 新增「AGC 资源预览评估过本机 loopback HTTP 资源服务,因收益不匹配代价而暂缓」,并显式注明它与既有的 `asset:` 否决**不是同一条、理由不同**:`asset:` 是「绕过安全门禁」的原则问题,loopback HTTP 是「收益与代价不匹配」的性价比问题,其门禁可在服务端逐条复用。记录内容含 token 化不暴露路径、服务端复用同一套校验、与「不向 WebView 暴露本机文件协议」合同的关系判定(同构替代而非违约,信任边界转移到 token 与审计),以及流式下**必须明确降级的三条**:读取漂移复核、全局 3 permit 并发闸门、有界 tombstone 预算。量化依据为真机 52 张 PNG 的分层实测——Rust + JS 冷开合计约 0.3 s,大头是已由 `decoding="async"` 移出主线程的 37.2 MPx 解码,因此当前量级下 0.3 s 换「不向 WebView 暴露文件系统」这条合同是划算交易;并写明重新评估的触发条件(几百张资源 / 大图量级)。
- 同批一并暂缓的备选也记在案:Tauri 自定义协议(非 `asset:`)+ opaque token 因拿不到 HTTP 缓存语义而比 loopback HTTP 更差;磁盘缩略图缓存与 PRD 的纯内存口径及既有价值观冲突。
- 纯文档改动,无代码改动;`tauri.conf.json`、`Cargo.toml`、Rust 命令与 CSP 均未动。
- index.tsx:整条删除 `ResourceBookOutlineNav` 组件(`.game-resource-outline` / `aria-label="资源栏目大纲"` / 栏目图标 + 栏目文字 + 未读红点)以及栏目页与「所有资源」页两处挂载点;`ResourceBookAllResourcesPage` 不再接收只服务导航的 `unreadCategories` / `onOpen`。
- index.tsx:一并清掉只服务这条导航的「未读红点」状态链(`unreadResourceCategoryState`、`resourceCategorySnapshotRef`、`resourceIdsByCategory`、`viewedResourceCategory` 与对应 effect),不留死代码、不留墓碑注释。
- styles.css:删除全部 `.game-resource-outline*` 规则(悬浮 Dock、常态/悬停态、图标列宽、`.game-resource-outline-label`、`.game-resource-outline-unread`,共 106 行)。
- 保留未动:分区栏目本身、栏目标题栏、资源卡、总览缩略卡片、第 0 张「所有资源」入口、「所有资源」汇总页、搜索、总览、快速编辑与选中工具条。
- 测试:切栏目 helper(appSurface/project-development、appSurface/home、resourceRename、resourceVersionSwitch)改为走「资源总览」缩略卡片(需要时先点「收起资源」回总览),不再点导航;栏目顺序断言改钉在总览缩略卡片的 `aria-label` 序列上(第 0 张「所有资源」+ 固定七栏目),断言内容未放宽。
- 测试:按「四不写」删除只守这条导航的断言——大纲常态可读性的 CSS 声明用例、大纲条目图标/文字的 DOM 用例(该用例保留其中与搜索浮层相关的断言并改名)、未读红点用例(该用例保留"只登记游戏代码的项目照常分页并落在待归类"的部分并改名)、空项目与「所有资源」页里的导航存在性断言。
- 测试:新增反向守卫 + 切栏目链路用例(`has no outline nav and keeps every section reachable through the remaining entries`):断言 `资源栏目大纲` 标签、`.game-resource-outline` 类名与对应 `<nav>` 都不再渲染,并逐条走通总览缩略卡片(含空栏目)→ 下一页 → 标题栏「资源总览」→ 「所有资源」入口往返。
- 测试:`preserves independent art viewports across sort and workbench mode switches` 保留全部原断言,只去掉排序模式切换后那些"点当前栏目"的空操作往返(旧导航点在同栏目上是提前返回,改 helper 后变成真实往返,会在 jsdom 里触发重新 fit)。
- decision-log.md:新增「删除资源画布左侧的栏目大纲悬浮导航」决策,先记录查证结论(decision-log / pitfalls / PRD / 技术方案里此前只有要求保留该导航的条文,没有任何删除决策,本次是新决策),再写决策、语义变更、不做什么与变异验证结果。
- PRD:§阶段说明与 §7.2 验收两条不再写"大纲展示全部栏目""通过栏目大纲和滚轮访问全部栏目",改为栏目大纲由「资源总览」的栏目缩略卡片承载、切栏目走总览卡片 +「所有资源」入口 + 页内「下一页」。
- 技术方案:目标、操作边界与验收共五处删掉"栏目大纲悬浮在画布左侧/只显示图标和栏目名称""点击大纲切页""分页大纲以悬浮 Dock 展示全部栏目"等条文,改成当前状态(左侧不再有悬浮导航)。
- pitfalls.md:补记一条 jsdom 坑——栏目页 fit key 需要非零可测量尺寸,jsdom 下恒为 0,所以"总览→栏目"往返会重新适配视口;进入栏目后应恢复上次 viewport 的用例在 jsdom 里必须避免这种往返,不要误判成产品回归、也不要去改 fit 逻辑。
把资源卡右下角的 @ 引用按钮做小:视觉缩到 0.8×,热区保持 44×44
Project CI / Repository checks (pull_request) Successful in 3m31s
Project CI / Frontend tests (pull_request) Successful in 4m24s
Project CI / Backend tests (pull_request) Successful in 6m51s
Project CI / Native shell tests (pull_request) Successful in 18m37s
9c4778c881
- styles.css:`.game-resource-card-reference` 视觉尺寸 30×30 → 24×24(0.8×),新加 `.game-resource-card-reference svg` 把内部 `AtSign` 图标同步从 16px 收到 13px,圆角/描边/底色/阴影 token 全部沿用原值。
- styles.css:`bottom` 由 9px 调到 14px,与右侧 34px 播放钮圆心对齐(9 + 34 / 2 = 14 + 24 / 2),避免缩小后两颗圆钮错位。
- styles.css:新增 `.game-resource-card-reference::before` 伪元素撑出 44×44 热区(本仓 window chrome / 素材选择列表同口径的最小可点尺寸),只扩命中范围、不参与布局也不改变绘制;整体向左让 4px,让热区右边缘与播放钮的盒子恰好相邻,不覆盖它。
- 语义未动:仍是「插入素材引用」入口,className、`aria-label={\`引用资源 ${resource.label}\`}`、key 与 `dispatchResourceReferenceInsert` 调用一行未改,本次只改 CSS 声明。
- 测试:新增 CSS 声明级用例,断言按钮与图标确实变小(24 < 30、13 < 16)、热区不小于 44×44、热区右边缘不越过播放钮盒子,并顺带钉住 `index.tsx` 里 @ 入口的 className / aria-label / 插入动作仍在。
- 变异验证:把按钮改回 30px、热区改回 24px、图标改回 16px → 用例红灯;只把热区缩回 24px → 用例同样红灯(均已实测,随后原样还原)。
suzmii added 3 commits 2026-09-11 15:07:55 +08:00
- styles.css:direct-codex 控制排里的 .conversation-model-select 由 position: static !important 改为 position: relative !important(只改这一个词,另补一段注释说明它同时是弹层锚点)。这颗容器是 .conversation-model-menu(position: absolute; right: 0; bottom: calc(100% + 8px))与模型提示([role='alert'] / [role='status'],position: absolute; bottom: 36px)的包含块;写成 static 时包含块回落到最近的有定位祖先——也就是 position: absolute 的 composer 盒子,菜单于是出现在整个 composer 上方、与触发钮之间隔着整个输入区,输入区一变高(多行、引用 chip、AI 润色)菜单与提示跟着往上飘:这就是"编辑框把元素挤开"的形态。
- styles.css:right/bottom: auto、flex: 0 1 auto、min-width: 0、max-width: 180px 全部保持原样——relative 不会把模型选择钮移出控制排,也不改它被压缩换行的行为;交互语义零改动(打开/选择/刷新模型、发送、@ 引用、快速模式全在原位)。
- project-development.suite.ts:新增一条声明级守卫。① 弹层不参与父容器流式布局(菜单与模型提示都是 absolute,菜单 bottom: calc(100% + …)、right: 0);② 锚点必须是有定位的容器(relative 且不是 static),同时仍在原位(right/bottom auto、flex 0 1 auto、min-width 0);③ 输入区高度变化不得推动同排操作元素(composer 是底边锚定的两行网格:position: absolute + bottom: 12px + grid-template-rows: auto auto;控制排是单行 flex 且不换行,@ 与发送钮 flex: 0 0 30px 不可压缩,窄屏下被压缩的是可收缩的模型选择钮)。
- conversationModelSelect.test.tsx:新增一条 DOM 守卫——菜单必须是 .conversation-model-select 的直接子节点,且不得成为控制排容器 .project-supervisor-composer-controls-right 的流式子节点。
- 变异验证(均已实测红灯):① 锚点改回 static → "expected … to match /position:\s*relative\s*!important/";② 菜单改成 position: static(弹层回流式)→ "expected … to match /position:\s*absolute/";③ 把菜单渲染挪出触发钮容器(变成同排容器的流式子节点)→ DOM 守卫 "expected null not to be null"。
- 门禁:typecheck exit 0;AGC 子集 1205 passed / 4 skipped / 0 failed(基线 1203 + 本次新增 2 条);src/components/image-editor 1385 passed;check:encoding 4379 文件通过;本次三个文件单独 `git diff --check` exit 0(全量 `git diff --check` 目前报的是另一位 Agent 在途修改的 packages/shared/src/components/styles.css:1281 EOF 空行,与本次无关)。
- packages/shared/src/components/PlatformSegmentedTabs.tsx:容器补 `platform-segmented-tabs`
  稳定钩子类,供共享样式表精确命中子项(不依赖 Tailwind 任意值扫描)
- packages/shared/src/components/styles.css:新增 `.platform-segmented-tabs > button`
  的 `min-width: max-content`,给每个子项一个「内容宽度」下限;放不下时由容器横向滚动兜底
- apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx:
  「资源分类与标签」的 6 类分类条由 `columns="threeToSix"` 改为 `layout="scroll"` +
  `frame="bare"` + `surface="transparent"`,与 `@` 面板和资源画布的分类条同一条既有形态
- packages/shared/src/components/PlatformResourceFilterBar.test.tsx:新增两条声明级断言,
  钉住「子项不换行 + 容器横向可滚 + 子项有不低于内容宽度的下限」
模型下拉菜单高度有界:健壮性补强,条目多时不再盖住消息
Project CI / Repository checks (pull_request) Successful in 2m36s
Project CI / Frontend tests (pull_request) Successful in 3m20s
Project CI / Backend tests (pull_request) Successful in 6m29s
Project CI / Native shell tests (pull_request) Successful in 18m34s
39a96aa418
- 性质说明:本条是**健壮性补强**,不是用户本轮报的那几条之一;由上一提交(锚点修复)排查时顺手发现的未修观察收尾。
- styles.css:`.conversation-model-menu` 补 `max-height: min(240px, 40vh)` + `overflow: auto` + `overscroll-behavior: contain`。菜单此前没有高度上限,模型条目很多时会长到 composer 顶边以上、盖住消息;240px 与视口 40vh 取小者,矮窗 / 窄屏(移动端优先)更稳。`overscroll-behavior: contain` 与既有 `.resource-reference-menu` 同一口径,菜单内滚动不传染给背后的消息列表。不新增视觉:border / radius / padding 与 token 全部沿用原值。
- 语义零改动:锚点与流式归属不变(`position: absolute; right: 0; bottom: calc(100% + 8px)` 未动),打开 / 选择 / 刷新模型列表的交互不变。
- project-development.suite.ts:新增声明级守卫「bounds the model dropdown height so a long catalog cannot cover the composer」——菜单规则必须同时声明 `max-height: min(240px, 40vh)`、`overflow: auto`、`overscroll-behavior: contain`,并仍是 `position: absolute`(高度有界不改锚点)。
- 变异验证(已实测):把 `max-height` 摘掉 → 用例红灯(expected '…' to match /max-height:\s*min\(\s*240px\s*,\s*40vh\s*\)/u)。
- 门禁:typecheck exit 0;AGC 子集 1206 passed / 4 skipped / 0 failed(基线 1205 + 本次新增 1 条);src/components/image-editor 1385 passed;check:encoding 4379 文件通过;`git diff --check` 全量与本次两个文件范围均 exit 0。
suzmii added 6 commits 2026-09-11 15:50:09 +08:00
真机现场:同一屏里 `idle` 与 `loaded` 交错,12 张相交卡从未入队(`idle` 的语义就是「从未请求」,不是被淘汰回退)。根因在 observer 的**创建时机**:`useProjectResourceCardPreviews` 建 IntersectionObserver 时把 `intersectionRootRef.current ?? canvasRef.current` 直接当 root 传下去,而 root 是资源画本容器、由被观察卡片所在的子树持有。创建那一刻 root 还是 `null` 时,浏览器会**退回按视口判定**,于是被画本容器裁掉的卡片永远报「不可见」,可见性门禁再也不放行它们 —— 卡面只剩占位图标,且没有任何错误提示。

- `useProjectResourceCardPreviews`:root 为 `null` 时**不建 observer**,等 root 就绪后由卡片注册触发的 `rootEpoch` 重建。判据是「卡片一定渲染在画本容器内部」,所以**有卡片注册本身就等价于 root 已就绪**,不需要新增跨组件契约、也不必改 `index.tsx`。
- 新增 `attachObservedCards()`:observer 就绪或重建后,按登记表把**每一张已注册卡片**补挂一遍。此前只在新 observer 创建时补挂一次,注册与创建分属不同 effect 存在时序窗口,错过那次补挂的卡会停在登记表里却从未被观察。
- root 未变且 observer 已在时只补挂、不重建,避免每次渲染重建观察器。
- `observePreview` 在没有 `IntersectionObserver` 的环境(如单测)保持静默,不制造多余渲染。

测试(`tests/useProjectResourceCardPreviews.test.ts` 新增 3 条契约):
- 「不在热预取窗口内的可见卡不得停在 idle」:注册 20 张、`eagerPreviewLimit: 12`,断言注册进 observer 的元素数等于注册数、回调报可见后全部落 `loaded`、且没有一张停留在未请求状态;
- 「等真正的 root 就绪后再观察并加载」:root 首次为 `null` 时**不得建 observer**(否则按视口判定),注册后必须以真正的 root 建出来并完成加载;
- 「建 observer 之前就注册的卡要被补挂」:断言 observer 就绪后登记表里的每一张都在观察集合内,且报可见后全部 `loaded`。

变异验证:把 root 守卫退回旧行为(允许 root 为 `null` 时照建 observer)后,「等真正的 root 就绪」这条立即失败(`expected [] to have a length of 0 but got 1`);恢复后 19/19 通过。断言不是恒真假守卫。

边界说明:只改预览 hook 与它的测试,未动 `index.tsx` / `styles.css` / `resourceBookLayout.ts`(均在他人手上),未放宽任何既有断言、未取消可见性门禁(仍是按需加载)。

验证:`npm run test -- apps/ai-game-creator-shell/tests` 84 files passed / 1216 passed / 4 skipped / 0 failed;`src/components/image-editor` 1385 passed;typecheck exit 0;check:encoding 4379 文件;prettier 与 eslint 干净;`git diff --check` 干净。
- ResourceClassificationPanel.tsx:面板改为只编辑 manifest `assets[].tags`,移除分类 6 类
  chip 那一排;标题与 aria-label 改「编辑素材标签」,副标题由资源路径改为素材名
  (`localPath` 的 basename,仓库没有独立显示名字段)
- ResourceClassificationPanel.tsx:标签状态由「整段逗号分隔字符串」改为字符串数组,
  每个已有标签渲染成自带删除按钮的胶囊 pill;保存时把未落成 pill 的输入尾巴一并切分,
  归一化仍走 `normalizeGameCreationAppAssetTags`,回车 / 中英文逗号 / 顿号提交方式不变
- ResourceClassificationPanel.tsx:写入命令 `category` 是必填,读一次当前权威值并
  在保存时原样回传,保证「只改标签」不漂移分类;底部按钮「保存」改「保存标签」,
  删除资源入口按既有保留(截图未画,但它是唯一的资源删除入口)
- resourceClassificationTagPanel.css:新增标签 pill 样式,删除按钮嵌在 pill 内部
  (视觉图标 11px、热区 32×32、负外边距抵消不撑高 pill);若第二处出现带删除按钮的
  标签 pill,抽到 `packages/shared` 做 `PlatformRemovableTagPill`,不要复制这份实现
- index.tsx:资源卡工具条入口 label / title / 文案由「分类与标签」改「编辑标签」,
  与面板标题一致(否则入口写着分类、打开的是纯标签面板)
- tests/resourceClassificationPanel.test.tsx:断言对齐截图文案、逐个 pill 自带删除、
  点某个 × 只删对应标签、aria-label 可区分、保存 payload 的 tags 数组与 category 原样回传、
  取消不写盘、入口 label 与面板标题一致、删除热区不小于 32px
- 文档:PRD §5.3「分类取值优先级」改写为当前状态(分类没有用户手动设置入口、
  该面板只编辑标签),并新增 decision-log 条目记录本次产品决策与「用户手动设
  category 的能力就此移除」这一事实
- 说明:截图文案(编辑素材标签 / 新增标签,多个用逗号分隔 / 保存标签)在全仓检索
  无命中,属仓库之外来源,本次按用户截图对齐,不是 PRD 明文
- 现象与归因:「所有资源」卡的预览区一直是空的,原因是我在 1cc9a2381 里加的闸 `!isResourceBookAllTarget(category)`(当时为避开"同一张资源卡挂两份")让它一张卡片计划都不生成;预览卡从来只由画本场景注入,占位摞本身不含卡片
- 改为真正生成卡片计划:喂进去的仍是全量投影(与标题栏计数徽标同一份口径),但当成一摞交给栏目卡同一个选取函数 `selectResourceBookOverviewCards`——没有视图中心时按投影顺序取前 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT`(=3) 张,与栏目卡每摞上限同口径;不分类型列,避免 66 项一口气铺出十几列
- 预览用同一个 `renderCard` 与同一套 `resourceBookOverviewCardLayout` 几何渲染,唯一差别是宿主类名换成 `.game-resource-book-preview-card`:转场层按 `.game-resource-book-scene-card` 收集 FLIP 节点并以 `card:${resourceId}` 为键,同一张卡两个宿主会让两条记录互相顶掉,因此预览宿主不进收集范围(`resourceBookController` 一行未改)
- 预览宿主带 `aria-hidden` + `pointer-events: none`:无障碍树与 `getByRole` 里每张资源卡仍然只有栏目卡那一份,不会重复命中;点预览区仍走进「所有资源」页,栏目卡预览卡仍可选中
- 转场淡出集合(`resourceBookPlanKeysRef` 那段)跳过「所有资源」组,避免预览节点被当成"离开视图的卡片"按子画布坐标重新贴到画布别处
- `ResourceBookThumbnail`:该卡正文的占位摞从最多 3 摞改为对齐真实摞数的 1 摞,卡片高度回到与栏目卡同一条口径
- `styles.css`:把 `.game-resource-book-preview-card` 并进 `.game-resource-book-scene-card` 的两条既有规则(宿主几何、主态卡片圆角/阴影)与 `prefers-reduced-motion`;没有新写一套预览样式
- 断言(`project-development.suite.ts`):预览卡的 `data-resource-card-id` 序列恰等于全量投影前 3 个、条数 == `min(3, 计数徽标)`、预览里是完整卡片(视觉体 + 选中按钮)、宿主在 `aria-hidden` 子树里、宿主几何规则与栏目卡共用同一条
- 断言(新增用例):空项目下沿用既有空态「暂无资源」、预览节点 0 个、计数 `0 项`、入口行仍在,卡片不破版
- 单测(`resourceBookLayout.test.ts`):把已被新行为取代的旧断言(`all` 组不出卡片、全计划无重复卡片键)删掉,换成新合同(预览 = 全量投影前 3 张、只铺一列、各栏目摞不变),并补空投影 → 0 张
- 变异验证(两条都跑过并已还原):① 喂进去的资源改回空数组 → `AssertionError: expected +0 to be 4`;② 恢复旧闸(计数仍正确、预览为 0)→ `AssertionError: expected [] to deeply equal [ Array(3) ]`
- tests/appSurface/project-development.suite.ts:跟随 6bdc8bbd9 把资源卡工具条入口由
  「分类与标签」改为「编辑标签」,同步音频资源工具条那条既有断言(否则 HEAD 的树是红的)
- 同一处注释同步说明:该面板只编辑 manifest `assets[].tags`,分类不再有手动入口
- 81482b458 原本只应包含 tests/appSurface/project-development.suite.ts 一条断言同步,
  但该验收文档当时已被预置在索引里,随那次提交一起被带走;这里只做「移出提交」,
  不改动文档内容,文档仍完整保留在工作区,由它的作者自行提交
- 未使用 --amend:按纪律不改写已有提交
新增 AGC 资源工作台 V3 端到端验收用例
Project CI / Repository checks (pull_request) Successful in 3m14s
Project CI / Frontend tests (pull_request) Successful in 3m39s
Project CI / Backend tests (pull_request) Failing after 6m3s
Project CI / Native shell tests (pull_request) Failing after 6m22s
3bfc3f0849
- 新增 docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md
- 给出从建项、对话生成素材到运行收尾的一条主线,按 S1–S21 分步列出操作、期望结果、对应 PRD 条款、可观察判据与已知例外
- 落定每步的可观察形态:资源卡 data-preview-kind / data-preview-status / data-preview-error、data-used-by-current-version、data-resource-book-category、data-resource-view-state 与控制台判据脚本
- 记录前置条件与门禁口径:dev 态前端走 Vite HMR、Rust 侧改动需重启客户端、服务端改动需重启 api-server、build-gate 代理假红需清代理变量
- 列出只靠视频或截图表达的 7 处条款清单与补齐方式,并更正 F12 无法观察 Rust 到 api-server 的 HTTP 这一前提
- 列出已知未做与已取消项,以及文档与代码的四处偏差(资源卡打开详情按钮、运行入口提示文案、画布生成入口进度、真机落盘分类分布)
- 附验收记录表与逐步骤失败排查索引
suzmii added 10 commits 2026-09-11 17:16:08 +08:00
- 删除资源卡右上角的 @ 引用圆钮,连同它的 44×44 热区伪元素与全部样式一起退役
- 选中资源的画布工具条新增「@引用」动作,排在「UI 编辑器」之后,出站负载与来源逐字未变(source: 'resource-card')
- 测试:原「卡片圆钮视觉尺寸」用例改写为「入口只留在工具条里」的源级 + 样式级断言,并补反向断言卡片组件内不再出现插入调用
- 测试:新增键盘通路用例,聚焦后回车必须真正派发 agc-resource-reference-insert,且两条通路负载逐字相同、每次激活只派发一次
- harness 注释同步:资源卡本体只剩「选中资源」与媒体播放钮
- 新增「信息」动作:排在「@引用」之后、「编辑标签」之前,点开非模态信息浮层,再点一次即收起(aria-pressed 反映开合)
- 新增只读字段组件 ResourceInfoFieldsView(ResourceInfoPanelView.tsx),运行页签的「信息展示」与画布浮层共用,字段清单只在 resolveResourceInfoFieldRows 单点定义
- 字段口径:名称 / 路径 / 类型 + 只读分类与标签(分类取画布栏目口径,标签为空时给「暂无标签」),刻意不含来源任务、资产 ID 等 manifest 内部标识
- 浮层 chrome 与快速编辑浮层同族(同一锚点、同档圆角与阴影),样式落在 resourceCanvasChrome.css;关闭入口三处:动作按钮、面板关闭按钮、画布浮层统一的 Esc / 点边界外
- 关闭判据收口:resolveResourceCanvasQuickEditDismissOpen 更名为 resolveResourceCanvasFloatingPanelDismissOpen、参数改为 isFloatingPanelOpen,宿主把「快速编辑开」与「信息开」合成一个布尔
- .game-resource-info-panel 补进 RESOURCE_CANVAS_INTERACTION_SELECTOR,否则点在只读字段值上会被判成点画布空白而清掉选中
- 换选 / 清空选中 / 切项目时信息浮层自动收起:在选中派生处按 selectedResourceId 收口,不在每个重置点各补一行
- 测试:新增 resourceCanvasInfoModel 三条与「信息面板在画布浮层与运行页签里渲染同一份只读字段」(工具条顺序、开合、关闭按钮只收面板、点画布以外收起、Esc 连工具条一起收、运行页签字段逐行相同);resourceCanvasFloatingDismiss 的判据用例与交互选择器夹具改为浮层通用口径
- resourceCanvasChrome.css 的字段排版收在浮层选择器下,不跨上下文改运行页签的字号与列宽
- decision-log 新增一条:记录这次对 Issue #309 C3「取消资源详情面板」的有意局部回退(只回退「看信息」,不恢复可编辑详情面板与全屏编辑路由),并登记需要同步的 C3 引用位置
- .game-resource-card.is-current-version 的外向扩散层从 0 0 0 2px /14%(那是描边)改成 0 0 0 3px /32% + 0 0 20px /34% 的真发光,边框口径仍是 1px
- 新增 game-resource-card-current-version-breathe 关键帧与 ::after 光环(只呼吸 opacity,照本页 .ui-editor-status-attention 的既有写法):声明档位 38%,呼吸区间 0.58 ↔ 1,视觉上约 22% ↔ 38%
- prefers-reduced-motion 下关掉动画但保留可见档位(opacity 0.75),收敛动画不收敛可见性
- 缺陷①:.game-resource-card.is-relation-version-binding 原本只写 border-color,而卡片本体 border: 0,规则从未生效(「点击版本卡后高亮绑定资源」这条 PRD 语义一直看不见);改为真实的 1px 边框 + ring,并把该规则移到 .is-current-version 之前,让常驻光环在两者同时命中时仍然赢
- 缺陷②:当前版本规则与 hover/focus/selected 同权重且在后,导致当前版本卡几乎看不出被选中;新增 .is-current-version.is-selected / :hover / :focus-within / .is-dragging 变体,显式提权到 (0,3,0) 并带上选中态自己那层阴影
- 测试:新增 resourceCardCurrentVersionStyle.test.ts(外向扩散层档位、呼吸实现方式与关键帧区间、选中态变体权重更高、reduced-motion 下仍可见、版本聚焦规则不再是死规则)
- 测试:resourceVersionSwitch 补 data-used-by-current-version 的 DOM 断言(命中为 "true"、未命中不含该属性、切版本后跟着换手)
- 测试夹具修正:project-development.suite 的版本夹具把 slotId 从 'player' 改成恒等映射 'asset:asset-player'('historical' → 'asset:asset-removed'),否则「当前版本」判定永远不命中、照它写断言必成假守卫;并补一条自证断言钉住它真的命中
真机现场:整个「UI 交互」栏(51 项)全部只剩占位图标。根因链是**同一对文件里的两个缺陷叠加**,因此作为一个原子提交收口(拆成两半任一半都不足以独立成立:光修 observer 仍可能被回调时序漏掉,光加扫描则 observer 根本不存在时的判定精度无从恢复)。

一、确认 2a157ea6f 引入的回归:observer 可能永远不建
- 那次改动把「root 为 null」当成「不建 observer」,并指望「卡片注册」作为 root 就绪信号重建。
- 但那个 effect 每个 scope **实际只跑一次**(`requestPreview` 是稳定引用,其余依赖在 scope 内不变),一旦这一轮因 root 为 null 直接返回就再无第二次机会;
- 而「卡片注册」信号只在**后续还有新注册**时才来 —— 初次挂载时 51 张卡一次性注册完,之后再没有新注册,信号永不再来。
- 结果:observer 永不创建、所有卡永不被观察、永远停在 `idle`,整栏只剩占位图标(`categoryIcons['ui-interaction'] = LayoutGrid`)。
- 修复:**绝不拿"root 未就绪"当作"不建 observer"的理由**。拿到容器 root 最好;拿不到就退到 canvas、再退到视口(`root: null`)。视口判定严格优于"没有 observer"——屏幕上真实可见的卡一定落在视口矩形内;代价只是视口外但仍在画布内的卡晚一步由滚动触发。容器 root 迟到时再有界重试(5 次、退避到 512ms)重建一次,恢复按画本容器裁剪的精确判定。

二、系统性防线:可见性兜底扫描(去掉单点依赖)
- 门禁原先只有 `IntersectionObserver` 一条路,observer 没建出来 / 回调没送达 / 注册与创建交错,卡片就再无第二次机会。
- 新增 `sweepVisiblePreviews()`:按**与 observer 对齐的视口几何判据**(含 160px 余量)独立复核登记表,只对 `idle`(从未请求)的卡补一次 `visible`,`loading`/`queued`/`loaded`/`failed` 一律不碰。
- **不是第二条加载通路**:仍经 `requestPreview` 走同一条队列,受同一套去重、优先级、全局 3 槽与 `48` 项 / `64 MiB` 预算约束;**只补"可判定为可见"的卡,不退化成全量预读**(PRD §3.3.2 合同不变)。
- 触发点:scope 变化后 0 / 250 / 1000ms 各扫一次(覆盖晚挂载的卡)、observer 就绪后立即扫一次、每次卡片注册时扫一次、窗口 resize 与页面重新可见时各扫一次。

测试(`tests/useProjectResourceCardPreviews.test.ts`)
- 更新「等真正的 root 就绪」这条契约:它原先断言"root 为 null 时不得建 observer"——那正是本轮回归的语义,改为断言"**仍然必须建出 observer**(`root: null` 视口判定)",并要求容器 root 迟到后重建为容器 root;
- 新增「不在热预取窗口内的可见卡不得停在 idle」(20 张 / 热预取 12);
- 新增「没有任何 observer 回调时,可见卡也必须被放行」——完全不触发回调,只靠几何可见性,专门守住兜底扫描;
- 新增「视口外的已登记卡不得被请求」——守住"不退化成全量预读"。

变异验证:把兜底扫描改成直接 `return 0`(等价于去掉扫描)→ 「没有任何 observer 回调时可见卡也必须被放行」立即失败(`expected undefined to be 'loaded'`);恢复后 21/21 通过。断言不是恒真假守卫。

可观测性:`data-preview-status` / `data-preview-error` 已在位,因此"可见却 idle"这类问题以后读一个 DOM 属性即可判定,不再需要解码图片或猜调度。

验证(同一时刻、同一命令的前后对照;树上另有并发改动):改动前 `86 files / 1225 passed / 4 skipped / 0 failed`;改动后 `86 files / 1227 passed / 4 skipped / 0 failed`(多出的 2 条为本提交新增用例)。`src/components/image-editor` 1385 passed;typecheck exit 0;check:encoding 4384 文件;prettier 与 eslint 干净;`git diff --check` 干净。
- 新增 features/project-workspace/usePromptPolish.ts:失败保留原文、首次成功落原文快照(反复润色只覆盖结果)、可重入保护、可注入的上下文与规范化回调,Tauri 调用固定在 AGC 侧(默认 requestChatPromptPolish)
- 聊天输入区改用它,行为逐字不变:润色按钮 aria-busy/disabled、Loader2/Sparkles、「润色中…」/失败文案、失败保留原文、恢复原文回到最初原文、草稿清空后重置
- 聊天特有的「发送前提醒」留在输入区:提醒偏好、本轮已确认草稿指纹、表单捕获拦截、面板内润色用另一句失败文案
- chatPromptPolish.ts 的 requestChatPromptPolish 及其单测未改动,继续作为这条 LLM 通道的契约测试
- 护栏:chatPromptPolish.test.tsx(16 条)与 resourceReferenceInput.test.tsx(20 条)全绿
- ImageCanvasBasicGenerationComposerView 新增 promptActionSlot(可选):不传时整块不渲染,默认 DOM 与行为逐字不变
- 注入位紧跟在提示词输入框之后,节点由宿主提供并自己负责在 composer 网格里的落位;共享组件内不做任何 Tauri 调用
- ImageCanvasQuickEditPanelView 原样透传该 prop,网页端两条调用链不传即不受影响
- 测试:composer 与快速编辑面板各补一条「不传时无占位节点、传入时落在提示词之后」,默认不变由既有用例继续覆盖
- 新增 ResourcePromptPolishSlot:素材侧两处入口共用的「AI 润色 / 恢复原文 / 润色中…」动作,取数走 AGC 侧 usePromptPolish,聊天框同款失败态(失败保留原文、可重试文案)
- 场景约束走 polish_local_project_prompt 的 context:「这是素材生成提示词(音效)」+「只润色这一句、不要扩写成需求/方案/验收标准、不要新增事实」(本批不动后端提示词文件),文案落在 resourceCanvasPromptPolishModel
- resourceEditModel 新增 truncateResourceEditPrompt:超上限先按类型截断(背景音乐 140 / 音效 1900 / 视频与角色动画 4000 / 其余 32000)再回填,并在状态行给出「已按长度上限截断」,不静默丢内容;后端上限未改
- resourceEditModel 新增 resolveResourceEditRequestIdentity:请求身份绑定到铸造时的 prompt(Rust request_fingerprint 含 prompt,改了提示词还复用同一 operationId 会被判「已绑定到不同资源编辑请求」);提示词没变时仍复用,失败重试照旧命中同一账本
- 生成素材弹窗用注入位渲染润色动作(共享 composer 只提供插槽,不上 Tauri 调用),提交时按同一口径解析身份;原先只在这一处使用的 createResourceCanvasGenerationRequest 随之下线
- 样式落在 resourceCanvasChrome.css(resource-prompt-polish 一族),弹窗与 composer 共用一行
- 测试:模型层补截断边界与身份复用/重铸;弹窗层补润色回填与上下文、超限截断提示、失败保留原文、失败后提示词与润色一起锁定且重试复用同一请求
- 变异验证:截断改成恒等 → 模型用例与「超限截断」用例双双红灯(实测字段长度 206 ≠ 140);身份去掉重铸 → 模型用例红灯
- 选中工具条的快速编辑面板通过共享 composer 的注入位渲染同一个 ResourcePromptPolishSlot,场景约束为「图片素材的快速编辑提示词」
- 润色回填走 applyResourceQuickEditPrompt:与共享 composer 的 resetFailedDialogStatus 同口径,提示词变了就把上一次的失败状态清掉
- 提交侧请求身份按提示词收口:同 sourceLayerId 且提示词未变时复用(失败重试仍命中同一 operation 账本),提示词变了则重铸 operationId / 幂等键——Rust 的 request_fingerprint 含 prompt,复用旧身份会被判「已绑定到不同资源编辑请求」;正规化结果与 revision 属于别的事实,换身份时原样保留
- 打开面板时的初始身份改用统一的 createResourceEditRequestIdentity(绑定面板草稿提示词)
- 测试:live integration 补两条——润色成功回填并换身份(断言 prompt 与 operationId/幂等键同时变化)、润色失败保留原文且身份不变
- 变异验证:提交侧去掉按提示词重铸(同 sourceLayerId 直接复用旧身份)→ 「润色后换身份」用例红灯(两次 operationId 相同,实测)
- S9 行与 7.4 差异表补一句:只读信息浮层已回归(工具条「信息」动作),C3 取消的仍只是可编辑详情面板与全屏编辑路由
- S10 / S11 行补上两处入口的「AI 润色」判据:按钮 aria-label 与 aria-busy、成功回填与「恢复原文」、失败保留原文、超类型上限截断并提示「已按长度上限截断」
- 纯文档改动,不动代码与断言
记下「不再提醒」偏好保持单键、不按入口分域的理由
Project CI / Repository checks (pull_request) Successful in 2m35s
Project CI / Frontend tests (pull_request) Successful in 3m28s
Project CI / Backend tests (pull_request) Successful in 5m48s
Project CI / Native shell tests (pull_request) Successful in 18m25s
a4bec3d018
- 该键关掉的只有聊天输入区的「发送前提醒」;资源侧生成素材与快速编辑没有可关的提醒,润色是显式按钮动作
- 分域只在「每个入口都有可关的提醒」时才有意义,现在分域等于给不存在的提醒提前留位
- 只补注释,不改行为与断言
suzmii added 32 commits 2026-09-11 20:36:39 +08:00
背景:全局 3 槽跨视图共享,而 hook 的 scopeKey 只含 `projectPath + projectId`(不含视图)。用户在栏目页滚一遍会按可见性入队最多 51 个预览 job;点「回到资源总览」时它们仍排在同一个队列里,总览自己那几张「该出图」的卡只能排在后面 —— 这就是「进总览要等图」的真实来源。

- `nextPreviewJob` 改为两级裁决:**理由优先级固定 `play > detail > visible` 不变**,只在**同一理由内部**让当前预取作用域的请求优先。
- ⚠️ 早期写法用「作用域匹配则权值 +3」,会让当前视图的 `visible` 压过上一视图的 `play` —— 直接破坏 PRD §3.3.2 的固定调度优先级。这个错误是被本提交新增的契约用例抓到的,已改为同级裁决,并把该约束写进函数注释。
- 新增 `prefetchScopeKey`:优先取调用方显式传入的值;未传时退化为**被喂进 hook 的资源集合签名**,因此调用方(`index.tsx`)无需改动即可生效。
- 队列任务记录入队时的 `prefetchScopeKey`,供同级裁决使用。
- 新增只读 `previewQueueSnapshot()`:暴露「谁在排队、属于哪个预取作用域、当前活动读取数」。队列病理此前只能靠猜,有了它「当前视图的卡是否真的进了队列」可直接断言。不参与渲染、无副作用。

断言(`tests/useProjectResourceCardPreviews.test.ts`):新增「当前预取作用域越过上一作用域的排队预取」契约用例,三条:
1. 当前作用域的 `visible` 越过上一作用域排队的 `visible`;
2. **但不得越过 PRD 优先级** —— 当前作用域的 `visible` 仍排在 `play` 之后;
3. 同一作用域内仍是 `detail > visible`。

变异验证:把作用域偏好关掉(`&& false`)→ 第 1 条断言立即失败(`expected 'previous' to be 'current'`);恢复后 22/22 通过。断言不是恒真假守卫。

验证:typecheck exit 0;该测试文件 22 passed;check:encoding 4388 文件;`git diff --check` 干净。(AGC 全量与本条无关的并发改动混跑,故此处只报本文件的定向结果;全量对照见后续提交的回报。)
用户离开某个视图后,继续为它排队读图没有任何收益,而这些排队的预取会**排在新视图按需请求的前面**(3 槽跨视图共享),把"进入总览后等图"变成纯等待。这是"进总览要等图"的主因,单张读取本身只有 5–15ms。

- `cancelQueuedVisiblePrefetches()`:只下掉**还在队列里**的 `visible` 理由任务。语义边界刻意收窄 ——
  - `detail` / `play` 理由的排队**保留**;
  - **在途请求不打断**(只占 3 槽中的 1 个,打断它拿不回已花的读盘成本,且切回来要重读);
  - **缓存与身份不失效**(不触发 `disposeAllCachedPreviews`),切回原视图不重读已拿到的图。
- 修法 4 一并收口:视图切换 effect 在取消后**立即按几何复核一次**(`sweepVisiblePreviews`),仍然可见的卡重新入队、不可见的自然不再请求 —— 因此不存在"取消后永不重试"的死角。
- 依赖使用**派生后的** `prefetchScopeKey`(不是可能为 `undefined` 的 `input.prefetchScopeKey`)。这修掉了本轮自查发现的一个真 bug:早期写法在调用方未传该 prop 时直接 `return`,整段取消逻辑成了**永不执行的死代码**;由下面的断言暴露。

断言(`tests/useProjectResourceCardPreviews.test.ts` 新增 2 条):
1. 「切视图只取消上一作用域排队中的可见性预取」:登记 6 张(3 在途占满槽 + 3 排队,其中 1 张为 `detail`),切视图后断言队列从 3 → 1、**留下的正是 `detail`**、且 `activeReadCount` 仍为 3(在途不被取消)、新视图 identity 立刻可用;
2. 「取消后再次可见会重新入队」:切走再切回,卡片重新注册且几何可见后,断言该卡**重新进入队列** —— 直接守住"不留永不重试死角"这条硬要求。

变异验证:把取消那一步去掉(effect 内只保留复核)→ 第 1 条断言立即失败(`expected [ …(3) ] to have a length of 1 but got 3`);恢复后 24/24 通过。

验证(同一时刻、同一命令的前后对照;当时树上有并发改动:`src-tauri/.../direct_project_history.rs`):
- 改动前(只 stash 我的两个文件):`86 files / 1236 passed / 4 skipped / 0 failed`
- 改动后:`86 files / 1238 passed / 4 skipped / 0 failed`(多出的 2 条为本提交新增用例)
- `src/components/image-editor` 1387 passed;typecheck exit 0;check:encoding 4388 文件;prettier 与 eslint 干净;`git diff --check` 干净。
- direct_project_history.rs:两处行信封判定收敛到 direct_project_history_item_from_parsed_line,response_item 与白名单化的 legacy 行走同一条判定路径
- 新增 direct_project_legacy_row:只认 schemaVersion=game-creator-conversation.v1、无 type、role 落在该写入器自己的角色集合(user/assistant/tool)内、content 为非空字符串的行,其余形状继续按损坏失败关闭
- user/assistant 旧行投影成与 direct_project_local_message_item 同形状的 message item:role 与 content 逐字节保留、不 trim、不改写、未知字段忽略,有 messageId 才带 id
- tool 旧行已识别但不进 Codex 上下文:它不是 Responses item,无法还原成真正的工具 item,混入会造出假的工具消息;聊天投影本来也只展示 user/assistant
- 抽出 direct_project_message_item 供本地补写与 legacy 投影共用,保证两种来源的 item 形状一致
- 补断言:旧行投影(role/content 逐字节、无 messageId 时无 id)、旧行与新行交替的混合文件按行顺序读取、tool 行被识别且不进上下文、非白名单异常行(role=system/developer、content 非字符串或为空、缺 role、缺 payload、坏 JSON)仍失败关闭、通用对话写入器产出的旧行形状落在白名单内
- 变异验证:去掉 legacy 兼容分支→投影/混合文件/通用写入器形状 3 个用例变红;把白名单放宽成「任意行都接受」→失败关闭用例变红
- project/conversation.rs:read_persisted_local_conversation_records_unlocked 跳过 type=response_item 且带 payload 的行(DirectProject 写给同一份「项目主对话」的行),其余解析失败仍按「解析对话记录失败」失败关闭
- 新增 is_direct_project_history_row 做行信封白名单,只认对方那一种明确枚举的形状,不放宽成「什么都跳过」
- 不把 response_item 行二次投影成 LocalConversationMessageRecord:DirectProject 侧已经拥有那份 Responses item → 聊天内容的投影,再造一份平行投影会与它漂移
- 不加「文件已属于 DirectProject 就拒绝追加旧行」的硬报错:runtime_driver/task_start.rs 在 ensure_..._accepted_public_status_at 返回 Err 时会中止后台任务(「后台任务启动确认落盘失败,任务未执行」),runtime_protocol/steering.rs:572/604/1300 也用 ? 上抛,加硬报错会把旧行噪声换成任务起不来;毒化本身已由读侧白名单堵住
- 补断言:混合文件里通用链只读自己的 legacy 行且坏行仍失败关闭、混合文件从两条链都能读(DirectProject 把两种行都读出来)、纯旧格式文件的写入行为不变
- direct_tools_mcp.rs:删除 conversation.record_codex_response 末尾往 project.jsonl 追加 legacy 行的投影,它写的是 {schemaVersion:game-creator-conversation.v1,...} 旧行,会毒化共用该文件的 DirectProject 历史
- 顺带删掉只为那条投影存在的 key/message_id 计算与描述它的注释,不留墓碑
- 该工具的事实来源仍是自己的只读 journal .agent/conversations/codex-responses.jsonl,返回内容与 conversation.list/read 行为不变
- 补断言:record_codex_response 只写 codex-responses.jsonl,project.jsonl 保持为空
- direct_runtime.rs:新增 direct_project_history_shape_failure 白名单(历史记录类型无效 / 缺少 payload / 解析历史失败)
- direct_codex_failure_recovery_hint 增加专门 hint,指向真实现象与动作:该类旧格式已被读侧兼容,仍失败说明记录不在白名单内,请检查项目诊断后修复该历史文件
- direct_codex_failure_is_retryable 对该类失败返回 false,前端不再显示「可直接重试」(同一份历史每次读得到同一结论)
- 打开/读取类 IO 失败不在白名单内,仍按可重试处理
- 补断言:hint 文案、IO 失败边界、诊断 message 里的 retryable=false 与 sidecar 的 "retryable": false
- 技术方案:把「不迁移旧 {role,content} 行 / 不会对旧格式做迁移或兼容」改成当前状态——读侧白名单兼容 legacy 行(含 tool 行只识别不注入)、写侧 DirectProject 自己的旧行写点已收口
- 技术方案:修正「Rust 是该历史文件的唯一写入方」,写清 project.jsonl 同时被通用对话链(agent_id=None 的项目主对话与 Runtime 公开状态消息)读写,并记录两条链互读兼容的规则
- 技术方案:说明为什么故意不给通用对话写入器加「Direct-owned 就拒写」的硬报错(会把旧行噪声换成任务起不来),以及这类失败为何按不可重试处理
- 技术方案:更新时间改 2026-09-11
- decision-log:新增 2026-09-11 条目,记录格式切换当年「唯一写入方 + 测试手动清理」的前提在存量用户项目上不成立、现改为「读侧白名单兼容 + 写侧统一」,以及为什么不做数据迁移
- 新增 `game_creation_app_asset_effective_category`:读显示口径的唯一实现——落盘 `category` 权威,仅在落盘值为 `unclassified` 且该资产 `kind` 能派生出明确的非 `unclassified` 分类时采用派生值,其余信任落盘值
- `GameCreationAppAssetManifestEntry` 的反序列化刻意不做自愈:反序列化结果就是落盘原值,「编辑标签」面板要靠它原样回写;自愈只属于读显示口径
- Agent 资源投影 `bridge_registered_resource` 不再直接透传 `asset.category`,改走有效分类,修掉真机 122 条资产里 55 条「UI 显示 UI 交互、Agent 读到待归类」的跨端口径分歧
- 新增 Rust 决策矩阵 `EFFECTIVE_CATEGORY_CONTRACT` 与用例 `asset_effective_category_follows_the_shared_contract_matrix`,矩阵是「落盘值 / kind 派生 / 读时自愈」三个口径的唯一真源
- 新增跨语言契约:`assetKindCanonicalMapping.test.ts` 解析 Rust 源码里的决策矩阵与 canonical kind→栏目表,逐条喂给 TS `gameCreationAppAssetCategory` 对照,任一侧改口径都会红
- `direct_tool_bridge` 新增 Agent 侧回归用例:`kind:"ui" + category:"unclassified"` 必须投影成 `ui-interaction`
- 既有用例 `..._projects_manifest_classification_verbatim` 更名为 `..._keeps_explicit_manifest_classification`:逐字透传只在落盘值不是 `unclassified` 时成立,旧名字会与新的有效分类语义矛盾
- 新增 `gameCreationAppAssetPersistedCategory`:写回口径,只做「缺失或非法 → 按 kind 派生」兜底,不套用读时自愈;它必然等于 Rust 反序列化后的落盘 `category`
- `ResourceClassificationPanel` 的分类初值从 `gameCreationAppAssetCategory`(读显示口径、会把落盘 `unclassified` 自愈成派生值)改为 `gameCreationAppAssetPersistedCategory`,保存时回传落盘原值
- 修掉真机上「同一 `kind:"ui"` 资产同时存在 55 条 `unclassified` 与 2 条 `ui-interaction`」的漂移签名——后者正是自愈值被回写持久化的结果
- 面板注释更正:原注释写「保证『只改标签』不会顺带改动分类」,但那行读的是自愈后的值,这个保证是假的
- `gameCreationAppAssetCategory` 的文档补上「这是读显示口径,回写必须用 `gameCreationAppAssetPersistedCategory`」,避免下次再混用
- 新增面板用例「保存标签不改动落盘 category:自愈值不得被回写」:夹具 `{kind:'ui', category:'unclassified'}`,断言写入命令收到的 `category` 仍是 `unclassified`
- 既有面板夹具 `{kind:'character', category:'character'}` 是自愈 no-op,永远测不出漂移,因此保留它作为对照、另加自愈夹具
- 新增契约用例「写回口径保留落盘 unclassified,读显示口径才自愈」,把两个口径的差集钉死
- `canonical_game_creation_app_asset_kind`(Rust)与 `canonicalGameCreationAppAssetKind`(TS)改为对 trim 后的小写值查别名表与 canonical 目录,修正「UI 设计资产的现役写入侧写的是大写 `"UI"`」这条真机事实
- 别名表补 `font → document`:字体上传(`ttf / otf / woff / woff2`)登记的 manifest `kind` 就是 `font`
- 为什么必须在别名表收口、读时自愈救不回来:`"UI"` 与 `"font"` 的派生结果本身就是 `unclassified`,自愈规则「派生值不是 unclassified 才触发」永不成立;真机 8 条 `kind:"UI"` + `mediaType:"application/json"` + `localPath:"ui/UI 设计 N.json"` 与写入路径逐字段吻合
- 改写 Rust 断言 `game_creation_app_asset_category_for_kind("UI") == Unclassified` → `UiInteraction`,并补 `canonical_...("UI") == "ui-design"` 与 `font == Document`:旧断言把这条 bug 钉成了「期望行为」,新形态才是唯一真源——写入侧真实写出的 kind 必须能落进明确栏目,否则真机资产永远归不了类
- 改写 TS 断言 `gameCreationAppAssetCategoryForKind('UI') == 'unclassified'` → `'ui-interaction'`,理由同上
- `game_creation_app.rs` 里「现役写入侧仍会写出这些非 canonical 值,必须在这里收口」的注释此前只收口了小写 `ui`,与大写写入侧矛盾,现按注释本意收口
- 新增写侧→分类的端到端契约用例:`resource_bridge.rs` 的 `bridge_is_idempotent_and_installs_source_image` 走真实生产函数 → 真实 `register_local_asset_at(..., "UI", ...)` → 断言落盘 `category` 为 `ui-interaction`
- 新增 `assetKindCanonicalMapping.test.ts` 的「写侧 kind 字面量 → 分类」用例组:直接解析 UI 编辑器写侧源码第 3 个实参,写点换个新字面量就会红,防下次再漏
- `register_local_asset_entry`(`assets.rs`)命中同 `localPath` 的既有资产时,旧实现只覆盖 `kind` / `media_type` / `source`,**从不重算 `category`**:`kind` 变了而 `category` 停在旧值,且陈旧的非 `unclassified` 值会被读侧无条件信任(自愈只在落盘值是 `unclassified` 时才触发),该资产就永远停在错误栏目
- 修法:只在 `existing.kind != kind` 时重派生 `category`;`kind` 未变时刻意不动 `category`——落盘分类是权威值,同 kind 重登记不得抹掉它
- `register_local_asset_records_existing_asset_with_canvas_source` 补上 `category` 断言:首次登记 `kind:"character"` 落 `character`,重登记改成 `kind:"ui"` 后必须变 `ui-interaction`(旧用例只断言 `kind` 与 `source.kind`,正好走更新分支却漏掉 `category`,所以这条错位一直没被抓住)
- 新增用例 `register_local_asset_keeps_explicit_category_when_kind_is_unchanged`:显式设成 `audio` 后同 kind 重登记,`category` 与 `tags` 必须原样保留,把「不能无条件重派生」这条不变量钉死
- 新增用例 `register_local_asset_derives_category_from_real_write_side_kinds`:用与现役写入侧逐字一致的字面量(`"UI"` / `"font"`)走真实 `register_local_asset_at`,断言落到 `ui-interaction` / `document`;它属于同一套 `register_local_asset_*` 写 API 的派生行为,因此与上面的更新分支断言放在同一个提交
- PRD 的分类取值段落改为「读显示 / 写回两个口径」:写清跨端同构的两个函数名、Agent 投影不许再透传落盘 `category`、面板保存回传落盘原值、别名表大小写不敏感(`"UI"`、`font`)
- 技术方案(AI游戏创作智能体App实施计划)的 manifest `category` 段落同步:`"UI" → ui-interaction`、`font → document`,其余映射不到的 kind 才落 `unclassified`;补上两侧口径函数名
- `pitfalls.md`:把「补别名只影响新登记,不重算 `category`」这条坑更新为已修形态,并写明「同 kind 重登记禁止动 `category`」这条必须保留的不变量;「读时重派生」条目补上两个口径不能混用与跨端必须同构两条
- `pitfalls.md` 新增条目「大写 `UI` / `font` 不在 alias 表 → 8 条真机 UI 资产永远落「待归类」,且自愈救不回」,记录成因链与三层守卫
- `decision-log.md` 新增决策条目:口径拆两半、自愈不下沉到反序列化、别名表大小写不敏感、重登记重派生但不抹掉显式分类,含变异验证与影响范围
- 端到端验收用例文档的 S5 栏目归属补一句:判定「编辑标签后分类有没有被改」必须比 manifest 文件,不能拿自愈后的显示值当落盘值
- 现象:提交 3082c3e85 把两条新增的 `decided` 表行(`'UI'`、`'font'`)插到了 `test.each(decided)` 的实参位置,`test.each` 拿到的是对象而不是回调,原来 7 条 `decided` 用例全部报 `fn2 is not a function`
- 成因:pre-commit 的 lint-staged 对**部分暂存**文件做「隐藏未暂存改动 → 跑 prettier → 还原未暂存改动」时会重新落位 hunk,本次两条新增行落错了位置;该文件当时是部分暂存(提交 ① 只暂存了其中 3 个 hunk),因此被波及
- 修法:把两条行移回 `decided` 数组内的正确位置,`test.each(decided)` 恢复成「表 + 单回调」形态
- 本提交只暂存这一个文件(不部分暂存),避开同一成因
- useProjectResourceCanvasLayout 新增读盘只读统计 inspectProjectResourceCanvasLayoutRead 与文案 describeProjectResourceCanvasLayoutRead。

- 读盘判定 changed 时写入 readReport(项目作用域身份 + 排序模式 + 旧分区归并条数),判据复用 normalizeResourceCanvasPosition,只统计、不改写回与丢弃语义。

- index.tsx 在既有 game-resource-book-notices 提示层内渲染一次性提示条,复用 .game-resource-live-notice 样式与「知道了」关闭按钮;每个项目只提示一次,切换项目清空。

- 断言:useProjectResourceCanvasLayout.test.ts 覆盖归并时 readReport 与文案、无需归并且不写盘、只补新资源落位的写回不报读时统计;appSurface/project-development.suite.ts 覆盖提示条出现(data-resource-canvas-layout-normalized、可关闭)与无需归并时不出现。

- 变异验证:去掉 setReadReport → 上述读盘统计与提示条用例变红。
- readReport 增加 droppedMissingResource(资源已不在 manifest)/ droppedSectionMismatch(分区与资源分类不匹配)/ dropped(两者之和),判据仍是 normalizeResourceCanvasPosition,未改 resourceCanvasLayoutModel 的丢弃语义。

- 文案补上跳过条数与两类原因:归并与丢弃同时发生时用「另有 N 条坐标无法对齐已跳过(…)」;只有丢弃时用「打开项目时有 N 条坐标无法对齐已跳过(…)」。

- 提示条暴露 data-resource-canvas-layout-normalized / -dropped / -dropped-missing-resource / -dropped-section-mismatch,供排障直接读 DOM;完全没丢时四个值都是 0。

- 读盘触发条件放宽为「有归并或有丢弃」,避免只有丢弃时什么都不提示。

- 断言:读盘统计用例覆盖三种情况分离;文案用例覆盖四形态(含真机 112 / 66 / 16 口径);appSurface 用例断言四个 data-* 与完整文案。

- 变异验证:把 dropped 计数改成恒 0 → 统计用例、读盘报告用例、提示条用例三处变红;去掉 data-resource-canvas-layout-dropped → 提示条用例变红。
- decision-log 2026-09-10 那条把「不写迁移脚本、不删除、不重置」改成当前事实:读时会把 legacy 分区(art / code)坐标归并到新分区并写回一次 sidecar(revision +1);不写迁移脚本、不重置坐标位置;但无法归并的坐标会被跳过(跳过条数在画布提示条上可见)。

- 新增 2026-09-11 decision-log 条目:背景(审计查实「读时原地重写」112 条 + 27%(66 + 16)丢弃与旧文档字面冲突)、决策(用户选 A:保留重写、不静默、改文档)、影响范围、验证方式(含两处变异验证)、关联文档。

- 技术方案【GameAgent资源自由画板与快速编辑】:同一句改成读路径写回一次 + 无法归并的坐标被跳过且可在提示条 / data-resource-canvas-layout-* 上读到。

- PRD【项目开发工作台】第 325 行同一句同样校准(避免权威文档里留着「不写迁移脚本、不删除、不重置」这句会误导排障)。

- 测试用例【AGC资源工作台V3端到端验收】S21 台账那格补上「存量项目第一次打开时读路径也会写回一次、并给一次性提示条」,避免把 revision 从 1 开始误判成异常。

- 行为、sidecar schema(v1)与跨端契约均未改动。
- WorkspaceLauncher:applyManifestSnapshot 拒收分支此前直接 return,改为必须先留痕(console.warn 记 decision/source/两个 revision)再走恢复;
- 新增 manifestMergeNotice 状态与提示条,DOM 暴露 data-manifest-merge-decision / -stage / -source / -held-revision / -snapshot-revision 作为排障观察点;
- 新增 recoverRejectedManifestSnapshot:重读磁盘 revision + 清单并重建 merge 状态,把被拒快照漏掉的新资源真正带进项目上下文;
- 同一项目只允许一条恢复在飞,避免拒收成串到达时打满读取槽;
- 重读期间若有更新的快照已被接受,绝不回退 revision(宁可报未解决,不放宽 CAS);
- scope-mismatch 仍只记日志不弹提示:串项目的快照对用户没有可执行语义;
- projectResourceLiveUpdateModel:新增拒收判定、三阶段文案与 rereadAuthoritativeProjectManifestSnapshot(两次读 revision 夹一次读清单,撕裂就放弃);
- styles.css:新增 .launcher-manifest-merge-notices 绝对定位提示位,容器不吃点击,与 .game-resource-book-notices 同口径;
- 新增 apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx:拒收可见 + 重读采纳 + 重读失败报未解决 + 不回退 revision + 串项目快照保持静默;
- projectResourceLiveUpdateModel.test.ts 追加拒收可见性/重读三读/撕裂放弃/跨项目拒绝/revision 非法/三阶段文案可区分六条断言。
- publishPreview 记录本轮驱逐条数,驱逐发生后在 previewsRef 落盘之后补一次 sweepVisiblePreviews;
- 被驱逐的 identity 状态正好回到 undefined,而 sweepVisiblePreviews 只对 undefined 的卡重新入队,此前只差不这一次调用;
- 调用点必须在 previewsRef.current = next 之后,否则扫描看到被驱逐卡仍是 loaded 而不做任何事;
- 注释写明重入有界:重新入队走 publishPreview({status:'loading'}),不进 loaded/failed 驱逐分支,递归深度恒为 1;
- 只在真的发生驱逐时补扫,普通 loading/loaded 发布不增加扫描开销;
- 新增用例:灌满 48 张缓存把仍可见的目标卡挤出(LRU 不看可见性),断言该卡被重新请求读成 loaded。
- requestPreview 在队列达上限且没有可顶掉的可见性预取时,按需请求(detail/play)此前直接 return,用户在可见卡上点播放没有任何请求与痕迹;
- 新增 droppedRequestsRef:单调计数 + 最近一条明细(identity / reason / queueLength / at),并补一条 [preview-queue] console.warn;
- previewQueueSnapshot 暴露 droppedRequestCount 与 lastDroppedRequest,排障与用例可直接观测;
- 可见性预取撞上限属于设计内背压(下一轮兜底扫描会补),不计入丢弃口径;
- 队列上限、3 个并发槽、优先级顺序(play > detail > visible)全部不动;
- 新增用例:压满 96 条 detail 后 play 被丢弃必须留下标记,且 visible 背压不计数。
- packages/shared/src/components/PlatformFilterPanel.tsx:新增按需弹出的筛选浮层外壳,只承载通用表现——竖向字段容器、右上角关闭键与 role=dialog 语义;字段名与控件全部由宿主经 children 传入
- 字段排布约定收敛到 PlatformFilterPanelField:字段名在上、控件在下,可用 controlId 把字段名与控件关联;字段名复用既有 PlatformFieldLabel,不另写标签样式
- 不下沉任何领域规则:外壳不知道区域 / 标签 / 状态等概念,不接后端,不算过滤;筛选取值与判据都由宿主持有,因此同一外壳可在不同页面各装一套字段
- 不自带 position:定位与层级由宿主经 className 注入,避免共享层猜宿主布局
- packages/shared/src/components/PlatformFilterPanel.test.tsx:覆盖 dialog 语义与关闭转发、字段逐个渲染且不出现外壳自带的「状态」占位字段、controlId 字段名关联、以及宿主定位类注入且组件不自带 absolute / fixed
- `canonicalGameCreationAppAssetKind`(TS)此前用对象字面量直接下标查别名表:`kind` 是外部输入,`constructor` / `__proto__` / `toString` / `valueOf` / `hasOwnProperty` 会命中 `Object.prototype` 上的成员,被当成 canonical kind 返回(`__proto__` 返回原型对象本身);Rust 侧是 `match` 字面量,只会落 `image` 兜底,同一输入两侧分叉
- 改用 `Object.hasOwn(GAME_CREATION_APP_LEGACY_ASSET_KINDS, normalized)` 先确认命中的是表自己的键,再取值;`noUncheckedIndexedAccess` 下仍保留一次取值后的 truthy 判定
- 补断言(无断言的边界修法下次会被改回去):`gameCreationApp.test.ts` 对 `constructor` / `__proto__` / `toString` / `valueOf` / `hasOwnProperty` 逐条断言 `canonicalGameCreationAppAssetKind` 落 `image`、`gameCreationAppAssetCategoryForKind` 落 `unclassified`
- 跨语言对照:`assetKindCanonicalMapping.test.ts` 的 `decided` 表新增 `constructor` 与 `__proto__` 两行(`image` / `unclassified`),与 TS 同一份口径对照
- Rust 侧同步断言:`game_creation_app.rs` 的 `asset_category_mapping_covers_every_canonical_kind` 补 `constructor` / `__proto__` / `toString` / `valueOf` 必须落 `image` 与 `unclassified`,把「两侧一致」这件事写进两侧各自的用例
- 边界范围:只收口「原型成员被当成别名」这一种极端输入;`kind` 的正常词表、大小写口径与读时自愈规则不变,manifest 字段构成与顺序不变
- resourceCanvasFilterModel.ts:把画布筛选收成一处纯函数——区域判据做恒等比较表达「只筛当前区域」,「全部区域」放行全量;标签复用共享 assetTagsMatchSelection(AND 语义);关键词沿用既有搜索的名称 / 路径 / 媒体类型 / 任务名四字段与大小写不敏感口径,使搜索浮层与筛选面板对同一关键词给出同一结果
- resourceCanvasFilterModel.ts:区域选项由 PROJECT_RESOURCE_CANVAS_SECTIONS 派生,中文显示名复用 @ 面板那份唯一权威口径 resourceReferenceCategoryLabel,不为「项目版本」以外的栏目另写译名表
- resourceCanvasFilterModel.ts:标签库只统计当前区域内的资源,切换区域后不残留只属于别的区域的标签
- tests/resourceCanvasFilterModel.test.ts:覆盖区域选项顺序与显示名、空筛选不重排、区域恒等比较、关键词四字段与空白归一、标签 AND 语义、无标签事实源资源的口径、三维度叠加、标签库计数与排序
- 三条变异验证实测均变红后还原:把区域判据改成恒 true → 4 failed;去掉关键词判据 → 3 failed;去掉标签判据 → 4 failed;还原后 13 passed
- ResourceFilterPanel.tsx:AGC 侧筛选浮层,领域规则留在这一层——区域取值就是画布当前栏目(只把选择交回宿主,面板自己不改栏目也不另存区域),关键词与画布搜索浮层共用同一份状态,标签走 AND 语义;共享层只提供浮层外壳与表单原语
- ResourceFilterPanel.tsx:不渲染「状态」字段,并按注释写明理由——manifest 资产与资源投影都没有状态事实源,唯二候选(任务状态、附件导入状态)在投影里已被上游过滤,硬用即恒单值假筛选
- ResourceFilterPanel.tsx:「点外部 / Esc 关闭」按既有搜索浮层口径在 document 上同时接管 click 与 Escape,点触发按钮不被判成外部;不复用美术画布那个 dismiss hook,因为它判定焦点是否在浮层里用的是自己的 portal-menu 类,本浮层不匹配会导致 Esc 关闭后焦点不回触发按钮
- ResourceFilterPanel.tsx:区域下拉由 RESOURCE_FILTER_REGION_OPTIONS 派生,显示名与 @ 面板同一份口径;标签 chip 用既有 platform-category-chip 类,选中态不另造样式
- resourceCanvasFilterModel.ts:筛选生效判据 resourceFilterIsActive 落在模型层,供宿主给 Dock 按钮打高亮(条件不随浮层收起一起消失);纯空白关键词不计入,避免按钮为一次空输入长期高亮
- styles.css:新增 .game-resource-filter-panel 右下角锚点(与搜索浮层同一 bottom / z-index 口径,向上展开),定位留在宿主,共享浮层外壳保持不自带 position
- tests/resourceFilterPanel.test.tsx 与 tests/resourceCanvasFilterModel.test.ts:覆盖三字段渲染且无「状态」占位、区域选项取值与中文显示名逐个钉住、关键词回显与回调、区域切换只回调、标签受控多选与 aria-pressed、空标签库隐藏该字段、关闭键 / Escape / 点外部 / 点触发按钮四种关闭口径、Escape 后焦点回触发按钮、筛选生效判据
- A 热预取不再按投影顺序盲取前 N:eager effect 现在只预取已登记且几何上可见(档 0 / 档 1)的卡,按两档顺序入队,eagerPreviewLimit 仍是硬上限;
- A 保留首屏兜底:一张可见卡都判不出来时(卡片尚未注册 / 容器还没布局出尺寸)退回原顺序预取,绝不把首屏热预取削成 0;
- B 新增 viewportBandOfElement:0 = 视口内、1 = 只落在 rootMargin(160px) 圈里、2 = 放行范围外(含量不出尺寸);门禁与放行顺序共用同一套几何口径;
- B 新增 requestPreviewCardsByViewportBand:先档 0 再档 1,只改入队顺序、不改总量(档 1 在同一次调用里一样放行),3 槽 / 48 项 / 64 MiB 合同不动;
- B 兜底扫描改用两档放行(门禁语义不变:只有档 0 / 档 1 才放行),不会退化成"只补第一档";
- B observer 回调一次报来十几二十张相交卡时,只把"视口内"的插到前面,其余一张不丢,既有"相交即放行"语义不变;
- 新增用例:视口外卡片不得进入热预取(改前首屏 distinct 读 20 张、含 8 张视口外;改后 12 张全部可见);相交一次性放行时先视口内后余量圈且总量不变(6 张全放行);
- 变异验证:整份还原成改前版本 → 两条用例分别红(20 vs 12、放行顺序反了);只把 observer 回调改回单趟 → 只有两档那条红。
- docs/prd/...PRD-2026-07-20.md:§5.2.4 新增资源筛选条款——按需弹出的独立浮层取代常驻筛选条(常驻条已移除、不回归)、固定三个字段(查找素材 / 所在区域 / 自定义标签)与各自口径、不提供「状态」字段及其理由、区域既是筛选维度也是画布当前栏目且必须是同一份状态、两种区域语义共用一份过滤实现、浮层与搜索浮层同锚点不得同时展开、关闭与高亮口径、筛选后空态要求
- docs/project-memory/shared-memory/decision-log.md:新增 2026-09-11 决策条目,按体例写背景 / 决策 / 影响范围 / 验证方式 / 关联文档
- decision-log 如实记录:已落地的部分是共享外壳、纯函数与 AGC 侧组件样式(各自独立提交),而 index.tsx 的 3 处接线因该文件被另一条线占用尚未提交,并写明接线完成后应补记——不把未落地行为写成已实现
- decision-log 记录「状态」字段不做的三条证据链(manifest 资产无 status、资源投影无 status、唯二候选任务状态与附件导入状态在投影里已被上游过滤故恒单值),以及区域用派生而非新增第二份 state、「全部区域」复用既有所有资源分组网格的取舍
- decision-log 记录 PlatformResourceFilterBar 不碰不扩的理由(横向常驻条且被主站参考图弹窗在役使用)与既有断言零放宽、三条变异验证实测结果
- ResourceFilterPanel.tsx:panelRef 由 HTMLElement 改为 HTMLDivElement。该 ref 挂在外层 div 上,React 的 Ref<HTMLDivElement> 不接受 RefObject<HTMLElement>,此前 typecheck 报 TS2322
- 漏跑原因:提交浮层组件那一笔时,我是在新增该文件之前跑的 typecheck,之后只跑了定向测试与 pre-commit 的 lint-staged(它对暂存文件只做 eslint 与 prettier,不做 tsc),因此这个类型错误当时没有被拦住;补跑 ai-game-creator-shell:typecheck 后修复
- 行为不变:只收窄 ref 的静态类型,关闭口径与渲染结构都不动;定向测试复跑 22 passed
- 新增可选 prop showParameters(默认 true):为 false 时不渲染「比例 / 时长 / 清晰度」
  入口按钮与对应浮层菜单,默认值等于网页端美术画布今天的渲染,行为逐字不变。
- 关闭理由写进注释:宿主若不能把这三个值送进请求,渲染出来就是点了不生效的假控件。
- 单独成一个提交,便于共享面板被其它改动覆盖时单独摘取或回退。
- resourceCanvasToolbarModel:只有在「已登记的栅格角色图」上放行 character-animation
  (非角色 / 未登记为正式素材 / 非栅格三类都不放行;Rust 侧角色动画要解码源图取宽高,SVG 必失败);其余动作仍按 opt-in 不渲染,并写明不做它们各自的阻塞证据。
- index.tsx:把源解析与 revision 缓存抽成 resolveResourceDeriveSource,快速编辑与生成动画共用,避免同一个源在两条链路上得到两份 manifest 视图;新增生成动画的源/浮层状态、请求身份(与快速编辑同一套「提示词变了才重铸」口径)与打开/提交/关闭时机。
- 面板复用共享 ImageCanvasCharacterAnimationPanelView,参数入口显式关闭(比例/时长/清晰度与模型由客户端 Rust 固定为 same/720p/32 帧/4 秒/seedance2.0-fast,改它不改变请求);档位常量与 Rust 常量同值,泥点价按同一算法算,避免按钮上出现与实际计费不符的数字。
- 「改造」(redraw) 不再另起入口:其语义由既有「快速编辑」承担,接独立按钮只是同链路重复入口。
- 断言改写(四不写):角色资源那条由「只放行快速编辑与下载」改写为「放行生成动画」,并新增三条反向守卫(非角色图片 / 角色图未登记 / 非栅格角色图均不放行)。
- 端到端新增「从角色资源卡生成动画」:断言 editKind/generationMode/prompt/assetName/sourceAssetId 与泥点价档位,并断言参数入口不存在。
- pitfalls.md:把「资源画布工具条的『改造』『角色动画』按钮点了没反应」按四不写改成已收口状态(不再写"尚未修"):生成动画已接真实链路 + 参数入口为什么关、改造由快速编辑承担、其余 5 个动作逐项不做的证据;补记两条连带经验(复用共享面板时要逐个确认"可选参数在该宿主是否影响请求";面板的泥点价就是提交按钮本身,档位常量必须与后端固定值一致)。
- decision-log.md:新增 2026-09-11 条目,记录四条决策(接通生成动画的门禁与链路、复用共享动画面板并关参数入口、改造认定为快速编辑、其余动作不接的逐项证据)与三条变异验证结果。
- AGC 技术方案「操作边界」:补 4 条当前口径(动作集合按"放进来就必须真能跑通"放行、生成动画的面板与固定档位、改造=快速编辑、未接通动作各自缺什么)。
- Issue #309 与 PR #316 正文未改:远程写操作需用户确认,decision-log 里已记录这两处需要怎么改。
- 现象:打开项目这一提交里刚落下的拒收提示,会被同一次 [projectPath] 变化的清除 effect 清掉 —— 资产命令在项目刚打开后马上推送清单快照正是主场景,提示于是时有时无;
- 改法:新增 manifestMergeNoticeScopeRef 记录上一次观察到的项目路径,undefined → 路径(首次进入)时不清;只有真正的 A→B 换项目或关掉项目才清提示与在飞标记;
- 断言:新增"换项目必须清掉上一个项目的拒收提示"用例(确定性反向钉子,防止把清除整个删掉);
- 用例加固:拒收提示的三处断言改为每轮重新查询 DOM(阶段切换时 React 可能替换节点,抓着旧引用会在负载下瞬时红),异步恢复等待放宽到 4s;
- 说明:这次加固的起因是一次偶发红(expected undefined to be 'revision-conflict')。事后在改动前后各跑 18/28 轮未能复现"去掉护栏必红",所以此处不声称护栏就是那次偶发的成因;护栏本身修的是同一提交窗口被清除这一确定性缺陷,长时压测(4 路并发同时跑两个用例文件)全绿。
- 现象:驱逐用例逐张 waitFor 48 张(3 个读取槽 + 48 轮 act/轮询)在同树多个写者并发跑测试时,会把用例 5s 超时耗光(Test timed out in 5000ms),红的是超时不是断言;
- 改法:48 张一次性入队,只等"读取都已发出"(read 次数 ≥ 49),不再逐张断言 48 个卡的状态;
- 原因说明:驱逐本来就会连锁(目标卡被驱逐 → 补扫描重读 → 再挤掉一张未被观察的填充卡),逐张断言会在链条行进中读到 undefined,红的是时序不是契约;
- 核心断言一条不动:目标卡必须被重新请求(读次数 1 → 2)且最终回到 loaded;本用例超时放宽到 60s、内部 waitFor 20s;
- 验证:改动前后各连跑 6 轮 + 4 路并发同时跑两个用例文件,全绿。
接线资源筛选浮层并把资源卡旁两个浮层改成互斥
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 3m16s
Project CI / Native shell tests (pull_request) Successful in 19m58s
0ef1a4ec15
- index.tsx:新增 resourceFilterOpen 与 resourceFilterTags 两个 state,区域不新增 state(唯一新增的筛选相关 state 是已选标签)
- index.tsx:可见资源由内联关键词过滤改为调用 filterCanvasResources,维度为区域 / 标签 / 关键词;区域由 resourceBookState 派生(不在分页栏目里时为「全部区域」),因此用户用滚轮 / 总览卡片 / 下一页换栏目时区域自动跟随,无需双向同步
- index.tsx:标签库按「区域 + 关键词」派生并故意不含已选标签——若拿已按标签收窄的集合派生,选了一个标签后列表只剩该标签,用户再也选不出第二个标签,AND 多选会形同废掉
- index.tsx:Dock 上紧邻搜索按钮新增筛选按钮,复用 game-resource-book-zoom-button 与 is-active 口径;两个浮层共用同一个右下角锚点,因此开筛选即收起搜索、开搜索即收起筛选
- index.tsx:挂载 ResourceFilterPanel,区域变更转交既有 openResourceBookChild(与栏目切换同一条路径),关键词与搜索浮层共用同一份 searchText
- resourceCanvasFilterModel.ts:resourceFilterIsActive 只看关键词与标签、不含区域——区域就是画布当前栏目,用户换栏时它照样在变,算进高亮会让按钮在「只是换了一栏看」时也常亮
- 新增 resourceFloatingPanelModel.ts 与其测试:资源卡旁「快速编辑」与「生成动画」的互斥规则收在一处并被两个开启入口共用,修掉「先开生成动画再点快速编辑两个浮层并存」的缺口;关闭仍只关自己,生成收尾不会吞掉用户刚打开的另一个面板
- 断言:新增 5 条互斥用例(含两个方向各自一条与来回点击序列),加上既有筛选用例;变异验证实测——把互斥模型的 closed 写成恒为 quick-edit(等价只修一个方向)→ 4 failed,还原后 5 passed
suzmii added 2 commits 2026-09-11 20:43:32 +08:00
- pitfalls.md 新增 2026-09-11 条目,记 lint-staged 只做 eslint 与 prettier、不做 tsc,因此 pre-commit 绿不等于类型正确
- 按体例写现象(第 113 行 TS2322、ref 元素类型不匹配)、原因(typecheck 是在新增该文件之前跑的)、处理(每新增或改名源文件后重跑完整 typecheck,门禁顺序固定为改完 → 全量 typecheck → 定向测试 → 其余门禁)
- 验证写明:修复后 exit 0、修复前 exit 2,而定向测试修复前后都是 22 passed,说明测试通过推不出类型正确;并推广到未使用 import、类型层 props 不匹配、只有 tsc 才发现的 .tsx 泛型推断失败
- 该文件原本结尾没有换行符,追加后上一行必须重写为带换行,因此 diff 记为 11 增 1 删;已逐字核对被删行原样出现在新增行中,实际无内容丢失
驱逐改为可见性优先,条目上限 48→72:图片不再自己消失又回来
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Successful in 3m14s
Project CI / Native shell tests (pull_request) Successful in 19m24s
2ea3bcbb07
用户报「图片会自己消失然后重新加载」。根因是**淘汰完全按 LRU 插入顺序、且完全不看"是否仍然可见"**:缓存条目顺序是「最近一次被请求」的顺序,而停在屏幕上不动的卡片不会产生新的请求 —— 于是恰恰是用户正看着的那几张排在队首被首选淘汰,`disposeCachedPreview` 释放 Blob 后图片凭空消失,再被兜底扫描重新读回来,表现为闪烁。上一轮「驱逐后补一次可见性复核」只是把"掉了不回来"变成"掉了再加载",是治标。

- `projectResourceCardPreviewEvictionIdentities` 新增 `visibleIdentities` 参数,淘汰改两轮:**第一轮只淘汰视口外的条目**(可见卡一律跳过);**第二轮才回退** —— 只有"剩余条目全部仍在视口内且依旧超预算"时才按全表 LRU 淘汰。回退不可省略,否则"可见即永不淘汰"会造成无界内存,该回退由用例钉住。
- `useProjectResourceCardPreviews` 在淘汰前按几何算出可见集合(复用既有的视口档位判据 `viewportBandOfElement <= 1`,经 ref 转发生效,避免定义顺序耦合)。
- `PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT` 由 `48` 提到 `72`。依据是真机实测:该项目「UI 交互」栏目有 **51 张**可预览卡,而原上限 48 **小于一栏的规模** ⇒ 滚满该栏目必然驱逐;取 72 覆盖 51 张并留约 40% 余量,按真机单张均值 591 KB 外推 ≈ **43 MiB**,**仍在既有 64 MiB 字节预算之内**(真机 52 张 blob 合计 29.32 MiB,仅用掉 45.8%)。**字节预算未动,也不是把上限放大到任意大。**

断言(`tests/useProjectResourceCardPreviews.test.ts`):
1. 「可见卡不得成为首选淘汰对象」:最老的 3 张都在屏幕上时,淘汰必须跳过它们、改淘汰视口外的第 4 张;对照用例同时钉住"不给可见信息时退化为纯 LRU";
2. 「全可见且超预算时仍必须淘汰」(防无界内存),条目上限与字节上限两侧各一条;
3. 「51 张整栏零淘汰」:真机栏目规模下 `projectResourceCardPreviewEvictionIdentities` 必须返回空数组,且断言字节侧余量。
另把原先守旧行为的用例改写为守新契约:可见卡被后续加载挤出缓存上限时**必须仍保持 `loaded` 且不产生第二次读取**(不再依赖"掉了再补读")。既有用例一条未放宽。

变异验证:
- 去掉可见性过滤(第一轮不再跳过可见卡)→ 断言 1 所属用例立即失败(`expected [ [ …(2) ], [ …(2) ] ] to have a length of 1 but got 2`,即目标卡被驱逐并重读);
- 去掉"全可见时回退全表 LRU"→ 断言 2 立即失败(`expected [] to deeply equal [ 'item-0' ]`,即缓存无界)。

验证:定向 `useProjectResourceCardPreviews` 31 passed;typecheck exit 0;prettier 干净。(全量 AGC 子集的前后对照见随后的回报。)
suzmii added 1 commit 2026-09-11 20:57:18 +08:00
Merge remote-tracking branch 'origin/master' into feat/agc-canvas-resource-workbench-v3
Project CI / Repository checks (pull_request) Failing after 1m23s
Project CI / Frontend tests (pull_request) Successful in 5m4s
Project CI / Backend tests (pull_request) Successful in 6m33s
Project CI / Native shell tests (pull_request) Successful in 19m45s
3a4f95d7ca
# Conflicts:
#	apps/ai-game-creator-shell/src/App.tsx
suzmii added 3 commits 2026-09-11 21:09:35 +08:00
- 两处 .catch(() => {…}) 没有 message 参数,失败原因被整个吞掉:用户只看到「布局保存失败」「布局读取失败」,排障端也拿不到为什么(revision 越界 / IPC 拒绝 / 权限);
- 写回失败(update_local_project_resource_canvas_layout 的 .catch)与读盘失败(read_local_project_resource_canvas_layout 的 .catch)都补上 error 参数,并落一行 console.warn;
- 新增 describeResourceCanvasLayoutFailure:把 error.message 压成一行、截断 400 字,并复用 errorReporting 的 normalizeDiagnosticText 脱敏(路径 / token / URL / 长 id 打码);
- errorReporting 的 normalizeDiagnosticText 改为导出:脱敏口径只留一份,业务文件不再各写一套正则;
- 用户可见文案一字不改(LayoutNotice 联合类型与既有四条用例的断言原样保留),原因只进日志;日志由客户端已装的 webview→application.log 桥带进诊断日志;
- 新增两条用例:写回失败必须留下原因(并断言可见文案仍是「布局保存失败,已恢复上次布局」)、IPC 拒绝且原因里带本机绝对路径与 token 时必须脱敏成 <path> 且压成一行;
- 变异验证:把写回那处改回吞掉原因 → 写回用例红灯;把读盘那处改回吞掉原因 → 脱敏用例红灯(各自只红对应一条,既有 24 条全绿)。
- 新增一条 2026-09-11 决策记录:冷启动首屏同一张卡被读两次的现象、两条对照证据(eagerPreviewLimit 置 0 同形;hook 整份换回 A/B 之前的 HEAD 版本同样重复)、怀疑方向(observePreview 的注册回声扫描 + 取消 visible 预取后的立刻重扫 + 0/250/1000ms 多延迟点叠加)、未修原因(赶 DDL、非用户可见故障、属门禁与队列时序这条更脆的通路)、影响(首屏请求量比理论最小值多约 20%)与建议的修复断言;
- 记录里写明 A+B 只改「取哪些、按什么顺序」,与这条重复读无关;
- prettier 顺带把该文件整份规范化(改动 2 行缩进 + 2 个空行,属既有旧条目,纯格式无语义)。该文件在 HEAD 上本就不是 prettier-clean(已用 HEAD 副本 prettier --check 实测为 warn),本次一并收口。
修 CI:补跑 AGC src-tauri 的 rustfmt,修正本批遗留的 8 处格式偏差
Project CI / Repository checks (pull_request) Successful in 2m52s
Project CI / Frontend tests (pull_request) Successful in 3m30s
Project CI / Backend tests (pull_request) Successful in 5m44s
Project CI / Native shell tests (pull_request) Successful in 19m31s
15660a98b2
- 只做格式重排(应合成长行/应拆多行),无语义改动
- 命中 agent/direct_project_history.rs、agent/direct_runtime.rs、main.rs、project/conversation.rs、project/conversation/tests.rs
- 分别来自 bfb928295 / 2b44b6259 / 458a65cb5 / f0d5687ab 四笔提交的新增行
- 成因:pre-commit 的 lint-staged 只覆盖 *.{js,mjs,cjs,ts,tsx},Rust 格式本地无守卫
suzmii added 8 commits 2026-09-11 21:39:59 +08:00
- 新增 validate_manifest_schema_version:只接受 GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION,未知版本报「manifest schemaVersion 不受支持:{实际值}(当前支持 {当前值})」
- read_manifest 在解析后立即校验 schemaVersion,与既有的 godotProjectRoot / versions 校验同级;读到未知版本直接拒绝打开,不再被当成已知版本继续使用
- write_manifest_locked 落盘前同样校验,保证本客户端永远不会把未知 schemaVersion 写进项目(该函数是 write_manifest 与 mutate_manifest_at 共用的唯一落盘入口)
- 前向兼容取舍:刻意不做「接受未来版本 + 读时就地升级」——当前并不存在 v2 定义,凭空写一个升级只能把未知数据改写成当前版本的形状,正是本次要修掉的「静默接受」;代价是未来发 v2 时旧客户端明确报错要求升级,而不是把项目按旧结构写回
- project/manifest/import_tests.rs 补 3 条断言:当前版本必须被接受且全字段回读相等(正向断言,挡住「无条件拒绝」这种改法)、未知版本读失败且磁盘文件逐字节未变、未知版本写失败且不落盘
- shared-contracts 的 GameCreationAppManifest 加 #[serde(deny_unknown_fields)],并写明取向与取舍
- 选「失败关闭」而不是「保留未知字段 round-trip」的理由:AGC 读写 manifest 是「整结构体反序列化 + 整结构体重新序列化覆盖落盘」,放行未知顶层字段就等于让「读一次 + 任意一次写」静默抹掉未来版本新增的字段;而 flatten catch-all 只能覆盖加了它的那一层,tasks / assets / versions / preview / commandRuns 内部的未来新增字段照样被抹掉,且写侧的 skip_serializing_if 会同时把已知字段归一化,落盘结果是「新字段原样 + 旧字段被规范化」的混合体,比直接报错更难排查
- 与既有取向同口径:本批新增的资源布局 sidecar 对未知 schema 就是失败关闭,UpdateLocalProjectResourceClassificationInput 也已 deny_unknown_fields
- 影响面已核实:server-rs 内 game_creation_app 模块只被自身引用,不进 /api/external/v1、不进 SpacetimeDB;全仓 GameCreationAppManifest 的反序列化点只有 src-tauri 的 read_manifest 一处;仓库自带 smoke 脚本写的 manifest 只有 schemaVersion/projectId/name/assets 四个已知键
- 断言:shared-contracts 补契约级用例(未知顶层字段反序列化失败且报出字段名、已知字段含可选字段照旧往返);project/manifest/import_tests.rs 补消费侧用例(纯读报错、读+写入口 mutate_manifest_at 也报错、两次失败后磁盘文件逐字节未变即未知字段没被抹掉)
- read_local_project_resource_canvas_layout 补 enforce_project_auto_permission_policy(root, "asset.list"),与紧邻的 read_local_project_resource_graph 完全同口径
- update_local_project_resource_canvas_layout 补 enforce_project_permission_policy(root, "asset.register"),与本文件 update_local_project_resource_classification / delete_local_project_asset / rename_local_project_asset 同口径
- 权限位依据:读路径是同一块资源画布的渲染读路径,邻居用 asset.list;写路径是「改动项目内资源相关持久化数据」,manifest 侧的分类写入本身就用 acquire_project_write_lock(root, "asset.register"),故沿用同一权限位,不新造权限名
- 读用 auto、写用普通门是刻意的:asset.list 是 Auto,auto 口径默认放行,只有策略显式 deny/confirm 才拒绝(读路径没有可插入的确认交互,要求确认等同拒绝);asset.register 默认是 Confirm,若写路径也用 auto 会直接打死默认路径
- tests/project.rs 补断言:默认策略下读与写都放行;deny asset.list 读失败、confirm asset.list 也读失败(钉住 auto 口径这一选择);deny asset.register 时读仍放行、写被拒且被拒的写不留任何落盘副作用(断言 sidecar revision 未变);恢复默认策略后写入照旧成功
- update_manifest_asset_classification_at 在 manifest 写成功后追加 recordType=asset.classification.update 的审计,复用既有 append_agent_db_record 惯例(照 assets.rs 的 asset.register / asset.update:manifest 写成功后追加)
- 字段:assetId(沿用 assets.rs 的 assetId)、expectedProjectRevision(本次写入实际校验的 CAS 基准 revision,沿用分类输入契约里的同名参数)、previousCategory / previousTags(变更前值)、category / tags(变更后值)
- 不记 localPath / kind / mediaType / source:本记录针对的是已有 assetId 的分类变更,这些字段并未改变,且能从 manifest 或既有 asset.register 记录追到,多记一份会在改名后产生互相矛盾的审计
- 不用 projectRevision 这个名字:既有 agent.runtime.action_receipt 里 projectRevision 的语义是「动作完成后的 revision」,本条审计追加在 revision 推进之前,沿用同名会指代不一致;审计是持久化数据,故在新记录类型里另起不冲突的名字,不动既有记录格式约定
- 位置放在推进 revision 之前是刻意的:分类已经真实落盘,审计不能因为紧随其后的 revision 推进失败而缺失,否则「改过但查不到」正是这条缺陷;推进失败仍然照旧报错
- 审计追加失败按既有惯例映射为可见错误「资源分类已写入,但审计记录失败:{error}」,不静默吞掉
- project/manifest/classification_tests.rs 补 3 条断言:成功写入恰好一条且前后值正确(含第二次写入的 previous 必须取第一次的落盘值)、四类被拒写入不产生任何审计、审计写失败时错误可见且不留假审计(同时确认分类本身已落盘、不回滚)
- 新增 scripts/lint-staged-rustfmt.mjs:对 server-rs 与 apps/ai-game-creator-shell/src-tauri 两个 workspace 跑 cargo fmt --all --manifest-path <m> -- --check,只查不改
- 为什么需要包装脚本:lint-staged 会把命中的暂存文件路径追加到命令末尾,而 cargo fmt 只按 workspace 粒度格式化、不接受文件参数,直接写成 npm run check:rustfmt 会被多余参数打断;脚本因此忽略 argv,并按 workspace 逐个检查
- package.json 的 lint-staged 配置新增 "*.rs": ["node scripts/lint-staged-rustfmt.mjs"]
- 成因:原配置只覆盖 *.{js,mjs,cjs,ts,tsx},Rust 格式在本地完全没有守卫,唯一防线是 CI 的 check:rustfmt,本 PR 已因此红过一次(见 15660a98b)
- 已实测:把包装脚本直接跑一遍 exit 0;lint-staged 分派层面确认 "*.rs" 任务会被真的触发(对 6 个 .rs 文件跑通);脚本本身过 eslint 与 prettier --check
- index.tsx 选中工具条的引用按钮:title 由「@引用」改为「引用」,可见文案 <span>@引用</span> 改为 <span>引用</span>。图标 AtSign 本身就是 `@`,文案再写一次就渲染成「@ @引用」。
- label={`引用资源 ${selectedResource.label}`} 保持不变:无障碍名仍要能读出「引用资源 <素材名>」,本次只去掉可见文案与 title 里的 `@`。
- 断言(appSurface/project-development.suite.ts):静态形状用例改判 title="引用" / <span>引用</span>,并新增 not.toContain('@引用') 反向守卫;键盘通路用例改判 title === '引用'、可见标签 .genarrative-image-canvas__chrome-button-label 文本为「引用」,并新增 aria-label === '引用资源 hero.png',钉住无障碍名没有被一起去掉。
- 变异验证(提交前已跑):把 index.tsx 的 title 与可见文案一起改回「@引用」→ appSurface 两条用例变红(资源卡的 @ 引用入口只留在选中工具条里 / 工具条里的「引用」用键盘也能插进聊天输入框),还原后复跑 412 passed。
- 门禁:`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts` 412 passed(改动前后同一时刻对照,均为 412 passed)。
- 原缺陷:预览 Hook 的预取作用域 key 缺省退化为"被喂进来的资源集合签名",而宿主按设计要求恒传**全量投影**(传全量才不会让总览里其它栏目只剩栏目标题栏),换栏目/换视图一字不改 ⇒ key 不变 ⇒ 取消上一视图排队预取的 effect 永不执行,"同级裁决"一并退化为 no-op。已有用例用"改 resources"模拟切视图,测的是生产不存在的调用方形状。
- resourceCanvasController.ts 新增 createResourceCanvasPrefetchScopeKey({ view, target, mode }):由视图身份(main / child:<栏目>)加排序模式算出标识。
- index.tsx 在资源画布处显式算出该标识并传给预览 Hook(栏目取 resourceBookState.view / resourceBookState.category,排序取 sortMode)。
- useProjectResourceCardPreviews.ts 把 prefetchScopeKey 由可选改为**必填**并删掉那段兜底:它能"看起来还在工作",正是它掩盖了漏接线;现在漏传是类型错误而不是静默少取消一次。
- 断言:resourceCanvasController.test.ts 新增标识派生两组用例(换栏目/换视图/换排序模式必须变,同一视图必须稳定);useProjectResourceCardPreviews.test.ts 改写两条既有切视图用例为生产形状(resources 恒为全量、只改视图标识与 eagerResources),并在用例内前置断言"换栏目必须换标识";其余既有用例经用例侧包装补齐标识,形状与生产一致,断言一条未放宽。
- 变异验证(提交前已跑):把 createResourceCanvasPrefetchScopeKey 的栏目分量去掉(只留 view + mode)→ 3 条用例变红(标识派生 / 取消上一视图排队预取 / 取消后重新可见再入队),还原后复跑 34 passed。
- 门禁:`npx vitest run` 上述两文件 34 passed;typecheck 见随后整轮。
素材上传不再每次触发一次拒收提示:上传与清单配对读放进同一个函数
Project CI / Repository checks (pull_request) Failing after 1m31s
Project CI / Frontend tests (pull_request) Successful in 3m46s
Project CI / Backend tests (pull_request) Successful in 8m21s
Project CI / Native shell tests (pull_request) Successful in 19m10s
006e9cc2f3
- 原缺陷:上传路径在上传**前**读 get_local_game_project_revision,上传完把那个**旧 revision** 交给 reloadManifestAfterAssetCommand;后者重新读盘拿**新清单**再打上旧 revision。上传本身会推进项目 revision(Rust advance_agent_runtime_project_revision_locked),于是这一对是撕裂的,「同 revision 不同指纹」被 projectResourceLiveUpdateModel 判成 revision-conflict —— 用户每上传一次素材就先看到一条「资源清单更新被拒收…」。
- projectResourceLiveUpdateModel.ts 新增 uploadProjectAssetFilesAndReadSnapshot:上传动作与"配对读"由同一个函数拥有,配对读复用既有 rereadAuthoritativeProjectManifestSnapshot(同一次读里配对 revision + 清单,并再读一次 revision 确认没被写盘插队)。调用方拿不到中间那个 revision,也就没有机会把它贴错。
- index.tsx 的 uploadResourcePanelFiles 改用它,删掉上传前那次 revision 读取与旧 revision 的传递。
- 附带文案误报:describeProjectManifestMergeRejection 的 unresolved 文案由「重新读取磁盘清单失败,请重新打开项目」改为「未能按磁盘清单重新对齐,请重新打开项目」。unresolved 有三条进入路径,其中两条**重读是成功的**(读到的一对比手上旧、或读到的是撕裂的一对),写成"读盘失败"是误报;新文案对所有路径都成立。WorkspaceLauncher.tsx 在"读到了但不采用"那一支补注释说明 stage 不是读失败。
- 断言:projectResourceLiveUpdateModel.test.ts 新增「素材上传后的清单快照必须配对」——假后端在上传时推进 revision,断言 merge 判 accepted 且 projectManifestMergeRejectionDecision 为 null(即"上传一次不产生拒收提示"),并附一条对照钉子显式写出旧形状、断言它必被判 revision-conflict;另新增 unresolved 文案不得包含"读取磁盘清单失败"的用例。
- 变异验证(提交前已跑):把 uploadProjectAssetFilesAndReadSnapshot 还原成"上传前读 revision + 上传后读清单"→ 配对用例变红(并给出 revision-conflict 的失败信息),还原后复跑 15 passed。
- 门禁:projectResourceLiveUpdateModel 15 passed、workspaceLauncherManifestMerge 5 passed。
suzmii added 14 commits 2026-09-12 00:04:56 +08:00
- 原缺陷(3082c3e85 的连带副作用):`kind:"UI"` 的资产投影分类由「待归类」修到「UI 交互」后,它们落盘的坐标 section 仍是现行栏目值 `unclassified`,于是 resolveResourceCanvasSection 走「现行栏目值 != 资源当前分类 ⇒ null」直接判丢弃,8 条用户手摆的坐标没能跟过来、被自动排布重算。
- resourceCanvasSectionMapping.ts:现行栏目值改为返回**资源当前分类**("这张卡属于哪一栏"的唯一真源本来就是 resource.category,协调时也按它分组),与旧栏目归并支路同一条语义 —— 只改写 section,x / y / manuallyPlaced 原样保留。判据:坐标是栏目内局部坐标(reconcileResourceCanvasLayout 先按 section 分组再算栏目带原点),所以换栏目保留 x / y 得到的正是"这张卡在新栏目里的同一个槽位";而"丢弃 + 自动重排"会连 manuallyPlaced 一起抹掉,是用户可见的损失。
- 判断与理由:**保坐标重写 section 可行**。同一函数对更难的旧栏目 case(旧 `art` 可能归并到 4 个不同栏目)已经在做同样的事,对 `unclassified → ui-interaction` 反而更窄;拒绝它是自相矛盾。
- 丢弃口径收窄:resolveResourceCanvasSection 只在**确实无从归并**时返回 null —— 无法识别的分区值,以及旧栏目归并目标集合不含资源当前分类(如旧 `code` 遇到已归为 `document` 的资源)。useProjectResourceCanvasLayout.ts 的 droppedSectionMismatch 文档与读盘统计注释按此更新(用户可见文案未变,它对新口径仍然成立)。
- 断言:resourceCanvasSectionMapping.test.ts 把原「沿用既有行为丢弃该坐标」用例改写为守新契约(归并 + changed=true),并新增 x / y / manuallyPlaced 逐项不变用例;resourceCanvasLayoutModel.test.ts 原「drops a persisted position…」改写为「rewrites … keeping the coordinates」;useProjectResourceCanvasLayout.test.ts 的读盘统计由 normalized 1 / droppedSectionMismatch 1 改为 normalized 2 / droppedSectionMismatch 0,并新增"归并必须连坐标一起保住"的断言,另补一条用例钉住 droppedSectionMismatch 只剩"无从归并"两类来源;appSurface/project-development.suite.ts 的提示文案与 data-* 计数同步,并新增写盘坐标断言。
- 变异验证(提交前已跑):把 resolveResourceCanvasSection 还原成 `section === resourceCategory ? section : null` → 6 条用例变红(映射 2 条、布局模型 1 条、读盘统计 2 条、appSurface 1 条),还原后复跑 83 passed + appSurface 该条 passed。
- 门禁:resourceCanvasSectionMapping 9 passed、resourceCanvasLayoutModel 47 passed、useProjectResourceCanvasLayout 27 passed。
- 删掉 publishPreview 里"驱逐后立刻补一次可见性扫描"(原 useProjectResourceCardPreviews.ts 的 `if (evictedCount > 0) sweepVisiblePreviews()`)。它原先的注释宣称"递归深度恒为 1、不自激",该不变量是假的,判据有三条:(1) 触发条件是"本轮驱逐了任意条目",而第一轮只淘汰视口外条目,那种情况下根本没有可见卡丢状态,补扫却照样把空闲且可见的卡重新入队;(2) 真正驱逐到可见卡时(全表都在视口内且仍超预算,第二轮全表 LRU 回退)补扫把刚被驱逐的卡重新入队 → 读回来又超预算 → 再驱逐 → 再补扫,每个周期跨一次异步读取,是不收敛的回路;(3) 那种回读换不来稳定结果,读回的卡立刻被下一轮 LRU 淘汰(第二轮回退是全表 LRU,刚读回的卡恰好最新),只在可见集合里轮转。因此正确反应是接受这次有界淘汰(由「全可见且超预算时仍必须淘汰」钉住),而不是反复重读;代码注释按这三条重写,不留误导后人的陈述。
- 顺带删掉只为那段补扫存在的 `evictedCount`(否则是死变量)。
- 断言:新增 useProjectResourceCardPreviews.test.ts「settles instead of re-reading evicted visible cards forever」——全部登记且都在视口内(第一轮无从下手),断言读取次数在观察窗口内不再增长、且缓存稳定在上限。
- 变异验证(提交前已跑):把补扫加回去 → 该用例**不是断言变红而是 worker 被撑爆**(`ERR_WORKER_OUT_OF_MEMORY`,约 74 秒后终止),即回路真的不收敛;还原后复跑 32 passed,该用例 2.1s 通过。
- 超时预算改为"标注并给出实测依据",不再用放宽时间门吸收负载代价:`releases Blob URLs while continuously browsing beyond the cache limit` 的 `}, 30000)` 保留,并在用例前写明理由 —— 它逐个 waitFor 等 79 张卡串行读完,轮数由条目上限 48→72 机械地从 55 涨到 79(+44%),实测 6120 ms,已超 vitest 默认 5s,属结构性必需。同一提交里的 `waitFor({ timeout: 30000 })`(由 20000 顺手放大)按实测**回到 20000** 并写明依据(该用例实测 124 ms,约 160 倍余量)。
- 门禁:useProjectResourceCardPreviews 32 passed。
- styles.css 的 .game-resource-book-main-grid 增加 width: 100%:父级 .game-resource-book-main-world 是列向 flex 容器,交叉轴 auto 外边距(margin: 0 auto)会吃掉自由空间并取消 stretch,使该网格按 fit-content 定宽;fit-content 定宽时网格 inline size 在内在尺寸阶段不定,按 CSS Grid 规范 auto-fit 的重复次数解析为 1,于是 2560x910 窗口下 grid 宽只有 432px、getComputedStyle(grid).gridTemplateColumns 只有 1 个轨道、8 张栏目缩略卡竖向排成一列(top 步距 230)
- 同款修 .game-resource-book-main-heading:它在同一个 flex 列里带 margin:auto,同样退化成 fit-content,标题栏只占 <h2> 宽度且 space-between 无从展开
- 两处都补注释写明「width:100% 是 auto-fit 按 max-width 解析列数的前提」,防止后人当作冗余声明删掉
- 修复后 auto-fit 按 max-width:1120px 解析重复次数:3x280+2x18=876 <= 1120、4x280+3x18=1174 > 1120,即 3 列
- 真机判据:gridTemplateColumns 应给出 3 个非 0 轨道(约 361.33px)、grid 宽 432 -> 1120、8 张缩略卡 top 只有 3 个取值;且必须在总览态核对(栏目态该层带 is-background,opacity 为 0,是背景层)
- 未改动任何既有断言;AGC vitest 不加载 styles.css(无 css: true),声明级断言随下一笔提交补进 appSurface 用例
- 「当前已完成」清单里「资源画布素材卡提供 `@引用`」既不准确也不再是当前形态:入口已从卡片挪到选中工具条,且可见文案与 title 都不再自带 `@`(`@` 由 `AtSign` 图标承担,文案再写一次会渲染成「@ @引用」)。
- 按「四不写」把该条直接改写成当前状态:入口位置(选中工具条)、图标与文案口径(图标 @ + 文案「引用」)、以及插入对话里仍然是 `@素材名` 芯片。
- 纯文档,无代码改动;单独提交便于 cherry-pick。
- `prefetches only cards that are actually in view, and still fills the first screen` 的 `}, 30000)` 收回(回到 vitest 默认 5 s):实测 93 ms,约 54 倍余量,而且真正的时间门是内层 `waitFor` 自带的 1 s。cfd89ba57 那次只是给负载留余量,没有对应的结构性成本 —— 对照 `releases Blob URLs while continuously browsing beyond the cache limit`(79 轮串行读盘、实测 6120 ms、超默认 5 s)那条的预算属结构性必需,保持不动。
- `keeps a visible card cached when later loads push the cache over its limit` 的 `}, 60000)` **保留**并写明理由:它的职责是挂起护栏,必须大于内层 `waitFor` 的 20 s 断言预算,否则内层断言自己的失败信息永远不出现(vitest 会先在测试超时处中止,只剩笼统的 "test timed out")。用例实测 120 ms,20 s 与 60 s 都不是耗时预期;硬收会让内层预算变成不可达配置,是更差的诊断而不是更紧的门禁。
- 无功能改动。
- `pitfalls.md` 新增「素材上传不要把『上传前读到的 revision』贴到『上传后读到的清单』上」:现象(每次上传都先吃一条拒收提示)、根因(上传本身推进 revision,错配必然发生而非竞态)、处理(上传动作与配对读收进同一个函数,调用方拿不到中间那个 revision,也就没机会贴错)、同类文案坑(`unresolved` 三条进入路径里有两条重读是**成功**的,写成"读取磁盘清单失败"就是误报)、验证与变异,并记录关联文件与提交。
- 覆盖边界(按当下口径写清,防止后人误读):断言打在生产函数 `uploadProjectAssetFilesAndReadSnapshot` 上 —— 它同时拥有上传与配对读,所以配对语义是真测的;但 AGC 侧**没有**「资源面板上传 → 拒收提示条」的端到端 UI 用例(现有 harness 只有聊天入口的 `/asset.upload`,资源面板 file input 无用例),因此「界面上不再出现拒收提示」是推理结论而非端到端断言,禁止当成端到端覆盖引用。
- 同一条边界同时写进 `projectResourceLiveUpdateModel.test.ts` 用例组头部,随断言一起走,避免只看测试文件的人误读。
- 纯文档 + 测试注释,无功能改动。
- 隔离探针实测(codex-cli 0.147.0,AGC 捆绑版本):experimentalRawEvents 线程收到 thread/inject_items 后会逐条原样回显 rawResponseItem/completed —— 注入一个 5 MiB 的 item,stdout 就回一条 5 243 245 字节的单行;而读侧上限原为 4 MiB,于是「我们注入得进去」却「我们读不回来」,报错方向指向 app-server,实际是我方读行判死
- codex_app_server.rs 的 GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES 由 4 MiB 提到 32 MiB,注明它同时充当 stdout 读侧上限与 stdin 写侧守卫;取值依据写进注释(单张图 base64 上限 10 MiB、单次图片总量 16 MiB 折 base64 约 21.3 MiB,再加 JSON 信封),再大就失去内存/DoS 边界的意义
- 新增写侧守卫 game_creator_codex_app_server_message_oversize_error:write_message 超限即失败关闭并给出字节数,绝不写出自己读不回来的行(两侧共用同一常量即这条不变量)
- 新增 DirectProject 历史注入前置校验 direct_project_history_injection_oversize_error:单条 item(扣掉 rawResponseItem/completed 回显信封余量)与整份载荷都必须落在上限内;超限指名 itemId/type/字节数并失败关闭,且不截断、不摘要、不改写历史(技术方案末节口径)
- 新增专属前缀 DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX,供 direct_runtime 判可重试性与给专属提示
- direct_runtime.rs:direct_codex_failure_is_retryable 把注入超限改为不可重试(同一份历史每次读结论相同,且本轮用户消息已先追加进同一文件、载荷只会更大);direct_codex_failure_recovery_hint 与 direct_codex_failure_public_summary 各补一条专属文案,用户不再看到笼统的「Codex 未完成本轮代码修改,请检查运行时配置后重试(可直接重试)」
- 新增 2 条用例:写侧守卫与读侧共用同一上限的边界(等于上限必须放行、上限+1 必须拒绝)、注入侧整份载荷与单条 item 双超限必须拒绝
- 变异验证:把写侧守卫改成 >=、把单条上限放成 usize::MAX,两条用例各在自己那一处变红;还原后逐字节哈希一致并复跑绿灯
- 门禁:npm run check:rustfmt exit 0;npm run typecheck exit 0;cargo test 定向 2 passed(2257 filtered out)
- 新增用例「keeps the resource overview grid off fit-content so its columns stay responsive」:断言 .game-resource-book-main-grid 同时声明 width: 100% / max-width: 1120px / margin: 0 auto / repeat(auto-fit …),并同款断言 .game-resource-book-main-heading 的 width: 100%
- 取声明体前先剥掉 CSS 注释:本次修复的注释里就写着 width: 100% 等字样,不剥掉会让断言匹配到注释而假绿(删掉真正的声明也照样通过)
- 注释里写明这条断言**验不到布局**:AGC 的 vitest 没开 css: true、styles.css 不会被加载、jsdom 也没有布局引擎,gridTemplateColumns 的解析结果与缩略卡列数只能在真机量;真机判据(3 个非 0 轨道 / grid 宽 432→1120 / 8 张缩略卡 top 只有 3 个取值,且需在总览态取样)写在用例注释里
- 变异验证:临时删掉 .game-resource-book-main-grid 的 width: 100% → 用例变红且失败信息打印的是真实声明体(证明注释剥离生效);还原后 styles.css 逐字节哈希与已提交版本一致,复跑绿灯
- 门禁:npm run typecheck exit 0;npm run check:encoding exit 0(4399 files);npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts 本用例 1 passed
- 新增「Codex app-server 会 1:1 回显注入内容:读侧上限不得小于写侧允许量」:记录隔离探针实测(捆绑 codex-cli 0.147.0)——注入 5 MiB 的 item,app-server 接受并回一条 5 243 245 字节的 rawResponseItem/completed 单行,而读侧上限当时 4 MiB、写侧无上限,于是合法的注入被读行判成连接级故障,报错方向指向 app-server;同时记下 stderrBytes=405 那类摘要不能当证据(正常启动就有 275/452/546/5996 B 的插件/别名警告)、experimentalRawEvents 需要 initialize.capabilities.experimentalApi、以及探针必须用管道 stdin 与按 CommandLine 精确清理
- 新增「「掉了就补读」的补扫不是修复:自激回路会一路耗到内存」:明确标注**由并行工作线报告、本线未独立复核**,并给出取证据前不得引用的警示与“去掉补扫症状是否回来”的判据
- 门禁:npm run check:encoding exit 0(4399 files)
- 记录 28781a420 的事故形态:该笔除我新增的用例(@@ -1260,6 +1260,51 @@)外,还混入了 peer 对「所有资源」测试期望的在途改动(@@ -9562,37 +9607,83 @@),提交描述与实际内容不符,且对方那份文件随即显示为干净、容易被误判为已收口;内容未丢
- 写清正确护栏:对可能被他人同时编辑的文件,把 git add <path> + git diff --cached(看内容、不只看文件名)+ git commit 压进同一条命令,并逐 hunk 确认每一个都是自己这次要写的;发现混入他人 hunk 后保持现状并立即上报,不在共享树里用 --amend/reset/checkout --/revert 做手术
- 写清状态信号:M (已暂存且工作区与索引一致)/ MM(add 后又改过)/  M(仅工作区),并点明「M 只能说明 add 之后没人再动过,不能说明这份文件里只有我的改动」
- 写清同类隐患:pre-commit 的 lint-staged 会对它格式化过的文件做 git add(prettier --write 之后 Applying modifications from tasks),与 npx lint-staged --diff=… 属同类反向 git add
- 与前三类索引事故(git add -A 卷走别人的文件 / stash pop 弹出别人的 stash / lint-staged --diff 反向 add)并列,指明本类正是「按文件名校验」这道护栏的失效点
- 门禁:npm run check:encoding exit 0(4399 file(s))
- 新增 resourceBookShowsAllCategories:把"子画布里的 all"这一态判定收在 resourceBookModel 一处
- resourceBookCategoryCardPresentation:展开态下真实栏目返回 child(卡片仍由各栏目组承载,每张资源只有一个宿主)、all 自己返回 null(只出标题栏与全量计数)
- 新增纯函数 buildResourceBookAllLayout:按栏目顺序纵向排"分带",用各栏目可见资源的 extent 定带宽高;卡片世界坐标 = 栏目内局部坐标 + 带原点
- 新增 resourceBookAllTitlebarRects:分带 → 栏目标题栏矩形,让展开态复用总览态"按矩形钉标题栏"的同一条渲染路径(ResourceBookScene 不需要知道"展开态"这个概念)
- 新增 resourceBookAllBandLocalPoint:展开态世界坐标 → 栏目内局部坐标,这是落盘前唯一的换算(减带原点)
- buildResourceBookScenePlan:展开态下 all 只出标题栏(钉在视口 + 全量计数),真实栏目按带偏移铺满 child 卡;真实栏目这一支仍按 presentation === child 取卡,"能出卡的判定"只有一处
- buildResourceBookScenePlan:返回总览的淡出改为按"刚离开的那段子画布"判定(exitingCategory 传 all 时覆盖所有栏目),并用带偏移位置淡出,否则返回动画会跳位置
- buildResourceBookScenePlan:「所有资源」组不再接 child / exiting 卡,避免同一张卡在两组里各挂一份(DOM 查询与 FLIP 键撞车)
- index.tsx:删掉独立网格页组件、其 host 块与 resourceBookAllResourcesSections memo,展开态整体交给画本场景
- index.tsx:新增内存态 resourceBookAllViewport(不落盘、不进按真实栏目键的 resourceCanvasViewports),滚轮 / 缩放 / 适应 / 平移 / 框选都作用到它
- index.tsx:分带几何按"项目 + 排序 + 可见资源集"冻结(签名 + ref 短路;签名不含 state.token——返回总览时 token 也会变,含它会在淡出期间重算),拖动不重算带高,避免"拖上面一带让后面所有带整体位移"
- index.tsx:拖动放开——展开态下每张卡都能拖,section 取 resource.category(栏目页仍只拖当前分页栏目)
- index.tsx:提交前用 resourceBookAllBandLocalPoint 把世界坐标减回带原点,写回的仍是同一 mode 同一 sidecar 的同一 section(.agent/workbench/resource-layouts/{dependency,type}.json),没有第二份空间,不改契约与 schema
- index.tsx:renderResourceBookCard 去掉 options.draggable:展开态已可拖,这个"只读"开关没有调用方了
- 测试:resourceBookModel.test 钉住展开态 presentation 与"栏目页仍是原判据";resourceBookLayout.test 退役旧分组函数用例,新增分带几何 / 标题栏矩形 / 减带原点换算 / 展开态计划(含"每张资源只有一个宿主"与按带位置淡出)断言
- 测试:appSurface 两处「所有资源」判据从旧网格宿主改为画本场景(.game-resource-book-scene-card[data-resource-book-category] 与 .is-active[data-resource-book-category="all"]),并断言旧网格容器 / 宿主一个都不再存在
- 删除 .game-resource-all-resources-host:整页 absoulte 层与自己的底纹(原本是为了盖住画本场景底纹、并躲开画布容器的滚动处理)
- 删除 .game-resource-all-scroll:分组网格的自身滚动容器
- 删除 .game-resource-all-section:按栏目分组的分节卡片(边框 / 圆角 / 半透明底)
- 删除 .game-resource-all-grid:repeat(auto-fill, 180px) 卡片网格
- 删除 .game-resource-all-card:网格里的卡片占位格
- 删除 .game-resource-all-grid .game-resource-card:只读页的光标与 touch-action 覆盖(展开态已可拖,卡片要保留画布那套 touch-action: none)
- 不留任何 all 专属替代规则:展开态的外观全部由既有的 .game-resource-book-scene-card / .game-resource-card 与共享规则给出("零新增 CSS"是本次红线)
- decision-log 新增「各栏目按分带铺在同一张画布上」:记分带几何(buildResourceBookAllLayout + resourceBookAllTitlebarRects 复用总览标题栏渲染路径)与冻结口径(只在项目/排序/可见资源集变化时重算,拖动不重算;签名不含 token 的理由),以及本轮不动的三处"仍是另一套"(依赖连线 / 空态分带 / 总览缩略卡网格)与它们的后续顺序
- decision-log 新增「两页共用同一份布局数据」:记裁决(不开独立空间、写回同一 mode 同一 sidecar 的同一 section、提交前减带原点)、"为什么不会写偏"的依据、与"历史手动坐标只读恢复/不删除/不重置/不迁移"不冲突的理由,以及用户可自证的往返判据
- 验收文档 S7 行判据改写:旧判据 data-resource-book-all-page / data-resource-book-all-host 已随网格分支删除,改钉 .game-resource-book-scene-card[data-resource-book-category](每张资源恰好一个宿主)、.is-active[data-resource-book-category="all"] 与 .game-resource-book-view;补上"展开态可拖且在栏目页看到同一位置"这条往返判据
- 验收文档 S7 期望结果同步:由"汇总页"改为"与栏目页同一套画本场景、各栏目按竖向分带铺开"
展开态补回空态提示:复用栏目页同一个 .game-resource-page-empty 与文案,不新增样式
Project CI / Repository checks (pull_request) Failing after 1m18s
Project CI / Frontend tests (pull_request) Successful in 3m29s
Project CI / Backend tests (pull_request) Successful in 6m42s
Project CI / Native shell tests (pull_request) Successful in 17m44s
f11942f357
- index.tsx:展开态在"可见资源为 0"时渲染 <p class="game-resource-page-empty">没有匹配资源</p>,与栏目页那一条同一条判据口径与同一句文案
- 它挂在既有的画布浮层 host 下(该 host 自身不定位),因此按画本场景根居中,观感与栏目页一致,不需要任何新 CSS
- 测试:空项目的「所有资源」用例补三条断言——进入展开态后标题栏是 .is-active[data-resource-book-category="all"]、卡片宿主数为 0、空态文案是「没有匹配资源」(把"复用既有空态、不新增样式"变成可执行判据)
suzmii added 5 commits 2026-09-12 01:15:14 +08:00
- direct_project_history.rs:幂等回扫与序列化移到取锁之前,取锁后常态只做一次追加,不再在锁内读完并逐行解析整份历史
- direct_project_history.rs:回扫到取锁之间若历史文件变了(len/mtime 与回扫时不一致)才回到锁内重扫一次;同一 id 不写第二行的语义不放宽,只是常态不再为它读整份文件
- direct_project_history.rs:新增 direct_project_history_duplicate_at 三态判定(Absent/Identical/Conflict)与回扫状态快照,对外行为与原实现一致(幂等 no-op、id 冲突失败关闭)
- direct_project_history.rs:新增回扫探针(两个时点回调)与三条用例:回扫在 append 锁外、锁外回扫后并发追加仍不重复写、id 冲突仍失败关闭
- project/agent_db.rs:新增测试专用探针 project_append_os_lock_is_held_for_test,用与生产同一套打开方式判断追加写目标的锁此刻是否被持有
- 变异验证:把回扫塞回锁内后 idempotency_reverse_scan_runs_outside_the_append_lock 变红(exit 101),报出「幂等回扫必须发生在 append 锁外」,验证后已还原
- project/agent_db.rs:Windows 取锁成功路径不再在"已持有零共享句柄"时做带提权的 ACL 修复;持锁期间只做不提权的严格校验,校验不过就先释放句柄、再提权修复,并返回"这轮没取到锁"由外层重试循环按修复后的 DACL 重新打开
- project/agent_db.rs:新增 release_project_append_os_lock_then_repair_acl,参数按值接收锁句柄且函数体第一件事是 drop,使"边持锁边等 UAC"在签名层面无法被表达
- 原因:提权修复走 powershell Start-Process -Verb RunAs -Wait 同步等用户点 UAC,在持锁期间等它等于把用户犹豫的时间记进别人的持锁窗口;只有 Windows 存在这条路径
- 判据(结构级):提权修复只在此一处被调用,且调用点必须交出句柄所有权;测试环境不弹真 UAC(config.rs 测试分支本地收紧),故不写假 UAC 用例
- 真机判据:DACL 有缺陷的机器上,持锁方进入修复期间不再持锁,等待方从"超时"变为可成功
- 不变项:首次创建锁文件的本地 harden 仍留在持锁期间(本进程新建对象不因继承 DACL 自动提权)
- scripts/git-hooks.test.mjs 用 assert.deepEqual 钉住 package.json 的 lint-staged 整份配置形状,dd7cf401a 新增 *.rs 键时漏了同步,Repository checks(经 npm run lint → check:git-hooks)deepStrictEqual 失败
- 按新形态补上 '*.rs': ['node scripts/lint-staged-rustfmt.mjs'],不放宽断言:仍是整份配置的 deepStrictEqual,改回「只要含 js 那条就算过」这类弱化写法一律不做
- 第 2 个用例里的本地 lintStagedConfig 是 temp repo 的测试替身,不是对真实配置的期望:temp repo 里没有 Rust 文件,加 *.rs 只会命中 0 个文件,故不动
- 变异验证:把 *.rs 从 package.json 摘掉 → 本用例以同样的 deepStrictEqual operator 变红(# fail 2);还原(package.json 字节级哈希一致)后复跑该用例回到 ok
- 新增 2026-09-11 条目:背景(本地无 Rust 守卫、CI check:rustfmt 是唯一防线)、决策(lint-staged 增 *.rs + 包装脚本对两个 workspace 只查不改)、连带维护点(git-hooks.test.mjs 钉住整份配置形状,增键必须同步)
- 记下刻意保留的取舍:无暂存 .rs 时不触发;一旦暂存 .rs 则按整个 workspace 检查(与 CI 同口径),副作用是别人的未暂存未格式化 .rs 会挡住本次提交
- 记下未修的本地限制:check:git-hooks 第 2 个用例在 Windows 因 finally 里 rmSync 报 EBUSY 而红(断言全过、红在清理),TEMP 里堆了 16 个残留目录最早到 2026-09-05,加删除重试实测无效,CI 在 Linux 上不受影响
- 不动历史条目:2026-08-12 那条的「提交门禁」一行按本文件顶部口径只用于追溯
append 锁:预算与项目写锁对齐、超时报可诊断的持锁方、争用做有界退避重试
Project CI / Repository checks (pull_request) Failing after 3m22s
Project CI / Frontend tests (pull_request) Successful in 3m46s
Project CI / Backend tests (pull_request) Successful in 6m30s
Project CI / Native shell tests (pull_request) Successful in 17m14s
9fea60b850
- project/agent_db.rs:等待预算从 10ms×100≈1s 改为 5ms×2000≈10s,与项目写锁完整窗口同口径;新增 lock_short(5ms×200≈1s)供只读路径使用
- project/agent_db.rs:超时文案在锁路径后追加持锁方线索(读锁文件里的诊断元数据,读不到就明说不可读),现场不再只拿到一句"检查运行时配置"
- project/agent_db.rs:取锁成功后把 label/pid/processStartedAt/acquiredAt 写进锁文件;仅作诊断,不参与判活、回收或抢占,同一进程重复取同一把锁不重写
- project/agent_db.rs:抽出 PROJECT_APPEND_LOCK_TIMEOUT_MARKER 常量,控制流不再各自复制中文
- project/conversation.rs:整份读对话记录的两处只读入口改用短窗口,避免把面板读路径一起拖住
- agent/direct_project_history.rs:争用类失败做一次有界退避重试(250ms);格式类失败不重试
- agent/direct_runtime.rs:锁争用给可操作提示(另一个客户端进程正在读写该项目历史或项目锁,请稍后重试,确认没有其它客户端再重启),retryable 保持 true,但现在确实会自动重试
- 用例:预算口径、锁文件诊断元数据、持锁方线索、被独占持有时超时且不留残留、争用重试成功、超出预算失败关闭、格式类不重试
- 变异验证:预算常量改回 1s → 预算用例红(left 10ms / right 5ms);去掉重试 → 重试用例红(append after retry 直接报跨进程锁超时);验证后已还原
suzmii added 2 commits 2026-09-12 01:36:08 +08:00
- 取舍 bullet 补裁定理由:保持「整个 workspace」检查、不换成只查暂存文件(rustfmt --check --skip-children 与 cargo fmt 的口径不一致);本批两次 CI 红的共同根因是「本地绿 ≠ CI 绿」,守卫必须与 CI 同口径
- 取舍 bullet 补流程归因:共树里的硌人是「共享 worktree」的症状、不是守卫的问题,对应流程修正是「一条线一个 worktree」
- 本地限制 bullet 定性为「本机环境限制,不是 CI 会红」,写明根因(Windows/WSL 跨 /mnt/c 的临时目录句柄在子进程退出后仍被持有)、删除重试实测无效且已还原、残留目录会累积(清前 16 个、最早 2026-09-05,本轮已清 0),并给出「Linux CI 上通过」的依据
- 明确裁定:接受该用例在 Windows 上红,不改它语义、本批不再修
pitfalls:记一条「只跑门禁子集 → 同一天两次 CI 红」
Project CI / Repository checks (pull_request) Failing after 2m51s
Project CI / Frontend tests (pull_request) Successful in 3m29s
Project CI / Backend tests (pull_request) Successful in 7m5s
Project CI / Native shell tests (pull_request) Successful in 17m8s
31fcbb79dc
- 现象:dd7cf401a 给 lint-staged 加 *.rs 键,但本地从未跑过 check:git-hooks(它是 npm run lint 链里的一环),而 scripts/git-hooks.test.mjs 用 assert.deepEqual 钉住 lint-staged 的整份配置形状 → CI 以 deepStrictEqual 失败;本批更早还因 check:rustfmt 红过一次(15660a98b 修 8 处格式偏差),成因是 pre-commit 的 lint-staged 当时只覆盖 *.{js,mjs,cjs,ts,tsx},Rust 格式本地无守卫
- 原因:npm run lint 是一条 && 长链(encoding / npm-workspaces / git-hooks / rustfmt / spacetime-schema / production-ops / preview-deployer / maintenance-page / eslint / typecheck),中间任一步失败则其后步骤根本不执行,「跑到第 N 步就以为本地绿了」;更隐蔽的是本地若因环境原因在中间断掉(如 Windows 上 check:git-hooks 第 2 个用例的 EBUSY),后面那些本来能通过的步骤也从未被验证
- 处理:push 前按完整链路跑;&& 链在某一步失败时,必须把失败步之后的每一步单独再跑一遍(10 步 + git diff --check)并逐条记 exit code(取 exit code 不要接管道);改动碰到 package.json / .husky/ / scripts/ 下的门禁资产时,check:git-hooks 是必跑项
- 验证:本次 check:git-hooks 因本机 Windows EBUSY 中断后,补跑其后各步全部 exit 0(spacetime-schema / production-ops / preview-deployer / maintenance-page / lint:eslint / typecheck / encoding),只有 check:rustfmt 红且红在别人在途的 .rs(assets.rs:519 落在其未提交 hunk +470,62、commands.rs:3079 落在其未提交 hunk +3068,58);变异验证:把 *.rs 从 package.json 摘掉 → 第 1 个用例以同样的 deepStrictEqual operator 变红,还原(SHA256 一致)后回 ok
- 纯文档追加:文件末尾 +8 行、无删改;本条只提交 docs/project-memory/shared-memory/pitfalls.md 一个路径
suzmii added 2 commits 2026-09-12 02:13:52 +08:00
- docs/project-memory/shared-memory/pitfalls.md:删掉文件末尾多出的一个空行,使文件以单个换行结尾(UTF-8 无 BOM、LF 不变,仅 -1 行)
- 该空行由先前提交 f16aa440b 引入;前两次 CI 分别停在 check:git-hooks 与 check:rustfmt,都没走到最后一道 git diff --check,因此一直潜伏
- 自检口径按 CI:git diff --check fafe6b63c..HEAD(CI 是提交范围比较;本地裸跑 git diff --check 在干净树上恒为空,看不出来)
pitfalls:补记「同一条命令的口径差异」——CI 用提交范围比较 git diff --check
Project CI / Repository checks (pull_request) Successful in 3m17s
Project CI / Frontend tests (pull_request) Successful in 3m55s
Project CI / Backend tests (pull_request) Successful in 6m17s
Project CI / Native shell tests (pull_request) Successful in 17m11s
90177308e0
- docs/project-memory/shared-memory/pitfalls.md:在「只跑门禁子集 → 同一天两次 CI 红」条目的「原因」里补第三类成因
- 口径差异:CI 跑 git diff --check "${base_ref}"..."${head_ref}"(提交范围),本地裸跑 git diff --check(工作树 vs 索引)在干净树上恒为空,所以本地绿不能证明 CI 这一跳会过
- 实例:pitfalls.md 末尾多出的空行(f16aa440b 引入),因前两次推送分别停在 check:git-hooks 与 check:rustfmt 而一直未被这一跳检查
suzmii added 1 commit 2026-09-12 02:54:32 +08:00
修复资源登记把任意图片写成 ui 类型
Project CI / Repository checks (pull_request) Successful in 3m17s
Project CI / Frontend tests (pull_request) Successful in 3m55s
Project CI / Backend tests (pull_request) Successful in 6m29s
Project CI / Native shell tests (pull_request) Successful in 17m16s
5b62d1e4ef
停用本地导入图片的 ui 默认 kind:agent_local_project_file_type 的图片扩展名分支改写 canonical image(派生 unclassified,落「待归类」),并注明不许把 ui 当图片默认值及其不可恢复原因——读时自愈只在落盘 category 为 unclassified 时触发,kind 本身写错时自愈只会把错值放大成 ui-interaction
账户素材导入改用平台真实类型:新增 imported_platform_asset_kind,账户素材库记录自带的 assetKind 不再被常量 ui 顶掉,缺失才退回中性 image
平台素材导入改用响应里的 assetKind:网页项目画布与平台素材导入同样走 imported_platform_asset_kind,不再写死 ui
上传素材按内容证据推导 kind:upload_local_asset_at 不再把来源词 uploaded 当类型,改由 uploaded_asset_kind 按 mediaType 与扩展名推导 audio/video/image/document/code,判不出才用中性 asset;同一命令也收 .wav 与 .md,因此不能一刀切成 image
补 4 处回归与反查用例:本地导入与上传断言落盘 kind 与 category 且不得出现 ui、ui-interaction、uploaded;两处平台导入的 kind 解析加纯函数单测与调用点反查门禁;变异把错值改回来必须变红(M1/M2/M3 实测 4 个用例全红,还原后 diff 与基线逐字节一致)
覆盖边界:账户素材库与平台素材导入是异步 HTTP 路径,仓库内没有可复用的端到端夹具,这两条覆盖是纯函数单测加调用点反查门禁,不是端到端断言;本地导入与上传是读落盘 manifest 的行为级断言
后续事项:ImportedAsset.asset_kind 仍返回平台原始值(缺失为 none),与已落盘 kind 可能不一致,本次未改动
suzmii added 1 commit 2026-09-12 09:38:41 +08:00
「所有资源」展开态改回平铺画布:不再为每个栏目渲染标题栏
Project CI / Repository checks (pull_request) Successful in 2m50s
Project CI / Frontend tests (pull_request) Successful in 3m19s
Project CI / Backend tests (pull_request) Successful in 7m23s
Project CI / Native shell tests (pull_request) Successful in 19m4s
3f7e41e466
展开态真实栏目不再渲染栏目标题栏,整个场景只留 all 那一条钉在视口上、带「资源总览」收起入口的标题栏

原因是分带只为有可见资源的栏目生成,空栏目拿不到标题栏矩形后全部回落到世界原点叠成一摞(空项目必现,现场是 7 条)

ResourceBookSceneCategoryPlan 新增 titlebar 字段,渲染层按它决定是否产出标题栏节点

分带去掉标题栏横带的高度预留与矩形换算,删除 RESOURCE_BOOK_ALL_BAND_TITLE_HEIGHT 与 resourceBookAllTitlebarRects

拖动写回口径不变(resourceBookAllBandLocalPoint 一行未改),零 CSS 变更

resourceBookLayout.test 与 appSurface 两条「所有资源」用例加反向守卫;变异验证改回旧行为时两条同时红(got 7)

decision-log 与 pitfalls 记这次倒因为「缺一路坐标输入时不要静默回落到原点」
suzmii added 2 commits 2026-09-12 12:26:35 +08:00
展开态缩放、滚轮、平移和卡片拖动改读 all 专用内存视口 ref

避免复用普通栏目页视口导致按钮按下后倍率被旧状态覆盖

补充所有资源连续缩放 appSurface 回归断言,并同步更新项目决策与踩坑记录
Merge remote-tracking branch 'origin/master' into fix/agc-all-resources-zoom
Project CI / Repository checks (pull_request) Successful in 3m3s
Project CI / Frontend tests (pull_request) Successful in 3m41s
Project CI / Backend tests (pull_request) Successful in 6m37s
Project CI / Native shell tests (pull_request) Successful in 17m17s
38cd688d71
Member

面对review吧(

─── apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs:76-78 ───
[bug · high] No guard prevents `destination` from resolving to the same file as `source`.
`File::open` happens first, but on a path-equivalent destination `File::create` truncates the file,
so the copy then reads 0 bytes and reports `byte_len = 0` "success" while the source asset has been
destroyed. The frontend uses a native save dialog (`saveNativeFileDialog`), which lets the user
navigate into the project assets directory and pick the source itself. Compare canonicalized paths
before creating the writer.

+     let source_canonical = source.canonicalize().map_err(|error| format!("解析素材源路径失败:{error}"))?;
+     if destination.exists() {
+         let destination_canonical =
+             destination.canonicalize().map_err(|error| format!("解析保存目标路径失败:{error}"))?;
+         if source_canonical == destination_canonical {
+             return Err("保存目标不能与素材源文件相同".to_string());
+         }
+     }
      let mut reader = File::open(&source).map_err(|error| format!("打开素材文件失败:{error}"))?;
      let mut writer =
          File::create(&destination).map_err(|error| format!("创建保存目标失败:{error}"))?;


─── apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs:31-33 ───
[security · medium] This resolution only rejects a symlink at the final component. An intermediate
directory inside the project (e.g. `assets` being a symlink/junction) is never checked, so a crafted
project can make the resolved source escape the project root and export arbitrary files. The rename
path uses `resolve_local_project_path`, which walks every component and rejects symlinks/reparse
points; this function should use the same helper for a consistent confinement boundary.

-     let source = root.join(&normalized);
+     let source = resolve_local_project_path(root, &normalized)?;
      // 只允许保存项目根内真实存在的普通文件:符号链接与目录都要拒绝。
      let metadata = std::fs::symlink_metadata(&source)


─── apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs:249-250 ───
[bug · medium] The file rename and `write_manifest` have already succeeded by this point. If
`advance_agent_runtime_project_revision_locked` fails (it performs a separate read+increment+write
on the revision file and can fail on IO/overflow), the function returns `Err` without rolling back
the file/manifest and without a reconciliation marker, even though the mutation fully committed. A
retry will then diverge: the asset is already renamed in the manifest, so the next attempt fails
with "同目录已存在同名文件". Consider either advancing the revision atomically with the manifest write or
reporting this as a committed-but-incomplete state with reconciliation semantics rather than a plain
failure.



─── apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs:93-95 ───
[other · low] The copy writes directly to the final destination and stops at `flush` (no
`sync_all`), and `File::create` truncates an existing destination before the copy result is durable.
A crash or copy error mid-way leaves a truncated/partial destination file, and any prior file at
that path is already lost. The manifest writer already uses the temp-file + atomic-rename pattern
(`write_manifest_locked`); exporting to a sibling temp file and renaming into place would avoid both
the truncation-before-completion and the partial-write exposure.



─── apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs:257-263 ───
[maintainability · medium] This test relies on a fault-injection hook
(`RenameLocalProjectAssetFaultStage` + the `fault` parameter) that lives directly in the production
signature of `rename_local_project_asset_at` in `project/asset_rename.rs`. The enum and parameter
are not gated behind `#[cfg(test)]`, so any internal (crate) caller can pass `Some(ManifestWrite)`
and drive the function down the rollback-only path that intentionally never writes the manifest.
Consider cfg-gating the fault stage and the `fault` parameter (or exposing a test-only constructor)
so test scaffolding doesn't leak into the production API surface.



─── apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2476-2476 ───
[bug · medium] The precedence implemented here is the reverse of what the doc comment above states.
The comment says the value order should be `error.details.message` → `details.message` →
`error.message` → …, but `details.or_else(|| error.get("details"))` gives top-level `details`
priority over nested `error.details`. When a response body contains both shapes, `details.message`
(often the generic fallback) wins and the actionable `error.details.message` is never surfaced —
defeating the purpose of this helper. Currently the platform's own error envelope only emits
`error.details` (no top-level `details`), so it is latent, but the code contradicts its documented
contract and no test covers the both-present case. Swap the lookup order to match the documented
priority.

-     let error_details = details.or_else(|| error.and_then(|error| error.get("details")));
+     let error_details = error
+         .and_then(|error| error.get("details"))
+         .or(details);


─── apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2468-2468 ───
[performance · low] `response.text().await` buffers the entire response body without any size cap
before parsing. This is an error path against an external editor endpoint, so a misbehaving or
compromised upstream returning a very large 4xx body causes an unbounded `String` allocation in the
Tauri backend (the 200-char truncation below happens only after the whole body has been parsed).
Consider reading a bounded prefix, e.g. stream a limited number of bytes via `bytes_stream()` and
stop once a cap is reached (or reject based on `Content-Length`).

-     let body = response.text().await.ok()?;
+     const MAX_EDITOR_ERROR_BODY_BYTES: usize = 64 * 1024;
+     let mut buf = Vec::new();
+     let mut stream = response.bytes_stream();
+     while let Some(chunk) = stream.next().await {
+         let chunk = chunk.ok()?;
+         if buf.len() + chunk.len() > MAX_EDITOR_ERROR_BODY_BYTES {
+             return None;
+         }
+         buf.extend_from_slice(&chunk);
+     }
+     let body = String::from_utf8(buf).ok()?;


─── apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts:186-191 ───
[bug · medium] `enterProjectDevelopment` is now async and awaits
`resolveSessionPreviewOnProjectOpen` before committing
`currentProjectContext`/`activeProjectPreview`. The project-page flows (`openProject`,
`createProjectFromProjectPage`) serialize through `projectActionRef`, but the home-create flow
(`createHomeProjectFromDirectory` -> `enterCreatedHomeProject`) has no equivalent guard, so it can
overlap with another entry (e.g. a quick home-create while an open is still in flight). The slower
call can resolve last and overwrite the newer project, leaving the workspace bound to the wrong
project. Consider capturing a monotonic entry token before the await and discarding the result if it
no longer matches after the await (or a shared in-flight guard across all entry paths).

+     const entryToken = ++projectEntryTokenRef.current;
      const session = await resolveSessionPreviewOnProjectOpen({
        invoke: resolveTauriInvoke(),
        projectPath: context.projectPath,
        recordedPreview: context.manifest.preview ?? null,
      });
+     if (entryToken !== projectEntryTokenRef.current) {
+       return;
+     }
      setCurrentProjectContext(


─── apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx:193-196 ───
[bug · medium] `patchStage` updates whichever `manifestMergeNotice` is currently displayed, but it
is not correlated with the rejection that started this recovery. In `applyManifestSnapshot` a new
rejection unconditionally overwrites `manifestMergeNotice` even when a recovery for the same project
is already in flight (the new recovery is skipped), so the in-flight recovery's completion
(`recovered`/`unresolved`) gets stamped onto a later rejection that was never re-read. Correlate the
completion patch with the recovery's input (e.g. only patch when the current notice's
`heldRevision`/`snapshotRevision`/`decision` match `input`), or queue a distinct recovery per
rejection.

        const patchStage = (stage: ProjectManifestMergeRecoveryStage) =>
          setManifestMergeNotice((current) =>
-           current ? { ...current, stage } : current,
+           current &&
+           current.projectId === input.projectId &&
+           current.heldRevision === input.heldRevision &&
+           current.snapshotRevision === input.snapshotRevision
+             ? { ...current, stage }
+             : current,
          );


─── apps/ai-game-creator-shell/src/features/app-shell/sessionPreview.ts:77-81 ───
[bug · low] On a failed `get_local_game_preview_status` read, the code falls through to the
`recordedPreview?.status === 'running'` branch and invokes `stop_local_game_preview`, which stops
any *live* preview (`registry.stop_for_project`). A read failure (e.g. `preview.status` permission
denied, or a transient invoke error) does not prove that no preview is running, so this path can
kill a genuinely running preview while intending only to clean up a stale record. When the status
read fails, skip the stop and return `{ sessionPreview: null, manifestPreviewPatch: null }` instead
of treating it as confirmed-not-running.

    } catch {
-     // 读不到 registry 就按"没有在跑"处理:宁可让用户点一下「运行」,也不要进一个
-     // 打不开的运行界面。
-     status = null;
+     // 读不到 registry 就无法确认真相:不当作"确认没有在跑"去停掉活体预览,
+     // 只按"不带预览进入"处理。
+     return { sessionPreview: null, manifestPreviewPatch: null };
    }


─── apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs:1386-1386 ───
[bug · medium] The primary manifest mutation path (`mutate_manifest_at_allowing_version_removals`,
used by asset register/update/delete, classification updates, and version append/removal) writes via
`write_manifest_locked` directly and never invalidates the preview cache with
`forget_preview_manifest`. Only `write_manifest_with_lock_hook` calls `forget_preview_manifest` on
success. The cache's own documentation states that the len/mtime/file-identity heuristics alone
cannot reliably catch same-length in-place rewrites within the same mtime granularity — and on
Windows volumes where `windows_file_identity` returns a constant file index (FAT/exFAT, some network
shares) the identity check is a no-op, so a stale manifest can be served after a mutation. Add
`forget_preview_manifest(&manifest_path)` after a successful write here.

+     let result = mutate(&mut manifest)?;
      write_manifest_locked(&manifest_path, &manifest, allowed_version_removals)?;
+     forget_preview_manifest(&manifest_path);
+     Ok(result)


─── apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs:1140-1140 ───
[maintainability · low] This classification command acquires the project write lock with the
`"asset.register"` label, copied from the asset registration path. The label is written into the
lock file's `commandId` and surfaced in contention diagnostics via
`ProjectWriteLockSnapshot::describe_holder` (`commandId=...`), so a classification update will be
misattributed as an asset registration whenever lock contention is reported. Use a
classification-specific label instead.

-     let _lock = acquire_project_write_lock(root, "asset.register")?;
+     let _lock = acquire_project_write_lock(root, "asset.classification.update")?;


─── apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs:1180-1181 ───
[bug · medium] The audit record is appended before `advance_agent_runtime_project_revision_locked`.
If revision persistence fails, the classification change and its audit are already durable while the
revision stays unchanged and the caller receives an error. A retry with the same
`expectedProjectRevision` then passes the CAS check, re-writes the identical classification, and
appends a second audit record whose `previousCategory`/`previousTags` equal the new values — a
misleading no-op entry that breaks the "changed from X to Y" semantics this audit is meant to
preserve. Consider advancing the revision before appending the audit, or making the audit idempotent
(skip when previous values already equal the new values).



─── apps/ai-game-creator-shell/src-tauri/src/assets.rs:2048-2049 ───
[bug · medium] If `advance_agent_runtime_project_revision_locked` fails here, the manifest mutation
(asset and possibly referenced versions removed) has already been durably written, but the revision
file stays at the old value. A retry with the same `expected_project_revision` passes the CAS checks
and then fails with `项目资源不存在` because the asset is already gone — deletion is not idempotent, so
this command cannot self-heal. This differs from `update_manifest_asset_classification_at`, whose
retry is naturally idempotent. Consider making the mutation tolerate a missing asset (no-op success
when identity/revision CAS already passed) or documenting an explicit recovery path for the
stale-revision-after-delete state.

-     let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
-         .map_err(|error| format!("素材登记已删除,但项目 revision 未能推进:{error}"))?;
+     // Consider making the mutate closure treat an already-deleted asset as a no-op
+     // (identity + revision CAS passed) so a retry after a failed revision advance converges.


─── apps/ai-game-creator-shell/src-tauri/src/assets.rs:2035-2042 ───
[performance · low] `asset_referencing_versions` scans all versions/bindings and is already
evaluated once in the `allowed_version_removals` closure; it is recomputed here, and `Vec::contains`
inside `retain` makes the filtering O(V×R) — O(V²) when many versions reference the asset. Compute
the referenced version IDs once and use a `HashSet<String>` for the membership test.

-                 let referenced_version_ids: Vec<String> =
+                 let referenced_version_ids: std::collections::HashSet<String> =
                      asset_referencing_versions(manifest, asset_id)
                          .into_iter()
                          .map(|version| version.version_id)
                          .collect();
                  manifest
                      .versions
                      .retain(|version| !referenced_version_ids.contains(&version.version_id));


─── apps/ai-game-creator-shell/src-tauri/src/commands.rs:1032-1032 ───
[maintainability · medium] `rename_local_project_asset` performs no identity/revision CAS, unlike
the sibling mutating commands `delete_local_project_asset` and
`update_local_project_resource_classification`, which both carry
`expectedProjectId`/`expectedProjectRevision` and reject on `project-identity-conflict` /
`project-revision-conflict`. A stale client holding an old revision can therefore rename an asset
and advance the project revision without ever observing the conflict, silently operating on project
state it does not actually know. Consider adding `expected_project_id`/`expected_project_revision`
to `RenameLocalProjectAssetInput` and enforcing the same CAS as the other mutations.



─── apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs:3668-3669 ───
[maintainability · low] On Windows the holder opens the lock file with `share_mode(0)` (see
`try_open_project_append_os_lock`), so while another process actually holds the lock,
`fs::read_to_string` here always fails with a sharing violation and this diagnostic always degrades
to `持锁方身份不可读`. That makes the new "who holds the lock" postmortem ineffective exactly on Windows —
the platform the UAC/lock-contention changes are targeting (the new tests only seed the file
*without* holding it, so this real path is never exercised). Consider writing the holder metadata to
a sidecar file opened with read sharing, or documenting that the holder report is Unix-only.



─── apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs:3570-3574 ───
[other · medium] `lock_with_attempts` takes the in-process `process_lock` *before* the OS lock and
holds it for the whole retry loop — up to ~10 s of `thread::sleep` for `lock()`. A same-process
`lock_short` caller (the read path) then blocks on `process_lock.lock()` for the writer's entire
window, so the "short window keeps read paths responsive" intent only holds across processes, not
within one. Consider bounding the process-lock wait (e.g. `try_lock` with its own short window)
while preserving the process→OS lock ordering to avoid an ABBA deadlock.



─── apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2514-2525 ───
[maintainability · low] The 200-char truncation is applied only to `candidate`. The `provider` /
`assetKind` / `mediaType` / `code` values are read from the same external error body without any
length limit and appended verbatim, so a single oversized field can still produce an arbitrarily
long user-visible error message, defeating the stated intent of keeping the body out of the UI
message. Consider capping each appended field (or the final `reason`) as well.

      if let Some(provider) = detail_text("provider") {
-         reason.push_str(format!("|provider {provider}").as_str());
+         reason.push_str("|provider ");
+         reason.extend(provider.chars().take(80));
      }
      if let Some(asset_kind) = detail_text("assetKind") {
-         reason.push_str(format!("|素材类型 {asset_kind}").as_str());
+         reason.push_str("|素材类型 ");
+         reason.extend(asset_kind.chars().take(80));
      }
      if let Some(media_type) = detail_text("mediaType") {
-         reason.push_str(format!("|媒体类型 {media_type}").as_str());
+         reason.push_str("|媒体类型 ");
+         reason.extend(media_type.chars().take(80));
      }
      if let Some(code) = code {
-         reason.push_str(format!("|错误码 {code}").as_str());
+         reason.push_str("|错误码 ");
+         reason.extend(code.chars().take(80));
      }


─── apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs:27-30 ───
[security · high] The export path never applies the sensitive-file / private control-path rejection
used by the generic project read path. `read_local_project_file_at` calls
`reject_sensitive_project_file_read` and `reject_agent_runtime_private_control_path` before opening
a file, but `resolve_export_source_file` skips both, so `save_local_project_asset_file` can copy
`.env`/config files and `.agent/runtime|checkpoints|workbench` control-plane files to an arbitrary
destination. This also contradicts the docstring, which says only "registered" assets are exported
while nothing here verifies manifest registration. Apply the same rejections after normalization
(and consider validating the file is actually registered in the manifest).

      let normalized = normalize_relative_path(relative_path.trim())?;
-     if normalized.is_empty() {
-         return Err("待保存的素材路径不能为空".to_string());
-     }
+     reject_agent_runtime_private_control_path(&normalized)?;
+     reject_sensitive_project_file_read(&normalized)?;


─── apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs:84-84 ───
[bug · low] `frame_directory == directory` is case-sensitive while the file-name comparison on the
same line is case-insensitive. On case-insensitive filesystems (Windows/macOS), a frame whose
`imageSrc` uses different directory casing than the manifest `localPath` (e.g. `Assets/hero.png` vs
`assets/hero.png`) points at the same file but will not be aligned after the rename, leaving a
dangling frame reference. Use a platform-aware directory comparison (case-insensitive on
Windows/macOS, case-sensitive on Linux) to match the filesystem semantics.

-     frame_directory == directory && frame_file_name.eq_ignore_ascii_case(previous_file_name)
+     let directory_matches = if cfg!(windows) || cfg!(target_os = "macos") {
+         frame_directory.eq_ignore_ascii_case(directory)
+     } else {
+         frame_directory == directory
+     };
+     directory_matches && frame_file_name.eq_ignore_ascii_case(previous_file_name)


─── apps/ai-game-creator-shell/src/App.tsx:6013-6015 ───
[bug · medium] The `conversation.write` policy-confirmation retry path re-invokes
`executeChatAgentReply` with `prompt`, `clientTurnId`, `creationType`, `attachments`, and
`directPolicyChecked: true`, but omits `references`. When a user's first direct-codex turn requires
policy confirmation, their attached resource references are silently dropped. Pass `references`
through the retry callback to keep the turn input intact.

-           if (references?.length) {
-             directTurnInput.references = references;
-           }
+               void executeChatAgentReply({
+                 prompt,
+                 clientTurnId,
+                 creationType,
+                 attachments,
+                 directPolicyChecked: true,
+                 references,
+               });


─── apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs:175-182 ───
[performance · medium] `resource_ids` is the only client-supplied collection in this module with no
bound. Every other field is capped (`MAX_DIRECT_CODEX_REFERENCES`, id/label/source/element/text char
limits), but a single `runtime-region` reference can carry an arbitrarily long list, and this loop
performs an O(assets) manifest scan per id with no dedup — so a large payload costs O(n·m) and
produces an unboundedly long `关联素材 ID:a,a,a,…` line in the injected prompt (ultimately only stopped
later by the 32 MiB write guard, which turns it into a confusing connection-level error). Cap the
count (e.g. `take(MAX_DIRECT_CODEX_REFERENCES)`) and dedup the ids to stay consistent with the
module's fail-closed boundedness.

      let mut related_resource_ids = Vec::new();
-     for resource_id in &reference.resource_ids {
+     for resource_id in reference
+         .resource_ids
+         .iter()
+         .take(MAX_DIRECT_CODEX_REFERENCES)
+     {
          let resource_id = validate_resource_reference_id(resource_id)?;
          if !manifest.assets.iter().any(|asset| asset.id == resource_id) {
              return Err("运行画面引用的素材已变化,请重新点选".to_string());
          }
+         if !related_resource_ids.contains(&resource_id) {
          related_resource_ids.push(resource_id);
+         }
      }


─── apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs:1795-1797 ───
[maintainability · low] This branch only ever fires for `PROJECT_APPEND_LOCK_TIMEOUT_MARKER`. The
`PROJECT_WRITE_LOCK_CONTENTION_PREFIX` case is already returned by the earlier check at line 1779
(`当前项目仍有写入正在结束…`), so the write-lock half of `direct_project_history_contention_failure` is
unreachable here — making the helper's “跨进程锁 / 项目锁争用” comment and the “或项目锁” wording misleading. If
the more specific message is meant to win for write-lock contention too, move this check above line
1779; otherwise narrow the helper to the append-lock marker only.



─── apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs:984-989 ───
[maintainability · medium] The DirectProject envelope type `"response_item"` is hardcoded here while
the writer side uses `DIRECT_PROJECT_HISTORY_RECORD_TYPE` (`agent/direct_project_history.rs`). These
two modules share the same `project.jsonl` file, so this is a cross-file contract kept in sync only
by convention. If the record type ever changes on the writer side, this reader will stop recognizing
those rows and will instead fail closed with `解析对话记录失败` on every DirectProject line, breaking the
shared-file read. Consider exposing the constant as `pub(crate)` and referencing it here so the
contract is compiled, not duplicated.

  fn is_direct_project_history_row(line: &str) -> bool {
      serde_json::from_str::<serde_json::Value>(line).is_ok_and(|parsed| {
-         parsed.get("type").and_then(serde_json::Value::as_str) == Some("response_item")
+         parsed.get("type").and_then(serde_json::Value::as_str)
+             == Some(crate::agent::DIRECT_PROJECT_HISTORY_RECORD_TYPE)
              && parsed.get("payload").is_some()
      })
  }


─── apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs:3827-3828 ───
[maintainability · low] When the strict validation fails with an error that is *not*
elevation-related (e.g. the lock file was deleted between open and validation, or an ACL error
string not matched by `windows_acl_error_may_need_elevation`),
`secure_windows_game_creator_path_for_current_user_with_auto_elevation` performs no repair and
returns the same error unchanged. This `map_err` then produces `{error};自动提权修复未完成:{error}` — a
duplicated error with a misleading claim that an elevation repair was attempted. Consider only
appending the `自动提权修复未完成` suffix when the error actually qualifies for elevation repair, otherwise
propagate the underlying error as-is.



─── apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs:1480-1482 ───
[bug · medium] TOCTOU race in the cache-fill path: `read_manifest(path)` reads the file content
first, and only afterwards does `remember_preview_manifest` snapshot `symlink_metadata` +
`File::open` identity. If a concurrent writer replaces the manifest between those two steps, the
cache stores the OLD content paired with the NEW file's identity/len/mtime. Subsequent reads then
pass the identity check and return the stale manifest until the next invalidation, defeating the
cache's stated correctness invariant. Capture the file identity/metadata snapshot before the read
(and/or re-verify it still matches before inserting), so the cached content is guaranteed to
correspond to the snapshotted file.



─── apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs:1135-1135 ───
[security · low] The normalized tags are persisted into the manifest without any bound on tag count
or per-tag length. `normalize_game_creation_app_asset_tags` only trims/dedupes, and a client can
submit arbitrarily many arbitrarily long tags, growing the manifest file (and the cost of every
subsequent full read/parse/write) without limit. Other persisted strings here are length-capped
(project name, task text, version IDs, binding counts); consider capping the tag count and per-tag
character length before writing.



─── apps/ai-game-creator-shell/src-tauri/src/main.rs:29-31 ───
[maintainability · low] The four newly added imports (`game_creation_app_asset_category_for_kind`,
`game_creation_app_asset_category_from_str`, `game_creation_app_asset_effective_category`,
`normalize_game_creation_app_asset_tags`) are never referenced anywhere in main.rs except this
import statement, so they produce `unused_imports` warnings. The modules that actually need them
(commands.rs, assets.rs, etc.) already import them directly. Remove these four names (or use them
here) to keep the build warning-free.

-     game_creation_app_asset_category_for_kind, game_creation_app_asset_category_from_str,
-     game_creation_app_asset_effective_category, new_game_creation_app_manifest,
-     new_game_creation_app_seed_tasks, normalize_game_creation_app_asset_tags,
+     new_game_creation_app_manifest, new_game_creation_app_seed_tasks,


─── apps/ai-game-creator-shell/src/features/project-workspace/usePromptPolish.ts:109-112 ───
[bug · medium] `polish()` wraps the awaited work in `try/finally` without a `catch`. Any rejection
thrown by `requestPolish`, `normalizeResult`, `resolveContext`, or `applyPrompt` propagates out of
the returned promise and never reaches the user-facing `error` state. The default
`requestChatPromptPolish` swallows its own failures, but `UsePromptPolishOptions` explicitly
supports injecting a custom `requestPolish` (and hosts pass `applyPrompt`/`normalizeResult`) that
can reject — callers such as `polishAndSubmitFromReminder` and the inline polish button then produce
an unhandled promise rejection and show no failure message. Add a catch that sets `error` and
returns `null`, keeping the `finally` cleanup.

+       } catch {
+         setError(options?.failureMessage ?? failureMessage);
+         return null;
        } finally {
          runningRef.current = false;
          setPolishing(false);
        }


─── apps/ai-game-creator-shell/src/features/project-workspace/usePromptPolish.ts:139-143 ───
[bug · medium] `reset()` clears `originalText`/`error`/`notice` but does not invalidate an in-flight
`polish()` request. If the host clears the composer or switches panels while `requestPolish` is
pending (the inline polish button does not lock the input), the later `applyPrompt(normalized.text)`
writes the stale polished text into the now-cleared or newly selected draft. Introduce a
request-generation counter: capture it before awaiting, ignore the result if it no longer matches,
and bump it inside `reset()` so superseded requests cannot write back.

    const reset = useCallback(() => {
+     requestIdRef.current += 1;
      setOriginalText(null);
      setError(null);
      setNotice(null);
    }, []);


─── apps/ai-game-creator-shell/src/features/project-workspace/ChatPromptPolishReminder.tsx:50-50 ───
[bug · medium] While `busy`, `useEscapeToClose(onClose, !busy)` disables the window-level Escape
handler and the backdrop `onMouseDown` returns early, but this section-level `onKeyDown` still calls
`closeDialogOnEscape(event, onClose)` with no `busy` guard. Pressing Escape during the AI polish
request closes the modal even though the other close paths intentionally block closing while a
request is in flight; the pending polish can then complete and submit the form after the user
already cancelled. Guard this path with `busy` for consistency.

-         onKeyDown={(event) => closeDialogOnEscape(event, onClose)}
+         onKeyDown={(event) => {
+           if (busy) return;
+           closeDialogOnEscape(event, onClose);
+         }}


─── apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts:229-236 ───
[bug · high] The runtime-region identity key only uses `runId/label/elementTag/text`, omitting
`resourceIds`, `versionId`, `elementRole`, `width`, and `height`. Two distinct runtime-region
selections that happen to share the same label/runId/elementTag/text but bind different resources
(e.g. two "Play" elements in the same run, or a re-inspected region whose bindings changed) collapse
to the same key, so `dedupeChatReferences` silently drops one and `chatReferenceListKey`/`sameDraft`
fail to emit a real draft change. Include the omitted discriminators in both `chatReferenceKey` and
`chatReferenceListKey`.

  function chatReferenceKey(reference: ChatReference) {
    if (reference.type === 'resource') {
      return `resource:${reference.resourceId}:${reference.source}`;
    }
+   const resourceIds = [...reference.resourceIds].sort().join(',');
    return `runtime-region:${reference.runId ?? ''}:${reference.label}:${
      reference.elementTag ?? ''
-   }:${reference.text ?? ''}`;
+   }:${reference.text ?? ''}:${reference.versionId ?? ''}:${
+     reference.elementRole ?? ''
+   }:${reference.width ?? ''}:${reference.height ?? ''}:${resourceIds}`;
  }


─── apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx:425-446 ───
[bug · high] This rebuild produces editor content whose readback differs from the props it was built
from: `collectDraftParts` serializes every chip as `@label`, but here the text is written as plain
paragraphs and references are appended in a separate trailing paragraph. Whenever props change
externally with non-empty `references` (AI polish writes plain text while references remain, or a
draft is restored with empty text plus references), the `OnChangePlugin` reads back extra `@label`
tokens, emits a different draft, and then line `lastEmittedDraftRef.current = nextDraft;` clobbers
the emitted value with the prop draft. On the next render `sameDraft` no longer matches, the editor
is rebuilt again with an additional reference paragraph, and the text grows by another `@label` each
pass — a render loop. Align the two representations (embed chips inline at the mention positions
instead of a separate paragraph, or make `collectDraftParts` not contribute mention text) and record
`lastEmittedDraftRef` from the actually-read draft rather than the prop draft.



─── apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx:318-321 ───
[performance · low] The comment on `assetsSignature` notes that callers rebuild the `assets` array
on every render, so memoizing on `[assets]` never actually skips work (and the same applies to
`currentVersionAssetReferences`). Use the content signature as the memo dependency (as `assetsById`
already does) so the filtering/mapping does not re-run on every render for large manifests.



─── apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx:1056-1064 ───
[performance · low] When the asset or project path changes, a new `read_local_project_image_preview`
invoke is issued without cancelling the previous in-flight request; the `cancelled` flag only
ignores its eventual result. Rapidly switching assets can leave overlapping preview requests
running. Cancel the previous scope (via `cancelLocalProjectResourcePreviewScope`) in this effect's
cleanup before issuing the new request, or otherwise fence the old request.



─── apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx:930-934 ───
[style · low] Nested ternary makes the empty-state copy hard to scan. Extract a small helper (e.g.
`pickerEmptyMessage(scopeReferences.length, pickerScope)`) so each branch reads as a single flat
condition.



─── apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx:29-32 ───
[bug · high] The outside-click check only tests `rootRef` (the trigger wrapper), but the open menu
is rendered via `createPortal(..., document.body)`, so it is not a DOM descendant of `rootRef`. A
`mousedown` on any menu option therefore hits `setOpen(false)` and the menu is removed before the
option's `onClick` can fire, making version selection unreliable/broken. Attach a ref to the portal
menu (declare `const menuRef = useRef<HTMLDivElement>(null)` and add `ref={menuRef}` to the menu
div) and include it in the containment check, similar to how `UiNodeContextMenu` checks its own
`menuRef`.

      const handlePointerDown = (event: MouseEvent) => {
-       if (rootRef.current?.contains(event.target as Node)) return;
+       const target = event.target as Node;
+       if (
+         rootRef.current?.contains(target) ||
+         menuRef.current?.contains(target)
+       ) {
+         return;
+       }
        setOpen(false);
      };


─── apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx:76-78 ───
[bug · low] The menu's `top`/`right` are computed once from `getBoundingClientRect()` when it opens
and never updated. If the window scrolls or resizes while the menu is open (the run preview canvas
can scale/scroll), the fixed-position menu stays where it was and can drift away from the trigger or
overflow the viewport. Consider recomputing on `scroll`/`resize`, or closing the menu on those
events (as `UiNodeContextMenu` does).



─── apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx:167-171 ───
[style · low] This three-way branch uses a nested ternary, which the project style rules prohibit
and hurts readability. Extract it into a small helper (e.g., `previewPlaceholder(entry)`) or use
explicit conditional rendering instead.



─── apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx:49-52 ───
[bug · low] Escape, the backdrop `onMouseDown`, and the close button all dismiss the panel without
checking `isUploading`. Closing mid-upload unmounts the view while the parent's
`uploadResourcePanelFiles` keeps running, hiding the in-flight progress and any resulting
error/notice until the panel is reopened. Consider disabling these close paths (or aborting the
upload) while `isUploading` is true.



─── apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx:254-256 ───
[maintainability · low] The header close button is not disabled while `saving`/`deleting` is in
flight, but the footer `取消`/`保存标签`/`删除` buttons are. Combined with `ThemedModal`'s default
`closeOnEscape`/`closeOnBackdrop`, the user can still dismiss the panel mid-write via ×, Escape, or
backdrop, after which the late `onSaved`/`onDeleted` callback fires for an unmounted panel. Consider
gating this button (and the modal's escape/backdrop close) with `disabled={saving || deleting ||
deleteDialogPreparing}` for a consistent in-flight state.



─── apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx:68-72 ───
[maintainability · low] `resourceClassificationErrorMessage` (and the sibling
`resourceDeleteErrorMessage`) surface the raw backend string/`Error.message` unchanged. For these
commands that can leak internal codes such as `project-identity-conflict` /
`project-revision-conflict` or filesystem paths, consider mapping known error codes to user-friendly
text before rendering in the `role="alert"` element.



─── apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx:127-132 ───
[bug · medium] `sanitizeInspectIdentifier` only accepts `[A-Za-z0-9._-]`, but resource IDs in this
codebase are canonicalized as `local-asset:<id>` (colon) and can also be arbitrary asset IDs (e.g.
`persisted-角色草图.png`). Running `selection.resourceIds` through this filter silently drops every such
ID, so the runtime-region reference loses its resource association whenever it isn't recoverable via
`sourcePath`. Consider a dedicated sanitizer for resource IDs that allows `:` and printable Unicode
while still capping the length.

    const resourceIds = Array.isArray(selection.resourceIds)
      ? selection.resourceIds
-         .map((value) => sanitizeInspectIdentifier(value, 200))
+         .map((value) => sanitizeInspectText(value, 200))
          .filter((value): value is string => Boolean(value))
          .slice(0, 32)
      : [];


─── apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx:424-429 ───
[maintainability · low] The `disabled` condition was changed from `needsUserInput` to
`runtimePanelProps.controlBusy || needsUserInput`, and the comment that documented the intent to
allow switching models mid-conversation was removed. Since `controlBusy` is true during an active
directCodex turn, users can no longer pre-select the model for the next turn. Please confirm this is
intentional; otherwise restore `disabled={needsUserInput}`.



─── apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx:92-95 ───
[maintainability · low] `submitting` is set to `true` before `onSubmit`, but it is only reset back
to `false` in the `catch` branch. On the success path the component therefore stays in the "生成中…"
state and keeps close/cancel/backdrop/escape disabled, relying entirely on the host unmounting the
panel. The current host does close on success, but this is a fragile contract for a standalone
component: any caller that keeps it mounted after a resolved `onSubmit` leaves the panel permanently
locked. Consider resetting `submitting` after the successful `await` (safe here, since
`attempted`/`inputLocked` still prevent re-submission) or documenting the unmount requirement on the
prop.

      } catch (submitError) {
        setError(resourceGenerationErrorMessage(submitError));
+     } finally {
        setSubmitting(false);
      }


─── apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx:128-132 ───
[bug · low] While uploading, the download button is disabled (`disabled={selectedCount === 0 ||
isUploading}`) but its icon branch renders a spinning `Loader2`. The button is not downloading
anything during an upload, so this reads as "下载" being in progress on a disabled control. The
in-flight indicator should live on the upload action (which already shows "上传中…" text) rather than
on the disabled download button.

            {isUploading ? (
-             <Loader2 size={15} className="animate-spin" aria-hidden="true" />
+             <Download size={15} aria-hidden="true" />
            ) : (
              <Download size={15} aria-hidden="true" />
            )}


─── apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts:223-227 ───
[bug · low] The restore diff only compares `section`, `x`, and `y`, while
`resourceCanvasSnapshotsEqual` also compares `manuallyPlaced`. This asymmetry means a history entry
whose only difference from the current layout is the `manuallyPlaced` flag gets pushed (snapshots
are considered unequal) but `resolveResourceCanvasRestoreEntries` returns an empty list, so undoing
that step becomes a silent no-op. Since the caller (`commitPosition`) can only write
`section`/`x`/`y` (and always sets `manuallyPlaced: true` via `moveResourceCanvasPosition`), the
flag is effectively never restorable. Consider either dropping `manuallyPlaced` from
`resourceCanvasSnapshotsEqual` (treating it as non-history-relevant, matching the "layout
coordinates only" contract) or documenting this intentional asymmetry.



─── apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts:41-44 ───
[maintainability · low] `canonicalProjectedResourceMediaType` is typed to accept a full
`ProjectResource`, but it only reads `mediaType` and `path` (verified in `resourceEditModel.ts`).
The `as ProjectResource` cast here (and in `isResourceRasterImage`) silently drops the other
`ProjectResource` fields, so if the helper ever starts reading more fields, this will pass
`undefined` without a compile error. Consider narrowing the helper's parameter to
`Pick<ProjectResource, 'mediaType' | 'path'>` (still compatible with full-resource callers) so the
cast can be removed.



─── apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts:200-202 ───
[maintainability · low] The `Promise<... | null>` signature only encodes validation failures (bad
revision, projectId mismatch, torn revision pair). An IPC failure in `readRevision` / `readManifest`
still rejects rather than resolving to `null`, so the type understates the failure surface. Both
current callers (`WorkspaceLauncher` and `uploadProjectAssetFilesAndReadSnapshot`) wrap the call in
try/catch, so there is no unhandled rejection today, but any future caller reading the signature
will reasonably assume `null` covers every failure path. Consider either catching rejections here
and returning `null` (matching the documented contract), or documenting explicitly that IO failures
reject.



─── apps/ai-game-creator-shell/src/view/project-development/index.tsx:4338-4340 ───
[bug · high] `handleResourceBookWheel` is registered twice for the same wheel events: once here as a
native `wheel` listener on `resourceBookManagerRef`, and once as React's `onWheel` on
`<ResourceBookScene ... onWheel={handleResourceBookWheel} />`. The scene lives inside the manager,
so a wheel event bubbles through both handlers. Since the handler mutates
`resourceBookMainViewportRef` / `resourceCanvasViewportRef` before applying the viewport, the second
invocation reads the already-updated value and pans/zooms a second time — doubling every wheel
pan/zoom step. Keep the native `passive: false` listener (it is what actually suppresses native
scrolling) and remove the `onWheel` prop from `ResourceBookScene` (and its `onWheel` prop) to avoid
double handling.



─── apps/ai-game-creator-shell/src/view/project-development/index.tsx:5546-5548 ───
[bug · medium] After `resolveResourceDeriveSource` normalizes a task-produced resource into an
asset, `onManifestChange` re-projects the resource under a new id (`asset:<id>`), while
`quickEditSourceLayer.id` still holds the pre-normalization id (`task:...`). On a retry after a
derive failure, `canvasResources.find((item) => item.id === layer.id)` therefore returns `undefined`
and `submitResourceQuickEdit` falls into the early return without any user feedback — the submit
button silently does nothing. Consider resolving the source via
`resourceQuickEditRequestRef.current.normalizedAssetId` (mapping to `asset:<id>`) when the layer-id
lookup misses, or closing/resyncing the panel after normalization changes the resource identity.



─── apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx:486-495 ───
[bug · high] This `KEY_ENTER_COMMAND` handler is registered at `COMMAND_PRIORITY_HIGH` and
unconditionally submits the form on Enter. `LexicalTypeaheadMenuPlugin` registers its own Enter
handler at `COMMAND_PRIORITY_NORMAL` to select the currently highlighted mention, but this handler
runs first and returns `true`, which stops command propagation. As a result, pressing Enter while
the `@` mention menu is open submits the chat (or opens the polish reminder) instead of inserting
the selected mention. Return `false` when the mention menu (and ideally the picker) is open so the
typeahead plugin can consume the key — e.g. keep a `mentionMenuOpenRef`/`pickerOpenRef` (or the
existing `query !== null` / `pickerOpen` state) and guard the submit path.

          (event) => {
-           if (!event || event.shiftKey || event.isComposing) {
+           if (
+             !event ||
+             event.shiftKey ||
+             event.isComposing ||
+             mentionMenuOpenRef.current ||
+             pickerOpenRef.current
+           ) {
              return false;
            }
            event.preventDefault();
            (event.target as HTMLElement | null)
              ?.closest('form')
              ?.requestSubmit();
            return true;
          },


─── apps/ai-game-creator-shell/src/styles.css:10151-10158 ───
[bug · medium] This `:focus-within` ring is dead code: the earlier, more specific rule
`.game-workbench-chat .project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex .resource-reference-input:focus-within` (line ~9889)
sets `box-shadow: none` and `outline: none` and wins on specificity (6 classes vs 5). In
direct-codex mode the composer element always carries `is-direct-codex`, so this rule never applies.
Combined with the base `.resource-reference-input-editor { outline: 0 }`, the direct-codex input
ends up with no visible focus indicator at all (there is no `:focus-within` rule on the composer box
either), which is an accessibility regression. Consider either removing the conflicting `box-shadow:
none` from the higher-specificity rule, or adding a `:focus-within` style to
`.project-supervisor-composer.is-direct-codex` so focus remains visible on the surrounding box.



─── apps/ai-game-creator-shell/src/view/project-development/ResourceAssetDeleteDialog.tsx:46-51 ───
[bug · medium] While `deleting` is in flight, the dialog's ×/取消/删除 buttons are disabled, but
`ThemedModal` defaults `closeOnEscape`/`closeOnBackdrop` to `true`, so the user can still dismiss
this confirmation via Escape or backdrop click. The delete then continues in the background and the
panel looks like it was cancelled. Consider passing `closeOnEscape={!deleting}` and
`closeOnBackdrop={!deleting}` (or gating `onClose` on `deleting`) so the modal cannot be dismissed
mid-operation.



─── apps/ai-game-creator-shell/src/view/project-development/ResourceRenameDialog.tsx:45-50 ───
[bug · medium] Same modal-dismissal inconsistency as the delete dialog: the ×/取消/重命名 buttons are
disabled while `renaming` is true, but `ThemedModal`'s default `closeOnEscape`/`closeOnBackdrop`
still let the user dismiss the dialog via Escape or backdrop click mid-rename. Consider
`closeOnEscape={!renaming}` and `closeOnBackdrop={!renaming}`.



─── apps/ai-game-creator-shell/src/view/project-development/ResourceFilterPanel.tsx:22-29 ───
[maintainability · low] This helper duplicates `tagChipClassName` in
`packages/shared/src/components/PlatformResourceFilterBar.tsx` byte-for-byte. The comment says the
two should stay in sync, but the class-name string is maintained in two places, so a change to the
shared bar will silently drift here. Consider exporting the shared helper and reusing it.



─── apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts:309-309 ───
[maintainability · medium] `composerInput` is captured once and then reused for ~40
`composerValue(composerInput)` / `document.activeElement` assertions after multiple intervening
`await submitChat(...)` and `fireEvent` interactions that re-render the chat pane. If the composer
node is ever remounted (key change, conditional render), the cached reference becomes detached and
`composerValue` reads stale content while the `document.activeElement` checks fail with a confusing
timeout. The sibling suites in this refactor consistently use the fresh-query `composerText()`
helper, which re-queries the DOM on every call. Consider using `composerText()` for the value
assertions and re-querying `screen.getByLabelText('创作想法')` inside the `waitFor` focus checks to stay
consistent and robust against remounts.



─── apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css:962-970 ───
[bug · medium] `.game-resource-panel` is a `flex-direction: column` container capped only by
`max-height`, and this grid has no `min-height: 0` or `flex` sizing. As a flex item its `min-height:
auto` stops it from shrinking below its content, so when there are many cards the grid keeps its
full content height and the `overflow-y: auto` never engages — the cards spill out of the panel
(which also has no `overflow` clipping). Add `flex: 1 1 auto; min-height: 0;` (and ideally
`overflow: hidden` on the panel) so the grid becomes the actual scroll region.

  .game-resource-panel-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
    gap: 0.6rem;
    margin: 0;
    padding: 0;
+   flex: 1 1 auto;
+   min-height: 0;
    overflow-y: auto;
    list-style: none;
  }


─── apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetTransferModel.ts:71-75 ───
[bug · medium] `resolveDownloadablePanelEntries` (and the `downloadable` field it filters on) is
never referenced anywhere in the app. The download path instead passes
`resolveSelectedPanelEntries(...)` straight into `saveProjectResourcesToDisk`, and `selectable` is
hardcoded to `true` for every entry. Version cards have `downloadable: false` and a synthetic `path`
(e.g. `项目版本 · ...`), so selecting one and clicking 下载 forwards that fake path to
`save_local_project_asset_file`, which fails. Wire the download flow through this helper (or mark
non-downloadable entries non-selectable) so `downloadable: false` entries never reach the copy
chain.



─── packages/shared/src/components/styles.css:528-530 ───
[bug · medium] This rule applies to both `layout="scroll"` and `layout="grid"`, but only the scroll
branch has an overflow container (`overflow-x-auto`). The grid branch is `display: grid` with
`grid-cols-N` tracks (`minmax(0, 1fr)`) and no overflow handling, so `min-width: max-content` forces
items wider than their 1fr tracks in narrow containers and the overflowing content paints over
adjacent cells — reintroducing the overlap this rule is meant to prevent. Consider scoping the
minimum to the scroll layout (e.g. a `platform-segmented-tabs--scroll` modifier emitted by the
scroll branch) and letting the grid layout rely on `truncateLabels`/track sizing instead.

- .platform-segmented-tabs > button {
+ .platform-segmented-tabs--scroll > button {
    min-width: max-content;
  }


─── packages/shared/src/components/PlatformResourceFilterBar.tsx:119-119 ───
[maintainability · low] `onToggleTag` is optional, but whenever `tagItems` is non-empty these chips
still render as enabled buttons with `aria-pressed`, so omitting the handler leaves
interactive-looking controls that do nothing. Either make `onToggleTag` required when tags are
provided (e.g. a discriminated union on `tagItems`) or disable the buttons when no handler is
supplied.

                  onClick={() => onToggleTag?.(option.tag)}
+                 disabled={!onToggleTag}


─── apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts:207-216 ───
[maintainability · low] Many newly added tests assert layout/geometry by reading `src/styles.css`
and `index.tsx` as raw text (via `styleRuleBody`/`styleNumber` and regex source scans). These
assertions are order-sensitive and coupled to exact declaration formatting plus hardcoded geometry
numbers (e.g. 228/140/96 in the composer-inset test, z-index comparisons, `styleRuleBody` only
matches the first rule per selector). Any unrelated comment addition, rule reorder, or formatting
refactor in production CSS/TSX will fail these tests without actually validating the rendered
layout. Consider consolidating these source-level checks into a shared helper/constants file so a
single place documents the pinned geometry, and keep the mapping from assertion to the intended
regression documented (some tests already do this — e.g. the comment-stripping in the overview-grid
test — but the pattern is repeated inconsistently across the file).

- /** 取 CSS 源文件里某条规则的声明体;jsdom 不加载这些样式表,可见性只能钉在声明上。 */
- function styleRuleBody(styles: string, selector: string) {
-   const match = new RegExp(`${selector}\\s*\\{([^}]*)\\}`, 'su').exec(styles);
-   expect(match, `${selector} 规则缺失`).not.toBeNull();
-   return match![1]!;
- }
- 
  function styleNumber(body: string, property: string) {
-   return Number(new RegExp(`${property}\\s*:\\s*(\\d+)`).exec(body)?.[1]);
+   const match = new RegExp(`${property}\s*:\s*(\d+)`).exec(body);
+   return match ? Number(match[1]) : Number.NaN;
  }


─── apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts:90-93 ───
[maintainability · low] The `resource` parameter is declared but never read inside this function
(and `previewReadErrorMessage`'s `resource` parameter is likewise only forwarded here, so it is also
effectively unused). After the refactor to `projectResourceDisplayKind`, the message label is
derived entirely from `kind`. Remove the unused parameter from both functions to keep the signature
honest.

  function resourceReadKindLabel(
-   resource: ProjectResource,
    kind: ProjectResourceCardPreviewKind,
  ) {


─── apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts:203-208 ───
[maintainability · low] The base-name extraction (`label.replace(/\.[^.]+$/u, '').trim()`)
duplicates the logic already in `defaultDerivedResourceName` above. Consider extracting a shared
helper (e.g. `resourceBaseName(resource)`) so the two naming rules can't drift apart in the future.

  export function defaultCharacterAnimationResourceName(
    resource: ProjectResource,
  ) {
-   const baseName = resource.label.replace(/\.[^.]+$/u, '').trim();
-   return `${baseName || '资源'}-角色动画`;
+   return `${resourceBaseName(resource)}-角色动画`;
  }


─── apps/ai-game-creator-shell/src/view/project-development/index.tsx:1595-1596 ───
[bug · medium] `characterAnimationPanel` is omitted from the floating-panel dismiss/Escape
condition. The comments in `clearResourceCanvasFocus` and `openResourceCharacterAnimationPanel`
state that the「生成动画」panel follows the same outside-click/Esc dismissal as quick-edit, but
`isFloatingPanelOpen` only checks `quickEditPanel !== null || resourceInfoPanelOpen`. When only the
character-animation panel is open, `resolveResourceCanvasFloatingPanelDismissOpen` returns false, so
the shared `useImageCanvasFloatingOptionDismiss` hook never arms, and clicking outside the canvas
manager (chat pane, top toolbar) leaves the animation panel open. The Escape path has the same
omission (it only works by luck because `hasSelectedResource` is usually still true). Include the
panel so the two floating panels actually share one dismissal rule.

        isCanvasVisible: mode === 'resources' && !uiEditorRoute,
-       isFloatingPanelOpen: quickEditPanel !== null || resourceInfoPanelOpen,
+       isFloatingPanelOpen:
+         quickEditPanel !== null ||
+         resourceInfoPanelOpen ||
+         characterAnimationPanel !== null,


─── apps/ai-game-creator-shell/src/view/project-development/resourceCanvasSectionMapping.ts:32-32 ───
[maintainability · low] `LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK` is exported but never referenced
by production code — `resolveResourceCanvasSection` returns `null` when the current category is not
in the legacy target set, and never consults this map. The constant is only exercised by tests, and
its doc comment (“无法精确归并时回落到的栏目”) contradicts the actual implementation (which treats those cases as
genuinely unmappable). Either wire it into `resolveResourceCanvasSection` or remove it to avoid a
misleading dead export.



─── apps/ai-game-creator-shell/src/styles.css:5790-5814 ───
[bug · medium] These `:has()` hover-highlight rules use stale category values. The actual
thumbnail/titlebar `data-resource-book-category` values come from `PROJECT_RESOURCE_CANVAS_SECTIONS`
(`ui-interaction`, `character`, `scene`, `audio`, `document`, `unclassified`, `version`), while
`code` and `art` are legacy values that never render in the overview. As a result the `code`/`art`
selectors are dead code, and four real categories (`ui-interaction`, `character`, `scene`,
`unclassified`) have no hover-highlight at all. Replace the category list with the current six
categories.

  .game-resource-book-manager--main:has(
-     .game-resource-book-thumbnail[data-resource-book-category='code']:hover,
-     .game-resource-book-thumbnail[data-resource-book-category='code']:focus-visible
+     .game-resource-book-thumbnail[data-resource-book-category='ui-interaction']:hover,
+     .game-resource-book-thumbnail[data-resource-book-category='ui-interaction']:focus-visible
    )
-   .game-resource-book-scene-titlebar[data-resource-book-category='code'],
+   .game-resource-book-scene-titlebar[data-resource-book-category='ui-interaction'],
+ .game-resource-book-manager--main:has(
+     .game-resource-book-thumbnail[data-resource-book-category='character']:hover,
+     .game-resource-book-thumbnail[data-resource-book-category='character']:focus-visible
+   )
+   .game-resource-book-scene-titlebar[data-resource-book-category='character'],
+ .game-resource-book-manager--main:has(
+     .game-resource-book-thumbnail[data-resource-book-category='scene']:hover,
+     .game-resource-book-thumbnail[data-resource-book-category='scene']:focus-visible
+   )
+   .game-resource-book-scene-titlebar[data-resource-book-category='scene'],
+ .game-resource-book-manager--main:has(
+     .game-resource-book-thumbnail[data-resource-book-category='audio']:hover,
+     .game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible
+   )
+   .game-resource-book-scene-titlebar[data-resource-book-category='audio'],
  .game-resource-book-manager--main:has(
      .game-resource-book-thumbnail[data-resource-book-category='document']:hover,
      .game-resource-book-thumbnail[data-resource-book-category='document']:focus-visible
    )
    .game-resource-book-scene-titlebar[data-resource-book-category='document'],
- .game-resource-book-manager--main:has(
-     .game-resource-book-thumbnail[data-resource-book-category='version']:hover,
-     .game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible
-   )
-   .game-resource-book-scene-titlebar[data-resource-book-category='version'],
  .game-resource-book-manager--main:has(
-     .game-resource-book-thumbnail[data-resource-book-category='art']:hover,
-     .game-resource-book-thumbnail[data-resource-book-category='art']:focus-visible
+     .game-resource-book-thumbnail[data-resource-book-category='unclassified']:hover,
+     .game-resource-book-thumbnail[data-resource-book-category='unclassified']:focus-visible
    )
-   .game-resource-book-scene-titlebar[data-resource-book-category='art'],
+   .game-resource-book-scene-titlebar[data-resource-book-category='unclassified'],
  .game-resource-book-manager--main:has(
-     .game-resource-book-thumbnail[data-resource-book-category='audio']:hover,
-     .game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible
+     .game-resource-book-thumbnail[data-resource-book-category='version']:hover,
+     .game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible
    )
-   .game-resource-book-scene-titlebar[data-resource-book-category='audio'] {
+   .game-resource-book-scene-titlebar[data-resource-book-category='version'] {


─── apps/ai-game-creator-shell/src/styles.css:6873-6875 ───
[bug · medium] With the base `.game-resource-card` now using `border: 0`, the existing
`.game-resource-card:hover`, `:focus-within` and `.is-selected` rules (which only set `border-color:
#d57b51`) have no visible effect — `border-color` alone cannot paint a zero-width border. This
mirrors the `is-relation-version-binding` problem the comment above fixes, but the
hover/selected/focus states were left behind. Add a real `border: 1px solid #d57b51` to those state
rules (or restore a base border) so selection and focus states remain distinguishable.

-   overflow: visible;
-   border: 0;
-   border-radius: 12px;
+ .game-resource-card:hover,
+ .game-resource-card:focus-within,
+ .game-resource-card.is-selected {
+   border: 1px solid #d57b51;
+   outline: 0;
+   box-shadow: 0 8px 22px rgb(195 105 62 / 15%);
+ }


─── apps/ai-game-creator-shell/src/styles.css:10198-10204 ───
[bug · low] The comment states the status should share the row with the actions, but `grid-row: 2`
combined with `grid-column: 1 / -1` places the status in a row of its own (or overlapping the
actions, depending on the two-column base grid). When polishing starts/fails the input still grows
by an extra row, which is exactly what the comment says this rule was written to avoid. Put the
status text and the action buttons in the same flex/grid row instead of assigning the status a
dedicated full-width row.



─── apps/ai-game-creator-shell/src/styles.css:10206-10224 ───
[maintainability · low] This rule targets `.resource-reference-input-at`, but in the direct-codex
composer the `@` button inside `ResourceReferenceInput` is never rendered — `ProjectSupervisorView`
passes `showTriggerButton={!directCodex}`, and the direct-codex `@` trigger is the separate
`.project-supervisor-reference-trigger` button. The rule therefore never matches and can be removed
to avoid dead styles.



─── packages/shared/src/components/PlatformResourceFilterBar.tsx:107-107 ───
[style · low] The tag chip row uses `platform-category-chip-scroll` (which sets `overflow-x: auto`)
but does not apply `scrollbar-hide`. This contradicts the `.scrollbar-hide` comment in styles.css,
which states both the segmented tabs' `layout="scroll"` and the tag row rely on it to keep the
appearance clean, and it makes the tag row the only horizontal scroll container that still paints a
visible scrollbar. Consider adding `scrollbar-hide` here (or updating the comment if the visible
scrollbar is intentional).

-           className="platform-category-chip-scroll min-w-0 flex-1"
+           className="platform-category-chip-scroll scrollbar-hide min-w-0 flex-1"


─── packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts:51-51 ───
[maintainability · low] The asset ID ordering relies on `localeCompare` with the runtime's default
locale, while the outer tag sort explicitly pins `'zh-CN'`. The documented guarantee here is a
reproducible "stable ascending" order across input permutations, but the default locale is
environment-dependent, so asset IDs could theoretically be ordered differently on machines with
different locales. Since asset IDs are typically ASCII (UUIDs/numeric strings), prefer deterministic
code-unit ordering (`[...assetIds].sort()`) or pin the same explicit locale used for the tag sort.

-     assetIds: [...assetIds].sort((left, right) => left.localeCompare(right)),
+     assetIds: [...assetIds].sort(),


─── scripts/lint-staged-rustfmt.mjs:7-10 ───
[maintainability · low] The `*.rs` hook in package.json matches any staged Rust file, but this list
only checks two workspaces. Staged `.rs` files under `apps/desktop-shell/src-tauri` (a third
standalone Cargo manifest with many `src/*.rs` files) will run this script yet never be
format-checked, so the hook passes silently for them. Consider adding
`apps/desktop-shell/src-tauri/Cargo.toml` here (and in `check:rustfmt`/`format:rust` for
consistency), or explicitly documenting/narrowing the glob if desktop-shell is intentionally
excluded.

  const workspaces = [
    'server-rs/Cargo.toml',
    'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
+   'apps/desktop-shell/src-tauri/Cargo.toml',
  ];


─── apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts:163-166 ───
[maintainability · low] The new navigation/assertion helpers drive the resource book through
internal class names and data attributes (e.g. `data-resource-book-view`,
`.game-resource-book-scene-card`, `.game-resource-book-scene-titlebar`) instead of RTL role/text
queries. This tightly couples the suites to implementation details: a refactor that renames any of
these selectors will break a large number of tests without catching a product regression, and
structural queries are much harder to read than accessible-name queries. Prefer roles/accessible
names (or stable test ids) for user-visible behavior, and reserve raw class/data-attribute selectors
for the few FLIP/z-index invariants that genuinely require them.

  async function openResourceBookCategory(label: string) {
    const categoryByLabel: Record<string, string> = {
      'UI 交互': 'ui-interaction',
      角色与对象: 'character',


─── server-rs/crates/shared-contracts/src/game_creation_app.rs:510-512 ───
[bug · high] The new wire struct has no `deny_unknown_fields` and no flattening that preserves
unknown keys, so serde silently discards any future asset-level fields. This contradicts the
top-level `GameCreationAppManifest` change, which adds `deny_unknown_fields` precisely because AGC
reads a manifest as a whole and rewrites the entire file — a read followed by any write would
silently erase newer `assets[i]` fields (e.g. a future `rotation` or `animations` key). Add
`deny_unknown_fields` here so unknown asset fields fail closed too.

  #[derive(Deserialize)]
- #[serde(rename_all = "camelCase")]
+ #[serde(deny_unknown_fields, rename_all = "camelCase")]
  struct GameCreationAppAssetManifestEntryWire {


─── server-rs/crates/api-server/src/editor_project.rs:4120-4120 ───
[bug · medium] This whitelist claims to cover every legal legacy static-image alias, but it omits
`"asset"`, which `canonical_game_creation_app_asset_kind` maps to `"image"`.
`register_local_asset_entry` (assets.rs) defaults an empty kind to `"asset"`, so an unclassified PNG
upload records `kind: "asset"` with `mediaType: "image/png"` and would still be rejected with 400 on
quick edit even though it is a static image. Add `"asset"` to keep the whitelist closed under the
canonical static-image aliases.

- pub(crate) const EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS: [&str; 17] = [
+     // AGC 本地 manifest 等在用的等价静态图类型
+     "ui",
+     // register_local_asset_entry 空 kind 兜底为 "asset"(canonical 映射到 image)
+     "asset",
+ ];


─── server-rs/crates/shared-contracts/src/game_creation_app.rs:534-538 ───
[bug · medium] For an unknown `category` value this silently substitutes a kind-derived category, so
the deserialized `category` is no longer the persisted original — contradicting the doc comment
stating the deserialization result is the on-disk value used for write-back. A newer client's
`category: "future-category"` would be read, replaced with the kind-derived value, and written back,
silently mutating the manifest on an unrelated edit. Consider failing closed on unknown category
values (consistent with the new manifest-level fail-closed policy) or preserving the raw value for
round-trip, and keeping derivation only in `game_creation_app_asset_effective_category`.



─── server-rs/crates/shared-contracts/src/game_creation_app.rs:780-780 ───
[test · low] The doc comment requires this order to stay in sync with the frontend
`PROJECT_RESOURCE_CANVAS_SECTIONS`, but the new test only asserts the Rust serialization order and
nothing cross-checks the TS constant (currently `GAME_CREATION_APP_ASSET_CATEGORIES + 'version'`).
The codebase already parses Rust constants from TS tests elsewhere (e.g.
`EFFECTIVE_CATEGORY_CONTRACT` in `assetKindCanonicalMapping.test.ts`); a similar cross-check would
prevent the two lists from drifting silently.



─── scripts/lint-staged-rustfmt.mjs:12-12 ───
[maintainability · medium] The hook ignores lint-staged's staged file list (`process.argv`) and
always runs `cargo fmt --all -- --check` on both hardcoded workspaces. Because `--all` checks every
file in each workspace — including unstaged, in-progress edits from other work — a commit that only
stages clean `.rs` files (or files in a single workspace) can be blocked by unrelated formatting
errors in another workspace's unstaged files. This defeats the purpose of a staged-only pre-commit
check. Consider using the staged paths to run the check only for workspaces that actually contain
staged `.rs` files (and/or document the whole-workspace false-failure behavior), e.g. filter
`workspaces` by `process.argv.slice(2)` path prefixes before looping.

- for (const manifestPath of workspaces) {
+ const stagedFiles = process.argv.slice(2);
+ const workspaces = [
+   { prefix: 'server-rs', manifestPath: 'server-rs/Cargo.toml' },
+   {
+     prefix: 'apps/ai-game-creator-shell/src-tauri',
+     manifestPath: 'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
+   },
+ ].filter(({ prefix }) =>
+   stagedFiles.some((file) => file.startsWith(`${prefix}/`)),
+ );
+ 
+ for (const { manifestPath } of workspaces) {


─── packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts:68-69 ───
[bug · low] `assetTagsMatchSelection` normalizes only the asset side (`tags`) via
`normalizeGameCreationAppAssetTags`, but compares `selectedTags` raw. Since the asset tags are
trimmed but the selected tags are not, any unnormalized selected tag (e.g. `' 像素风 '`) will silently
fail to match — even though the same tag exists on the asset after trimming. For a shared contract
function this asymmetry is easy to trip over; normalize `selectedTags` the same way before matching
(or document the precondition).

    const normalized = new Set(normalizeGameCreationAppAssetTags(tags ?? []));
-   return selectedTags.every((tag) => normalized.has(tag));
+   const selected = normalizeGameCreationAppAssetTags(selectedTags);
+   return selected.every((tag) => normalized.has(tag));


─── src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx:55-60 ───
[bug · medium] This effect resets the dialog's local `query`/`category`/`selection` whenever the
parent re-renders while the dialog is open. `selectedAssetIds` is recomputed in
`useImageCanvasGenerationSurface` as a fresh `.filter().flatMap()` array on every render, so its
identity changes on every parent render, defeating the "reset only on open" intent and wiping
in-progress search/filter/selection (e.g. during background editor re-renders). Reset only on the
open transition, e.g. depend on `[open]` (the render where `open` flips to `true` already carries
the latest initial selection), or memoize `projectAssetPickerSelectedIds` in the parent.

    useEffect(() => {
      if (!open) return;
      setQuery('');
      setCategory('all');
      setSelection([...selectedAssetIds]);
-   }, [open, selectedAssetIds]);
+     // eslint-disable-next-line react-hooks/exhaustive-deps -- only reset when the dialog opens
+   }, [open]);


─── src/components/image-editor/projectAssetReferencePickerModel.ts:28-31 ───
[bug · medium] `EditorAsset.mediaType` can also be `'image-sequence'`, which falls through to
`'image'` here, so image-sequence assets are shown as selectable image cards. But
`projectAssetPickerReferences` keeps `mediaType: asset.mediaType` (`'image-sequence'`), and
`appendLimitedImageReferences` only keeps references whose mediaType is exactly `'image'`. A
selected image-sequence asset is therefore silently dropped after confirmation (with a misleading
"参考图只支持图片素材" warning). Consider excluding image-sequence (and any non-`'image'`) assets from the
selectable list, or normalizing the mediaType used for reference filtering so category display and
reference acceptance stay consistent.



─── server-rs/crates/api-server/src/editor_project.rs:20747-20755 ───
[test · low] This test is titled and commented as a regression test for the AGC client's quick-edit
payload, but `agc_payload` is never passed into any function under test. The only assertions call
`ensure_editor_image_edit_source_kind_allowed` directly with hardcoded strings, and the removed
`ensure_editor_image_edit_source_kind_allowed_for_request` was the sole consumer of
`generationInputs`. As a result, the fixture assertion is trivially self-consistent and provides no
protection against a future change reintroducing a `generationInputs.source` gate in
`resolve_editor_image_edit_source`/`ensure_editor_image_edit_target_matches_source`. Either drive
the actual request-resolution path with this payload, or reword the comment so it doesn't claim the
request shape is being exercised.
面对review吧( ``` ─── apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs:76-78 ─── [bug · high] No guard prevents `destination` from resolving to the same file as `source`. `File::open` happens first, but on a path-equivalent destination `File::create` truncates the file, so the copy then reads 0 bytes and reports `byte_len = 0` "success" while the source asset has been destroyed. The frontend uses a native save dialog (`saveNativeFileDialog`), which lets the user navigate into the project assets directory and pick the source itself. Compare canonicalized paths before creating the writer. + let source_canonical = source.canonicalize().map_err(|error| format!("解析素材源路径失败:{error}"))?; + if destination.exists() { + let destination_canonical = + destination.canonicalize().map_err(|error| format!("解析保存目标路径失败:{error}"))?; + if source_canonical == destination_canonical { + return Err("保存目标不能与素材源文件相同".to_string()); + } + } let mut reader = File::open(&source).map_err(|error| format!("打开素材文件失败:{error}"))?; let mut writer = File::create(&destination).map_err(|error| format!("创建保存目标失败:{error}"))?; ─── apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs:31-33 ─── [security · medium] This resolution only rejects a symlink at the final component. An intermediate directory inside the project (e.g. `assets` being a symlink/junction) is never checked, so a crafted project can make the resolved source escape the project root and export arbitrary files. The rename path uses `resolve_local_project_path`, which walks every component and rejects symlinks/reparse points; this function should use the same helper for a consistent confinement boundary. - let source = root.join(&normalized); + let source = resolve_local_project_path(root, &normalized)?; // 只允许保存项目根内真实存在的普通文件:符号链接与目录都要拒绝。 let metadata = std::fs::symlink_metadata(&source) ─── apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs:249-250 ─── [bug · medium] The file rename and `write_manifest` have already succeeded by this point. If `advance_agent_runtime_project_revision_locked` fails (it performs a separate read+increment+write on the revision file and can fail on IO/overflow), the function returns `Err` without rolling back the file/manifest and without a reconciliation marker, even though the mutation fully committed. A retry will then diverge: the asset is already renamed in the manifest, so the next attempt fails with "同目录已存在同名文件". Consider either advancing the revision atomically with the manifest write or reporting this as a committed-but-incomplete state with reconciliation semantics rather than a plain failure. ─── apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs:93-95 ─── [other · low] The copy writes directly to the final destination and stops at `flush` (no `sync_all`), and `File::create` truncates an existing destination before the copy result is durable. A crash or copy error mid-way leaves a truncated/partial destination file, and any prior file at that path is already lost. The manifest writer already uses the temp-file + atomic-rename pattern (`write_manifest_locked`); exporting to a sibling temp file and renaming into place would avoid both the truncation-before-completion and the partial-write exposure. ─── apps/ai-game-creator-shell/src-tauri/src/tests/asset_rename.rs:257-263 ─── [maintainability · medium] This test relies on a fault-injection hook (`RenameLocalProjectAssetFaultStage` + the `fault` parameter) that lives directly in the production signature of `rename_local_project_asset_at` in `project/asset_rename.rs`. The enum and parameter are not gated behind `#[cfg(test)]`, so any internal (crate) caller can pass `Some(ManifestWrite)` and drive the function down the rollback-only path that intentionally never writes the manifest. Consider cfg-gating the fault stage and the `fault` parameter (or exposing a test-only constructor) so test scaffolding doesn't leak into the production API surface. ─── apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2476-2476 ─── [bug · medium] The precedence implemented here is the reverse of what the doc comment above states. The comment says the value order should be `error.details.message` → `details.message` → `error.message` → …, but `details.or_else(|| error.get("details"))` gives top-level `details` priority over nested `error.details`. When a response body contains both shapes, `details.message` (often the generic fallback) wins and the actionable `error.details.message` is never surfaced — defeating the purpose of this helper. Currently the platform's own error envelope only emits `error.details` (no top-level `details`), so it is latent, but the code contradicts its documented contract and no test covers the both-present case. Swap the lookup order to match the documented priority. - let error_details = details.or_else(|| error.and_then(|error| error.get("details"))); + let error_details = error + .and_then(|error| error.get("details")) + .or(details); ─── apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2468-2468 ─── [performance · low] `response.text().await` buffers the entire response body without any size cap before parsing. This is an error path against an external editor endpoint, so a misbehaving or compromised upstream returning a very large 4xx body causes an unbounded `String` allocation in the Tauri backend (the 200-char truncation below happens only after the whole body has been parsed). Consider reading a bounded prefix, e.g. stream a limited number of bytes via `bytes_stream()` and stop once a cap is reached (or reject based on `Content-Length`). - let body = response.text().await.ok()?; + const MAX_EDITOR_ERROR_BODY_BYTES: usize = 64 * 1024; + let mut buf = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.ok()?; + if buf.len() + chunk.len() > MAX_EDITOR_ERROR_BODY_BYTES { + return None; + } + buf.extend_from_slice(&chunk); + } + let body = String::from_utf8(buf).ok()?; ─── apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts:186-191 ─── [bug · medium] `enterProjectDevelopment` is now async and awaits `resolveSessionPreviewOnProjectOpen` before committing `currentProjectContext`/`activeProjectPreview`. The project-page flows (`openProject`, `createProjectFromProjectPage`) serialize through `projectActionRef`, but the home-create flow (`createHomeProjectFromDirectory` -> `enterCreatedHomeProject`) has no equivalent guard, so it can overlap with another entry (e.g. a quick home-create while an open is still in flight). The slower call can resolve last and overwrite the newer project, leaving the workspace bound to the wrong project. Consider capturing a monotonic entry token before the await and discarding the result if it no longer matches after the await (or a shared in-flight guard across all entry paths). + const entryToken = ++projectEntryTokenRef.current; const session = await resolveSessionPreviewOnProjectOpen({ invoke: resolveTauriInvoke(), projectPath: context.projectPath, recordedPreview: context.manifest.preview ?? null, }); + if (entryToken !== projectEntryTokenRef.current) { + return; + } setCurrentProjectContext( ─── apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx:193-196 ─── [bug · medium] `patchStage` updates whichever `manifestMergeNotice` is currently displayed, but it is not correlated with the rejection that started this recovery. In `applyManifestSnapshot` a new rejection unconditionally overwrites `manifestMergeNotice` even when a recovery for the same project is already in flight (the new recovery is skipped), so the in-flight recovery's completion (`recovered`/`unresolved`) gets stamped onto a later rejection that was never re-read. Correlate the completion patch with the recovery's input (e.g. only patch when the current notice's `heldRevision`/`snapshotRevision`/`decision` match `input`), or queue a distinct recovery per rejection. const patchStage = (stage: ProjectManifestMergeRecoveryStage) => setManifestMergeNotice((current) => - current ? { ...current, stage } : current, + current && + current.projectId === input.projectId && + current.heldRevision === input.heldRevision && + current.snapshotRevision === input.snapshotRevision + ? { ...current, stage } + : current, ); ─── apps/ai-game-creator-shell/src/features/app-shell/sessionPreview.ts:77-81 ─── [bug · low] On a failed `get_local_game_preview_status` read, the code falls through to the `recordedPreview?.status === 'running'` branch and invokes `stop_local_game_preview`, which stops any *live* preview (`registry.stop_for_project`). A read failure (e.g. `preview.status` permission denied, or a transient invoke error) does not prove that no preview is running, so this path can kill a genuinely running preview while intending only to clean up a stale record. When the status read fails, skip the stop and return `{ sessionPreview: null, manifestPreviewPatch: null }` instead of treating it as confirmed-not-running. } catch { - // 读不到 registry 就按"没有在跑"处理:宁可让用户点一下「运行」,也不要进一个 - // 打不开的运行界面。 - status = null; + // 读不到 registry 就无法确认真相:不当作"确认没有在跑"去停掉活体预览, + // 只按"不带预览进入"处理。 + return { sessionPreview: null, manifestPreviewPatch: null }; } ─── apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs:1386-1386 ─── [bug · medium] The primary manifest mutation path (`mutate_manifest_at_allowing_version_removals`, used by asset register/update/delete, classification updates, and version append/removal) writes via `write_manifest_locked` directly and never invalidates the preview cache with `forget_preview_manifest`. Only `write_manifest_with_lock_hook` calls `forget_preview_manifest` on success. The cache's own documentation states that the len/mtime/file-identity heuristics alone cannot reliably catch same-length in-place rewrites within the same mtime granularity — and on Windows volumes where `windows_file_identity` returns a constant file index (FAT/exFAT, some network shares) the identity check is a no-op, so a stale manifest can be served after a mutation. Add `forget_preview_manifest(&manifest_path)` after a successful write here. + let result = mutate(&mut manifest)?; write_manifest_locked(&manifest_path, &manifest, allowed_version_removals)?; + forget_preview_manifest(&manifest_path); + Ok(result) ─── apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs:1140-1140 ─── [maintainability · low] This classification command acquires the project write lock with the `"asset.register"` label, copied from the asset registration path. The label is written into the lock file's `commandId` and surfaced in contention diagnostics via `ProjectWriteLockSnapshot::describe_holder` (`commandId=...`), so a classification update will be misattributed as an asset registration whenever lock contention is reported. Use a classification-specific label instead. - let _lock = acquire_project_write_lock(root, "asset.register")?; + let _lock = acquire_project_write_lock(root, "asset.classification.update")?; ─── apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs:1180-1181 ─── [bug · medium] The audit record is appended before `advance_agent_runtime_project_revision_locked`. If revision persistence fails, the classification change and its audit are already durable while the revision stays unchanged and the caller receives an error. A retry with the same `expectedProjectRevision` then passes the CAS check, re-writes the identical classification, and appends a second audit record whose `previousCategory`/`previousTags` equal the new values — a misleading no-op entry that breaks the "changed from X to Y" semantics this audit is meant to preserve. Consider advancing the revision before appending the audit, or making the audit idempotent (skip when previous values already equal the new values). ─── apps/ai-game-creator-shell/src-tauri/src/assets.rs:2048-2049 ─── [bug · medium] If `advance_agent_runtime_project_revision_locked` fails here, the manifest mutation (asset and possibly referenced versions removed) has already been durably written, but the revision file stays at the old value. A retry with the same `expected_project_revision` passes the CAS checks and then fails with `项目资源不存在` because the asset is already gone — deletion is not idempotent, so this command cannot self-heal. This differs from `update_manifest_asset_classification_at`, whose retry is naturally idempotent. Consider making the mutation tolerate a missing asset (no-op success when identity/revision CAS already passed) or documenting an explicit recovery path for the stale-revision-after-delete state. - let committed_project_revision = advance_agent_runtime_project_revision_locked(root) - .map_err(|error| format!("素材登记已删除,但项目 revision 未能推进:{error}"))?; + // Consider making the mutate closure treat an already-deleted asset as a no-op + // (identity + revision CAS passed) so a retry after a failed revision advance converges. ─── apps/ai-game-creator-shell/src-tauri/src/assets.rs:2035-2042 ─── [performance · low] `asset_referencing_versions` scans all versions/bindings and is already evaluated once in the `allowed_version_removals` closure; it is recomputed here, and `Vec::contains` inside `retain` makes the filtering O(V×R) — O(V²) when many versions reference the asset. Compute the referenced version IDs once and use a `HashSet<String>` for the membership test. - let referenced_version_ids: Vec<String> = + let referenced_version_ids: std::collections::HashSet<String> = asset_referencing_versions(manifest, asset_id) .into_iter() .map(|version| version.version_id) .collect(); manifest .versions .retain(|version| !referenced_version_ids.contains(&version.version_id)); ─── apps/ai-game-creator-shell/src-tauri/src/commands.rs:1032-1032 ─── [maintainability · medium] `rename_local_project_asset` performs no identity/revision CAS, unlike the sibling mutating commands `delete_local_project_asset` and `update_local_project_resource_classification`, which both carry `expectedProjectId`/`expectedProjectRevision` and reject on `project-identity-conflict` / `project-revision-conflict`. A stale client holding an old revision can therefore rename an asset and advance the project revision without ever observing the conflict, silently operating on project state it does not actually know. Consider adding `expected_project_id`/`expected_project_revision` to `RenameLocalProjectAssetInput` and enforcing the same CAS as the other mutations. ─── apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs:3668-3669 ─── [maintainability · low] On Windows the holder opens the lock file with `share_mode(0)` (see `try_open_project_append_os_lock`), so while another process actually holds the lock, `fs::read_to_string` here always fails with a sharing violation and this diagnostic always degrades to `持锁方身份不可读`. That makes the new "who holds the lock" postmortem ineffective exactly on Windows — the platform the UAC/lock-contention changes are targeting (the new tests only seed the file *without* holding it, so this real path is never exercised). Consider writing the holder metadata to a sidecar file opened with read sharing, or documenting that the holder report is Unix-only. ─── apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs:3570-3574 ─── [other · medium] `lock_with_attempts` takes the in-process `process_lock` *before* the OS lock and holds it for the whole retry loop — up to ~10 s of `thread::sleep` for `lock()`. A same-process `lock_short` caller (the read path) then blocks on `process_lock.lock()` for the writer's entire window, so the "short window keeps read paths responsive" intent only holds across processes, not within one. Consider bounding the process-lock wait (e.g. `try_lock` with its own short window) while preserving the process→OS lock ordering to avoid an ABBA deadlock. ─── apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2514-2525 ─── [maintainability · low] The 200-char truncation is applied only to `candidate`. The `provider` / `assetKind` / `mediaType` / `code` values are read from the same external error body without any length limit and appended verbatim, so a single oversized field can still produce an arbitrarily long user-visible error message, defeating the stated intent of keeping the body out of the UI message. Consider capping each appended field (or the final `reason`) as well. if let Some(provider) = detail_text("provider") { - reason.push_str(format!("|provider {provider}").as_str()); + reason.push_str("|provider "); + reason.extend(provider.chars().take(80)); } if let Some(asset_kind) = detail_text("assetKind") { - reason.push_str(format!("|素材类型 {asset_kind}").as_str()); + reason.push_str("|素材类型 "); + reason.extend(asset_kind.chars().take(80)); } if let Some(media_type) = detail_text("mediaType") { - reason.push_str(format!("|媒体类型 {media_type}").as_str()); + reason.push_str("|媒体类型 "); + reason.extend(media_type.chars().take(80)); } if let Some(code) = code { - reason.push_str(format!("|错误码 {code}").as_str()); + reason.push_str("|错误码 "); + reason.extend(code.chars().take(80)); } ─── apps/ai-game-creator-shell/src-tauri/src/project/asset_export.rs:27-30 ─── [security · high] The export path never applies the sensitive-file / private control-path rejection used by the generic project read path. `read_local_project_file_at` calls `reject_sensitive_project_file_read` and `reject_agent_runtime_private_control_path` before opening a file, but `resolve_export_source_file` skips both, so `save_local_project_asset_file` can copy `.env`/config files and `.agent/runtime|checkpoints|workbench` control-plane files to an arbitrary destination. This also contradicts the docstring, which says only "registered" assets are exported while nothing here verifies manifest registration. Apply the same rejections after normalization (and consider validating the file is actually registered in the manifest). let normalized = normalize_relative_path(relative_path.trim())?; - if normalized.is_empty() { - return Err("待保存的素材路径不能为空".to_string()); - } + reject_agent_runtime_private_control_path(&normalized)?; + reject_sensitive_project_file_read(&normalized)?; ─── apps/ai-game-creator-shell/src-tauri/src/project/asset_rename.rs:84-84 ─── [bug · low] `frame_directory == directory` is case-sensitive while the file-name comparison on the same line is case-insensitive. On case-insensitive filesystems (Windows/macOS), a frame whose `imageSrc` uses different directory casing than the manifest `localPath` (e.g. `Assets/hero.png` vs `assets/hero.png`) points at the same file but will not be aligned after the rename, leaving a dangling frame reference. Use a platform-aware directory comparison (case-insensitive on Windows/macOS, case-sensitive on Linux) to match the filesystem semantics. - frame_directory == directory && frame_file_name.eq_ignore_ascii_case(previous_file_name) + let directory_matches = if cfg!(windows) || cfg!(target_os = "macos") { + frame_directory.eq_ignore_ascii_case(directory) + } else { + frame_directory == directory + }; + directory_matches && frame_file_name.eq_ignore_ascii_case(previous_file_name) ─── apps/ai-game-creator-shell/src/App.tsx:6013-6015 ─── [bug · medium] The `conversation.write` policy-confirmation retry path re-invokes `executeChatAgentReply` with `prompt`, `clientTurnId`, `creationType`, `attachments`, and `directPolicyChecked: true`, but omits `references`. When a user's first direct-codex turn requires policy confirmation, their attached resource references are silently dropped. Pass `references` through the retry callback to keep the turn input intact. - if (references?.length) { - directTurnInput.references = references; - } + void executeChatAgentReply({ + prompt, + clientTurnId, + creationType, + attachments, + directPolicyChecked: true, + references, + }); ─── apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_references.rs:175-182 ─── [performance · medium] `resource_ids` is the only client-supplied collection in this module with no bound. Every other field is capped (`MAX_DIRECT_CODEX_REFERENCES`, id/label/source/element/text char limits), but a single `runtime-region` reference can carry an arbitrarily long list, and this loop performs an O(assets) manifest scan per id with no dedup — so a large payload costs O(n·m) and produces an unboundedly long `关联素材 ID:a,a,a,…` line in the injected prompt (ultimately only stopped later by the 32 MiB write guard, which turns it into a confusing connection-level error). Cap the count (e.g. `take(MAX_DIRECT_CODEX_REFERENCES)`) and dedup the ids to stay consistent with the module's fail-closed boundedness. let mut related_resource_ids = Vec::new(); - for resource_id in &reference.resource_ids { + for resource_id in reference + .resource_ids + .iter() + .take(MAX_DIRECT_CODEX_REFERENCES) + { let resource_id = validate_resource_reference_id(resource_id)?; if !manifest.assets.iter().any(|asset| asset.id == resource_id) { return Err("运行画面引用的素材已变化,请重新点选".to_string()); } + if !related_resource_ids.contains(&resource_id) { related_resource_ids.push(resource_id); + } } ─── apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs:1795-1797 ─── [maintainability · low] This branch only ever fires for `PROJECT_APPEND_LOCK_TIMEOUT_MARKER`. The `PROJECT_WRITE_LOCK_CONTENTION_PREFIX` case is already returned by the earlier check at line 1779 (`当前项目仍有写入正在结束…`), so the write-lock half of `direct_project_history_contention_failure` is unreachable here — making the helper's “跨进程锁 / 项目锁争用” comment and the “或项目锁” wording misleading. If the more specific message is meant to win for write-lock contention too, move this check above line 1779; otherwise narrow the helper to the append-lock marker only. ─── apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs:984-989 ─── [maintainability · medium] The DirectProject envelope type `"response_item"` is hardcoded here while the writer side uses `DIRECT_PROJECT_HISTORY_RECORD_TYPE` (`agent/direct_project_history.rs`). These two modules share the same `project.jsonl` file, so this is a cross-file contract kept in sync only by convention. If the record type ever changes on the writer side, this reader will stop recognizing those rows and will instead fail closed with `解析对话记录失败` on every DirectProject line, breaking the shared-file read. Consider exposing the constant as `pub(crate)` and referencing it here so the contract is compiled, not duplicated. fn is_direct_project_history_row(line: &str) -> bool { serde_json::from_str::<serde_json::Value>(line).is_ok_and(|parsed| { - parsed.get("type").and_then(serde_json::Value::as_str) == Some("response_item") + parsed.get("type").and_then(serde_json::Value::as_str) + == Some(crate::agent::DIRECT_PROJECT_HISTORY_RECORD_TYPE) && parsed.get("payload").is_some() }) } ─── apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs:3827-3828 ─── [maintainability · low] When the strict validation fails with an error that is *not* elevation-related (e.g. the lock file was deleted between open and validation, or an ACL error string not matched by `windows_acl_error_may_need_elevation`), `secure_windows_game_creator_path_for_current_user_with_auto_elevation` performs no repair and returns the same error unchanged. This `map_err` then produces `{error};自动提权修复未完成:{error}` — a duplicated error with a misleading claim that an elevation repair was attempted. Consider only appending the `自动提权修复未完成` suffix when the error actually qualifies for elevation repair, otherwise propagate the underlying error as-is. ─── apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs:1480-1482 ─── [bug · medium] TOCTOU race in the cache-fill path: `read_manifest(path)` reads the file content first, and only afterwards does `remember_preview_manifest` snapshot `symlink_metadata` + `File::open` identity. If a concurrent writer replaces the manifest between those two steps, the cache stores the OLD content paired with the NEW file's identity/len/mtime. Subsequent reads then pass the identity check and return the stale manifest until the next invalidation, defeating the cache's stated correctness invariant. Capture the file identity/metadata snapshot before the read (and/or re-verify it still matches before inserting), so the cached content is guaranteed to correspond to the snapshotted file. ─── apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs:1135-1135 ─── [security · low] The normalized tags are persisted into the manifest without any bound on tag count or per-tag length. `normalize_game_creation_app_asset_tags` only trims/dedupes, and a client can submit arbitrarily many arbitrarily long tags, growing the manifest file (and the cost of every subsequent full read/parse/write) without limit. Other persisted strings here are length-capped (project name, task text, version IDs, binding counts); consider capping the tag count and per-tag character length before writing. ─── apps/ai-game-creator-shell/src-tauri/src/main.rs:29-31 ─── [maintainability · low] The four newly added imports (`game_creation_app_asset_category_for_kind`, `game_creation_app_asset_category_from_str`, `game_creation_app_asset_effective_category`, `normalize_game_creation_app_asset_tags`) are never referenced anywhere in main.rs except this import statement, so they produce `unused_imports` warnings. The modules that actually need them (commands.rs, assets.rs, etc.) already import them directly. Remove these four names (or use them here) to keep the build warning-free. - game_creation_app_asset_category_for_kind, game_creation_app_asset_category_from_str, - game_creation_app_asset_effective_category, new_game_creation_app_manifest, - new_game_creation_app_seed_tasks, normalize_game_creation_app_asset_tags, + new_game_creation_app_manifest, new_game_creation_app_seed_tasks, ─── apps/ai-game-creator-shell/src/features/project-workspace/usePromptPolish.ts:109-112 ─── [bug · medium] `polish()` wraps the awaited work in `try/finally` without a `catch`. Any rejection thrown by `requestPolish`, `normalizeResult`, `resolveContext`, or `applyPrompt` propagates out of the returned promise and never reaches the user-facing `error` state. The default `requestChatPromptPolish` swallows its own failures, but `UsePromptPolishOptions` explicitly supports injecting a custom `requestPolish` (and hosts pass `applyPrompt`/`normalizeResult`) that can reject — callers such as `polishAndSubmitFromReminder` and the inline polish button then produce an unhandled promise rejection and show no failure message. Add a catch that sets `error` and returns `null`, keeping the `finally` cleanup. + } catch { + setError(options?.failureMessage ?? failureMessage); + return null; } finally { runningRef.current = false; setPolishing(false); } ─── apps/ai-game-creator-shell/src/features/project-workspace/usePromptPolish.ts:139-143 ─── [bug · medium] `reset()` clears `originalText`/`error`/`notice` but does not invalidate an in-flight `polish()` request. If the host clears the composer or switches panels while `requestPolish` is pending (the inline polish button does not lock the input), the later `applyPrompt(normalized.text)` writes the stale polished text into the now-cleared or newly selected draft. Introduce a request-generation counter: capture it before awaiting, ignore the result if it no longer matches, and bump it inside `reset()` so superseded requests cannot write back. const reset = useCallback(() => { + requestIdRef.current += 1; setOriginalText(null); setError(null); setNotice(null); }, []); ─── apps/ai-game-creator-shell/src/features/project-workspace/ChatPromptPolishReminder.tsx:50-50 ─── [bug · medium] While `busy`, `useEscapeToClose(onClose, !busy)` disables the window-level Escape handler and the backdrop `onMouseDown` returns early, but this section-level `onKeyDown` still calls `closeDialogOnEscape(event, onClose)` with no `busy` guard. Pressing Escape during the AI polish request closes the modal even though the other close paths intentionally block closing while a request is in flight; the pending polish can then complete and submit the form after the user already cancelled. Guard this path with `busy` for consistency. - onKeyDown={(event) => closeDialogOnEscape(event, onClose)} + onKeyDown={(event) => { + if (busy) return; + closeDialogOnEscape(event, onClose); + }} ─── apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts:229-236 ─── [bug · high] The runtime-region identity key only uses `runId/label/elementTag/text`, omitting `resourceIds`, `versionId`, `elementRole`, `width`, and `height`. Two distinct runtime-region selections that happen to share the same label/runId/elementTag/text but bind different resources (e.g. two "Play" elements in the same run, or a re-inspected region whose bindings changed) collapse to the same key, so `dedupeChatReferences` silently drops one and `chatReferenceListKey`/`sameDraft` fail to emit a real draft change. Include the omitted discriminators in both `chatReferenceKey` and `chatReferenceListKey`. function chatReferenceKey(reference: ChatReference) { if (reference.type === 'resource') { return `resource:${reference.resourceId}:${reference.source}`; } + const resourceIds = [...reference.resourceIds].sort().join(','); return `runtime-region:${reference.runId ?? ''}:${reference.label}:${ reference.elementTag ?? '' - }:${reference.text ?? ''}`; + }:${reference.text ?? ''}:${reference.versionId ?? ''}:${ + reference.elementRole ?? '' + }:${reference.width ?? ''}:${reference.height ?? ''}:${resourceIds}`; } ─── apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx:425-446 ─── [bug · high] This rebuild produces editor content whose readback differs from the props it was built from: `collectDraftParts` serializes every chip as `@label`, but here the text is written as plain paragraphs and references are appended in a separate trailing paragraph. Whenever props change externally with non-empty `references` (AI polish writes plain text while references remain, or a draft is restored with empty text plus references), the `OnChangePlugin` reads back extra `@label` tokens, emits a different draft, and then line `lastEmittedDraftRef.current = nextDraft;` clobbers the emitted value with the prop draft. On the next render `sameDraft` no longer matches, the editor is rebuilt again with an additional reference paragraph, and the text grows by another `@label` each pass — a render loop. Align the two representations (embed chips inline at the mention positions instead of a separate paragraph, or make `collectDraftParts` not contribute mention text) and record `lastEmittedDraftRef` from the actually-read draft rather than the prop draft. ─── apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx:318-321 ─── [performance · low] The comment on `assetsSignature` notes that callers rebuild the `assets` array on every render, so memoizing on `[assets]` never actually skips work (and the same applies to `currentVersionAssetReferences`). Use the content signature as the memo dependency (as `assetsById` already does) so the filtering/mapping does not re-run on every render for large manifests. ─── apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx:1056-1064 ─── [performance · low] When the asset or project path changes, a new `read_local_project_image_preview` invoke is issued without cancelling the previous in-flight request; the `cancelled` flag only ignores its eventual result. Rapidly switching assets can leave overlapping preview requests running. Cancel the previous scope (via `cancelLocalProjectResourcePreviewScope`) in this effect's cleanup before issuing the new request, or otherwise fence the old request. ─── apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx:930-934 ─── [style · low] Nested ternary makes the empty-state copy hard to scan. Extract a small helper (e.g. `pickerEmptyMessage(scopeReferences.length, pickerScope)`) so each branch reads as a single flat condition. ─── apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx:29-32 ─── [bug · high] The outside-click check only tests `rootRef` (the trigger wrapper), but the open menu is rendered via `createPortal(..., document.body)`, so it is not a DOM descendant of `rootRef`. A `mousedown` on any menu option therefore hits `setOpen(false)` and the menu is removed before the option's `onClick` can fire, making version selection unreliable/broken. Attach a ref to the portal menu (declare `const menuRef = useRef<HTMLDivElement>(null)` and add `ref={menuRef}` to the menu div) and include it in the containment check, similar to how `UiNodeContextMenu` checks its own `menuRef`. const handlePointerDown = (event: MouseEvent) => { - if (rootRef.current?.contains(event.target as Node)) return; + const target = event.target as Node; + if ( + rootRef.current?.contains(target) || + menuRef.current?.contains(target) + ) { + return; + } setOpen(false); }; ─── apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx:76-78 ─── [bug · low] The menu's `top`/`right` are computed once from `getBoundingClientRect()` when it opens and never updated. If the window scrolls or resizes while the menu is open (the run preview canvas can scale/scroll), the fixed-position menu stays where it was and can drift away from the trigger or overflow the viewport. Consider recomputing on `scroll`/`resize`, or closing the menu on those events (as `UiNodeContextMenu` does). ─── apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx:167-171 ─── [style · low] This three-way branch uses a nested ternary, which the project style rules prohibit and hurts readability. Extract it into a small helper (e.g., `previewPlaceholder(entry)`) or use explicit conditional rendering instead. ─── apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx:49-52 ─── [bug · low] Escape, the backdrop `onMouseDown`, and the close button all dismiss the panel without checking `isUploading`. Closing mid-upload unmounts the view while the parent's `uploadResourcePanelFiles` keeps running, hiding the in-flight progress and any resulting error/notice until the panel is reopened. Consider disabling these close paths (or aborting the upload) while `isUploading` is true. ─── apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx:254-256 ─── [maintainability · low] The header close button is not disabled while `saving`/`deleting` is in flight, but the footer `取消`/`保存标签`/`删除` buttons are. Combined with `ThemedModal`'s default `closeOnEscape`/`closeOnBackdrop`, the user can still dismiss the panel mid-write via ×, Escape, or backdrop, after which the late `onSaved`/`onDeleted` callback fires for an unmounted panel. Consider gating this button (and the modal's escape/backdrop close) with `disabled={saving || deleting || deleteDialogPreparing}` for a consistent in-flight state. ─── apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx:68-72 ─── [maintainability · low] `resourceClassificationErrorMessage` (and the sibling `resourceDeleteErrorMessage`) surface the raw backend string/`Error.message` unchanged. For these commands that can leak internal codes such as `project-identity-conflict` / `project-revision-conflict` or filesystem paths, consider mapping known error codes to user-friendly text before rendering in the `role="alert"` element. ─── apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx:127-132 ─── [bug · medium] `sanitizeInspectIdentifier` only accepts `[A-Za-z0-9._-]`, but resource IDs in this codebase are canonicalized as `local-asset:<id>` (colon) and can also be arbitrary asset IDs (e.g. `persisted-角色草图.png`). Running `selection.resourceIds` through this filter silently drops every such ID, so the runtime-region reference loses its resource association whenever it isn't recoverable via `sourcePath`. Consider a dedicated sanitizer for resource IDs that allows `:` and printable Unicode while still capping the length. const resourceIds = Array.isArray(selection.resourceIds) ? selection.resourceIds - .map((value) => sanitizeInspectIdentifier(value, 200)) + .map((value) => sanitizeInspectText(value, 200)) .filter((value): value is string => Boolean(value)) .slice(0, 32) : []; ─── apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx:424-429 ─── [maintainability · low] The `disabled` condition was changed from `needsUserInput` to `runtimePanelProps.controlBusy || needsUserInput`, and the comment that documented the intent to allow switching models mid-conversation was removed. Since `controlBusy` is true during an active directCodex turn, users can no longer pre-select the model for the next turn. Please confirm this is intentional; otherwise restore `disabled={needsUserInput}`. ─── apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx:92-95 ─── [maintainability · low] `submitting` is set to `true` before `onSubmit`, but it is only reset back to `false` in the `catch` branch. On the success path the component therefore stays in the "生成中…" state and keeps close/cancel/backdrop/escape disabled, relying entirely on the host unmounting the panel. The current host does close on success, but this is a fragile contract for a standalone component: any caller that keeps it mounted after a resolved `onSubmit` leaves the panel permanently locked. Consider resetting `submitting` after the successful `await` (safe here, since `attempted`/`inputLocked` still prevent re-submission) or documenting the unmount requirement on the prop. } catch (submitError) { setError(resourceGenerationErrorMessage(submitError)); + } finally { setSubmitting(false); } ─── apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx:128-132 ─── [bug · low] While uploading, the download button is disabled (`disabled={selectedCount === 0 || isUploading}`) but its icon branch renders a spinning `Loader2`. The button is not downloading anything during an upload, so this reads as "下载" being in progress on a disabled control. The in-flight indicator should live on the upload action (which already shows "上传中…" text) rather than on the disabled download button. {isUploading ? ( - <Loader2 size={15} className="animate-spin" aria-hidden="true" /> + <Download size={15} aria-hidden="true" /> ) : ( <Download size={15} aria-hidden="true" /> )} ─── apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts:223-227 ─── [bug · low] The restore diff only compares `section`, `x`, and `y`, while `resourceCanvasSnapshotsEqual` also compares `manuallyPlaced`. This asymmetry means a history entry whose only difference from the current layout is the `manuallyPlaced` flag gets pushed (snapshots are considered unequal) but `resolveResourceCanvasRestoreEntries` returns an empty list, so undoing that step becomes a silent no-op. Since the caller (`commitPosition`) can only write `section`/`x`/`y` (and always sets `manuallyPlaced: true` via `moveResourceCanvasPosition`), the flag is effectively never restorable. Consider either dropping `manuallyPlaced` from `resourceCanvasSnapshotsEqual` (treating it as non-history-relevant, matching the "layout coordinates only" contract) or documenting this intentional asymmetry. ─── apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts:41-44 ─── [maintainability · low] `canonicalProjectedResourceMediaType` is typed to accept a full `ProjectResource`, but it only reads `mediaType` and `path` (verified in `resourceEditModel.ts`). The `as ProjectResource` cast here (and in `isResourceRasterImage`) silently drops the other `ProjectResource` fields, so if the helper ever starts reading more fields, this will pass `undefined` without a compile error. Consider narrowing the helper's parameter to `Pick<ProjectResource, 'mediaType' | 'path'>` (still compatible with full-resource callers) so the cast can be removed. ─── apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts:200-202 ─── [maintainability · low] The `Promise<... | null>` signature only encodes validation failures (bad revision, projectId mismatch, torn revision pair). An IPC failure in `readRevision` / `readManifest` still rejects rather than resolving to `null`, so the type understates the failure surface. Both current callers (`WorkspaceLauncher` and `uploadProjectAssetFilesAndReadSnapshot`) wrap the call in try/catch, so there is no unhandled rejection today, but any future caller reading the signature will reasonably assume `null` covers every failure path. Consider either catching rejections here and returning `null` (matching the documented contract), or documenting explicitly that IO failures reject. ─── apps/ai-game-creator-shell/src/view/project-development/index.tsx:4338-4340 ─── [bug · high] `handleResourceBookWheel` is registered twice for the same wheel events: once here as a native `wheel` listener on `resourceBookManagerRef`, and once as React's `onWheel` on `<ResourceBookScene ... onWheel={handleResourceBookWheel} />`. The scene lives inside the manager, so a wheel event bubbles through both handlers. Since the handler mutates `resourceBookMainViewportRef` / `resourceCanvasViewportRef` before applying the viewport, the second invocation reads the already-updated value and pans/zooms a second time — doubling every wheel pan/zoom step. Keep the native `passive: false` listener (it is what actually suppresses native scrolling) and remove the `onWheel` prop from `ResourceBookScene` (and its `onWheel` prop) to avoid double handling. ─── apps/ai-game-creator-shell/src/view/project-development/index.tsx:5546-5548 ─── [bug · medium] After `resolveResourceDeriveSource` normalizes a task-produced resource into an asset, `onManifestChange` re-projects the resource under a new id (`asset:<id>`), while `quickEditSourceLayer.id` still holds the pre-normalization id (`task:...`). On a retry after a derive failure, `canvasResources.find((item) => item.id === layer.id)` therefore returns `undefined` and `submitResourceQuickEdit` falls into the early return without any user feedback — the submit button silently does nothing. Consider resolving the source via `resourceQuickEditRequestRef.current.normalizedAssetId` (mapping to `asset:<id>`) when the layer-id lookup misses, or closing/resyncing the panel after normalization changes the resource identity. ─── apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx:486-495 ─── [bug · high] This `KEY_ENTER_COMMAND` handler is registered at `COMMAND_PRIORITY_HIGH` and unconditionally submits the form on Enter. `LexicalTypeaheadMenuPlugin` registers its own Enter handler at `COMMAND_PRIORITY_NORMAL` to select the currently highlighted mention, but this handler runs first and returns `true`, which stops command propagation. As a result, pressing Enter while the `@` mention menu is open submits the chat (or opens the polish reminder) instead of inserting the selected mention. Return `false` when the mention menu (and ideally the picker) is open so the typeahead plugin can consume the key — e.g. keep a `mentionMenuOpenRef`/`pickerOpenRef` (or the existing `query !== null` / `pickerOpen` state) and guard the submit path. (event) => { - if (!event || event.shiftKey || event.isComposing) { + if ( + !event || + event.shiftKey || + event.isComposing || + mentionMenuOpenRef.current || + pickerOpenRef.current + ) { return false; } event.preventDefault(); (event.target as HTMLElement | null) ?.closest('form') ?.requestSubmit(); return true; }, ─── apps/ai-game-creator-shell/src/styles.css:10151-10158 ─── [bug · medium] This `:focus-within` ring is dead code: the earlier, more specific rule `.game-workbench-chat .project-supervisor-surface.is-direct-codex .project-supervisor-composer.is-direct-codex .resource-reference-input:focus-within` (line ~9889) sets `box-shadow: none` and `outline: none` and wins on specificity (6 classes vs 5). In direct-codex mode the composer element always carries `is-direct-codex`, so this rule never applies. Combined with the base `.resource-reference-input-editor { outline: 0 }`, the direct-codex input ends up with no visible focus indicator at all (there is no `:focus-within` rule on the composer box either), which is an accessibility regression. Consider either removing the conflicting `box-shadow: none` from the higher-specificity rule, or adding a `:focus-within` style to `.project-supervisor-composer.is-direct-codex` so focus remains visible on the surrounding box. ─── apps/ai-game-creator-shell/src/view/project-development/ResourceAssetDeleteDialog.tsx:46-51 ─── [bug · medium] While `deleting` is in flight, the dialog's ×/取消/删除 buttons are disabled, but `ThemedModal` defaults `closeOnEscape`/`closeOnBackdrop` to `true`, so the user can still dismiss this confirmation via Escape or backdrop click. The delete then continues in the background and the panel looks like it was cancelled. Consider passing `closeOnEscape={!deleting}` and `closeOnBackdrop={!deleting}` (or gating `onClose` on `deleting`) so the modal cannot be dismissed mid-operation. ─── apps/ai-game-creator-shell/src/view/project-development/ResourceRenameDialog.tsx:45-50 ─── [bug · medium] Same modal-dismissal inconsistency as the delete dialog: the ×/取消/重命名 buttons are disabled while `renaming` is true, but `ThemedModal`'s default `closeOnEscape`/`closeOnBackdrop` still let the user dismiss the dialog via Escape or backdrop click mid-rename. Consider `closeOnEscape={!renaming}` and `closeOnBackdrop={!renaming}`. ─── apps/ai-game-creator-shell/src/view/project-development/ResourceFilterPanel.tsx:22-29 ─── [maintainability · low] This helper duplicates `tagChipClassName` in `packages/shared/src/components/PlatformResourceFilterBar.tsx` byte-for-byte. The comment says the two should stay in sync, but the class-name string is maintained in two places, so a change to the shared bar will silently drift here. Consider exporting the shared helper and reusing it. ─── apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts:309-309 ─── [maintainability · medium] `composerInput` is captured once and then reused for ~40 `composerValue(composerInput)` / `document.activeElement` assertions after multiple intervening `await submitChat(...)` and `fireEvent` interactions that re-render the chat pane. If the composer node is ever remounted (key change, conditional render), the cached reference becomes detached and `composerValue` reads stale content while the `document.activeElement` checks fail with a confusing timeout. The sibling suites in this refactor consistently use the fresh-query `composerText()` helper, which re-queries the DOM on every call. Consider using `composerText()` for the value assertions and re-querying `screen.getByLabelText('创作想法')` inside the `waitFor` focus checks to stay consistent and robust against remounts. ─── apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css:962-970 ─── [bug · medium] `.game-resource-panel` is a `flex-direction: column` container capped only by `max-height`, and this grid has no `min-height: 0` or `flex` sizing. As a flex item its `min-height: auto` stops it from shrinking below its content, so when there are many cards the grid keeps its full content height and the `overflow-y: auto` never engages — the cards spill out of the panel (which also has no `overflow` clipping). Add `flex: 1 1 auto; min-height: 0;` (and ideally `overflow: hidden` on the panel) so the grid becomes the actual scroll region. .game-resource-panel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.6rem; margin: 0; padding: 0; + flex: 1 1 auto; + min-height: 0; overflow-y: auto; list-style: none; } ─── apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetTransferModel.ts:71-75 ─── [bug · medium] `resolveDownloadablePanelEntries` (and the `downloadable` field it filters on) is never referenced anywhere in the app. The download path instead passes `resolveSelectedPanelEntries(...)` straight into `saveProjectResourcesToDisk`, and `selectable` is hardcoded to `true` for every entry. Version cards have `downloadable: false` and a synthetic `path` (e.g. `项目版本 · ...`), so selecting one and clicking 下载 forwards that fake path to `save_local_project_asset_file`, which fails. Wire the download flow through this helper (or mark non-downloadable entries non-selectable) so `downloadable: false` entries never reach the copy chain. ─── packages/shared/src/components/styles.css:528-530 ─── [bug · medium] This rule applies to both `layout="scroll"` and `layout="grid"`, but only the scroll branch has an overflow container (`overflow-x-auto`). The grid branch is `display: grid` with `grid-cols-N` tracks (`minmax(0, 1fr)`) and no overflow handling, so `min-width: max-content` forces items wider than their 1fr tracks in narrow containers and the overflowing content paints over adjacent cells — reintroducing the overlap this rule is meant to prevent. Consider scoping the minimum to the scroll layout (e.g. a `platform-segmented-tabs--scroll` modifier emitted by the scroll branch) and letting the grid layout rely on `truncateLabels`/track sizing instead. - .platform-segmented-tabs > button { + .platform-segmented-tabs--scroll > button { min-width: max-content; } ─── packages/shared/src/components/PlatformResourceFilterBar.tsx:119-119 ─── [maintainability · low] `onToggleTag` is optional, but whenever `tagItems` is non-empty these chips still render as enabled buttons with `aria-pressed`, so omitting the handler leaves interactive-looking controls that do nothing. Either make `onToggleTag` required when tags are provided (e.g. a discriminated union on `tagItems`) or disable the buttons when no handler is supplied. onClick={() => onToggleTag?.(option.tag)} + disabled={!onToggleTag} ─── apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts:207-216 ─── [maintainability · low] Many newly added tests assert layout/geometry by reading `src/styles.css` and `index.tsx` as raw text (via `styleRuleBody`/`styleNumber` and regex source scans). These assertions are order-sensitive and coupled to exact declaration formatting plus hardcoded geometry numbers (e.g. 228/140/96 in the composer-inset test, z-index comparisons, `styleRuleBody` only matches the first rule per selector). Any unrelated comment addition, rule reorder, or formatting refactor in production CSS/TSX will fail these tests without actually validating the rendered layout. Consider consolidating these source-level checks into a shared helper/constants file so a single place documents the pinned geometry, and keep the mapping from assertion to the intended regression documented (some tests already do this — e.g. the comment-stripping in the overview-grid test — but the pattern is repeated inconsistently across the file). - /** 取 CSS 源文件里某条规则的声明体;jsdom 不加载这些样式表,可见性只能钉在声明上。 */ - function styleRuleBody(styles: string, selector: string) { - const match = new RegExp(`${selector}\\s*\\{([^}]*)\\}`, 'su').exec(styles); - expect(match, `${selector} 规则缺失`).not.toBeNull(); - return match![1]!; - } - function styleNumber(body: string, property: string) { - return Number(new RegExp(`${property}\\s*:\\s*(\\d+)`).exec(body)?.[1]); + const match = new RegExp(`${property}\s*:\s*(\d+)`).exec(body); + return match ? Number(match[1]) : Number.NaN; } ─── apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts:90-93 ─── [maintainability · low] The `resource` parameter is declared but never read inside this function (and `previewReadErrorMessage`'s `resource` parameter is likewise only forwarded here, so it is also effectively unused). After the refactor to `projectResourceDisplayKind`, the message label is derived entirely from `kind`. Remove the unused parameter from both functions to keep the signature honest. function resourceReadKindLabel( - resource: ProjectResource, kind: ProjectResourceCardPreviewKind, ) { ─── apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts:203-208 ─── [maintainability · low] The base-name extraction (`label.replace(/\.[^.]+$/u, '').trim()`) duplicates the logic already in `defaultDerivedResourceName` above. Consider extracting a shared helper (e.g. `resourceBaseName(resource)`) so the two naming rules can't drift apart in the future. export function defaultCharacterAnimationResourceName( resource: ProjectResource, ) { - const baseName = resource.label.replace(/\.[^.]+$/u, '').trim(); - return `${baseName || '资源'}-角色动画`; + return `${resourceBaseName(resource)}-角色动画`; } ─── apps/ai-game-creator-shell/src/view/project-development/index.tsx:1595-1596 ─── [bug · medium] `characterAnimationPanel` is omitted from the floating-panel dismiss/Escape condition. The comments in `clearResourceCanvasFocus` and `openResourceCharacterAnimationPanel` state that the「生成动画」panel follows the same outside-click/Esc dismissal as quick-edit, but `isFloatingPanelOpen` only checks `quickEditPanel !== null || resourceInfoPanelOpen`. When only the character-animation panel is open, `resolveResourceCanvasFloatingPanelDismissOpen` returns false, so the shared `useImageCanvasFloatingOptionDismiss` hook never arms, and clicking outside the canvas manager (chat pane, top toolbar) leaves the animation panel open. The Escape path has the same omission (it only works by luck because `hasSelectedResource` is usually still true). Include the panel so the two floating panels actually share one dismissal rule. isCanvasVisible: mode === 'resources' && !uiEditorRoute, - isFloatingPanelOpen: quickEditPanel !== null || resourceInfoPanelOpen, + isFloatingPanelOpen: + quickEditPanel !== null || + resourceInfoPanelOpen || + characterAnimationPanel !== null, ─── apps/ai-game-creator-shell/src/view/project-development/resourceCanvasSectionMapping.ts:32-32 ─── [maintainability · low] `LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK` is exported but never referenced by production code — `resolveResourceCanvasSection` returns `null` when the current category is not in the legacy target set, and never consults this map. The constant is only exercised by tests, and its doc comment (“无法精确归并时回落到的栏目”) contradicts the actual implementation (which treats those cases as genuinely unmappable). Either wire it into `resolveResourceCanvasSection` or remove it to avoid a misleading dead export. ─── apps/ai-game-creator-shell/src/styles.css:5790-5814 ─── [bug · medium] These `:has()` hover-highlight rules use stale category values. The actual thumbnail/titlebar `data-resource-book-category` values come from `PROJECT_RESOURCE_CANVAS_SECTIONS` (`ui-interaction`, `character`, `scene`, `audio`, `document`, `unclassified`, `version`), while `code` and `art` are legacy values that never render in the overview. As a result the `code`/`art` selectors are dead code, and four real categories (`ui-interaction`, `character`, `scene`, `unclassified`) have no hover-highlight at all. Replace the category list with the current six categories. .game-resource-book-manager--main:has( - .game-resource-book-thumbnail[data-resource-book-category='code']:hover, - .game-resource-book-thumbnail[data-resource-book-category='code']:focus-visible + .game-resource-book-thumbnail[data-resource-book-category='ui-interaction']:hover, + .game-resource-book-thumbnail[data-resource-book-category='ui-interaction']:focus-visible ) - .game-resource-book-scene-titlebar[data-resource-book-category='code'], + .game-resource-book-scene-titlebar[data-resource-book-category='ui-interaction'], + .game-resource-book-manager--main:has( + .game-resource-book-thumbnail[data-resource-book-category='character']:hover, + .game-resource-book-thumbnail[data-resource-book-category='character']:focus-visible + ) + .game-resource-book-scene-titlebar[data-resource-book-category='character'], + .game-resource-book-manager--main:has( + .game-resource-book-thumbnail[data-resource-book-category='scene']:hover, + .game-resource-book-thumbnail[data-resource-book-category='scene']:focus-visible + ) + .game-resource-book-scene-titlebar[data-resource-book-category='scene'], + .game-resource-book-manager--main:has( + .game-resource-book-thumbnail[data-resource-book-category='audio']:hover, + .game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible + ) + .game-resource-book-scene-titlebar[data-resource-book-category='audio'], .game-resource-book-manager--main:has( .game-resource-book-thumbnail[data-resource-book-category='document']:hover, .game-resource-book-thumbnail[data-resource-book-category='document']:focus-visible ) .game-resource-book-scene-titlebar[data-resource-book-category='document'], - .game-resource-book-manager--main:has( - .game-resource-book-thumbnail[data-resource-book-category='version']:hover, - .game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible - ) - .game-resource-book-scene-titlebar[data-resource-book-category='version'], .game-resource-book-manager--main:has( - .game-resource-book-thumbnail[data-resource-book-category='art']:hover, - .game-resource-book-thumbnail[data-resource-book-category='art']:focus-visible + .game-resource-book-thumbnail[data-resource-book-category='unclassified']:hover, + .game-resource-book-thumbnail[data-resource-book-category='unclassified']:focus-visible ) - .game-resource-book-scene-titlebar[data-resource-book-category='art'], + .game-resource-book-scene-titlebar[data-resource-book-category='unclassified'], .game-resource-book-manager--main:has( - .game-resource-book-thumbnail[data-resource-book-category='audio']:hover, - .game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible + .game-resource-book-thumbnail[data-resource-book-category='version']:hover, + .game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible ) - .game-resource-book-scene-titlebar[data-resource-book-category='audio'] { + .game-resource-book-scene-titlebar[data-resource-book-category='version'] { ─── apps/ai-game-creator-shell/src/styles.css:6873-6875 ─── [bug · medium] With the base `.game-resource-card` now using `border: 0`, the existing `.game-resource-card:hover`, `:focus-within` and `.is-selected` rules (which only set `border-color: #d57b51`) have no visible effect — `border-color` alone cannot paint a zero-width border. This mirrors the `is-relation-version-binding` problem the comment above fixes, but the hover/selected/focus states were left behind. Add a real `border: 1px solid #d57b51` to those state rules (or restore a base border) so selection and focus states remain distinguishable. - overflow: visible; - border: 0; - border-radius: 12px; + .game-resource-card:hover, + .game-resource-card:focus-within, + .game-resource-card.is-selected { + border: 1px solid #d57b51; + outline: 0; + box-shadow: 0 8px 22px rgb(195 105 62 / 15%); + } ─── apps/ai-game-creator-shell/src/styles.css:10198-10204 ─── [bug · low] The comment states the status should share the row with the actions, but `grid-row: 2` combined with `grid-column: 1 / -1` places the status in a row of its own (or overlapping the actions, depending on the two-column base grid). When polishing starts/fails the input still grows by an extra row, which is exactly what the comment says this rule was written to avoid. Put the status text and the action buttons in the same flex/grid row instead of assigning the status a dedicated full-width row. ─── apps/ai-game-creator-shell/src/styles.css:10206-10224 ─── [maintainability · low] This rule targets `.resource-reference-input-at`, but in the direct-codex composer the `@` button inside `ResourceReferenceInput` is never rendered — `ProjectSupervisorView` passes `showTriggerButton={!directCodex}`, and the direct-codex `@` trigger is the separate `.project-supervisor-reference-trigger` button. The rule therefore never matches and can be removed to avoid dead styles. ─── packages/shared/src/components/PlatformResourceFilterBar.tsx:107-107 ─── [style · low] The tag chip row uses `platform-category-chip-scroll` (which sets `overflow-x: auto`) but does not apply `scrollbar-hide`. This contradicts the `.scrollbar-hide` comment in styles.css, which states both the segmented tabs' `layout="scroll"` and the tag row rely on it to keep the appearance clean, and it makes the tag row the only horizontal scroll container that still paints a visible scrollbar. Consider adding `scrollbar-hide` here (or updating the comment if the visible scrollbar is intentional). - className="platform-category-chip-scroll min-w-0 flex-1" + className="platform-category-chip-scroll scrollbar-hide min-w-0 flex-1" ─── packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts:51-51 ─── [maintainability · low] The asset ID ordering relies on `localeCompare` with the runtime's default locale, while the outer tag sort explicitly pins `'zh-CN'`. The documented guarantee here is a reproducible "stable ascending" order across input permutations, but the default locale is environment-dependent, so asset IDs could theoretically be ordered differently on machines with different locales. Since asset IDs are typically ASCII (UUIDs/numeric strings), prefer deterministic code-unit ordering (`[...assetIds].sort()`) or pin the same explicit locale used for the tag sort. - assetIds: [...assetIds].sort((left, right) => left.localeCompare(right)), + assetIds: [...assetIds].sort(), ─── scripts/lint-staged-rustfmt.mjs:7-10 ─── [maintainability · low] The `*.rs` hook in package.json matches any staged Rust file, but this list only checks two workspaces. Staged `.rs` files under `apps/desktop-shell/src-tauri` (a third standalone Cargo manifest with many `src/*.rs` files) will run this script yet never be format-checked, so the hook passes silently for them. Consider adding `apps/desktop-shell/src-tauri/Cargo.toml` here (and in `check:rustfmt`/`format:rust` for consistency), or explicitly documenting/narrowing the glob if desktop-shell is intentionally excluded. const workspaces = [ 'server-rs/Cargo.toml', 'apps/ai-game-creator-shell/src-tauri/Cargo.toml', + 'apps/desktop-shell/src-tauri/Cargo.toml', ]; ─── apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts:163-166 ─── [maintainability · low] The new navigation/assertion helpers drive the resource book through internal class names and data attributes (e.g. `data-resource-book-view`, `.game-resource-book-scene-card`, `.game-resource-book-scene-titlebar`) instead of RTL role/text queries. This tightly couples the suites to implementation details: a refactor that renames any of these selectors will break a large number of tests without catching a product regression, and structural queries are much harder to read than accessible-name queries. Prefer roles/accessible names (or stable test ids) for user-visible behavior, and reserve raw class/data-attribute selectors for the few FLIP/z-index invariants that genuinely require them. async function openResourceBookCategory(label: string) { const categoryByLabel: Record<string, string> = { 'UI 交互': 'ui-interaction', 角色与对象: 'character', ─── server-rs/crates/shared-contracts/src/game_creation_app.rs:510-512 ─── [bug · high] The new wire struct has no `deny_unknown_fields` and no flattening that preserves unknown keys, so serde silently discards any future asset-level fields. This contradicts the top-level `GameCreationAppManifest` change, which adds `deny_unknown_fields` precisely because AGC reads a manifest as a whole and rewrites the entire file — a read followed by any write would silently erase newer `assets[i]` fields (e.g. a future `rotation` or `animations` key). Add `deny_unknown_fields` here so unknown asset fields fail closed too. #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] + #[serde(deny_unknown_fields, rename_all = "camelCase")] struct GameCreationAppAssetManifestEntryWire { ─── server-rs/crates/api-server/src/editor_project.rs:4120-4120 ─── [bug · medium] This whitelist claims to cover every legal legacy static-image alias, but it omits `"asset"`, which `canonical_game_creation_app_asset_kind` maps to `"image"`. `register_local_asset_entry` (assets.rs) defaults an empty kind to `"asset"`, so an unclassified PNG upload records `kind: "asset"` with `mediaType: "image/png"` and would still be rejected with 400 on quick edit even though it is a static image. Add `"asset"` to keep the whitelist closed under the canonical static-image aliases. - pub(crate) const EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS: [&str; 17] = [ + // AGC 本地 manifest 等在用的等价静态图类型 + "ui", + // register_local_asset_entry 空 kind 兜底为 "asset"(canonical 映射到 image) + "asset", + ]; ─── server-rs/crates/shared-contracts/src/game_creation_app.rs:534-538 ─── [bug · medium] For an unknown `category` value this silently substitutes a kind-derived category, so the deserialized `category` is no longer the persisted original — contradicting the doc comment stating the deserialization result is the on-disk value used for write-back. A newer client's `category: "future-category"` would be read, replaced with the kind-derived value, and written back, silently mutating the manifest on an unrelated edit. Consider failing closed on unknown category values (consistent with the new manifest-level fail-closed policy) or preserving the raw value for round-trip, and keeping derivation only in `game_creation_app_asset_effective_category`. ─── server-rs/crates/shared-contracts/src/game_creation_app.rs:780-780 ─── [test · low] The doc comment requires this order to stay in sync with the frontend `PROJECT_RESOURCE_CANVAS_SECTIONS`, but the new test only asserts the Rust serialization order and nothing cross-checks the TS constant (currently `GAME_CREATION_APP_ASSET_CATEGORIES + 'version'`). The codebase already parses Rust constants from TS tests elsewhere (e.g. `EFFECTIVE_CATEGORY_CONTRACT` in `assetKindCanonicalMapping.test.ts`); a similar cross-check would prevent the two lists from drifting silently. ─── scripts/lint-staged-rustfmt.mjs:12-12 ─── [maintainability · medium] The hook ignores lint-staged's staged file list (`process.argv`) and always runs `cargo fmt --all -- --check` on both hardcoded workspaces. Because `--all` checks every file in each workspace — including unstaged, in-progress edits from other work — a commit that only stages clean `.rs` files (or files in a single workspace) can be blocked by unrelated formatting errors in another workspace's unstaged files. This defeats the purpose of a staged-only pre-commit check. Consider using the staged paths to run the check only for workspaces that actually contain staged `.rs` files (and/or document the whole-workspace false-failure behavior), e.g. filter `workspaces` by `process.argv.slice(2)` path prefixes before looping. - for (const manifestPath of workspaces) { + const stagedFiles = process.argv.slice(2); + const workspaces = [ + { prefix: 'server-rs', manifestPath: 'server-rs/Cargo.toml' }, + { + prefix: 'apps/ai-game-creator-shell/src-tauri', + manifestPath: 'apps/ai-game-creator-shell/src-tauri/Cargo.toml', + }, + ].filter(({ prefix }) => + stagedFiles.some((file) => file.startsWith(`${prefix}/`)), + ); + + for (const { manifestPath } of workspaces) { ─── packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts:68-69 ─── [bug · low] `assetTagsMatchSelection` normalizes only the asset side (`tags`) via `normalizeGameCreationAppAssetTags`, but compares `selectedTags` raw. Since the asset tags are trimmed but the selected tags are not, any unnormalized selected tag (e.g. `' 像素风 '`) will silently fail to match — even though the same tag exists on the asset after trimming. For a shared contract function this asymmetry is easy to trip over; normalize `selectedTags` the same way before matching (or document the precondition). const normalized = new Set(normalizeGameCreationAppAssetTags(tags ?? [])); - return selectedTags.every((tag) => normalized.has(tag)); + const selected = normalizeGameCreationAppAssetTags(selectedTags); + return selected.every((tag) => normalized.has(tag)); ─── src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx:55-60 ─── [bug · medium] This effect resets the dialog's local `query`/`category`/`selection` whenever the parent re-renders while the dialog is open. `selectedAssetIds` is recomputed in `useImageCanvasGenerationSurface` as a fresh `.filter().flatMap()` array on every render, so its identity changes on every parent render, defeating the "reset only on open" intent and wiping in-progress search/filter/selection (e.g. during background editor re-renders). Reset only on the open transition, e.g. depend on `[open]` (the render where `open` flips to `true` already carries the latest initial selection), or memoize `projectAssetPickerSelectedIds` in the parent. useEffect(() => { if (!open) return; setQuery(''); setCategory('all'); setSelection([...selectedAssetIds]); - }, [open, selectedAssetIds]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- only reset when the dialog opens + }, [open]); ─── src/components/image-editor/projectAssetReferencePickerModel.ts:28-31 ─── [bug · medium] `EditorAsset.mediaType` can also be `'image-sequence'`, which falls through to `'image'` here, so image-sequence assets are shown as selectable image cards. But `projectAssetPickerReferences` keeps `mediaType: asset.mediaType` (`'image-sequence'`), and `appendLimitedImageReferences` only keeps references whose mediaType is exactly `'image'`. A selected image-sequence asset is therefore silently dropped after confirmation (with a misleading "参考图只支持图片素材" warning). Consider excluding image-sequence (and any non-`'image'`) assets from the selectable list, or normalizing the mediaType used for reference filtering so category display and reference acceptance stay consistent. ─── server-rs/crates/api-server/src/editor_project.rs:20747-20755 ─── [test · low] This test is titled and commented as a regression test for the AGC client's quick-edit payload, but `agc_payload` is never passed into any function under test. The only assertions call `ensure_editor_image_edit_source_kind_allowed` directly with hardcoded strings, and the removed `ensure_editor_image_edit_source_kind_allowed_for_request` was the sole consumer of `generationInputs`. As a result, the fixture assertion is trivially self-consistent and provides no protection against a future change reintroducing a `generationInputs.source` gate in `resolve_editor_image_edit_source`/`ensure_editor_image_edit_target_matches_source`. Either drive the actual request-resolution path with this payload, or reword the comment so it doesn't claim the request shape is being exercised. ```
suzmii added 11 commits 2026-09-12 14:29:09 +08:00
- shared-contracts 新增 game_creation_app_asset_category_with_read_time_healing:把 PRD §5.3「分类取值优先级」的读时自愈口径落到 Rust(落盘 unclassified 且 kind 能明确分类时采用派生值),与 packages/shared 的 gameCreationAppAssetCategory 逐分支一致,并补定向用例锁定该窗口
- 新增 project/version_resource_replacement.rs:三项兼容性判据(categoryEqual 用读时自愈口径、subtypeEqual 用 canonical kind、sizeSpecEqual 用规范化媒体格式 + 已知帧尺寸与时长事实)
- sizeSpecEqual 在代码注释里明确标注降级:manifest 资产表今天没有 width/height/durationMs 字段,且现役写入侧几乎全部写 imageSequenceFrames=None,所以该项实际退化为「媒体格式相等」;要支持跨图片格式替换必须先给 manifest asset 加尺寸字段(跨端契约变更)
- 新增 replace_local_project_version_resource_at:持项目写锁并按 expectedProjectId + expectedProjectRevision 做 CAS,一次写入里追加 createdReason=resource-replacement 的子版本(parentVersionId 指向源版本),子版本绑定 = 源版本绑定去掉源素材并保证替换素材在集合里;全程不调用 mutate_manifest_at_allowing_version_removals,既有版本记录一个字节不改
- 替换前后资源身份按 PRD §5.4 版本字段表口径用推导记录(父−子 = {源素材}、子−父 = {替换素材}),并注明「替换素材在源版本创建时就已登记」时子−父为空集的已知限制
- 新增 read_local_project_version_replacement_candidates_at:只读返回候选与后端权威兼容性结论,候选渲染但禁用并给出原因,不在前端重算判据
- commands.rs 新增两个命令包装(读用 asset.list、写用 asset.register),main.rs 注册进 generate_handler
- 新增 8 条定向用例:只追加与父子/修订关系、两条绑定路径(1:1 交换与替换素材已绑定)、三项兼容性逐项拒绝且零副作用、CAS、四条拒绝路径、候选读取顺序与原因、读时自愈口径锁定
- 中间状态声明:本提交落地时前端调用方尚未提交,npm run ai-game-creator-shell:typecheck 会因 check-config.mjs 要求「每个 Tauri 命令都有 App invoke 调用方」而失败;这是刻意保留的中间状态,不得把这两个命令加进 native-only 白名单换绿
- 新增 resourceVersionReplacementTransport.ts:两个 IPC 边界(read_..._candidates / replace_..._resource)只传一个 input 对象,不在客户端内执行时明确报错;这两个 invoke 调用点是 check-config.mjs「每个 Tauri 命令都要有 App invoke 调用方」的落点
- 新增 resourceVersionReplacementModel.ts:PRD §5.3 的 ProjectVersionResourceReplacement / 兼容性类型、禁用原因与失败文案映射、候选→弹窗素材映射;判据不在前端重算,只做呈现
- 候选→弹窗素材复用资源投影口径(resourceCanvasMediaType / resourceCanvasAssetKind),src 固定为空串,缩略图交给弹窗的 renderAssetMedia,不给 img 喂空 src
- ImageCanvasProjectAssetPickerDialog 以 6 个可选 prop 扩展:singleSelect / assetBlockedReasons / renderAssetMedia / selectionNoun / errorMessage 与新的禁用原因文案;全部默认值保持网页端美术画布行为逐字不变(默认仍是多选、仍是 img、仍是「参考图」文案、不渲染禁用与错误区)
- project-development/index.tsx 接线:状态与处理函数(读候选→开弹窗→CAS 写入→重读 manifest→selectActiveVersion),失败保留弹窗并显示原因,候选读取失败不弹空壳弹窗而是说明原因;本提交暂不放行工具条入口
- styles.css 增 .game-resource-replacement-media:候选行的类型占位样式,避免挂破图
- 资源卡选中工具条放行「替换素材」按钮:判据复用现役的 isResourceUsedByCurrentVersion(manifest 身份 + 被当前版本绑定),未绑定的素材一律不渲染,避免"点了没反应"的假按钮;本仓工具条 opt-in 硬约定要求真链路先通,因此入口放在最后一批提交
- 按钮落在宿主 extraActions,与「UI 编辑器 / 编辑标签 / 重命名」同处,不动 ImageCanvasSelectedLayerToolbarAction 共享 union、不改 resourceCanvasToolbarModel 的 supportedActions
- selectActiveVersion 改为 useCallback 固定身份:替换成功后要走同一条记录层版本切换通道,普通函数声明会让 useCallback 依赖数组每次渲染都变(eslint react-hooks/exhaustive-deps 报错);替换相关的处理函数随之挪到它之后声明,依赖数组求值顺序才对
- 新增 tests/resourceVersionReplacement.test.tsx(真链路 4 条):入口只在被当前版本绑定时渲染、候选弹窗禁用不兼容项并显示原因且不合成非候选素材、写入 IPC 载荷逐字精确、成功后切版本但不重载预览(onPlay 不被调用)、后端拒绝时保留弹窗显示原因且不切版本不重读 manifest、候选读取失败时不弹空壳弹窗
- 新增 tests/resourceVersionReplacementModel.test.ts(8 条):禁用原因口径(后端原因优先、按 PRD §5.3 字段顺序派生、兜底不静默放行)、候选→弹窗素材映射(复用资源投影口径、不给 img 喂空 src、缺投影不合成条目)、失败文案逐条映射与未知错误原样透出、两个命令的 IPC 载荷形状
- 用例独立成文件,不往 tests/appSurface/project-development.suite.ts 里插,避免与工具条那条线互相踩
- 变异验证:放宽入口判据为"只要有 manifest 身份"→「不给假按钮」用例转红;失败路径改成静默关弹窗→「保留弹窗显示原因」用例转红;均已还原
- PRD §5.3 补实现口径:替换落盘 = 改绑定 + 追加新版本、子版本绑定 = 源绑定去掉源素材并保证替换素材在集合里、前后身份用推导记录、三项兼容性逐条判据与 sizeSpecEqual 的降级声明、失败即拒绝、入口与可见效果;同时标注 :364-373 的 ProjectResourceDescriptor 是历史四分类形状(其 width/height/durationMs 在 manifest 里并不存在)
- PRD §5.4 前的版本章节与 §6 P1、§7.4 第 4 条按当前状态校准:替换已实施,但版本聚焦态仍不提供创建/替换/切换/回滚按钮,资源替换入口在资源卡工具条
- PRD 新增 §7.8 P1 资源替换验收六条(入口放行判据、候选禁用与原因、只追加写入形状、两条绑定路径、拒绝零副作用、成功后切版本但不重载预览)
- 【测试用例】AGC资源工作台V3端到端验收:删掉「PRD L387 兼容性三项属 C6 取消范围」那句(改为明确不属于),更新 §7.3 工具条 opt-in 清单,补三条已知边界(运行画面不因改绑定而变、候选弹窗不加载缩略图、picker 在 AGC 首次使用),§8 覆盖与不覆盖范围补资源替换
- 【技术方案】AI游戏创作智能体App实施计划::669 的「本阶段不提供版本创建、替换、切换」与 :641 的布局切片范围按当前状态校准,并在文末新增「2026-09-11 AGC 资源工作台 V3:版本级资源替换」一节,写全命令、写入语义、前后身份推导口径、三项兼容性判据与降级声明、入口、成功后行为与验证数字
- decision-log 新增一条(背景 / 决策 / 恒等绑定硬约束 / 判据 / 降级 / 边界 / 影响范围 / 验证方式 / 关联文档):说明本次按 PRD §3.2 / §5.3 恢复「替换后创建下一迭代版本」,与 2026-09-10 那条「不创建新版本、替换功能整条取消」的口径关系,以及 C6 的候选/队列/审核仍不做
- Issue #309 的三处口径(C1 决策、贯穿性决策 6、验收总纲的「不创建新版本 / 不产生新版本」)按远程写确认规则**未擅自改动**,需要在用户确认后再改
- 新增第二条、也是更窄的版本放行通道 mutate_manifest_allowing_version_binding_rewrites:只放行"显式列出的版本改写自己的 resourceBindings",供「直接替换」写入使用
- 新增独立校验函数 validate_version_records_allow_binding_rewrites:不增不删(候选与磁盘版本数量必须相等)、不重排(版本 ID 序列逐项相同)、只有放行 ID 允许 resourceBindings 不同、其余字段(versionId / parentVersionId / projectRevision / createdReason / createdAt / editPrompt)逐字段相等、未放行版本整条相等、放行集合取自写入前 manifest
- 两组放行集合必须互斥:同一版本 ID 既被放行删除又被放行改写绑定 → 报「版本绑定改写放行与版本删除放行必须互斥」失败关闭
- 既有 validate_version_records_are_append_only 与既有 mutate_manifest_at_allowing_version_removals 的语义一行未改:本通道是另一条独立的窄校验路径,默认写入路径仍然只有「只追加 + 显式放行删除」
- 把写盘公共体抽成 write_manifest_locked_with_version_guard(校验器由调用方注入),write_manifest_locked 变成一行委托:装盘 / 回读 / 原子替换只有一份实现,存储行为与改造前逐行等价;唯一被参数化的就是版本校验那一步
- 新增 7 条定向用例:放行版本只改自己的绑定成功且其余版本整条相等、未放行版本改绑定被拒且字节不变、增删版本被拒、重排被拒(直接打校验器 + 真实通道两条)、放行版本改 createdAt / editPrompt / parentVersionId / versionId 四种其它字段都被拒、两组放行集合重叠被拒、默认路径与删除放行通道仍然拒绝绑定改写(证明只追加保证没被打穿)
- 回归确认:既有 manifest_versions_are_append_only_at_the_storage_boundary 与 asset_delete 7 条继续全绿
- 口径变更:资源替换从「版本级替换(改绑定 + 追加下一迭代版本)」改成「直接替换(只改 manifest 里该版本的绑定,不建新版本)」,原因是用户 2026-09-11 的裁决「替换这块先做成直接替换」(同日 DDL)
- 按「四不写」删除版本级路径:不再创建 replace-{revision} 版本、不再写 parentVersionId / createdReason=resource-replacement、不再有「替换前后身份可推」的父子版本对;相关注释与常量一并删除,不留兼容分支
- 改绑定改走 manifest.rs 的窄放行通道 mutate_manifest_allowing_version_binding_rewrites(上一提交新增),放行集合固定为 [sourceVersionId]:不增删版本、不重排、只改这一个版本的 resourceBindings
- 绑定改写语义不变(恒等绑定口径):源素材从该版本的绑定集合里消失 + 保证替换素材在集合里;替换素材是版本创建后才登记时按源素材原位置插回,早已登记时只摘除(避免「资源槽位重复」)
- 准入校验按用户裁决调整:categoryEqual 与 subtypeEqual 仍是硬门禁(拒绝并说明哪一项不等),sizeSpecEqual 降级为提示不再拒绝 —— 它的完整判据今天不存在(manifest 无 width/height/durationMs,实际只等于媒体格式相等),硬拦会误拒 png↔webp 这类直接替换里最常见的需求;提示文案为「格式与源素材不同」,同时出现在候选与写入结果里
- 写入成功后推进一次项目 revision(改绑定属于 versions 变化,跨面快照门禁要求 revision 前进),并追加一条 asset.version_binding.replace 审计(复用既有 append_agent_db_record,字段 versionId / sourceResourceId / replacementResourceId / projectRevision);审计写失败报错但不回滚,与 asset.register 同口径
- 返回结构去掉 parentVersionId:改为 { versionId, committedProjectRevision, replacement: { versionId, sourceResourceId, replacementResourceId, compatibility, warning } }
- 定向用例按新形态重写并补齐 8 条:只改绑定不产生新版本且除绑定外逐字段不变、替换素材早已绑定时只摘除、分类/类型硬门禁拒绝且零副作用、跨格式只提示仍放行、已知帧尺寸与时长事实只提示、读时自愈口径、四条拒绝路径、CAS、候选读取顺序与原因、审计记录留痕
- 已知代价(写进模块文档):直接替换没有可回溯的替换历史,替换前身份只剩这条审计与 manifest 的 .previous 副本
- 验证:定向 8 passed / 0 failed;cargo check --locked --all-targets exit 0
- 口径跟进:前端从「版本级替换」改成「直接替换」——成功后**不再切换版本**(没有新版本可切),只重读 manifest;「当前使用」高亮会随新绑定自动移动(改的仍是同一个源版本)
- resourceVersionReplacementModel:兼容性判据的展示口径收窄为「硬门禁只有分类与类型」,尺寸规格不再产生禁用原因,改为非阻断提示「格式与源素材不同」;新增 resourceReplacementWarning 与 resourceReplacementAssetHints;类型去掉 parentVersionId、加上 warning
- index.tsx:confirmResourceVersionReplacement 去掉 selectActiveVersion 调用,失败仍保留弹窗与选择并显示原因(不切版本、不动高亮、不提示成功);新增 hint 映射并传给弹窗
- ImageCanvasProjectAssetPickerDialog:以可选 prop assetHints 扩展(默认 undefined → 不渲染),用于展示"可选但有差异"的提示;与 assetBlockedReasons 的区别是提示不改变可点性,默认行为仍逐字不变
- 失败文案补一条:版本写入边界拒绝(「不可修改、删除或重排」)翻成「替换被项目版本写入边界拒绝,请刷新项目后重试」,不让用户看到内部口径原文
- 用例改造:模型 9 条(尺寸规格不再禁用、提示表只收可选候选、提示不覆盖真正的不兼容原因、边界拒绝文案)、真链路 4 条(候选弹窗禁用硬门禁项 + 格式提示仍可选、写入载荷精确、成功后不切版本且不重载预览、失败保留弹窗且不重读 manifest、候选读取失败不弹空壳弹窗)
- 验证:两个前端用例文件 13 passed / 0 failed;npm run ai-game-creator-shell:typecheck(含 check-config)exit 0
- decision-log 新增一条(背景 DDL + 用户裁决 / 决策 / 恒等绑定硬约束 / 准入与提示 / 存储与留痕 / 代价 / 文档口径 / 影响范围 / 验证方式 / 关联文档):说明本次从「版本级替换」改成「直接替换」,并明确记录「替换历史不可回溯」这条代价(上一轮版本级形态里"父版本绑定"就是那份历史,现在只剩一条审计与 manifest 的 .previous 副本)
- PRD §3.2:四条保留为未来合同,就地标注「当前实现为直接替换(2026-09-11),版本级替换暂缓」,并说明今天不实现的正是"创建下一迭代版本 / parentVersionId / 前后身份 / 创建原因",而"可运行版本不可变"与"运行中版本不消费尚未生成的新版本变更"仍然成立
- PRD §5.3:DTO 与"三项兼容性必须同时为 true 才能创建下一版本"标注为未来合同;实现口径块整段改写为直接替换(落盘 = 改该版本绑定、绑定改写语义、硬门禁只有分类与类型、尺寸规格降级为提示、失败即拒绝、revision 前进与审计、入口与可见效果)
- PRD §5.4 存储边界(当前状态):新增第二个例外「直接替换时显式放行列出的版本允许改写自己的 resourceBindings」,写明六条不变式与两组互斥;开头的"已有记录不得删除、重排或修改"补两个例外的指向
- PRD §4 版本条目、§6 P1、§7.4 第 4 条、§7.8 验收六条全部按直接替换口径改写(验收新增第 7 条:审计留痕 + 替换历史不可回溯的声明)
- 技术方案文末那一节整段重写为「资源替换(直接替换:只改该版本的绑定,不建新版本)」:新增放行口与六条不变式、命令与写入语义、准入与提示、成功后行为、已知代价、变异验证四条 + 门禁数字;:641 与 :669 的注解同步校准
- 【测试用例】验收文档:§7.3 第 1 条改写为"版本级替换当前不实现但保留为未来合同",新增第 12/13 条(尺寸规格只提示、替换历史不可回溯),第 10 条去掉"版本下拉多一条";§8 覆盖与不覆盖范围按新形态更新
- 【技术方案】AGC资源派生与非破坏性编辑合同 :28 的"已有版本数组元素不可修改、删除或重排"补上两个例外的指向
- projectResourceLiveUpdateModel 新增 manifestCasFingerprint,按 { assets, versions } 判定同版本号冲突
- 同 revision 且判据面一致时,仅非 CAS 账目状态(preview 运行地址/端口、任务进度、项目名)变化改为 accepted 并让内容落地,revision 不推不退
- 判据面变化仍判 revision-conflict,保留「改了被保护内容却没推 revision」的可观测信号,不吞真实缺陷
- 新增回归用例:同 revision 仅预览状态变化必须被接受;同 revision 资产变化必须仍被拒收
- 变异验证:把判据改回整份 JSON 指纹后,新用例以 expected 'revision-conflict' to be 'accepted' 失败
- 定向验证:projectResourceLiveUpdateModel 17 passed、workspaceLauncherManifestMerge 5 passed、appSurface 413 passed、npm run typecheck exit 0、npm run check:encoding 4399 files passed、git diff --check 干净
- 同步 decision-log 与 pitfalls 记录 CAS 判据面契约与本次取证
- resourceVersionSwitch 新增用例:运行模块内切换版本必须既上报宿主又触发 onPlay 重载预览
- 覆盖验收用例 S16 的「并重载当前预览」条款(此前版本入口只测了记录层与高亮,重载没有守卫)
- 变异验证:摘掉 selectActiveVersion 里的 onPlay 调用后,该用例以 expected "spy" to be called at least once 失败
- 定向验证:resourceVersionSwitch 4 passed
合并 feat/agc-resource-replace:把「替换素材」接进资源工作台
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 3m14s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Native shell tests (pull_request) Successful in 20m52s
e4ee6c2be8
- 解冲突 project/manifest.rs:保留版本写入公共体 write_manifest_locked_with_version_guard(版本只追加 + 绑定改写放行),并把 schema 校验并入该公共体,两侧语义都不丢
- 解冲突 shared-contracts:取当前分支的读显示口径 game_creation_app_asset_effective_category(含与 TS 交叉钉住的 EFFECTIVE_CATEGORY_CONTRACT 矩阵),同步改写 version_resource_replacement 对旧函数名的调用
- 解冲突 index.tsx:两处均为纯 import 追加,两侧都保留
- 解冲突 decision-log 与技术方案文档:两侧条目都是追加,全部保留
- 随之进入本分支:版本绑定改写窄放行通道、直接替换命令与其兼容性判据、资源卡工具条「替换素材」入口
suzmii added 1 commit 2026-09-12 14:36:31 +08:00
修正合并带入的 rustfmt 漂移与换行符,恢复 Repository checks
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Frontend tests (pull_request) Successful in 3m3s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Native shell tests (pull_request) Successful in 20m2s
c76ecd07ab
- cargo fmt 三个由 feat/agc-resource-replace 带入且从未跑过 CI 的 Rust 文件:version_resource_replacement.rs、manifest/version_binding_rewrite_tests.rs、tests/version_resource_replacement.rs
- 四个文件统一为 LF,修掉 rustfmt 报的 Incorrect newline style:shared-contracts/game_creation_app.rs、view/project-development/index.tsx、decision-log.md、技术方案文档
- 本地按 CI 链复跑:encoding / npm-workspaces / spacetime-schema / production-ops / preview-deployer / maintenance-page / rustfmt / eslint / typecheck 全部 exit 0
- check:git-hooks 仍红,但红在已知 Windows EBUSY 抖动(rmdir 临时 repo 被占用),Linux CI 不受影响
suzmii added 1 commit 2026-09-12 14:45:15 +08:00
Merge remote-tracking branch 'origin/master' into fix/agc-manifest-revision-cas
Project CI / Repository checks (pull_request) Successful in 3m11s
Project CI / Frontend tests (pull_request) Successful in 3m14s
Project CI / Backend tests (pull_request) Successful in 5m38s
Project CI / Native shell tests (pull_request) Successful in 18m27s
33a85a67b8
suzmii added 1 commit 2026-09-12 15:32:28 +08:00
资源工作台「替换素材」候选窗接上预览管线,补齐候选排序、禁用态可读性与删除素材入口
Project CI / Repository checks (pull_request) Successful in 2m48s
Project CI / Frontend tests (pull_request) Successful in 3m14s
Project CI / Backend tests (pull_request) Successful in 7m49s
Project CI / Native shell tests (pull_request) Successful in 18m27s
dc243b0d57
- 「替换素材」候选卡缩略图接入资源卡同一条预览管线:新增 ResourcePreviewMedia,复用同一份预览身份、同一条队列与 LRU 缓存,不再只渲染类型占位图标
- 预览 hook 暴露 sweepVisiblePreviews:弹窗内容挂在 portal 下、不在资源画本 observer 的 root 子树内,改为在「弹窗打开」与「候选区滚动」两个几何变化点显式按同一套视口几何判据兜底放行,不新增第二条加载通路
- 候选排序抽出纯函数 sortResourceReplacementCandidates:可替换(无硬门禁失败)的排最前,其余按后端原名次跟随且仍渲染但禁用;判据与弹窗禁用状态同源,不会出现「排最前却点不动」
- 抽出纯函数 projectResourcesByManifestAssetId,让弹窗素材映射与候选缩略图共用同一份资源投影口径
- 共享选择弹窗的禁用态不再整卡压透明度:变灰只压在缩略图与名称上,「分类不同」这类禁用原因保持全对比度可读(网页端美术画布不传禁用原因,视觉逐字不变)
- 资源卡选中工具条末位新增「删除素材」,前置共享工具条同一套分隔线与前面的非破坏性动作隔开,只在 manifestAssetId 存在时才渲染
- 删除流程抽成 useResourceAssetDeleteFlow(读引用信息 → ResourceAssetDeleteDialog 二次确认 → delete_local_project_asset 的 deleteReferencedVersions 三分支),工具条与面板共用同一份实现,没有第二套删除
- 「编辑素材标签」面板底部只留右下角一个「添加」:点它把输入框内容(含未按回车的尾巴)落成 pill 并按现有写入路径保存,分类仍原样回传落盘原值;删除素材/取消/保存标签三个按钮移除,关闭仍走头部 ×
- 标签 pill 内的删除标签按钮保持不变
- 更新与补充用例:候选排序、排序与禁用同源、候选缩略图真链路与滚动兜底放行、工具条删除入口位置与 IPC 载荷、连带删除分支、面板无删除入口与「添加」语义、禁用态可读性类名契约、工具条动作清单
suzmii added 1 commit 2026-09-12 16:01:34 +08:00
修复「编辑素材标签」保存后关窗:点「添加」成功后面板保持打开,可连续添加
Project CI / Repository checks (pull_request) Successful in 2m44s
Project CI / Frontend tests (pull_request) Successful in 3m37s
Project CI / Backend tests (pull_request) Successful in 7m5s
Project CI / Native shell tests (pull_request) Successful in 17m30s
46788edbb8
- 定位关窗来源:面板的 saveResourceClassification 只调 onSaved、从不调 onClose;真正卸载面板的是宿主 index.tsx 的 setResourceClassificationAssetId(null)(在 reloadManifestAfterAssetCommand 里,由 handleResourceClassificationSaved 触发)
- 在宿主层给 reloadManifestAfterAssetCommand 加 keepClassificationPanelOpen 开关:默认仍然收起,删除素材 / 版本替换 / 重命名的既有行为不变,只有标签保存显式传 true
- 标签保存后不再清 resourceClassificationAssetId:面板保持打开,新标签已落成 pill、输入框已清空,用户可以接着加下一个标签
- 关闭面板仍只走头部 ×;标签 pill 内的「删除标签」按钮与删除素材流程都不受影响
- 面板用例:添加后不得调用 onClose、面板仍在、新标签出现在已有标签列表、输入框清空;连续两次「添加」各自写盘且第二次带上累计标签、category 仍原样回传落盘值
- 工台用例(resourceVersionReplacement.test.tsx)补真宿主链路:经工具条「编辑标签」打开面板 → 添加 → 断言写入载荷与面板仍在 → 再添加一次 → 两次写入各自发生且面板依然打开(只测面板组件看不出宿主把面板卸载了)
suzmii added 2 commits 2026-09-12 16:52:23 +08:00
- 快速编辑面板提示词换成与聊天同一个 `ResourceReferenceInput`,`@` 候选、插入动作与引用模型全部复用聊天那一套
- 共享 composer / 快速编辑面板新增 `promptInputSlot` 注入位,宿主控件替换默认 textarea,不传时 DOM 与行为逐字不变
- `ResourceReferenceInput` 新增 `showPolishAction`:快速编辑的润色仍由 ResourcePromptPolishSlot 承担,面板里不再出现第二个润色入口
- `ResourceReferenceInput` 的多行输入区把 Enter 留给换行,只有单行输入区才用 Enter 提交
- `@` 面板的搜索与筛选改用共享 `PlatformResourceFilterBar`,搜索 + 功能分类 + 标签三者在面板内叠加生效
- 新增 `resourceReferenceMatchesTagSelection` / `resourceReferenceTagLibrary`,标签口径(多标签 AND、计数与排序)落在共享标签库,两个页签各持一份
- 宿主新增 `quickEditReferences` 与 `applyResourceQuickEditDraft`,派生请求的 prompt 就是输入区回填的那份文本(`@显示名`),不引入第二种引用格式
- `useImageCanvasFloatingOptionDismiss` 新增 `isInsideExtraOverlay`:宿主 portal 到 body 的 `@` 选择器与候选菜单不再被判成点外部而收起快速编辑面板
- 资源画布快速编辑提示词输入区补样式(铺满 composer 一整行、高度上限),`@` 面板去掉手写搜索行样式
- 用例:新增标签库口径、标签筛选(多标签叠加 + 关键字 + 功能分类)、两页签独立标签、聊天与快速编辑草稿一致性、快速编辑内 `@` 出选择器且派生 payload 断言;扩展 AGC 资源工作台 V3 端到端验收用例
合并资源工作台分支:快速编辑 @ 引用素材与标签面板不关窗两套行为并存
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 3m2s
Project CI / Native shell tests (pull_request) Successful in 17m29s
ace31a5f19
- 把 v3 顶端 46788edbb(「编辑素材标签」保存后不关窗)合入本分支,与 35d3a92af(快速编辑支持 @ 引用素材、@ 面板补标签筛选)并存
- 冲突零:index.tsx 两侧改动落在不同位置(keepClassificationPanelOpen 与 quickEditReferences/promptInputSlot 接线),自动合并
- 验证:npm run typecheck exit 0;resourceClassificationPanel + resourceVersionReplacement + resourceReferenceInput + projectResourceLiveIntegration 共 4 files / 73 tests 全绿
suzmii added 2 commits 2026-09-12 16:57:46 +08:00
- ResourceClassificationPanel.tsx:加素材类型(功能分类)选择器,与标签同一次保存、同一条写入路径;选项 = GAME_CREATION_APP_ASSET_CATEGORIES × resourceReferenceCategoryLabel,不新造第二套中文译名
- ResourceClassificationPanel.tsx:选择器读显示口径(与画布栏目 projectResourceAssetCategory 同源,选中项就是卡片所在栏目);写回分两路——没碰过类型控件回传 gameCreationAppAssetPersistedCategory 的落盘原值,主动选过才写用户选的值(categoryChoice ?? persisted),保证「只改标签」不漂移分类
- index.tsx:资源卡右上角角标由「文件/媒体类型」改为「资源类型」,取 categoryLabels[resource.category](栏目与筛选共用的同一份文案),角标恒等于该卡所在栏目;projectResourceTypeLabel 仍服务资源管理面板小字与总览摞分列,不再用于角标
- tests/resourceClassificationPanel.test.tsx:新增「变更素材类型」6 条用例(选择器 6 项与中文口径、选择器显示显示口径自愈值、主动选值写入载荷、只改标签回传落盘原值对照、显式待归类写入 unclassified、连续两次各带当次选择且不关窗),并按新增控件更新既有按钮序与「分类不再由面板编辑」两条断言
- tests/projectResourceLiveIntegration.test.tsx:新增真宿主跟随链用例(角标显示资源类型并与栏目同名、改类型后卡片进目标栏目且角标跟着变、原栏目不再有它),配套状态化宿主 fixture、分类写入与 get_local_game_manifest 两条命令桩、切栏目与读角标助手
- tests/appSurface/project-development.suite.ts:原 data-resource-type 媒体类型断言改为资源类型(角色与对象 / 待归类 / 音频),并说明卡面正文走媒体分支、角标走资源类型这两个独立轴
- docs:PRD §5.3 由「分类没有用户手动设置入口」改为当前两口径分叉(选择器读显示口径、写回按是否主动选过分流)与「显式待归类被读时自愈覆盖」盲区
- docs:AGC 资源工作台 V3 端到端验收 S5 的「前端交互不能改分类」改写为可改分类,补判据(角标读 .game-resource-card-type-badge)与盲区说明;实施计划写入命令一节的面板名与职责同步为「编辑素材标签(标签 + 素材类型)」
- docs:decision-log 新增条目记录本次决策(Rust 无需改动、画布跟随链无需额外迁移代码、待归类盲区、四条变异验证结果与一条不可构造的变异)
- 说明:Rust、manifest 字段构成与 SpacetimeDB 均不改;写入命令 update_local_project_resource_classification 早已接受任意合法 category
合并资源工作台分支:素材类型可由用户变更与角标显示资源类型并入
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 3m0s
Project CI / Native shell tests (pull_request) Successful in 17m12s
2d40f02286
- 把 v3 顶端 ace31a5f1(快速编辑 @ 引用素材 + @ 面板标签筛选)合入本分支,与本分支 1079f6c91(用户可变更素材类型、角标改显示资源类型)并存
- 冲突零:index.tsx 两侧改动落点不同(快速编辑 promptInputSlot 接线 vs 角标取值点),projectResourceLiveIntegration 与验收文档自动合并
- 验证:npm run typecheck exit 0;resourceClassificationPanel + projectResourceLiveIntegration 共 45 tests 全绿;check:encoding 4409 文件通过;git diff --check 干净
suzmii added 1 commit 2026-09-12 17:35:10 +08:00
合并 origin/master 到资源工作台 V3 分支:解 4 处冲突并保留两侧行为
Project CI / Repository checks (pull_request) Successful in 3m14s
Project CI / Frontend tests (pull_request) Successful in 3m57s
Project CI / Backend tests (pull_request) Successful in 6m30s
Project CI / Native shell tests (pull_request) Successful in 17m27s
87fbf92c58
- WorkspaceLauncher.tsx:保留本分支清单快照 CAS 拒收提示(manifestMergeNotice 提示条、data-manifest-merge-* 观察点、recoverRejectedManifestSnapshot「重新读取清单」)与 activeVersionId/onActiveVersionChange 版本口径,并入 master 的策划/游戏运行态切换(onMakeGame + switchToGameRuntime、suppressInitialGameTurn 抑制首轮、supervisor key 带 agentRuntimeMode、onSwitchToGameRuntime),planningStartMode 统一取 master 的派生值(上下文 startMode 为 planning 且未切到 game 运行时)
- ProjectSupervisorView.tsx:props 同时保留本分支 versions/activeVersionId 与 master 的 designView/onDesignApprove/onDesignClarify/onDesignRetry,DesignAgentSurface 审批链路与本分支资源引用输入区并存;directCodex 输入区保留本分支 @ 引用按钮 + 模型选择 + 发送按钮控制条,发送按钮禁用条件并入 master 的 designView 审批待定口径,模型选择沿用 master 的「对话中可切换」口径
- styles.css:本分支追加的资源画布/输入区样式块与 master 追加的 .design-agent-reasoning 样式块都保留,并补上本分支最后一条规则的收尾大括号
- view/project-development/index.tsx:保留本分支的 image-editor 引用(ImageCanvasProjectAssetPickerDialog、ImageCanvasQuickEditPanelView、ImageCanvasSelectedLayerToolbarView、useImageCanvasFloatingOptionDismiss),去掉 master 对已退役 features/asset-canvas 的 import(本分支已由 resource-canvas 取代),保留 master 的 DesignWorkspacePanel 与 planningStartMode 策划工作台分支
- tests/workspaceLauncherManifestMerge.test.tsx:补 master 新引入的 get_design_agent_runtime_mode mock(返回 null,与 master 各套件同口径),拒收提示断言不变
- tests/appSurface/design-agent.suite.ts:审批待定禁用输入的断言改用本分支 Lexical 输入区的禁用口径(容器 data-disabled + editor.setEditable(false)),断言意图不变
- 验证:npm run typecheck 通过;apps/ai-game-creator-shell typecheck 通过;apps/ai-game-creator-shell/tests 96 个测试文件 1380 通过 4 跳过 0 失败;全仓 vitest 297 通过 2 失败(scripts 下两例为 Windows 权限语义导致的既有失败,相关文件与实现均未参与本次合并);npm run check:encoding 与 git diff --check 通过
suzmii added 5 commits 2026-09-12 18:13:35 +08:00
- 钉在视口上的栏目标题栏(栏目名 / 计数徽标 / 资源总览入口)改挂场景根,成为带 scale() 的 .game-resource-book-scene-world 的兄弟节点,不再用 scale(1 / s) 抵消 world;总览态的栏目标题栏仍留在 world 里与缩略卡对齐
- index.tsx 抽出 ResourceBookScene.renderTitlebar(placement.pinned) 一处产出两种宿主,删除只服务抵消变换的 titlebarTransform 与 safeViewport/safeMainViewport 中间量
- resourceBookController 按节点自己的坐标系换算:world 内用 world 缩放,world 外(钉住标题栏)用 1;新增 nodeWorldTransform,readWorld 补 element 输出,nodeWorld 贯穿 cleanRect / FLIP 反向 transform / worldScale
- 更新 resourceBookFlipValues 与 readOrigin 的口径注释:translate 单位是宿主自身坐标系,不再假定所有宿主都在 world 里
- appSurface 用例改钉新不变量:钉住的标题栏内联 transform 必须为空、不在 world 内、父节点为场景根,卡片必须仍在 world 内;并说明标题栏宿主不再跨视图同一节点、转场仍由控制器按 key 记录的 First 屏幕矩形驱动
- resourceBookController 用例新增「屏幕坐标系的宿主不被 world 缩放除」,并保留 world 内卡片仍按缩放除的对照
- 技术方案 2026-09-05 段落同步新结构;pitfalls 增记取证(截图墨色峰值 98.6 vs 同色正文 91.7、点阵周期反推 dpr=1)、本机复现尝试与结论、三处变异验证和真机判据
- 新增 apps/ai-game-creator-shell/src/services/tauriEventSubscription.ts:AGC 唯一的事件订阅入口。真实 WebView 内用 plugin:event|listen + transformCallback 自建登记,注销时先 unregisterCallback(handlerId)(callbacks.delete,幂等、缺条目也不抛)再发 plugin:event|unlisten,并对重复注销去重,注销失败只在 console.warn 显式记录;非原生环境沿用注入的 event.listen(两参调用形状不变),未接桥接时返回空操作。tauri 2.11 的注销脚本会先读注册表条目再摘回调,而条目由注册 eval 异步写入,与 IPC 返回无序,这一层自建登记正好绕开该竞态(上游 tauri-apps/tauri#15799 / #15800,2.12 起脚本自带判空,升级后可删掉 internals 分支)。
- App.tsx:5 处事件订阅(game-creator-direct-turn-update / agent-progress / agent-runtime-update / planning-session-v2-stream / manifest-invalidated)与角色 Agent 流式回复监听改用订阅入口,守卫由 window.__TAURI__?.event?.listen 换成 canSubscribeTauriEvents()。
- features/app-shell/useDeveloperAgentPanel.ts:Agent Runtime 与角色 Agent 流式回复两处订阅改用订阅入口。
- services/errorReportingBridge.ts:error-report-updated 订阅改用订阅入口,不再直接依赖库内 listen。
- components/AppUpdateNotice.tsx:更新下载进度订阅改用订阅入口。
- components/WindowChrome.tsx:窗口尺寸监听不再走 nativeWindow.onResized,改为订阅 tauri://resize(限定当前窗口),避开库内注销竞态。
- 新增 tests/tauriEventFake.ts:与 tauri 2.11.3 等价的 Tauri 事件替身(注册表条目由注册 eval 异步写入、库内注销脚本读缺失条目即抛并留痕、全局桥 event.listen 按库内实现返回会读条目的注销函数)。
- 新增 tests/tauriEventSubscription.test.ts(7 例):注册 eval 未落地就注销、重复注销只摘一次、注册落地后能投递且注销后不再投递、真实 WebView 不走库内注销脚本、无 internals 时回落注入桥接、无桥接时空操作。
- 新增 tests/runVersionSwitchEventSubscription.test.tsx(2 例):真实 launcher + 运行模块连续切换两次版本(断言 start_local_game_preview 被调用)、卸载后后端订阅与 JS 回调都不泄漏、切换版本后清单失效事件仍能送达并重读清单。
- docs/project-memory/shared-memory/pitfalls.md:记录该竞态的成因、触发面、处理取舍与变异验证结论。
- 合并来源:6618207b5(fix/agc-version-switch-event)。冲突只有 1 个文件 1 处:apps/ai-game-creator-shell/src/App.tsx:1889-1940。
- 冲突成因:master 侧(策划 agent 重构)在 planning-session-v2-stream 之后新增了一条 design-agent-update 订阅(App.tsx:1888);6618207b5 则把这处相邻的 manifest-invalidated 守卫 `const listen = window.__TAURI__?.event?.listen; if (!listen)` 换成 `canSubscribeTauriEvents()`,两段文本相邻导致冲突。
- 解冲突(两侧都保留,未删任何一侧):
  - 保留 master 新增的 design-agent-update effect 及其全部行为符号:designAgentTurnRef 轮次配对、payload.projectPath 比对、setPlanningV2TransientReply、setPlanningV2Reasoning、applyDesignView(App.tsx:1888-1931);只把它的订阅方式换成 6618207b5 的入口(subscribeTauriEvent<DesignEvent> + canSubscribeTauriEvents 守卫)。
  - 采用 6618207b5 对 manifest-invalidated 的写法:canSubscribeTauriEvents() 守卫 + subscribeTauriEvent<GameCreatorManifestInvalidatedEvent>(App.tsx:1933-1940),refreshManifest 行为不变。
- 冲突区外补齐同一件修复:apps/ai-game-creator-shell/src/features/project-workspace/DesignWorkspacePanel.tsx:264-301 是 master 新增、而 6618207b5 基于共同祖先 46788edbb 写作时还没出现(base 里没有这个文件)的一处订阅,事件同为 design-agent-update、写法同为「订阅后立刻注销」,会同样踩 React.StrictMode 的注销竞态,因此一并改走订阅入口;否则「唯一订阅入口」不成立、该竞态在本次要打包的版本里仍然活着。
- docs/project-memory/shared-memory/pitfalls.md 自动合并成功,6618207b5 与 ff0bc5a3e 两条 2026-09-12 记录都在(无手工取舍)。
- 自证:git grep '^<<<<<<<|^>>>>>>>' 无命中;AGC 内除 services/tauriEventSubscription.ts 自身外,已无 __TAURI__?.event?.listen 直连订阅。
- 验证(本提交时点):npm --prefix apps/ai-game-creator-shell run typecheck exit 0;npx vitest run apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx 7 passed。全量验证(两侧 typecheck、AGC 全量用例、encoding、git diff --check)在两条修复都并入后统一执行。
665982eb9 的 tauri 事件注销竞态是两个独立原因,用户看到的是同一个症状:
① 切换版本时的重载被事件竞态报错打断(665982eb9,已并入 6618207b5);
② 版本菜单在选项收到 click 之前被「点外部」判定收掉,onSelectVersion 永远不被调用(本提交)。

- 现象:在运行模块点版本菜单里的选项没有任何反应(菜单直接消失、当前版本不切换)。
- 原因:菜单用 createPortal 挂到 document.body(运行画面会缩放,挂 body 才不跟着画布跑偏),DOM 上不在触发按钮 rootRef 的子树里;而「点外部关闭」判定只认 rootRef,落在选项上的那一次按下就被当成点外部,菜单在选项收到 click 之前被卸载,选项的 onClick 永远不会触发。
- 改法:删掉自建的 window mousedown 判定,复用共享 hook useImageCanvasFloatingOptionDismiss(AGC 的 view/project-development/index.tsx:1661 已在用同一份口径,不新造第二套判定),并把 portal 菜单登记成第二个边界:GameRunVersionPicker.tsx:35 新增 menuRef、:37-41 传 boundaryRefs: [rootRef, menuRef]、:69 把 menuRef 挂到菜单容器上。菜单的关闭时机随之从 mousedown 变为 click,与画布浮层口径一致。
- 回归用例(apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx,全部走真实指针序列 mousedown → mouseup → click,不再像旧用例那样只派发 click):
  - does not dismiss the portal version menu when an option is pressed down:mousedown 后菜单必须还在,随后 mouseup+click 必须调用 onSelectVersion('version-root')。
  - selects the version from the run module when the option is clicked after a real press:真实 launcher + 运行模块,走完真实序列后宿主必须收到 onActiveVersionChange('version-root')。
  - keeps the portal menu mounted when the click lands on the menu itself:点 portal 菜单自身(非选项区)不算点外部。
- 变异验证(两条,都先红后绿):
  - 还原旧实现(window mousedown + 只认 rootRef)→ 3 条新用例全红:2 条 AssertionError: expected null not to be null(菜单被关掉),1 条 expected "spy" to be called with arguments: [ 'version-root' ] / Number of calls: 0(onActiveVersionChange 没被调用,正是用户症状)。
  - 只把边界收回 boundaryRefs: [rootRef](仍复用 hook,但不登记 portal 菜单)→ keeps the portal menu mounted when the click lands on the menu itself 变红(expected null not to be null)。注意另两条此时仍绿:共享 hook 的判定在 click 而非 mousedown,所以要复现用户现象必须连旧的 mousedown 判定一起还原,这也是本次把判定整体换成共享 hook 的直接理由。
- 验证:npx vitest run apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx 7 passed(原 4 条 + 新 3 条);npm --prefix apps/ai-game-creator-shell run typecheck exit 0。
合并 fix/agc-manifest-revision-cas:钉住的栏目标题栏移出画布缩放层(解 pitfalls.md 冲突)
Project CI / Repository checks (pull_request) Successful in 2m48s
Project CI / Frontend tests (pull_request) Successful in 3m42s
Project CI / Backend tests (pull_request) Successful in 6m30s
Project CI / Native shell tests (pull_request) Successful in 17m5s
4c180ac29a
- 合并来源:ff0bc5a3e(fix/agc-manifest-revision-cas)。
- 冲突只有 1 个文件 1 处:docs/project-memory/shared-memory/pitfalls.md(两侧都在文件末尾追加了一条 2026-09-12 记录,文本上是同一个插入点)。
- 解冲突(两侧内容逐字都保留,都不改写、不删):
  - 保留 HEAD 侧 6618207b5 的「运行模块切换版本报 listeners[eventId].handlerId」一节(pitfalls.md:5397)。
  - 追加 ff0bc5a3e 的「资源工作台顶部那一行发虚」一节(pitfalls.md:5407)。
  - 同一天的记录之间没有排序语义,取「HEAD 内容不动、新记录追加在末尾」,这样两侧 diff 都是纯插入。
- 另外两处自动合并(git 未报冲突)已逐段语义核对,两侧行为都在:
  - apps/ai-game-creator-shell/src/view/project-development/index.tsx:ff0bc5a3e 的 renderTitlebar 结构完整保留(:1187 定义、:1244 总览态在 world 内渲染、:1313-1317 钉住的那一条渲染在 world 之外、作为 world 的兄弟节点);ff0bc5a3e 删掉的 titlebarTransform / safeViewport / safeMainViewport 已无残留;本分支 V3 的行为同时保留(:1234 plan.map 栏目卡、:1250 card.presentation、:1279 isResourceBookAllTarget 预览卡、:1307 worldOverlay、:1318 overlay)。
  - apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts:ff0bc5a3e 钉的新不变量(:782-800 钉住标题栏不在 world 内、内联 transform 为空、父节点是场景根)与本分支 V3 既有用例都在。
  - resourceBookController.ts 未冲突:nodeWorldTransform(:221)、readWorld 补的 element 输出、nodeWorld 贯穿 cleanRect / FLIP / worldScale(:457、:521)都在。
- 自证:
  - 仓库内 git grep '^<<<<<<<|^>>>>>>>' 无命中。
  - git diff --name-only 87fbf92c5 <本提交> 恰好 18 个文件 = 6618207b5(10) + ff0bc5a3e(5,与前者共用 pitfalls.md) + 46458a825(2) + DesignWorkspacePanel.tsx(1),没有多余文件。
  - git diff ff0bc5a3e <本提交> -- resourceBookController.ts resourceBookController.test.ts 为空(这两个文件 ff0bc5a3e 改过、master 没碰,因此必须逐字一致);与 ff0bc5a3e 的差异只落在双方都改过的 index.tsx / project-development.suite.ts / pitfalls.md 三个文件上。
- 验证(两条修复都并入后统一执行,全绿):npm run typecheck exit 0;npm --prefix apps/ai-game-creator-shell run typecheck exit 0;npx vitest run apps/ai-game-creator-shell/tests → 98 files / 1394 passed | 4 skipped(0 failed);npm run check:encoding → 4489 file(s) passed;git diff --check 干净;npm run check:rustfmt exit 0(这两笔修复都没动 Rust,额外确认一次)。
suzmii added 2 commits 2026-09-12 18:49:43 +08:00
- 成因:共享工具条在 showQuickEdit 分支于「快速编辑」后输出一条分隔线,同时为 extraActions 自动生成前置分隔线(extraActionDivider);AGC 资源卡是「快速编辑可用 + 中间动作未接通不渲染」的组合,两条因此相邻
- 修法:样式层去重(相邻的两条只显示一条),不改 JSX 结构,对美术画布与资源画布两端都成立,也不会隐藏任何真正起分隔作用的那一条
- 新增契约用例 selectedLayerToolbarDividerDedupe.test.ts 钉住去重规则与它的成因两端
- 验证:该用例 3 passed;变异验证(删掉去重规则)→ 1 failed,还原后 3 passed
修复改完素材标签后聊天侧标签统计与候选不刷新
Project CI / Repository checks (pull_request) Successful in 2m56s
Project CI / Frontend tests (pull_request) Successful in 3m54s
Project CI / Backend tests (pull_request) Successful in 6m47s
Project CI / Native shell tests (pull_request) Has been cancelled
b498c2780e
- App.tsx:新增跟随工作台壳清单快照的 effect(`manifestRef` + 同一 `projectId` 且内容确实变了才 `setManifest`),
  让聊天侧 `@` 选择器的标签计数与候选在资源命令写入后跟上画布那份清单
- tests/resourceTagStatsRefresh.test.tsx(新增):真宿主链路用例 —— 资源画布「编辑标签」保存后,
  聊天 `@` 选择器出现该标签 chip 与计数、并按它收窄候选;反向用例钉住未标注标签时不渲染标签 chip
- docs/project-memory/shared-memory/decision-log.md:记录标签派生数据的三处来源与各自数据源、
  根因(`initialProjectManifest` 只是 `useState` 初值,壳里换清单不会再进来)、修复口径与已知未覆盖项
suzmii added 2 commits 2026-09-12 18:51:41 +08:00
- 根因:弹窗面板用的 `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 记录「共享弹窗的宿主类必须落在所有宿主都会加载的样式表里」的排障经验
合并资源工作台分支:弹窗宿主类样式迁到共享表与既有修复并存
Project CI / Repository checks (pull_request) Successful in 2m36s
Project CI / Frontend tests (pull_request) Successful in 3m11s
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
05612c46ae
- 把远端最新 b498c2780(标签统计刷新 + 工具条分隔线去重)合入本分支 074924190(弹窗宿主类 platform-modal-shell/overlay/backdrop 迁到共享样式表)
- 冲突零:src/index.css 自动合并(本分支删除三条宿主类规则,远端最新新增分隔线去重规则)
- 验证:npm run typecheck exit 0;projectAssetPickerDialogShellStyle + selectedLayerToolbarDividerDedupe + resourceTagStatsRefresh 三个用例文件全绿
suzmii added 2 commits 2026-09-12 18:58:58 +08:00
- apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts:begin() 顺带按 (栏目, 类型摞列号) 记下同一摞最深那张卡的 First 矩形当"堆锚点",play() 时没有 First 帧的卡片认领它当合成 First(仍是同一套 FLIP,不新增动画系统、不为动画多渲染节点);堆锚点只在本次转场有效(maybeFinish / settle / invalidate 清掉),转场中重基(已在跑动画)不认领锚点,起点仍是"此刻像素"。
- apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts:展开态(allOpen)的卡片补上真实类型摞列号,不再一律 0——否则第 2 摞之后的卡片会全从第 1 摞飞出来。
- apps/ai-game-creator-shell/src/view/project-development/index.tsx:卡片宿主补 data-resource-book-stack-column / data-resource-book-stack-index,把"哪一摞"的摞身份交给转场层。
- apps/ai-game-creator-shell/tests/resourceBookController.test.ts:新增 3 条用例——每一张卡都从自己那一摞位移出来(含第 0/1 摞分别认领各自锚点、总览已渲染的仍用自己 First)、转场中重基从此刻像素而不是被拉回摞上、转场收尾后挂载的卡片不从过期锚点飞出。
- apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts:新增"展开态每张卡带自己那一摞的列号"用例(同栏目两摞 ⇒ [0,0,1])。
- apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts:在既有总览/进栏目用例里钉住宿主上的摞身份属性(总览 3 张 = 下标 0,1,2;进栏目后 5 张 = 下标 0..4、同类型列号 0)。
- docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md:把资源画本转场一节更新为当前状态(堆锚点来源、锚点生命周期、展开态列号)。
- docs/project-memory/shared-memory/pitfalls.md:记录本次排障——现象、已用代码核实的原因(总览每摞只铺 3 张 ⇒ 其余卡片没有节点、没有 First 帧)、"钉住的标题栏移出 world"那笔改动不是原因的证据、处理、变异验证与真机判据。
合并资源工作台分支:卡片从所属摞浮现与既有三条修复并存
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
e4c2f2466f
- 把远端最新 05612c46a(弹窗宿主类样式迁共享表 + 标签统计刷新 + 工具条分隔线去重)合入本分支 b41b26cab(卡片全部从所属摞浮现)
- 冲突零:仅 pitfalls.md 两侧追加记录自动合并,两条都保留
- 验证:npm run typecheck exit 0;resourceBookController / resourceBookLayout / resourceTagStatsRefresh / projectAssetPickerDialogShellStyle / selectedLayerToolbarDividerDedupe 五文件全绿
Author
Member

终于差不多整完了

终于差不多整完了
suzmii added 1 commit 2026-09-12 19:33:36 +08:00
修复资源画布浮层里的滚轮被画布消费:@ 选择器列表滚动时画布跟着平移 / 缩放
Project CI / Repository checks (pull_request) Successful in 2m27s
Project CI / Frontend tests (pull_request) Successful in 3m3s
Project CI / Backend tests (pull_request) Successful in 6m33s
Project CI / Native shell tests (pull_request) Successful in 18m54s
fc67439802
- 共享浮层判据抽出 isEventInsideFloatingOverlay,并新增 isFloatingOverlayWheelEvent:关闭判定与滚轮归属共用同一份口径(DOM 不在边界里的 portal 浮层 + 已登记浮层)
- isEventInsideFloatingMenu 返回值收紧为布尔,避免判据把命中的元素当真值往下传
- 资源画布唯一滚轮入口 handleResourceBookWheel 加统一守卫:浮层里的滚轮一律不消费(不 preventDefault、不动视口),不再逐浮层加 stopPropagation
- resourceCanvasFocusModel 增加 RESOURCE_CANVAS_WHEEL_OVERLAY_SELECTOR 与 isResourceCanvasWheelOverlayTarget,登记留在画布 DOM 里自带滚动区的浮层(快速编辑 / 信息 / 筛选)
- projectResourceLiveIntegration 新增用例:在选择器列表上派发真实 wheel → 浮层自己收到该事件、画布 data-resource-viewport 不变;对照组场景根派发 wheel → 视口照旧变化
- resourceCanvasFloatingDismiss 新增判据单测:portal 浮层 / 共享弹出层 / 已登记浮层三种来源归浮层,边界里的卡片与空白仍归画布,判不出归属不抢
- pitfalls.md 记录「portal 事件沿 React 树冒泡导致画布误吃滚轮」的根因与排查口径
suzmii added 4 commits 2026-09-12 19:54:31 +08:00
- server-rs/crates/shared-contracts/src/game_creation_app.rs:517 [bug high] 资产级 wire 结构补 deny_unknown_fields:与顶层 GameCreationAppManifest 同一取向,避免「读一次 + 任意一次写」静默抹掉未来新增的 assets[i] 字段;新增契约测试 asset_manifest_entry_rejects_unknown_asset_fields(裸条目 + 顶层 manifest 两条读路径都失败关闭,且已知字段照旧往返)。
- server-rs/crates/shared-contracts/src/game_creation_app.rs:536 [bug medium] 未知 category 从「退回 kind 派生值」改为失败关闭:反序列化结果就是落盘原值、要被整结构体写回,静默替换等于无迁移改落盘数据;缺字段仍按 kind 派生,派生只保留在 game_creation_app_asset_effective_category;原用例改名重写为 asset_manifest_entry_keeps_explicit_category_and_rejects_unknown_category。
- server-rs/crates/shared-contracts/src/game_creation_app.rs:780 [test low] 分区顺序跨语言交叉校验:packages/shared 侧新增解析 Rust PROJECT_RESOURCE_CANVAS_SECTIONS 并与 TS 常量对齐的测试(TS 侧补,见后续提交)。
- server-rs/crates/api-server/src/editor_project.rs:4120 [bug medium] 静态图别名白名单补 "asset"(register_local_asset_entry 空 kind 兜底值,canonical 映射到 image),数组长度 17→18,并在用例里补放行/拒绝两条断言。
- server-rs/crates/api-server/src/editor_project.rs:20747 [test low] AGC 快速编辑回归用例不再自洽:agc_payload 真正反序列化成 EditorImageEditRequest 并由请求体自己的 generationInputs.assetKind 驱动判据,另加源码级闸门断言(resolve_editor_image_edit_source / ensure_editor_image_edit_target_matches_source 体内不得再出现 generation_inputs)。
- TS 侧 gameCreationApp 的口径注释同步为「非法值这一支已不可达」(见后续提交)。
- packages/shared/src/components/styles.css:528 [bug medium] min-width: max-content 收窄到新的 .platform-segmented-tabs--scroll 修饰类:grid 分支没有溢出容器、轨道是 minmax(0,1fr),同样的下限只会让内容画到相邻格上;grid 侧靠轨道等分与宿主 truncateLabels 收口,选中 PlatformSegmentedTabs.tsx 的 scroll 分支补修修饰类。
- packages/shared/src/components/PlatformResourceFilterBar.test.tsx 同步改成钉「下限只挂在 --scroll」+「grid 分支不带 --scroll/overflow-x-auto」(声明级断言,jsdom 不计算外部样式表,属弱验证,真机判据见提交说明)。
- packages/shared/src/components/PlatformResourceFilterBar.tsx:24 [maintainability low] Props 改为以 tagItems 为判别项的联合类型:给 tagItems 就必须给 onToggleTag,非法状态编译期不可表达;组件内部再兜 disabled={!onToggleTag} 供给不带类型检查的调用方,并补对应运行时用例。
- packages/shared/src/components/PlatformResourceFilterBar.tsx:107 [style low] 标签行补 scrollbar-hide:它是唯一还画滚动条的横向滚动容器,与 styles.css 里 .scrollbar-hide 的注释口径对齐;补类名与声明级用例。
- packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts:51 [maintainability low] assetIds 排序改为确定性 code-unit sort(),不再依赖运行环境默认 locale(标签那一层仍显式钉 zh-CN),补跨输入顺序可复现的用例。
- packages/shared/src/contracts/gameCreationAppAssetTagLibrary.ts:68 [bug low] assetTagsMatchSelection 两侧对称归一化:selectedTags 也过 normalizeGameCreationAppAssetTags,' 像素风 ' 这类未归一化的选中项不再静默匹配不上。
- packages/shared/src/contracts/gameCreationApp.test.ts [test low] 新增解析 Rust PROJECT_RESOURCE_CANVAS_SECTIONS 与 TS 常量对齐的交叉校验(仿 assetKindCanonicalMapping.test.ts 解析 Rust 常量先例),钉住两侧分区顺序不漂移。
- packages/shared/src/contracts/gameCreationApp.ts 同步「读显示 / 写回」口径注释:未知 category 已在 Rust 读侧失败关闭,「非法值 → 派生」只剩内存对象语义。
- apps/ai-game-creator-shell/src/styles.css:5790 [bug medium] :has() 悬停高亮分类值换成现行 7 个分区(ui-interaction/character/scene/audio/document/unclassified/version),删掉永不渲染的 code/art 死选择器,补齐缺失栏目的高亮。
- apps/ai-game-creator-shell/src/styles.css:6873 [bug medium] hover/focus-within/is-selected 由只写 border-color 改为完整 border: 1px solid #d57b51(基规则 border: 0 下 border-color 无效果,选中/焦点态此前完全不可见),box-sizing: border-box 下不改卡片尺寸。
- apps/ai-game-creator-shell/src/styles.css:10151 [bug medium] 去掉高优先级 :focus-within 规则里的 box-shadow: none:它盖掉了 --platform-input-focus-ring 焦点光环,direct-codex 输入区此前没有任何可见焦点(无障碍回归),现在光环生效。
- apps/ai-game-creator-shell/src/styles.css:10198 [bug low] 润色状态文字与操作排改为同一行分列(grid-row: 2 + grid-column: 1 / 2),不再整行独占或与按钮重叠,状态出现/消失不改变输入框高度。
- apps/ai-game-creator-shell/src/styles.css:10206 [maintainability low] 删除永不命中的 .resource-reference-input-at direct-codex 规则(showTriggerButton={!directCodex} 不渲染该按钮),保留说明指向真正的 .project-supervisor-reference-trigger。
- 附带 prettier 对 .design-agent-reasoning summary 单行规则的换行归一(该文件本来未过 prettier --check)。
- scripts/lint-staged-rustfmt.mjs:12 [maintainability medium] 改为读取 lint-staged 追加的暂存 .rs 路径,只对真正命中暂存文件的 workspace 跑 cargo fmt --all -- --check:此前永远全量跑两个 workspace,只暂存 server-rs 干净文件的提交会被别人未暂存的 src-tauri 改动挡下来;没收到暂存列表(手工执行)时回退全量检查并在 stderr 说明,避免假装检查过。
- scripts/lint-staged-rustfmt.mjs:7 [maintainability low] apps/desktop-shell/src-tauri 显式记为「有意排除」而不是静默通过:实测该 workspace 当前整体不过 cargo fmt --check(host_bridge/mod.rs 等),直接纳入会让任何 Rust 提交都失败;命中该前缀的暂存文件会打印跳过提示,纳入步骤写在模块注释里(同时要补 package.json 的 check:rustfmt / format:rust,package.json 不在本批次改动范围)。
- 新增 scripts/lint-staged-rustfmt-workspaces.mjs:把「暂存路径 → 待检查 workspace」的纯映射逻辑独立出来,附 Windows 反斜杠/绝对路径归一化。
- 新增 scripts/lint-staged-rustfmt.test.ts(vitest,随 npm test 运行):覆盖只暂存单侧 workspace 的回归判据、手工执行回退全量、排除项与未登记路径必须被点名、路径归一化,以及与 package.json check:rustfmt 的 workspace 表同集交叉校验。
合并资源工作台分支:PR review 共享契约与样式批次并入滚轮修复
Project CI / Repository checks (pull_request) Successful in 2m38s
Project CI / Frontend tests (pull_request) Successful in 3m12s
Project CI / Backend tests (pull_request) Successful in 5m33s
Project CI / Native shell tests (pull_request) Successful in 16m46s
1cc258d2a4
- 把远端最新 fc6743980(浮层滚轮归属修复)合入本分支(PR #316 review 的 shared/server-rs/CSS/scripts 三笔:9835bbec9、8b8597782、3b385ef57)
- 冲突零:自动合并成功
- 验证:npm run typecheck exit 0;shared 契约/标签/筛选条与 lint-staged 用例全绿
suzmii added 4 commits 2026-09-12 20:24:09 +08:00
修复素材导出缺少敏感文件拒绝与自我覆盖保护(PR #316 review)

- asset_export.rs:27-30 [security · high] 已被修复:`resolve_export_source_file` 现在与通用读取路径同一口径,normalize 后先 `reject_agent_runtime_private_control_path` 再 `reject_sensitive_project_file_read`,`.env` / 本地凭据配置与 `.agent/runtime|checkpoints|workbench` 控制面都不能被复制到项目外。
- asset_export.rs:31-33 [security · medium] 已被修复:源路径改用 `resolve_local_project_path` 逐段复核符号链接与 Windows reparse point,`assets` 本身是链接/junction 时不再把项目外文件当成素材导出。
- asset_export.rs:76-78 [bug · high] 已被修复:新增 `reject_export_destination_matching_source`,在创建写句柄之前按「路径等价(大小写不敏感平台忽略大小写)」+「文件身份(含硬链接,退化身份不参与判定)」拒绝保存目标与源素材同一份文件;否则 `File::create` 先截断源文件、复制读到 0 字节还会报成功。
- asset_export.rs:93-95 [other · low] 已被修复:复制改为同目录临时文件 + `sync_all` + `rename` 原子替换,失败时清理临时文件;不再先截断既有目标、也不再留下半截文件。
- 文档:命令 docstring 不再声称「只导出 manifest 已登记素材」——DirectProject 导入的附件同样要能另存,而它们不是 manifest 资产,所以这里明确写成与通用读取同一套路径门禁。
- 测试:新增 5 条用例覆盖敏感/控制面源拒绝、中间目录符号链接逃逸、目标即源文件、硬链接目标、原子替换不留临时文件;7 条变异验证全部 CAUGHT。
修复素材删除幂等、重命名 CAS 与 manifest 预览缓存一致性(PR #316 review)

- assets.rs:2048-2049 [bug · medium] 已被修复:删除登记在「身份 + revision 两项 CAS 都已通过」后遇到素材已不在 manifest 时按 no-op 成功收敛并照常推进 revision,不再报「项目资源不存在」;新增 `asset_delete_retry_converges_when_only_the_revision_advance_failed` 复现「manifest 已提交、revision 未推进」并验证重试收敛(原「不存在的素材必须报错」断言相应改为幂等语义,前端只消费 assetId/committedProjectRevision)。
- assets.rs:2035-2042 [performance · low] 已被修复:引用集合只算一次并用 `HashSet<String>` 做成员判定,`retain` 不再是 O(V²);版本守卫闭包按约定在写入前另行求值、拿不到该可变借用,故两次扫描保留并已在注释里说明。
- commands.rs:1032 [maintainability · medium] 已被修复:`RenameLocalProjectAssetInput` 补 `expectedProjectId` / `expectedProjectRevision`,`rename_local_project_asset_at` 与删除/分类更新同口径做两段身份复核 + revision CAS,失败时磁盘、manifest、revision 全不动;新增 CAS 用例(陈旧 revision / 跨项目身份 / 空身份)。注意:前端 `confirmResourceRename` 必须同时补传这两个字段(TS 侧由前端 owner 落地)。
- asset_rename.rs:84 [bug · low] 已被修复:新增 `asset_frame_directory_matches`,目录段在 Windows/macOS 按大小写不敏感比较(Linux 保持敏感),与文件名比较同口径;新增 `Assets/hero.png` 帧对齐用例。
- asset_rename.rs:249-250 [bug · medium] 已被修复:revision 推进失败改为报 `reconciliation-required: 素材已改名…且 manifest 已落盘,但项目 revision 未能推进`,明确这是「已提交但对账未完成」而非普通失败;同时新增的 CAS 使后续命令必须按新 revision 重试,重试会命中同名 no-op 分支收敛。
- tests/asset_rename.rs:257-263 [maintainability · medium] 已被修复:删除 `RenameLocalProjectAssetFaultStage` 枚举与生产签名上的 `fault` 参数,故障注入改用 `#[cfg(test)]` 的 `.agent/runtime/test-fail-next-asset-rename-manifest-write` 标记文件(与 agent_db 既有约定一致),生产 API 面不再能被推上「只回滚、不写 manifest」的路径。
- manifest.rs:1386 [bug · medium] 已被修复:预览缓存失效点下沉到唯一的装盘公共体 `write_manifest_locked_with_version_guard`(install 成功后立即失效,含安装后回读不一致的失败路径),`mutate_manifest_at_allowing_version_removals` 与绑定改写通道一并覆盖;`write_manifest_with_lock_hook` 里的重复调用已移除。
- manifest.rs:1480-1482 [bug · medium] 已被修复:填充侧改为「先取身份快照 → 读取 → 复核快照一致才入缓存」,不再可能出现「旧内容 + 新文件身份」的缓存条目。
- manifest.rs:1135 [security · low] 已被修复:新增 `normalize_manifest_asset_tags`,在共享归一化之后按数量(16)与单标签长度(32 字符)失败关闭;超长标签不截断,避免与共享标签库静默分叉。
- manifest.rs:1180-1181 [bug · medium] 已被修复:审计改为幂等——前后分类与标签完全相同时不追加记录,重试不会再写出 `previousCategory == category` 的假变更审计(保留「manifest 落盘后、推进 revision 前追加」这一既有位置约定)。
- manifest.rs:1140 [maintainability · low] 已被修复:分类写入的项目写锁标签由 `asset.register` 改为 `asset.classification.update`,争用诊断不再误报成素材登记。
- 测试:新增 preview_cache_tests(命中/TOCTOU 不入缓存/身份漂移丢弃/主写入通道显式失效)、分类重复写入不留假审计、标签上下界拒绝;7 条变异验证全部 CAUGHT。
收敛外部错误体、注入提示词与锁诊断的不设界行为(PR #316 review)

- resource_editor.rs:2476 [bug · medium] 已被修复:`error_details` 改为 `error.details` 优先、顶层 `details` 兜底,与 docstring 声明的取值顺序一致;新增「两种形状同时出现」用例钉住优先级。
- resource_editor.rs:2468 [performance · low] 已被修复:4xx 错误体改走 `bytes_stream` 有界读取(64KiB 上限,超限即放弃取原因),不再用 `response.text()` 无上限缓冲外部正文。
- resource_editor.rs:2514-2525 [maintainability · low] 已被修复:provider / assetKind / mediaType / code 四个追加字段各自按 80 字符截断(`MAX_EDITOR_ERROR_DETAIL_CHARS`),最终文案长度不再随外部正文增长;新增超长字段整串比对用例。
- direct_codex_references.rs:175-182 [performance · medium] 已被修复:`resourceIds` 超过 32 条直接按模块失败关闭口径拒绝,并在同一 id 的重复项上做去重,注入提示词的「关联素材 ID」行长度与 manifest 扫描次数都有界;新增去重 + 超限用例。
- direct_runtime.rs:1795-1797 [maintainability · low] 已被修复:`direct_project_history_contention_failure` 收窄为只认追加锁超时标记(项目写锁争用已在更早的专用分支判掉,是死代码),恢复提示文案相应去掉「或项目锁」;新增用例钉住两条判据互不重叠、各自命中正确提示。
- conversation.rs:984-989 [maintainability · medium] 已被修复:`DIRECT_PROJECT_HISTORY_RECORD_TYPE` 提升为 `pub(crate)`,读取侧 `is_direct_project_history_row` 引用同一常量,`project.jsonl` 的信封契约改为编译期共享。
- agent_db.rs:3570-3574 [other · medium] 已被修复:`lock_with_attempts` 的进程内锁改为有界 `try_lock` 轮询(窗口与调用方一致,2000/200 次 × 5ms),同进程读路径不再被写者的 10s 跨进程等待拖住;顺序仍是「进程内锁 → 跨进程锁」,无 ABBA;新增用例验证短窗口 <5s 失败且释放后立即可重取。
- agent_db.rs:3668-3669 [maintainability · low] 已被修复:新增与锁同级的旁路诊断文件(普通共享写入),诊断优先读它、读不到再退回锁文件,Windows 上 zero-share 独占锁文件时也能报出持锁方;新增「取锁写出旁路文件」「锁文件不可读时退到旁路」两条用例。仅存 pid/用途/时间戳,不参与判活、回收或抢占。
- agent_db.rs:3827-3828 [maintainability · low] 已被修复:仅当修复错误真的符合提权口径(`windows_acl_error_may_need_elevation`)时才追加「自动提权修复未完成」,否则原样透传底层错误,不再重复报同一个错误并谎称尝试过提权。
- main.rs:29-31 [maintainability · low] 判定为不适用/不改:实测删掉那四个 import 会产生 18 处 E0425(`direct_tool_bridge` / `canvas_generation` / `assets` / `manifest` / `resource_editor` / `resource_dependency_graph` / `recovery_tests` 等经 `use super::*` 从 crate 根复用它们),而保留它们并不产生 `unused_imports` 警告(cargo check --all-targets 无一条指向这几行);已按原样恢复 main.rs,工作树中该文件无改动。
合并资源工作台分支:PR review 的 Rust 批次并入共享契约与滚轮修复
Project CI / Repository checks (pull_request) Successful in 2m49s
Project CI / Frontend tests (pull_request) Successful in 3m20s
Project CI / Backend tests (pull_request) Successful in 6m58s
Project CI / Native shell tests (pull_request) Successful in 19m0s
5ab33a8db3
- 把远端最新 1cc258d2a 合入本分支(PR #316 review 的 Rust 三笔:3a9e45c80、daef9f6cc、64b0d2f76)
- 覆盖:素材导出补敏感文件/控制面拒绝与自我覆盖保护、删除幂等、重命名 CAS、manifest 缓存 TOCTOU、标签上限、注入资源 ID 上限与去重、锁诊断与提权口径
- 冲突零:自动合并成功
- 注意:rename_local_project_asset 现要求 expectedProjectId/expectedProjectRevision,前端调用点由前端批次同批补齐
suzmii added 4 commits 2026-09-12 20:52:44 +08:00
- App.tsx:`conversation.write` 策略确认后的重跑改为整体复用首轮入参(`directCodexPolicyRetryInput`),不再手写字段,修掉漏传 `references` 导致 @ 引用被静默丢弃的缺陷
- resourceReferences.ts:运行画面引用的判别指纹补上 `resourceIds`(排序集合口径)/`versionId`/`elementRole`/`width`/`height`,避免不同选点撞 key 被 `dedupeChatReferences` 去重丢掉
- ResourceReferenceInput.tsx:程序化重建编辑器内容改为把 chip 内联嵌在文本里的同名 token 位置(不再另起一段堆在末尾),并把 `lastEmittedDraftRef` 记为「编辑器实际读回来的草稿」而不是 props,消除每轮多一段 `@显示名` 的渲染循环
- ResourceReferenceInput.tsx:候选浮层(候选菜单 / 素材选择器)打开时 Enter 不再提交表单,把按键让给 `LexicalTypeaheadMenuPlugin`
- ResourceReferenceInput.tsx:`assets` / `currentVersionAssetReferences` 的 memo 依赖改用内容签名;预览请求在换素材 / 卸载时主动作废上一个 scope;空态文案的嵌套三元抽成 `pickerEmptyMessage`
- usePromptPolish.ts:`polish()` 补 `catch`,注入式 `requestPolish` 拒绝时给出可见失败提示而不是未处理拒绝;`reset()` 递增请求代次作废在飞请求
- ChatPromptPolishReminder.tsx:章节级 Esc 分支补 `busy` 判据,与关闭按钮 / 遮罩同一口径(在飞不许关面板)
- LocalGamePreviewFrame.tsx:资源 id 改用专用净化(允许 `:` 与可打印非 ASCII),修掉 `local-asset:<id>`、`persisted-角色草图.png` 被整条过滤导致引用丢素材关联
- sessionPreview.ts:`get_local_game_preview_status` 读失败不再当作「确认没有在跑」去发停止命令,避免停掉真在跑的活体预览
- useHomeProjectCreation.ts:进项目流程加代次令牌,慢请求后到不再覆盖新项目
- WorkspaceLauncher.tsx:拒收恢复的阶段回写按 `projectId`/`heldRevision`/`snapshotRevision` 关联,不再把恢复结果盖到后续新拒收的提示上
- 用例:`directCodexPolicyRetryInput` 透传、引用指纹区分度、重建不循环、浮层打开时 Enter 不提交、润色拒绝 / reset 作废、预览读失败不停预览
- 素材重命名(跨范围契约):`rename_local_project_asset` 现在要求 `expectedProjectId` + `expectedProjectRevision`,前端按删除 / 分类保存同一套 CAS 口径补齐——先 `get_local_game_project_revision` 读当前 revision 再连同项目身份提交,不用可能过期的本地缓存值
- 失败文案统一:新增 `projectAssetCommandErrorMessage`,把 `project-identity-conflict` / `project-revision-conflict` 翻成用户可读中文,重命名 / 删除 / 分类保存三条链路共用一份映射,其余错误原样透出
- index.tsx:资源面板下载按 `resolveDownloadablePanelEntries` 过滤,版本卡这类合成 path 的条目不再进入落盘链路
- index.tsx:`characterAnimationPanel` 纳入画布浮层开关判据(抽 `resolveResourceCanvasFloatingPanelOpen`),「生成动画」面板与快速编辑 / 信息浮层共用同一条「点外部 / Esc 关闭」规则
- index.tsx:去掉 `ResourceBookScene` 上重复的 `onWheel`,滚轮只由 manager 上的原生 `passive:false` 监听处理,平移 / 缩放不再被翻倍
- index.tsx:快速编辑的源资源在层 id 命中不到时回落到已正规化的 `asset:<id>`,重试不再静默什么都不做;取不到时给出可见失败
- ResourceCanvasPanelView.tsx:下载按钮按可下载条数判可用、不再在上传中显示下载转圈;上传中禁掉 × / Esc / 遮罩三条关闭路径;预览占位文案抽 helper
- GameRunVersionPicker.tsx:portal 菜单在滚动 / 缩放后重算位置;外部点击判定沿用共享 `useImageCanvasFloatingOptionDismiss` + `menuRef` 边界(review 该条已修,保留现状并补位置用例)
- ResourceCanvasGenerationPanelView.tsx:`submitting` 改 `finally` 收回,成功路径不再永久锁住面板
- ResourceAssetDeleteDialog.tsx / ResourceRenameDialog.tsx / ResourceClassificationPanel.tsx:在飞时 `closeOnEscape` / `closeOnBackdrop` 与头部 × 一起挡住
- resourceEditModel.ts / resourceCanvasToolbarModel.ts / useProjectResourceCardPreviews.ts / projectResourceLiveUpdateModel.ts:抽 `resourceBaseName`、`canonicalProjectedResourceMediaType` 参数收窄去掉强转、删未使用参数、补「IO 失败会 reject」的签名说明
- resourceCanvasHistoryModel.ts:`resourceCanvasSnapshotsEqual` 不再比较不可恢复的 `manuallyPlaced`,消除「压进历史但撤销是空操作」
- resourceCanvasSectionMapping.ts:删掉从未接线、且注释与实现相矛盾的 `LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK`
- resourceCanvasChrome.css:`.game-resource-panel-grid` 补 `flex: 1 1 auto; min-height: 0`,卡片网格成为真正的滚动区
- 用例:重命名 CAS 载荷与读序、CAS 拒绝文案、版本卡下载门禁、上传中关闭路径、浮层判据、菜单跟随滚动、分区映射
- ImageCanvasProjectAssetPickerDialog.tsx:打开时的重置 effect 只依赖 `open`,父级每次渲染重建 `selectedAssetIds` 不再清空进行中的搜索 / 筛选 / 选择
- ImageCanvasProjectAssetPickerDialog.tsx:素材卡媒体渲染抽成 `AssetCardMedia`,顺带去掉该处嵌套三元
- projectAssetReferencePickerModel.ts:`image-sequence` 单列一类(新增「序列帧」页签),不再混进「图片」——参考图链路只接受 `mediaType === 'image'`,混进去就是「按图片选中、按图片文案拒掉」;缩略图口径由 `projectAssetPickerUsesThumbnail` 收口
- 用例:父级重渲染不清空在选状态;序列帧分类归属与其被参考图链路过滤掉的事实
合并资源工作台分支:PR review 前端批次并入(含滚轮归属与重命名 CAS)
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 2m50s
Project CI / Native shell tests (pull_request) Successful in 18m4s
6f212a3f2c
- 把远端最新 5ab33a8db 合入本分支(PR #316 review 前端三笔:d04fe83f1、d1581892d、a9a5fba92,41 文件)
- 冲突零;同时保留两侧对滚轮的处理:本批把 ResourceBookScene 的重复 onWheel 改成 manager 原生监听(body portal 收不到),远端那笔的浮层守卫仍在 handleResourceBookWheel 内兜住留在 DOM 里带滚动区的浮层,互补不冲突
- 含追加契约:confirmResourceRename 补 expectedProjectId/expectedProjectRevision(与删除/分类同一套 CAS 口径)+ 共享错误码映射 projectAssetCommandErrorMessage
- 验证:npm run typecheck exit 0;resourceRename / resourceCanvasFloatingDismiss / projectResourceLiveIntegration / resourceReferenceInput 四文件全绿
suzmii added 1 commit 2026-09-12 20:55:49 +08:00
合并 master:修复 CI comparison base 门禁
Project CI / Repository checks (pull_request) Successful in 2m55s
Project CI / Frontend tests (pull_request) Successful in 3m35s
Project CI / Backend tests (pull_request) Successful in 6m26s
Project CI / Native shell tests (pull_request) Failing after 14m12s
aa0fcc1d71
- 合入 origin/master(b12a81e9c「修复 AGC 自主运行时测试目录权限与任务目录加固」),使 PR 头包含当前 master tip,门禁 Resolve comparison base 不再拦
- 冲突零:自动合并成功
- 验证:npm run typecheck exit 0;npm run check:rustfmt exit 0
suzmii marked the pull request as ready for review 2026-09-12 21:55:25 +08:00
Author
Member

本轮修复与 PR review 处置汇总(附 Native shell 现状说明)

一、交互与功能修复

  1. 「资源清单更新被拒收(同一版本号上的清单内容不一致)」假警报:CAS 判据面收窄为「资源 + 版本」——预览起停、任务进度、改名这类不推 revision 的簿记写入不再被判冲突(此前每次开预览必弹,且快照被丢弃导致新资源/新版本进不了画布)。
  2. 切换版本报 undefined is not an object (evaluating 'listeners[eventId].handlerId'):tauri 2.11.3 注入的 unlisten 脚本缺条目判空 + listen promise 与 cleanup 竞态(StrictMode 下每次挂载都走)。新增统一订阅入口:注销先摘回调再 unlisten,并去重。
  3. 切换版本「点了选项没反应」:版本菜单 createPortal 到 body 后仍被自家「点外部」判定吃掉 mousedown(菜单先卸载、click 落空)。改为把 portal 菜单纳入浮层边界,复用共享 dismiss 口径。
  4. 资源管理页顶部那一行文字发虚:钉住的栏目标题栏移出画布缩放层(此前靠反向 scale 抵消活在缩放层里),转场控制器按节点是否在 world 内选坐标系。
  5. 替换素材候选弹窗:① 缩略图不加载(弹窗在预览 observer 的 root 之外)→ 接上资源卡同一套预览管线 + 视口几何扫描;② 可替换的候选排到最前(判据与禁用状态同源);③ 面板背景透明(宿主类样式只写在网页端整站表里,AGC 不加载该表)→ 迁到所有宿主都会加载的共享样式表。
  6. 「编辑标签」:点「添加」不再关窗(真凶是宿主在保存成功后卸载面板);底部只留右下角一个「添加」;删素材入口移到工具条(标签 pill 内自己的 × 保留)。
  7. 素材类型可由用户变更:面板加类型选择器(读写口径按「是否主动选过」分流),改完画布自动换栏目、角标同步。
  8. 卡片角标显示「资源类型」(等于所在栏目名)而非文件类型,三处渲染面一并生效。
  9. 「快速编辑」支持 @ 引用素材(复用聊天同一套引用语义与输入区),@ 面板补标签筛选(多标签 AND,并与搜索/分类叠加)。
  10. 改完标签后聊天侧标签统计与候选不刷新:聊天宿主拿的是另一份快照且没有跟随链路,补齐同步。
  11. 工具条「快速编辑」与「引用」之间出现两条分隔线:共享工具条两处各输出一条,去重。
  12. 卡片出现动画只覆盖部分卡片:总览每摞只铺 3 张,其余卡片没有 FLIP 起始帧;改为按 (栏目, 列号) 合成「堆锚点」,全部卡片都从所属那一摞浮现。
  13. 浮层里滚轮串到画布:React portal 事件沿 React 树冒泡到画布 onWheel(且 wheel 委托是 passive,preventDefault 无效)。统一浮层判据 + 画布侧一处守卫。
  14. 「所有资源」展开态缩放按钮按不动:读错视口 ref(展开态与栏目页各有一份)。

二、Code review 处置(三批共 73 条)

  • 共享 / server-rs / 样式 / 脚本 17 条:16 修 + 1 显式记录排除。含资产级 deny_unknown_fields(失败关闭)、未知 category 改 fail-closed、Rust↔TS 分类顺序交叉校验、静态图别名补 asset、AGC 三条 CSS 真 bug(:has() 用了已退役分类值 / 卡片 border:0 下状态边框无效 / 焦点光环被盖)、lint-staged 按暂存文件筛 workspace。
  • Rust 19 条:17 修 + 2 判定不改。含素材导出补敏感文件与控制面路径拒绝(security high)、同文件覆盖保护、导出改原子替换、删除幂等、重命名补 CAS、manifest 预览缓存 TOCTOU、标签与注入资源 ID 上限、进程内锁有界等待、锁持方旁路诊断。
  • 前端 37 条:35 修 + 2 不改。含策略确认重试丢失 @ 引用、runtime-region 引用指纹漏字段导致去重丢引用、提示词输入区重建渲染循环、候选弹窗每次父渲染清空搜索/选择、版本卡被当可下载、动画面板未纳入浮层关闭判据、image-sequence 被静默丢弃等。
  • 每条都有处置或理由("已修 / 已修过 / 判定不改 + 依据");契约与安全类补了测试;关键项做了变异验证(改回旧行为必须红);jsdom 测不了的(CSS 声明级、真机视觉)均已标注并给出真机判据。

三、工程与门禁

  • 修掉替换分支带入的 rustfmt 漂移(该分支此前从未跑过 CI)。
  • 合入 master 修复 Resolve comparison base 门禁(要求 PR 头包含当前 master tip)。

四、CI 现状

作业 结果
Repository checks success
Frontend tests success
Backend tests success
Native shell tests 5 条守卫类用例失败

五、这 5 条不是本 PR 引入的(证据)

  • master tip b12a81e9c 自身就是红的:前三项 success,Native shell failure(13m9s);其日志 test result: FAILED. 2367 passed; 5 failed,5 条用例名与本 PR 完全一致。
  • master 前序 f505d2792 同为 failure,其父 2e87a1403 为 success ⇒ 该红是这两个提交新引入。
  • 本 PR 上一个全绿提交(Native shell 19m0s)到当前 HEAD 的 src-tauri 差异,只有合入 master 的 b12a81e9c 那 5 个文件
  • 涉及用例:
    1. action_execution::canvas_only_execution_tests::art_director_canvas_only_execution_rejects_file_write_and_mixed_batch_without_side_effects
    2. runtime_tools::media::platform_art_generation_observation_tests::stale_ready_child_is_rejected_before_external_canvas_request
    3. tests::collaboration::delegation::autonomous_delegate_descendant_inherits_and_enforces_the_current_root_guard
    4. tests::collaboration::delegation::autonomous_direct_child_collaboration_mutations_require_the_current_root
    5. tests::collaboration::delegation::canvas_replacement_rejects_parent_run_that_terminates_during_external_request

六、根因与建议修法

b12a81e9cproject_gates.rsensure_current_autonomous_ready_child_mutation_at_locked 中按 autonomous_relaxed_run_at 提前返回 Ok,使该函数后续约 120 行「当前根 Run 身份 + 根存活」校验成为不可达死代码;同时在 action_execution.rs 为 Canvas-only 门禁加了 relaxed 旁路——正对应上面 5 条的判据(file.write 实得 ok 期望 rejectedagent.message 实得 ok 期望 failed;过期 ready child 未在外部请求前被拒)。

建议(请引入方确认口径):

  • 若旁路是有意的(支持 relaxed autonomous run)⇒ 应同步更新这 5 条用例的期望值;
  • 若属漏改测试⇒ 回退这两处旁路即可(本地已验证:仅回退这两处、保留同批的目录加固 / Windows 临时目录 owner / 取消前置检查,5 条逐条转绿);
  • 无论哪种,建议先在 master 上修:这是 master 自带的红,其它分支合入 master 后都会被同一批用例拦下。
  • 关联:b12a81e9c(kdletters)、f505d2792(lhk229)。
## 本轮修复与 PR review 处置汇总(附 Native shell 现状说明) ### 一、交互与功能修复 1. **「资源清单更新被拒收(同一版本号上的清单内容不一致)」假警报**:CAS 判据面收窄为「资源 + 版本」——预览起停、任务进度、改名这类不推 revision 的簿记写入不再被判冲突(此前每次开预览必弹,且快照被丢弃导致新资源/新版本进不了画布)。 2. **切换版本报 `undefined is not an object (evaluating 'listeners[eventId].handlerId')`**:tauri 2.11.3 注入的 unlisten 脚本缺条目判空 + `listen` promise 与 cleanup 竞态(StrictMode 下每次挂载都走)。新增统一订阅入口:注销先摘回调再 unlisten,并去重。 3. **切换版本「点了选项没反应」**:版本菜单 `createPortal` 到 body 后仍被自家「点外部」判定吃掉 `mousedown`(菜单先卸载、click 落空)。改为把 portal 菜单纳入浮层边界,复用共享 dismiss 口径。 4. **资源管理页顶部那一行文字发虚**:钉住的栏目标题栏移出画布缩放层(此前靠反向 `scale` 抵消活在缩放层里),转场控制器按节点是否在 world 内选坐标系。 5. **替换素材候选弹窗**:① 缩略图不加载(弹窗在预览 observer 的 root 之外)→ 接上资源卡同一套预览管线 + 视口几何扫描;② 可替换的候选**排到最前**(判据与禁用状态同源);③ 面板背景透明(宿主类样式只写在网页端整站表里,AGC 不加载该表)→ 迁到所有宿主都会加载的共享样式表。 6. **「编辑标签」**:点「添加」不再关窗(真凶是宿主在保存成功后卸载面板);底部只留右下角一个「添加」;**删素材入口移到工具条**(标签 pill 内自己的 × 保留)。 7. **素材类型可由用户变更**:面板加类型选择器(读写口径按「是否主动选过」分流),改完画布自动换栏目、角标同步。 8. **卡片角标显示「资源类型」**(等于所在栏目名)而非文件类型,三处渲染面一并生效。 9. **「快速编辑」支持 @ 引用素材**(复用聊天同一套引用语义与输入区),**@ 面板补标签筛选**(多标签 AND,并与搜索/分类叠加)。 10. **改完标签后聊天侧标签统计与候选不刷新**:聊天宿主拿的是另一份快照且没有跟随链路,补齐同步。 11. **工具条「快速编辑」与「引用」之间出现两条分隔线**:共享工具条两处各输出一条,去重。 12. **卡片出现动画只覆盖部分卡片**:总览每摞只铺 3 张,其余卡片没有 FLIP 起始帧;改为按 (栏目, 列号) 合成「堆锚点」,全部卡片都从所属那一摞浮现。 13. **浮层里滚轮串到画布**:React portal 事件沿 React 树冒泡到画布 `onWheel`(且 wheel 委托是 passive,`preventDefault` 无效)。统一浮层判据 + 画布侧一处守卫。 14. **「所有资源」展开态缩放按钮按不动**:读错视口 ref(展开态与栏目页各有一份)。 ### 二、Code review 处置(三批共 73 条) - **共享 / server-rs / 样式 / 脚本 17 条**:16 修 + 1 显式记录排除。含资产级 `deny_unknown_fields`(失败关闭)、未知 `category` 改 fail-closed、Rust↔TS 分类顺序交叉校验、静态图别名补 `asset`、AGC 三条 CSS 真 bug(`:has()` 用了已退役分类值 / 卡片 `border:0` 下状态边框无效 / 焦点光环被盖)、`lint-staged` 按暂存文件筛 workspace。 - **Rust 19 条**:17 修 + 2 判定不改。含**素材导出补敏感文件与控制面路径拒绝**(security high)、同文件覆盖保护、导出改原子替换、删除幂等、重命名补 CAS、manifest 预览缓存 TOCTOU、标签与注入资源 ID 上限、进程内锁有界等待、锁持方旁路诊断。 - **前端 37 条**:35 修 + 2 不改。含策略确认重试**丢失 @ 引用**、runtime-region 引用指纹漏字段导致去重丢引用、提示词输入区**重建渲染循环**、候选弹窗每次父渲染清空搜索/选择、版本卡被当可下载、动画面板未纳入浮层关闭判据、`image-sequence` 被静默丢弃等。 - 每条都有处置或理由("已修 / 已修过 / 判定不改 + 依据");契约与安全类补了测试;关键项做了**变异验证**(改回旧行为必须红);jsdom 测不了的(CSS 声明级、真机视觉)均已标注并给出真机判据。 ### 三、工程与门禁 - 修掉替换分支带入的 rustfmt 漂移(该分支此前从未跑过 CI)。 - 合入 master 修复 `Resolve comparison base` 门禁(要求 PR 头包含当前 master tip)。 ### 四、CI 现状 | 作业 | 结果 | |---|---| | Repository checks | ✅ success | | Frontend tests | ✅ success | | Backend tests | ✅ success | | Native shell tests | ❌ 5 条守卫类用例失败 | ### 五、这 5 条**不是本 PR 引入的**(证据) - **master tip `b12a81e9c` 自身就是红的**:前三项 success,**Native shell failure(13m9s)**;其日志 `test result: FAILED. 2367 passed; 5 failed`,5 条用例名与本 PR 完全一致。 - master 前序 `f505d2792` 同为 failure,其父 `2e87a1403` 为 success ⇒ 该红是这两个提交新引入。 - 本 PR 上一个全绿提交(Native shell ✅ 19m0s)到当前 HEAD 的 `src-tauri` 差异,**只有合入 master 的 `b12a81e9c` 那 5 个文件**。 - 涉及用例: 1. `action_execution::canvas_only_execution_tests::art_director_canvas_only_execution_rejects_file_write_and_mixed_batch_without_side_effects` 2. `runtime_tools::media::platform_art_generation_observation_tests::stale_ready_child_is_rejected_before_external_canvas_request` 3. `tests::collaboration::delegation::autonomous_delegate_descendant_inherits_and_enforces_the_current_root_guard` 4. `tests::collaboration::delegation::autonomous_direct_child_collaboration_mutations_require_the_current_root` 5. `tests::collaboration::delegation::canvas_replacement_rejects_parent_run_that_terminates_during_external_request` ### 六、根因与建议修法 `b12a81e9c` 在 `project_gates.rs` 的 `ensure_current_autonomous_ready_child_mutation_at_locked` 中按 `autonomous_relaxed_run_at` **提前返回 `Ok`**,使该函数后续约 120 行「当前根 Run 身份 + 根存活」校验成为**不可达死代码**;同时在 `action_execution.rs` 为 Canvas-only 门禁加了 relaxed 旁路——正对应上面 5 条的判据(`file.write` 实得 `ok` 期望 `rejected`;`agent.message` 实得 `ok` 期望 `failed`;过期 ready child 未在外部请求前被拒)。 建议(请引入方确认口径): - 若旁路**是有意的**(支持 relaxed autonomous run)⇒ 应同步更新这 5 条用例的期望值; - 若属**漏改测试**⇒ 回退这两处旁路即可(本地已验证:仅回退这两处、保留同批的目录加固 / Windows 临时目录 owner / 取消前置检查,5 条逐条转绿); - 无论哪种,**建议先在 master 上修**:这是 master 自带的红,其它分支合入 master 后都会被同一批用例拦下。 - 关联:`b12a81e9c`(kdletters)、`f505d2792`(lhk229)。
Some checks are pending
Project CI / Repository checks (pull_request) Successful in 2m55s
Project CI / Frontend tests (pull_request) Successful in 3m35s
Project CI / Backend tests (pull_request) Successful in 6m26s
Project CI / Native shell tests (pull_request) Failing after 14m12s
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/agc-canvas-resource-workbench-v3:feat/agc-canvas-resource-workbench-v3
git checkout feat/agc-canvas-resource-workbench-v3
Sign in to join this conversation.