Merge remote-tracking branch 'gitea/master' into HEAD

This commit is contained in:
xiaohan.yuan committed 2026-10-21 12:50:30 +08:00
commit 9f1e583e23
17 files changed
+688 -37

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
# B01 交付 02:F01 与模式、确认按钮修复
日期:2026-10-21(Asia/Shanghai)。开发端接续验收退回与补充反馈,状态:**源码已实现、离线自测通过,真实 Windows 复测待验**。不关闭整个 B01。
- 基线:`ddd677bae3c43a5514eb40f7a1d9749fdf389115`。
- **受测代码 SHA:`1ed71b63b5af9a98b942ff47c66df5799d4d2c8a`**。
- 需求:[验收 01](B01-验收-01.md) 的 F01;[补充反馈 01](B01-补充反馈-01.md) 的 FB01/A06、FB02/A05;沿用 [B01 需求](B01-需求.md) 的 AC1.3、AC3.1~3.3。
- 范围:YC-001 的 F01 修复,以及 YC-003 的 A05/A06 部分交付。A01/A04 已有验收证据不推倒重做;YC-002、YC-004、A07/A08 未在本轮实施。**没有恢复用户撤回的骨架补丁。**
## 1. 实现
### F01:原生 part 稳定身份
`completed-fold.ts` 统一生成展示身份,识别原生 `plan.id`、`task.id`、`artifact.id`,以及 tool、reasoning、image 的稳定 ID;React key 与 disclosure scope 共用同一身份。类型与 ID/位置域分开,消息级 store 不共享。
真实 `applyAssistantEvent → MessageBubble` 回归覆盖原验收的两个 text 被 done 校正合并、原生 plan/task、插入重排、相反展开选择、外层重复开关、同 ID 跨消息隔离。无 ID 旧历史保留位置 fallback;**不承诺没有稳定 ID 时任意重排仍可识别同一项**。未改 reducer 或历史源对象。
### FB02/A05:ModeChip
恢复透明默认背景、pill 圆角、次级文字;模式图标与 X 共用同一 16px grid 槽,hover/focus-within 原位替换,无右上角额外 X。保留 32px 高、13px/500/20px 文字、8px padding、原生退出按钮、craft 隐藏和禁用语义。继续调用原宿主 clear 回调,不重写草稿或权限偏好。
图标仍为本地几何,源码保留待同版本 REA SVG/像素验收的标注。
### FB01/A06:TaskPopconfirm
改用自带 CSS 的 `ui/Button`,明确 primary/secondary/danger,避免依赖 portal 外旧 `.wb-button` 类作用域。恢复 66px 最小宽度、19px 水平 padding、16px 浮层圆角、600 标题,保留 240px 右侧锚定、箭头、焦点、点击吞掉、忙态重复提交锁及失败重试。
按已确认要求保留原版归档说明,移除附加兼容解释。**YCode 当前仍没有“系统设置-数据管理”归档入口,实际查看/恢复仍在侧栏“已归档”;本轮仅恢复指定文案,不声称已实现该设置页。**
## 2. 验证结果
证据:[离线日志](evidence/B01-02/verification.txt)、[浏览器环境失败记录](evidence/B01-02/browser-environment.txt)。Linux x64、Node 24.19.0、npm 11.9.0、jsdom 30.1.3;复用已安装依赖,未改 package/lock。
| 检查 | 结果与边界 |
| --- | --- |
| `npm run build` | 退出 0,含两个 tsconfig 类型检查与 main/preload/renderer 构建;中间发现的 TS narrowing 错误已修正后重跑 |
| `YCODE_TEST_DOM_MODULE=<jsdom模块路径> npm run test:alignment` | 退出 0;包含原生身份新回归、ModeChip 37 项、TaskPopconfirm 34 项(含运行中停止并删除、busy、失败重试)、原有问答/授权 DOM |
| `test:conversation`、`test:workbuddy-import`、`test:artifacts` | 均退出 0 |
| F01 基线负对照 | 以基线两个 CompletedFold 模块替换后准确复现 false→true,预期退出 1;新产品路径通过 |
| `test:fold-summary` | 本 Linux LF 检出退出 0;不等于 Windows CRLF 问题已修复,本轮未改摘要/manifest/行尾策略 |
| `check:tokens` | 退出 1,基线与最终均为相同 6 项既存失败,无新增失败、未刷新 baseline |
| 两个新 UI fixture 的 `--build-only` | 退出 0,仅证明夹具打包成功,不是计算样式/截图通过 |
| 独立复审 | 重跑原生身份、37 项模式、34 项确认 DOM;未发现生产代码阻塞性回归;补齐 fixture CSS、主题同步与安全默认参数 |
本轮无模型请求、无真实 SDK/CLI 执行、无原版访问。当前环境 Chromium 在页面加载前因 socket 权限失败,未取得新截图和真实计算样式;没有绕过限制。实际 Electron 全应用、Windows、窄窗、像素与辅助技术验收仍待执行,不能从 jsdom/构建结果推导通过。
## 3. 验收复跑
按协作流程在固定代码 SHA 的独立检出安装构建。完整应用测试必须使用新临时 `YCODE_USER_DATA_DIR`,不能启动默认正式数据目录。不要复用开发机 node_modules 或旧 out。
```powershell
npm ci --legacy-peer-deps
npm run build
npm install --prefix .tmp/b01-dom --ignore-scripts --no-audit --no-fund jsdom@30.1.3
$env:YCODE_TEST_DOM_MODULE = Join-Path $PWD '.tmp/b01-dom/node_modules/jsdom'
npm run test:alignment
npm run test:conversation
npm run test:workbuddy-import
npm run test:artifacts
node scripts/test-mode-chip-native.mjs
node scripts/verify-task-menu.cjs
```
- `test-mode-chip-native.mjs` 自建隔离 Electron 组件窗口/userData,覆盖浅深、hover/focus、原生 Tab/Enter/Space、禁用、窄窗,输出截图与 report。不是完整宿主草稿/焦点验收;不访问原版或模型。
- `verify-task-menu.cjs` 保留真实隔离 main/preload/IPC/本地落库测试,新增 portal 两主题计算样式;通过 `ycode:theme` storage 通路同步应用主题,不仅改根节点。必须在可用 Electron 环境执行后再判断通过。
- `node scripts/test-task-popconfirm-browser.mjs --build-only` 生成独立可移植 HTML;去掉该参数并设置 `CHROMIUM_PATH` 到本机 Chromium/Chrome 可执行文件可运行真实计算样式断言。浅深、主次危险、busy/error/retry 有断言;这个组件 fixture 不代替真实 IPC/落库。
- 两个新浏览器脚本默认保留 sandbox;无浏览器时不把 build-only 当作验收完成。
重点复测 F01 原验收事件序列,A05 两模式默认/悬停/焦点/退出、权限及草稿保留,A06 取消/确认、运行中停止并删除、两主题样式、焦点和失败重试。保留旧失败记录,另写新验收记录。
交付记录可位于受测代码后的文档提交;只有远端推送与 SHA 核对成功后才算 Git 交接完成。本轮交付完成后推送窗口交回验收端。
@@ -0,0 +1,8 @@
Observed during environment capability check, 2026-10-21 UTC; copied from tool output (not a successful fixture run).
Target: local Chromium headless with explicit repository .tmp user-data-dir.
No page loaded and no computed-style result was obtained.
[6:6:1021/133654.542575:ERROR:third_party/crashpad/crashpad/util/file/filesystem_posix.cc:63] mkdir : No such file or directory (2)
chrome_crashpad_handler: --database is required
Try 'chrome_crashpad_handler --help' for more information.
[6:6:1021/133654.579509:FATAL:chrome/browser/process_singleton_posix.cc:297] Check failed: . socket() failed: Operation not permitted (1)
Stopped after this error. Checked-in browser runner retains the Chromium sandbox; only --build-only completed in this environment.
@@ -0,0 +1,202 @@
B01 delivery 02 offline evidence
Code SHA: 1ed71b63b5af9a98b942ff47c66df5799d4d2c8a
Base: ddd677bae3c43a5514eb40f7a1d9749fdf389115
Linux x64; Node 24.19.0; npm 11.9.0; jsdom 30.1.3
No model/SDK process or original app was started. Native style/layout acceptance is not claimed.
Token failure sets before and after are equal (6 pre-existing failures); baseline unchanged.
First intermediate build found a TS narrowing error; fixed before successful final build.
Independent review repeated DOM tests and found no blocking production regression.
===== alignment.txt: exit 0 =====
npm warn Unknown env config "http-proxy". This will stop working in the next major version of npm.
> ycode@0.0.7 test:alignment
> node scripts/run-alignment-regression.mjs
PASS model alignment: fresh/legacy migration, new/edit/default/delete/reload, valid selection (offline host fixtures)
PASS model settings: deferred owner switch, rename mapping, addition selection, empty deletion, immutable history
PASS session factory: first-send preference, mode preservation, invalid permission rejection (isolated ports)
{"historyMessages":10000,"bytes":747781,"parseMs":3.751840999999999,"storeHydrationMs":0.13198400000000277,"note":"Node parsing/store benchmark only, not browser rendering or Windows IPC performance"}
session-composer-alignment: loading/cache/retry/races/model/permissions/modes/SSR passed
alignment-conversation: local groups, original expanded order, question projection and truthful status passed
A01 native MessageBubble identity aliases, immutable inputs and ordinary raw output passed
A01 imported native card, linked dedup and unknown-position compatibility passed
question-answer-shell: structural and recovered CSS contracts passed
question-answer-shell: DOM hierarchy, controls linkage, click, Enter, Space and inert contracts passed
Runtime progress, session-grant scope and approval lifecycle regressions passed
Permission intercept static rendering passed; interactive keyboard/focus remains UI acceptance
Claude host approval policy unit checks passed (no SDK process/application/matching verification)
Mock SDK replay lifecycle passed: fail closed, retry, session isolation, independent mode guard
task popconfirm DOM regression: 34 checks passed (jsdom, not pixel validation)
question-interaction: 24 DOM behavioral checks passed
question-identity: 30 A01 DOM checks passed (not Electron/pixel/SDK acceptance)
completed-fold-nested: real question/tool/thinking/local-summary remount, event, keyboard and identity contracts passed
completed-fold-native-identity: real reducer done correction, native IDs, insertion/reorder, repeated outer toggles, message isolation and legacy fallback passed
Permission intercept DOM: 14 checks passed (not pixel/SDK verification)
{"checks":37,"passed":true,"scope":"Isolated React DOM and CSS contracts; no native keyboard/layout/pixel acceptance"}
npm notice
npm notice New major version of npm available! 11.9.0 -> 12.2.0
npm notice Changelog: https://github.com/npm/cli/releases/tag/v12.2.0
npm notice To update run: npm install -g npm@12.2.0
npm notice
===== conversation.txt: exit 0 =====
npm warn Unknown env config "http-proxy". This will stop working in the next major version of npm.
> ycode@0.0.7 test:conversation
> esbuild scripts/conversation-renderer-regression.tsx scripts/composer-regression.tsx scripts/completed-fold-regression.tsx scripts/streaming-footer-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=text --out-extension:.js=.cjs --outdir=.tmp/conversation-tests --log-level=error && node .tmp/conversation-tests/conversation-renderer-regression.cjs && node .tmp/conversation-tests/composer-regression.cjs && node .tmp/conversation-tests/streaming-footer-regression.cjs && node .tmp/conversation-tests/completed-fold-regression.cjs
Conversation specialized renderer SSR regression checks passed
Composer/message action regression cases passed
streaming-footer-regression: persistent stream status, delayed/paused rotation, brand/locale mapping, source CSS, and completed model footer passed
Completed-fold model and SSR regression checks passed
===== import.txt: exit 0 =====
npm warn Unknown env config "http-proxy". This will stop working in the next major version of npm.
> ycode@0.0.7 test:workbuddy-import
> esbuild scripts/workbuddy-import-regression.ts --bundle --platform=node --format=esm --outfile=.tmp/workbuddy-import-regression.mjs --log-level=warning && node .tmp/workbuddy-import-regression.mjs
WorkBuddy import regression passed
===== artifacts.txt: exit 0 =====
npm warn Unknown env config "http-proxy". This will stop working in the next major version of npm.
> ycode@0.0.7 test:artifacts
> esbuild scripts/artifact-diff-regression.ts scripts/artifact-renderer-regression.tsx scripts/specialized-block-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=empty --out-extension:.js=.cjs --outdir=.tmp/artifact-tests --log-level=warning && node .tmp/artifact-tests/artifact-diff-regression.cjs && node .tmp/artifact-tests/artifact-renderer-regression.cjs && node .tmp/artifact-tests/specialized-block-regression.cjs
Artifact diff recovery regression checks passed
Artifact routing, content loading, full write projection and renderer regressions passed
Specialized block icon and label SSR regression checks passed
===== build.txt: exit 0 =====
[Tail of successful full build; includes prior typecheck in command]
out/renderer/assets/vue-vine-DXhYlaTf-DTauGW8s.js 201.27 kB
out/renderer/assets/SourceSelect-DrNU8YnZ.js 203.20 kB
out/renderer/assets/chunk-4HAMMTFA-BEFC_AJa.js 210.32 kB
out/renderer/assets/swimlanes-42K2YHIH-Bp72q37Z.js 265.51 kB
out/renderer/assets/wolfram-BVoTES0L-6ikJLWGF.js 267.14 kB
out/renderer/assets/chunk-DU6HZSFF-DPu5LxXf.js 271.07 kB
out/renderer/assets/architectureDiagram-5GKGNRK7-DAeFXfK3.js 318.23 kB
out/renderer/assets/katex-Y9ra-ykP.js 497.35 kB
out/renderer/assets/texmath-Bkeg3ZLn.js 594.25 kB
out/renderer/assets/wasm-CVjZL0jK-2Bi6p3c3.js 622.62 kB
out/renderer/assets/cpp-9jHBA231-CtHklR8a.js 626.73 kB
out/renderer/assets/app-BYh1ERuc.js 675.57 kB
out/renderer/assets/emacs-lisp-DjtOxg4j-CUUa7aOE.js 780.06 kB
out/renderer/assets/cytoscape.esm-bVGYt-iW.js 869.69 kB
out/renderer/assets/ArtifactPierreDiff-D5xhRc5y.js 1,119.58 kB
out/renderer/assets/chunk-FOHPRMQF-BCllzKJD.js 1,154.18 kB
out/renderer/assets/index-lmA4yj_k.js 3,760.12 kB
out/renderer/assets/editor.api2-dPwET-7w-BYIQ9bT3.js 4,889.23 kB
✓ built in 2.08s
npm notice
npm notice New major version of npm available! 11.9.0 -> 12.2.0
npm notice Changelog: https://github.com/npm/cli/releases/tag/v12.2.0
npm notice To update run: npm install -g npm@12.2.0
npm notice
===== fold-before.txt: exit 0 =====
npm warn Unknown env config "http-proxy". This will stop working in the next major version of npm.
> ycode@0.0.7 test:fold-summary
> node scripts/rea/recovered-fold-summary-regression.mjs
Recovered fold-summary closure regression passed (descriptor aliases, object selection, states, topics and labels).
===== tokens-before.txt: exit 1 =====
npm warn Unknown env config "http-proxy". This will stop working in the next major version of npm.
> ycode@0.0.7 check:tokens
> node scripts/check-design-tokens.mjs
设计 token 漂移检查(静态)
扫描:src/renderer/src 共 367 个文件(用时 49 ms)
baseline:scripts/design-token-baseline.json(334 个文件)
门禁分类:adapter 8 | core 6 | local 279(合计 293 个文件)
合计:裸 hex=611 裸 px=2682 !important=6 --wb-* 定义=525 var(--yc-*) 使用=1642 var(--wb-*) 使用=1203
非门禁分类:vendor 2 | recovered 22 | source 47 | tokenSource 3(合计 74 个文件,仅记录不阻塞)
合计:裸 hex=10642 裸 px=13642 !important=920 --wb-* 定义=17966 var(--yc-*) 使用=77 var(--wb-*) 使用=19638
非门禁相对 baseline:文件 +0;裸 hex +78 裸 px +64 !important +0 --wb-* 定义 +0 var(--yc-*) 使用 +22 var(--wb-*) 使用 -120
结果:失败,共 6 项:
[新增门禁文件] src/renderer/src/capabilities/recommended-flow.css(local):裸 px=22
[新增门禁文件] src/renderer/src/capabilities/skill-detail-shell.css(local):裸 px=21
[回归] src/renderer/src/capabilities/skills-host.css(local):裸 px 25 → 46(+21)
[新增门禁文件] src/renderer/src/workbuddy/composer/capability-picker.css(local):裸 hex=26,裸 px=48
[回归] src/renderer/src/workbuddy/composer/composer.css(local):裸 hex 23 → 38(+15);裸 px 83 → 139(+56)
[新增门禁文件] src/renderer/src/workbuddy/composer/prompt-queue.css(local):裸 hex=8,裸 px=35
提示:确认变更符合预期后运行 `node scripts/check-design-tokens.mjs --update-baseline` 刷新 baseline;
否则请改用 --yc-* token、复用现有值,或去掉重复的 --wb-* 定义。
提示(不失败,仅记录):3 个门禁文件 token 使用数上升:
src/renderer/src/capabilities/skills-host.css(local):var(--wb-*) 使用 12 → 19(+7)
src/renderer/src/workbuddy/composer/composer.css(local):var(--yc-*) 使用 35 → 47(+12);var(--wb-*) 使用 0 → 1(+1)
src/renderer/src/workbuddy/conversation/conversation.css(local):var(--yc-*) 使用 0 → 4(+4)
提示(不失败):baseline 中 8 个文件已不存在:src/renderer/src/components/ReportedRunUsage.tsx;src/renderer/src/workbuddy/subagents/SourceMemberIcon.tsx;src/renderer/src/workbuddy/subagents/SubagentCollaboration.tsx;src/renderer/src/workbuddy/subagents/SubagentDetailPanel.tsx;src/renderer/src/workbuddy/subagents/adapters.css;src/renderer/src/workbuddy/subagents/member-view.ts;src/renderer/src/workbuddy/subagents/source.css;src/renderer/src/workbuddy/subagents/theme.css
提示:新增门禁文件 36 个且无受控计数(允许):src/renderer/src/capabilities/CapabilityMaintenance.tsx(local);src/renderer/src/capabilities/RecommendedSkills.tsx(local);src/renderer/src/capabilities/SkillAvatar.tsx(local);src/renderer/src/capabilities/SkillDetailShell.tsx(local);src/renderer/src/capabilities/SkillModalPortal.tsx(local);src/renderer/src/capabilities/skill-modal-focus.ts(local);src/renderer/src/components/SessionLoading.tsx(local);src/renderer/src/components/TaskPopconfirm.tsx(local);src/renderer/src/components/session-loading.css(local);src/renderer/src/components/task-popconfirm.css(local);src/renderer/src/components/updates/UpdateSidebarCard.tsx(local);src/renderer/src/components/updates/update-ui-model.ts(local);src/renderer/src/components/updates/update-ui-store.ts(local);src/renderer/src/components/updates/updates-ui.css(local);src/renderer/src/ui/Button.tsx(local);src/renderer/src/ui/IconButton.tsx(local);src/renderer/src/ui/Input.tsx(local);src/renderer/src/ui/Popover.tsx(local);src/renderer/src/ui/Select.tsx(local);src/renderer/src/ui/Switch.tsx(local);…(还有 16 项,详见 --json)
===== tokens-after.txt: exit 1 =====
npm warn Unknown env config "http-proxy". This will stop working in the next major version of npm.
> ycode@0.0.7 check:tokens
> node scripts/check-design-tokens.mjs
设计 token 漂移检查(静态)
扫描:src/renderer/src 共 367 个文件(用时 48 ms)
baseline:scripts/design-token-baseline.json(334 个文件)
门禁分类:adapter 8 | core 6 | local 279(合计 293 个文件)
合计:裸 hex=611 裸 px=2682 !important=6 --wb-* 定义=525 var(--yc-*) 使用=1642 var(--wb-*) 使用=1201
非门禁分类:vendor 2 | recovered 22 | source 47 | tokenSource 3(合计 74 个文件,仅记录不阻塞)
合计:裸 hex=10642 裸 px=13642 !important=920 --wb-* 定义=17966 var(--yc-*) 使用=77 var(--wb-*) 使用=19638
非门禁相对 baseline:文件 +0;裸 hex +78 裸 px +64 !important +0 --wb-* 定义 +0 var(--yc-*) 使用 +22 var(--wb-*) 使用 -120
结果:失败,共 6 项:
[新增门禁文件] src/renderer/src/capabilities/recommended-flow.css(local):裸 px=22
[新增门禁文件] src/renderer/src/capabilities/skill-detail-shell.css(local):裸 px=21
[回归] src/renderer/src/capabilities/skills-host.css(local):裸 px 25 → 46(+21)
[新增门禁文件] src/renderer/src/workbuddy/composer/capability-picker.css(local):裸 hex=26,裸 px=48
[回归] src/renderer/src/workbuddy/composer/composer.css(local):裸 hex 23 → 38(+15);裸 px 83 → 139(+56)
[新增门禁文件] src/renderer/src/workbuddy/composer/prompt-queue.css(local):裸 hex=8,裸 px=35
提示:确认变更符合预期后运行 `node scripts/check-design-tokens.mjs --update-baseline` 刷新 baseline;
否则请改用 --yc-* token、复用现有值,或去掉重复的 --wb-* 定义。
提示(不失败,仅记录):3 个门禁文件 token 使用数上升:
src/renderer/src/capabilities/skills-host.css(local):var(--wb-*) 使用 12 → 19(+7)
src/renderer/src/workbuddy/composer/composer.css(local):var(--yc-*) 使用 35 → 47(+12);var(--wb-*) 使用 0 → 1(+1)
src/renderer/src/workbuddy/conversation/conversation.css(local):var(--yc-*) 使用 0 → 4(+4)
提示(不失败):baseline 中 8 个文件已不存在:src/renderer/src/components/ReportedRunUsage.tsx;src/renderer/src/workbuddy/subagents/SourceMemberIcon.tsx;src/renderer/src/workbuddy/subagents/SubagentCollaboration.tsx;src/renderer/src/workbuddy/subagents/SubagentDetailPanel.tsx;src/renderer/src/workbuddy/subagents/adapters.css;src/renderer/src/workbuddy/subagents/member-view.ts;src/renderer/src/workbuddy/subagents/source.css;src/renderer/src/workbuddy/subagents/theme.css
提示:新增门禁文件 36 个且无受控计数(允许):src/renderer/src/capabilities/CapabilityMaintenance.tsx(local);src/renderer/src/capabilities/RecommendedSkills.tsx(local);src/renderer/src/capabilities/SkillAvatar.tsx(local);src/renderer/src/capabilities/SkillDetailShell.tsx(local);src/renderer/src/capabilities/SkillModalPortal.tsx(local);src/renderer/src/capabilities/skill-modal-focus.ts(local);src/renderer/src/components/SessionLoading.tsx(local);src/renderer/src/components/TaskPopconfirm.tsx(local);src/renderer/src/components/session-loading.css(local);src/renderer/src/components/task-popconfirm.css(local);src/renderer/src/components/updates/UpdateSidebarCard.tsx(local);src/renderer/src/components/updates/update-ui-model.ts(local);src/renderer/src/components/updates/update-ui-store.ts(local);src/renderer/src/components/updates/updates-ui.css(local);src/renderer/src/ui/Button.tsx(local);src/renderer/src/ui/IconButton.tsx(local);src/renderer/src/ui/Input.tsx(local);src/renderer/src/ui/Popover.tsx(local);src/renderer/src/ui/Select.tsx(local);src/renderer/src/ui/Switch.tsx(local);…(还有 16 项,详见 --json)
===== mode-fixture-build.txt: exit 0 =====
{"output":"/workspace/scratch/0aaa9774aecc/single-package-staging/ycode/.tmp/mode-chip-native-ZJMFra","fixtureBuilt":true,"nativeExecuted":false}
===== popconfirm-fixture-build.txt: exit 0 =====
Browser fixture: /workspace/scratch/0aaa9774aecc/single-package-staging/ycode/.tmp/task-popconfirm-browser-RoVW4H/index.html
===== identity-baseline-negative-control.txt: exit 1 =====
AssertionError [ERR_ASSERTION]: terminal correction preserves choices
+ actual - expected
{
plan: [
+ 'true'
- 'false'
],
task: [
+ 'true'
- 'false'
]
}
at main2 (/workspace/scratch/0aaa9774aecc/single-package-staging/ycode/.tmp/f01-baseline-check.cjs:293667:25) {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: [Object],
expected: [Object],
operator: 'deepStrictEqual',
diff: 'simple'
}
Baseline negative control reproduced exact terminal correction failure (expected exit 1).
@@ -0,0 +1,99 @@
/** F01 acceptance reproduction extended through the real reducer and MessageBubble.
* No Electron, model, SDK, or external service; DOM behavior, not visual parity.
*/
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import type { ChatMessage, ChatEvent, MessagePart } from '../src/shared/types'
const { JSDOM } = createRequire(process.cwd() + '/package.json')(process.env.YCODE_TEST_DOM_MODULE || 'jsdom')
const dom = new JSDOM('<!doctype html><html><body><main id="root"></main></body></html>', { pretendToBeVisual: true, url: 'http://localhost/' })
for (const key of ['window', 'document', 'Node', 'Element', 'HTMLElement', 'MouseEvent', 'KeyboardEvent', 'Event']) Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true })
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true })
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
const { act } = require('react') as typeof import('react')
const { createRoot } = require('react-dom/client') as typeof import('react-dom/client')
const MessageBubble = require('../src/renderer/src/components/MessageBubble').default as typeof import('../src/renderer/src/components/MessageBubble').default
const { applyAssistantEvent } = require('../src/shared/message-parts') as typeof import('../src/shared/message-parts')
const { completedFoldPartIdentity } = require('../src/renderer/src/workbuddy/conversation/completed-fold') as typeof import('../src/renderer/src/workbuddy/conversation/completed-fold')
const root = createRoot(document.getElementById('root')!)
const plan = '.cb-plan-header-left'
const task = '.cb-task-result-item-title'
const outer = '.cr-collapse--completed > .cr-collapse__header'
const nodes = (selector: string) => [...document.querySelectorAll(selector)]
const get = (selector: string, index = 0) => { const node = nodes(selector)[index]; assert.ok(node, `${selector}[${index}]`); return node }
const states = () => ({ plan: nodes(plan).map(node => node.getAttribute('aria-expanded')), task: nodes(task).map(node => node.getAttribute('aria-expanded')) })
async function render(message: ChatMessage) { await act(async () => root.render(<MessageBubble message={message} />)) }
async function click(selector: string, index = 0) { await act(async () => get(selector, index).dispatchEvent(new MouseEvent('click', { bubbles: true }))) }
type EventInput = ChatEvent extends infer E ? E extends ChatEvent ? Omit<E, 'runId'> : never : never
const event = (value: EventInput) => ({ runId: 'native-identity', ...value }) as ChatEvent
const planPart = (id: string): Extract<MessagePart, { type: 'plan' }> => ({ type: 'plan', plan: { id, title: `Plan ${id}`, steps: [] } })
const taskPart = (id: string): Extract<MessagePart, { type: 'task' }> => ({ type: 'task', task: { id, title: `Task ${id}`, status: 'success', summary: `Result ${id}` } })
async function main() {
// All native business IDs, including non-disclosure image/artifact parts, are stable.
const stableParts: MessagePart[] = [planPart('same'), taskPart('same'), { type: 'artifact', artifact: { id: 'same', name: 'File', path: '/file' } }, { type: 'image', id: 'same', url: '/image' }, { type: 'reasoning', id: 'same', text: 'Thought' }, { type: 'tool', toolId: 'same' }]
const identities = stableParts.map(part => completedFoldPartIdentity(part, 1))
assert.equal(new Set(identities).size, stableParts.length, 'part types cannot share identity')
stableParts.forEach((part, i) => assert.equal(completedFoldPartIdentity(part, 99), identities[i], `${part.type} ignores position`))
assert.notEqual(completedFoldPartIdentity(planPart('1'), 1), completedFoldPartIdentity({ type: 'plan', plan: {} }, 1), 'business IDs cannot collide with fallback indices')
assert.equal(completedFoldPartIdentity({ type: 'future', id: 'native' }, 1), completedFoldPartIdentity({ type: 'future', id: 'native' }, 2), 'unknown top-level IDs remain supported')
let message: ChatMessage = { id: 'native-one', role: 'assistant', createdAt: 1, content: '', status: 'streaming', parts: [] }
// Exact acceptance event sequence: two text chunks separated by reasoning.
const streamingEvents: EventInput[] = [
{ type: 'text', delta: 'Draft one' },
{ type: 'part', part: { type: 'reasoning', id: 'r', text: 'Thought', finished: true } },
{ type: 'text', delta: 'Draft two' },
{ type: 'part', part: planPart('p') },
{ type: 'part', part: taskPart('x') }
]
for (const value of streamingEvents) message = applyAssistantEvent(message, event(value))
assert.deepEqual(message.parts?.map(part => part.type), ['text', 'reasoning', 'text', 'plan', 'task'])
await render(message)
assert.deepEqual(states(), { plan: ['true'], task: ['true'] })
await click(plan); await click(task)
const collapsed = { plan: ['false'], task: ['false'] }
assert.deepEqual(states(), collapsed)
message = applyAssistantEvent(message, event({ type: 'done', content: 'Corrected final prose', tools: [], turnDurationMs: 1000 }))
assert.deepEqual(message.parts?.map(part => part.type), ['text', 'reasoning', 'plan', 'task'], 'real done reducer coalesces text')
await render(message)
assert.deepEqual(states(), collapsed, 'terminal correction preserves choices')
for (let repeat = 0; repeat < 2; repeat++) {
await click(outer); assert.deepEqual(states(), collapsed, 'outer expansion preserves choices')
await click(outer); assert.deepEqual(states(), collapsed, 'outer collapse preserves choices')
}
// Insert new same-kind cards before originals, then reorder. New cards must not inherit state.
const prefix = message.parts!.filter(part => part.type !== 'plan' && part.type !== 'task')
message = { ...message, parts: [...prefix, planPart('p2'), taskPart('x2'), planPart('p'), taskPart('x')] }
await render(message)
assert.deepEqual(states(), { plan: ['true', 'false'], task: ['true', 'false'] }, 'insertions retain original identity only')
await click(plan, 1) // explicitly open original; keep task closed
await click(plan, 0) // close new plan; leave new task open
message = { ...message, parts: [taskPart('x'), planPart('p'), ...prefix, taskPart('x2'), planPart('p2')] }
await render(message)
assert.deepEqual(states(), { plan: ['true', 'false'], task: ['false', 'true'] }, 'reorder retains independent choices')
await click(task, 1)
await click(outer); await click(outer)
assert.deepEqual(states(), { plan: ['true', 'false'], task: ['false', 'false'] }, 'reordered choices survive outer reparenting')
await render({ ...message, id: 'native-two' })
assert.deepEqual(states(), { plan: ['true', 'true'], task: ['true', 'true'] }, 'same part IDs in another message are isolated')
assert.equal(get(outer).getAttribute('aria-expanded'), 'false')
// Legacy persisted payloads may predate required ID fields. Keep their positional fallback,
// without promising identity across arbitrary insertions when no business identity exists.
const legacyParts = [planPart(''), taskPart('')].map(part => {
const copy = structuredClone(part) as any
delete copy[copy.type].id
return copy
}) as MessagePart[]
const legacy = { ...message, id: 'legacy', parts: [...prefix, ...legacyParts] }
await render(legacy)
await click(plan); await click(task)
await render(structuredClone(legacy))
await click(outer); await click(outer)
assert.deepEqual(states(), collapsed, 'no-ID old history retains choice at the same position')
assert.notEqual(completedFoldPartIdentity(legacyParts[0], 1), completedFoldPartIdentity(legacyParts[0], 2), 'no-ID fallback remains positional')
await render({ ...legacy, id: 'legacy-other' })
assert.deepEqual(states(), { plan: ['true'], task: ['true'] }, 'legacy fallback remains message-isolated')
await act(async () => root.unmount()); dom.window.close()
console.log('completed-fold-native-identity: real reducer done correction, native IDs, insertion/reorder, repeated outer toggles, message isolation and legacy fallback passed')
}
main().then(() => process.exit(0)).catch(error => { console.error(error); dom.window.close(); process.exit(1) })
+4 -1
View File
@@ -24,11 +24,14 @@ for (const file of ['scripts/build-runtime-regressions.mjs', '.tmp/claude-approv
// Optional DOM tests use separately installed jsdom, not Electron/CLI or an upstream model.
if (process.env.YCODE_TEST_DOM_MODULE) {
for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'completed-fold-nested-regression', 'permission-intercept-dom-regression']) {
for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'completed-fold-nested-regression', 'completed-fold-native-identity-regression', 'permission-intercept-dom-regression']) {
const outfile = `.tmp/alignment-tests/${file}.cjs`
await build({ entryPoints: [`scripts/${file}.tsx`], outfile, bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', loader: { '.css': 'empty' }, logLevel: 'warning' })
const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 })
if (result.error) throw result.error
if (result.status !== 0) process.exit(result.status ?? 1)
}
const modeChip = spawnSync(process.execPath, ['scripts/test-mode-chip.mjs'], { stdio: 'inherit', timeout: 120000 })
if (modeChip.error) throw modeChip.error
if (modeChip.status !== 0) process.exit(modeChip.status ?? 1)
}
@@ -0,0 +1,62 @@
/** Real Chromium fixture, isolated callbacks only: no IPC, sessions, models, or original app. */
import React from 'react'
import { createRoot } from 'react-dom/client'
import { flushSync } from 'react-dom'
import '../src/renderer/src/workbuddy/typography/tokens.css'
import '../src/renderer/src/styles/tokens.css'
import '../src/renderer/src/ui/tokens.css'
import '../src/renderer/src/styles/app.css'
import '../src/renderer/src/styles/sidebar.css'
import '../src/renderer/src/workbuddy/conversation/source-styles.css'
import '../src/renderer/src/workbuddy/conversation/source-adapters.css'
import '../src/renderer/src/components/workspace-source.css'
import '../src/renderer/src/components/source-select/source-select-recovered.css'
import '../src/renderer/src/components/source-select/source-select.css'
import { TaskPopconfirm } from '../src/renderer/src/components/TaskPopconfirm'
const root = createRoot(document.getElementById('root')!)
const anchor = document.getElementById('anchor')!
const results: unknown[] = []
const pause = () => new Promise(resolve => setTimeout(resolve, 200))
function check(actual: unknown, expected: unknown, label: string) {
results.push({label, actual, expected})
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw Error(label + ': ' + JSON.stringify(actual))
}
const button = (name: string) => document.querySelector<HTMLButtonElement>('.task-popconfirm__' + name)!
async function run() {
for (const theme of ['light', 'dark']) for (const kind of ['archive', 'delete'] as const) for (const running of (kind === 'delete' ? [false, true] : [false])) {
document.documentElement.dataset.theme = theme
let calls = 0, closes = 0, resolve!: () => void, reject!: (cause: Error) => void
const close = () => { closes++; flushSync(() => root.render(null)) }
flushSync(() => root.render(<TaskPopconfirm kind={kind} running={running} anchor={anchor} onClose={close} onConfirm={() => { calls++; return new Promise<void>((yes, no) => { resolve = yes; reject = no }) }} />))
await pause()
const label = `${theme}/${kind}/${running}`
const dark = theme === 'dark'
const confirm = button('confirm'), cancel = button('cancel')
for (const node of [confirm, cancel]) {
const s = getComputedStyle(node)
check([s.minWidth, s.paddingLeft, s.paddingRight, s.height, s.borderRadius, s.fontSize], ['66px', '19px', '19px', '28px', '6px', '12px'], label + ' geometry')
}
const primary = getComputedStyle(confirm), secondary = getComputedStyle(cancel)
check([primary.backgroundColor, primary.color], kind === 'delete' ? [dark ? 'rgb(175, 25, 26)' : 'rgb(232, 92, 92)', 'rgb(255, 255, 255)'] : dark ? ['rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.9)'] : ['rgba(0, 0, 0, 0.9)', 'rgb(255, 255, 255)'], label + ' primary/danger colors')
check([secondary.backgroundColor, secondary.borderTopWidth, secondary.borderTopStyle, secondary.borderTopColor], [dark ? 'rgb(38, 38, 38)' : 'rgb(255, 255, 255)', '1px', 'solid', dark ? 'rgb(61, 61, 61)' : 'rgb(222, 222, 222)'], label + ' secondary colors/border')
const popup = document.querySelector<HTMLElement>('.task-popconfirm')!
check([getComputedStyle(popup).width, getComputedStyle(popup).borderRadius, getComputedStyle(document.querySelector('.task-popconfirm__title')!).fontWeight], ['240px', '16px', '600'], label + ' shell')
check(getComputedStyle(popup).backgroundColor, dark ? 'rgb(36, 36, 36)' : 'rgb(255, 255, 255)', label + ' shell background')
check(document.activeElement === confirm, true, label + ' initial focus')
if (running) check(confirm.textContent, '停止并删除', label + ' running delete semantic')
confirm.click(); confirm.click(); await pause()
check([calls, confirm.disabled, cancel.disabled, confirm.textContent], [1, true, true, '处理中…'], label + ' busy prevents duplicates')
document.dispatchEvent(new KeyboardEvent('keydown', {key:'Escape', bubbles:true})); cancel.click()
check(closes, 0, label + ' busy cannot dismiss')
if (kind === 'delete') check(getComputedStyle(confirm).backgroundColor, dark ? 'rgb(128, 56, 56)' : 'rgb(245, 185, 185)', label + ' danger busy background')
reject(new Error('fixture failure')); await pause()
check(document.querySelector('[role="alert"]')?.textContent, 'fixture failure', label + ' failure retained')
check(confirm.disabled, false, label + ' retry enabled')
confirm.click(); await pause(); resolve(); await pause()
check([calls, closes, !!document.querySelector('.task-popconfirm')], [2, 1, false], label + ' retry success')
}
}
run().then(() => finish(true), error => finish(false, String(error)))
function finish(pass: boolean, error?: string) {
const output = document.createElement('pre'); output.id = 'result'; output.textContent = JSON.stringify({pass, error, results}); document.body.append(output)
}
+17 -1
View File
@@ -41,7 +41,7 @@ async function main() {
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-labelledby'), 'accessible title')
check(document.activeElement === document.querySelector('.task-popconfirm__confirm'), 'confirmation auto-focuses')
check(taskConfirmationCopy('delete', true).confirm === '停止并删除', 'running delete copy')
check(document.querySelector('.task-popconfirm__compatibility')?.textContent?.includes('YCode 暂无此设置入口'), 'archive destination mismatch explicit')
check(!document.querySelector('.task-popconfirm__compatibility'), 'archive preserves selected original copy without extra explanation')
const confirm = document.querySelector('.task-popconfirm__confirm')!
await act(async () => { click(confirm); click(confirm) })
check(calls === 1, 'duplicate confirmation guarded synchronously')
@@ -56,6 +56,22 @@ async function main() {
await act(async () => resolve!())
check(closes === 1 && !document.querySelector('.task-popconfirm'), 'success closes')
await render('delete')
await act(async () => root.render(null))
await act(async () => root.render(<TaskPopconfirm kind="delete" running anchor={anchor} onConfirm={submit} onClose={close} />)); await settle()
const runningConfirm = document.querySelector<HTMLButtonElement>('.task-popconfirm__confirm')!
const runningCancel = document.querySelector<HTMLButtonElement>('.task-popconfirm__cancel')!
check(runningConfirm.textContent === '停止并删除', 'running delete uses stop-and-delete action')
const beforeRunning = calls, beforeRunningClose = closes
await act(async () => { click(runningConfirm); click(runningConfirm); click(runningCancel) })
check(calls === beforeRunning + 1 && closes === beforeRunningClose, 'running delete is single-flight and cancellation locked')
check(runningConfirm.disabled && runningCancel.disabled && runningConfirm.textContent === '处理中…', 'running delete busy buttons remain readable and disabled')
await act(async () => reject!(new Error('stop failed')))
check(document.querySelector('[role="alert"]')?.textContent === 'stop failed', 'stop-and-delete failure retained')
check(!runningConfirm.disabled && runningConfirm.textContent === '停止并删除', 'stop-and-delete failure restores action')
await act(async () => click(runningConfirm))
await act(async () => resolve!())
check(calls === beforeRunning + 2 && closes === beforeRunningClose + 1, 'stop-and-delete retry succeeds once')
await render('delete')
let underlyingClicks = 0
other.addEventListener('click', () => underlyingClicks++)
await act(async () => {
+77
View File
@@ -0,0 +1,77 @@
/** Own-YCode isolated component fixture. Run on a permitted Electron desktop:
* node scripts/test-mode-chip-native.mjs
* No original app, model, SDK, user data, or production session access.
*/
import fs from 'node:fs'
import path from 'node:path'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
const require = createRequire(import.meta.url), module = require('electron')
const base = path.resolve('.tmp')
fs.mkdirSync(base, { recursive: true })
const file = 'src/renderer/src/workbuddy/composer/ModeChip.tsx'
async function prepareFixture(output) {
const src = relative => JSON.stringify(path.resolve(relative).replaceAll('\\', '/'))
fs.writeFileSync(path.join(output, 'fixture.tsx'), `
import React,{useState} from 'react'; import {createRoot} from 'react-dom/client';
import {ModeChip} from ${src(file)};
import ${src('src/renderer/src/workbuddy/typography/tokens.css')};
import ${src('src/renderer/src/styles/tokens.css')};
import ${src('src/renderer/src/ui/tokens.css')};
import ${src('src/renderer/src/styles/app.css')};
import ${src('src/renderer/src/workbuddy/conversation/source-styles.css')};
const q=(window as any).qa={clears:0};
function Fixture(){const [mode,setMode]=useState<any>('plan'),[disabled,setDisabled]=useState(false);Object.assign(q,{setMode,setDisabled});return <div className="chat-pane cr-theme" style={{padding:40}}><div style={{display:"flex",alignItems:"center",gap:8}}><button id="before">Before</button><ModeChip mode={mode} disabled={disabled} onClear={()=>{q.clears++;setMode('craft')}}/><button id="after">After</button></div></div>};createRoot(document.getElementById('root')!).render(<Fixture/>);`)
await require('esbuild').build({ entryPoints: [path.join(output, 'fixture.tsx')], bundle: true, platform: 'browser', jsx: 'automatic', outfile: path.join(output, 'fixture.js'), logLevel: 'warning' })
fs.writeFileSync(path.join(output, 'index.html'), '<html data-theme="light"><head><meta charset="utf-8"><link rel="stylesheet" href="fixture.css"></head><body><div id="root"></div><script src="fixture.js"></script></body></html>')
}
if (!process.versions.electron) {
const output = fs.mkdtempSync(path.join(base, 'mode-chip-native-')), env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
if (process.argv.includes('--build-only')) {
await prepareFixture(output); console.log(JSON.stringify({ output, fixtureBuilt: true, nativeExecuted: false }))
} else {
const child = spawn(module, [path.resolve('scripts/test-mode-chip-native.mjs'), output], { env, windowsHide: true, stdio: 'inherit' })
const timeout = setTimeout(() => child.kill(), 60000)
child.on('error', error => { clearTimeout(timeout); console.error(error); process.exitCode = 1 })
child.on('exit', code => { clearTimeout(timeout); process.exitCode = code ?? 1; console.log(JSON.stringify({ output, exitCode: code })) })
}
} else {
const { app, BrowserWindow } = module, output = path.resolve(process.argv.at(-1))
assert.ok(path.dirname(output) === base && path.basename(output).startsWith('mode-chip-native-'))
fs.mkdirSync(path.join(output, 'data')); app.setPath('userData', path.join(output, 'data')); app.disableHardwareAcceleration()
let win; const checks = [], samples = [], errors = []
try {
await prepareFixture(output)
await app.whenReady(); win = new BrowserWindow({ width: 800, height: 300, show: false, webPreferences: { contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
win.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) errors.push(event.message) })
await win.loadFile(path.join(output, 'index.html')); win.showInactive()
const read = code => win.webContents.executeJavaScript(code), pause = () => new Promise(resolve => setTimeout(resolve, 80))
const check = (label, actual, expected = true) => { assert.deepEqual(actual, expected, label); checks.push(label) }
const key = async keyCode => { win.webContents.sendInputEvent({ type: 'keyDown', keyCode }); win.webContents.sendInputEvent({ type: 'keyUp', keyCode }); await pause() }
const sample = () => read(`(()=>{const c=document.querySelector('.yc-mode-chip'),i=c.querySelector('.yc-mode-chip__mode-icon'),b=c.querySelector('button'),s=getComputedStyle(c),r=e=>{const x=e.getBoundingClientRect();return{x:x.x,y:x.y,width:x.width,height:x.height}};return{chip:r(c),icon:r(i),button:b?r(b):null,background:s.backgroundColor,radius:s.borderRadius,color:s.color,expectedColor:s.getPropertyValue('--cr-text-secondary').trim(),font:s.fontSize,weight:s.fontWeight,line:s.lineHeight,padding:s.paddingLeft,iconOpacity:getComputedStyle(i).opacity,clearOpacity:b?getComputedStyle(b).opacity:null}})()`)
await pause()
for (const theme of ['light', 'dark']) for (const mode of ['plan', 'ask']) {
await read(`document.documentElement.dataset.theme='${theme}'; document.querySelector('.chat-pane').dataset.crTheme='${theme}'; qa.setMode('${mode}');qa.setDisabled(false);document.querySelector('#before').focus()`)
win.webContents.sendInputEvent({ type: 'mouseMove', x: 1, y: 1 }); await pause()
const normal = await sample(); samples.push({ theme, mode, state: 'normal', ...normal })
check('transparent', normal.background, 'rgba(0, 0, 0, 0)'); check('pill', parseFloat(normal.radius) >= normal.chip.height / 2)
check('height', normal.chip.height, 32); check('font', normal.font, '13px'); check('weight', normal.weight, '500'); check('line', normal.line, '20px'); check('padding', normal.padding, '8px')
check('same slot', normal.icon, normal.button); check('icon width', normal.icon.width, 16); check('default icon', normal.iconOpacity, '1'); check('default close', normal.clearOpacity, '0')
check('secondary color', await read(`(()=>{const c=document.querySelector('.yc-mode-chip'),p=document.createElement('span');p.style.color='var(--cr-text-secondary)';c.append(p);const ok=getComputedStyle(p).color===getComputedStyle(c).color;p.remove();return ok})()`))
win.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(normal.chip.x + normal.chip.width / 2), y: Math.round(normal.chip.y + normal.chip.height / 2) }); await pause()
const hover = await sample(); samples.push({ theme, mode, state: 'hover', ...hover }); check('hover icon hidden', hover.iconOpacity, '0'); check('hover X', hover.clearOpacity, '1'); check('no hover layout shift', hover.chip, normal.chip)
win.webContents.sendInputEvent({ type: 'mouseMove', x: 1, y: 1 }); await read(`document.querySelector('.yc-mode-chip').focus()`); await pause()
const focus = await sample(); check('focus icon hidden', focus.iconOpacity, '0'); check('focus X', focus.clearOpacity, '1'); check('no focus layout shift', focus.chip, normal.chip)
await fs.promises.writeFile(path.join(output, `${theme}-${mode}-focus.png`), (await win.webContents.capturePage()).toPNG())
await key('Tab'); check('Tab reaches native clear', await read(`document.activeElement.matches('.yc-mode-chip__clear')`))
const before = await read('qa.clears'); await key(mode === 'plan' ? 'Enter' : 'Space'); check('native keyboard exit', await read('qa.clears'), before + 1); check('craft hidden', await read(`!document.querySelector('.yc-mode-chip')`))
await read(`qa.setMode('${mode}');qa.setDisabled(true)`); await pause(); check('disabled has no clear', await read(`!document.querySelector('.yc-mode-chip button')`)); check('disabled not tabbable', await read(`document.querySelector('.yc-mode-chip').tabIndex`), -1)
}
win.setSize(320, 300); await read(`qa.setMode('ask');qa.setDisabled(false)`); await pause(); const narrow = await sample(); check('narrow slot preserved', narrow.icon, narrow.button); check('narrow no overflow', narrow.chip.x + narrow.chip.width <= 320)
await fs.promises.writeFile(path.join(output, 'narrow.png'), (await win.webContents.capturePage()).toPNG()); check('no renderer errors', errors, [])
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({ checks, samples, errors, sourceSha256: createHash('sha256').update(fs.readFileSync(file)).digest('hex'), scope: 'Own-YCode isolated component browser test, not full app/store integration or original-app pixel acceptance' }, null, 2)); app.exit(0)
} catch (error) { fs.writeFileSync(path.join(output, 'failure.txt'), String(error.stack || error)); console.error(error); app.exit(1) }
}
+47
View File
@@ -0,0 +1,47 @@
/** Isolated DOM regression; not a browser layout/pixel or original-app acceptance test. */
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { createRequire } from 'node:module'
import { build } from 'esbuild'
const require = createRequire(import.meta.url)
const { JSDOM } = require(process.env.YCODE_TEST_DOM_MODULE || process.env.YCODE_JSDOM_PATH || 'jsdom')
const output = fs.mkdtempSync(path.resolve('.tmp/mode-chip-dom-'))
await build({ entryPoints: ['src/renderer/src/workbuddy/composer/ModeChip.tsx'], bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', packages: 'external', loader: { '.css': 'empty' }, outfile: path.join(output, 'component.cjs'), logLevel: 'error' })
const dom = new JSDOM('<html><head></head><body><div id="root"></div></body></html>', { url: 'http://localhost' })
Object.assign(globalThis, { window: dom.window, document: dom.window.document, HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true })
const React = require('react'), { createRoot } = require('react-dom/client')
const { ModeChip } = require(path.join(output, 'component.cjs'))
const css = fs.readFileSync('src/renderer/src/workbuddy/composer/mode-chip.css', 'utf8')
const style = document.createElement('style'); style.textContent = css; document.head.append(style)
const root = createRoot(document.getElementById('root')); let clears = 0, checks = 0
const check = (actual, expected, message) => { assert.deepEqual(actual, expected, message); checks++ }
const render = async (mode, disabled = false) => React.act(async () => root.render(React.createElement(ModeChip, { mode, disabled, onClear: () => { clears++ } })))
for (const mode of ['plan', 'ask']) {
await render(mode)
const chip = document.querySelector('.yc-mode-chip'), slot = chip.querySelector('.yc-mode-chip__icon-slot'), button = slot.querySelector('button'), icon = slot.querySelector('.yc-mode-chip__mode-icon')
check(chip.textContent, mode === 'plan' ? '计划' : '仅问答', 'mode label has no extra X text')
check(chip.getAttribute('role'), 'group', 'chip remains a group, not a menu')
check(button.type, 'button', 'clear cannot submit composer')
check(button.getAttribute('aria-label'), `退出${chip.textContent}模式`, 'accessible exit name')
check(icon.parentElement === button.parentElement, true, 'icon and close share one slot')
check(dom.window.getComputedStyle(slot).display, 'grid', 'shared grid slot')
check(dom.window.getComputedStyle(button).gridArea, '1 / 1', 'button occupies original icon cell')
check(dom.window.getComputedStyle(icon).gridArea, '1 / 1', 'mode occupies same original cell')
check(dom.window.getComputedStyle(button).position, 'static', 'no floating absolute close')
check(dom.window.getComputedStyle(chip).backgroundColor, 'rgba(0, 0, 0, 0)', 'transparent background')
await React.act(async () => button.focus())
check(document.activeElement === button, true, 'native button receives keyboard focus')
const before = clears
await React.act(async () => button.click())
check(clears, before + 1, 'activation calls existing host clear exactly once')
await render(mode, true)
check(document.querySelector('button'), null, 'disabled mode exposes no clear action')
check(document.querySelector('.yc-mode-chip').getAttribute('aria-disabled'), 'true', 'disabled state is announced')
check(document.querySelector('.yc-mode-chip').tabIndex, -1, 'disabled mode not in keyboard tab order')
}
await render('craft'); check(document.querySelector('.yc-mode-chip'), null, 'craft stays hidden')
// CSS contracts complement DOM checks; test-mode-chip-native.mjs is an unexecuted native hover/focus/layout probe.
for (const contract of ['border-radius:var(--yc-radius-full)', 'color:var(--cr-text-secondary)', ':hover .yc-mode-chip__mode-icon', ':focus-within .yc-mode-chip__mode-icon', ':hover .yc-mode-chip__clear', ':focus-within .yc-mode-chip__clear']) check(css.includes(contract), true, contract)
await React.act(async () => root.unmount()); dom.window.close()
console.log(JSON.stringify({ checks, passed: true, scope: 'Isolated React DOM and CSS contracts; no native keyboard/layout/pixel acceptance' }))
+25
View File
@@ -0,0 +1,25 @@
/** Build and run real-browser computed-style checks; no jsdom style substitute.
* CHROMIUM_PATH=/path/to/chrome node scripts/test-task-popconfirm-browser.mjs
* --build-only creates a portable HTML fixture for an available browser.
* Chromium sandbox remains enabled. Runtime failure is NOT a passing test.
*/
import { build } from 'esbuild'
import { mkdtemp, writeFile, readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { spawnSync } from 'node:child_process'
const dir = await mkdtemp(resolve('.tmp/task-popconfirm-browser-'))
await build({ entryPoints: ['scripts/task-popconfirm-browser-fixture.tsx'], bundle: true, platform: 'browser', format: 'iife', jsx: 'automatic', outfile: dir + '/fixture.js', loader: {'.svg':'dataurl', '.woff2':'dataurl', '.woff':'dataurl'}, logLevel: 'warning' })
const js = await readFile(dir + '/fixture.js', 'utf8'), css = await readFile(dir + '/fixture.css', 'utf8')
await writeFile(dir + '/index.html', `<!doctype html><html><head><meta charset="utf-8"><style>${css}</style></head><body><button id="anchor" style="position:absolute;left:30px;top:100px">Task</button><main id="root"></main><script>${js.replaceAll('</script', '<\\/script')}</script></body></html>`)
console.log('Browser fixture: ' + dir + '/index.html')
if (!process.argv.includes('--build-only')) {
const proc = spawnSync(process.env.CHROMIUM_PATH || 'chromium', ['--headless', '--disable-gpu', '--no-first-run', '--user-data-dir=' + dir + '/profile', '--virtual-time-budget=20000', '--dump-dom', pathToFileURL(dir + '/index.html').href], { encoding:'utf8', timeout:60000, maxBuffer:20*1024*1024 })
await writeFile(dir + '/browser.log', (proc.stderr || '') + '\n' + (proc.error || ''))
const match = proc.stdout?.match(/<pre id="result">([\s\S]*?)<\/pre>/)
if (proc.status !== 0 || !match) throw Error(`Browser did not finish; status=${proc.status}; see ${dir}/browser.log`)
const report = JSON.parse(match[1].replaceAll('&quot;', '"').replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&amp;', '&'))
await writeFile(dir + '/report.json', JSON.stringify(report, null, 2))
if (!report.pass) throw Error(report.error)
console.log(`Task confirmation real Chromium: ${report.results.length} assertions passed; ${dir}/report.json`)
}
+25 -2
View File
@@ -66,6 +66,27 @@ if (!process.versions.electron) {
const capture = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(capture.data, 'base64'))
}
// Assert styles in the real body portal, not just variant class names.
async function checkConfirmationStyles(kind) {
const previous = await read('document.documentElement.dataset.theme || "light"')
const setTheme = async theme => {
await read(`(()=>{localStorage.setItem('ycode:theme',${JSON.stringify(theme)});window.dispatchEvent(new StorageEvent('storage',{key:'ycode:theme',newValue:${JSON.stringify(theme)}}))})()`)
await wait(`document.documentElement.dataset.theme === ${JSON.stringify(theme)}`, 'application theme synchronization')
await pause(400)
}
for (const theme of ['light', 'dark']) {
await setTheme(theme)
const values = await read(`(()=>{const style=name=>{const s=getComputedStyle(document.querySelector('.task-popconfirm__'+name));return {background:s.backgroundColor,color:s.color,border:[s.borderTopWidth,s.borderTopStyle,s.borderTopColor],geometry:[s.minWidth,s.paddingLeft,s.paddingRight,s.height,s.borderRadius,s.fontSize]}};return {cancel:style('cancel'),confirm:style('confirm'),titleWeight:getComputedStyle(document.querySelector('.task-popconfirm__title')).fontWeight}})()`)
const dark = theme === 'dark'
check(kind+' '+theme+' secondary background/border', [values.cancel.background, values.cancel.border], [dark ? 'rgb(38, 38, 38)' : 'rgb(255, 255, 255)', ['1px', 'solid', dark ? 'rgb(61, 61, 61)' : 'rgb(222, 222, 222)']])
check(kind+' '+theme+' primary/danger background', values.confirm.background, kind === 'delete' ? (dark ? 'rgb(175, 25, 26)' : 'rgb(232, 92, 92)') : (dark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'))
check(kind+' '+theme+' confirmation foreground', values.confirm.color, kind === 'archive' && dark ? 'rgba(0, 0, 0, 0.9)' : 'rgb(255, 255, 255)')
for (const action of ['confirm', 'cancel']) check(kind+' '+theme+' '+action+' geometry', values[action].geometry, ['66px', '19px', '19px', '28px', '6px', '12px'])
check(kind+' '+theme+' title weight', values.titleWeight, '600')
await shot(kind+'-confirm-'+theme)
}
await setTheme(previous)
}
// 真实用户路径:先悬停行让操作按钮显形,再点击按钮。
const openMenu = `(()=>{const row=document.querySelector('[data-session-id]');if(!row)return null;row.classList.add('sidebar__item--menu-open');const button=row.querySelector('.agent-card-more-button');if(!button)return null;button.click();return true})()`
const menuSource = `(()=>{const menu=document.querySelector('.conversation-context-menu');if(!menu)return null;const style=getComputedStyle(menu);const rect=menu.getBoundingClientRect();return {rect:[rect.x,rect.y,rect.width,rect.height],background:style.backgroundColor,borderRadius:style.borderRadius,padding:style.padding,items:[...menu.querySelectorAll('.cb-context-menu-item')].map(button=>{const box=button.getBoundingClientRect();const icon=button.querySelector('.cb-context-menu-item-icon svg');const label=button.querySelector('.cb-context-menu-item-label');const itemStyle=getComputedStyle(button);return {label:label?label.textContent:'',width:box.width,height:box.height,fontSize:itemStyle.fontSize,lineHeight:itemStyle.lineHeight,borderRadius:itemStyle.borderRadius,color:itemStyle.color,icon:icon?[icon.getBoundingClientRect().width,icon.getBoundingClientRect().height]:null}}),dividers:[...menu.querySelectorAll('.cb-context-menu-divider')].map(divider=>{const box=divider.getBoundingClientRect();const dividerStyle=getComputedStyle(divider);return {width:box.width,height:box.height,margin:dividerStyle.margin}})}})()`
@@ -167,9 +188,10 @@ if (!process.versions.electron) {
const archiveConfirm = await read(`(()=>{const dialog=document.querySelector('.task-popconfirm');const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};const style=getComputedStyle(dialog);return {box:box(dialog),title:dialog.querySelector('.task-popconfirm__title')?.textContent,desc:dialog.querySelector('.task-popconfirm__description')?.textContent,cancel:dialog.querySelector('.task-popconfirm__cancel')?.textContent,confirm:dialog.querySelector('.task-popconfirm__confirm')?.textContent,padding:style.padding,borderRadius:style.borderRadius}})()`)
check('archive opens a confirmation before changing the task', archiveConfirm.title, '归档任务')
check('archive restores the documented original destination', archiveConfirm.desc, '确认将该任务归档吗?归档后可在“系统设置-数据管理”中查看。')
check('archive explains the unavailable destination and working fallback', await read(`document.querySelector('.task-popconfirm__compatibility').textContent.includes('侧栏“已归档”')`), true)
check('archive does not append unrequested compatibility copy', await read(`!!document.querySelector('.task-popconfirm__compatibility')`), false)
check('archive confirmation actions use the recovered labels', [archiveConfirm.cancel, archiveConfirm.confirm], ['取消', '确认归档'])
check('archive confirmation uses the plan-defined 240px popconfirm', [Math.round(archiveConfirm.box[2]), archiveConfirm.padding, archiveConfirm.borderRadius], [240, '16px', '12px'])
check('archive confirmation uses the plan-defined 240px popconfirm', [Math.round(archiveConfirm.box[2]), archiveConfirm.padding, archiveConfirm.borderRadius], [240, '16px', '16px'])
await checkConfirmationStyles('archive')
await shot('archive-confirm')
await read(`document.querySelector('.task-popconfirm__cancel').click()`)
await wait(`!document.querySelector('.task-popconfirm')`, 'archive cancellation closes')
@@ -195,6 +217,7 @@ if (!process.versions.electron) {
check('delete uses the idle confirmation description', deleteConfirm.desc, '删除后,这些任务将从你的所有设备中移除,且无法恢复。')
check('delete confirmation actions use the recovered idle labels', [deleteConfirm.cancel, deleteConfirm.confirm], ['取消', '确认'])
check('idle delete is not marked as running', deleteConfirm.running, null)
await checkConfirmationStyles('delete')
await shot('delete-confirm')
await read(`document.querySelector('.task-popconfirm__confirm').click()`)
await wait(`window.ycode.session.listArchived().then(list=>!list.some(item=>item.id===${JSON.stringify(remainingId)}))`, 'delete completes after confirmation')
@@ -1,4 +1,5 @@
import { useEffect, useId, useRef, useState } from 'react'
import { Button } from '../ui/Button'
import { Popover } from '../ui/Popover'
import './task-popconfirm.css'
@@ -49,12 +50,11 @@ export function TaskPopconfirm({ kind, running, anchor, onConfirm, onClose }: Ta
<div aria-busy={busy} data-task-confirm-kind={kind} data-task-confirm-running={running || undefined}>
<h2 className="task-popconfirm__title" id={titleId}>{copy.title}</h2>
<p className="task-popconfirm__description" id={descriptionId}>{copy.description}</p>
{kind === 'archive' && <p className="task-popconfirm__compatibility">YCode 暂无此设置入口,请从侧栏“已归档”查看和恢复。</p>}
{error && <div className="task-popconfirm__error" id={errorId} role="alert">{error}</div>}
<div className="task-popconfirm__actions">
<button type="button" className="wb-button wb-button--secondary wb-button--small task-popconfirm__cancel" disabled={busy} onClick={close}>取消</button>
<button type="button" className={`wb-button wb-button--primary wb-button--small task-popconfirm__confirm${kind === 'delete' ? ' wb-button--danger' : ''}`}
disabled={busy} onClick={() => void confirm()}>{busy ? '处理中…' : copy.confirm}</button>
<Button variant="secondary" size="sm" className="task-popconfirm__cancel" disabled={busy} onClick={close}>取消</Button>
<Button variant={kind === 'delete' ? 'danger' : 'primary'} size="sm" className="task-popconfirm__confirm"
disabled={busy} onClick={() => void confirm()}>{busy ? '处理中…' : copy.confirm}</Button>
</div>
</div>
</Popover>
@@ -4,7 +4,7 @@
width: calc(var(--yc-space-10) * 5);
max-width: calc(100vw - var(--yc-space-5));
padding: var(--yc-space-5);
border-radius: var(--yc-radius-xl);
border-radius: var(--yc-radius-2xl);
overflow: visible;
color: var(--wb-color-text-primary);
background: var(--wb-bg-popover);
@@ -14,17 +14,20 @@
margin: 0 0 var(--yc-space-3);
font-size: var(--yc-font-size-5);
line-height: var(--yc-line-height-5-2);
font-weight: 500;
font-weight: var(--yc-font-weight-600);
}
.task-popconfirm__description,
.task-popconfirm__compatibility {
.task-popconfirm__description {
margin: 0;
color: var(--wb-color-text-secondary);
font-size: var(--yc-font-size-4);
line-height: var(--yc-line-height-5);
overflow-wrap: anywhere;
}
.task-popconfirm__compatibility { margin-top: var(--yc-space-3); color: var(--wb-color-text-tertiary); }
.task-popconfirm__error { margin-top: var(--yc-space-3); color: var(--wb-color-text-error-default); font-size: var(--yc-font-size-4); line-height: var(--yc-line-height-5); }
.task-popconfirm__actions { display: flex; justify-content: flex-end; gap: var(--yc-space-3); margin-top: var(--yc-space-5); }
.task-popconfirm-source .task-popconfirm__actions .wb-button { min-width: calc(var(--yc-space-10) + var(--yc-space-2)); height: var(--yc-line-height-7); padding: 0 var(--yc-space-3); }
.task-popconfirm-source .task-popconfirm__actions .yc-button {
/* Recorded minimum width and inset, composed from existing tokens. */
min-width: calc(var(--yc-size-17) - var(--yc-border-width) * 2);
height: var(--yc-line-height-7);
padding: 0 calc(var(--yc-space-6) - var(--yc-border-width));
}
@@ -5,10 +5,14 @@ import './mode-chip.css'
export function ModeChip({ mode, disabled, onClear }: { mode: AgentMode; disabled?: boolean; onClear(): void | Promise<void> }): React.JSX.Element | null {
if (mode === 'craft') return null
const label = mode === 'plan' ? '计划' : '仅问答'
return <span className="yc-mode-chip" role="group" tabIndex={0} aria-label={`${label}模式`}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.2" aria-hidden="true">
{mode === 'plan' ? <><rect x="3" y="3" width="10" height="11" rx="1.5" /><rect x="5.5" y="1.5" width="5" height="3" rx="1" /><path d="M5.5 7h5M5.5 10h5" /></> : <path d="M13.5 7.5a5.5 5.5 0 0 1-5.5 5.5H3l-1.5 1v-6.5A5.5 5.5 0 0 1 7 2h1a5.5 5.5 0 0 1 5.5 5.5Z" />}
</svg><span>{label}</span>
{!disabled && <button type="button" className="yc-mode-chip__clear" aria-label={`退出${label}模式`} title="切换到工作模式" onClick={() => void onClear()}>×</button>}
return <span className="yc-mode-chip" role="group" tabIndex={disabled ? -1 : 0} aria-disabled={disabled || undefined} aria-label={`${label}模式`}>
<span className="yc-mode-chip__icon-slot">
<svg className="yc-mode-chip__mode-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.2" aria-hidden="true">
{mode === 'plan' ? <><rect x="3" y="3" width="10" height="11" rx="1.5" /><rect x="5.5" y="1.5" width="5" height="3" rx="1" /><path d="M5.5 7h5M5.5 10h5" /></> : <path d="M13.5 7.5a5.5 5.5 0 0 1-5.5 5.5H3l-1.5 1v-6.5A5.5 5.5 0 0 1 7 2h1a5.5 5.5 0 0 1 5.5 5.5Z" />}
</svg>
{!disabled && <button type="button" className="yc-mode-chip__clear" aria-label={`退出${label}模式`} title="切换到工作模式" onClick={() => void onClear()}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.2" aria-hidden="true"><path d="m4 4 8 8M12 4l-8 8" /></svg>
</button>}
</span><span>{label}</span>
</span>
}
@@ -1,6 +1,9 @@
.yc-mode-chip { position:relative; display:inline-flex; flex-shrink:0; align-items:center; gap:var(--yc-space-2); height:var(--yc-space-8); padding:0 var(--yc-space-3); border-radius:var(--yc-space-3); background:var(--cr-bg-tertiary); color:var(--cr-text-primary); font-size:var(--yc-font-size-4); line-height:var(--yc-line-height-5); font-weight:500; }
.yc-mode-chip > svg { flex-shrink:0; width:var(--yc-space-5); height:var(--yc-space-5); }
.yc-mode-chip { display:inline-flex; flex-shrink:0; align-items:center; gap:var(--yc-space-2); height:var(--yc-space-8); padding:0 var(--yc-space-3); border-radius:var(--yc-radius-full); background:transparent; color:var(--cr-text-secondary); font-size:var(--yc-font-size-4); line-height:var(--yc-line-height-5); font-weight:500; }
.yc-mode-chip__icon-slot { display:grid; flex-shrink:0; width:var(--yc-space-5); height:var(--yc-space-5); }
.yc-mode-chip__mode-icon, .yc-mode-chip__clear { grid-area:1 / 1; width:var(--yc-space-5); height:var(--yc-space-5); }
.yc-mode-chip__clear > svg { width:100%; height:100%; }
.yc-mode-chip:focus-visible { outline:var(--yc-space-1) solid var(--cr-text-secondary); outline-offset:var(--yc-space-1); }
.yc-mode-chip__clear { display:inline-flex; align-items:center; justify-content:center; width:var(--yc-space-5); height:var(--yc-space-5); padding:0; border:0; border-radius:50%; background:var(--cr-bg-tertiary); color:inherit; font-size:var(--yc-space-5); cursor:pointer; opacity:0; position:absolute; right:calc(-1 * var(--wb-spacing-2-5)); top:calc(-1.5 * var(--yc-space-1)); }
.yc-mode-chip:hover .yc-mode-chip__clear, .yc-mode-chip:focus-within .yc-mode-chip__clear { opacity:1; }
.yc-mode-chip__clear { display:inline-flex; align-items:center; justify-content:center; padding:0; border:0; border-radius:var(--yc-radius-full); background:transparent; color:inherit; cursor:pointer; opacity:0; }
.yc-mode-chip:not([aria-disabled="true"]):hover .yc-mode-chip__mode-icon, .yc-mode-chip:not([aria-disabled="true"]):focus-within .yc-mode-chip__mode-icon { opacity:0; }
.yc-mode-chip:not([aria-disabled="true"]):hover .yc-mode-chip__clear, .yc-mode-chip:not([aria-disabled="true"]):focus-within .yc-mode-chip__clear { opacity:1; }
.yc-mode-chip__clear:focus-visible { outline:var(--yc-space-1) solid var(--cr-text-secondary); }
@@ -4,6 +4,7 @@ import { SourceFoldChevronIcon } from '../message-actions/SourceActionIcons'
import {
buildCompletedFoldModel,
classifyCompletedFoldPart,
completedFoldPartIdentity,
type CompletedFoldOptions,
type CompletedFoldPart,
type CompletedFoldSegment,
@@ -26,7 +27,7 @@ function renderPart(
summaryGroup?: CompletedFoldSummaryGroup
): ReactNode {
const classification = unit.classification ?? classifyCompletedFoldPart(unit.part, unit.index)
return <DisclosureStateScope key={unit.key} id={partDisclosureId(unit.part, unit.index)}>{render(unit.part, unit.index, {
return <DisclosureStateScope key={unit.key} id={completedFoldPartIdentity(unit.part, unit.index)}>{render(unit.part, unit.index, {
kind: summaryGroup ? 'summary-member' : 'part',
classification,
presentationGroupId: classification.presentationGroupId,
@@ -42,7 +43,7 @@ function renderPresentationUnit(
if (unit.kind === 'part') return renderPart(unit.unit, render)
const children = unit.group.units.map(member => renderPart(member, render, unit.group))
return (
<DisclosureStateScope key={unit.group.key} id={`summary:${partDisclosureId(unit.group.units[0].part, unit.group.units[0].index)}`}>
<DisclosureStateScope key={unit.group.key} id={`summary:${completedFoldPartIdentity(unit.group.units[0].part, unit.group.units[0].index)}`}>
{renderGroup ? renderGroup(unit.group, children) : <>{children}</>}
</DisclosureStateScope>
)
@@ -146,10 +147,6 @@ export function LiveEventDisclosure({
)
}
function partDisclosureId(part: CompletedFoldPart, index: number): string {
const id = 'id' in part && typeof part.id === 'string' ? part.id : part.type === 'tool' && typeof part.toolId === 'string' ? part.toolId : index
return JSON.stringify([part.type, id])
}
export function CompletedFold(props: CompletedFoldProps) {
return <DisclosureStateProvider key={props.foldId}><CompletedFoldContent {...props} /></DisclosureStateProvider>
@@ -183,7 +180,7 @@ function CompletedFoldContent({
if (!model) return <>{parts.map((part, index) => {
const classification = classifyCompletedFoldPart(part, index, classifyToolCall)
if (!classification.hasRootBlock) return null
return <DisclosureStateScope key={`${part.type}:${index}`} id={partDisclosureId(part, index)}>{render(part, index, {
return <DisclosureStateScope key={completedFoldPartIdentity(part, index)} id={completedFoldPartIdentity(part, index)}>{render(part, index, {
kind: 'part',
classification,
presentationGroupId: classification.presentationGroupId
@@ -208,7 +205,7 @@ function CompletedFoldContent({
) : parts.map((part, index) => {
const classification = classifyCompletedFoldPart(part, index, classifyToolCall)
if (!classification.hasRootBlock) return null
return <DisclosureStateScope key={`${part.type}:${index}`} id={partDisclosureId(part, index)}>{render(part, index, { kind: 'part', classification })}</DisclosureStateScope>
return <DisclosureStateScope key={completedFoldPartIdentity(part, index)} id={completedFoldPartIdentity(part, index)}>{render(part, index, { kind: 'part', classification })}</DisclosureStateScope>
})
})()}
</CollapseSection>
@@ -241,13 +241,24 @@ export function formatCompletedFoldDuration(durationMs: number): string {
return `${seconds}s`
}
/** Business identity survives terminal text coalescing and presentation reordering.
* Old histories without an ID intentionally retain positional fallback semantics.
*/
export function completedFoldPartIdentity(part: CompletedFoldPart, index: number): string {
let id: unknown = 'id' in part ? part.id : undefined
if (part.type === 'tool') id = part.toolId
let content: unknown
if (part.type === 'plan') content = part.plan
if (part.type === 'task') content = part.task
if (part.type === 'artifact') content = part.artifact
if (content && typeof content === 'object' && 'id' in content) id = content.id
return typeof id === 'string' && id.length > 0
? JSON.stringify([part.type, 'id', id])
: JSON.stringify([part.type, 'index', index])
}
function makeUnit(part: CompletedFoldPart, index: number): CompletedFoldUnit {
const partId = 'id' in part && typeof part.id === 'string'
? part.id
: part.type === 'tool' && typeof part.toolId === 'string'
? part.toolId
: String(index)
return { key: `${part.type}:${partId}:${index}`, index, part }
return { key: completedFoldPartIdentity(part, index), index, part }
}
function buildSourceSegments(