Commit Graph

1453 Commits

Author SHA1 Message Date
suzmii 77871bfa56 把可能弹 UAC 的 ACL 提权修复移出 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 自动提权)
2026-09-12 00:12:52 +08:00
suzmii 1affb5eec3 锁内只保留 append:DirectProject 历史的幂等回扫移出持锁窗口
- 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 锁外」,验证后已还原
2026-09-12 00:07:04 +08:00
suzmii f11942f357 展开态补回空态提示:复用栏目页同一个 .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
- index.tsx:展开态在"可见资源为 0"时渲染 <p class="game-resource-page-empty">没有匹配资源</p>,与栏目页那一条同一条判据口径与同一句文案
- 它挂在既有的画布浮层 host 下(该 host 自身不定位),因此按画本场景根居中,观感与栏目页一致,不需要任何新 CSS
- 测试:空项目的「所有资源」用例补三条断言——进入展开态后标题栏是 .is-active[data-resource-book-category="all"]、卡片宿主数为 0、空态文案是「没有匹配资源」(把"复用既有空态、不新增样式"变成可执行判据)
2026-09-11 23:55:36 +08:00
suzmii 0d98bd05cc 删掉「所有资源」页的独立网格样式:它已经走画本场景,这六条没有调用方了
- 删除 .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"是本次红线)
2026-09-11 23:33:59 +08:00
suzmii 9788248d9b 「所有资源」页改走画本场景:与栏目页同一套卡片宿主与几何,并可拖动
- 新增 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"]),并断言旧网格容器 / 宿主一个都不再存在
2026-09-11 23:33:08 +08:00
suzmii 28781a420c 补资源总览网格的声明级断言:钉住 width:100% 与 max-width 并存,防止退回单列
- 新增用例「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
2026-09-11 22:50:01 +08:00
suzmii 3db6afa052 对齐 Codex app-server 读写单行上限:写侧守卫 + DirectProject 历史注入前置校验,并把注入超限改判为不可重试
- 隔离探针实测(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)
2026-09-11 22:24:30 +08:00
suzmii 1b7261685c 登记素材上传配对的踩坑与覆盖边界:断言打在生产函数上,端到端 UI 未覆盖
- `pitfalls.md` 新增「素材上传不要把『上传前读到的 revision』贴到『上传后读到的清单』上」:现象(每次上传都先吃一条拒收提示)、根因(上传本身推进 revision,错配必然发生而非竞态)、处理(上传动作与配对读收进同一个函数,调用方拿不到中间那个 revision,也就没机会贴错)、同类文案坑(`unresolved` 三条进入路径里有两条重读是**成功**的,写成"读取磁盘清单失败"就是误报)、验证与变异,并记录关联文件与提交。
- 覆盖边界(按当下口径写清,防止后人误读):断言打在生产函数 `uploadProjectAssetFilesAndReadSnapshot` 上 —— 它同时拥有上传与配对读,所以配对语义是真测的;但 AGC 侧**没有**「资源面板上传 → 拒收提示条」的端到端 UI 用例(现有 harness 只有聊天入口的 `/asset.upload`,资源面板 file input 无用例),因此「界面上不再出现拒收提示」是推理结论而非端到端断言,禁止当成端到端覆盖引用。
- 同一条边界同时写进 `projectResourceLiveUpdateModel.test.ts` 用例组头部,随断言一起走,避免只看测试文件的人误读。
- 纯文档 + 测试注释,无功能改动。
2026-09-11 22:19:14 +08:00
suzmii c3ccae75af 按实测收回 cfd89ba57 顺手加宽的一处测试超时,并写明另一处保留的理由
- `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 都不是耗时预期;硬收会让内层预算变成不可达配置,是更差的诊断而不是更紧的门禁。
- 无功能改动。
2026-09-11 22:18:40 +08:00
suzmii a88034dab1 修复资源总览网格退化成单列:父链宽度不定让 auto-fit 只解析 1 个轨道
- 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 用例
2026-09-11 22:10:43 +08:00
suzmii e98066ee7e 驱逐后不再补扫:删掉那条会把可见卡反复重读的自激回路,并如实标注超时预算
- 删掉 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。
2026-09-11 22:02:31 +08:00
suzmii b12b0998d0 分类改动不再丢弃已落盘的画布坐标:分区不一致时归并到资源当前分类并保留 x / y
- 原缺陷(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。
2026-09-11 21:48:43 +08:00
suzmii 006e9cc2f3 素材上传不再每次触发一次拒收提示:上传与清单配对读放进同一个函数
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
- 原缺陷:上传路径在上传**前**读 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。
2026-09-11 21:33:54 +08:00
suzmii 09ed7b099b 视图切换取消排队预取在生产路径上真正生效:视图标识改为必填
- 原缺陷:预览 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 见随后整轮。
2026-09-11 21:31:30 +08:00
suzmii 4175d82fa1 工具条「引用」不再渲染成「@ @引用」:@ 只留图标,文案去掉重复的 @
- 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)。
2026-09-11 21:25:14 +08:00
suzmii 5fff7a134c ④ 资源分类写入补一条 agent.db 审计记录
- 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 必须取第一次的落盘值)、四类被拒写入不产生任何审计、审计写失败时错误可见且不留假审计(同时确认分类本身已落盘、不回滚)
2026-09-11 21:18:55 +08:00
suzmii 4161888219 ③ 资源画布布局的读/写命令补项目权限门
- 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 未变);恢复默认策略后写入照旧成功
2026-09-11 21:18:41 +08:00
suzmii 6cc73b07ac ② GameCreationAppManifest 加 deny_unknown_fields:未知顶层字段失败关闭
- 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 也报错、两次失败后磁盘文件逐字节未变即未知字段没被抹掉)
2026-09-11 21:18:29 +08:00
suzmii 5eaf9faefd ① manifest 的 schemaVersion 补读/写失败关闭门
- 新增 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 条断言:当前版本必须被接受且全字段回读相等(正向断言,挡住「无条件拒绝」这种改法)、未知版本读失败且磁盘文件逐字节未变、未知版本写失败且不落盘
2026-09-11 21:18:15 +08:00
suzmii 15660a98b2 修 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
- 只做格式重排(应合成长行/应拆多行),无语义改动
- 命中 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 格式本地无守卫
2026-09-11 21:09:09 +08:00
suzmii 87968e3be3 布局落盘 / 读盘失败不再吞掉原因:脱敏后留一行日志
- 两处 .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 21:00:16 +08:00
suzmii 3a4f95d7ca 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
# Conflicts:
#	apps/ai-game-creator-shell/src/App.tsx
2026-09-11 20:56:14 +08:00
suzmii 2ea3bcbb07 驱逐改为可见性优先,条目上限 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
用户报「图片会自己消失然后重新加载」。根因是**淘汰完全按 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 子集的前后对照见随后的回报。)
2026-09-11 20:39:59 +08:00
suzmii 0ef1a4ec15 接线资源筛选浮层并把资源卡旁两个浮层改成互斥
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
- 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
2026-09-11 20:29:45 +08:00
suzmii cfd89ba570 ③ 驱逐用例抗并发负载:一次灌满替代 48 轮逐张等待
- 现象:驱逐用例逐张 waitFor 48 张(3 个读取槽 + 48 轮 act/轮询)在同树多个写者并发跑测试时,会把用例 5s 超时耗光(Test timed out in 5000ms),红的是超时不是断言;
- 改法:48 张一次性入队,只等"读取都已发出"(read 次数 ≥ 49),不再逐张断言 48 个卡的状态;
- 原因说明:驱逐本来就会连锁(目标卡被驱逐 → 补扫描重读 → 再挤掉一张未被观察的填充卡),逐张断言会在链条行进中读到 undefined,红的是时序不是契约;
- 核心断言一条不动:目标卡必须被重新请求(读次数 1 → 2)且最终回到 loaded;本用例超时放宽到 60s、内部 waitFor 20s;
- 验证:改动前后各连跑 6 轮 + 4 路并发同时跑两个用例文件,全绿。
2026-09-11 20:23:10 +08:00
suzmii a0d1fca30a 拒收提示不再被"进入项目"那一次清除吃掉:只在真的换项目时清
- 现象:打开项目这一提交里刚落下的拒收提示,会被同一次 [projectPath] 变化的清除 effect 清掉 —— 资产命令在项目刚打开后马上推送清单快照正是主场景,提示于是时有时无;
- 改法:新增 manifestMergeNoticeScopeRef 记录上一次观察到的项目路径,undefined → 路径(首次进入)时不清;只有真正的 A→B 换项目或关掉项目才清提示与在飞标记;
- 断言:新增"换项目必须清掉上一个项目的拒收提示"用例(确定性反向钉子,防止把清除整个删掉);
- 用例加固:拒收提示的三处断言改为每轮重新查询 DOM(阶段切换时 React 可能替换节点,抓着旧引用会在负载下瞬时红),异步恢复等待放宽到 4s;
- 说明:这次加固的起因是一次偶发红(expected undefined to be 'revision-conflict')。事后在改动前后各跑 18/28 轮未能复现"去掉护栏必红",所以此处不声称护栏就是那次偶发的成因;护栏本身修的是同一提交窗口被清除这一确定性缺陷,长时压测(4 路并发同时跑两个用例文件)全绿。
2026-09-11 20:22:52 +08:00
suzmii e765c9f8a6 资源卡工具条接通「生成动画」:只放行真能跑通的角色图派生
- 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 与泥点价档位,并断言参数入口不存在。
2026-09-11 20:08:22 +08:00
suzmii 5f03f050ad 修正筛选浮层 panelRef 的元素类型,补回漏跑的类型检查
- 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
2026-09-11 20:07:04 +08:00
suzmii 149c0b591a 冷启动首屏:热预取过可见性判据,相交卡按视口内 / 余量圈两档放行
- 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 回调改回单趟 → 只有两档那条红。
2026-09-11 19:58:09 +08:00
suzmii 1c43f2c2b2 新增资源筛选浮层组件与右下角锚点样式
- 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 后焦点回触发按钮、筛选生效判据
2026-09-11 19:55:22 +08:00
suzmii 70256c7585 新增资源画布筛选纯函数与三条变异验证用例
- 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
2026-09-11 19:50:19 +08:00
suzmii 1be5218aeb kind 别名表查表加 Object.hasOwn 守卫:原型键不再被误当成别名
- `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 字段构成与顺序不变
2026-09-11 19:44:04 +08:00
suzmii d4e7b8bbaf 队列满时被吞掉的按需请求留痕:点播放没反应不再是静默
- requestPreview 在队列达上限且没有可顶掉的可见性预取时,按需请求(detail/play)此前直接 return,用户在可见卡上点播放没有任何请求与痕迹;
- 新增 droppedRequestsRef:单调计数 + 最近一条明细(identity / reason / queueLength / at),并补一条 [preview-queue] console.warn;
- previewQueueSnapshot 暴露 droppedRequestCount 与 lastDroppedRequest,排障与用例可直接观测;
- 可见性预取撞上限属于设计内背压(下一轮兜底扫描会补),不计入丢弃口径;
- 队列上限、3 个并发槽、优先级顺序(play > detail > visible)全部不动;
- 新增用例:压满 96 条 detail 后 play 被丢弃必须留下标记,且 visible 背压不计数。
2026-09-11 19:35:25 +08:00
suzmii 56a8c2c32c 驱逐仍可见的卡之后补一次可见性复核:图片不再凭空消失
- publishPreview 记录本轮驱逐条数,驱逐发生后在 previewsRef 落盘之后补一次 sweepVisiblePreviews;
- 被驱逐的 identity 状态正好回到 undefined,而 sweepVisiblePreviews 只对 undefined 的卡重新入队,此前只差不这一次调用;
- 调用点必须在 previewsRef.current = next 之后,否则扫描看到被驱逐卡仍是 loaded 而不做任何事;
- 注释写明重入有界:重新入队走 publishPreview({status:'loading'}),不进 loaded/failed 驱逐分支,递归深度恒为 1;
- 只在真的发生驱逐时补扫,普通 loading/loaded 发布不增加扫描开销;
- 新增用例:灌满 48 张缓存把仍可见的目标卡挤出(LRU 不看可见性),断言该卡被重新请求读成 loaded。
2026-09-11 19:28:47 +08:00
suzmii f88e6aa5fe 清单快照被拒收不再静默:可见提示 + 强制重读磁盘真相
- 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 非法/三阶段文案可区分六条断言。
2026-09-11 19:23:25 +08:00
suzmii e15456ea14 读时丢弃的坐标不再静默:提示条带条数并在 DOM 上分开三类计数
- 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 → 提示条用例变红。
2026-09-11 19:18:12 +08:00
suzmii 18361f8585 读时归并不再静默:打开项目归并旧分区坐标后给一次性提示
- 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 → 上述读盘统计与提示条用例变红。
2026-09-11 19:14:44 +08:00
suzmii 27be57bf68 修正上一条提交里错位的别名表用例行
- 现象:提交 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)` 恢复成「表 + 单回调」形态
- 本提交只暂存这一个文件(不部分暂存),避开同一成因
2026-09-11 18:57:42 +08:00
suzmii f476eb89e5 同路径重登记:kind 变化时重派生 category,同 kind 时保留落盘分类
- `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 的派生行为,因此与上面的更新分支断言放在同一个提交
2026-09-11 18:48:03 +08:00
suzmii 3082c3e854 kind 别名表大小写不敏感并收口 font:堵住 8 条 UI 资产永远落「待归类」
- `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 个实参,写点换个新字面量就会红,防下次再漏
2026-09-11 18:46:58 +08:00
suzmii 576786324f 编辑标签面板回传落盘原值:堵住「只改标签」静默改分类
- 新增 `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,读显示口径才自愈」,把两个口径的差集钉死
2026-09-11 18:45:38 +08:00
suzmii 458a65cb5e 资源分类读显示口径下沉到 Rust:Agent 投影与 UI 同构
- 新增 `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` 时成立,旧名字会与新的有效分类语义矛盾
2026-09-11 18:43:39 +08:00
suzmii 2b44b62599 DirectProject 历史格式类失败给出专门提示并按不可重试处理
- 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
2026-09-11 18:36:19 +08:00
suzmii 5ac26b99c9 显式 Codex 返回不再往项目主对话投影旧格式行
- 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 保持为空
2026-09-11 18:36:16 +08:00
suzmii f0d5687ab7 共享的 project.jsonl 让两条对话链互读对方的行,混合历史不再双向失败关闭
- 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 把两种行都读出来)、纯旧格式文件的写入行为不变
2026-09-11 18:36:07 +08:00
suzmii bfb928295e DirectProject 读侧白名单兼容格式切换前的旧行,存量项目不再全部读不动
- 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 个用例变红;把白名单放宽成「任意行都接受」→失败关闭用例变红
2026-09-11 18:35:26 +08:00
kdletters fafe6b63cd 修复AGC模型请求与对话登录态自动续期 (#329)
Project CI / Repository checks (push) Successful in 2m40s
Project CI / Frontend tests (push) Successful in 3m23s
Project CI / Backend tests (push) Successful in 5m46s
Project CI / Native shell tests (push) Successful in 18m30s
## 变更说明

- AGC 客户端配套后端 API 在 401 时自动刷新登录态并重试一次,覆盖模型目录请求。
- DirectProject 对话鉴权失效时刷新平台会话、同步 Rust/Runner 凭据并重试同一 clientTurnId。
- 403 权限拒绝不触发续期;账号切换后不重发旧请求。
- 补充并发续期、失败保留原错误、权限边界和 Direct 对话重试测试。

## 验证

- npm run test -- apps/ai-game-creator-shell/tests/appSurface.test.ts apps/ai-game-creator-shell/tests/clientApi.test.ts apps/ai-game-creator-shell/tests/clientHttp.test.ts apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx
- npm run agc:typecheck
- npm run check:encoding
- git diff --check

Reviewed-on: #329
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-09-11 18:09:48 +08:00
suzmii f5b381dbd8 视图切换时只取消上一视图排队中的可见性预取
用户离开某个视图后,继续为它排队读图没有任何收益,而这些排队的预取会**排在新视图按需请求的前面**(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` 干净。
2026-09-11 17:55:22 +08:00
suzmii 8edee9eef4 预览队列改为同级裁决:当前预取作用域优先,但不越过 play
背景:全局 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 全量与本条无关的并发改动混跑,故此处只报本文件的定向结果;全量对照见后续提交的回报。)
2026-09-11 17:44:21 +08:00
suzmii a4bec3d018 记下「不再提醒」偏好保持单键、不按入口分域的理由
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
- 该键关掉的只有聊天输入区的「发送前提醒」;资源侧生成素材与快速编辑没有可关的提醒,润色是显式按钮动作
- 分域只在「每个入口都有可关的提醒」时才有意义,现在分域等于给不存在的提醒提前留位
- 只补注释,不改行为与断言
2026-09-11 17:06:35 +08:00