Merge remote-tracking branch 'gitea/master' into HEAD
This commit is contained in:
commit
9f1e583e23
17 files changed
+687
-36
No files matched your search
@@ -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) })
|
||||||
@@ -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.
|
// Optional DOM tests use separately installed jsdom, not Electron/CLI or an upstream model.
|
||||||
if (process.env.YCODE_TEST_DOM_MODULE) {
|
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`
|
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' })
|
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 })
|
const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 })
|
||||||
if (result.error) throw result.error
|
if (result.error) throw result.error
|
||||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
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)
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ async function main() {
|
|||||||
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-labelledby'), 'accessible title')
|
check(document.querySelector('[role="dialog"]')?.getAttribute('aria-labelledby'), 'accessible title')
|
||||||
check(document.activeElement === document.querySelector('.task-popconfirm__confirm'), 'confirmation auto-focuses')
|
check(document.activeElement === document.querySelector('.task-popconfirm__confirm'), 'confirmation auto-focuses')
|
||||||
check(taskConfirmationCopy('delete', true).confirm === '停止并删除', 'running delete copy')
|
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')!
|
const confirm = document.querySelector('.task-popconfirm__confirm')!
|
||||||
await act(async () => { click(confirm); click(confirm) })
|
await act(async () => { click(confirm); click(confirm) })
|
||||||
check(calls === 1, 'duplicate confirmation guarded synchronously')
|
check(calls === 1, 'duplicate confirmation guarded synchronously')
|
||||||
@@ -56,6 +56,22 @@ async function main() {
|
|||||||
await act(async () => resolve!())
|
await act(async () => resolve!())
|
||||||
check(closes === 1 && !document.querySelector('.task-popconfirm'), 'success closes')
|
check(closes === 1 && !document.querySelector('.task-popconfirm'), 'success closes')
|
||||||
await render('delete')
|
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
|
let underlyingClicks = 0
|
||||||
other.addEventListener('click', () => underlyingClicks++)
|
other.addEventListener('click', () => underlyingClicks++)
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
@@ -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' }))
|
||||||
@@ -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('"', '"').replaceAll('<', '<').replaceAll('>', '>').replaceAll('&', '&'))
|
||||||
|
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`)
|
||||||
|
}
|
||||||
@@ -66,6 +66,27 @@ if (!process.versions.electron) {
|
|||||||
const capture = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
|
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'))
|
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 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}})}})()`
|
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}})()`)
|
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 opens a confirmation before changing the task', archiveConfirm.title, '归档任务')
|
||||||
check('archive restores the documented original destination', archiveConfirm.desc, '确认将该任务归档吗?归档后可在“系统设置-数据管理”中查看。')
|
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 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 shot('archive-confirm')
|
||||||
await read(`document.querySelector('.task-popconfirm__cancel').click()`)
|
await read(`document.querySelector('.task-popconfirm__cancel').click()`)
|
||||||
await wait(`!document.querySelector('.task-popconfirm')`, 'archive cancellation closes')
|
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 uses the idle confirmation description', deleteConfirm.desc, '删除后,这些任务将从你的所有设备中移除,且无法恢复。')
|
||||||
check('delete confirmation actions use the recovered idle labels', [deleteConfirm.cancel, deleteConfirm.confirm], ['取消', '确认'])
|
check('delete confirmation actions use the recovered idle labels', [deleteConfirm.cancel, deleteConfirm.confirm], ['取消', '确认'])
|
||||||
check('idle delete is not marked as running', deleteConfirm.running, null)
|
check('idle delete is not marked as running', deleteConfirm.running, null)
|
||||||
|
await checkConfirmationStyles('delete')
|
||||||
await shot('delete-confirm')
|
await shot('delete-confirm')
|
||||||
await read(`document.querySelector('.task-popconfirm__confirm').click()`)
|
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')
|
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 { useEffect, useId, useRef, useState } from 'react'
|
||||||
|
import { Button } from '../ui/Button'
|
||||||
import { Popover } from '../ui/Popover'
|
import { Popover } from '../ui/Popover'
|
||||||
import './task-popconfirm.css'
|
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}>
|
<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>
|
<h2 className="task-popconfirm__title" id={titleId}>{copy.title}</h2>
|
||||||
<p className="task-popconfirm__description" id={descriptionId}>{copy.description}</p>
|
<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>}
|
{error && <div className="task-popconfirm__error" id={errorId} role="alert">{error}</div>}
|
||||||
<div className="task-popconfirm__actions">
|
<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 variant="secondary" size="sm" className="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' : ''}`}
|
<Button variant={kind === 'delete' ? 'danger' : 'primary'} size="sm" className="task-popconfirm__confirm"
|
||||||
disabled={busy} onClick={() => void confirm()}>{busy ? '处理中…' : copy.confirm}</button>
|
disabled={busy} onClick={() => void confirm()}>{busy ? '处理中…' : copy.confirm}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
width: calc(var(--yc-space-10) * 5);
|
width: calc(var(--yc-space-10) * 5);
|
||||||
max-width: calc(100vw - var(--yc-space-5));
|
max-width: calc(100vw - var(--yc-space-5));
|
||||||
padding: var(--yc-space-5);
|
padding: var(--yc-space-5);
|
||||||
border-radius: var(--yc-radius-xl);
|
border-radius: var(--yc-radius-2xl);
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
color: var(--wb-color-text-primary);
|
color: var(--wb-color-text-primary);
|
||||||
background: var(--wb-bg-popover);
|
background: var(--wb-bg-popover);
|
||||||
@@ -14,17 +14,20 @@
|
|||||||
margin: 0 0 var(--yc-space-3);
|
margin: 0 0 var(--yc-space-3);
|
||||||
font-size: var(--yc-font-size-5);
|
font-size: var(--yc-font-size-5);
|
||||||
line-height: var(--yc-line-height-5-2);
|
line-height: var(--yc-line-height-5-2);
|
||||||
font-weight: 500;
|
font-weight: var(--yc-font-weight-600);
|
||||||
}
|
}
|
||||||
.task-popconfirm__description,
|
.task-popconfirm__description {
|
||||||
.task-popconfirm__compatibility {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--wb-color-text-secondary);
|
color: var(--wb-color-text-secondary);
|
||||||
font-size: var(--yc-font-size-4);
|
font-size: var(--yc-font-size-4);
|
||||||
line-height: var(--yc-line-height-5);
|
line-height: var(--yc-line-height-5);
|
||||||
overflow-wrap: anywhere;
|
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__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__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 {
|
export function ModeChip({ mode, disabled, onClear }: { mode: AgentMode; disabled?: boolean; onClear(): void | Promise<void> }): React.JSX.Element | null {
|
||||||
if (mode === 'craft') return null
|
if (mode === 'craft') return null
|
||||||
const label = mode === 'plan' ? '计划' : '仅问答'
|
const label = mode === 'plan' ? '计划' : '仅问答'
|
||||||
return <span className="yc-mode-chip" role="group" tabIndex={0} aria-label={`${label}模式`}>
|
return <span className="yc-mode-chip" role="group" tabIndex={disabled ? -1 : 0} aria-disabled={disabled || undefined} aria-label={`${label}模式`}>
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.2" aria-hidden="true">
|
<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" />}
|
{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>
|
</svg>
|
||||||
{!disabled && <button type="button" className="yc-mode-chip__clear" aria-label={`退出${label}模式`} title="切换到工作模式" onClick={() => void onClear()}>×</button>}
|
{!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>
|
</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 { 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 > svg { flex-shrink:0; width:var(--yc-space-5); height:var(--yc-space-5); }
|
.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: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__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:hover .yc-mode-chip__clear, .yc-mode-chip:focus-within .yc-mode-chip__clear { opacity:1; }
|
.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); }
|
.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 {
|
import {
|
||||||
buildCompletedFoldModel,
|
buildCompletedFoldModel,
|
||||||
classifyCompletedFoldPart,
|
classifyCompletedFoldPart,
|
||||||
|
completedFoldPartIdentity,
|
||||||
type CompletedFoldOptions,
|
type CompletedFoldOptions,
|
||||||
type CompletedFoldPart,
|
type CompletedFoldPart,
|
||||||
type CompletedFoldSegment,
|
type CompletedFoldSegment,
|
||||||
@@ -26,7 +27,7 @@ function renderPart(
|
|||||||
summaryGroup?: CompletedFoldSummaryGroup
|
summaryGroup?: CompletedFoldSummaryGroup
|
||||||
): ReactNode {
|
): ReactNode {
|
||||||
const classification = unit.classification ?? classifyCompletedFoldPart(unit.part, unit.index)
|
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',
|
kind: summaryGroup ? 'summary-member' : 'part',
|
||||||
classification,
|
classification,
|
||||||
presentationGroupId: classification.presentationGroupId,
|
presentationGroupId: classification.presentationGroupId,
|
||||||
@@ -42,7 +43,7 @@ function renderPresentationUnit(
|
|||||||
if (unit.kind === 'part') return renderPart(unit.unit, render)
|
if (unit.kind === 'part') return renderPart(unit.unit, render)
|
||||||
const children = unit.group.units.map(member => renderPart(member, render, unit.group))
|
const children = unit.group.units.map(member => renderPart(member, render, unit.group))
|
||||||
return (
|
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}</>}
|
{renderGroup ? renderGroup(unit.group, children) : <>{children}</>}
|
||||||
</DisclosureStateScope>
|
</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) {
|
export function CompletedFold(props: CompletedFoldProps) {
|
||||||
return <DisclosureStateProvider key={props.foldId}><CompletedFoldContent {...props} /></DisclosureStateProvider>
|
return <DisclosureStateProvider key={props.foldId}><CompletedFoldContent {...props} /></DisclosureStateProvider>
|
||||||
@@ -183,7 +180,7 @@ function CompletedFoldContent({
|
|||||||
if (!model) return <>{parts.map((part, index) => {
|
if (!model) return <>{parts.map((part, index) => {
|
||||||
const classification = classifyCompletedFoldPart(part, index, classifyToolCall)
|
const classification = classifyCompletedFoldPart(part, index, classifyToolCall)
|
||||||
if (!classification.hasRootBlock) return null
|
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',
|
kind: 'part',
|
||||||
classification,
|
classification,
|
||||||
presentationGroupId: classification.presentationGroupId
|
presentationGroupId: classification.presentationGroupId
|
||||||
@@ -208,7 +205,7 @@ function CompletedFoldContent({
|
|||||||
) : parts.map((part, index) => {
|
) : parts.map((part, index) => {
|
||||||
const classification = classifyCompletedFoldPart(part, index, classifyToolCall)
|
const classification = classifyCompletedFoldPart(part, index, classifyToolCall)
|
||||||
if (!classification.hasRootBlock) return null
|
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>
|
</CollapseSection>
|
||||||
|
|||||||
@@ -241,13 +241,24 @@ export function formatCompletedFoldDuration(durationMs: number): string {
|
|||||||
return `${seconds}s`
|
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 {
|
function makeUnit(part: CompletedFoldPart, index: number): CompletedFoldUnit {
|
||||||
const partId = 'id' in part && typeof part.id === 'string'
|
return { key: completedFoldPartIdentity(part, index), index, part }
|
||||||
? part.id
|
|
||||||
: part.type === 'tool' && typeof part.toolId === 'string'
|
|
||||||
? part.toolId
|
|
||||||
: String(index)
|
|
||||||
return { key: `${part.type}:${partId}:${index}`, index, part }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSourceSegments(
|
function buildSourceSegments(
|
||||||
|
|||||||
Reference in new issue
Block a user