diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml
index 1fa95a5e2d..4b6ef324b9 100644
--- a/.agents/notes/README.i18n.yaml
+++ b/.agents/notes/README.i18n.yaml
@@ -12,8 +12,8 @@
en: 055a5e49f9af3dc2
zh: 9f7c78409cccfded
/agent-notes/archiving-and-deletion:
- en: 6c393c7058c787d7
- zh: 70ce9ca3bd9c2133
+ en: 57ccc5fec461ebb0
+ zh: 69e8f9e79abc53aa
/agent-notes/when-to-write-one:
en: 2beb1b94d8fb633c
zh: 889b5542bb771205
diff --git a/.agents/notes/README.md b/.agents/notes/README.md
index 6872de0a13..8cee939fcf 100644
--- a/.agents/notes/README.md
+++ b/.agents/notes/README.md
@@ -39,7 +39,7 @@ Delete an implemented Agent Note that only describes small UI adjustments or pur
The archive is path-encoded as `archived/{class}/yyyy-mm-dd-topic-title.md`; `implemented` is deliberately absent because only implemented notes can enter it. An archival change moves the complete English/Chinese/sidecar triplet, retains `Status: implemented`, inserts the same `Archived: YYYY-MM-DD` line immediately below that status in both language files, re-records the sidecar, and repairs or deletes inbound links. These are the only permitted content changes during archival. Existing title punctuation, blank-line layout, and language-switcher wording do not block archival; preserve them with the body.
-Once sealed, every archived triplet is permanently frozen. Do not edit, translate, reformat, update, move, or delete it, and do not treat it as authority for current behavior. Documentation gates skip archived sources, including their outbound links; active prose may still link into an archived note when it intentionally cites history. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) enforces the closed class tree, complete triplets, archive metadata, sidecar hashes, and the append-only frozen-content manifest. The [archive-policy Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) owns the rationale.
+Once sealed, every archived triplet is permanently frozen. Do not edit, translate, reformat, update, move, or delete it, and do not treat it as authority for current behavior. Documentation gates skip archived sources, including their outbound links; active prose may still link into an archived note when it intentionally cites history. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) enforces the closed class tree, complete triplets, archive metadata, sidecar hashes, and the append-only frozen-content manifest, except for the exact seal transitions documented in [archive instructions](archived/AGENTS.md). The [archive-policy Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) owns the rationale.
## When to write one
diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md
index ced81f77fa..6339f56f7c 100644
--- a/.agents/notes/README.zh.md
+++ b/.agents/notes/README.zh.md
@@ -41,7 +41,7 @@
归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。 既有标题标点、空行布局和语言切换栏措辞不阻止归档;与正文一起保留。
-封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单)。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.zh.md) 记录了设计依据。
+封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单),但[归档说明](archived/AGENTS.md)中记录的精确封存哈希转换除外。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.zh.md) 记录了设计依据。
diff --git a/.agents/notes/archived/AGENTS.md b/.agents/notes/archived/AGENTS.md
index 2ac4518a6a..d013ed068e 100644
--- a/.agents/notes/archived/AGENTS.md
+++ b/.agents/notes/archived/AGENTS.md
@@ -5,3 +5,5 @@ Archived Agent Note triplets under the kind directories are frozen historical sn
The archival change may only relocate a complete English/Chinese/sidecar triplet, insert the identical `Archived: YYYY-MM-DD` line below both `Status: implemented` lines, re-record the sidecar, and repair or delete inbound links. Do not inspect, verify, or repair links out of archived notes.
Run the [`dsh-archive-agent-notes`](../../skills/dsh-archive-agent-notes/SKILL.md) workflow and append new artifact hashes with `pnpm run verify-archived-agent-notes --write`. The normal verifier rejects changed or missing sealed artifacts, incomplete triplets, unknown kind folders, and invalid archive metadata.
+
+The authorized Figma-link removal from `feature/2026-08-10-durable-workflow-runs-in-chat` has three exact old-to-new seal exceptions in `scripts/archived-agent-notes.ts` for its English, Chinese, and pairing files. These exceptions permit no other content change, reversal, or deletion.
diff --git a/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.i18n.yaml b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.i18n.yaml
new file mode 100644
index 0000000000..b5321c036f
--- /dev/null
+++ b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write
+2026-09-24-web-default-schedule-composition.md: 3f27bb09ebc00d42c32563e17a1de82c8e525be1
+2026-09-24-web-default-schedule-composition.zh.md: 9e0e1fb77ce89b579e1bc8b0d4d0a1b9f4e9fea5
diff --git a/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.md b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.md
similarity index 99%
rename from .agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.md
rename to .agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.md
index 91f533950a..3f27bb09eb 100644
--- a/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.md
+++ b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.md
@@ -1,6 +1,7 @@
# Agent Note: Schedule in the shipped Web composition
Status: implemented
+Archived: 2026-09-25
English | [中文](2026-09-24-web-default-schedule-composition.zh.md)
diff --git a/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.zh.md b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.zh.md
similarity index 99%
rename from .agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.zh.md
rename to .agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.zh.md
index af561fe63e..9e0e1fb77c 100644
--- a/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.zh.md
+++ b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.zh.md
@@ -1,6 +1,7 @@
# Agent Note: 默认 Web 组合中的 Schedule
Status: implemented
+Archived: 2026-09-25
[English](2026-09-24-web-default-schedule-composition.md) | 中文
diff --git a/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml b/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml
index 284f43658a..871a8eefb8 100644
--- a/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml
+++ b/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.md
-2026-08-10-durable-workflow-runs-in-chat.md: 01c59ef25996d6349ab2904f148bf4e72fdd5f09
-2026-08-10-durable-workflow-runs-in-chat.zh.md: 5f6b39b59beca5e2363261605ec58fbdcf71ea6f
+2026-08-10-durable-workflow-runs-in-chat.md: a49cb75e44bd65ef694679eeb295cfef564c7545
+2026-08-10-durable-workflow-runs-in-chat.zh.md: 753e02238cd8c00d0494cb673ba9896d61ff4cd8
diff --git a/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.md b/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.md
index 01c59ef259..a49cb75e44 100644
--- a/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.md
+++ b/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.md
@@ -25,7 +25,7 @@ The renderer gives each level a distinct visual responsibility. The run uses a 3
Navigation is derived from two current authorities rather than persisted. A member row is interactive only while its durable member state is running and the current ordinary Session list contains the same id with `origin: 'subagent'`, `parentId` equal to the displayed parent, and `running: true`. Underlined member text is the only visible affordance; keyboard focus draws a two-pixel business-primary ring around the name area, and the fixed status label remains the lifecycle word rather than an action instruction. The renderer invokes only the injected ordinary `sessions.open(id)` callback. Addressed-only, remote, wrong-parent, and terminal members remain visible but static.
-The [seven-state Figma reference](https://www.figma.com/design/tguwzZRmHCjbq58mfsqT0M?node-id=5-2) fixes the information hierarchy for running expanded/collapsed, completed history/expanded, failed plus cancelled, interrupted recovery, and dark narrow presentation. Repository `DisclosureRow`, `StateDot`, icons, semantic tokens, and keyed-node behavior remain the implementation authority; the reference introduces no runtime field or state owner.
+The seven-state Figma reference fixes the information hierarchy for running expanded/collapsed, completed history/expanded, failed plus cancelled, interrupted recovery, and dark narrow presentation. Repository `DisclosureRow`, `StateDot`, icons, semantic tokens, and keyed-node behavior remain the implementation authority; the reference introduces no runtime field or state owner.
## Verification
diff --git a/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md b/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md
index 5f6b39b59b..753e02238c 100644
--- a/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md
+++ b/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md
@@ -25,7 +25,7 @@ renderer 为每一层分配不同视觉职责。运行使用 32 像素 module-pl
导航从两个当前权威派生,不写入持久记录。只有持久成员状态仍为运行中,且当前普通 Session 列表包含同一 id、`origin: 'subagent'`、`parentId` 等于当前父 Session、`running: true` 时,成员行才可交互。带下划线的成员文字是唯一可见提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,固定状态列继续只表达生命周期,而不写动作说明。renderer 只调用注入的普通 `sessions.open(id)` 回调。仅地址化、远程、父级不符或终态成员继续可见,但保持静态。
-[七状态 Figma 参考](https://www.figma.com/design/tguwzZRmHCjbq58mfsqT0M?node-id=5-2)固定运行展开/收起、完成历史/展开、失败与取消、恢复后中断以及暗色窄列的信息层级。仓库的 `DisclosureRow`、`StateDot`、图标、语义 token 和 keyed-node 行为仍是实现权威;参考稿不引入运行时字段或状态 owner。
+七状态 Figma 参考固定运行展开/收起、完成历史/展开、失败与取消、恢复后中断以及暗色窄列的信息层级。仓库的 `DisclosureRow`、`StateDot`、图标、语义 token 和 keyed-node 行为仍是实现权威;参考稿不引入运行时字段或状态 owner。
## 验证
diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json
index 5ed6850038..44178cc0ec 100644
--- a/.agents/notes/archived/manifest.json
+++ b/.agents/notes/archived/manifest.json
@@ -376,6 +376,9 @@
"architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml": "sha256:af027c7dae53a181e4d73fd2309f69e9ae3c48df0d3b1dfc53cae274aa60d69e",
"architecture/2026-09-11-wasm-preview-font-and-image-budgets.md": "sha256:44dd52839cdb67644c0dbcbc4beb6f36802628c4a8d17a0aad11db1f9be649ed",
"architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md": "sha256:8285432ee1676be8b79a05953c4b6ac4a7ca5d8e701173b3a2b533608b584d0a",
+ "architecture/2026-09-24-web-default-schedule-composition.i18n.yaml": "sha256:8c3d5c614924d0e81f361a299427ac697aefc6189c41eb3f4151f5c42e371ca0",
+ "architecture/2026-09-24-web-default-schedule-composition.md": "sha256:3a139593ade13f95cbb67094c1eec66cb0a2c14bcc154512799803b2a7d22ab4",
+ "architecture/2026-09-24-web-default-schedule-composition.zh.md": "sha256:59908f7987058742d1226dc8a801d03c265c84d51172249ba51716b91f084f0c",
"bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml": "sha256:8be5b0afd8820c593e2ec254d35fb8c384267814104f6234bb9af824c5f974ba",
"bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md": "sha256:861e6130e893489271478def5f54f212a106a1580b744e6e40f56356f73c7e10",
"bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md": "sha256:3549257e06f074591de580ebfaa71284c7c384fb0ffb1963c30258d7a7c26db7",
@@ -1264,9 +1267,9 @@
"feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml": "sha256:74f519839f0cf82c7304bdeae41ae1cab8bb930bfb94f79ab44708acd3b72128",
"feature/2026-08-10-creator-guidance-introduce-cue.md": "sha256:3e25409dda498de150de18943ee332e1760963e377a40a365902b66f667fdc9f",
"feature/2026-08-10-creator-guidance-introduce-cue.zh.md": "sha256:203847010cab9e9d13c3969f17921d3a5aa0d69377eccfef555c7ff96572f162",
- "feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml": "sha256:0db3c21a3c8785e56d5d7b90df990b158069cd1cea664e62fc4786c6d7588450",
- "feature/2026-08-10-durable-workflow-runs-in-chat.md": "sha256:f9f5290cd880908d17b1080ae5776f22e182f5253471f90cf8bead418b33b502",
- "feature/2026-08-10-durable-workflow-runs-in-chat.zh.md": "sha256:f4ffd2700bc9c2a0cc4b9d90f84e1d0a576939e2e6f0b130c5b142fd8cd518b0",
+ "feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml": "sha256:03eadb60c8ba4fde262cd61e3f911d8cd68ea4a58f57adeb8dd7bb9a97d08ca2",
+ "feature/2026-08-10-durable-workflow-runs-in-chat.md": "sha256:6018de4a89ca99d6cbfd618aff1c8136b23d8e4854a30f7b12a602f0417cbdc2",
+ "feature/2026-08-10-durable-workflow-runs-in-chat.zh.md": "sha256:0080859221773122022d5880efa3d52f29c0872b52d63e170250d94f214ff37a",
"feature/2026-08-10-minimal-read-image-tool.i18n.yaml": "sha256:2311b6453b90a1f81d4fcbdaa723ab233cc0998f34393956364c8b92bc4d1213",
"feature/2026-08-10-minimal-read-image-tool.md": "sha256:debe6b570d11634f57a80eaa5891e4fd1e7a5aac19f10fb7dd1683c316515150",
"feature/2026-08-10-minimal-read-image-tool.zh.md": "sha256:572d080340682b354cb1f8a0f74af153d6542bd3dffbe3b72b32591e5d5d3baf",
diff --git a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.i18n.yaml
index 159fee0bf9..8bc4eaeaa5 100644
--- a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.i18n.yaml
@@ -15,5 +15,5 @@
en: 21d7ec9a9ff3cbf2
zh: 93d03b8b3012aa7e
/agent-note-host-owned-scheduled-messages/consequences:
- en: 0358aab730e4bf56
- zh: 61e89e0374891f8a
+ en: a48f13f89a1944b1
+ zh: f8fa677122ce2e12
diff --git a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md
index 79a4f72071..3d90e8ad7e 100644
--- a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md
+++ b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md
@@ -58,7 +58,7 @@ Session inbox persistence and the task put occur in separate persistence systems
**Keep the mock's hourly choice out of the stored vocabulary.** The approved mock lists 每小时整点 beside the daily, weekly and one-shot choices. Hourly and fixed-rate intervals are the same elapsed-time rule, so the card maps that menu slot onto the existing interval kind instead of adding a persisted kind. A dedicated hourly kind would need a new durable variant, its decoder, its target resolution, and its tool schema, all to express what an interval already expresses.
-Task discovery and timers do not depend on loaded Sessions. The after, absolute-time, and fixed-rate selectors retain their semantics alongside Daily, Weekly, and Cron; reading existing saved tasks does not rewrite them. The Host must have its ordinary Session persistence and recovery services available, and the Web bundle mounts Schedule in its host row list. Explicit deletion stops future delivery and removes the task from `list` and `catalog` without deleting conversation messages; the removed row takes its saved receipts with it. History is pruned to the configured window and record cap when an acknowledgment is appended, loaded into Host memory, and rewritten with the task on each acknowledgment. Pagination limits the returned record count; the plugin configuration (`deliveryHistoryDays`, default 30, and `deliveryHistoryRecords`, default 200) limits storage growth. Earlier overwritten receipts and deliveries whose task write failed cannot be reconstructed. Pause and execution-history operations remain unsupported.
+Task discovery and timers do not depend on loaded Sessions. The after, absolute-time, and fixed-rate selectors retain their semantics alongside Daily, Weekly, and Cron; reading existing saved tasks does not rewrite them. The Host must have its ordinary Session persistence and recovery services available, and enabling the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` mounts Schedule; the Web composition carries its row disabled. Explicit deletion stops future delivery and removes the task from `list` and `catalog` without deleting conversation messages; the removed row takes its saved receipts with it. History is pruned to the configured window and record cap when an acknowledgment is appended, loaded into Host memory, and rewritten with the task on each acknowledgment. Pagination limits the returned record count; the plugin configuration (`deliveryHistoryDays`, default 30, and `deliveryHistoryRecords`, default 200) limits storage growth. Earlier overwritten receipts and deliveries whose task write failed cannot be reconstructed. Pause and execution-history operations remain unsupported.
Required coverage includes startup from stored tasks, missing-status normalization, cold Session restoration, latest-only catch-up, inactive-task retention without repeat dispatch, real message receipts, deletion across restart, removal of a deleted task's saved deliveries, queue/persistence failures, and disposal. Wall-clock obligations include gaps and skipped dates, earlier-only overlaps, fractional seconds, UTC-range edges, committed-target and alias preservation, and post-restoration clock rollback. Weekly adds weekday normalization and validation, next-target selection across a gap and an overlap, and stored set preservation. Historical decoders must reject Daily, Weekly, and Cron while Host storage accepts them. Client coverage includes every recurring kind, exact rule time, zone, and weekday set, All/Enabled/Inactive status filters, retained inactive details and original-Session navigation, authoritative refresh after confirmed deletion and notification, empty lists, reconnect, and late responses. Presentation coverage includes the page's New action starting a Session, the transcript card's narrowing of persisted result JSON including an incomplete or replayed result, the header clock's direct open for exactly one task and its count label, the idle-only row mark with the hover-card two-task limit and omission line. History coverage includes legacy byte preservation, append and restart, immutable sent instructions, strict latest-receipt consistency, stable cursor pagination, binding checks, failed persistence, and late page replies during refresh or task changes. Cross-store crash duplication remains an explicit limitation rather than a completion claim.
diff --git a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.zh.md b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.zh.md
index 393a773fa0..f7b9a8f889 100644
--- a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.zh.md
@@ -58,7 +58,7 @@ Session 收件箱持久化和任务 put 发生在两个持久化系统中。收
**不把 mock 的「每小时整点」引入持久词表。** 设计稿把它与每天、每周、仅一次并列。每小时整点与固定间隔在语义上是同一种按经过时间计量的规则,因此详情里的该项直接映射到既有的间隔类型,而不是新增一个持久种类。若真要新增,需要新的持久变体、解码器、目标解析与工具 schema,而它表达的能力现有间隔已经覆盖。
-任务发现和 timer 不依赖已加载的 Session。延迟、绝对时间和固定速率选择器在与 Daily、Weekly、Cron 并存时保留自身语义;读取已有任务不会重写它们。Host 必须提供普通 Session 持久化和恢复服务,Web bundle 在其宿主行列表中挂载 Schedule。显式删除会阻止后续投递,并将任务从 `list` 和 `catalog` 中移除,不删除对话消息;被移除的行会带走其已保存回执。历史在追加确认时按配置的窗口与条数上限裁剪,随后加载到 Host 内存,并在每次确认时随任务一起重写。分页限制返回记录数量,不限制存储增长或指令字节数。此前被覆盖的回执,以及任务写入失败的投递记录,无法重建。暂停和执行历史操作仍不受支持。
+任务发现和 timer 不依赖已加载的 Session。延迟、绝对时间和固定速率选择器在与 Daily、Weekly、Cron 并存时保留自身语义;读取已有任务不会重写它们。Host 必须提供普通 Session 持久化和恢复服务,而挂载 Schedule 需要启用可选实验 bundle `@deepseek-ai/dsh-experimental-schedule-bundle`;Web 组合以禁用状态携带其条目。显式删除会阻止后续投递,并将任务从 `list` 和 `catalog` 中移除,不删除对话消息;被移除的行会带走其已保存回执。历史在追加确认时按配置的窗口与条数上限裁剪,随后加载到 Host 内存,并在每次确认时随任务一起重写。分页限制返回记录数量,不限制存储增长或指令字节数。此前被覆盖的回执,以及任务写入失败的投递记录,无法重建。暂停和执行历史操作仍不受支持。
必要覆盖范围包括从已存储任务启动、缺失状态规范化、冷 Session 恢复、仅补发最新发生时点、保留未运行任务且不重复投递、真实消息回执、重启后的删除结果、删除会移除已保存发送记录、排队或持久化失败,以及销毁。钟表规则的验证要求包括缺口和跳过的日期、仅选择较早时点的重叠、小数秒、UTC 范围边缘、已提交目标和别名保留,以及恢复后的时钟回拨。Weekly 另需覆盖星期集合的规范化与校验、跨缺口与重叠的下一目标选择,以及已存集合的保留。历史解码器必须拒绝 Daily、Weekly 与 Cron,而宿主存储接受它们。客户端覆盖范围包括每种重复规则、精确规则时间、时区和星期集合、全部/已开启/未运行状态筛选、保留未运行任务详情和原 Session 导航、确认删除和通知后的权威刷新、空列表、重新连接和晚到响应。呈现覆盖范围包括页面“新建”操作打开新会话、transcript 卡片从不完整或回放的结果 JSON 中收窄任务、页头时钟在恰好一个任务时直接打开并给出数量标签、仅空闲行标记及其悬浮卡片两个任务上限与遗漏行。历史覆盖范围包括旧文件字节保留、追加与重启、不可变的发送指令、最近回执一致性严格校验、稳定游标分页、绑定校验、持久化失败,以及刷新或切换任务期间晚到的分页响应。跨存储崩溃导致的重复投递仍是明确限制,不能被表述为完成保证。
diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml
index e8573b2e72..521567dd7e 100644
--- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml
@@ -9,11 +9,11 @@
en: 2ba6c43b20a3d347
zh: 5647179ddaa38ad9
/agent-note-ship-experimental-capabilities-as-optional-bundles/decision:
- en: 1c5a43b3ce2d3056
- zh: b59f549a12cf67d1
+ en: fdd54ede661f7a83
+ zh: a41ed1a872bb8ba3
/agent-note-ship-experimental-capabilities-as-optional-bundles/alternatives-considered:
en: a65d84004e48b3ef
zh: 17221f08e7a8a89b
/agent-note-ship-experimental-capabilities-as-optional-bundles/consequences:
- en: d52ed0a248131a37
- zh: 779b8d415281c5da
+ en: e7fb2a93a89ba9b6
+ zh: 37ed9f0a3ca44583
diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md
index a2a83645c9..b7139f6d9a 100644
--- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md
+++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md
@@ -10,7 +10,7 @@ The Web plugin page offered two optional bundles, Agent Teams and voice input. A
## Decision
-`OPTIONAL_BUNDLES` lists Agent Teams, voice input, and Auto review. Every optional bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`, so the Official group renders a localized title, description, and image; `verify-default-product-isolation` rejects an optional bundle without them. The Inspector's `cordis.patch.yml` names the package instead of a built-artifact path, so the same file is the bundle patch and the `--patch` overlay for a built Web launch.
+`OPTIONAL_BUNDLES` lists Agent Teams, voice input, Auto review, and the Schedule bundle. Every optional bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`, so the Official group renders a localized title, description, and image; `verify-default-product-isolation` rejects an optional bundle without them. The Inspector's `cordis.patch.yml` names the package instead of a built-artifact path, so the same file is the bundle patch and the `--patch` overlay for a built Web launch.
An optional bundle is a runtime dependency of the installation, so its dependency graph is downloaded by every `dsh` install. The list therefore admits only packages whose graph is already in the installation's closure; Auto review adds nothing. The Inspector stays available through explicit installation and does not appear in the default plugin list. The browser-use and computer-use providers stay explicit compositions: Playwright MCP, Chrome DevTools MCP, and the native Cua Driver runtime binaries add about 85 MB and 21 packages to every install whether or not the bundle is enabled, and a shipped Cua Driver MCP switch would offer a capability whose executable the installation does not carry. Those provider packages keep locale display metadata for their component rows. Two more packages stay out for other reasons: `ptc-runtime-python` replaces the PTC runtime, and `workflow-ptc` rejects a non-TypeScript runtime at load while the Web presets carry `workflow-ptc` rows a bundle patch cannot reach; `browser-use-stagehand-native` requires a native model name and API key at schema validation with no configuration form on the page.
@@ -22,4 +22,4 @@ An optional bundle is a runtime dependency of the installation, so its dependenc
## Consequences
-The Official group grows from two to three entries, each tagged experimental by its experimental name. The runtime dependency closure of the installation is unchanged. A browser-use or computer-use provider still needs its Service Definition and the provider in a profile patch or composition.
+The Official group carries four optional-bundle entries, each tagged experimental by its experimental name. The runtime dependency closure of the installation is unchanged. A browser-use or computer-use provider still needs its Service Definition and the provider in a profile patch or composition.
diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md
index 62e248e5ae..af4048bc5e 100644
--- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md
@@ -10,7 +10,7 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r
## 决策
-`OPTIONAL_BUNDLES` 列出 Agent Teams、语音输入与 Auto review。每个可选 bundle 都声明 `icon` 并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`,官方分组因此能渲染本地化标题、描述和图片;`verify-default-product-isolation` 会拒绝缺少这些声明的可选 bundle。Inspector 的 `cordis.patch.yml` 直接写包名而非构建产物路径,所以同一文件既是 bundle patch,也是已构建 Web 启动的 `--patch` overlay。
+`OPTIONAL_BUNDLES` 列出 Agent Teams、语音输入、Auto review 与 Schedule bundle。每个可选 bundle 都声明 `icon` 并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`,官方分组因此能渲染本地化标题、描述和图片;`verify-default-product-isolation` 会拒绝缺少这些声明的可选 bundle。Inspector 的 `cordis.patch.yml` 直接写包名而非构建产物路径,所以同一文件既是 bundle patch,也是已构建 Web 启动的 `--patch` overlay。
可选 bundle 是安装的运行时依赖,其依赖图会随每次 `dsh` 安装一起下载。因此名单只接纳依赖图已在安装闭包内的包;Auto review 不增加任何依赖。Inspector 保留显式安装方式,不在默认插件列表中显示。浏览器操作与电脑操作提供方仍保持显式组合:Playwright MCP、Chrome DevTools MCP 与原生 Cua Driver 运行时二进制会让每次安装多出约 85 MB、21 个包,无论 bundle 是否启用;而随附的 Cua Driver MCP 开关会提供一个安装本身并不携带其可执行文件的能力。这些提供方包保留 locale 显示元数据,供组件行使用。另有两个包因其他原因不纳入:`ptc-runtime-python` 会替换 PTC 运行时,而 `workflow-ptc` 在加载时拒绝非 TypeScript 运行时,且 Web preset 内含 bundle patch 无法触及的 `workflow-ptc` 行;`browser-use-stagehand-native` 在 schema 校验时就要求原生模型名称与 API 密钥,而插件页没有对应的配置表单。
@@ -22,4 +22,4 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r
## 后果
-官方分组从两项增至三项,每项都因实验包名而带实验性标签。安装的运行时依赖闭包保持不变。浏览器操作或电脑操作提供方仍需在 profile patch 或组合中同时挂载其 Service Definition 与提供方。
+官方分组包含四个可选 bundle 条目,每项都因实验包名而带实验性标签。安装的运行时依赖闭包保持不变。浏览器操作或电脑操作提供方仍需在 profile patch 或组合中同时挂载其 Service Definition 与提供方。
diff --git a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml
new file mode 100644
index 0000000000..ca6497cb81
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-09-24-schedule-opt-in-optional-bundle.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md
+/agent-note-schedule-as-an-opt-in-optional-bundle:
+ en: 8c2381fd778a9888
+ zh: 8877255cb980eea1
+/agent-note-schedule-as-an-opt-in-optional-bundle/problem:
+ en: bb23c2f7f93a462b
+ zh: 9fb600fc8e203323
+/agent-note-schedule-as-an-opt-in-optional-bundle/decision:
+ en: 483d1dd51b9cc073
+ zh: b069e2e2385c8132
+/agent-note-schedule-as-an-opt-in-optional-bundle/alternatives-considered:
+ en: 8e5f4d8fa8c2e78d
+ zh: 3353c7042c6a1d6b
+/agent-note-schedule-as-an-opt-in-optional-bundle/consequences:
+ en: 36cd0eb71abeedbe
+ zh: 7c134095cc19fcfd
diff --git a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md
new file mode 100644
index 0000000000..865f97848d
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md
@@ -0,0 +1,35 @@
+# Agent Note: Schedule as an opt-in optional bundle
+
+Status: implemented
+
+English | [中文](2026-09-24-schedule-opt-in-optional-bundle.zh.md)
+
+## Problem
+
+Schedule adds four tool schemas to every live root Agent request and one durable clock message per eligible step, so the shipped Web composition must not mount it by default. A person using the shipped Web profile still needs a product-surface switch to turn it on; a profile patch layer or a `--patch` overlay is a configuration file that person cannot reach.
+
+## Decision
+
+`packages/bundle/web-app/cordis.patch.yml` carries none of `time-context`, `schedule`, and `ui-schedule`, so the shipped Web composition exposes no Schedule surface: no `schedule_*` tools, no Session reminder catalog, no Automation tasks page, and no per-step clock reading.
+
+`@deepseek-ai/dsh-experimental-schedule-bundle` (`packages/experimental/schedule-bundle/`) inserts the three rows in its `cordis.patch.yml` and depends on their packages, as every other optional bundle inserts the rows it ships. `OPTIONAL_BUNDLES` in `packages/boot/app-boot/src/profile.ts` names the package, and `apps/cli` declares it as a runtime dependency, so every installation ships it switched off. The [experimental-as-optional-bundles decision](2026-09-21-experimental-capabilities-as-optional-bundles.md) owns the `OPTIONAL_BUNDLES` conventions and the localized `icon` and `meta.title` / `meta.description` metadata the Web Plugins page renders in its Official group.
+
+Enabling the bundle inserts `time-context` (a per-step clock reading with the sampled instant, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message), `schedule` (durable reminders plus `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` on live root Agents), and `ui-schedule` (the Session reminder catalog and the Automation tasks page). The bundle carries `time-context` with `schedule` because a reminder request states a wall-clock target: the sampled instant is what lets the model turn a request such as "tomorrow at nine" into an offset-bearing `at` value. The [Schedule subsystem](../../../../docs/subsystems/schedule.md) owns the durable records, delivery, and management operations; the [Web bundle](../../../../packages/bundle/web-app/README.md) owns the composition the bundle adds the rows to. Disabling the bundle restores the shipped composition, and the Host stops scheduling while the `schedule` row is absent; stored task records remain in the Schedule domain.
+
+## Alternatives considered
+
+**Ship Schedule on by default.** Every Web session then pays four tool schemas in each request header and one durable user message per eligible step, and a conversation that never creates a reminder pays both costs. The arrangement also left the capability without an off switch on the product surface.
+
+**Keep a `--patch` overlay file.** An overlay is a launch-time argument rather than a product-surface switch, so a person using the shipped Web profile cannot reach it. Re-declaring the rows through its `insert` list does not override them: `applyEntryPatches` appends that list without de-duplicating ids, and the Loader collapses the composed list to one entry per id with the last declaration winning, so the overlay replaces the Web bundle's rows instead of setting fields on them.
+
+**Keep the rows in the Web composition with `disabled: true` and switch them on with id-targeted patches.** The bundle would then set fields on existing rows, but the plugin manager lists only the rows a bundle inserts, so the bundle's page reported no components and needed a second listing path for overridden rows, while every other optional bundle inserts its rows. A profile that also declares one of the ids resolves the same way as for any other bundle: the Loader keeps the last declaration.
+
+**Lock the three rows to the bundle switch.** A bundle manifest field that makes the plugin manager refuse to switch the rows one by one adds a plugin-manager concept only this bundle would use. The bundle's page offers a switch per row, as for every other bundle, and the bundle README records that the three rows work only together.
+
+## Consequences
+
+- A default `dsh web` session carries no Schedule tool schemas, no Session reminder catalog, no Automation tasks page, and no per-step clock reading. An installation that wants them enables the optional bundle from the Plugins page or lists the package in a profile's `dsh.profile.bundles`.
+- An enabled installation adds four tool schemas to every live root Agent and one durable clock message per eligible step; the reading is model-visible and durable, so it replays, compacts, and appears in exported Session logs like any other user message.
+- The Plugins page lists the three rows under the bundle with the titles their packages' `locale/*.json` declare, their state, and, while the bundle is on, a switch per row; switching one of them off leaves Schedule without that part.
+- A profile patch or `--patch` overlay that targets one of the three ids matches no row while the bundle is not selected, and the loader warns `patch: entry not found` for it; selecting the bundle replaces switching the rows on by id.
+- The switch is configuration-only: `src/index.ts` is an empty module, the patch carries the runtime content, and the package owns no mutable runtime state, so it publishes no invariant companion.
diff --git a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md
new file mode 100644
index 0000000000..a90516606a
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: Schedule 作为按需开启的可选 bundle
+
+Status: implemented
+
+[English](2026-09-24-schedule-opt-in-optional-bundle.md) | 中文
+
+## Problem
+
+Schedule 会给每个活跃根 Agent 的请求增加四个工具 schema,并在每个符合条件的步骤追加一条持久时钟消息,因此随发行版交付的 Web 组合不能默认挂载它。使用随发行版 Web profile 的人仍需要产品界面上的开关来打开它;profile patch 层或 `--patch` overlay 都是这个人无法触及的配置文件。
+
+## Decision
+
+`packages/bundle/web-app/cordis.patch.yml` 不含 `time-context`、`schedule` 与 `ui-schedule` 中的任何一行,因此随发行版交付的 Web 组合不提供任何定时能力:没有 `schedule_*` 工具、没有 Session 提醒目录、没有自动化任务页面,也没有逐步时钟读数。
+
+`@deepseek-ai/dsh-experimental-schedule-bundle`(`packages/experimental/schedule-bundle/`)在其 `cordis.patch.yml` 中插入这三行并依赖它们的包,与其他可选 bundle 插入各自随附的行相同。`packages/boot/app-boot/src/profile.ts` 的 `OPTIONAL_BUNDLES` 列出该包,`apps/cli` 将其声明为运行时依赖,因此每次安装都随包携带且默认关闭。[实验性能力作为可选 bundle 的决策](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)负责 `OPTIONAL_BUNDLES` 的约定,以及 Web 插件管理页在“官方”分组中渲染的本地化 `icon` 与 `meta.title` / `meta.description` 元数据。
+
+启用该 bundle 会插入 `time-context`(每个步骤的时钟读数,包含采样瞬时、附加到当前开放请求的浏览器时区,以及自前一条模型可见消息以来的经过时长)、`schedule`(持久提醒,以及每个活跃根 Agent 上的 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`)与 `ui-schedule`(Session 提醒目录与自动化任务页面)。该 bundle 把 `time-context` 与 `schedule` 一起携带,因为提醒请求给出的是挂钟时间目标:采样瞬时正是让模型把“明天九点”这类请求转换成带偏移量的 `at` 值的依据。持久记录、投递与管理操作由 [Schedule 子系统](../../../../docs/subsystems/schedule.zh.md)负责;该 bundle 向其加入这些行的组合由 [Web bundle](../../../../packages/bundle/web-app/README.zh.md) 负责。禁用该 bundle 会恢复随发行版交付的组合,`schedule` 行不存在期间 Host 不再调度;已存储的任务记录保留在 Schedule domain 中。
+
+## Alternatives considered
+
+**默认发布 Schedule。** 那样每个 Web 会话都要在每个请求头中带上四个工具 schema,并在每个符合条件的步骤追加一条持久 user 消息,从不创建提醒的对话也要承担这两项成本。这种安排还让该能力在产品界面上没有关闭开关。
+
+**保留一个 `--patch` overlay 文件。** overlay 是启动时的参数,而不是产品界面上的开关,因此使用随发行版 `web` profile 的人无法触及它。通过它的 `insert` 列表重新声明这些条目并不能覆盖它们:`applyEntryPatches` 追加该列表时不对 id 去重,而 Loader 会把组合出的列表按 id 收敛为一个条目、最后一个声明生效,因此该 overlay 会替换 Web Bundle 的条目,而不是给它们设值。
+
+**把这三行以 `disabled: true` 留在 Web 组合中,用按 id 定位的 patch 打开。** 这样 bundle 只是给已有的行设值,但插件管理器只列出 bundle 插入的行,因此该 bundle 的页面显示不含任何组件,需要为被覆盖的行另建一条列出路径;而其他可选 bundle 都插入各自的行。某个 profile 若也声明了其中某个 id,处理方式与任何其他 bundle 相同:Loader 保留最后一份声明。
+
+**把这三行锁定在 bundle 开关上。** 让插件管理器拒绝逐行开关的 bundle 清单字段,会给插件管理器增加一个只有这个 bundle 使用的概念。该 bundle 的页面像其他所有 bundle 一样为每行提供开关,由 bundle README 说明这三行只能一起工作。
+
+## Consequences
+
+- 默认的 `dsh web` 会话不带 Schedule 工具 schema、Session 提醒目录、自动化任务页面与逐步时钟读数。需要它们的安装可从插件管理页启用该可选 bundle,或把该包列进 profile 的 `dsh.profile.bundles`。
+- 启用后的安装会给每个活跃根 Agent 增加四个工具 schema,并在每个符合条件的步骤追加一条持久时钟消息;该读数对模型可见且持久,因此它与其他 user 消息一样参与回放、压缩,并出现在导出的 Session 日志中。
+- 插件管理页在该 bundle 下列出这三行,显示各自包的 `locale/*.json` 声明的标题、它们的状态,并在 bundle 打开时为每行提供开关;关掉其中一行会让 Schedule 缺少这一部分。
+- 未选中该 bundle 时,按 id 定位这三行之一的 profile 补丁或 `--patch` overlay 匹配不到任何条目,加载器会为它报告 `patch: entry not found` 警告;选中该 bundle 即可取代按 id 打开这些行。
+- 该开关只涉及配置:`src/index.ts` 是空模块,运行时内容由 patch 承载,包本身不拥有可变的运行时状态,因此不发布不变量伴随模块。
diff --git a/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.i18n.yaml
deleted file mode 100644
index fec9a85467..0000000000
--- a/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.i18n.yaml
+++ /dev/null
@@ -1,22 +0,0 @@
-# Bilingual-pair consistency record for 2026-09-24-web-default-schedule-composition.md (docs/i18n/README.md): per heading
-# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
-# After editing either side, bring the other along and re-record with:
-# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.md
-/agent-note-schedule-in-the-shipped-web-composition:
- en: 39229d7ab6ed433b
- zh: 54287471d371a11c
-/agent-note-schedule-in-the-shipped-web-composition/problem:
- en: 2871fd5ed881c007
- zh: d03c78d402d186da
-/agent-note-schedule-in-the-shipped-web-composition/decision:
- en: bb19ad12b2ab0ffc
- zh: 971f33e95474a36c
-/agent-note-schedule-in-the-shipped-web-composition/recorded-sessions:
- en: 5539a42052f4ddb7
- zh: 98cc190ae63b4549
-/agent-note-schedule-in-the-shipped-web-composition/alternatives-considered:
- en: ca032a407c2ee8cb
- zh: 748a4fcf2485a3da
-/agent-note-schedule-in-the-shipped-web-composition/consequences:
- en: 4f23bda8fda37987
- zh: 9f7c4a3538bd2979
diff --git a/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.i18n.yaml
new file mode 100644
index 0000000000..15f69188be
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-09-25-session-log-otel-byte-limits.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.md
+/agent-note-session-log-otlp-strings-and-bounded-requests:
+ en: 041f7e2b55202ea3
+ zh: e01834ae6c9fc7bc
+/agent-note-session-log-otlp-strings-and-bounded-requests/problem:
+ en: 20f659a15fb55352
+ zh: 49d39c95da1b8aa5
+/agent-note-session-log-otlp-strings-and-bounded-requests/decision:
+ en: 0f0866232326658b
+ zh: aa608d17ba712d8a
+/agent-note-session-log-otlp-strings-and-bounded-requests/alternatives-considered:
+ en: 0dc933b18a1a3573
+ zh: 77655740322c4e45
+/agent-note-session-log-otlp-strings-and-bounded-requests/consequences:
+ en: 3859569c9cdea30c
+ zh: 517449b8c49807f6
diff --git a/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.md b/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.md
new file mode 100644
index 0000000000..57fbe72dc5
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.md
@@ -0,0 +1,29 @@
+# Agent Note: Session-log OTLP strings and bounded requests
+
+Status: implemented
+
+English | [中文](2026-09-25-session-log-otel-byte-limits.zh.md)
+
+## Problem
+
+Session logs contain nested JSON and large tool results. The product collector accepts named records with string content and limits request size; count-only SDK batching cannot bound bytes after UTF-8 encoding and JSON escaping.
+
+## Decision
+
+The shared `otel` Cordis plugin owns ordinary-event and Session-log transport implementations. Business adapters inject it and create independent caller-owned channels; their existing UI/RPC entry points retain authorization, redaction, identity, configuration, and bounded shutdown. Mounting the shared service creates no unused queues. Each canonical event becomes `eventName: "session-log"`, with `sessionId` and JSON-stringified event values in `content`; serialization does not preserve JSONL key ordering. Redaction runs before serialization and the copied envelope excludes the payload.
+
+Session logs use a separate provider and byte/count queue. Each record is measured once with the SDK serializer including a full envelope; the sum conservatively bounds a combined request. Greedy packing avoids full-candidate serialization and recursive re-encoding. Single oversized records receive one diagnostic and do not block later events. One HTTP request owns the transport slot until settlement, even after its watchdog fires; shutdown expiry stops the remaining queue. Scope name/version and explicit transport options remain owned by the backend.
+
+The [telemetry revival](../feature/2026-07-23-session-telemetry-otel-revival.md) remains authoritative for redaction, capture, and best-effort handoff. This decision replaces its SDK-only queue/batching rule: SDK batch deadlines cannot own an operation split into multiple HTTP requests. The shared Session-log channel schedules complete requests; the SDK still owns transport/retry. Feedback authorization and the separate DeepSeek model-request contribution remain unchanged.
+
+## Alternatives considered
+
+**One string for the entire Session.** Rejected because one large Session would exceed the request ceiling even when each event fits. One event per record preserves event identity and permits batching.
+
+**Truncate oversized records or add client-only fragments.** Rejected because truncation loses content and fragments require a collector reassembly protocol. Explicit rejection retains the one-event-per-record meaning.
+
+**Count compressed bytes or characters.** Rejected because collector limits can apply after decompression, and characters omit UTF-8 and escaping overhead. Measuring full uncompressed requests gives a conservative bound.
+
+## Consequences
+
+Product and Session records never share a request or queue, and neither package imports the other. Conservative per-record envelope accounting can produce smaller batches than exact combined accounting. Queue overflow, oversize rejection, exit, and transport failure can lose data; no durable outbox or acknowledgement is added. Tests cover byte limits, linear measurement, slow-request serialization, metadata, redaction-copy isolation, and feedback-authorized replay.
diff --git a/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.zh.md b/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.zh.md
new file mode 100644
index 0000000000..f6e8306978
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: Session 日志的 OTLP 字符串与有界请求
+
+Status: implemented
+
+[English](2026-09-25-session-log-otel-byte-limits.md) | 中文
+
+## 问题
+
+Session 日志包含嵌套 JSON 和大型工具结果。产品采集端接收带名称和字符串内容的记录,并限制请求大小;仅按数量分批的 SDK 无法约束 UTF-8 编码与 JSON 转义后的字节数。
+
+## 决策
+
+共享 `otel` Cordis 插件负责普通事件和 Session 日志的传输实现。业务适配器注入它并创建独立、由调用方持有的通道;现有 UI/RPC 入口保留授权、脱敏、身份、配置和有期限的关闭。只挂载共享服务不会创建无用队列。每条规范事件转换为 `eventName: "session-log"`,带有 `sessionId` 和在 `content` 中 JSON 字符串化的事件值;序列化不保留 JSONL 键顺序。脱敏先于序列化,复制的 envelope 不包含 payload。
+
+Session 日志使用独立 provider 和字节/条数队列。每条记录使用 SDK 序列化器计量一次,包含完整 envelope;其总和保守约束合并请求大小。顺序组包避免候选批次的完整序列化和递归重复编码。单条超限记录只产生一次诊断,不阻塞后续事件。每个 HTTP 请求在结束前始终占有传输槽位,即使其监测期限已经触发;关闭期限到期后停止剩余队列。scope 名称/版本和显式传输选项仍由后端拥有。
+
+[遥测恢复决策](../feature/2026-07-23-session-telemetry-otel-revival.zh.md) 继续约束脱敏、捕获和尽力交接。本决策替代其仅使用 SDK 队列/聚合的规则:SDK 批次期限无法拥有拆成多个 HTTP 请求的操作。共享 Session 日志通道调度完整请求,SDK 仍负责传输和重试。反馈授权和独立的 DeepSeek 模型请求附加路径不变。
+
+## 考虑过的替代方案
+
+**整个 Session 合为一个字符串。** 拒绝,因为即使每条事件都能放入请求,大型 Session 仍会超过请求上限。每条事件对应一条记录,既保留事件身份又允许分批。
+
+**截断超限记录或仅在客户端分片。** 拒绝,因为截断会丢失内容,而分片需要采集端重组协议。显式拒绝保留一条事件对应一条记录的含义。
+
+**计算压缩字节数或字符数。** 拒绝,因为采集端限制可能作用于解压后内容,而字符数不包含 UTF-8 和转义开销。计量完整未压缩请求提供保守上限。
+
+## 影响
+
+产品事件和 Session 记录不共享请求或队列,两个包也不相互导入。保守的逐记录 envelope 计量可能比精确合并计量产生更小批次。队列溢出、超限拒绝、退出和传输失败可能丢失数据;不新增持久 outbox 或确认机制。测试覆盖字节上限、线性计量、慢请求串行调度、元数据、脱敏复制隔离和反馈授权回放。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.i18n.yaml
new file mode 100644
index 0000000000..727bb883f4
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-09-19-failed-step-tool-results.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.md
+/agent-note-record-unanswered-tool-results-before-closing-a-failed-step:
+ en: 8fd2a50a041b1b18
+ zh: 01aed07a6425a322
+/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/problem:
+ en: 1fa7525f83af9035
+ zh: 401ce968e3dd5434
+/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/decision:
+ en: 3d58367a05183b4a
+ zh: f9ccc3a7011a6240
+/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/alternatives-considered:
+ en: 37da4d9c8533b736
+ zh: e52a59763ae2b62e
+/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/consequences:
+ en: 1ea72421a98551d8
+ zh: c01e02ddac8a3454
diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.md b/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.md
new file mode 100644
index 0000000000..80c813598e
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.md
@@ -0,0 +1,33 @@
+# Agent Note: Record unanswered tool results before closing a failed step
+
+Status: implemented
+
+English | [中文](2026-09-19-failed-step-tool-results.zh.md)
+
+## Problem
+
+A committed assistant message can request tools whose scheduler fails before all results are recorded. Closing the step and turn with those calls unanswered makes later model requests invalid for providers that require paired tool calls and results. An error turn is already closed, so crash-tail repair cannot recover it on resume.
+
+## Decision
+
+The AgentLoop driver records recovery results for unanswered calls before it closes a failed step, then propagates the original failure. Live step recovery and `interruptedTurnClosers` share incremental pending-call tracking and result construction; the live driver still owns its step and turn events. A step-local observer retains pending identities from committed events without copying session history. The scheduler stops replenishment and drains started dispatches before rejecting, so result recovery cannot race a live dispatch.
+
+Recovery preserves committed results and follows the assistant's call order. A recorded `tool/call` without a result receives `TOOL_OUTCOME_UNKNOWN`; its text warns against blindly retrying an operation with possible side effects. An assistant request with no call record receives `TOOL_NOT_STARTED`. A call record precedes preparation and does not establish that the tool body ran, so unknown outcomes remain conservative.
+
+This decision supersedes only the missing-result policy in [parallel tool-call execution](../feature/2026-07-10-parallel-tool-call-execution.md). Its classification, concurrency limit, ordering, and quiescence rules remain applicable.
+
+## Alternatives considered
+
+**Leave calls unanswered to avoid inventing tool outcomes.** An unanswered call invalidates later provider requests. An explicit unknown-outcome error records the limit of the available evidence without claiming success, cancellation, or absence of side effects.
+
+**Repair only when resuming or forking.** The ordinary failure path already records `step/end` and `turn/end`; open-tail repair intentionally preserves closed history. Recovery belongs before those records, while the driver owns the failed step.
+
+**Repair inside the tool scheduler alone.** Failure can occur after the assistant message is recorded but before scheduling starts. The step driver covers that interval and reuses the existing session repair semantics rather than adding a second implementation of call/result matching.
+
+**Retry automatically.** A dispatch can have side effects even when its result is missing. The error result supplies retry guidance, while the turn retains the original failure and waits for later work.
+
+## Consequences
+
+Later requests, resume, and fork retain a usable call/result history after a live step failure. Recovery appends results; it does not remove assistant messages, replace completed results, execute tools, or convert the failed turn into success. The log uses existing event types and recovery codes, with no format change.
+
+Already-closed inconsistent historical turns remain unchanged. The live recovery path requires the session to accept the missing result records; it cannot promise recovery from a failed log append.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.zh.md
new file mode 100644
index 0000000000..7d69b46a9a
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: 在关闭失败步骤前补齐工具结果
+
+Status: implemented
+
+[English](2026-09-19-failed-step-tool-results.md) | 中文
+
+## 问题
+
+已提交的 assistant 消息可能请求多个工具,而调度器在全部结果记录完成之前失败。如果关闭步骤和轮次时仍有调用尚无结果,后续模型请求就不满足要求工具调用与结果配对的提供方。错误轮次已经关闭,因此恢复会话时的崩溃尾部修复无法恢复它。
+
+## 决策
+
+AgentLoop 驱动器在关闭失败步骤之前,为尚无结果的调用记录恢复结果,随后传播原始失败。实时步骤恢复和 `interruptedTurnClosers` 共享增量的待处理调用跟踪与结果构造;实时驱动器仍拥有自己的步骤和轮次事件。步骤本地观察器从已提交事件中保留待处理调用标识,不复制会话历史。调度器在拒绝之前停止补充池并排空已启动的派发,因此结果恢复不会与运行中的派发竞争。
+
+恢复保留已提交的结果,并遵循 assistant 的调用顺序。已有 `tool/call` 记录但尚无结果的调用获得 `TOOL_OUTCOME_UNKNOWN`;其文本警告不要盲目重试可能存在副作用的操作。没有调用记录的 assistant 请求获得 `TOOL_NOT_STARTED`。调用记录先于准备阶段,不能证明工具主体已经运行,因此未知结果保持保守。
+
+本决策仅取代[并行工具调用执行](../feature/2026-07-10-parallel-tool-call-execution.zh.md)中的缺失结果策略。其中的分类、并发上限、顺序和完全停稳规则继续适用。
+
+## 备选方案
+
+**让调用保持无结果,避免虚构工具执行结果。** 尚无结果的调用会使后续提供方请求无效。显式的未知结果错误记录可用证据的局限,而不声称成功、取消或没有副作用。
+
+**仅在恢复或 fork 时修复。** 普通失败路径已经记录 `step/end` 和 `turn/end`;开放尾部修复有意保留已关闭历史。恢复应在这些记录之前执行,此时驱动器仍拥有失败步骤。
+
+**仅在工具调度器内部修复。** 失败可能发生在 assistant 消息记录之后、调度开始之前。步骤驱动器覆盖这段区间,并复用已有的会话修复语义,避免另行实现调用/结果匹配。
+
+**自动重试。** 即使结果缺失,派发也可能产生副作用。错误结果提供重试指引,而轮次保留原始失败并等待后续工作。
+
+## 影响
+
+实时步骤失败后,后续请求、恢复和 fork 会保留可用的调用/结果历史。恢复追加结果;它不移除 assistant 消息、不替换已完成结果、不执行工具,也不将失败轮次改为成功。日志使用已有事件类型和恢复代码,无需格式变更。
+
+此前已关闭的不一致历史轮次保持不变。实时恢复路径要求会话能够接纳缺失结果记录;它不能保证从日志追加失败中恢复。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.i18n.yaml
new file mode 100644
index 0000000000..af95d9cdf7
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.i18n.yaml
@@ -0,0 +1,22 @@
+# Bilingual-pair consistency record for 2026-09-27-pwsh-prompt-tail-grace.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.md
+/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace:
+ en: 5e8c5673a0669d9c
+ zh: c8442a6186288a2a
+/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/problem:
+ en: ac4fb08e3203cee9
+ zh: 69fcfcb4f8fc0319
+/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/decision:
+ en: 653c74442decb65a
+ zh: 46f01c45d48b846b
+/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/alternatives-considered:
+ en: 21a9a19b76f42758
+ zh: 30a8f5425b59900d
+/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/consequences:
+ en: b58904921286a1db
+ zh: a696c2954d86c04f
+/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/testing:
+ en: 78253191eea198fa
+ zh: 32bdd6807ddc13a0
diff --git a/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.md b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.md
new file mode 100644
index 0000000000..0aa25795c1
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.md
@@ -0,0 +1,31 @@
+# Agent Note: A seen prompt marker keeps its own tail grace
+
+Status: implemented
+
+English | [中文](2026-09-27-pwsh-prompt-tail-grace.zh.md)
+
+## Problem
+
+Windows sessions on the self-hosted lane settled one or two of seven sends on the controlled prompt and charged the silence tier for the rest (2026-09-25/26, issue #2487). The exact readiness evidence is the controlled prompt's printable tail after the OSC `133;D` marker, and the bound on waiting for it was `idleSilenceMs + handoffGraceMs`. A native Windows probe shows the render arrives as two pty chunks: the marker alone, then the five-byte `dsh> ` 2-28 ms later (56 renders), with nothing printable after any prompt and no terminal queries in the output. The marker is written by the shell's own prompt function and the tail by the same render, so a tail that arrives past the bound means the host stalled that delivery, not that the prompt is absent. The session settled `inferred_idle` anyway, and each such send cost the tool call roughly three seconds.
+
+## Decision
+
+`dsh-terminal-bash` gains a validated `promptTailGraceMs` configuration field, default `0`. While a send has seen the prompt marker and its printable tail has not arrived, the silence bound becomes `idleSilenceMs + handoffGraceMs + promptTailGraceMs`; every other bound, wait reason, and code path is unchanged. The [persistent PTY readiness design](../feature/2026-07-16-persistent-pty-sessions.md) owns the tier ladder this bound extends. Zero reproduces the previous bound exactly, so an upgrade changes no deployment's behaviour. The persistent pwsh Loader-composition case sets 5000 ms, because the hosts whose stalls the field is for are exactly the ones that run it, and its assertion (at least six `stdin_read` settlements, no `inferred_idle`) then measures the controlled-prompt path instead of the host's delivery timing. `validateConfig` accepts zero for this field — the one numeric bound whose zero is a documented value — and rejects negative or fractional values.
+
+## Alternatives considered
+
+**Raise the default instead of adding a field.** Rejected: it trades one fixed cost for another on every host. A marker whose tail never arrives (a stray marker from a child, a broken prompt function) would then fall back that much later everywhere, while the timing that matters differs per deployment.
+
+**Make the observation sticky: once the exact tail was seen, let later output stand.** Rejected: the tail rule exists to prove the marker was followed by the prompt text rather than by a command's output, and a sticky flag would settle a send while that command is still printing.
+
+**Wait for the tail up to the absolute `timeoutMs` once a marker was seen.** Rejected: a prompt that never completes its render would hang every send until the tool deadline instead of falling back after a bounded silence, and the send budget belongs to the caller.
+
+**Detect the stall in the test and retry the send.** Rejected: retries hide which tier settled a send, and the durable fix belongs where the evidence is interpreted, not where it is measured.
+
+## Consequences
+
+A deployment whose console renderer stalls keeps those sends on the exact path by setting `promptTailGraceMs`; the default path is bit-for-bit the previous bound, so the negative control — the same delayed tail with the field at zero — still settles `inferred_idle`. The tolerance applies only after a marker was seen: a missing prompt keeps the plain silence bound, so the regression the Loader-composition case pins (a lost prompt must fail) is still caught, and a marker whose tail never arrives falls back one configured interval later.
+
+## Testing
+
+`packages/terminal/terminal-bash/tests/session.spec.ts` pins every state with fake timers: with `promptTailGraceMs` set, a marker whose tail arrives after the plain bound stays pending and then settles `stdin_read`; a marker whose tail never arrives falls back to `inferred_idle` at the extended bound; a tail that arrived and was invalidated by later output keeps the plain bound; and with the field at zero the same delayed delivery settles `inferred_idle`. `packages/terminal/terminal-bash/tests/config.spec.ts` accepts zero and rejects negative or fractional values, and rejects a nonzero tolerance shorter than one `pollIntervalMs`. `packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` carries the end-to-end evidence on native Windows: with every session's prompt tail withheld for four seconds the case passes with the configured tolerance — nine settles, eight `stdin_read` and the `exit` command's `session_exit`, in 33.4 s and 37.5 s — and fails with the tolerance patched to zero, where two sends settle `inferred_idle` with `promptSeen` true, `promptTextSeen` false, an empty tail and `idleFor` 3312-3316 ms.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md
new file mode 100644
index 0000000000..feb551d35f
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md
@@ -0,0 +1,31 @@
+# Agent Note: 已看到的提示符标记保留自己的尾部宽限
+
+Status: implemented
+
+[English](2026-09-27-pwsh-prompt-tail-grace.md) | 中文
+
+## Problem
+
+自托管 lane 上的 Windows 会话在七次 send 中只有一到两次按受控提示符结算,其余都要支付静默层(2026-09-25/26,issue #2487)。精确的就绪证据是 OSC `133;D` 标记之后受控提示符的可打印尾部,而等待它的上界是 `idleSilenceMs + handoffGraceMs`。原生 Windows 探测表明该渲染以两个 pty 分块到达:先是标记本身,2–28 毫秒后是 5 字节的 `dsh> `(56 次渲染),任何提示符之后都没有可打印文本,输出中也没有终端查询。标记由 shell 自己的 prompt 函数写出、尾部由同一次渲染写出,因此尾部晚于上界到达意味着宿主机拖慢了这次投递,而不是提示符缺失。会话此前仍会按 `inferred_idle` 结算,每次这样的 send 让工具调用多付约三秒。
+
+## Decision
+
+`dsh-terminal-bash` 新增一个经过校验的 `promptTailGraceMs` 配置字段,默认 `0`。当某次 send 已看到提示符标记而其可打印尾部尚未到达时,静默上界变为 `idleSilenceMs + handoffGraceMs + promptTailGraceMs`;其他上界、等待原因与代码路径均不变。该上界所扩展的层级阶梯由[持久 PTY 就绪设计](../feature/2026-07-16-persistent-pty-sessions.zh.md)拥有。零精确复现此前的上界,因此升级不改变任何部署的行为。持久 pwsh 的 Loader 组合用例设置 5000 毫秒,因为需要该字段的宿主机正是运行该用例的那些,而其断言(至少六次 `stdin_read` 结算、不含 `inferred_idle`)由此度量受控提示符路径,而不是宿主机的投递时序。`validateConfig` 允许该字段取零——这是唯一一个零为文档化取值的数值上界——并拒绝负数与小数。
+
+## Alternatives considered
+
+**不加字段,直接提高默认值。** 不采纳:那是把一种固定成本换成另一种,且对所有宿主机生效。尾部永不到达的标记(子进程打印的杂散标记、损坏的 prompt 函数)会在所有地方都多等那么久,而真正重要的时序因部署而异。
+
+**让观测变为粘性:一旦看到精确尾部,后续输出不再使其失效。** 不采纳:尾部规则的存在就是为了证明标记之后跟着的是提示符文本而不是命令输出;粘性标志会在命令仍在打印时结算该 send。
+
+**看到标记后一直等到绝对 `timeoutMs`。** 不采纳:渲染永不完成的提示符会让每次 send 都挂到工具截止时间,而不是在有界静默后回退,而 send 预算属于调用方。
+
+**在测试里检测停摆并重试该 send。** 不采纳:重试会掩盖究竟是哪一层结算的,而持久的修复属于解释证据的地方,而不是测量证据的地方。
+
+## Consequences
+
+控制台渲染器会停摆的部署通过设置 `promptTailGraceMs` 让这些 send 留在精确路径上;默认路径与此前的上界逐位一致,因此负控——同样的尾部延迟、字段为零——仍然按 `inferred_idle` 结算。该宽限只在看到标记之后生效:提示符缺失时保持普通静默上界,所以 Loader 组合用例所固定的回归(提示符丢失必须失败)仍会被抓住,而尾部永不到达的标记只会晚一个配置区间回退。
+
+## Testing
+
+`packages/terminal/terminal-bash/tests/session.spec.ts` 用假定时器固定每一种状态:设置 `promptTailGraceMs` 时,尾部晚于普通上界到达的标记保持挂起,随后按 `stdin_read` 结算;尾部始终不到时在扩展上界处回落到 `inferred_idle`;尾部已到达但被后续输出失效时保持普通上界;字段为零时,同样的延迟投递按 `inferred_idle` 结算。`packages/terminal/terminal-bash/tests/config.spec.ts` 接受零、拒绝负数或小数,并拒绝非零但短于一个 `pollIntervalMs` 的宽限。`packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` 承载原生 Windows 上的端到端证据:把每个 session 的提示符尾部扣留四秒时,配置了该宽限后用例通过——九次结算、八次 `stdin_read` 加 `exit` 命令的 `session_exit`,耗时 33.4 秒与 37.5 秒;把该宽限改为零后同一停摆失败,其中两次 send 以 `inferred_idle` 结算,`promptSeen` 为 true、`promptTextSeen` 为 false、尾部为空且 `idleFor` 为 3312–3316 毫秒。
diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml
index 4b82a34a31..5867da4d43 100644
--- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml
@@ -12,8 +12,8 @@
en: 532bb7268d9b93ce
zh: ad47162233222ab0
/agent-note-parallel-tool-call-execution-by-per-call-safety/scheduling-and-ordering:
- en: f0906d03fef98e7b
- zh: f9e43c4c832c9a05
+ en: a485c530ea4366de
+ zh: 6f9915d726de9ac7
/agent-note-parallel-tool-call-execution-by-per-call-safety/safety-contract:
en: 16c4ee08b65ead23
zh: df80d6e49982615b
@@ -24,8 +24,8 @@
en: eb5a6aa84980f0ab
zh: eed2680a160e8ed2
/agent-note-parallel-tool-call-execution-by-per-call-safety/alternatives-considered:
- en: 44f8fb5851ba4e39
- zh: 4d75c937ac5277f7
+ en: 17becc038bb7df7c
+ zh: 673d9a9bdfa39edf
/agent-note-parallel-tool-call-execution-by-per-call-safety/consequences:
- en: 5e5b2a1c7d850f83
- zh: b63311f28eb69366
+ en: a5508057bc03f4f6
+ zh: 96b46c59402474c3
diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md
index d5d26e03a2..004d725bfd 100644
--- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md
+++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md
@@ -46,7 +46,7 @@ Only dispatch and the tool body overlap. `tools/pre-execute` and `tools/post-exe
Each started call appends `tool/call` immediately before its pre-execute gate. Completed dispatches occupy model-order slots, and a commit cursor appends `tool/result` and collects `additionalContexts` only when the next slot is ready. Live surfaces may show several pending calls, but results and post-tool context remain model-ordered.
-An abort before a group starts records no calls from that group. An abort during a group stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and then ends the step through the existing abort path. Calls that never start have no audit event. An unexpected scheduler failure stops new dispatches, waits for every already-started dispatch to settle, and rethrows the first failure. Because that failure is terminal internal state rather than a tool outcome, the loop does not invent tool results for rejected or uncommitted calls.
+An abort stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and records synthetic aborted call/result pairs for undispatched calls. An unexpected scheduler failure stops new dispatches, waits for every already-started dispatch to settle, and rethrows the first failure. The driver records missing results before closing the failed step under the [failed-step recovery decision](../bug-fix/2026-09-19-failed-step-tool-results.md).
PTC mode remains outside this scheduler because the model emits one native `run_code` call. `run_code` and its internal dispatch queue remain serial; native sibling calls in `mode: 'both'` use the normal scheduler.
@@ -84,7 +84,7 @@ Snapshot coverage pins the visible multi-call transcript: pending calls may over
**Expose staged methods or a scheduling waterfall.** Public `prepare` / `dispatch` / `finalize` methods or a `tools/execution-mode` event add extension surface before another consumer needs it. The loop uses an internal scheduler view, while `executionMode(exec)` leaves an insertion point for a policy hook.
-**Convert scheduler failures into tool results.** AgentLoop cannot determine whether a rejected dispatch invoked the tool body; ToolRuntime owns body-invocation state and typed tool outcomes. Internal scheduler failures therefore remain terminal instead of being reclassified as `ABORTED` results.
+**Convert scheduler failures into ordinary tool results.** AgentLoop cannot determine whether a rejected dispatch invoked the tool body; ToolRuntime owns body-invocation state and typed tool outcomes. Internal scheduler failures remain terminal instead of being reclassified as `ABORTED` results. Explicit recovery results preserve this uncertainty without leaving unanswered calls.
**Start calls while the model streams.** This may reduce latency further but changes assistant-message authority, replay, and call/result pairing. The scheduler starts only after the assistant message is complete.
@@ -96,7 +96,7 @@ Snapshot coverage pins the visible multi-call transcript: pending calls may over
The design is fail-closed and simple for tool authors, but it cannot exploit concurrency whose safety depends on comparing siblings. A tool that opts in too broadly can expose latent shared-state races.
-Parallel calls may begin in cases where serial execution would have aborted before reaching them. The scheduler therefore records only started calls, drains them on abort, and never starts replacements after cancellation.
+Parallel calls may begin in cases where serial execution would have aborted before reaching them. The scheduler drains them on abort and never starts replacements after cancellation; undispatched calls receive synthetic aborted call/result pairs.
Ordered commits may hold a fast result behind a slow earlier sibling. This preserves replay and model-history order while live surfaces still show pending progress.
@@ -104,4 +104,4 @@ Concurrent external calls can compete for quota or process capacity. Providers o
Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. Already-started calls retain the scheduling decision under which they entered the pool.
-A terminal scheduler failure may leave recorded calls without results before the failed step closes. Waiting for live dispatches preserves quiescence without misreporting those internal failures as tool outcomes.
+A terminal scheduler failure reaches step recovery only after live dispatches settle. Recovery reports uncommitted outcomes as unknown, preserving quiescence and the original failure while keeping later model history usable.
diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md
index 707428985c..e78f56e07c 100644
--- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md
@@ -46,7 +46,7 @@ Status: implemented
每个已启动的调用都会在进入 pre-execute 门禁之前立即追加 `tool/call`。已完成的派发占据模型顺序的槽位;提交游标只有在下一个槽位就绪时,才会追加 `tool/result` 并收集 `additionalContexts`。实时界面可以显示多个待处理调用,但结果和工具执行后的上下文仍按模型顺序排列。
-如果在一组启动前中止,系统不会记录该组的任何调用。如果在一组执行期间中止,系统会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。调度器发生意外故障时,会停止新的派发,等待每项已启动的派发结算,并重新抛出第一个故障。由于该故障是内部终态,而非工具结果,循环不会为被拒绝或未提交的调用虚构工具结果。
+中止会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,并为未派发的调用记录合成的中止调用/结果对。调度器发生意外故障时,会停止新的派发,等待每项已启动的派发结算,并重新抛出第一个故障。驱动器按照[失败步骤恢复决策](../bug-fix/2026-09-19-failed-step-tool-results.zh.md),在关闭失败步骤之前记录缺失结果。
PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_code` 调用。`run_code` 及其内部派发队列仍按串行方式执行;`mode: 'both'` 中的原生并列调用使用常规调度器。
@@ -84,7 +84,7 @@ PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_co
**公开分阶段方法或调度 waterfall(瀑布式事件)。** 公开的 `prepare` / `dispatch` / `finalize` 方法或 `tools/execution-mode` 事件,会在另一个消费方需要它之前扩大扩展接口。循环使用内部调度器视图,而 `executionMode(exec)` 为策略钩子保留了插入点。
-**将调度器故障转换为工具结果。** AgentLoop 无法判断被拒绝的派发是否已调用工具主体;ToolRuntime 负责工具主体调用状态和类型化工具结果。因此,内部调度器故障保持为终态,而不会被重新分类为 `ABORTED` 结果。
+**将调度器故障转换为普通工具结果。** AgentLoop 无法判断被拒绝的派发是否已调用工具主体;ToolRuntime 负责工具主体调用状态和类型化工具结果。内部调度器故障保持为终态,而不会被重新分类为 `ABORTED` 结果。显式恢复结果保留这种不确定性,同时避免留下尚无结果的调用。
**在模型流式输出时启动调用。** 这可能进一步降低延迟,但会改变 assistant 消息的权威性、回放以及调用/结果配对。调度器只在 assistant 消息完成后才启动。
@@ -96,7 +96,7 @@ PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_co
该设计遵循安全侧原则,对工具作者而言也很简单,但无法利用必须通过比较并列调用才能确认安全的并发性。工具过于宽泛地选择并行,可能暴露潜在的共享状态竞态。
-在某些情形下,并行调用会先行启动,而串行执行原本会在轮到这些调用之前中止。因此,调度器只记录已启动的调用,在中止时将其排空,且取消后绝不启动替换调用。
+在某些情形下,并行调用会先行启动,而串行执行原本会在轮到这些调用之前中止。调度器在中止时将其排空,且取消后绝不启动替换调用;未派发的调用获得合成的中止调用/结果对。
有序提交可能会让快速结果等待较慢的早期并列调用。这保留了回放和模型历史顺序,同时实时界面仍可显示待处理进度。
@@ -104,4 +104,4 @@ PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_co
工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。已启动的调用保留它们进入池时所依据的调度决策。
-终态调度器故障可能会在故障步骤关闭前留下已记录但没有结果的调用。等待仍在运行的派发可确保完全停稳,而不会将这些内部故障误报为工具结果。
+终态调度器故障只有在运行中的派发结算后才会进入步骤恢复。恢复将未提交的执行结果报告为未知,保留完全停稳与原始失败,同时让后续模型历史保持可用。
diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml
index ac9be41f4d..6f55ce4f48 100644
--- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml
@@ -24,8 +24,8 @@
en: b92715f36550c728
zh: e18d7e0e2363347a
/agent-note-persistent-pty-sessions/decision/local-readiness-detection:
- en: cd8b4032a624c277
- zh: 47f0d8c15f1c9cad
+ en: 8f36f06869bd5367
+ zh: f3560477a8965cbe
/agent-note-persistent-pty-sessions/decision/model-visible-output-and-durability:
en: 1b504ab41b47216d
zh: 8fb3d360b3a4e6a1
diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
index d62587ced7..b309e1b1f9 100644
--- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
+++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
@@ -72,13 +72,13 @@ With `run_in_background: true`, `dsh-tool-terminal` registers the in-flight send
### Local readiness detection
-The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires the printable tail after the latest marker to exactly equal the controlled `PS1` before declaring prompt readiness and runs three bounded fallback tiers. Carrying that tail across data callbacks covers delivery where the marker and prompt arrive separately; requiring the exact tail rejects a delayed earlier prompt once echoed input or output follows it, so it cannot settle the current send. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, and `timeoutMs`.
+The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires the printable tail after the latest marker to exactly equal the controlled `PS1` before declaring prompt readiness and runs three bounded fallback tiers. Carrying that tail across data callbacks covers delivery where the marker and prompt arrive separately; requiring the exact tail rejects a delayed earlier prompt once echoed input or output follows it, so it cannot settle the current send. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, `promptTailGraceMs`, and `timeoutMs`.
On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. The waiting thread's `/proc//task//fd/0` must identify the shell's controlling terminal device, so a thread-local fd table cannot substitute its leader's terminal descriptor and a pipeline reader blocked on its pipe remains a running command. Direct PTY descriptors use their device number; `/dev/tty` uses the owning process's `tty_nr` because `stat` reports the alias device rather than the selected PTY. A wait already present before terminal input is not post-write readiness: the same PGID must be observed outside that wait before re-entering it, while a changed foreground PGID is new evidence. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. A host policy that denies `/proc//task//syscall`, including hardened ptrace policy, likewise skips Tier 1 and preserves the bounded Tier 2 idle inference; process sleep state never substitutes for inaccessible syscall evidence. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; the inspector admits the runtime architecture, then checks every supported kernel ABI because `/proc` can report a different ABI under user-mode emulation. Unsupported runtime architectures skip Tier 1.
On macOS there is no exact syscall tier. Output silence returns `inferred_idle` for any foreground process group, including Python and `gdb`; `ps`-derived terminal PGID is used for signaling, not as proof that only the shell can be idle. Pure process-inspector logic is injectable and unit-tested on Linux, while a macOS CI job exercises the real PTY and process-table path.
-Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. When a prompt marker was already seen, Tier 2 waits a further `handoffGraceMs` so a bash foreground handoff that lands on the silence boundary still settles as the exact `stdin_read` attribution instead of the weaker inference; the grace is a deployment-owned config field validated to cover at least one `pollIntervalMs`, because a grace shorter than the poll period cannot contain a single readiness poll and so cannot change any outcome. It bounds only sends that saw a marker, so its cost is the interactive return latency of that one case rather than every send. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.jobs`, signal the foreground group, or inspect from another session.
+Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. When a prompt marker was already seen, Tier 2 waits a further `handoffGraceMs` so a bash foreground handoff that lands on the silence boundary still settles as the exact `stdin_read` attribution instead of the weaker inference; the grace is a deployment-owned config field validated to cover at least one `pollIntervalMs`, because a grace shorter than the poll period cannot contain a single readiness poll and so cannot change any outcome. A marker whose printable tail has not arrived yet adds `promptTailGraceMs` to that bound, because the marker and its tail are written by the same prompt render and a contended host delays only the tail; the [prompt tail grace decision](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.md) owns the field, and it applies only while the tail can still complete. It bounds only sends that saw a marker, so its cost is the interactive return latency of that one case rather than every send. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.jobs`, signal the foreground group, or inspect from another session.
Once a send settles under any tier, `TerminalSendOperation.append` stops accepting output, so later child output no longer reaches that settled operation; it still reaches the scrollback, and any send that is active when it arrives. A test that waits for a marker on the operation it started must therefore set `idleSilenceMs` and `timeoutMs` above the child's own startup latency; interpreter startup on a loaded macOS runner otherwise ends the send before the marker is printed.
diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md
index a26c3ed852..46257a6835 100644
--- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md
@@ -72,13 +72,13 @@ UI 渲染约定精确且不携带位置信息。`terminal_send` 只为前台发
### 本地就绪检测
-本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在最近一个 marker 后的可打印尾部与受控 `PS1` 完全相等时才声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留该尾部,可以适配 marker 与 prompt 被分开交付的情况;如果回显的输入或输出跟在延迟到达的先前 prompt 之后,要求尾部完全相等会拒绝该 prompt,使其无法完成当前 send。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs` 和 `timeoutMs`。
+本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在最近一个 marker 后的可打印尾部与受控 `PS1` 完全相等时才声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留该尾部,可以适配 marker 与 prompt 被分开交付的情况;如果回显的输入或输出跟在延迟到达的先前 prompt 之后,要求尾部完全相等会拒绝该 prompt,使其无法完成当前 send。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs`、`promptTailGraceMs` 和 `timeoutMs`。
在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。等待线程的 `/proc//task//fd/0` 还必须标识 shell 的控制终端设备,因此线程本地 fd 表无法用 leader 的终端描述符冒充自身 fd,阻塞于管道的流水线读取端仍属于正在运行的命令。直接 PTY 描述符使用其设备号;`/dev/tty` 则使用所属进程的 `tty_nr`,因为 `stat` 报告的是别名设备而非选定的 PTY。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。宿主策略(包括加固的 ptrace 策略)若拒绝读取 `/proc//task//syscall`,同样会跳过 Tier 1 并保留有界的 Tier 2 idle 推断;进程休眠状态绝不会代替不可访问的 syscall 证据。架构表只包含对应 Linux UAPI 定义的 syscall number;检查器先准入运行时架构,再检查每个受支持的内核 ABI,因为在用户态模拟下,`/proc` 可能报告不同的 ABI。不受支持的运行时架构会跳过 Tier 1。
macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入,并在 Linux 上经过单元测试,同时由 macOS CI job 驱动真实 PTY 和进程表路径。
-Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。如果此前已经见过 prompt marker,Tier 2 会再等待 `handoffGraceMs`,使恰好落在静默边界上的 bash 前台交接仍然以精确的 `stdin_read` 归因结束,而不是退到较弱的推断;该宽限是由部署方拥有的配置字段,并被校验为至少覆盖一个 `pollIntervalMs`——短于轮询周期的宽限装不下一次就绪轮询,因此不可能改变任何结果。它只约束见过 marker 的 send,代价是这一种情况的交互返回延迟,而不是每一次 send。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.jobs` 等待、向前台组发信号,或从另一个会话排查。
+Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。如果此前已经见过 prompt marker,Tier 2 会再等待 `handoffGraceMs`,使恰好落在静默边界上的 bash 前台交接仍然以精确的 `stdin_read` 归因结束,而不是退到较弱的推断;该宽限是由部署方拥有的配置字段,并被校验为至少覆盖一个 `pollIntervalMs`——短于轮询周期的宽限装不下一次就绪轮询,因此不可能改变任何结果。若已见 marker 而其可打印尾部尚未到达,该上界还会加上 `promptTailGraceMs`:marker 与尾部由同一次提示符渲染写出,负载高的宿主机只会拖慢尾部;该字段由[提示符尾部宽限决策](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md)拥有,且只在尾部仍可能补齐时生效。它只约束见过 marker 的 send,代价是这一种情况的交互返回延迟,而不是每一次 send。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.jobs` 等待、向前台组发信号,或从另一个会话排查。
一次 send 在任一层级 settle 之后,`TerminalSendOperation.append` 就不再接受输出,此后子进程的输出不会再进入那个已 settle 的 operation;它仍然会进入 scrollback,以及此时恰好处于活跃状态的任何 send。因此,等待自己所启动的 operation 上出现标记的测试,必须把 `idleSilenceMs` 与 `timeoutMs` 设得高于子进程自身的启动耗时;否则在负载较高的 macOS runner 上,解释器启动会在标记打印之前就结束这次 send。
diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml
index 8f2fe2b211..a06d830891 100644
--- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml
@@ -9,8 +9,8 @@
en: d3383b495424d118
zh: 4e6ffc8ebfa7f741
/agent-note-session-telemetry-seam-with-mandatory-redaction-and-the-otel-backend/decision:
- en: 85b950a67315023c
- zh: cb4c08338c30b226
+ en: 739b871ae583dcbf
+ zh: d17053e3b0a35942
/agent-note-session-telemetry-seam-with-mandatory-redaction-and-the-otel-backend/alternatives-considered:
en: 01838eefca2bc117
zh: fc71f5449d095b27
diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md
index dc52ee87ef..b3b50d4be5 100644
--- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md
+++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md
@@ -16,6 +16,8 @@ Every deployment that wants harness sessions in an observability stack must hand
- **The `session-telemetry/record` waterfall** — the delta over the branch version and the seam's redaction extension point. Every record passes it before reaching any backend; the seam ships NO rules of its own — the innermost `next()` is a pass-through, deployments mount their rules as listeners (stacking by transforming `next()`'s return value), and a throwing rule withholds the record fail-closed. Redaction applies to the exported copy only; the canonical log is never rewritten.
- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. `DISABLED` is the plugin default and constructs no transport. `FEEDBACK_ONLY` requires `exporter.url`; the [explicit-feedback policy](../architecture/2026-09-05-nonofficial-feedback-otel.md) requires a new submission for every bounded capture, for all providers. [Buffer-free feedback replay](../../archived/simplification/2026-08-06-buffer-free-feedback-telemetry.md) avoids a second in-memory copy of the session prefix.
+The [Session-log request limits](../architecture/2026-09-25-session-log-otel-byte-limits.md) supersede the SDK-only batching rule below for the OTel Session sender; capture, redaction, and handoff rationale remain applicable.
+
The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs. Delivery is best-effort: a crash can lose queued records. Backend retry or losing the same live object's module-scope cursor can duplicate lifecycle-local rows, so receivers deduplicate on `(session.id, session.format_version, event.seq)`.
## Alternatives considered
diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md
index f647ea2896..349cd47e59 100644
--- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md
@@ -16,6 +16,8 @@ Status: implemented
- **`session-telemetry/record` waterfall(瀑布式事件)** —— 相对分支版本的增量,也是该 seam 的脱敏扩展点。每条记录抵达任何后端前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。
- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考后端:OTel JS SDK 日志流水线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`DISABLED` 是插件默认值,且不构造任何传输。`FEEDBACK_ONLY` 要求 `exporter.url`;[显式反馈策略](../architecture/2026-09-05-nonofficial-feedback-otel.zh.md)要求每次有界捕获都由新提交触发,适用于所有提供方。[无缓冲反馈回放](../../archived/simplification/2026-08-06-buffer-free-feedback-telemetry.md)避免在内存中创建会话前缀的第二份副本。
+[Session 日志请求上限](../architecture/2026-09-25-session-log-otel-byte-limits.zh.md) 在 OTel Session 发送器中替代下述仅由 SDK 分批的规则;采集、脱敏和交接的理由仍然适用。
+
边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,并经 passthrough 配置。投递是尽力而为:崩溃可能丢失已排队记录。后端重试或丢失同一 live 对象的模块作用域游标可能重复生命周期本地 row,因此接收端基于 `(session.id, session.format_version, event.seq)` 去重。
## 考虑过的替代方案
diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
index 770368862d..e811b85577 100644
--- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
@@ -12,8 +12,8 @@
en: 8892eea7f62d45ed
zh: 18f64966ff53e38b
/agent-note-web-subagent-catalog-and-human-continuation/design-context:
- en: 3c44fc5af90bc807
- zh: 478538d028a52858
+ en: da3858c86cf685ff
+ zh: b13ee4f45e3dff7d
/agent-note-web-subagent-catalog-and-human-continuation/product-contract:
en: 2060d607d1cd1ef3
zh: e7100887358625c4
diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
index 3798a5a2e5..ba72e31d88 100644
--- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
+++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
@@ -28,7 +28,7 @@ This decision covers Web discovery, transcript viewing, and parent-authorized hu
## Design context
-The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-14602&p=f), [hierarchical expansion](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-15917&p=f), and [child conversation](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=388-18584&p=f) frames are non-normative interaction and visual references. This note owns lifecycle, wire, and failure semantics.
+The Figma subagent list, hierarchical expansion, and child conversation frames are non-normative interaction and visual references. This note owns lifecycle, wire, and failure semantics.
| Design intent | Shipped contract |
| --- | --- |
diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
index 4531528dd2..a3841b05fb 100644
--- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
@@ -28,7 +28,7 @@ Web 产品通过页头的当前 title 谱系区域公开选中会话中由会话
## 设计上下文
-Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-14602&p=f)、[层级展开](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-15917&p=f)与 [child 对话](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=388-18584&p=f)画框是非规范性的交互与视觉参考。本记录负责生命周期、协议与失败语义。
+Figma 中的 subagent 列表、层级展开与 child 对话画框是非规范性的交互与视觉参考。本记录负责生命周期、协议与失败语义。
| 设计意图 | 已交付约定 |
| --- | --- |
diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
index 42a4a26dc0..c161ba26e8 100644
--- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
@@ -9,11 +9,11 @@
en: 4fb1c27a631b32d3
zh: 9dbe9a4b9aadfca9
/agent-note-serial-cross-platform-ci-reference/decision:
- en: f547014d316c1385
- zh: f13b09a4a778adaa
+ en: 7c58d88d4c002f19
+ zh: a3b961be49f2a96b
/agent-note-serial-cross-platform-ci-reference/alternatives-considered:
- en: bc72a1e194e3b0e2
- zh: 9a92cbac88adc7a7
+ en: 3610152c5c4e93af
+ zh: f4c09d327716f750
/agent-note-serial-cross-platform-ci-reference/consequences:
en: 7042125ce6a209d4
zh: ef2be4fa8a4d6320
diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
index 24022fea27..ca2b47c1d0 100644
--- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
+++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
@@ -24,7 +24,7 @@ Platform ownership remains explicit inside that complete aggregate. `terminal-ba
The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling stages each candidate table, graph, and watch-baseline map before publishing a rescan, and a missing bundle remains dirty until a successful content hash. PTY readiness retains a prompt candidate while polling checks foreground ownership; the ordinary silence bound covers inherited markers from interactive children. Real PTY fixtures assemble synchronization tokens at runtime so the interactive shell's input echo cannot satisfy a child-readiness wait. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install.
-The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow belongs to the reference side of the same split. Its bwrap, Landlock x64/arm64, and Seatbelt real-kernel matrix runs only after a push to `master`. Those four jobs are diagnostic: they are not branch-protection requirements and do not feed `all checks passed` across workflow files. Pull-request CI still checks sandbox source through its ordinary unit and coverage inventory; the host-kernel and packed-install proofs report after merge.
+The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow belongs to the reference side of the same split. Its bwrap, Landlock x64/arm64, and Seatbelt real-kernel matrix runs only after a push to `master`. Those four jobs are diagnostic: they are not branch-protection requirements and do not feed `all checks passed` across workflow files. A fifth job, `unit-darwin`, runs the complete unit suite on `macos-latest` under its own budget, so the darwin-only unit expectations keep a platform oracle without holding the Seatbelt leg to the unit suite's duration. Pull-request CI still checks sandbox source through its ordinary unit and coverage inventory; the host-kernel and packed-install proofs report after merge.
Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The ci-master and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
@@ -35,7 +35,7 @@ The active serial references run on the self-hosted `vm-backup` (`serial / linux
- **Set each timeout equal to its latency target** - rejected because scheduling variance would cancel correct work and suppress the evidence needed to diagnose a regression.
- **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check.
- **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and native Windows checks, and the other native jobs supply the complete Windows result.
-- **Run the real-kernel Sandbox matrix on every pull request** - rejected because its four statuses do not participate in branch protection, while repeated installs, Landlock builds, and macOS unit parity consume runner capacity without changing the merge verdict. The master run retains the platform and installed-launcher signal.
+- **Run the real-kernel Sandbox matrix on every pull request** - rejected because its statuses do not participate in branch protection, while repeated installs, Landlock builds, and macOS unit parity consume runner capacity without changing the merge verdict. The master run retains the platform and installed-launcher signal.
- **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism.
- **Run the serial reference on larger runners** - rejected because both required CI and its independent reference must remain runnable when organization-owned pools cannot allocate jobs.
diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
index c9fbc84d8f..4feb6c3ae8 100644
--- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
+++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
@@ -24,7 +24,7 @@ Status: implemented
macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑每次发布重新扫描结果前,都会先暂存候选表、候选图和候选监视基线映射;构建产物缺失后会一直保持脏状态,直到成功计算内容哈希。PTY 就绪检测会在轮询检查前台进程组归属期间保留提示符候选项;常规静默时限也涵盖从交互式子进程继承而来的标记。真实 PTY fixture 会在运行时拼接同步标记,使就绪等待逻辑不会把交互式 shell 的输入回显误判为子进程已就绪。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。
-独立的 [Sandbox](../../../../.github/workflows/sandbox.yml) 工作流属于同一职责划分中的参考侧。其 bwrap、Landlock x64/arm64 与 Seatbelt 真实内核矩阵只在向 `master` 推送后运行。这四个作业仅用于诊断:它们既不是分支保护的必需项,也不会跨工作流计入 `all checks passed`。拉取请求 CI 仍通过常规的单元测试与覆盖率清单检查沙箱源码;宿主内核与 packed-install 验证在合并后报告结果。
+独立的 [Sandbox](../../../../.github/workflows/sandbox.yml) 工作流属于同一职责划分中的参考侧。其 bwrap、Landlock x64/arm64 与 Seatbelt 真实内核矩阵只在向 `master` 推送后运行。这四个作业仅用于诊断:它们既不是分支保护的必需项,也不会跨工作流计入 `all checks passed`。第五个作业 `unit-darwin` 在 `macos-latest` 上以自己的预算运行完整单元测试套件,让只在 darwin 上生效的单元测试预期保有平台判据,同时不让 Seatbelt 作业受制于单元测试套件的时长。拉取请求 CI 仍通过常规的单元测试与覆盖率清单检查沙箱源码;宿主内核与 packed-install 验证在合并后报告结果。
master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。ci-master 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
@@ -35,7 +35,7 @@ master 分支的参考作业仅用于诊断,不参与拉取请求所要求的
- **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。
- **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。
- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和原生 Windows 检查,而其它原生作业提供完整的 Windows 结果。
-- **在每个拉取请求上运行真实内核 Sandbox 矩阵**:不予采纳,因为它的四个状态不参与分支保护,而重复安装、Landlock 构建以及为保持平台一致而运行的 macOS 单元测试会消耗运行器容量,却不会改变合并裁决。master 上的运行保留平台与已安装 launcher 的信号。
+- **在每个拉取请求上运行真实内核 Sandbox 矩阵**:不予采纳,因为它的状态不参与分支保护,而重复安装、Landlock 构建以及为保持平台一致而运行的 macOS 单元测试会消耗运行器容量,却不会改变合并裁决。master 上的运行保留平台与已安装 launcher 的信号。
- **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。
- **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,必需 CI 及其独立参考流程都必须仍可运行。
diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml
index 1b44f40971..982839c935 100644
--- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml
@@ -9,8 +9,8 @@
en: cef0f05fd8dfd6a1
zh: 228107eb6c00a91a
/agent-note-freeze-low-future-value-agent-notes-outside-the-active-corpus/decision:
- en: 453cf66046816477
- zh: af5bb77e849416a1
+ en: f0d9c292c3b8dd3b
+ zh: fd928084dfd333e2
/agent-note-freeze-low-future-value-agent-notes-outside-the-active-corpus/alternatives-considered:
en: 7637deef67f3ec14
zh: 7f43606e487fab67
diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md
index e9ecfa78f5..8e4ae6beab 100644
--- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md
+++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md
@@ -18,7 +18,7 @@ The root `.rgignore` excludes the archive from searches that traverse a parent d
After archival, the triplet is permanently frozen and is historical context rather than current authority. It is not updated for renamed packages, changed behavior, translation standards, formatting rules, broken outbound links, or later documentation contracts. Active prose may intentionally link into an archived note, redirect that link to current authority, or delete it. Repository gates therefore validate links into archived files but never treat archived files as link sources.
-[`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) owns the frozen boundary. It accepts only the closed set of Agent Note kinds, requires a complete triplet with implemented status and matching valid archive dates, verifies the sidecar against both current Git blob hashes, and seals every artifact by path and SHA-256 content hash in an append-only manifest. Its `--write` mode first proves every existing seal unchanged and then appends only newly archived artifacts. Pull-request CI supplies the trusted base SHA and checks out complete history before running the verifier, so a reused runner's shallow checkout cannot omit the baseline manifest. The ordinary Agent Note format, translation-pairing, wrapping, Markdown-link, package-path, Mermaid, documentation-TypeScript, and type-equivalence gates exclude archive sources; their evolving standards cannot create pressure to edit history.
+[`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) owns the frozen boundary. It accepts only the closed set of Agent Note kinds, requires a complete triplet with implemented status and matching valid archive dates, verifies the sidecar against both current Git blob hashes, and seals every artifact by path and SHA-256 content hash in an append-only manifest, with only the exact seal-transition exceptions recorded in [archive instructions](../../archived/AGENTS.md). Its `--write` mode first proves every existing seal unchanged and then appends only newly archived artifacts. Pull-request CI supplies the trusted base SHA and checks out complete history before running the verifier, so a reused runner's shallow checkout cannot omit the baseline manifest. The ordinary Agent Note format, translation-pairing, wrapping, Markdown-link, package-path, Mermaid, documentation-TypeScript, and type-equivalence gates exclude archive sources; their evolving standards cannot create pressure to edit history.
The [`dsh-archive-agent-notes`](../../../skills/dsh-archive-agent-notes/SKILL.md) workflow owns classification. It requires a semantic note-by-note audit, uses code and current documentation to identify present authority, treats word count only as triage, carries calibrated keep/archive/delete examples, and reports genuinely borderline outcomes for review.
diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md
index 9a3ce2e6e2..a41cf83cf5 100644
--- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md
+++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md
@@ -18,7 +18,7 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录
归档后,这三个文件永久冻结,只作为历史背景,不再是当前权威依据。不得因为包重命名、行为变化、翻译标准、格式规则、出站链接失效或后续文档约定而更新归档文件。活跃文档可以有意链接到归档 Agent Note,也可以把该链接重定向到当前权威依据,或直接删除。仓库门禁因此会校验指向归档文件的链接,但绝不把归档文件作为链接源来校验。
-[`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) 负责维护冻结边界。它只接受封闭集合中的 Agent Note 类别,要求三个配对文件完整、状态为 implemented,且归档日期有效并互相匹配;它还会用双方当前的 Git blob hash 校验伴随记录,并在仅追加的 manifest(元数据清单)中按路径和 SHA-256 内容 hash 封存每项产物。其 `--write` 模式会先证明每条现有封存记录对应的内容都未改变,再仅追加新归档的产物。拉取请求 CI 会提供可信的基准 SHA,并在运行校验器前检出完整历史,因此复用运行器上的浅克隆检出无法漏掉基线 manifest。普通的 Agent Note 格式、翻译配对、换行、Markdown 链接、包路径、Mermaid、文档 TypeScript 和类型等价门禁都排除归档源文件,因此这些门禁持续演进的标准不会产生修改历史记录的压力。
+[`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) 负责维护冻结边界。它只接受封闭集合中的 Agent Note 类别,要求三个配对文件完整、状态为 implemented,且归档日期有效并互相匹配;它还会用双方当前的 Git blob hash 校验伴随记录,并在仅追加的 manifest(元数据清单)中按路径和 SHA-256 内容 hash 封存每项产物,仅允许[归档说明](../../archived/AGENTS.md)中记录的精确封存哈希转换例外。其 `--write` 模式会先证明每条现有封存记录对应的内容都未改变,再仅追加新归档的产物。拉取请求 CI 会提供可信的基准 SHA,并在运行校验器前检出完整历史,因此复用运行器上的浅克隆检出无法漏掉基线 manifest。普通的 Agent Note 格式、翻译配对、换行、Markdown 链接、包路径、Mermaid、文档 TypeScript 和类型等价门禁都排除归档源文件,因此这些门禁持续演进的标准不会产生修改历史记录的压力。
[`dsh-archive-agent-notes`](../../../skills/dsh-archive-agent-notes/SKILL.md) 工作流负责分类判断。它要求逐份 Agent Note 做语义审计,使用代码和当前文档识别现行权威依据,仅把字数作为初步筛选手段,收录经过校准的保留、归档和删除示例,并报告真正处于边界的结果,以供评审。
diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml
index 5d29f39a16..a87c0b5e8b 100644
--- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml
@@ -9,8 +9,8 @@
en: b3ec2245644b46e6
zh: 83de1cb620f9ddcf
/agent-note-make-lefthook-installation-worktree-local/decision:
- en: 577d90ff7e03ebaf
- zh: dc680155c0c3a2a1
+ en: b53a2425f88a6031
+ zh: 41f459b00803b3f9
/agent-note-make-lefthook-installation-worktree-local/alternatives-considered:
en: d7100b0d2c95a3f9
zh: 070c38c4d0d1331a
diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md
index 42adc1ed1d..66ae7a136e 100644
--- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md
+++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md
@@ -14,7 +14,7 @@ Lefthook-generated hooks prefer an absolute binary path captured from the instal
Hook installation is worktree-scoped. With `CI=true` or `GITHUB_ACTIONS=true`, the installer returns before Git discovery or mutation because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer so `git config --show-scope` can report which scope supplied a value, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`.
-Before upgrading format 0, the installer refuses direct common-config `extensions.*`; it also refuses direct `core.worktree` or `core.bare=true` and non-empty dormant worktree configs that enabling the extension would activate. The migration removes direct `core.bare=false` because false is Git's default. The common repository config and every existing `config.worktree` must be regular files. These checks disable include expansion because Git's repository-format parser also ignores included targets. A repository-scoped lock serializes migration and hook writes; its process ID, random token, file identity, and exact contents must still match at release. Dead or invalid locks require manual recovery rather than automatic breaking.
+Before upgrading format 0, the installer refuses direct common-config `extensions.*`; it also refuses direct `core.worktree` or `core.bare=true` and non-empty dormant worktree configs that enabling the extension would activate. The migration removes direct `core.bare=false` because false is Git's default. The common repository config and every existing `config.worktree` must be regular files. These checks disable include expansion because Git's repository-format parser also ignores included targets. A repository-scoped lock serializes migration and hook writes; its process ID, random token, file identity, and exact contents must still match at release. Dead or invalid locks require manual recovery rather than automatic breaking. On Windows, acquiring or inspecting a lock retries EPERM within the existing 30-second acquisition deadline because deleting a lock can temporarily deny access while readers close their handles; other errors and expired deadlines still fail.
Each hook directory carries a JSON ownership marker containing the absolute path last published to worktree config. After a checkout moves, that marker permits replacement of only the exact stale owned value. Git seeds a new linked worktree's `config.worktree` from the main worktree; when that seed contains the marker-backed reserved hook path of a registered worktree, the installer replaces only the new worktree's config with its own path. Before Lefthook runs, the marker and every existing generated hook must be unaliased regular files. The installer resolves the effective scope, origin, and value of `core.hooksPath`, including active `config.worktree` includes; it refuses command-scoped paths, unowned worktree-scoped paths, and unowned reserved directories. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which opts only the current worktree into Lefthook. Inactive `includeIf` targets are not recursively inspected because they do not affect the current configuration. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation.
diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md
index b4b9059678..641bc6d2c8 100644
--- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md
+++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md
@@ -14,7 +14,7 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝
钩子安装以 worktree 为作用域。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,安装程序要求 Git 2.26 或更高版本,使 `git config --show-scope` 可以报告由哪个作用域提供配置值;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。
-升级格式 0 之前,安装程序会拒绝共用配置中直接设置的 `extensions.*`;它还会拒绝直接设置的 `core.worktree` 或 `core.bare=true`,以及启用扩展后将被激活的非空休眠 worktree 配置。迁移会移除直接设置的 `core.bare=false`,因为 false 是 Git 的默认值。共用仓库配置和每个已有的 `config.worktree` 都必须是常规文件。这些检查会禁用 include 展开,因为 Git 的仓库格式解析器也会忽略 include 目标。仓库级锁会串行化迁移和钩子写入;释放时,锁的进程 ID、随机令牌、文件身份和完整内容必须仍然匹配。所属进程已结束或内容无效的锁必须人工介入恢复,而不能自动强制解除。
+升级格式 0 之前,安装程序会拒绝共用配置中直接设置的 `extensions.*`;它还会拒绝直接设置的 `core.worktree` 或 `core.bare=true`,以及启用扩展后将被激活的非空休眠 worktree 配置。迁移会移除直接设置的 `core.bare=false`,因为 false 是 Git 的默认值。共用仓库配置和每个已有的 `config.worktree` 都必须是常规文件。这些检查会禁用 include 展开,因为 Git 的仓库格式解析器也会忽略 include 目标。仓库级锁会串行化迁移和钩子写入;释放时,锁的进程 ID、随机令牌、文件身份和完整内容必须仍然匹配。所属进程已结束或内容无效的锁必须人工介入恢复,而不能自动强制解除。在 Windows 上,获取或检查锁时遇到 EPERM 会在原有的 30 秒获取期限内重试,因为删除锁时,读取者关闭句柄前可能暂时无法访问该文件;其他错误及超时仍会导致失败。
每个钩子目录都有一个 JSON 所有权标记,其中包含上次写入 worktree 配置的绝对路径。检出目录移动后,该标记只允许替换确切的陈旧自有值。Git 会以主 worktree 的配置为新链接 worktree 初始化 `config.worktree`;当该初始配置包含某个已注册 worktree 中由所有权标记佐证的保留钩子路径时,安装程序只会在新 worktree 的配置中将其替换为新 worktree 自有的路径。Lefthook 运行前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件。安装程序会解析 `core.hooksPath` 的生效作用域、来源和值,包括通过当前生效的 `config.worktree` include 加载的值;它会拒绝命令作用域路径、非自有的 worktree 作用域路径以及非自有的保留目录。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook。未生效的 `includeIf` 目标不会被递归检查,因为它们不影响当前配置。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。
diff --git a/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.i18n.yaml b/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.i18n.yaml
new file mode 100644
index 0000000000..8f91e16be8
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.i18n.yaml
@@ -0,0 +1,22 @@
+# Bilingual-pair consistency record for 2026-09-26-web-conversation-clock-fixture-day.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.md
+/agent-note-web-conversation-clocks-on-the-fixture-day:
+ en: 13b5d9d0bb3626af
+ zh: 0bd01e1848284a35
+/agent-note-web-conversation-clocks-on-the-fixture-day/problem:
+ en: 6f334488a6d3a28d
+ zh: b33aed9b10d0cd84
+/agent-note-web-conversation-clocks-on-the-fixture-day/decision:
+ en: ba875c82795d58e1
+ zh: 13a7df6f8c4e0aa6
+/agent-note-web-conversation-clocks-on-the-fixture-day/alternatives-considered:
+ en: 9c0a2afb1473c1d1
+ zh: 36c9e72a70072f5e
+/agent-note-web-conversation-clocks-on-the-fixture-day/verification:
+ en: 5031ca866fed24dd
+ zh: c11f71af70c1f5ec
+/agent-note-web-conversation-clocks-on-the-fixture-day/consequences:
+ en: c5cdbbd3b2889df7
+ zh: 8458ef92443cf1a6
diff --git a/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.md b/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.md
new file mode 100644
index 0000000000..8b572d43f3
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.md
@@ -0,0 +1,47 @@
+# Agent Note: Web conversation clocks on the fixture day
+
+Status: implemented
+
+English | [中文](2026-09-26-web-conversation-clock-fixture-day.zh.md)
+
+## Problem
+
+Run 36157253018 (job 108144715986, `node 24 / snapshots and artifacts`) failed four browser scenarios in one pass — [steering](../../../../apps/web/tests/steering.e2e.ts), [markdown-images](../../../../apps/web/tests/markdown-images.e2e.ts), [message-actions](../../../../apps/web/tests/message-actions.e2e.ts), and [reference-composer](../../../../apps/web/tests/reference-composer.e2e.ts) — with one aria difference: a message rendered `… and stop. 9/25 23:58` where its golden records `… and stop. {{clock}}`.
+
+[`newEnglishPage`](../../../../apps/web/tests/support.ts) pins the browser to `Asia/Shanghai`, and [`formatMessageClock`](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) prefixes `clock.md` / `clock.ymd` to the clock as soon as the message's local calendar day or year differs from the renderer's. These scenarios left both clocks on the wall clock, and their message times are stamped during the run: a live composer send by the Host, a seed whose fixture header carries `createdAt: 0` anchored at `Date.now() - 60_000` by `seedSession`. The failing job started at 15:54Z, six minutes before local midnight, so rows stamped before it were captured after it and rendered a date prefix; the next run of the same scenario passed minutes after midnight.
+
+The lane already carried the remedy — [`WEB_FIXTURE_TIME`](../../../../apps/web/tests/support.ts), documented as the same-day anchor for seeded event times and the `Asia/Shanghai` browser clock, and used by seven other scenarios — but these four scenarios never adopted it.
+
+## Decision
+
+The four scenarios read the fixture day on both clocks.
+
+Live-stamped scenarios pin the Host clock with `pinHostClock()`, a `Date.now` spy advancing from the anchor with real elapsed time, and the browser clock with [`pinBrowserClock(page)`](../../../../apps/web/tests/support.ts). Seeded scenarios anchor the seed through `seedSession`'s `createdAt` argument and pin the browser clock the same way.
+
+The seed anchor is not only the rendered clock: the sidebar's relative ages are computed against the browser clock, so a pinned browser with a live-anchored seed reads `8mo` where the [message-actions](../../../../apps/web/tests/message-actions.e2e.ts) fork golden asserts `now` and `1min`.
+
+`pinBrowserClock` re-applies one frozen instant from the anchor on an interval instead of leaving it at a single value, because product code reads two `Date.now()` values to decide how long a rendered value is held: the step process keeps its previous title for `PROCESS_TITLE_MINIMUM_MS` = 150 ms ([ChatGroupSeat](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx)) and the composer keymap keeps a composition guard 10 ms past `compositionend` ([keymap](../../../../packages/client/ui-conversation/src/client/input/editor/keymap.ts)). Both compute the remaining hold as `Date.now() - previousReading`, which a permanently frozen clock never advances, so the hold never elapses and the golden's settled text stays unrendered. CI run 36179748541 (job 108218939163) caught exactly that on the first revision that froze the clock: `Preparing questions` where the steering mid golden records `Waiting for your action · Ready to continue?`.
+
+The steering mid capture waits for that settled question title before it captures, and the [whole-queue scenario](../../../../apps/web/tests/steering.e2e.ts) calls the queue gesture only after the running turn's Stop control appears, so Enter's queue decision always resolves against an agent that already reports busy.
+
+## Alternatives considered
+
+**Collapse the rendered date prefix in the aria normalizer.** Collapsing `9/25 23:58` to `{{clock}}` in `normalizeAria` fixes the four scenarios with a two-line change, and it was measured: it passed the four files and then failed `cordis-tool-round`, `stats-paged-history`, and `navigation-panes` in one `DSH_WEB_SNAPSHOT_WORKERS=16` lane run, because their goldens deliberately record the dated form (`9/1 {{clock}}` and the year form in the Timing pane), and `skill-tool-row` already tokenizes that form as `{{date}} {{clock}}`. The two renderings state different facts — this row is from another day, this row is from today — so collapsing them deletes assertions instead of stabilizing them.
+
+**Leave the browser clock frozen.** Pinning one instant for the whole scenario is simpler and passed the four files, but it removes elapsed time from every two-reading hold in the page, which is how the `Preparing questions` capture above happened. Re-applying the anchor keeps the calendar pinned without changing how long a held value is held.
+
+**Pin the clocks for the whole lane.** Pinning inside `launchWebScaffold` and `newEnglishPage` would cover every scenario at once, including the ones that fail this way in future. It loses on blast radius: 83 goldens carry a clock and session-tree goldens assert literal relative ages, so a lane-wide now moves every scenario away from its own session and fixture times simultaneously, and each would still need its own anchor to read the buckets it was recorded with.
+
+**Keep scenarios clear of local midnight.** Scheduling or refusing runs near the boundary leaves the dependency in place and fails the run instead of the code under test. The [CI readiness and completion decision](2026-09-08-ci-readiness-and-completion.md) already rejects budgets and retries as substitutes for a controlled observation.
+
+## Verification
+
+Two negative controls pin one clock and leave the other on the wall clock, reproducing the recorded signature exactly: `steering` with the browser clock pinned and the Host clock live fails its two golden tests with `… and stop. 9/26 {{clock}}`, and `message-actions` with the browser clock pinned and a live-anchored seed fails with `9/26 {{clock}}` on every clock line plus `now` where the fork golden asserts `1min`. Pinning both clocks passes both files, and their goldens are unchanged.
+
+The queue scenario's start race is measured, not inferred: with the clocks pinned and no busy observation it failed 10 times in 61 runs of `vitest run … -t "queues two messages"`, all at the same `2 queued messages` wait; `origin/master` without the pins failed 0 of 41; with the observation added, 20 of 20 passed.
+
+CI run 36179748541 (job 108218939163) shows the calendar fix holding under the lane's own topology: the prompt line matches `{{clock}}` where run 36157253018 rendered `9/25 {{clock}}`.
+
+## Consequences
+
+The four scenarios render the same aria line whenever they run, and their goldens keep asserting a clock without a date prefix. The calendar fact is owned per scenario: a scenario that types messages, or seeds a fixture whose header carries `createdAt: 0`, must read the fixture day on both clocks, or it inherits the midnight exposure. Product clock rendering, the `Asia/Shanghai` browser timezone, every committed golden, and the assertions of the scenarios not listed here are unchanged.
diff --git a/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.zh.md b/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.zh.md
new file mode 100644
index 0000000000..4631d36b8a
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.zh.md
@@ -0,0 +1,47 @@
+# Agent Note: Web conversation clocks on the fixture day
+
+Status: implemented
+
+[English](2026-09-26-web-conversation-clock-fixture-day.md) | 中文
+
+## Problem
+
+运行 36157253018(作业 108144715986,`node 24 / snapshots and artifacts`)在一次执行中让四个浏览器场景失败——[steering](../../../../apps/web/tests/steering.e2e.ts)、[markdown-images](../../../../apps/web/tests/markdown-images.e2e.ts)、[message-actions](../../../../apps/web/tests/message-actions.e2e.ts) 与 [reference-composer](../../../../apps/web/tests/reference-composer.e2e.ts)——且是同一处 aria 差异:消息渲染为 `… and stop. 9/25 23:58`,而 golden 记录为 `… and stop. {{clock}}`。
+
+[`newEnglishPage`](../../../../apps/web/tests/support.ts) 将浏览器时区固定为 `Asia/Shanghai`,而 [`formatMessageClock`](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) 一旦消息的本地日历日或年份与渲染时不同,就会在时钟前拼接 `clock.md` / `clock.ymd`。这些场景把两个时钟都留在墙上时钟上,而它们的消息时间是在运行期盖章的:实时输入由 Host 盖章,fixture 头部为 `createdAt: 0` 的种子由 `seedSession` 以 `Date.now() - 60_000` 为锚点。失败的作业于 15:54Z 启动,距本地午夜还有六分钟,于是午夜前盖章的行在午夜后捕获并渲染出日期前缀;同一场景的下一次运行在午夜后数分钟通过。
+
+通道本已具备对策——[`WEB_FIXTURE_TIME`](../../../../apps/web/tests/support.ts),其文档写明它是种子事件时间与 `Asia/Shanghai` 浏览器时钟的同日锚点,另有七个场景在使用它——但这四个场景从未采用。
+
+## Decision
+
+这四个场景让两个时钟都读取 fixture 日。
+
+实时盖章的场景用 `pinHostClock()` 固定 Host 时钟(一个从锚点起按真实流逝时间推进的 `Date.now` spy),并用 [`pinBrowserClock(page)`](../../../../apps/web/tests/support.ts) 固定浏览器时钟。种子场景通过 `seedSession` 的 `createdAt` 参数锚定种子,并用同样方式固定浏览器时钟。
+
+种子锚点不只影响渲染出的时钟:侧边栏的相对时间也以浏览器时钟为基准计算,因此固定浏览器却用实时锚点的种子会读出 `8mo`,而 [message-actions](../../../../apps/web/tests/message-actions.e2e.ts) 的 fork golden 断言的是 `now` 与 `1min`。
+
+`pinBrowserClock` 按固定间隔从锚点重新施加同一个冻结时刻,而不是让时钟停在单一数值上,因为产品代码要靠读取两个 `Date.now()` 值来决定一个已渲染值要保持多久:步骤进程会把上一个标题保持 `PROCESS_TITLE_MINIMUM_MS` = 150 ms([ChatGroupSeat](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx)),输入框 keymap 会在 `compositionend` 后再保持 10 ms 的合成守卫([keymap](../../../../packages/client/ui-conversation/src/client/input/editor/keymap.ts))。两者都把剩余保持时间算作 `Date.now() - previousReading`,而永久冻结的时钟永远不会让它推进,于是保持期不会结束,golden 里那行稳定文案始终不出现。CI 运行 36179748541(作业 108218939163)在第一个冻结时钟的修订上正是抓到了这一点:steering 的 mid golden 记录的是 `Waiting for your action · Ready to continue?`,实际捕获到 `Preparing questions`。
+
+steering 的 mid 捕获在截图前等待该稳定标题出现;[整队场景](../../../../apps/web/tests/steering.e2e.ts) 则只在运行中回合的停止控件出现后才做排队手势,因此 Enter 的排队判定总是相对一个已上报忙碌的 agent 解析。
+
+## Alternatives considered
+
+**在 aria normalizer 中折叠渲染出的日期前缀。** 在 `normalizeAria` 中把 `9/25 23:58` 折叠为 `{{clock}}` 只需两行即可修好这四个场景,且已实测:它让四个文件通过,随后在一次 `DSH_WEB_SNAPSHOT_WORKERS=16` 的通道运行中让 `cordis-tool-round`、`stats-paged-history` 与 `navigation-panes` 失败,因为这些 golden 有意记录带日期的形式(`9/1 {{clock}}`,以及 Timing 面板中的年份形式),而 `skill-tool-row` 早已把该形式 token 化为 `{{date}} {{clock}}`。两种渲染陈述的是不同事实——此行来自另一天,此行来自今天——因此折叠它们是在删除断言,而不是在稳定断言。
+
+**让浏览器时钟保持冻结。** 整个场景固定单一时刻更简单,也能让四个文件通过,但它抽掉了页面中所有“读两次”保持逻辑的时间流逝,上面的 `Preparing questions` 捕获就是这样发生的。重新施加锚点既保持日历固定,又不改变已渲染值的保持时长。
+
+**为整条通道固定时钟。** 在 `launchWebScaffold` 与 `newEnglishPage` 内固定时钟可以一次覆盖所有场景,包括将来以同样方式失败的场景。它因影响面而落选:83 个 golden 带有时钟,且会话树 golden 断言字面相对时间,因此通道级的 now 会同时把所有场景移离各自的会话与 fixture 时间,而每个场景仍需自己的锚点才能读出录制时的分桶。
+
+**让场景避开本地午夜。** 在边界附近调度或拒绝运行会把依赖留在原处,并让运行而非被测代码失败。[CI 就绪与完成决策](2026-09-08-ci-readiness-and-completion.zh.md) 已经拒绝用预算或重试替代受控观察。
+
+## Verification
+
+两个负向对照各固定一个时钟、把另一个留在墙上时钟,精确复现了记录到的特征:`steering` 固定浏览器时钟而 Host 时钟实时时,两个 golden 用例以 `… and stop. 9/26 {{clock}}` 失败;`message-actions` 固定浏览器时钟但仍用实时锚点的种子时,每行时钟都以 `9/26 {{clock}}` 失败,且本应断言 `1min` 的 fork golden 读出 `now`。两个时钟都固定后两个文件均通过,且它们的 golden 未改动。
+
+排队场景的启动竞态是实测而非推断:固定时钟且不等待忙碌状态时,`vitest run … -t "queues two messages"` 在 61 次中失败 10 次,全部停在同一个 `2 queued messages` 等待上;未加固定时钟的 `origin/master` 在 41 次中失败 0 次;加上该观察后 20 次全部通过。
+
+CI 运行 36179748541(作业 108218939163)显示日历修复在通道自身的拓扑下成立:提示行匹配 `{{clock}}`,而运行 36157253018 渲染的是 `9/25 {{clock}}`。
+
+## Consequences
+
+这四个场景无论何时运行都渲染同一行 aria,其 golden 继续断言不带日期前缀的时钟。日历事实现在按场景归属:自行输入消息、或种子 fixture 头部为 `createdAt: 0` 的场景必须让两个时钟都读取 fixture 日,否则会继承午夜暴露。产品时钟渲染、`Asia/Shanghai` 浏览器时区、所有已提交的 golden,以及本说明未列出的场景的断言均不变。
diff --git a/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.i18n.yaml b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.i18n.yaml
new file mode 100644
index 0000000000..3e4c2f6895
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.i18n.yaml
@@ -0,0 +1,22 @@
+# Bilingual-pair consistency record for 2026-09-27-observation-waits-on-observed-state.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.md
+/agent-note-windows-lane-observations-wait-on-the-state-they-read:
+ en: 8949f81c9d585fc0
+ zh: 34e1bdbca1cf2126
+/agent-note-windows-lane-observations-wait-on-the-state-they-read/problem:
+ en: 270b015afc8de8ac
+ zh: 65aeb1e270d19a27
+/agent-note-windows-lane-observations-wait-on-the-state-they-read/decision:
+ en: 479cb25421102304
+ zh: 9e23396728001e2c
+/agent-note-windows-lane-observations-wait-on-the-state-they-read/alternatives-considered:
+ en: 00e59c561085e081
+ zh: 4f6ac4dfc3259f58
+/agent-note-windows-lane-observations-wait-on-the-state-they-read/consequences:
+ en: 13db32f9da19d87b
+ zh: 48081fc7035dea45
+/agent-note-windows-lane-observations-wait-on-the-state-they-read/deferred:
+ en: 18dc81f98375fb04
+ zh: e30413cd3d201b80
diff --git a/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.md b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.md
new file mode 100644
index 0000000000..e0218a4d5f
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.md
@@ -0,0 +1,35 @@
+# Agent Note: Windows-lane observations wait on the state they read
+
+Status: implemented
+
+English | [中文](2026-09-27-observation-waits-on-observed-state.zh.md)
+
+## Problem
+
+Two self-hosted Windows lane cases observed state through a wall-clock window instead of the state they assert on.
+
+`packages/boot/plugin-manager/tests/operations.spec.ts` armed a 100 ms timer when the case started and read the profile's `run.json` when it fired, to observe the run record while the run was still in flight. The repaired installation it observes runs only after a refused installation, a `pnpm view` lookup, the restoration of the profile files, and a second `launch`, so the timer measured that whole prefix rather than the record write. Under the loaded runner the repair began after the deadline and the case failed with `expected null to deeply equal { pid: 4343, grouped: false }` (PR #4595 `windows node 24 / coverage`, job 108402945554, 2026-09-26; the case took 345 ms, and its sibling single-run cases, which need no such prefix, passed in 113 ms and 116 ms).
+
+`packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` counts each send's settlement tier and requires the controlled-prompt fast path, but reported only the count, so a failure could not say which tier settled which send — the one fact that separates a degraded readiness path from a mis-sized window.
+
+## Decision
+
+`observingChild` stays in flight until the test has read the run record, so the read cannot race the run's end, and its bounded wait starts at the mocked launcher's hand-over instead of at test start: the recorded run is one atomic write that follows the launch, so the bound only has to cover that write. `RECORD_WAIT_MS` (2 s) is deliberately below the case budget, so a record that never appears reports as the assertion that names it rather than as a runner timeout. Both the repair-install case and the single-run case hand the child over through the same `spawned` hook.
+
+The loader-composition settle-reason assertion carries `JSON.stringify(settleReasons)` as its failure message. The threshold and the assertions are unchanged.
+
+## Alternatives considered
+
+**Raise the fixed timer, or poll for the record from test start.** Rejected: both keep the window anchored before the operation reached the run it observes, so the bound has to cover the whole prefix and grows with whatever the runner does before the repair run — the same mistake at a larger size. Anchoring on the hand-over measures only the state transition being waited for.
+
+**Wait for the record without a bound.** Rejected: a record the operation never writes would hang the case until the runner's own timeout and report nothing about the record.
+
+**Record only the settlements that matter, or assert a subset of sends.** Rejected: it weakens what the suite pins — that no send falls back to the silence tier — to make the failure go away.
+
+## Consequences
+
+Both plugin-manager record cases now observe the record while the run is in flight on a loaded runner, and a genuinely missing record still fails the same assertion. The bounded wait costs 2 s only when the operation never writes the record.
+
+## Deferred
+
+The loader-composition failure of 2026-09-25/26 is an environment-sensitive readiness window, not a product logic error, and the product now owns a documented tolerance for it: the [prompt tail grace decision](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.md). On Windows `isStdinWaiting` returns false and `foregroundPgid` returns the shell pid, so `stdin_read` can only come from the controlled-prompt tail. A native Windows probe (Windows 11 ARM64 guest, 8 vCPU, with the readiness probe enabled) shows the fast path is deterministic when delivery is normal: every prompt render is the OSC marker alone in one pty chunk followed by a five-byte `dsh> ` chunk 2-28 ms later (56 renders), nothing printable follows any prompt, the output carries no terminal queries, and 21 runs (baseline, six CPU burners, emulated x64, four concurrent pty-heavy instances, and Windows PowerShell 5.1 as the shell) settled all seven sends on the prompt, six `stdin_read` plus the `exit` command's `session_exit`. Withholding each session's prompt tail for four seconds reproduces the reported failure exactly: `expected 4 to be greater than or equal to 6` with `["inferred_idle","stdin_read","stdin_read","stdin_read","stdin_read","session_exit","inferred_idle"]`, the degraded settles carrying `promptSeen` true, `promptTextSeen` false, an empty tail and `idleFor` 3301/3313 ms (exactly the configured bound), 22.1/21.9 s against 17.5/20.9 s in CI, and every output assertion still passing. The CI trigger itself was not reproduced locally, so this case keeps its assertion and supplies `promptTailGraceMs` in its composition: it measures the controlled-prompt path rather than the host's delivery timing, and it still fails when no prompt marker appears at all.
diff --git a/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.zh.md b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.zh.md
new file mode 100644
index 0000000000..079ae65451
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: Windows lane 的观测等待它所读取的状态
+
+Status: implemented
+
+[English](2026-09-27-observation-waits-on-observed-state.md) | 中文
+
+## Problem
+
+自托管 Windows lane 的两个用例用墙钟窗口观测状态,而不是等待它们真正断言的那个状态。
+
+`packages/boot/plugin-manager/tests/operations.spec.ts` 在用例开始时启动一个 100 毫秒定时器,并在它触发时读取 profile 的 `run.json`,以此在该 run 仍在进行时观测 run 记录。它要观测的「修复安装」只有在一次被拒绝的安装、一次 `pnpm view` 查询、profile 文件恢复以及第二次 `launch` 之后才会发生,因此该定时器度量的是这整段前缀,而不是记录写入本身。在负载较高的 runner 上修复安装晚于该截止时刻才开始,用例因而以 `expected null to deeply equal { pid: 4343, grouped: false }` 失败(PR #4595 的 `windows node 24 / coverage`,job 108402945554,2026-09-26;该用例耗时 345 毫秒,而相邻的单 run 用例不需要这段前缀,分别以 113 毫秒和 116 毫秒通过)。
+
+`packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` 统计每次 send 的结算层级并要求受控提示符快路径,但只报告数量,失败时无法说明哪一层级结算了哪一次 send——而这正是区分「就绪路径退化」与「窗口取值不当」的唯一事实。
+
+## Decision
+
+`observingChild` 会保持该 run 在进行中,直到测试读到 run 记录,因此读取不可能与 run 结束竞争;其有界等待从被 mock 的启动器交接处开始,而不是从用例开始处开始:被记录的 run 只是紧随启动之后的一次原子写入,因此等待上界只需覆盖这次写入。`RECORD_WAIT_MS`(2 秒)刻意低于用例预算,于是一条始终没有出现的记录会以指名它的断言报告,而不是以 runner 超时报告。修复安装用例与单 run 用例都通过同一个 `spawned` 钩子交接子进程。
+
+loader-composition 的结算层级断言把 `JSON.stringify(settleReasons)` 作为失败信息。阈值与断言本身不变。
+
+## Alternatives considered
+
+**提高固定定时器的时长,或从用例开始处轮询记录。** 不采纳:两者都把窗口锚定在操作尚未到达它所观测的 run 之前,于是上界必须覆盖整段前缀,并随 runner 在修复 run 之前的行为一起增长——同样的错误,只是更大。锚定在交接处则只度量真正要等待的那个状态转换。
+
+**无上界地等待记录。** 不采纳:若操作永不写入记录,用例会挂到 runner 自身的超时,且对记录本身不给出任何信息。
+
+**只记录关键的那几次结算,或断言 send 的子集。** 不采纳:这会把该套件所固定的内容——任何 send 都不得回退到静默层——削弱成让失败消失。
+
+## Consequences
+
+在负载较高的 runner 上,两个插件管理器记录用例现在都能在该 run 仍在进行时观测到记录,而真正缺失的记录仍会以同一条断言失败。只有在操作始终不写记录时,这段有界等待才会付出 2 秒。
+
+## Deferred
+
+loader-composition 于 2026-09-25/26 的失败是一个对投递时序敏感的就绪窗口,而不是 product 逻辑错误;product 现在为它拥有一个文档化的容忍度:[提示符尾部宽限决策](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md)。在 Windows 上 `isStdinWaiting` 返回 false、`foregroundPgid` 返回 shell 自身的 pid,因此 `stdin_read` 只能来自受控提示符尾部。原生 Windows 探测(Windows 11 ARM64 guest,8 vCPU,启用就绪探测)表明只要投递正常,快路径就是确定的:每次提示符渲染都是 OSC 标记独占一个 pty 分块,紧随其后的下一个分块是 5 字节的 `dsh> `,间隔 2–28 毫秒(56 次渲染),任何提示符之后都没有可打印文本,输出中也没有终端查询;21 次运行(基线、6 个 CPU burner、模拟 x64、4 个并发 pty 实例、以及以 Windows PowerShell 5.1 作为 shell)全部七次 send 都按提示符结算——六次 `stdin_read` 加 `exit` 命令的 `session_exit`。把每个 session 的提示符尾部扣留 4 秒可以精确复现上报的失败:`expected 4 to be greater than or equal to 6`,`settleReasons` 为 `["inferred_idle","stdin_read","stdin_read","stdin_read","stdin_read","session_exit","inferred_idle"]`,退化的结算带着 `promptSeen` 为 true、`promptTextSeen` 为 false、空尾部与 `idleFor` 3301/3313 毫秒(正是配置的上界),耗时 22.1/21.9 秒对照 CI 的 17.5/20.9 秒,且所有输出断言仍然通过。CI 的触发条件本身未能在本地复现,因此该用例保留其断言并在组合中提供 `promptTailGraceMs`:它度量受控提示符路径而不是宿主机的投递时序,且在完全没有提示符标记时仍然失败。
diff --git a/.agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.i18n.yaml b/.agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.i18n.yaml
new file mode 100644
index 0000000000..7de0fe8b38
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.i18n.yaml
@@ -0,0 +1,22 @@
+# Bilingual-pair consistency record for 2026-09-27-plugin-manager-git-child-environment.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.md
+/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration:
+ en: ebb8f6a97dcc126a
+ zh: 4f7fbb487136f218
+/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/problem:
+ en: 67f7316995be5f48
+ zh: fbb8dda3946ff1f3
+/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/decision:
+ en: 7723360996b8b09e
+ zh: 78d8dfa61b1711ec
+/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/alternatives-considered:
+ en: 3f4e5b0bc9a8a961
+ zh: 5f39ffd20e076914
+/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/verification:
+ en: 7c8b5b8f4002c71a
+ zh: 59493bd968ba34bf
+/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/consequences:
+ en: b81437323c8ae6e2
+ zh: 79f8d0926218ae2e
diff --git a/.agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.md b/.agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.md
new file mode 100644
index 0000000000..ded480eacb
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.md
@@ -0,0 +1,29 @@
+# Agent Note: Plugin-manager Git children isolate the host's command-line configuration
+
+Status: implemented
+
+English | [中文](2026-09-27-plugin-manager-git-child-environment.zh.md)
+
+## Problem
+
+The self-hosted Windows serial lane failed 13 cases in run 36227311113 (job 108363684565); 12 share one cause: [github-connection.spec.ts](../../../../packages/boot/plugin-manager/tests/github-connection.spec.ts) 9 (the six install-address forms and three proxy cases) and [manager.spec.ts](../../../../packages/boot/plugin-manager/tests/manager.spec.ts) 3 real-Git install cases. The host carried Git's indexed command-line configuration — `GIT_CONFIG_COUNT` with `GIT_CONFIG_KEY_0` / `GIT_CONFIG_VALUE_0`, the group Git hands to `git -c` calls and their hook children. The shared subprocess scrub (`scrubbedParentEnv`) removes credential-shaped names, so it stripped the key and kept the counter; Git reads the counter first, and every child died with `error: missing config key GIT_CONFIG_KEY_0` / `fatal: unable to parse command-line config` (exit 128) before opening any configuration file.
+
+## Decision
+
+Both spec files delete Git's command-line configuration group from their own process environment before they spawn Git, through [isolateGitCommandLineConfig](../../../../packages/boot/plugin-manager/tests/git-environment.ts). These specs judge profile behavior — profile Git configuration, real installs — rather than host state, so a host that exports the group no longer changes their outcome. The helper restores the removed entries after the file with `vi.stubEnv(name, undefined)` and `vi.unstubAllEnvs()`.
+
+## Alternatives considered
+
+**Remove the group whole in `scrubbedParentEnv`.** The product-side fix treats the counter and its keys as one unit, but it changes shared subprocess behavior for every consumer, which the review directive for this change excludes. It is preserved on the branch `deferred/git-command-line-config-scrub-20260927` for a future product PR with its own justification.
+
+**Pin `GIT_CONFIG_COUNT: '0'` at the plugin-manager call sites.** `packages/deliverables/workspace-changes/src/git.ts` does exactly that for its children, so the same two lines in `github-connection.ts` and `operations.ts` would cover this class. That is still a product behavior change, so it stays out of scope here.
+
+**Clear the group in the lane's workflow.** The group reaches the job from the host image rather than from a step this repository owns, and other Git-spawning specs would stay exposed.
+
+## Verification
+
+`GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.bare GIT_CONFIG_VALUE_0=true pnpm exec vitest run packages/boot/plugin-manager/tests/github-connection.spec.ts packages/boot/plugin-manager/tests/manager.spec.ts` fails 12 cases at the parent revision with the lane's assertion set and passes 95/95 with the isolation in place.
+
+## Consequences
+
+The two specs pass on a host that exports Git command-line configuration. The product's treatment of such hosts is unchanged, so the same environment still breaks any other consumer of the shared scrub; a future product change to `scrubbedParentEnv` must revisit this note.
diff --git a/.agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.zh.md b/.agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.zh.md
new file mode 100644
index 0000000000..66d667af50
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: plugin-manager 的 Git 子进程隔离宿主机的命令行配置
+
+Status: implemented
+
+[English](2026-09-27-plugin-manager-git-child-environment.md) | 中文
+
+## Problem
+
+自托管 Windows 串行 lane 在运行 36227311113(作业 108363684565)中失败 13 个用例,其中 12 个同一成因:[github-connection.spec.ts](../../../../packages/boot/plugin-manager/tests/github-connection.spec.ts) 9 个(六种安装地址形式与三个代理用例)与 [manager.spec.ts](../../../../packages/boot/plugin-manager/tests/manager.spec.ts) 3 个真实 Git 安装用例。该宿主机携带 Git 的索引式命令行配置——`GIT_CONFIG_COUNT` 加上 `GIT_CONFIG_KEY_0` / `GIT_CONFIG_VALUE_0`,即 Git 交给 `git -c` 调用及其 hook 子进程的那一组变量。共享的子进程清洗(`scrubbedParentEnv`)按凭据形态的名字移除变量,于是移除了键却保留了计数;Git 先读计数,每个子进程都在打开任何配置文件之前以 `error: missing config key GIT_CONFIG_KEY_0` / `fatal: unable to parse command-line config`(退出码 128)失败。
+
+## Decision
+
+这两个 spec 文件在派生 Git 之前,从自身进程环境里删除 Git 的命令行配置组,由 [isolateGitCommandLineConfig](../../../../packages/boot/plugin-manager/tests/git-environment.ts) 完成。这些 spec 判定的是 profile 行为——profile 的 Git 配置、真实安装——而不是宿主机状态,因此导出该组的宿主机不再改变它们的结果。辅助函数在文件结束后用 `vi.stubEnv(name, undefined)` 与 `vi.unstubAllEnvs()` 还原被移除的条目。
+
+## Alternatives considered
+
+**在 `scrubbedParentEnv` 中整体移除该组。** 产品侧修复把计数与它的键当作一个整体,但它会改变所有消费者共享的子进程行为,而本次改动的 review 指令排除了这一点。该修复保留在分支 `deferred/git-command-line-config-scrub-20260927`,留待将来带自身论证的产品 PR。
+
+**在 plugin-manager 的调用点固定 `GIT_CONFIG_COUNT: '0'`。** `packages/deliverables/workspace-changes/src/git.ts` 正是这样对待自己的子进程,因此在 `github-connection.ts` 与 `operations.ts` 加同样两行即可覆盖这一类。但那仍属产品行为改动,故不在此范围内。
+
+**在 lane 的工作流里清除该组。** 该组来自宿主机镜像,而不是本仓库拥有的某个步骤,且其他会派生 Git 的 spec 仍会暴露。
+
+## Verification
+
+`GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.bare GIT_CONFIG_VALUE_0=true pnpm exec vitest run packages/boot/plugin-manager/tests/github-connection.spec.ts packages/boot/plugin-manager/tests/manager.spec.ts` 在父修订上以 lane 的断言集合失败 12 个用例,加入隔离后 95/95 通过。
+
+## Consequences
+
+这两个 spec 在导出 Git 命令行配置的宿主机上通过。产品对此类宿主机的处理未变,因此同一环境仍会破坏共享清洗的其他消费者;将来若改动 `scrubbedParentEnv`,必须回头审视本记录。
diff --git a/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.i18n.yaml b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.i18n.yaml
new file mode 100644
index 0000000000..76b9ec6213
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.i18n.yaml
@@ -0,0 +1,22 @@
+# Bilingual-pair consistency record for 2026-09-27-web-lane-assertions-name-their-input-state.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.md
+/agent-note-web-lane-assertions-name-their-input-state:
+ en: 6b04734ce8fbeb4a
+ zh: 73db3bd3ac5985dc
+/agent-note-web-lane-assertions-name-their-input-state/problem:
+ en: 12391509553e0152
+ zh: 86d02884ae9c1b09
+/agent-note-web-lane-assertions-name-their-input-state/decision:
+ en: c6a8e79707975429
+ zh: 4fe1f6abd6fd4c1e
+/agent-note-web-lane-assertions-name-their-input-state/alternatives-considered:
+ en: 6cd5940f55500c3d
+ zh: ded68b931ed81e10
+/agent-note-web-lane-assertions-name-their-input-state/verification:
+ en: 6d11abb7a60f81ae
+ zh: 42bc43777a3b02b7
+/agent-note-web-lane-assertions-name-their-input-state/consequences:
+ en: 2ecf084ca4df49c4
+ zh: bbd8823fd0910609
diff --git a/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.md b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.md
new file mode 100644
index 0000000000..acee58b51a
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.md
@@ -0,0 +1,49 @@
+# Agent Note: Web lane assertions name their input state
+
+Status: implemented
+
+English | [中文](2026-09-27-web-lane-assertions-name-their-input-state.zh.md)
+
+## Problem
+
+Three browser-lane scenarios captured an aria region while a state the golden records was only implied by whatever the pointer, the keyboard, or an in-flight Host read happened to be doing at that instant, so each capture could land on the other state.
+
+[workspace-new-session-folding](../../../../apps/web/tests/workspace-new-session-folding.e2e.ts) captures two goldens of one sidebar. `sidebar.expected.md` records the Workspace row with its row-actions cell revealed, which [Rows.module.css](../../../../packages/client/ui-workspace/src/client/rows/Rows.module.css) does under `.projectRow:hover`; `first-batch.expected.md` records the sixth session that way under `.sessionRow:hover`, the first row the batch reveals. Both captures therefore depend on where the pointer sits, and the scenario's own "Show 11 more sessions" click reflows the list under a stationary pointer. The serial self-hosted master lane failed this scenario with both goldens' requirements unmet — `treeitem "{{workspace}}"` without the actions, and the golden's hovered row rendering its plain relative time — in every one of the nine failing runs observed between 2026-09-24 and 2026-09-27.
+
+[turn-tail-actions](../../../../apps/web/tests/turn-tail-actions.e2e.ts) captures the Copy footer focused twice: `running.expected.md` records a `tooltip "Copy"` beside it, `settled.expected.md` records none. [Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx) ignores focus while the last input came from a pointer, and only a keydown clears that flag, so whether the first capture shows the bubble depends on whether the harness pressed a key after the scenario's last click.
+
+[plugin-install-cancel](../../../../apps/web/tests/plugin-install-cancel.e2e.ts) read the enabled plugin card's `data-plugin-status` once after the highlighted card appeared. The store's `enableInstalled` marks the card and starts the directory reload the Host answers, so the highlight is observable before the reload commits the enabled bundle into the list: the status flipped `disabled` → `running` 61 ms after the highlight in three local runs, and the run that failed read it inside that window.
+
+## Decision
+
+Each capture names the state its golden records before it captures.
+
+The folding scenario hovers the Workspace row before the sidebar capture and the first row the batch reveals before the first-batch capture, so both hovered rows are established rather than inherited from the layout around the Show-more click. The goldens, the folding quota, and the row rendering are unchanged; the scenario now performs the hover a reader of the golden would perform.
+
+The turn-tail scenario clears the pointer flag with a key that moves nothing, focuses the Copy button, and waits for the tooltip the running golden records. The settled capture keeps the plain focus, which is what its golden records once the Stop click has left the pointer owning the last input.
+
+The install-cancel scenario polls the card's status instead of sampling it, so the assertion observes the directory reload that the highlight precedes. The highlight stays the locator that finds the card, because the later `2400 ms` clock advance still asserts the card detaches with it.
+
+## Alternatives considered
+
+**Collapse the hovered and unhovered renderings in the aria normalizer.** The two renderings state different facts — one row is hovered, the others are not — and both goldens deliberately record the row-actions cell and the relative time. Collapsing them would delete the assertion rather than stabilize it, the same trade every lane golden that deliberately records a dated or stateful form already rejects.
+
+**Deliver a blank event and keep the pointer where the click leaves it.** The button moves as the batch inserts, so the row under the pointer still follows layout: the folding negative control below reproduces the failure with the pointer inside the sidebar and no row hovered.
+
+**Disable hover styling or the tooltip modality rule in the scenario.** Both are shipped rendering and interaction behavior the goldens are the evidence for; a scenario-local override would test a page the product does not serve.
+
+**Raise the capture timeout or retry the comparison.** Neither establishes which state was captured; `captureStableAria` already waits for two equal consecutive snapshots, and it was satisfied by the wrong state — the limit the [CI completion decision](2026-09-08-ci-readiness-and-completion.md) and the [connection and compaction fixture preconditions](2026-09-12-connection-and-compaction-fixture-preconditions.md) already record.
+
+## Verification
+
+Two negative controls reproduce the recorded signatures with the shipped assertions. Hovering the New Session row instead of the Workspace row before the folding scenario's first capture fails `sidebar.expected.md` with `treeitem "{{workspace}}"` where the golden records `treeitem "{{workspace}} Workspace actions …"`. A bare mouse press with no key before the turn-tail scenario focuses the Copy button raises no bubble, and the running capture then fails with the missing `tooltip "Copy"` the serial lane recorded. Both controls pass after the change.
+
+Measured locally, one file at a time: `DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/.e2e.ts` → workspace-new-session-folding 15/15 before the change and 20/20 after, turn-tail-actions 8/8 before and 20/20 after, plugin-install-cancel 12/12 before and 20/20 after. The lane configuration `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=16 pnpm run test:web:ci` passes all three files in three consecutive local runs.
+
+The install-cancel scenario carries a residual exposure this change does not remove and whose cause is not diagnosed: twice on `node 24 / snapshots and artifacts`, and once locally with the pre-change file on the same lane command, the highlighted card locator at `plugin-install-cancel.e2e.ts:217` stayed unmatched for 30 s after the enable click closed the dialog. The paused page clock rules out the highlight expiry timer, so the wait is not racing that; no local focused run or the three lane runs above reproduced it, and nothing here is known to hang the store's `load()`. The poll added by this change covers the status read after the card appears, not this locator.
+
+The WebKit case the serial master lane also failed is not a scenario race and stays out of this change: `declared-reasoning.e2e.ts` launches Playwright's WebKit, the lane's step fetched that browser without its host libraries, and `browserType.launch` failed with "Host system is missing dependencies to run browsers". [ci.yml](../../../../.github/workflows/ci.yml) states the persistent VM image owns Playwright's Linux system packages and installs the dependency set only off the self-hosted pool, so repairing the image or the pool's ownership is a runner change, not a scenario one. Installing WebKit's libraries on a developer machine makes the file's 8 cases pass.
+
+## Consequences
+
+The three scenarios render the aria their goldens record whatever state the harness arrives in, and no golden, product surface, or timeout changed. The input state is now owned by each capture: a scenario that captures a hovered row, a focused Tooltip anchor, or a value published by a later Host read must establish that state itself. `document-preview.e2e.ts`'s spreadsheet-selection read has the same class of defect and stays with its own change.
diff --git a/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.zh.md b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.zh.md
new file mode 100644
index 0000000000..7b7d3bc603
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.zh.md
@@ -0,0 +1,49 @@
+# Agent Note: Web lane assertions name their input state
+
+Status: implemented
+
+[English](2026-09-27-web-lane-assertions-name-their-input-state.md) | 中文
+
+## Problem
+
+浏览器通道的三个场景在捕获 aria 区域时,golden 记录的状态只是被指针、键盘或一次进行中的 Host 读取所隐含,因此每次捕获都可能落到另一种状态上。
+
+[workspace-new-session-folding](../../../../apps/web/tests/workspace-new-session-folding.e2e.ts) 捕获同一侧边栏的两份 golden。`sidebar.expected.md` 记录的是 Workspace 行显示出行操作单元格的状态,该行为由 [Rows.module.css](../../../../packages/client/ui-workspace/src/client/rows/Rows.module.css) 的 `.projectRow:hover` 提供;`first-batch.expected.md` 则以 `.sessionRow:hover` 记录第六个会话行,即该批次展开出的第一行。两次捕获因此都取决于指针所在位置,而场景自身的 “Show 11 more sessions” 点击会在指针静止时重排列表。串行自托管 master 通道在这个场景上失败时,两份 golden 的要求都没有满足——`treeitem "{{workspace}}"` 不带操作按钮,golden 记录为悬停的那一行渲染出普通的相对时间——2026-09-24 至 2026-09-27 观察到的九次失败运行全部如此。
+
+[turn-tail-actions](../../../../apps/web/tests/turn-tail-actions.e2e.ts) 两次捕获聚焦后的 Copy 页脚:`running.expected.md` 记录其旁有一个 `tooltip "Copy"`,`settled.expected.md` 则不记录。只要最后一次输入来自指针,[Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx) 就忽略聚焦,而只有 keydown 会清除该标记,因此第一次捕获是否出现气泡,取决于场景最后一次点击之后测试框架是否按过键。
+
+[plugin-install-cancel](../../../../apps/web/tests/plugin-install-cancel.e2e.ts) 在带高亮的插件卡片出现后只读一次 `data-plugin-status`。store 的 `enableInstalled` 先标记卡片,再启动由 Host 应答的目录重载,因此高亮先于重载把已启用的 bundle 写入列表即可观察:三次本地运行中状态在 `disabled` → `running` 之间用了 61 ms,失败的那次运行正是在这个窗口内读取的。
+
+## Decision
+
+每次捕获都先确立其 golden 所记录的状态。
+
+folding 场景在侧边栏捕获前悬停 Workspace 行,在 first-batch 捕获前悬停该批次展开出的第一行,两个被悬停的行都由场景确立,而不再继承 Show-more 点击周围的布局。golden、折叠配额与行渲染均不变;场景现在执行的是 golden 读者本会执行的那次悬停。
+
+turn-tail 场景先用一个不移动焦点的按键清除指针标记,再聚焦 Copy 按钮,并等待 running golden 记录的气泡。settled 捕获保留普通聚焦,这正是 Stop 点击让指针拥有最后一次输入之后、其 golden 所记录的状态。
+
+install-cancel 场景改为轮询卡片状态,而不再单次采样,因此该断言观察到的是高亮所先于的目录重载。高亮仍保留在定位卡片的 locator 中,因为随后推进 `2400 ms` 时钟的那步仍以它断言卡片会脱离。
+
+## Alternatives considered
+
+**在 aria normalizer 中折叠悬停与非悬停两种渲染。** 两者陈述不同事实——一行被悬停,其余没有被悬停——且两份 golden 都在有意记录行操作单元格与相对时间。折叠它们是在删除断言而非稳定断言,这与通道中每一份有意记录带日期或带状态形式的 golden 所拒绝的取舍相同。
+
+**投递空白事件并让指针停留在点击后的位置。** 批次插入时按钮随之移动,指针下的行仍由布局决定:下面的 folding 负向对照在指针位于侧边栏内、没有任何行被悬停时复现了该失败。
+
+**在场景内禁用悬停样式或 tooltip 的模态规则。** 两者都是 golden 所证明的已发布渲染与交互行为;场景级覆盖将测试一个产品并不提供的页面。
+
+**提高捕获超时或重试比较。** 两者都不能确立实际捕获到的是哪个状态;`captureStableAria` 本已等待连续两次快照相等,而错误的状态同样能满足它——这一限度已由 [CI 就绪与完成决策](2026-09-08-ci-readiness-and-completion.zh.md) 与 [连接与压缩 fixture 前置条件](2026-09-12-connection-and-compaction-fixture-preconditions.zh.md) 记录。
+
+## Verification
+
+两个负向对照用已发布的断言复现了记录到的特征。folding 场景第一次捕获前改为悬停 New Session 行而非 Workspace 行时,`sidebar.expected.md` 失败:golden 记录 `treeitem "{{workspace}} Workspace actions …"`,实际得到 `treeitem "{{workspace}}"`。turn-tail 场景在聚焦 Copy 按钮前只做一次鼠标按下而不按键时不会出现气泡,running 捕获随后失败并缺少串行通道记录到的 `tooltip "Copy"`。两个对照在改动之后均通过。
+
+本地按文件实测:`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/.e2e.ts` → workspace-new-session-folding 改动前 15/15、改动后 20/20,turn-tail-actions 改动前 8/8、改动后 20/20,plugin-install-cancel 改动前 12/12、改动后 20/20。通道配置 `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=16 pnpm run test:web:ci` 连续三次运行中三个文件全部通过。
+
+install-cancel 场景仍带有本次改动未消除、且原因尚未定位的残余暴露:`node 24 / snapshots and artifacts` 上有两次、把该文件恢复到改动前版本后用同一通道命令运行的本地一次,都在启用点击关闭对话框后让 `plugin-install-cancel.e2e.ts:217` 的高亮卡片 locator 持续 30 s 未匹配。页面时钟已暂停,因此可排除高亮到期计时器,该等待并非与它竞争;上述三次通道运行与所有单文件运行都未复现,且尚无证据表明 store 的 `load()` 会挂起。本次改动新增的轮询覆盖的是卡片出现之后的状态读取,而不是这个 locator。
+
+串行 master 通道同时失败的 WebKit 用例不是场景竞态,也不属于本次改动:`declared-reasoning.e2e.ts` 启动 Playwright 的 WebKit,通道的步骤只取浏览器本体而未安装宿主库,于是 `browserType.launch` 以 “Host system is missing dependencies to run browsers” 失败。[ci.yml](../../../../.github/workflows/ci.yml) 明确说明持久 VM 镜像拥有 Playwright 的 Linux 系统包,并且只在非自托管池随浏览器安装依赖集,因此修复镜像或该池的归属是 runner 侧的改动,而非场景改动。在开发者机器上安装 WebKit 依赖后该文件的 8 个用例全部通过。
+
+## Consequences
+
+三个场景无论测试框架以何种状态进入,都渲染其 golden 记录的 aria,且没有任何 golden、产品界面或超时被改动。输入状态现在由每次捕获自己拥有:凡是捕获悬停行、聚焦的 Tooltip 锚点,或由后续 Host 读取发布的值的场景,都必须自行确立该状态。`document-preview.e2e.ts` 的电子表格选区读取属于同一类缺陷,仍留给它自己的改动。
diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml
index 8bac404b2d..6cd25b059d 100644
--- a/.github/workflows/docs-pages.yml
+++ b/.github/workflows/docs-pages.yml
@@ -35,7 +35,7 @@ env:
jobs:
build:
- runs-on: ubuntu-latest
+ runs-on: blacksmith-8vcpu-ubuntu-2404
permissions:
contents: read
pages: read
diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml
index 713616f3b6..f40be2a778 100644
--- a/.github/workflows/sandbox.yml
+++ b/.github/workflows/sandbox.yml
@@ -1,7 +1,8 @@
-# Sandbox CI: the keyless real-kernel confinement proofs. This master-only
-# reference stays outside the pull-request verdict; rationale lives in
+# Sandbox CI: the keyless real-kernel confinement proofs, plus the darwin
+# unit-parity job (unit-darwin) that needs the matrix's macOS image. This
+# master-only reference stays outside the pull-request verdict; rationale lives in
# .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md.
-# A separate workflow from ci.yml because the axis is different — these jobs
+# A separate workflow from ci.yml because the axis is different — the e2e jobs
# fan out over OS×runner (kernel capabilities), not node versions. The Landlock
# launcher is built from native/system on each Landlock leg and installed
# from the same tarballs the main-repository release workflow publishes.
@@ -96,14 +97,6 @@ jobs:
pnpm --dir native/system run build:ts
pnpm --dir native/system run build:native
- # The unit suite runs on ubuntu in `checks`; this is the one darwin leg
- # in the workflow, so run it here too — the platform-dependent unit
- # expectations (Seatbelt path canonicalization: /tmp IS /private/tmp)
- # take their darwin branch only on this runner.
- - name: Unit tests (darwin parity)
- if: matrix.runner == 'seatbelt'
- run: pnpm run test
-
- name: Sandbox e2e (real kernel confinement, world-verified)
# NO_COLOR: vitest force-enables ANSI color under GITHUB_ACTIONS even
# without a TTY, which would thread escape codes through the summary
@@ -139,3 +132,30 @@ jobs:
echo "$out"
[ "$status" -eq 0 ]
echo "$out" | grep -qE 'Test Files[[:space:]]+1 passed \(1\)'
+
+ # The unit suite runs on ubuntu in ci.yml's Linux jobs; macos-latest is this
+ # workflow's only darwin runner, so the platform-dependent unit expectations
+ # (Seatbelt path canonicalization: /tmp IS /private/tmp — sandbox semantics,
+ # hence this workflow rather than ci-master.yml) take their darwin branch only
+ # here. A job of its own so the seatbelt e2e leg keeps the matrix budget. The
+ # budget below is a completion ceiling, not a latency target: the full suite
+ # takes 16 minutes on a fast macos-latest runner and was under half done at
+ # 20 on the slowest observed.
+ unit-darwin:
+ name: unit tests (darwin parity, macos-latest)
+ runs-on: macos-latest
+ timeout-minutes: 60
+ steps:
+ - uses: actions/checkout@v6
+
+ - uses: pnpm/action-setup@v4
+
+ - uses: actions/setup-node@v6
+ with:
+ node-version: 24
+
+ - name: Install (immutable)
+ run: pnpm install --frozen-lockfile
+
+ - name: Unit tests (darwin parity)
+ run: pnpm run test
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 3f325012a0..e08afa0c1c 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -56,7 +56,6 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/core`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
-| [`@opentelemetry/exporter-logs-otlp-http`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/otlp-exporter-base`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/otlp-transformer`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/resources`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
diff --git a/apps/cli/composition.md b/apps/cli/composition.md
index e72a1f0ce6..e627d9e47d 100644
--- a/apps/cli/composition.md
+++ b/apps/cli/composition.md
@@ -74,6 +74,8 @@ flowchart LR
cfg --> plugin_dsh_base_storage_domain
plugin_dsh_base_session_projection_cache["session-projection-cache @deepseek-ai/dsh-session-projection-cache"]
cfg --> plugin_dsh_base_session_projection_cache
+ plugin_dsh_base_otel["otel @deepseek-ai/dsh-otel"]
+ cfg --> plugin_dsh_base_otel
plugin_dsh_base_session_telemetry_otel["session-telemetry-otel @deepseek-ai/dsh-session-telemetry-otel"]
cfg --> plugin_dsh_base_session_telemetry_otel
plugin_dsh_base_subprocess["subprocess @deepseek-ai/dsh-subprocess-local"]
@@ -231,6 +233,7 @@ flowchart LR
| `storage-json` | `@deepseek-ai/dsh-storage-json` |
| `storage-domain` | `@deepseek-ai/dsh-storage-domain` |
| `session-projection-cache` | `@deepseek-ai/dsh-session-projection-cache` |
+| `otel` | `@deepseek-ai/dsh-otel` |
| `session-telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` |
| `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
| `sandbox` | `@deepseek-ai/dsh-sandbox-local` |
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 8b9dbbac17..be98d17216 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -39,6 +39,7 @@
"@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*",
"@deepseek-ai/dsh-cordis-client-runner": "workspace:*",
"@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:*",
+ "@deepseek-ai/dsh-experimental-schedule-bundle": "workspace:*",
"@deepseek-ai/dsh-fs-local": "workspace:*",
"@deepseek-ai/dsh-goal": "workspace:*",
"@deepseek-ai/dsh-goal-round-driver": "workspace:*",
diff --git a/apps/cli/tests/desktop-host.e2e.ts b/apps/cli/tests/desktop-host.e2e.ts
index e486354a33..615270aae0 100644
--- a/apps/cli/tests/desktop-host.e2e.ts
+++ b/apps/cli/tests/desktop-host.e2e.ts
@@ -28,7 +28,11 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
}
writeFileSync(join(root, 'package.json'), '{"type":"module"}')
writeFileSync(join(modules, 'dsh-app-boot', 'package.json'), '{"type":"module","exports":"./index.js"}')
- writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), 'export const loadProfileDirectory = () => ({ skippedBundles: [] }); export const reportSkippedBundles = () => {}; export const loadLayeredEnv = () => ({})')
+ writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), `
+ export const loadProfileDirectory = () => ({ skippedBundles: [] });
+ export const reportSkippedBundles = () => {};
+ export const loadLayeredEnv = () => ({});
+ `)
writeFileSync(join(modules, 'dsh', 'package.json'), '{"type":"module","exports":{"./profile-boot":"./profile-boot.js"}}')
writeFileSync(join(modules, 'dsh', 'profile-boot.js'), `
import { writeFileSync } from 'node:fs';
diff --git a/apps/cli/tests/profiles/web/tests/fixtures/schedule.patch.yml b/apps/cli/tests/profiles/web/tests/fixtures/schedule.patch.yml
deleted file mode 100644
index fd53f05e15..0000000000
--- a/apps/cli/tests/profiles/web/tests/fixtures/schedule.patch.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-- id: schedule
- disabled: false
diff --git a/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts
index 0cf380636f..a6846024da 100644
--- a/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts
+++ b/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts
@@ -8,7 +8,8 @@ import type { DailyScheduleRecord, ScheduleCatalogEntry } from '@deepseek-ai/dsh
import { expect, it } from 'vitest'
import { withDefaultWeb, webGet, webRequest } from './default-web-process.ts'
-const patches = [fileURLToPath(new URL('./fixtures/schedule.patch.yml', import.meta.url))]
+/** The optional Schedule bundle is the switch this built-Web scenario turns on. */
+const patches = [fileURLToPath(new URL('../../../../../../packages/experimental/schedule-bundle/cordis.patch.yml', import.meta.url))]
const id = 'retained-remote-task' as DailyScheduleRecord['id']
const sessionId = 'unloaded-owner' as ScheduleCatalogEntry['sessionId']
diff --git a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts
index a9d9821ec4..eef3fb2908 100644
--- a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts
+++ b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts
@@ -29,15 +29,13 @@ it('boots default Web without experimental modules, scheduling, time context, or
expect(roster.plugins.length).toBeGreaterThan(roster.entries.length)
expect(roster.modules.some(url => modulePackage(url) === '@deepseek-ai/dsh')).toBe(true)
expect(roster.client.entries.length).toBeGreaterThan(0)
- for (const name of [
- '@deepseek-ai/dsh-client-ui-sidebar-browser',
- '@deepseek-ai/dsh-time-context',
- '@deepseek-ai/dsh-schedule',
- '@deepseek-ai/dsh-client-ui-schedule',
- ]) {
- const entry = roster.entries.find(entry => entry.name === name)
- expect(entry, name).toBeDefined()
- expect(entry!.state, name).toBeUndefined()
+ const browser = roster.entries.find(entry => entry.name === '@deepseek-ai/dsh-client-ui-sidebar-browser')
+ expect(browser).toBeDefined()
+ expect(browser!.state).toBeUndefined()
+ expect(delivered.entries.some(entry => entry.id === '@deepseek-ai/dsh-client-ui-sidebar-browser')).toBe(false)
+ // The optional Schedule bundle inserts these rows; the shipped composition carries none of them.
+ for (const name of ['@deepseek-ai/dsh-time-context', '@deepseek-ai/dsh-schedule', '@deepseek-ai/dsh-client-ui-schedule']) {
+ expect(roster.entries.some(entry => entry.name === name), name).toBe(false)
expect(delivered.entries.some(entry => entry.id === name), name).toBe(false)
}
expect(experimentalRuntimeReferences(roster)).toEqual([])
diff --git a/apps/desktop-host/tests/platform-session.spec.ts b/apps/desktop-host/tests/platform-session.spec.ts
index fbc26da6e1..4abc1b2055 100644
--- a/apps/desktop-host/tests/platform-session.spec.ts
+++ b/apps/desktop-host/tests/platform-session.spec.ts
@@ -9,6 +9,7 @@ const state: AccountView = {
}
class Account extends DeepSeekAccount {
+ getDeviceIdentity = async () => ({ osVersion: 'test-os' })
fail = false
ended = Promise.withResolvers()
reading = Promise.withResolvers()
diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml
index 039d4701b1..93f5641fb1 100644
--- a/apps/desktop/README.i18n.yaml
+++ b/apps/desktop/README.i18n.yaml
@@ -3,11 +3,11 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
/deepseek-harness-desktop:
- en: 45561fc0dd3a1b20
- zh: f4b03355d4bd0693
+ en: b8e4f357cf330721
+ zh: cdaaf35a061f41b1
/deepseek-harness-desktop/closing-the-window-and-quitting:
- en: fb852f3bd70d8602
- zh: 3d2b0a8d580d7f0a
+ en: c323e02e3f0305fe
+ zh: 3c730db632136443
/deepseek-harness-desktop/key-technical-decisions:
en: 069fdfd72d2ea26a
zh: 76ae549d43c99bb4
@@ -15,20 +15,20 @@
en: 31cbb7452be8e1ad
zh: 502ec556e2ec301e
/deepseek-harness-desktop/installation-ownership:
- en: 8307baf6c5b691d0
- zh: 86e46b9552017ff6
+ en: d186d3cfc9597688
+ zh: 5f7094355c5f562c
/deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation:
en: 0d11af8639977a6d
zh: 86b086b12d2210d3
/deepseek-harness-desktop/develop:
- en: 9b9b362a9865e763
- zh: c748901168e2db8c
+ en: 40d2ad4014bf445b
+ zh: 6a08de06df9ed042
/deepseek-harness-desktop/develop/startup-onboarding:
- en: 5e2d62207a603d81
- zh: 5fb863d4906f4a67
+ en: a51346b8d880ddba
+ zh: 7913214c1c66063c
/deepseek-harness-desktop/develop/welcome-window-appearance:
- en: 7220801ccc3b2e6b
- zh: 042dc97976aef979
+ en: ae319a496639b38d
+ zh: ef044103240ac9da
/deepseek-harness-desktop/package:
en: 4cd8a8da6977928d
zh: 9f624855a2edac37
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index 0f579c249b..cf780cddc6 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -2,6 +2,8 @@
English | [中文](README.zh.md)
+Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. Update installation waits for its local analytics intake before locking API admission and stopping the Host. Intake has a one-second deadline; failure does not prevent installation, and collector delivery is not awaited.
+
The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it.
The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. On macOS, Hide, Hide Others, Show All, and Quit use localized labels; Hide and Quit include the DeepSeek Harness product name. These commands retain their native actions and shortcuts. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG.
@@ -28,6 +30,8 @@ Windows keeps a tray icon for the whole run. Its tooltip is the product name, a
Every ordinary quit entry — ⌘Q, the application menu, the Dock menu, the Windows tray and caption Application menu, and the quits caused by closing the mandatory-update or welcome window — asks the Host what it would interrupt. The Host answers over the private IPC channel with two facts: active tasks under the same rule as the update restart check (running agents including subagents and approval waits, queued messages, running or stopping jobs), and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded during this run. Neither fact, the quit proceeds silently. Otherwise a native message box without an owner window — hidden windows stay hidden — shows **Quit DeepSeek Harness?** with one of three localized explanations: running tasks will be interrupted, scheduled tasks will not run while the application is closed, or both. Quit is the default button and Cancel answers Esc; macOS places Cancel left of Quit, Windows places Quit left of Cancel, and the Windows task dialog shows the application icon and stays light regardless of the application theme. A Host that has not reached ready or has failed cannot run tasks, so the quit proceeds without asking. An inspection failure or a Host that misses the two-second deadline is treated as running tasks. While the box is open, another quit request joins it instead of stacking a second one (macOS also raises it; Electron exposes no handle to the Windows task dialog), the explanation does not change when work starts or ends, Quit stops the application without re-inspecting, and Cancel changes nothing. Cancelling a quit that closing the welcome window started shows the welcome window again.
+The mandatory update confirmation includes the installation wait notice on Windows; macOS uses the shorter restart explanation.
+
The confirmation is skipped when the installer restart already confirmed task interruption, when the fatal-recovery dialog exits or restarts, when the development Restart App and Host command runs, and during operating-system shutdown, restart, or log-off: Windows sets this on the definitive session-end message; macOS sets it on the power-off notification, and because another application can still cancel that shutdown, the next focus or show of the main window clears it. Installer-owned quit cancels any pending ordinary quit decision; late inspection results and dialog answers cannot open another confirmation or repeat cleanup. A user-initiated update download that finishes while the window is hidden defers its Install and Restart confirmation until the window is shown again; the mandatory flow keeps its taskbar and Dock attention. The Windows installer and uninstaller tell a user whose application is still running to quit it from the system tray. Desktop does not enable scheduled tasks by default, so the scheduled-task explanations appear only once that feature is on; reminders fire only in loaded sessions, and unloaded sessions are neither counted nor resumed until they are opened.
The tray renderer enlarges the whale by 20% around the tile center while preserving the background and aspect ratio; application and installer icons retain their original proportions.
@@ -84,6 +88,8 @@ The `dsh-app://shell/` origin serves packaged update documents, scripts, and sty
The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles.
+Update copy preserves the complete version, including prerelease suffixes, without adding v or V. When no update is available, the dialog title reports the result and the body shows the current version.
+
Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. The macOS application bundle declares English and Simplified Chinese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources.
Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor, independently of custom shortcut bindings. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy.
@@ -130,7 +136,7 @@ pnpm run start:desktop
The Web counterparts are `pnpm run dev:web` and `pnpm run start:web`, documented in the [development guide](../../docs/development.md). Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths.
-The [native/renderer keyboard tests](tests/keyboard.spec.ts) compile in [their own Client test project](../../tsconfig.desktop-keyboard-tests.json), reached by the repository Client typecheck. Its Desktop imports are limited to Cordis-free input, persistence, IPC, browser-guest, and overlay modules.
+The [native/renderer keyboard tests](tests/keyboard.spec.ts) compile as part of the repository Client typecheck. Their Desktop imports are limited to Cordis-free input, persistence, IPC, browser-guest, and overlay modules.
### Startup onboarding
@@ -138,7 +144,7 @@ The API-key input starts empty and uses `autocomplete="new-password"` to ask Chr
Repeated launches and `dsh://open` keep the workspace hidden until the startup credential check or a welcome action permits entry. Entering from Welcome places keyboard focus on the document without selecting a sidebar control; Tab navigation remains available.
-Desktop checks configured model API-key references after the Host starts and before opening the workspace. With no configured key, the welcome window offers the [API-key page](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2138-44626). Save and continue writes through the existing credential service using the official DeepSeek provider's configured reference, then opens the workspace. Set up later opens the workspace without saving the draft or a completion setting; the next process launch checks credentials again. Back to sign in returns to the entry and clears the unsaved key and validation message. Buttons keep their labels and block competing actions while saving or opening the workspace. The Desktop preload marker suppresses the Web credential dialog, while retaining the Models settings page and the welcome notice.
+Desktop checks configured model API-key references after the Host starts and before opening the workspace. With no configured key, the welcome window offers the API-key page. Save and continue writes through the existing credential service using the official DeepSeek provider's configured reference, then opens the workspace. Set up later opens the workspace without saving the draft or a completion setting; the next process launch checks credentials again. Back to sign in returns to the entry and clears the unsaved key and validation message. Buttons keep their labels and block competing actions while saving or opening the workspace. The Desktop preload marker suppresses the Web credential dialog, while retaining the Models settings page and the welcome notice.
The welcome window reads the shared `locale.preference` before it appears. An explicit English or Chinese choice wins; otherwise Desktop picks the first supported OS language and falls back to English. The main UI reads the same preference and OS language order through its isolated preload before mounting. Language changes in Settings update the shell’s current dictionary and menu; automatic selection writes no preference. The welcome window has no language selector.
@@ -146,7 +152,7 @@ The browser-login waiting page offers a copy-link action for the current pending
### Welcome window appearance
-The welcome window follows system appearance with the design’s Platform light/dark colors and shows the 600 × 700 [entry layout](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2121-39334) and the API-key form, with native window controls, a draggable title area, a local brand SVG, system sans-serif fallbacks, and locally bundled Montserrat Light for non-button text. The window uses macOS menu vibrancy or Windows acrylic with the onboarding window tint: 40% white in light mode and 50% rgb(24 25 28) in dark mode. The local React welcome entry bundles React and the shared `StateDot` loading indicator with its CSS; it uses the isolated preload without loading the main Web application. The entry, sign-in status and API-key pages share a fixed bottom action row; the back-to-sign-in link sits below it. Buttons share the platform motion timings, and Reduce Motion disables their transitions. The OS owns blur strength and outer corners. macOS Reduce Transparency suppresses translucency, and Increase Contrast forces that setting on. Save and continue writes to the development credential store; Set up later opens the real workspace without saving a key or completion flag. The generated project links the declared workspace dependency closure as well as pnpm’s hoisted packages, so unhoisted configured plugins remain resolvable. [The window note](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md) owns the material and onboarding decisions.
+The welcome window follows system appearance with the design’s Platform light/dark colors and shows the 600 × 700 entry layout and the API-key form, with native window controls, a draggable title area, a local brand SVG, system sans-serif fallbacks, and locally bundled Montserrat Light for non-button text. The window uses macOS menu vibrancy or Windows acrylic with the onboarding window tint: 40% white in light mode and 50% rgb(24 25 28) in dark mode. The local React welcome entry bundles React and the shared `StateDot` loading indicator with its CSS; it uses the isolated preload without loading the main Web application. The entry, sign-in status and API-key pages share a fixed bottom action row; the back-to-sign-in link sits below it. Buttons share the platform motion timings, and Reduce Motion disables their transitions. The OS owns blur strength and outer corners. macOS Reduce Transparency suppresses translucency, and Increase Contrast forces that setting on. Save and continue writes to the development credential store; Set up later opens the real workspace without saving a key or completion flag. The generated project links the declared workspace dependency closure as well as pnpm’s hoisted packages, so unhoisted configured plugins remain resolvable. [The window note](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md) owns the material and onboarding decisions.
## Package
diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md
index 62e34fad48..a64cbd2246 100644
--- a/apps/desktop/README.zh.md
+++ b/apps/desktop/README.zh.md
@@ -2,6 +2,8 @@
[English](README.md) | 中文
+桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。安装更新会等待该操作的本地埋点接收请求结束,再锁定 API 准入并停止 Host。接收请求的时限为一秒,失败不会阻止安装,也不等待收集端完成发送。
+
桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。
应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。
@@ -28,6 +30,8 @@ Windows 在整个运行期间常驻托盘图标。悬停提示为产品名,单
所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭强制更新窗口或欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会并入同一弹框而不叠加新弹框(macOS 上还会把它提到前面;Electron 不暴露 Windows 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。
+强制更新的安装确认在 Windows 上额外显示等待提示,macOS 只显示简短的重启说明。
+
以下情况跳过确认:安装更新的重启已确认过任务中断、致命错误恢复对话框中的退出或重启、开发版"重启应用与 Host"命令,以及操作系统关机、重启或注销:Windows 在确定性的会话结束消息上设置该状态;macOS 在关机通知上设置,而其他应用仍可能取消这次关机,因此主窗口下一次获得焦点或显示时会清除它。安装器接管退出时会取消尚未结束的普通退出决策;晚到的查询结果和弹框答复不会再次打开确认框或重复清理。窗口隐藏期间完成的用户主动发起的更新下载,把"安装并重启"确认推迟到窗口再次显示时;强制更新流程沿用其任务栏和 Dock 提醒。Windows 安装程序和卸载程序在应用仍在运行时提示用户先在系统托盘中退出。Desktop 默认未开启定时任务,定时任务的说明只在该功能开启后出现;提醒只在已加载的会话中触发,未加载的会话既不计入,也要等到打开后才会继续。
托盘渲染器以底板中心为基准将鲸鱼放大 20%,保留背景和宽高比;应用和安装器图标保持原有比例。
@@ -86,6 +90,8 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm
产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。
+升级文案完整展示版本号(包括预发布后缀),不额外添加 v 或 V。无可用更新时,弹窗标题显示检查结果,正文显示当前版本。
+
Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语和简体中文,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选,不受自定义快捷键绑定影响。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。
@@ -132,7 +138,7 @@ pnpm run start:desktop
Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开发指南](../../docs/development.zh.md)。Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
-[原生输入与渲染进程键盘测试](tests/keyboard.spec.ts)在[独立的 Client 测试项目](../../tsconfig.desktop-keyboard-tests.json)中编译,由仓库 Client 类型检查纳入。它只导入不依赖 Cordis 的 Desktop 输入、持久化、IPC、浏览器 guest 和蒙层模块。
+[原生输入与渲染进程键盘测试](tests/keyboard.spec.ts)直接纳入仓库 Client 类型检查。它只导入不依赖 Cordis 的 Desktop 输入、持久化、IPC、浏览器 guest 和蒙层模块。
### 启动引导
@@ -140,7 +146,7 @@ API Key 输入框初始为空,并通过 `autocomplete="new-password"` 请求 C
重复启动和 `dsh://open` 会保持工作区隐藏,直到启动凭据检查或欢迎页操作允许进入。从 Welcome 进入时,键盘焦点落在文档上,不选中侧边栏控件;Tab 导航仍可使用。
-Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否已配置。没有已配置的密钥时,欢迎窗口提供 [API Key 页面](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2138-44626)。“保存并继续”通过现有凭证服务写入 DeepSeek 官方提供方配置的引用,然后打开工作区。“稍后配置”打开工作区,但不保存草稿或完成标记;下次进程启动时会重新检查凭证。“返回登录”回到入口并清空未保存的密钥和校验提示。保存或打开工作区期间,按钮保持原文案并禁用竞争操作。Desktop preload 标记使 Web 凭证弹窗不再显示,同时保留模型设置页和欢迎须知。
+Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否已配置。没有已配置的密钥时,欢迎窗口提供 API Key 页面。“保存并继续”通过现有凭证服务写入 DeepSeek 官方提供方配置的引用,然后打开工作区。“稍后配置”打开工作区,但不保存草稿或完成标记;下次进程启动时会重新检查凭证。“返回登录”回到入口并清空未保存的密钥和校验提示。保存或打开工作区期间,按钮保持原文案并禁用竞争操作。Desktop preload 标记使 Web 凭证弹窗不再显示,同时保留模型设置页和欢迎须知。
欢迎窗口在显示前读取共享的 `locale.preference`。用户明确选择的英文或中文优先;否则 Desktop 按系统语言顺序匹配支持的语言,并以英文兜底。主界面在挂载前通过隔离 preload 读取同一偏好和系统语言顺序。在设置中切换语言会更新桌面壳的当前词典和菜单;自动选择不会写入偏好。欢迎窗口不提供语言切换入口。
@@ -148,7 +154,7 @@ Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否
### 欢迎窗口外观
-欢迎窗口使用设计稿的 Platform light/dark 颜色跟随系统外观,展示 600 × 700 的[入口布局](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2121-39334)和 API Key 表单,包含原生窗口控件、可拖动标题区域、本地品牌 SVG、系统无衬线字体回退,以及非按钮文字使用的本地 Montserrat Light 字体。窗口使用 macOS menu vibrancy 或 Windows acrylic,叠加 onboarding 的窗口背景色:浅色模式为 40% 白色,深色模式为 50% rgb(24 25 28)。本地 React 欢迎入口将 React、公共 `StateDot` 加载指示器及其 CSS 一起打包;它通过隔离 preload 工作,不加载主 Web 应用。入口、登录状态和 API Key 页面共用固定的底部操作行;“返回登录”链接位于操作行下方。按钮共用平台的过渡时序,开启“减少动态效果”会禁用过渡。操作系统控制模糊强度和外部圆角。macOS 的“降低透明度”会抑制半透明效果,“增强对比度”会强制开启该设置。“保存并继续”写入开发环境的凭证存储;“稍后配置”打开真实工作区,不保存密钥或完成标记。生成的开发项目同时链接已声明的 workspace 依赖闭包和 pnpm 提升的包,因此未提升的配置插件仍能解析。[窗口记录](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md)负责材质与引导决策。
+欢迎窗口使用设计稿的 Platform light/dark 颜色跟随系统外观,展示 600 × 700 的入口布局和 API Key 表单,包含原生窗口控件、可拖动标题区域、本地品牌 SVG、系统无衬线字体回退,以及非按钮文字使用的本地 Montserrat Light 字体。窗口使用 macOS menu vibrancy 或 Windows acrylic,叠加 onboarding 的窗口背景色:浅色模式为 40% 白色,深色模式为 50% rgb(24 25 28)。本地 React 欢迎入口将 React、公共 `StateDot` 加载指示器及其 CSS 一起打包;它通过隔离 preload 工作,不加载主 Web 应用。入口、登录状态和 API Key 页面共用固定的底部操作行;“返回登录”链接位于操作行下方。按钮共用平台的过渡时序,开启“减少动态效果”会禁用过渡。操作系统控制模糊强度和外部圆角。macOS 的“降低透明度”会抑制半透明效果,“增强对比度”会强制开启该设置。“保存并继续”写入开发环境的凭证存储;“稍后配置”打开真实工作区,不保存密钥或完成标记。生成的开发项目同时链接已声明的 workspace 依赖闭包和 pnpm 提升的包,因此未提升的配置插件仍能解析。[窗口记录](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md)负责材质与引导决策。
## 打包
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index cc0ce4f064..81c8059d55 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -75,6 +75,7 @@
"vite": "^6.0.0",
"@deepseek-ai/dsh-client-ui-theme": "workspace:*",
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
- "@deepseek-ai/dsh-atomic-write": "workspace:*"
+ "@deepseek-ai/dsh-atomic-write": "workspace:*",
+ "@deepseek-ai/dsh-client-product-analytics": "workspace:*"
}
}
diff --git a/apps/desktop/renderer/mandatory-update.css b/apps/desktop/renderer/mandatory-update.css
index 89cf0cf0d5..591a82b6ab 100644
--- a/apps/desktop/renderer/mandatory-update.css
+++ b/apps/desktop/renderer/mandatory-update.css
@@ -9,4 +9,8 @@
#copy { min-height: 32px; padding: 3px 0; background: transparent; color: #4d6bfe; font-size: 12px; }
#copy-message { color: #d93025 !important; }
#manual-copy { display: block; margin-top: 8px; }
-#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; accent-color: #4d6bfe; }
+#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; appearance: none; -webkit-appearance: none; background: #e6e7eb; accent-color: #0f1115; }
+
+#progress::-webkit-progress-bar { background: #e6e7eb; }
+#progress::-webkit-progress-value { background: #0f1115; }
+#progress::-moz-progress-bar { background: #0f1115; }
diff --git a/apps/desktop/src/account-backend.ts b/apps/desktop/src/account-backend.ts
index 889b4371bc..770d3e4e9d 100644
--- a/apps/desktop/src/account-backend.ts
+++ b/apps/desktop/src/account-backend.ts
@@ -67,9 +67,13 @@ export interface DesktopAccountBackend {
* @param listener - state recipient.
* @param failed - stream failure recipient.
* @param expired - live credential-expiry recipient.
+ * @param onAnalyticsEnabledChanged - optional Desktop collection-policy recipient; disconnected streams publish false.
* @returns stream disposer.
*/
- watch(listener: (state: AccountView) => void, failed: () => void, expired: () => void): () => void
+ watch(
+ listener: (state: AccountView) => void, failed: () => void, expired: () => void,
+ onAnalyticsEnabledChanged?: (enabled: boolean) => void,
+ ): () => void
}
/**
@@ -86,19 +90,21 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
state: () => call('getState'),
start: client => call('startSignIn', { client, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }),
cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: client => call('signOut', { client }),
- watch(listener, failed, expired) {
+ watch(listener, failed, expired, onAnalyticsEnabledChanged) {
let closed = false
let socket: WebSocket | undefined
let retry: ReturnType | undefined
const connect = (): void => {
const streamId = randomUUID()
const expiryStreamId = randomUUID()
+ const analyticsPolicyStreamId = randomUUID()
void cookies().then((cookie) => {
if (closed) return
const url = new URL(REMOTE_STREAM_MUX_PATH, origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 })
socket.on('open', () => {
+ if (onAnalyticsEnabledChanged !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: analyticsPolicyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } }))
socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } }))
socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } }))
})
@@ -106,6 +112,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
try {
const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data)
const frame = parseRemoteStreamServerMessage(bytes.toString('utf8'))
+ if (frame.streamId === analyticsPolicyStreamId) {
+ if (frame.type === 'item' && typeof frame.value === 'boolean') onAnalyticsEnabledChanged?.(frame.value)
+ else onAnalyticsEnabledChanged?.(false)
+ return
+ }
if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return }
if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream')
if (frame.type === 'item') listener(accountView(frame.value))
@@ -113,11 +124,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
} catch { socket?.close() }
})
socket.on('error', () => { socket?.close() })
- socket.on('close', () => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } })
- }).catch(() => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } })
+ socket.on('close', () => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } })
+ }).catch(() => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } })
}
connect()
- return () => { closed = true; clearTimeout(retry); socket?.close() }
+ return () => { closed = true; onAnalyticsEnabledChanged?.(false); clearTimeout(retry); socket?.close() }
},
}
}
diff --git a/apps/desktop/src/client/WelcomePage.tsx b/apps/desktop/src/client/WelcomePage.tsx
index 8fcde7768a..b367061732 100644
--- a/apps/desktop/src/client/WelcomePage.tsx
+++ b/apps/desktop/src/client/WelcomePage.tsx
@@ -19,6 +19,7 @@ export function Welcome({ api }: { api: WelcomeApi }) {
const [expiryNotice, setExpiryNotice] = useState(false)
const [page, setPage] = useState('entry')
const pageRef = useRef('entry')
+ const visiblePage = useRef('entry')
const [attempt, setAttempt] = useState(null)
const attemptRef = useRef(null)
const [starting, setStarting] = useState(false)
@@ -69,12 +70,14 @@ export function Welcome({ api }: { api: WelcomeApi }) {
}, [api, m.welcomeTitle])
useEffect(() => {
+ if (page === 'entry' && visiblePage.current !== 'entry') void api.analytics?.('auth_page_view', {})
+ visiblePage.current = page
if (page === 'key') input.current?.focus()
else if (page === 'entry' && focusEntry.current) {
focusEntry.current = false
keyButton.current?.focus()
}
- }, [page])
+ }, [page, api])
useEffect(() => {
if (copyState !== 'copied' && copyState !== 'failed') return
@@ -85,6 +88,7 @@ export function Welcome({ api }: { api: WelcomeApi }) {
async function saveKey(event: FormEvent) {
event.preventDefault()
if (busyRef.current) return
+ void api.analytics?.('api_key_save_click', {})
const value = draft.trim()
if (!/^[\x21-\x7e]+$/.test(value) || /^[A-Z][A-Z0-9_]*=[^=]/.test(value)
|| ((value.startsWith('"') || value.startsWith("'") || value.charCodeAt(0) === 96) && value.at(-1) === value[0])) {
@@ -198,15 +202,15 @@ export function Welcome({ api }: { api: WelcomeApi }) {
-
-
+
+
-
-
+
+
diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts
index b3f9e535c9..066418e6bb 100644
--- a/apps/desktop/src/locale.ts
+++ b/apps/desktop/src/locale.ts
@@ -77,17 +77,17 @@ export const en = {
updateCheckFailed: 'Could not check for updates. Please try again later.',
updateDownloadFailed: 'Could not download the update. Please try again.',
updateInstallFailed: 'Could not install the update. Please try again later.',
- updateCheckNetworkFailed: 'Could not check for updates. Please try again later. The connection was interrupted. Check your network and try again.',
- updateDownloadNetworkFailed: 'Could not download the update. Please try again. The connection was interrupted. Check your network and try again.',
- updateInstallNetworkFailed: 'Could not install the update. Please try again later. The connection was interrupted. Check your network and try again.',
+ updateCheckNetworkFailed: 'Could not check for updates. Check your connection and try again.',
+ updateDownloadNetworkFailed: 'Could not download the update. Check your connection and try again.',
+ updateInstallNetworkFailed: 'Could not install the update. Check your connection and try again.',
unknownError: 'Unknown error',
updateCheckTitle: 'Check for Updates',
- updateCurrent: 'No updates available. Current version: V{version}',
+ updateCurrentDetail: 'Current version: {version}',
+ updateCurrent: 'You’re up to date!',
updateChecking: 'Checking for updates…',
updateDownload: 'Download update',
- updateDownloadedTitle: 'DeepSeek Harness v{version} downloaded',
- updateDownloadedDetail: 'The update package has downloaded. Select “Install and Restart” to restart the app and begin installation.',
- updateDownloadedTitleWindows: 'New version v{version} is ready',
+ updateDownloadedTitle: 'Version {version} is ready to install',
+ updateDownloadedDetail: 'The app will close during the update and reopen automatically when it is complete.',
updateDownloadedDetailWindows: 'The app will close temporarily during the update and reopen automatically when it is complete.\n\nThe update may take some time. Please wait and do not launch the app again during installation.',
updateClose: 'Close',
updateAcknowledge: 'OK',
@@ -97,47 +97,47 @@ export const en = {
updateInstalling: 'Preparing to restart…',
updateRetry: 'Retry update',
updateActiveTasks: 'Tasks are still in progress',
- updateActiveTasksDetail: 'Restarting to update may interrupt these tasks. Continue updating?',
+ updateActiveTasksDetail: 'Updating will stop the tasks in progress and restart the app. Continue?',
updateStopTasks: 'Stop tasks and update',
- updateTasksChanged: 'New tasks started. Review the update confirmation again.',
+ updateTasksChanged: 'New tasks have started. Confirm again to stop the tasks and update.',
updateTasksUnavailable: 'Task status is unavailable. Try updating again when the workspace is ready.',
- updateStopFailed: 'Tasks could not be stopped safely. The update was not installed. Please try again later.',
+ updateStopFailed: 'Could not safely stop the tasks. The update has not been installed. Please try again later.',
updateTechnicalDetails: 'View technical details',
updateTitle: 'DeepSeek Harness Update',
- updateAvailable: 'An update is available',
- updateDetail: 'DeepSeek Harness {version}\n\nDeepSeek Harness will restart to complete the update.',
+ updateAvailable: 'New version available: {version}',
+ updateDetail: 'Once the download is complete, you can install the update and restart the app.',
installAndRestart: 'Install and Restart',
later: 'Later',
updateFailedTitle: 'Update Failed',
- mandatoryTitle: 'Update required',
- mandatoryDetail: 'This version is no longer supported. Update to continue. Existing tasks can keep running until you approve a restart.',
- mandatoryUnavailable: 'The update requirement could not be checked. Retry when the connection is available.',
+ mandatoryTitle: 'Update to continue',
+ mandatoryDetail: 'This version is no longer supported. Update to continue. Tasks in progress will keep running until you confirm installation and restart.',
+ mandatoryUnavailable: 'Could not check update requirements. Please try again later.',
policyLoginTitle: 'Sign in to the test environment',
- policyLoginRequired: 'This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update.',
+ policyLoginRequired: 'Sign in with Feishu to check update requirements for this test build. Signing in will not download or install an update.',
policyLogin: 'Sign in with Feishu',
- policyLoginFailed: 'Test environment sign-in did not complete. Please check your connection and try again.',
+ policyLoginFailed: 'Feishu sign-in did not complete. Please try again.',
policyLoginLoading: 'Loading sign-in page…',
- mandatoryNoRelease: 'No applicable update is available. Check again or contact support.',
+ mandatoryNoRelease: 'No compatible update was found. Check again or contact support.',
mandatoryRefresh: 'Check again',
- mandatoryPage: 'Open download page',
- mandatoryCopy: 'Copy download address',
- mandatoryPageFailed: 'The download page could not be opened. Copy the address below and open it in your browser.',
- mandatoryActionFailed: 'The update action failed. Retry; the update requirement remains active.',
+ mandatoryPage: 'Download from the official website',
+ mandatoryCopy: 'Copy download link',
+ mandatoryPageFailed: 'Could not open the official download page. Copy the link and open it in your browser.',
+ mandatoryActionFailed: 'The action failed. Please try again. You can use the app again after the update is complete.',
mandatoryReady: 'Update ready',
- mandatoryVersion: 'V{version}',
- mandatoryReadyDetail: 'Installing the update will restart the application.',
- mandatoryDeferred: 'Existing tasks can keep running. Update to continue using the application.',
- mandatoryContinue: 'Continue installing update',
+ mandatoryVersion: 'New version: {version}',
+ mandatoryReadyDetail: 'The app will close during the update and reopen automatically when it is complete.',
+ mandatoryDeferred: 'Tasks in progress will keep running. Complete the update before using the app again.',
+ mandatoryContinue: 'Continue update',
mandatoryInspecting: 'Checking tasks…',
- mandatoryStopping: 'Safely stopping tasks in the application.',
- mandatoryRestarting: 'The application will restart shortly. Please wait.',
+ mandatoryStopping: 'Stopping tasks…',
+ mandatoryRestarting: 'The app will restart shortly. Please wait.',
mandatoryDownloadFailed: 'The update files could not be downloaded or prepared. Please retry.',
- mandatoryInstallFailed: 'The update was not installed. Check tasks again and retry.',
- mandatoryOpenHelp: 'If the page did not open, you can',
- mandatoryReopen: 'Open download page again',
+ mandatoryInstallFailed: 'The update has not been installed. Check the tasks again and retry.',
+ mandatoryOpenHelp: 'Page didn’t open?',
+ mandatoryReopen: 'Open the official download page again',
mandatoryCopied: 'Link copied',
- mandatoryCopyFailed: 'Copy failed. Select and copy the address below manually.',
- mandatoryAddress: 'Download address',
+ mandatoryCopyFailed: 'Could not copy the link. Select and copy it below.',
+ mandatoryAddress: 'Download link',
mandatoryNotification: 'Return to the application to confirm installation and restart.',
} as const
@@ -221,17 +221,17 @@ export const zh = {
updateCheckFailed: '检查更新失败,请稍后重试。',
updateDownloadFailed: '下载更新失败,请重试。',
updateInstallFailed: '安装更新失败,请稍后重试。',
- updateCheckNetworkFailed: '检查更新失败,请稍后重试。网络连接异常,请检查网络后重试。',
- updateDownloadNetworkFailed: '下载更新失败,请重试。网络连接异常,请检查网络后重试。',
- updateInstallNetworkFailed: '安装更新失败,请稍后重试。网络连接异常,请检查网络后重试。',
+ updateCheckNetworkFailed: '检查更新失败,请检查网络连接后重试。',
+ updateDownloadNetworkFailed: '下载更新失败,请检查网络连接后重试。',
+ updateInstallNetworkFailed: '安装更新失败,请检查网络连接后重试。',
unknownError: '未知错误',
updateCheckTitle: '检查更新',
- updateCurrent: '当前暂无可用更新。当前版本:V{version}',
+ updateCurrentDetail: '当前版本:{version}',
+ updateCurrent: '已是最新版本',
updateChecking: '正在检查更新…',
updateDownload: '下载更新',
- updateDownloadedTitle: 'DeepSeek Harness v{version} 下载完成',
- updateDownloadedDetail: '安装包已下载完毕,点击“安装并重启”,即刻重启客户端,开始部署。',
- updateDownloadedTitleWindows: '新版本 v{version} 已准备就绪',
+ updateDownloadedTitle: '新版本 {version} 已准备就绪',
+ updateDownloadedDetail: '更新期间应用将暂时关闭,完成后会自动打开。',
updateDownloadedDetailWindows: '更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。',
updateClose: '关闭',
updateAcknowledge: '确定',
@@ -241,47 +241,47 @@ export const zh = {
updateInstalling: '正在准备重启…',
updateRetry: '重试更新',
updateActiveTasks: '仍有进行中的任务',
- updateActiveTasksDetail: '重启更新可能中断这些任务,是否要继续更新?',
+ updateActiveTasksDetail: '更新将停止进行中的任务并重启应用,是否继续?',
updateStopTasks: '停止任务并更新',
- updateTasksChanged: '有新任务开始,请重新确认更新。',
+ updateTasksChanged: '有新任务开始运行,请重新确认是否停止任务并更新。',
updateTasksUnavailable: '无法确认任务状态,请在工作区就绪后重试更新。',
- updateStopFailed: '未能安全停止任务,更新未安装。请稍后重试。',
+ updateStopFailed: '未能安全停止任务,更新尚未安装,请稍后重试。',
updateTechnicalDetails: '查看技术详情',
updateTitle: 'DeepSeek Harness 更新',
- updateAvailable: '发现可用更新',
- updateDetail: 'DeepSeek Harness {version}\n\nDeepSeek Harness 将重启以完成更新。',
+ updateAvailable: '发现新版本 {version}',
+ updateDetail: '下载完成后,可安装并重启应用。',
installAndRestart: '安装并重启',
later: '稍后',
updateFailedTitle: '更新失败',
- mandatoryTitle: '需要更新',
- mandatoryDetail: '当前版本已停止支持,请更新后继续使用。在您确认重启之前,现有任务可以继续运行。',
- mandatoryUnavailable: '暂时无法检查更新要求,请在网络恢复后重试。',
+ mandatoryTitle: '请更新后继续使用',
+ mandatoryDetail: '当前版本已停止支持,请更新后继续使用。确认安装并重启前,进行中的任务会继续运行。',
+ mandatoryUnavailable: '暂时无法检查更新要求,请稍后重试。',
policyLoginTitle: '登录测试环境',
- policyLoginRequired: '这是测试版应用,检查更新要求需要先通过飞书登录。登录不会下载或安装更新。',
+ policyLoginRequired: '此测试版需要先通过飞书登录,才能检查更新要求。登录不会下载或安装更新。',
policyLogin: '通过飞书登录',
- policyLoginFailed: '测试环境登录未完成,请检查网络后重试。',
+ policyLoginFailed: '飞书登录未完成,请重试。',
policyLoginLoading: '正在加载登录页面…',
- mandatoryNoRelease: '暂时没有可用的更新,请重新检查或联系支持人员。',
+ mandatoryNoRelease: '暂未找到适用的更新,请重新检查或联系支持人员。',
mandatoryRefresh: '重新检查',
mandatoryPage: '前往官网下载',
mandatoryCopy: '复制下载链接',
- mandatoryPageFailed: '无法打开浏览器,请复制下载链接后手动打开。',
- mandatoryActionFailed: '更新操作失败,请重试;应用仍需更新后才能继续使用。',
+ mandatoryPageFailed: '无法打开官网下载页面,请复制链接后在浏览器中打开。',
+ mandatoryActionFailed: '操作失败,请重试。完成更新后才能继续使用应用。',
mandatoryReady: '更新已准备就绪',
- mandatoryVersion: 'V{version}',
- mandatoryReadyDetail: '安装后将重新启动应用。',
- mandatoryDeferred: '现有任务可以继续运行。完成更新后才能继续操作应用。',
- mandatoryContinue: '继续安装更新',
+ mandatoryVersion: '新版本:{version}',
+ mandatoryReadyDetail: '更新期间应用将暂时关闭,完成后会自动打开。',
+ mandatoryDeferred: '进行中的任务会继续运行。请完成更新后再操作应用。',
+ mandatoryContinue: '继续更新',
mandatoryInspecting: '正在检查任务状态…',
- mandatoryStopping: '正在安全结束应用中的任务。',
+ mandatoryStopping: '正在停止任务…',
mandatoryRestarting: '应用即将重启,请稍候。',
mandatoryDownloadFailed: '更新文件下载或准备失败,请重试。',
mandatoryInstallFailed: '更新尚未安装,请重新检查任务后重试。',
- mandatoryOpenHelp: '若页面未打开,可',
- mandatoryReopen: '重新打开官网',
- mandatoryCopied: '已复制链接',
- mandatoryCopyFailed: '复制失败,请手动选择下方地址复制。',
- mandatoryAddress: '下载地址',
+ mandatoryOpenHelp: '页面未打开?',
+ mandatoryReopen: '重新前往官网下载',
+ mandatoryCopied: '链接已复制',
+ mandatoryCopyFailed: '复制失败,请手动选择并复制下方链接。',
+ mandatoryAddress: '下载链接',
mandatoryNotification: '返回应用确认安装并重启。',
} as const satisfies DesktopMessages
@@ -325,7 +325,7 @@ export function formatDesktopMessage(
/**
* Select localized copy for an ordinary downloaded-update confirmation.
* @param messages - Selected Desktop dictionary.
- * @param version - Prepared update version without a leading v.
+ * @param version - Prepared update version, including any prerelease suffix.
* @param platform - Operating system presenting the confirmation.
* @returns The versioned title and installation guidance.
*/
@@ -335,7 +335,7 @@ export function desktopUpdateReadyConfirmation(
platform: string,
): { message: string; detail: string } {
return {
- message: formatDesktopMessage(platform === 'win32' ? messages.updateDownloadedTitleWindows : messages.updateDownloadedTitle, { version }),
+ message: formatDesktopMessage(messages.updateDownloadedTitle, { version }),
detail: platform === 'win32' ? messages.updateDownloadedDetailWindows : messages.updateDownloadedDetail,
}
}
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
index a59a8a8b35..eb75980ad7 100644
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -1,3 +1,4 @@
+import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types'
import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
/** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */
@@ -44,7 +45,7 @@ import { DesktopUpdateSchedule, resolveDesktopUpdateScheduleConfig } from './upd
import { desktopUpdateErrorSummary, presentDesktopUpdate } from './update-presentation.ts'
import { desktopErrorState } from './startup-error.ts'
import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig, type DesktopPolicyState } from './mandatory-update-policy.ts'
-import { desktopClientMetadata } from './client-metadata.ts'
+import { desktopClientMetadata, desktopClientVersion } from './client-metadata.ts'
import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts'
import { DesktopPolicyTestAuth } from './policy-test-auth.ts'
import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts'
@@ -376,6 +377,14 @@ async function main(): Promise {
const browserGuests = new DesktopBrowserGuests(() => hostUrl)
let injections: readonly unknown[] = []
let welcomeBackend: DesktopWelcomeBackend | undefined
+ let reportedLaunch = false
+ let analyticsEnabled = false
+ const track = async (eventName: K, attributes: ProductEventMap[K]): Promise => {
+ const event = { eventName, attributes, timestamp: Date.now() } as ProductEvent
+ try {
+ if (analyticsEnabled) await welcomeBackend?.report(event)
+ } catch (_error) { /* Analytics cannot interrupt native actions. */ }
+ }
let stopAccount: (() => void) | undefined
let openedAttempt: string | undefined
let returnedAttempt: string | undefined
@@ -408,7 +417,7 @@ async function main(): Promise {
const backend = new DesktopBackendController((onFailure) => {
const hostInspectPort = developmentHostInspectPort(development)
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
- hostInspectPort, process.env, onFailure,
+ hostInspectPort, { ...process.env, DSH_CLIENT_VERSION: desktopClientVersion() }, onFailure,
primaryRuntime,
resources, (next) => { platformView.setSession(next) })
return {
@@ -419,6 +428,8 @@ async function main(): Promise {
if (ready.injections === undefined) throw new Error('Desktop Host did not provide boot injections')
injections = ready.injections
welcomeBackend = await connectDesktopWelcome(ready.url, (input, init) => net.fetch(input, init), async () => (await session.defaultSession.cookies.get({ url: ready.url })).map(cookie => `${cookie.name}=${cookie.value}`).join('; '))
+ analyticsEnabled = await welcomeBackend.analyticsEnabled().catch(() => false)
+ if (!reportedLaunch) { reportedLaunch = true; void track('desktop_app_launch', {}) }
stopAccount?.()
const accountBackend = welcomeBackend.account
stopAccount = accountBackend.watch((state) => {
@@ -456,9 +467,11 @@ async function main(): Promise {
const state = await accountBackend.state()
if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state)
}).catch(() => undefined)
- })
+ }, (enabled) => { analyticsEnabled = enabled })
},
stop: async () => {
+ analyticsEnabled = false
+ stopAccount?.()
try { await host.stop(requireCleanStop) }
catch (error) {
if (!requireCleanStop || !(error instanceof DesktopHostUncleanExitError)) throw error
@@ -567,6 +580,8 @@ async function main(): Promise {
const result = await updateDialog.show(parent, confirmation)
if (result.response !== 0 || isMandatory()) return false
}
+ // The update lock rejects new HTTP requests, including analytics intake.
+ await track('desktop_upgrade_install_restart_click', {})
if (backend.host !== host) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable)
try {
const stillActive = await host.updateTasks('lock')
@@ -591,11 +606,14 @@ async function main(): Promise {
}
return true
},
+ undefined, undefined, undefined,
+ (success, reason) => { void track('desktop_upgrade_download_result', { is_success: success, ...reason === undefined ? {} : { error_reason: reason } }) },
)
const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env))
const downloadUpdate = async (version: string): Promise => {
+ void track('desktop_upgrade_click', {})
updateJournal?.action('download-requested')
const state = await updates.download(version)
if (state.phase !== 'ready' || quitting) return state
@@ -778,7 +796,8 @@ async function main(): Promise {
if (state.phase === 'error' && state.failedOperation === 'check') { await showUpdateFailure(state); return }
if (state.phase === 'idle') {
await ordinaryMessageBox({ type: 'info', title: locale.messages.updateCheckTitle,
- message: formatDesktopMessage(locale.messages.updateCurrent, { version: app.getVersion() }) })
+ message: locale.messages.updateCurrent,
+ detail: formatDesktopMessage(locale.messages.updateCurrentDetail, { version: app.getVersion() }) })
return
}
if (state.phase === 'ready' || (state.phase === 'error' && state.failedOperation === 'install')) {
@@ -790,8 +809,9 @@ async function main(): Promise {
}
if (state.phase !== 'available' && !(state.phase === 'error' && state.failedOperation === 'download')) return
if (manual) {
- const result = await ordinaryMessageBox({ title: locale.messages.updateCheckTitle, message: locale.messages.updateAvailable,
- detail: formatDesktopMessage(locale.messages.updateDetail, { version: state.version ?? '' }),
+ const result = await ordinaryMessageBox({ title: locale.messages.updateCheckTitle,
+ message: formatDesktopMessage(locale.messages.updateAvailable, { version: state.version ?? '' }),
+ detail: locale.messages.updateDetail,
buttons: [locale.messages.updateDownload], cancelId: 1 })
if (result.response !== 0) return
}
@@ -1083,12 +1103,15 @@ async function main(): Promise {
const showWelcome = (): Promise => {
if (quitting) return Promise.resolve()
if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) {
+ if (!welcomeWindow.isVisible()) void track('auth_page_view', {})
welcomeWindow.show()
welcomeWindow.focus()
return Promise.resolve()
}
openingWelcome ??= (async () => {
welcomeWindow = await openWelcomeWindow(locale, {
+ analytics: track,
+ analyticsEnabled: () => Promise.resolve(analyticsEnabled),
takeNotice: () => {
const notice = pendingWelcomeNotice
pendingWelcomeNotice = undefined
diff --git a/apps/desktop/src/mandatory-update-window.ts b/apps/desktop/src/mandatory-update-window.ts
index 724df4ff97..3713883600 100644
--- a/apps/desktop/src/mandatory-update-window.ts
+++ b/apps/desktop/src/mandatory-update-window.ts
@@ -243,7 +243,11 @@ export class DesktopMandatoryUpdateWindow {
}
private view(): MandatoryUpdateView {
- return { locale: this.options.locale, policy: this.options.policy(), update: this.options.update(),
+ const locale = process.platform === 'win32'
+ ? { ...this.options.locale, messages: { ...this.options.locale.messages,
+ mandatoryReadyDetail: this.options.locale.messages.updateDownloadedDetailWindows } }
+ : this.options.locale
+ return { locale, policy: this.options.policy(), update: this.options.update(),
deferred: this.deferred,
...(this.confirmation === undefined ? {}
: { confirmation: { version: this.confirmation.version, active: this.confirmation.active, revision: this.confirmation.revision } }),
diff --git a/apps/desktop/src/preload-welcome.ts b/apps/desktop/src/preload-welcome.ts
index e9ec1aa910..6748367e81 100644
--- a/apps/desktop/src/preload-welcome.ts
+++ b/apps/desktop/src/preload-welcome.ts
@@ -10,6 +10,10 @@ const locale = process.argv.find(argument => argument.startsWith(prefix))?.slice
if (locale === undefined) throw new Error('desktop welcome: missing window locale')
const api: WelcomeApi = {
...resolveDesktopLocale(locale),
+ analytics: async (eventName, attributes) => {
+ if (await api.analyticsEnabled()) await ipcRenderer.invoke(WELCOME_IPC.analytics, eventName, attributes)
+ },
+ analyticsEnabled: () => ipcRenderer.invoke(WELCOME_IPC.analyticsEnabled) as Promise,
takeNotice: () => ipcRenderer.invoke(WELCOME_IPC.takeNotice) as Promise,
startSignIn: () => ipcRenderer.invoke(WELCOME_IPC.start) as Promise,
cancelSignIn: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.cancel, id) as Promise,
diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts
index c7bf91c384..8d6eb808e7 100644
--- a/apps/desktop/src/update-coordinator.ts
+++ b/apps/desktop/src/update-coordinator.ts
@@ -45,6 +45,7 @@ export class DesktopUpdateCoordinator {
* @param updater - Process-owned Electron updater, replaceable at the network/platform test boundary.
* @param enabled - Whether this process has a packaged update source.
* @param currentVersion - Actual installed application version.
+ * @param downloadResult - Once per completed download attempt, including platform preparation failures.
*/
constructor(
private readonly publish: (state: DesktopUpdateState) => DesktopUpdateState,
@@ -52,6 +53,7 @@ export class DesktopUpdateCoordinator {
private readonly updater: AppUpdater = autoUpdater,
private readonly enabled: () => boolean = () => app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')),
private readonly currentVersion: () => string = () => app.getVersion(),
+ private readonly downloadResult?: (success: boolean, reason?: string) => void,
) {
if (updater === autoUpdater) {
// electron-updater omits this internal transport property from its public declarations.
@@ -108,9 +110,11 @@ export class DesktopUpdateCoordinator {
try {
await this.updater.downloadUpdate()
if (!this.downloaded) throw new Error('desktop update: platform preparation did not report readiness')
+ this.downloadResult?.(true)
return this.setState({ phase: 'ready', version })
} catch (error) {
this.downloaded = false
+ this.downloadResult?.(false, error instanceof DesktopUpdatePreparationError ? error.kind : 'download_failed')
return this.setState(this.failure(error, 'download'))
}
}).finally(() => { this.downloadOperation = undefined })
diff --git a/apps/desktop/src/welcome-api.ts b/apps/desktop/src/welcome-api.ts
index 015296776a..c693f385d6 100644
--- a/apps/desktop/src/welcome-api.ts
+++ b/apps/desktop/src/welcome-api.ts
@@ -1,11 +1,14 @@
/** Operations available to the isolated native welcome renderer. */
import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
+import type { ProductEventMap } from '@deepseek-ai/dsh-client-product-analytics/types'
import type { DesktopLocale } from './locale.ts'
/** Private native welcome channels, installed only while its window exists. */
export const WELCOME_IPC = {
saveApiKey: 'dsh-welcome:save-api-key',
+ analytics: 'dsh-welcome:analytics',
+ analyticsEnabled: 'dsh-welcome:analytics-enabled',
skip: 'dsh-welcome:skip',
start: 'dsh-welcome:start',
cancel: 'dsh-welcome:cancel',
@@ -20,8 +23,14 @@ export type WelcomeSaveResult = { readonly ok: true } | { readonly ok: false }
/** One-time notification retained by the main process until Welcome receives it. */
export type WelcomeNotice = 'session-expired'
+type WelcomeEventName = 'auth_page_view' | 'auth_page_click' | 'api_key_save_click'
+
/** Host-owned operations used by the welcome window. */
export interface WelcomeOperations {
+ /** @param eventName - allowed welcome event. @param attributes - approved fields without credentials. */
+ analytics?(eventName: K, attributes: ProductEventMap[K]): Promise
+ /** @returns the Host's current effective collection policy. */
+ analyticsEnabled(): Promise
/** @returns the pending notification, clearing it before another renderer can receive it. */
takeNotice(): Promise
/** @returns account state after starting a login attempt. */
diff --git a/apps/desktop/src/welcome-backend.ts b/apps/desktop/src/welcome-backend.ts
index a0bb4e9440..54ab46d85e 100644
--- a/apps/desktop/src/welcome-backend.ts
+++ b/apps/desktop/src/welcome-backend.ts
@@ -1,3 +1,4 @@
+import type { ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types'
/** Native welcome operations using the shared Web authentication and RPC APIs. */
import { randomUUID } from 'node:crypto'
@@ -13,7 +14,11 @@ export interface WelcomeState {
/** Narrow operations available to the native welcome flow. */
export interface DesktopWelcomeBackend {
+ /** @returns the current Host policy; every read observes live configuration. */
+ analyticsEnabled(): Promise
readonly account: DesktopAccountBackend
+ /** @param event - desktop-owned fields. @returns after local Host intake. */
+ report(event: ProductEvent): Promise
/** @returns Configured-key presence and the shared language preference, without credential values. */
read(): Promise
/** @returns The saved UI language without account or provider requests. */
@@ -44,11 +49,14 @@ export async function connectDesktopWelcome(
const authenticated = await send(authenticatedUrl, { credentials: 'include' })
await authenticated.body?.cancel()
if (!authenticated.ok) throw new Error('desktop welcome: Web authentication failed')
- const invoke = async (request: { namespace: string; method: string; args: Record }): Promise => {
+ const invoke = async (
+ request: { namespace: string; method: string; args: Record },
+ signal?: AbortSignal,
+ ): Promise => {
const rpcId = randomUUID()
const method = `${request.namespace}/${request.method}`
const response = await send(new URL(`/api/${method}`, origin).href, {
- method: 'POST', credentials: 'include', redirect: 'error',
+ method: 'POST', credentials: 'include', redirect: 'error', ...(signal === undefined ? {} : { signal }),
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args: request.args } }),
})
@@ -115,6 +123,12 @@ export async function connectDesktopWelcome(
return {
account,
read,
+ async analyticsEnabled() {
+ const enabled = await invoke({ namespace: 'productAnalytics', method: 'enabled', args: {} }, AbortSignal.timeout(1000))
+ if (typeof enabled !== 'boolean') throw new Error('desktop analytics: invalid collection policy')
+ return enabled
+ },
+ async report(event) { await invoke({ namespace: 'productAnalytics', method: 'report', args: { event } }, AbortSignal.timeout(1000)) },
async readLocalePreference() {
const settings = await invoke({ namespace: 'settings', method: 'describe', args: {} })
if (!record(settings) || !Array.isArray(settings.namespaces)) throw new Error('desktop welcome: missing settings namespaces')
diff --git a/apps/desktop/src/welcome-window.ts b/apps/desktop/src/welcome-window.ts
index a32e1f1c56..61fda9e36a 100644
--- a/apps/desktop/src/welcome-window.ts
+++ b/apps/desktop/src/welcome-window.ts
@@ -57,14 +57,16 @@ let disposeActiveHandlers: (() => void) | undefined
* @returns the visible window; a failed load destroys it before rejecting.
*/
export async function openWelcomeWindow(locale: DesktopLocale, operations: WelcomeOperations): Promise {
- const window = new BrowserWindow(welcomeWindowOptions(process.platform, locale))
+ const options = welcomeWindowOptions(process.platform, locale)
+ const window = new BrowserWindow(options)
disposeActiveHandlers?.()
let active = true
const disposeHandlers = (): void => {
if (!active) return
active = false
for (const channel of [
- WELCOME_IPC.takeNotice, WELCOME_IPC.saveApiKey, WELCOME_IPC.skip, WELCOME_IPC.start, WELCOME_IPC.cancel, WELCOME_IPC.copyLink,
+ WELCOME_IPC.analyticsEnabled, WELCOME_IPC.analytics, WELCOME_IPC.takeNotice, WELCOME_IPC.saveApiKey,
+ WELCOME_IPC.skip, WELCOME_IPC.start, WELCOME_IPC.cancel, WELCOME_IPC.copyLink,
]) {
ipcMain.removeHandler(channel)
}
@@ -76,6 +78,20 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco
throw new Error('desktop welcome: rejected action from an unowned frame')
}
}
+ ipcMain.handle(WELCOME_IPC.analyticsEnabled, (event) => {
+ assertSender(event)
+ return operations.analyticsEnabled()
+ })
+ ipcMain.handle(WELCOME_IPC.analytics, async (event, eventName: unknown, attributes: unknown) => {
+ assertSender(event)
+ if (typeof attributes !== 'object' || attributes === null || Array.isArray(attributes)) throw new Error('desktop welcome: invalid analytics attributes')
+ if (eventName === 'auth_page_click' && 'button_name' in attributes && Object.keys(attributes).length === 1
+ && (attributes.button_name === 'sign_in' || attributes.button_name === 'api-key')) {
+ await operations.analytics?.(eventName, { button_name: attributes.button_name })
+ } else if ((eventName === 'auth_page_view' || eventName === 'api_key_save_click') && Object.keys(attributes).length === 0) {
+ await operations.analytics?.(eventName, {})
+ } else throw new Error('desktop welcome: invalid analytics event')
+ })
ipcMain.handle(WELCOME_IPC.takeNotice, async (event) => { assertSender(event); return operations.takeNotice() })
ipcMain.handle(WELCOME_IPC.saveApiKey, async (event, value: unknown) => {
assertSender(event)
@@ -108,6 +124,9 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco
throw error
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Another window can replace ownership during loadFile.
- if (active && !window.isDestroyed()) window.show()
+ if (active && !window.isDestroyed()) {
+ window.show()
+ void operations.analytics?.('auth_page_view', {})
+ }
return window
}
diff --git a/apps/desktop/tests/account-backend.spec.ts b/apps/desktop/tests/account-backend.spec.ts
index 5dde64c7d0..b9cee7b61a 100644
--- a/apps/desktop/tests/account-backend.spec.ts
+++ b/apps/desktop/tests/account-backend.spec.ts
@@ -49,14 +49,17 @@ it('receives live expiry separately from account snapshots', async () => {
if (typeof address === 'string' || address === null) throw new Error('missing server address')
const connected = once(server, 'connection')
const expired = vi.fn()
+ const onAnalyticsEnabledChanged = vi.fn()
const snapshot = { status: 'signed-out', attempt: null,
links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }
const received = Promise.withResolvers()
const backend = desktopAccountBackend(`http://127.0.0.1:${address.port}`, () => Promise.resolve(snapshot), () => Promise.resolve(''))
- const stop = backend.watch((value) => { expect(value).toEqual(snapshot); received.resolve(undefined) }, vi.fn(), expired)
+ const stop = backend.watch(
+ (value) => { expect(value).toEqual(snapshot); received.resolve(undefined) }, vi.fn(), expired, onAnalyticsEnabledChanged,
+ )
onTestFinished(stop)
await connected
- // Both logical streams share the socket; observe their opening frames before sending data.
+ // Logical streams share the socket; observe their opening frames before sending data.
const streams = new Map()
const opened = Promise.withResolvers()
const socket = [...server.clients][0]!
@@ -64,11 +67,19 @@ it('receives live expiry separately from account snapshots', async () => {
if (!Buffer.isBuffer(data)) throw new Error('expected a Buffer WebSocket frame')
const frame = JSON.parse(data.toString('utf8')) as { endpoint: string; streamId: string }
streams.set(frame.endpoint, frame.streamId)
- if (streams.size === 2) opened.resolve(undefined)
+ if (streams.size === 3) opened.resolve(undefined)
})
await opened.promise
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watch'), value: snapshot }))
await received.promise
+ socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: true }))
+ await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(true) })
+ socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: false }))
+ await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(false) })
+ socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: true }))
+ await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(true) })
+ socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: 'invalid policy' }))
+ await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(false) })
expect(expired).not.toHaveBeenCalled()
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watchExpiry'), value: 'session-expired' }))
await vi.waitFor(() => { expect(expired).toHaveBeenCalledOnce() })
diff --git a/apps/desktop/tests/expected/mandatory-update-zh.json b/apps/desktop/tests/expected/mandatory-update-zh.json
index fbe0356bcc..13a960e51e 100644
--- a/apps/desktop/tests/expected/mandatory-update-zh.json
+++ b/apps/desktop/tests/expected/mandatory-update-zh.json
@@ -1,8 +1,8 @@
{
"title": "仍有进行中的任务",
- "detail": "重启更新可能中断这些任务,是否要继续更新?",
+ "detail": "更新将停止进行中的任务并重启应用,是否继续?",
"status": "",
- "version": "V1.0.1-nightly.1",
+ "version": "新版本:1.0.1-nightly.1",
"buttons": [
"停止任务并更新",
"稍后更新"
diff --git a/apps/desktop/tests/expected/policy-login-en.json b/apps/desktop/tests/expected/policy-login-en.json
index 3415d20680..822c1a0f30 100644
--- a/apps/desktop/tests/expected/policy-login-en.json
+++ b/apps/desktop/tests/expected/policy-login-en.json
@@ -1,18 +1,18 @@
{
"returned": [
"Checking for updates…",
- "No updates available. Current version: V1.0.0",
- "This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update."
+ "You’re up to date!",
+ "Sign in with Feishu to check update requirements for this test build. Signing in will not download or install an update."
],
"cancelled": [
"Checking for updates…",
- "No updates available. Current version: V1.0.0",
- "This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update."
+ "You’re up to date!",
+ "Sign in with Feishu to check update requirements for this test build. Signing in will not download or install an update."
],
"failed": [
"Checking for updates…",
- "No updates available. Current version: V1.0.0",
- "This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update.",
- "Test environment sign-in did not complete. Please check your connection and try again."
+ "You’re up to date!",
+ "Sign in with Feishu to check update requirements for this test build. Signing in will not download or install an update.",
+ "Feishu sign-in did not complete. Please try again."
]
}
diff --git a/apps/desktop/tests/expected/update-guidance-en.json b/apps/desktop/tests/expected/update-guidance-en.json
new file mode 100644
index 0000000000..13becc60a1
--- /dev/null
+++ b/apps/desktop/tests/expected/update-guidance-en.json
@@ -0,0 +1,49 @@
+[
+ {
+ "title": "Update to continue",
+ "detail": "This version is no longer supported. Update to continue. Tasks in progress will keep running until you confirm installation and restart.",
+ "version": "New version: 0.1.7-alpha.2",
+ "update": "Download update"
+ },
+ {
+ "title": "Update ready",
+ "detail": "The app will close during the update and reopen automatically when it is complete.",
+ "version": "New version: 0.1.7-alpha.2",
+ "update": "Install and Restart"
+ },
+ {
+ "title": "Tasks are still in progress",
+ "detail": "Updating will stop the tasks in progress and restart the app. Continue?",
+ "version": "New version: 0.1.7-alpha.2",
+ "update": "Stop tasks and update",
+ "later": "Update later"
+ },
+ {
+ "title": "Update ready",
+ "detail": "Tasks in progress will keep running. Complete the update before using the app again.",
+ "version": "New version: 0.1.7-alpha.2",
+ "update": "Continue update"
+ },
+ {
+ "title": "Preparing to restart…",
+ "detail": "Stopping tasks…",
+ "version": "New version: 0.1.7-alpha.2"
+ },
+ {
+ "title": "Update to continue",
+ "detail": "This version is no longer supported. Update to continue. Tasks in progress will keep running until you confirm installation and restart.",
+ "version": "New version: 0.1.7-alpha.2",
+ "error": "The update has not been installed. Check the tasks again and retry.",
+ "update": "Retry update",
+ "page": "Download from the official website"
+ },
+ {
+ "title": "Update to continue",
+ "detail": "This version is no longer supported. Update to continue. Tasks in progress will keep running until you confirm installation and restart.",
+ "version": "",
+ "error": "No compatible update was found. Check again or contact support.",
+ "page": "Open the official download page again",
+ "browser-message": "Could not open the official download page. Copy the link and open it in your browser.",
+ "copy-message": "Could not copy the link. Select and copy it below."
+ }
+]
diff --git a/apps/desktop/tests/expected/update-guidance-zh-CN.json b/apps/desktop/tests/expected/update-guidance-zh-CN.json
new file mode 100644
index 0000000000..8ff226d70e
--- /dev/null
+++ b/apps/desktop/tests/expected/update-guidance-zh-CN.json
@@ -0,0 +1,49 @@
+[
+ {
+ "title": "请更新后继续使用",
+ "detail": "当前版本已停止支持,请更新后继续使用。确认安装并重启前,进行中的任务会继续运行。",
+ "version": "新版本:0.1.7-alpha.2",
+ "update": "下载更新"
+ },
+ {
+ "title": "更新已准备就绪",
+ "detail": "更新期间应用将暂时关闭,完成后会自动打开。",
+ "version": "新版本:0.1.7-alpha.2",
+ "update": "安装并重启"
+ },
+ {
+ "title": "仍有进行中的任务",
+ "detail": "更新将停止进行中的任务并重启应用,是否继续?",
+ "version": "新版本:0.1.7-alpha.2",
+ "update": "停止任务并更新",
+ "later": "稍后更新"
+ },
+ {
+ "title": "更新已准备就绪",
+ "detail": "进行中的任务会继续运行。请完成更新后再操作应用。",
+ "version": "新版本:0.1.7-alpha.2",
+ "update": "继续更新"
+ },
+ {
+ "title": "正在准备重启…",
+ "detail": "正在停止任务…",
+ "version": "新版本:0.1.7-alpha.2"
+ },
+ {
+ "title": "请更新后继续使用",
+ "detail": "当前版本已停止支持,请更新后继续使用。确认安装并重启前,进行中的任务会继续运行。",
+ "version": "新版本:0.1.7-alpha.2",
+ "error": "更新尚未安装,请重新检查任务后重试。",
+ "update": "重试更新",
+ "page": "前往官网下载"
+ },
+ {
+ "title": "请更新后继续使用",
+ "detail": "当前版本已停止支持,请更新后继续使用。确认安装并重启前,进行中的任务会继续运行。",
+ "version": "",
+ "error": "暂未找到适用的更新,请重新检查或联系支持人员。",
+ "page": "重新前往官网下载",
+ "browser-message": "无法打开官网下载页面,请复制链接后在浏览器中打开。",
+ "copy-message": "复制失败,请手动选择并复制下方链接。"
+ }
+]
diff --git a/apps/desktop/tests/expected/update-restart-darwin-en-US.json b/apps/desktop/tests/expected/update-restart-darwin-en-US.json
index 599f39fa68..744475cb22 100644
--- a/apps/desktop/tests/expected/update-restart-darwin-en-US.json
+++ b/apps/desktop/tests/expected/update-restart-darwin-en-US.json
@@ -1,6 +1,6 @@
{
- "message": "DeepSeek Harness v0.1.99 downloaded",
- "detail": "The update package has downloaded. Select “Install and Restart” to restart the app and begin installation.",
+ "message": "Version 0.1.7-alpha.2 is ready to install",
+ "detail": "The app will close during the update and reopen automatically when it is complete.",
"buttons": [
"Install and Restart"
]
diff --git a/apps/desktop/tests/expected/update-restart-darwin-zh-CN.json b/apps/desktop/tests/expected/update-restart-darwin-zh-CN.json
index d7ea2daa57..0bc196f79a 100644
--- a/apps/desktop/tests/expected/update-restart-darwin-zh-CN.json
+++ b/apps/desktop/tests/expected/update-restart-darwin-zh-CN.json
@@ -1,6 +1,6 @@
{
- "message": "DeepSeek Harness v0.1.99 下载完成",
- "detail": "安装包已下载完毕,点击“安装并重启”,即刻重启客户端,开始部署。",
+ "message": "新版本 0.1.7-alpha.2 已准备就绪",
+ "detail": "更新期间应用将暂时关闭,完成后会自动打开。",
"buttons": [
"安装并重启"
]
diff --git a/apps/desktop/tests/expected/update-restart-win32-en-US.json b/apps/desktop/tests/expected/update-restart-win32-en-US.json
index 0e8af77ca2..71a7f43253 100644
--- a/apps/desktop/tests/expected/update-restart-win32-en-US.json
+++ b/apps/desktop/tests/expected/update-restart-win32-en-US.json
@@ -1,5 +1,5 @@
{
- "message": "New version v0.1.99 is ready",
+ "message": "Version 0.1.7-alpha.2 is ready to install",
"detail": "The app will close temporarily during the update and reopen automatically when it is complete.\n\nThe update may take some time. Please wait and do not launch the app again during installation.",
"buttons": [
"Install and Restart"
diff --git a/apps/desktop/tests/expected/update-restart-win32-zh-CN.json b/apps/desktop/tests/expected/update-restart-win32-zh-CN.json
index 104f40b266..520dda5711 100644
--- a/apps/desktop/tests/expected/update-restart-win32-zh-CN.json
+++ b/apps/desktop/tests/expected/update-restart-win32-zh-CN.json
@@ -1,5 +1,5 @@
{
- "message": "新版本 v0.1.99 已准备就绪",
+ "message": "新版本 0.1.7-alpha.2 已准备就绪",
"detail": "更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。",
"buttons": [
"安装并重启"
diff --git a/apps/desktop/tests/fixtures/workspace-updates.mjs b/apps/desktop/tests/fixtures/workspace-updates.mjs
index b136c79ce0..b7f79c777b 100644
--- a/apps/desktop/tests/fixtures/workspace-updates.mjs
+++ b/apps/desktop/tests/fixtures/workspace-updates.mjs
@@ -218,7 +218,7 @@ async function qualify() {
const checking = await dialogWith(messages.updateChecking)
await screenshot(checking, 'checking.png')
server.release()
- const current = await dialogWith(messages.updateCurrent.replace('{version}', app.getVersion()))
+ const current = await dialogWith(messages.updateCurrent)
assert.equal(current, checking)
await clickText(current, messages.updateAcknowledge)
assert.equal(server.requests.filter(path => path === '/payload.exe').length, 0)
@@ -227,7 +227,7 @@ async function qualify() {
server.select('healthy', '0.1.6-nightly.1')
checkMenu.click()
- const available = await dialogWith(messages.updateAvailable)
+ const available = await dialogWith(messages.updateAvailable.replace('{version}', '0.1.6-nightly.1'))
server.select('corrupt', '0.1.6-nightly.1')
await clickText(available, messages.updateDownload)
await waitFor(() => fixture.coordinator.state.phase === 'error', 'checksum failure')
diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts
index e47edce02d..abce2c0bbc 100644
--- a/apps/desktop/tests/main-startup.spec.ts
+++ b/apps/desktop/tests/main-startup.spec.ts
@@ -172,6 +172,9 @@ const harness = await vi.hoisted(async () => {
}),
})
let accountListener: ((state: AccountView) => void) | undefined
+ let analyticsEnabled = true
+ let analyticsEnabledListener: ((enabled: boolean) => void) | undefined
+ const analytics = vi.fn(async (_event: unknown) => {})
const nativeTheme = { themeSource: 'system', shouldUseDarkColors: false }
const trays: FakeTray[] = []
class FakeTray extends EventEmitter {
@@ -184,14 +187,21 @@ const harness = await vi.hoisted(async () => {
const shellDialog = { isOpen: false, focus: vi.fn() }
return {
failWindow(error: Error) { windowFailure = error },
- windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, trays, FakeTray, backgroundNotice, shellDialog,
+ windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, analytics,
+ trays, FakeTray, backgroundNotice, shellDialog,
menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall,
platformDispose,
platformCloseAndWait,
- watchAccount: (listener: (state: AccountView) => void) => {
+ get analyticsEnabled() { return analyticsEnabled },
+ set analyticsEnabled(value: boolean) { analyticsEnabled = value; analyticsEnabledListener?.(value) },
+ watchAccount: (
+ listener: (state: AccountView) => void, _failed: () => void, _expired: () => void,
+ onAnalyticsEnabledChanged?: (enabled: boolean) => void,
+ ) => {
+ analyticsEnabledListener = onAnalyticsEnabledChanged
accountListener = listener
- return () => { accountListener = undefined }
+ return () => { accountListener = undefined; analyticsEnabledListener = undefined }
},
publishAccount(state: AccountView) { accountListener?.(state) },
ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(),
@@ -343,6 +353,8 @@ vi.mock('../src/platform-view.ts', async importOriginal => ({
}))
vi.mock('../src/welcome-backend.ts', () => ({
connectDesktopWelcome: async () => ({
+ analyticsEnabled: async () => harness.analyticsEnabled,
+ report: harness.analytics,
readLocalePreference: async () => null,
read: async (): Promise => (await harness.hosts.at(-1)!.fetch()).json() as Promise,
save: async () => ({ ok: true }),
@@ -396,6 +408,7 @@ beforeEach(() => {
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined)
vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', undefined)
vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3')
+ harness.analyticsEnabled = true
})
afterEach(async () => {
@@ -543,7 +556,7 @@ describe('desktop main startup', () => {
expect(testAuth.login).toHaveBeenCalledOnce()
const messages = harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message)
expect(messages.filter(message => message === en.policyLoginRequired)).toHaveLength(1)
- expect(messages).toContain('No updates available. Current version: V1.0.0')
+ expect(messages).toContain('You’re up to date!')
} finally {
explanation.resolve({ response: 1 })
login.resolve('cancelled')
@@ -1062,7 +1075,7 @@ describe('desktop main startup', () => {
vi.stubGlobal('process', { ...process, platform })
vi.spyOn(harness.app, 'getPreferredSystemLanguages').mockReturnValue([language])
await readyForUpdate()
- harness.updateState = { phase: 'ready', version: '0.1.99' }
+ harness.updateState = { phase: 'ready', version: '0.1.7-alpha.2' }
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 1 })
await expect(harness.prepareUpdate()).resolves.toBe(false)
const { message, detail, buttons } = harness.dialog.showMessageBox.mock.lastCall![0] as MessageBoxOptions
@@ -1441,7 +1454,7 @@ describe('desktop main startup', () => {
await prompt
expect(signal.aborted).toBe(true)
expect(harness.dialog.showMessageBox).toHaveBeenLastCalledWith(expect.objectContaining({
- message: 'No updates available. Current version: V1.0.0',
+ message: 'You’re up to date!', detail: 'Current version: 1.0.0',
}))
})
@@ -1506,7 +1519,7 @@ describe('desktop main startup', () => {
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => policy.promise))
harness.updateCheck.mockResolvedValue({ phase: 'available', version: '1.0.1-nightly.1' })
harness.dialog.showMessageBox.mockImplementation(({ signal, message }: { signal?: AbortSignal; message: string }) => {
- if (message !== en.updateAvailable || signal === undefined) return Promise.resolve({ response: 1 })
+ if (message !== en.updateAvailable.replace('{version}', '1.0.1-nightly.1') || signal === undefined) return Promise.resolve({ response: 1 })
available.resolve(signal)
return new Promise((resolve) => { signal.addEventListener('abort', () => { resolve({ response: 0 }) }, { once: true }) })
})
@@ -1543,7 +1556,7 @@ describe('desktop main startup', () => {
const ordinaryShown = Promise.withResolvers()
const policyShown = Promise.withResolvers()
harness.dialog.showMessageBox.mockImplementation(({ message }: { message: string }) => {
- if (message.startsWith('No updates available.')) {
+ if (message === en.updateCurrent) {
ordinaryShown.resolve(undefined)
return ordinaryResult.promise
}
@@ -1638,6 +1651,23 @@ describe('desktop main startup', () => {
await expect(preparing).resolves.toBe(true)
})
+ it.each(['accepted', 'failed'] as const)('settles analytics intake before locking API admission and continues after intake failure: %s', async (outcome) => {
+ const host = await readyForUpdate()
+ await vi.waitFor(() => { expect(harness.analytics).toHaveBeenCalledWith(expect.objectContaining({ eventName: 'desktop_app_launch' })) })
+ const intake = Promise.withResolvers()
+ harness.analytics.mockImplementationOnce(() => intake.promise)
+ harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
+ const preparing = harness.prepareUpdate()
+ await vi.waitFor(() => { expect(harness.analytics).toHaveBeenLastCalledWith(expect.objectContaining({ eventName: 'desktop_upgrade_install_restart_click' })) })
+ expect(host.updateTasks.mock.calls).toEqual([['inspect']])
+ expect(host.stop).not.toHaveBeenCalled()
+ if (outcome === 'accepted') intake.resolve(undefined)
+ else intake.reject(new Error('local analytics intake timed out'))
+ await host.stopping.promise
+ host.exited.resolve()
+ await expect(preparing).resolves.toBe(true)
+ })
+
it('reports a Platform storage cleanup failure as preparation failure without stopping the Host', async () => {
const host = await readyForUpdate()
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
@@ -2034,7 +2064,9 @@ describe('desktop main startup', () => {
primaryRuntime: join('desktop-test-resources', 'runtime', 'primary-runtime'),
profile: 'desktop-test-profile',
})
- expect(harness.hosts[0]!.environment).toBe(process.env)
+ expect(harness.hosts[0]!.environment).not.toBe(process.env)
+ expect(harness.hosts[0]!.environment?.DSH_CLIENT_VERSION).toBe('1.2.3')
+ expect(harness.analytics).toHaveBeenCalledExactlyOnceWith({ eventName: 'desktop_app_launch', timestamp: Date.now(), attributes: {} })
expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1)
expect(harness.windows).toHaveLength(1)
expect(window.urls).toEqual(['dsh-app://app/'])
@@ -2159,3 +2191,15 @@ it.each([['light', false], ['dark', true]] as const)('opens Platform authorizati
harness.publishAccount(state)
expect(harness.openExternal).toHaveBeenCalledExactlyOnceWith(`https://platform.deepseek.com/dsh/authorize?state=state-1&theme=${theme}`)
})
+
+it('disables native product events for a disabled Desktop launch', async () => {
+ harness.analyticsEnabled = false
+ await import('../src/main.ts')
+ await harness.preparing.promise
+ harness.prepared.resolve()
+ await harness.hostStarted.promise
+ harness.hosts[0]!.ready.resolve()
+ await harness.navigated.promise
+ expect(harness.analytics).not.toHaveBeenCalled()
+ harness.analyticsEnabled = true
+})
diff --git a/apps/desktop/tests/mandatory-update-window.spec.ts b/apps/desktop/tests/mandatory-update-window.spec.ts
index 484d01065f..901205bdb6 100644
--- a/apps/desktop/tests/mandatory-update-window.spec.ts
+++ b/apps/desktop/tests/mandatory-update-window.spec.ts
@@ -205,3 +205,10 @@ it('rebinds policy updates and reloads to a replacement Windows main window', ()
sender: window.webContents, senderFrame: window.webContents.mainFrame,
})).not.toThrow()
})
+
+it.each(['darwin', 'win32'] as const)('includes the installation wait notice only on Windows: %s', (platform) => {
+ const f = setup(platform)
+ expect(f.view().locale.messages.mandatoryReadyDetail).toBe(platform === 'win32'
+ ? '更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。'
+ : '更新期间应用将暂时关闭,完成后会自动打开。')
+})
diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts
index d600b5eecb..790ad63fcf 100644
--- a/apps/desktop/tests/update-coordinator.spec.ts
+++ b/apps/desktop/tests/update-coordinator.spec.ts
@@ -61,14 +61,15 @@ function fixture() {
})
const quitAndInstall = vi.fn()
const beforeRestart = vi.fn(async () => true)
+ const downloadResult = vi.fn()
const states: DesktopUpdateState[] = []
const updater = Object.assign(events, { checkForUpdates, downloadUpdate, quitAndInstall }) as unknown as AppUpdater
const coordinator = new DesktopUpdateCoordinator(
(state) => { states.push(state); return state },
- beforeRestart, updater, () => true, () => '1.1.0-alpha.1',
+ beforeRestart, updater, () => true, () => '1.1.0-alpha.1', downloadResult,
)
coordinators.push(coordinator)
- return { coordinator, updater, events, states, checkForUpdates, downloadUpdate, quitAndInstall, beforeRestart }
+ return { coordinator, updater, events, states, checkForUpdates, downloadUpdate, quitAndInstall, beforeRestart, downloadResult }
}
describe('desktop update coordinator', () => {
@@ -222,3 +223,22 @@ describe('desktop update coordinator', () => {
expect(f.events.listenerCount('download-progress')).toBe(0)
})
})
+
+
+it('reports one download result when callers share an operation and none for cached readiness', async () => {
+ const f = fixture()
+ await f.coordinator.check()
+ await Promise.all([f.coordinator.download('1.1.0-rc.2'), f.coordinator.download('1.1.0-rc.2')])
+ await f.coordinator.download('1.1.0-rc.2')
+ expect(f.downloadResult).toHaveBeenCalledExactlyOnceWith(true)
+})
+
+
+it.each([new DesktopUpdatePreparationError('stop-failed', 'private diagnostic'), new Error('private URL')])('reports safe download failure classification: %s', async (error) => {
+ const f = fixture()
+ await f.coordinator.check()
+ f.downloadUpdate.mockRejectedValueOnce(error)
+ await f.coordinator.download('1.1.0-rc.2')
+ expect(f.downloadResult).toHaveBeenCalledExactlyOnceWith(false, error instanceof DesktopUpdatePreparationError ? 'stop-failed' : 'download_failed')
+ expect(JSON.stringify(f.downloadResult.mock.calls)).not.toContain('private')
+})
diff --git a/apps/desktop/tests/update-error-renderer.spec.ts b/apps/desktop/tests/update-error-renderer.spec.ts
index ad92f2df8f..54bc5d5e2e 100644
--- a/apps/desktop/tests/update-error-renderer.spec.ts
+++ b/apps/desktop/tests/update-error-renderer.spec.ts
@@ -95,7 +95,7 @@ it('keeps mandatory diagnostics expandable without clearing the block or authori
expect([p.element('error').textContent, p.element('technical-details-label').textContent,
p.element('update').textContent]).toMatchInlineSnapshot(`
[
- "未能安全停止任务,更新未安装。请稍后重试。",
+ "未能安全停止任务,更新尚未安装,请稍后重试。",
"查看技术详情",
"重试更新",
]
@@ -182,7 +182,7 @@ it('uses the same modal for download, verification, inspected confirmation and t
p.publish({ ...p.initial, update: { phase: 'installing' }, restart: 'preparing' })
expect(p.element('detail').textContent).toBe('应用即将重启,请稍候。')
p.publish({ ...p.initial, update: { phase: 'installing' }, restart: 'stopping-tasks' })
- expect(p.element('detail').textContent).toBe('正在安全结束应用中的任务。')
+ expect(p.element('detail').textContent).toBe('正在停止任务…')
expect(p.document.querySelector('main')).toBe(modal)
})
@@ -191,7 +191,7 @@ it('reveals the complete selectable address only after copy failure, without rep
await expect.poll(() => p.element('error').textContent).toBe('更新文件下载或准备失败,请重试。')
const originalError = p.element('error').textContent
p.publish({ ...p.initial, navigation: { page: 'requested' } })
- expect(p.element('browser-message').textContent).toBe('若页面未打开,可')
+ expect(p.element('browser-message').textContent).toBe('页面未打开?')
expect(p.element('manual-copy').hidden).toBe(true)
p.publish({ ...p.initial, navigation: { page: 'failed', copy: 'failed' } })
expect(p.element('manual-copy').hidden).toBe(false)
@@ -199,11 +199,11 @@ it('reveals the complete selectable address only after copy failure, without rep
expect((p.element('address') as HTMLTextAreaElement).readOnly).toBe(true)
expect(p.element('error').textContent).toBe(originalError)
expect(p.element('technical-details-content').textContent).toBe('HASH_MISMATCH')
- expect(p.element('copy-message').textContent).toBe('复制失败,请手动选择下方地址复制。')
+ expect(p.element('copy-message').textContent).toBe('复制失败,请手动选择并复制下方链接。')
p.publish({ ...p.initial, navigation: { page: 'failed', copy: 'copied' } })
expect(p.element('manual-copy').hidden).toBe(true)
expect((p.element('address') as HTMLTextAreaElement).value).toBe('')
- expect(p.element('copy').textContent).toBe('已复制链接')
+ expect(p.element('copy').textContent).toBe('链接已复制')
expect(p.action).not.toHaveBeenCalled()
})
@@ -215,3 +215,31 @@ it('renders server markup literally in a dedicated safety case', async () => {
expect(p.element('title').childElementCount).toBe(0)
expect(p.element('detail').childElementCount).toBe(0)
})
+
+it.each(['en', 'zh-CN'])('records mandatory update guidance and actions across its states: %s', async (language) => {
+ const p = page('mandatory-update')
+ const locale = resolveDesktopLocale(language)
+ const base: MandatoryUpdateView = { locale, deferred: false, policy: { blocking: true, checking: false,
+ page: 'https://example.invalid/download' }, update: { phase: 'available', version: '0.1.7-alpha.2' } }
+ let publish!: (view: MandatoryUpdateView) => void
+ const api: MandatoryUpdateApi = { status: async () => base, action: async () => {},
+ subscribe: (listener) => { publish = listener; return () => {} } }
+ Object.defineProperty(p.dom.window, 'dshMandatoryUpdate', { value: api })
+ p.run()
+ await expect.poll(() => p.element('title').textContent).toBe(locale.messages.mandatoryTitle)
+ const states: MandatoryUpdateView[] = [base,
+ { ...base, update: { ...base.update, phase: 'ready' }, confirmation: { active: false, version: '0.1.7-alpha.2', revision: 1 } },
+ { ...base, update: { ...base.update, phase: 'ready' }, confirmation: { active: true, version: '0.1.7-alpha.2', revision: 1 } },
+ { ...base, update: { ...base.update, phase: 'ready' }, deferred: true },
+ { ...base, update: { ...base.update, phase: 'installing' }, restart: 'stopping-tasks' },
+ { ...base, update: { ...base.update, phase: 'error', failedOperation: 'install' } },
+ { ...base, update: { phase: 'idle' }, navigation: { page: 'failed', copy: 'failed' } },
+ ]
+ const snapshots = states.map((state) => {
+ publish(state)
+ return Object.fromEntries(['title', 'detail', 'version', 'error', 'update', 'later', 'page', 'browser-message', 'copy-message']
+ .filter(id => !p.element(id).hidden && !p.element(id).closest('[hidden]'))
+ .map(id => [id, p.element(id).textContent]))
+ })
+ await expect(JSON.stringify(snapshots, null, 2) + '\n').toMatchFileSnapshot(`./expected/update-guidance-${language}.json`)
+})
diff --git a/apps/desktop/tests/welcome-host.spec.ts b/apps/desktop/tests/welcome-host.spec.ts
index 3c2066ae34..dea2905583 100644
--- a/apps/desktop/tests/welcome-host.spec.ts
+++ b/apps/desktop/tests/welcome-host.spec.ts
@@ -20,6 +20,8 @@ function transport(preference?: string) {
if (method === 'account/getState') value = { links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null }
else if (method === 'settings/describe') value = { namespaces }
else if (method === 'llm/listConfigurableProviders') value = [{ settingsNs: 'llm-pi-ai', settingsPath: ['profiles', 'example'] }]
+ else if (method === 'productAnalytics/enabled') value = true
+ else if (method === 'productAnalytics/report') value = undefined
else if (method === 'credentials/set') keys.set(payload.args.ref, payload.args.value)
else value = Object.fromEntries(payload.args.refs.map(ref => [ref, { configured: keys.has(ref), writable: true }]))
return Response.json({ type: 'server-response', rpcId, result: { ok: true, value } })
@@ -34,6 +36,7 @@ describe('desktop welcome Web operations', () => {
const host = transport()
const backend = await connectDesktopWelcome(url, host.send)
expect(host.send).toHaveBeenCalledExactlyOnceWith(url, { credentials: 'include' })
+ expect(await backend.analyticsEnabled()).toBe(true)
expect(await backend.save('sk-example')).toEqual({ ok: true })
expect(host.keys.get('CUSTOM_DEEPSEEK_KEY')).toBe('sk-example')
expect(await backend.read()).toEqual({ loggedIn: false, hasApiKey: true, writable: true, localePreference: null })
@@ -90,6 +93,19 @@ describe('desktop welcome Web operations', () => {
await expect(backend.read()).rejects.toThrow('Web RPC failed')
})
+
+ it('submits native analytics through authenticated RPC with a bounded request', async () => {
+ const host = transport()
+ const backend = await connectDesktopWelcome(url, host.send)
+ const event = { eventName: 'desktop_app_launch' as const, timestamp: 100, attributes: {} }
+ await backend.report(event)
+ const [input, init] = host.send.mock.calls.at(-1)!
+ expect(input).toBe('http://127.0.0.1:19387/api/productAnalytics/report')
+ expect(init?.credentials).toBe('include')
+ expect(init?.signal).toBeInstanceOf(AbortSignal)
+ expect(JSON.parse(init!.body as string)).toMatchObject({ payload: { args: { event } } })
+ })
+
it('refuses an unauthenticated Web launch', async () => {
const send = vi.fn[1]>(async () => new Response(null, { status: 401 }))
await expect(connectDesktopWelcome(url, send)).rejects.toThrow('Web authentication failed')
diff --git a/apps/desktop/tests/welcome-renderer.client.spec.tsx b/apps/desktop/tests/welcome-renderer.client.spec.tsx
index ee0f06fb09..575c7423a0 100644
--- a/apps/desktop/tests/welcome-renderer.client.spec.tsx
+++ b/apps/desktop/tests/welcome-renderer.client.spec.tsx
@@ -16,6 +16,8 @@ function mount(language = 'zh-CN', takeNotice = vi.fn<() => Promise {}),
+ analyticsEnabled: async () => true,
onAccountState: vi.fn((_listener: (state: AccountView) => void) => stopAccount),
startSignIn: vi.fn(async (): Promise => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })),
cancelSignIn: vi.fn(async (): Promise => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })),
@@ -326,3 +328,16 @@ it.each(['zh-CN', 'en'])('returns from completed sign-in to the initial page aft
expect(view.document.querySelector('#auth-page')!.hidden).toBe(true)
await expect(view.copy()).toMatchFileSnapshot(`./expected/welcome/${language}.expected.txt`)
})
+
+it('reports each return to the welcome entry once, including account cancellation', async () => {
+ const view = mount()
+ expect(view.api.analytics).not.toHaveBeenCalled()
+ fireEvent.click(view.button('#api-key'))
+ fireEvent.click(view.button('#back-to-login'))
+ expect(view.api.analytics.mock.calls.map(([action]) => action)).toEqual(['auth_page_click', 'auth_page_view'])
+ const pending = Promise.withResolvers()
+ view.api.startSignIn.mockReturnValueOnce(pending.promise)
+ fireEvent.click(view.button('#sign-in'))
+ await act(async () => { pending.resolve({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null }) })
+ expect(view.api.analytics.mock.calls.map(([action]) => action)).toEqual(['auth_page_click', 'auth_page_view', 'auth_page_click', 'auth_page_view'])
+})
diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts
index 7518cc74af..86a3da51e4 100644
--- a/apps/desktop/tests/welcome-startup.spec.ts
+++ b/apps/desktop/tests/welcome-startup.spec.ts
@@ -119,6 +119,7 @@ vi.mock('../src/host-process.ts', () => ({
}))
vi.mock('../src/welcome-backend.ts', () => ({
connectDesktopWelcome: async () => ({
+ analyticsEnabled: async () => false,
readLocalePreference: async () => state.preference,
read: async () => {
await state.beforeRead()
@@ -175,6 +176,7 @@ it.each([false, true])('starts welcome onboarding without carrying update focus
state.accountState.mockResolvedValue({ status: 'signed-out', attempt: null, links: { usageUrl: '', topUpUrl: '' } })
if (updated) vi.stubGlobal('process', { ...process, platform: 'win32', argv: ['desktop', '--updated'] })
vi.useFakeTimers()
+ vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3')
vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', '/development-profile')
vi.stubEnv('DSH_DESKTOP_NODE_BINARY', '/runtime/node')
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', '/runtime/pnpm')
diff --git a/apps/desktop/tests/welcome-window.spec.ts b/apps/desktop/tests/welcome-window.spec.ts
index 5ab4a1c031..6d2807c425 100644
--- a/apps/desktop/tests/welcome-window.spec.ts
+++ b/apps/desktop/tests/welcome-window.spec.ts
@@ -6,13 +6,13 @@ import { needsWelcome, WELCOME_IPC } from '../src/welcome-api.ts'
const electron = vi.hoisted(() => ({
create: vi.fn<(options: unknown) => ReturnType>(),
root: '/desktop-app',
- handlers: new Map Promise>(),
+ handlers: new Map Promise>(),
}))
vi.mock('electron', () => ({
app: { getAppPath: () => electron.root },
BrowserWindow: vi.fn(function (options: unknown) { return electron.create(options) }),
ipcMain: {
- handle: (name: string, handler: (event: unknown, value?: unknown) => Promise) => {
+ handle: (name: string, handler: (event: unknown, value?: unknown, attributes?: unknown) => Promise) => {
if (electron.handlers.has(name)) throw new Error(`duplicate IPC handler: ${name}`)
electron.handlers.set(name, handler)
},
@@ -40,6 +40,7 @@ function createWindow() {
beforeEach(() => { electron.create.mockReset(); electron.handlers.clear() })
const operations = {
+ analyticsEnabled: async () => true,
takeNotice: async () => undefined,
startSignIn: async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out' as const, attempt: null }),
cancelSignIn: async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out' as const, attempt: null }),
@@ -186,3 +187,24 @@ describe('desktop welcome window', () => {
expect(needsWelcome({ loggedIn: true, hasApiKey: true })).toBe(false)
})
})
+
+it('accepts only permitted event fields and reads the current policy over IPC', async () => {
+ const window = createWindow()
+ electron.create.mockReturnValue(window)
+ const analytics = vi.fn(async () => {})
+ let enabled = true
+ await openWelcomeWindow(resolveDesktopLocale('en'), { ...operations, analytics, analyticsEnabled: async () => enabled })
+ const own = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
+ const policy = electron.handlers.get(WELCOME_IPC.analyticsEnabled)!
+ expect(await policy(own)).toBe(true)
+ enabled = false
+ expect(await policy(own)).toBe(false)
+ const report = electron.handlers.get(WELCOME_IPC.analytics)!
+ await report(own, 'auth_page_click', { button_name: 'api-key' })
+ expect(analytics).toHaveBeenLastCalledWith('auth_page_click', { button_name: 'api-key' })
+ await expect(report(own, 'auth_page_click', { button_name: 'api-key', token: 'private' })).rejects.toThrow('invalid analytics')
+ await expect(report(own, 'desktop_app_launch', {})).rejects.toThrow('invalid analytics')
+ await expect(report(own, 'auth_page_view', null)).rejects.toThrow('invalid analytics')
+ await report(own, 'api_key_save_click', {})
+ expect(analytics).toHaveBeenLastCalledWith('api_key_save_click', {})
+})
diff --git a/apps/desktop/tsconfig.client.json b/apps/desktop/tsconfig.client.json
index 9af87aed6b..3de33f06dd 100644
--- a/apps/desktop/tsconfig.client.json
+++ b/apps/desktop/tsconfig.client.json
@@ -15,6 +15,9 @@
},
{
"path": "../../packages/credentials/deepseek-account"
+ },
+ {
+ "path": "../../packages/client/product-analytics/tsconfig.client.json"
}
]
}
diff --git a/apps/desktop/tsconfig.host.json b/apps/desktop/tsconfig.host.json
index 370ee0a448..982d7835ee 100644
--- a/apps/desktop/tsconfig.host.json
+++ b/apps/desktop/tsconfig.host.json
@@ -28,6 +28,9 @@
},
{
"path": "../../packages/util/atomic-write"
+ },
+ {
+ "path": "../../packages/client/product-analytics/tsconfig.host.json"
}
],
"exclude": [
diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts
index 7569106fc6..e8d0141ba1 100644
--- a/apps/web/tests/assembled-remote.ts
+++ b/apps/web/tests/assembled-remote.ts
@@ -223,8 +223,10 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
return ok(undefined)
})
mock.unary('session/list', () => ok({ items: structuredClone(sessions) }))
- // The shipped Web composition mounts the Schedule client, whose catalog and
- // per-Session list read empty here: these scenarios drive no reminder.
+ // The shipped bundle layers this graph composes carry the `ui-schedule` row
+ // disabled, so the Schedule client does not mount and no case here reads the
+ // catalog or the per-Session list. Both stay declared so an enabled
+ // composition reads empty lists instead of a missing-rule failure.
mock.unary('schedule/catalog', () => ok([]))
mock.unary('schedule/list', () => ok([]))
mock.unary('session/projections', (request: unknown) => {
diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts
index 5e0e23934a..4d22e66e98 100644
--- a/apps/web/tests/chat-scroll-contract.e2e.ts
+++ b/apps/web/tests/chat-scroll-contract.e2e.ts
@@ -23,7 +23,7 @@ import {
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
-import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
+import { expandOwningTurnProcess, newEnglishPage, openSettings, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const HISTORY_SESSION_ID = 'chat-scroll-history-e2e'
@@ -677,7 +677,7 @@ describe('web e2e: long Chat scroll contract', () => {
})
}, 180_000)
- it.skipIf(MODE === 'record')('follows a growing process group independently of the outer transcript', async () => {
+ it.skipIf(MODE === 'record')('follows a growing Standard-mode process group independently of the outer transcript', async () => {
const parts = [
'GROUP_SCROLL_START\n\n',
...Array.from({ length: 4 }, (_, batch) => Array.from({ length: 20 }, (_, row) =>
@@ -701,6 +701,14 @@ describe('web e2e: long Chat scroll contract', () => {
}, async (world) => {
await world.page.emulateMedia({ reducedMotion: 'no-preference' })
await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns))
+ await openSettings(world.page, 'en')
+ await world.page.getByRole('dialog', { name: 'Settings', exact: true })
+ .getByText('Work details', { exact: true }).locator('../..')
+ .getByRole('button', { name: 'Detailed', exact: true }).click()
+ await world.page.getByRole('menuitem', { name: 'Standard', exact: true }).click()
+ await expect.poll(() => world.scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value)
+ .toMatchObject({ transcriptView: 'standard' })
+ await world.page.keyboard.press('Escape')
const gates = parts.slice(1).map(() => Promise.withResolvers())
const dispose = world.scaffold.ctx.on('llm/stream', async function* (_options, next) {
let index = -1
diff --git a/apps/web/tests/desktop-locale.e2e.ts b/apps/web/tests/desktop-locale.e2e.ts
index e392789d8d..7065c95552 100644
--- a/apps/web/tests/desktop-locale.e2e.ts
+++ b/apps/web/tests/desktop-locale.e2e.ts
@@ -33,6 +33,7 @@ describe.skipIf(MODE === 'record')('web e2e: native and Client locale preference
})
await page.exposeFunction('nativeLocaleChanged', (locale: string) => { reported.push(locale) })
await page.addInitScript(() => {
+ Object.defineProperty(globalThis, 'dshDesktop', { value: { protocolVersion: 1 } })
const bridge = globalThis as typeof globalThis & {
nativeLocaleRead(): Promise
nativeLocaleChanged(locale: string): Promise
diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts
index cc5c1fa763..7812b5b6b6 100644
--- a/apps/web/tests/document-preview.e2e.ts
+++ b/apps/web/tests/document-preview.e2e.ts
@@ -275,7 +275,7 @@ it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pan
await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(width)
await expectExcelLayout(excel)
expect(await meetingCanvas.evaluate(node => node.isConnected)).toBe(true)
- expect(await selection.innerText()).toBe('A1')
+ await expect.poll(() => selection.innerText()).toBe('A1')
await expect.poll(() => excel.locator('.fortune-sheettab-scroll').count()).toBe(width === 360 ? 2 : 0)
}
const gridOffset = await excel.locator('.luckysheet-scrollbar-x').evaluate(node => node.scrollLeft)
diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md
index d408f284f3..6862d5b098 100644
--- a/apps/web/tests/expected/plugin-config/official.expected.md
+++ b/apps/web/tests/expected/plugin-config/official.expected.md
@@ -3,7 +3,7 @@
- button "刷新"
- button "添加插件"
- heading "官方" [level=3]
-- text: "7"
+- text: "8"
- list:
- listitem:
- button "查看 智能体团队": 智能体团队
@@ -13,9 +13,13 @@
- button "查看 自动授权审查": 自动授权审查
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
- switch "启用 自动授权审查"
+ - listitem:
+ - button "查看 自动化任务": 自动化任务
+ - text: 实验性 按设定的时间或周期,在会话中自动执行任务。
+ - switch "启用 自动化任务"
- listitem:
- button "查看 语音输入": 语音输入
- - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
+ - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
- switch "启用 语音输入"
- listitem:
- button "查看 终端": 终端
diff --git a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md
index 14d7992611..b61895ca6c 100644
--- a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md
+++ b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md
@@ -3,7 +3,7 @@
- button "关闭"
- paragraph: 输入插件的包名、GitHub 仓库地址或本地目录路径。
- textbox "包名或地址":
- - /placeholder: 例如 @deepseek-ai/dsh-subagent-codex
+ - /placeholder: 例如 dsh-plugin-whale-pet
- text: slow-package
- button "插件安装引导和示例"
- button "安装源 默认安装源"
diff --git a/apps/web/tests/expected/plugin-install-cancel/highlight.expected.md b/apps/web/tests/expected/plugin-install-cancel/highlight.expected.md
new file mode 100644
index 0000000000..b56769d22f
--- /dev/null
+++ b/apps/web/tests/expected/plugin-install-cancel/highlight.expected.md
@@ -0,0 +1,30 @@
+- listitem:
+ - button "查看 slow-package": slow-package
+ - switch "启用 slow-package" [checked]
+
+[
+ {
+ "colorScheme": "light",
+ "reducedMotion": "no-preference",
+ "boxShadow": "oklab(0.589542 -0.0217781 -0.177995 / 0.4) 0px 0px 0px 2px",
+ "animated": true
+ },
+ {
+ "colorScheme": "light",
+ "reducedMotion": "reduce",
+ "boxShadow": "color(srgb 0.254902 0.462745 0.901961 / 0.4) 0px 0px 0px 2px",
+ "animated": false
+ },
+ {
+ "colorScheme": "dark",
+ "reducedMotion": "no-preference",
+ "boxShadow": "oklab(0.739592 -0.02038 -0.132631 / 0.4) 0px 0px 0px 2px",
+ "animated": true
+ },
+ {
+ "colorScheme": "dark",
+ "reducedMotion": "reduce",
+ "boxShadow": "color(srgb 0.478431 0.666667 1 / 0.4) 0px 0px 0px 2px",
+ "animated": false
+ }
+]
diff --git a/apps/web/tests/expected/plugin-install-cancel/invalid-registry.expected.md b/apps/web/tests/expected/plugin-install-cancel/invalid-registry.expected.md
new file mode 100644
index 0000000000..2decb95ebb
--- /dev/null
+++ b/apps/web/tests/expected/plugin-install-cancel/invalid-registry.expected.md
@@ -0,0 +1,66 @@
+- radio "自定义地址" [checked]
+- text: 自定义地址
+- textbox "自定义地址" [invalid]:
+ - /placeholder: https://npm.example.com/
+ - text: invalid-registry
+- alert: 请输入以 http:// 或 https:// 开头的地址
+- text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。
+
+{
+ "registry": [
+ {
+ "colorScheme": "light",
+ "focused": {
+ "outline": "none",
+ "boxShadow": "rgb(65, 118, 230) 0px 0px 0px 0.5px inset",
+ "borderColor": "rgb(236, 19, 19)"
+ },
+ "blurred": {
+ "outline": "none",
+ "boxShadow": "none",
+ "borderColor": "rgb(236, 19, 19)"
+ }
+ },
+ {
+ "colorScheme": "dark",
+ "focused": {
+ "outline": "none",
+ "boxShadow": "rgb(122, 170, 255) 0px 0px 0px 0.5px inset",
+ "borderColor": "rgb(242, 90, 90)"
+ },
+ "blurred": {
+ "outline": "none",
+ "boxShadow": "none",
+ "borderColor": "rgb(242, 90, 90)"
+ }
+ }
+ ],
+ "packageName": [
+ {
+ "colorScheme": "light",
+ "focused": {
+ "outline": "none",
+ "boxShadow": "rgb(65, 118, 230) 0px 0px 0px 0.5px inset",
+ "borderColor": "rgb(236, 19, 19)"
+ },
+ "blurred": {
+ "outline": "none",
+ "boxShadow": "none",
+ "borderColor": "rgb(236, 19, 19)"
+ }
+ },
+ {
+ "colorScheme": "dark",
+ "focused": {
+ "outline": "none",
+ "boxShadow": "rgb(122, 170, 255) 0px 0px 0px 0.5px inset",
+ "borderColor": "rgb(242, 90, 90)"
+ },
+ "blurred": {
+ "outline": "none",
+ "boxShadow": "none",
+ "borderColor": "rgb(242, 90, 90)"
+ }
+ }
+ ]
+}
diff --git a/apps/web/tests/expected/plugin-install-github/another-way.expected.md b/apps/web/tests/expected/plugin-install-github/another-way.expected.md
index b36f699bb5..0932737ec2 100644
--- a/apps/web/tests/expected/plugin-install-github/another-way.expected.md
+++ b/apps/web/tests/expected/plugin-install-github/another-way.expected.md
@@ -2,14 +2,14 @@
- heading "添加插件" [level=2]
- button "关闭"
- textbox "插件包名":
- - /placeholder: 例如 @deepseek-ai/dsh-subagent-codex
+ - /placeholder: 例如 dsh-plugin-whale-pet
- button "收起引导" [expanded]
- button "安装源 中国大陆镜像源"
- list:
- listitem:
- text: 包名 插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。 示例:
- - code: "@deepseek-ai/dsh-subagent-codex"
- - button "填入示例 @deepseek-ai/dsh-subagent-codex": 填入示例
+ - code: dsh-plugin-whale-pet
+ - button "填入示例 dsh-plugin-whale-pet": 填入示例
- listitem:
- text: GitHub 仓库地址 插件在 GitHub 上的开源仓库地址,也支持其他 Git 仓库。 示例:
- code: https://github.com/author/dsh-plugin
diff --git a/apps/web/tests/expected/plugin-install-github/mirror.expected.md b/apps/web/tests/expected/plugin-install-github/mirror.expected.md
index b649252d7d..d7e9793ddb 100644
--- a/apps/web/tests/expected/plugin-install-github/mirror.expected.md
+++ b/apps/web/tests/expected/plugin-install-github/mirror.expected.md
@@ -2,7 +2,7 @@
- heading "添加插件" [level=2]
- button "关闭"
- textbox "插件包名":
- - /placeholder: 例如 @deepseek-ai/dsh-subagent-codex
+ - /placeholder: 例如 dsh-plugin-whale-pet
- button "插件安装引导和示例"
- button "安装源 中国大陆镜像源"
- button "安装" [disabled]
diff --git a/apps/web/tests/expected/plugin-install-registry/fastest-default.expected.md b/apps/web/tests/expected/plugin-install-registry/fastest-default.expected.md
index ff8c008df0..91c3574956 100644
--- a/apps/web/tests/expected/plugin-install-registry/fastest-default.expected.md
+++ b/apps/web/tests/expected/plugin-install-registry/fastest-default.expected.md
@@ -1,10 +1,10 @@
- group "从哪个 npm 源下载插件":
- - radio "npm 官方源(registry.npmjs.org)"
- - text: npm 官方源(registry.npmjs.org)
- - radio "中国大陆镜像源(registry.npmmirror.com)" [checked]
- - text: 中国大陆镜像源(registry.npmmirror.com)
+ - radio "npm 官方源 registry.npmjs.org"
+ - text: npm 官方源 registry.npmjs.org
+ - radio "中国大陆镜像源 registry.npmmirror.com" [checked]
+ - text: 中国大陆镜像源 registry.npmmirror.com
- radio "自定义地址"
- text: 自定义地址
- - textbox "自定义地址" [disabled]:
+ - textbox "自定义地址":
- /placeholder: https://npm.example.com/
- - text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里
+ - text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。
diff --git a/apps/web/tests/expected/plugin-install-registry/official-default.expected.md b/apps/web/tests/expected/plugin-install-registry/official-default.expected.md
index 9c219b83e0..944fe05716 100644
--- a/apps/web/tests/expected/plugin-install-registry/official-default.expected.md
+++ b/apps/web/tests/expected/plugin-install-registry/official-default.expected.md
@@ -1,10 +1,10 @@
- group "从哪个 npm 源下载插件":
- - radio "npm 官方源(registry.npmjs.org)" [checked]
- - text: npm 官方源(registry.npmjs.org)
- - radio "中国大陆镜像源(registry.npmmirror.com)"
- - text: 中国大陆镜像源(registry.npmmirror.com)
+ - radio "npm 官方源 registry.npmjs.org" [checked]
+ - text: npm 官方源 registry.npmjs.org
+ - radio "中国大陆镜像源 registry.npmmirror.com"
+ - text: 中国大陆镜像源 registry.npmmirror.com
- radio "自定义地址"
- text: 自定义地址
- - textbox "自定义地址" [disabled]:
+ - textbox "自定义地址":
- /placeholder: https://npm.example.com/
- - text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里
+ - text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。
diff --git a/apps/web/tests/expected/plugin-install-registry/picker.expected.md b/apps/web/tests/expected/plugin-install-registry/picker.expected.md
index ff8c008df0..91c3574956 100644
--- a/apps/web/tests/expected/plugin-install-registry/picker.expected.md
+++ b/apps/web/tests/expected/plugin-install-registry/picker.expected.md
@@ -1,10 +1,10 @@
- group "从哪个 npm 源下载插件":
- - radio "npm 官方源(registry.npmjs.org)"
- - text: npm 官方源(registry.npmjs.org)
- - radio "中国大陆镜像源(registry.npmmirror.com)" [checked]
- - text: 中国大陆镜像源(registry.npmmirror.com)
+ - radio "npm 官方源 registry.npmjs.org"
+ - text: npm 官方源 registry.npmjs.org
+ - radio "中国大陆镜像源 registry.npmmirror.com" [checked]
+ - text: 中国大陆镜像源 registry.npmmirror.com
- radio "自定义地址"
- text: 自定义地址
- - textbox "自定义地址" [disabled]:
+ - textbox "自定义地址":
- /placeholder: https://npm.example.com/
- - text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里
+ - text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。
diff --git a/apps/web/tests/expected/plugin-manager/ime-enter.expected.md b/apps/web/tests/expected/plugin-manager/ime-enter.expected.md
new file mode 100644
index 0000000000..732a5c2d20
--- /dev/null
+++ b/apps/web/tests/expected/plugin-manager/ime-enter.expected.md
@@ -0,0 +1,62 @@
+Synthetic browser KeyboardEvents, not an OS input-method test.
+package / isComposing=true: inspect=0, install=0; editable; value retained
+- textbox "包名或地址":
+ - /placeholder: 例如 dsh-plugin-whale-pet
+ - text: ime-confirm-package
+package / Safari isComposing=false, keyCode=229: inspect=0, install=0; editable; value retained
+- textbox "包名或地址":
+ - /placeholder: 例如 dsh-plugin-whale-pet
+ - text: ime-confirm-package
+package / compositionstart + unmarked Enter: inspect=0, install=0; editable; value retained
+- textbox "包名或地址":
+ - /placeholder: 例如 dsh-plugin-whale-pet
+ - text: ime-confirm-package
+package / compositionend + immediate unmarked Enter: inspect=0, install=0; editable; value retained
+- textbox "包名或地址":
+ - /placeholder: 例如 dsh-plugin-whale-pet
+ - text: ime-confirm-package
+package / compositionend + 9ms unmarked Enter: inspect=0, install=0; editable; value retained
+- textbox "包名或地址":
+ - /placeholder: 例如 dsh-plugin-whale-pet
+ - text: ime-confirm-package
+package / plain Enter: inspect=1, install=1
+- dialog "插件安装失败":
+ - button "返回编辑": 编辑
+ - button "关闭"
+ - alert: 插件安装失败
+ - paragraph: "IME fixture: no package was installed"
+ - paragraph: ime-confirm-package
+ - paragraph: 版本 1.0.0
+ - button "查看安装详情"
+ - button "重试"
+custom registry / isComposing=true: inspect=0, install=0; editable; value retained
+- textbox "自定义地址":
+ - /placeholder: https://npm.example.com/
+ - text: https://registry.example.test/
+custom registry / Safari isComposing=false, keyCode=229: inspect=0, install=0; editable; value retained
+- textbox "自定义地址":
+ - /placeholder: https://npm.example.com/
+ - text: https://registry.example.test/
+custom registry / compositionstart + unmarked Enter: inspect=0, install=0; editable; value retained
+- textbox "自定义地址":
+ - /placeholder: https://npm.example.com/
+ - text: https://registry.example.test/
+custom registry / compositionend + immediate unmarked Enter: inspect=0, install=0; editable; value retained
+- textbox "自定义地址":
+ - /placeholder: https://npm.example.com/
+ - text: https://registry.example.test/
+custom registry / compositionend + 9ms unmarked Enter: inspect=0, install=0; editable; value retained
+- textbox "自定义地址":
+ - /placeholder: https://npm.example.com/
+ - text: https://registry.example.test/
+custom registry / plain Enter: inspect=1, install=1
+- dialog "插件安装失败":
+ - button "返回编辑": 编辑
+ - button "关闭"
+ - alert: 插件安装失败
+ - paragraph: "IME fixture: no package was installed"
+ - paragraph: ime-confirm-registry
+ - paragraph: 版本 1.0.0
+ - button "查看安装详情"
+ - button "重试"
+Profile manifest unchanged; controlled Host result performed no package installation.
diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md
index 2f7b9bc832..a6d04f4f70 100644
--- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md
+++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md
@@ -3,7 +3,7 @@
- button "刷新"
- button "添加插件"
- heading "官方" [level=3]
-- text: "7"
+- text: "8"
- list:
- listitem:
- button "查看 智能体团队": 智能体团队
@@ -13,9 +13,13 @@
- button "查看 自动授权审查": 自动授权审查
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
- switch "启用 自动授权审查"
+ - listitem:
+ - button "查看 自动化任务": 自动化任务
+ - text: 实验性 按设定的时间或周期,在会话中自动执行任务。
+ - switch "启用 自动化任务"
- listitem:
- button "查看 语音输入": 语音输入
- - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
+ - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
- switch "启用 语音输入"
- listitem:
- button "查看 终端": 终端
diff --git a/apps/web/tests/expected/plugin-manager/loading.expected.md b/apps/web/tests/expected/plugin-manager/loading.expected.md
new file mode 100644
index 0000000000..467153a406
--- /dev/null
+++ b/apps/web/tests/expected/plugin-manager/loading.expected.md
@@ -0,0 +1,11 @@
+- heading "插件" [level=1]
+- paragraph: 添加和管理插件
+- button "刷新" [disabled]
+- button "添加插件" [disabled]
+- status "正在读取插件…"
+
+1680px: 4 rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px
+1000px: 4 rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px
+Loading: header actions disabled; action spaces are empty 36×20 areas, with no switch placeholders
+Loaded: skeleton removed; header actions enabled
+Motion: 13 opacity-only pulses, 2000ms; reduced motion stops all pulses
diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md
index 61c517953b..8e523514b2 100644
--- a/apps/web/tests/expected/plugin-manager/manager.expected.md
+++ b/apps/web/tests/expected/plugin-manager/manager.expected.md
@@ -3,7 +3,7 @@
- button "刷新"
- button "添加插件"
- heading "官方" [level=3]
-- text: "7"
+- text: "8"
- list:
- listitem:
- button "查看 智能体团队": 智能体团队
@@ -13,9 +13,13 @@
- button "查看 自动授权审查": 自动授权审查
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
- switch "启用 自动授权审查"
+ - listitem:
+ - button "查看 自动化任务": 自动化任务
+ - text: 实验性 按设定的时间或周期,在会话中自动执行任务。
+ - switch "启用 自动化任务"
- listitem:
- button "查看 语音输入": 语音输入
- - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
+ - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
- switch "启用 语音输入"
- listitem:
- button "查看 终端": 终端
diff --git a/apps/web/tests/expected/plugin-manager/refresh.expected.md b/apps/web/tests/expected/plugin-manager/refresh.expected.md
new file mode 100644
index 0000000000..75dda78845
--- /dev/null
+++ b/apps/web/tests/expected/plugin-manager/refresh.expected.md
@@ -0,0 +1,12 @@
+Hover at 499ms: 0 tooltips
+Hover at 500ms:
+- tooltip "刷新"
+Focus at 499ms: 0 tooltips
+Focus at 500ms:
+- tooltip "刷新"
+failure: pending
+- button "刷新" [disabled]
+{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]}
+failure: settled
+- button "刷新"
+{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]}
diff --git a/apps/web/tests/expected/schedule-after/conversation.expected.md b/apps/web/tests/expected/schedule-after/conversation.expected.md
index 995cfb9044..e91ea95ad2 100644
--- a/apps/web/tests/expected/schedule-after/conversation.expected.md
+++ b/apps/web/tests/expected/schedule-after/conversation.expected.md
@@ -1 +1,3 @@
+- text: Automation task
+
- paragraph: "Reminder: Check the deployment log."
diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md
index e4ebda98ac..a31abbd9a6 100644
--- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md
+++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md
@@ -19,7 +19,7 @@
- button "Increase font size"
- button "Decrease font size"
- text: px Work details Choose how much detail to show for tool calls
- - button "Standard"
+ - button "Detailed"
- text: Performance & usage Choose how much performance and usage information to show
- button "Detailed"
- text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats
diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md
index e4ebda98ac..a31abbd9a6 100644
--- a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md
+++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md
@@ -19,7 +19,7 @@
- button "Increase font size"
- button "Decrease font size"
- text: px Work details Choose how much detail to show for tool calls
- - button "Standard"
+ - button "Detailed"
- text: Performance & usage Choose how much performance and usage information to show
- button "Detailed"
- text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats
diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md
index 40d4ecf3d7..75c367d690 100644
--- a/apps/web/tests/expected/settings-chrome/dialog.expected.md
+++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md
@@ -19,7 +19,7 @@
- button "增大字号"
- button "减小字号"
- text: px 工作步骤展示 选择希望看到多少工具调用细节
- - button "标准"
+ - button "详细"
- text: 性能与用量 选择性能与用量信息展示的详细程度
- button "详细"
- text: 代码工作工具 开启后显示轨迹、本轮代码差异,新对话中的 Agent 预设切换
diff --git a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md
index 612291fe84..16d2109528 100644
--- a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md
+++ b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md
@@ -1,23 +1,20 @@
- list:
- listitem:
- - button "tool-subagent-control, tool-subagent-control, 已启用":
+ - button "tool-subagent-control, 已启用":
- strong: tool-subagent-control
- - text: 已启用 Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations
- - code: tool-subagent-control
+ - text: Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations
- listitem:
- button "tool-subagent-control/list-agents, tool-subagent-list-agents, 已启用":
- strong: tool-subagent-control/list-agents
- - text: 已启用
- code: tool-subagent-list-agents
- listitem:
- - button "tool-subagent, tool-subagent, 已启用":
+ - button "tool-subagent, 已启用":
- strong: tool-subagent
- - text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam
- - code: tool-subagent
+ - text: Model-facing subagent delegation tool over the ctx.subagents seam
- listitem:
- button "tool-subagent, tool-subagent-fork, 已启用":
- strong: tool-subagent
- - text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam
+ - text: Model-facing subagent delegation tool over the ctx.subagents seam
- code: tool-subagent-fork
- listitem:
- button "tool-subagent, tool-subagent-codex, 已停用":
diff --git a/apps/web/tests/expected/settings-chrome/plugins.expected.md b/apps/web/tests/expected/settings-chrome/plugins.expected.md
index 7cfe33f5f0..20e096a308 100644
--- a/apps/web/tests/expected/settings-chrome/plugins.expected.md
+++ b/apps/web/tests/expected/settings-chrome/plugins.expected.md
@@ -1,5 +1,4 @@
- listitem:
- - button "tool-subagent, tool-subagent, 已启用":
+ - button "tool-subagent, 已启用":
- strong: tool-subagent
- - text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam
- - code: tool-subagent
+ - text: Model-facing subagent delegation tool over the ctx.subagents seam
diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md
index 37a6c38e98..d37563c909 100644
--- a/apps/web/tests/expected/steer-all/mid-steer.expected.md
+++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md
@@ -8,13 +8,13 @@
- tab "Trajectory"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy"
-- status: Deep diving...
-- button "Deep diving for {{duration}}" [disabled] [expanded]
-- button "Analyzing the request · The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that."
+- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that."
- text: "Interjection: include the word BANANA in your final reply."
- button "Copy"
- text: "Interjection: include the word ORANGE in your final reply."
- button "Copy"
+- status: Deep diving...
+- text: Deep diving for {{duration}}...
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md b/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md
index 6009227833..ec04e12324 100644
--- a/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md
+++ b/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md
@@ -1,18 +1,18 @@
- tree "Sessions":
- treeitem "{{workspace}}" [expanded]
- treeitem "New Session" [selected]
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} Session actions for {{workspace}} Archive session Pin session":
- - text: {{workspace}}
- - button "Session actions for {{workspace}}"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled Session actions for Untitled Archive session Pin session":
+ - text: Untitled
+ - button "Session actions for Untitled"
- button "Archive session"
- button "Pin session"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
- button "Show 6 more sessions"
diff --git a/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md b/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md
index 8778baac12..1aeb3e5a77 100644
--- a/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md
+++ b/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md
@@ -4,9 +4,9 @@
- button "Workspace actions for {{workspace}}"
- button "New session in {{workspace}}"
- treeitem "New Session" [selected]
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
- button "Show 11 more sessions"
diff --git a/apps/web/tests/feedback-release.e2e.ts b/apps/web/tests/feedback-release.e2e.ts
index 4643da9cf8..3de0ea2fca 100644
--- a/apps/web/tests/feedback-release.e2e.ts
+++ b/apps/web/tests/feedback-release.e2e.ts
@@ -27,6 +27,7 @@ const MODE = webSnapshotMode()
interface OtlpCapture {
resourceLogs: { scopeLogs: { logRecords: {
+ eventName: string
attributes: { key: string; value: { stringValue?: string; intValue?: number | string } }[]
}[] }[] }[]
}
@@ -53,8 +54,11 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
return capture.resourceLogs.flatMap(resource => resource.scopeLogs.flatMap(scope =>
scope.logRecords.map((record) => {
const attribute = (key: string) => record.attributes.find(value => value.key === key)?.value
- return [attribute('session.id')?.stringValue, Number(attribute('event.seq')?.intValue),
- attribute('event.type')?.stringValue] as [string | undefined, number, string | undefined]
+ expect(record.eventName).toBe('session-log')
+ const content = attribute('content')?.stringValue
+ expect(typeof content).toBe('string')
+ const event = JSON.parse(content!) as SessionEvent
+ return [attribute('sessionId')?.stringValue, event.seq, event.type] as [string | undefined, number, string | undefined]
})))
})
}
@@ -71,6 +75,10 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
const expected = authorized.map(event => [sessionId, event.seq, event.type])
// No teardown, flush hook, or subsequent interaction may cause this delivery.
await expect.poll(captured, { timeout: 10_000 }).toEqual(expected)
+ const uploadedEvents = uploads.flatMap(upload => (JSON.parse(upload) as OtlpCapture).resourceLogs
+ .flatMap(resource => resource.scopeLogs.flatMap(scope => scope.logRecords))
+ .map(record => JSON.parse(record.attributes.find(attribute => attribute.key === 'content')!.value.stringValue!) as SessionEvent))
+ expect(uploadedEvents).toEqual(authorized)
suffixes.push(authorized.slice(releasedCount).map(event => event.type))
releasedCount = authorized.length
expect(scaffold.ctx.agents.get(sessionId)).toBe(agent)
@@ -268,7 +276,9 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
}
})
await compareOrRefreshGolden(RELEASE_EXPECTED, JSON.stringify({
- mode: 'FEEDBACK_ONLY', feedback,
+ mode: 'FEEDBACK_ONLY',
+ eventName: (JSON.parse(uploads[0]!) as OtlpCapture).resourceLogs[0]!.scopeLogs[0]!.logRecords[0]!.eventName,
+ feedback,
// The prefix is compared with each provider's actual canonical log above.
laterSubmissionSuffixes: suffixes.slice(1),
}, null, 2), MODE)
diff --git a/apps/web/tests/fixtures/time-context-every-step.patch.yml b/apps/web/tests/fixtures/time-context-every-step.patch.yml
index dcff9f2b61..7a1937d44d 100644
--- a/apps/web/tests/fixtures/time-context-every-step.patch.yml
+++ b/apps/web/tests/fixtures/time-context-every-step.patch.yml
@@ -1,9 +1,6 @@
# Browser-zone assertions need a fresh reading after the non-browser preparation turn.
+# The Schedule rows stay disabled in the shipped composition; the scenario enables them
+# with the shipped `@deepseek-ai/dsh-experimental-schedule-bundle` patch.
- id: time-context
- disabled: false
config:
refreshIntervalMs: 0
-- id: schedule
- disabled: false
-- id: ui-schedule
- disabled: false
diff --git a/apps/web/tests/idle-submission-handoff.e2e.ts b/apps/web/tests/idle-submission-handoff.e2e.ts
index 9d27909d4d..92f6a7c661 100644
--- a/apps/web/tests/idle-submission-handoff.e2e.ts
+++ b/apps/web/tests/idle-submission-handoff.e2e.ts
@@ -145,7 +145,7 @@ it.skipIf(webSnapshotMode() === 'record').each(['inbox-first', 'transcript-first
if (order === 'transcript-first') {
await expect.poll(() => gate.turnStartDelivery()).toBeDefined()
gate.releaseThrough(gate.turnStartDelivery()!)
- await page.locator('[data-turn-process]').last().filter({ hasText: 'Deep diving' }).waitFor()
+ await page.locator('[data-chat-flow-kind="turn-process"]').last().waitFor({ state: 'attached' })
await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 })
trace.push({ phase: 'turn-start', ...await placement(page) })
await expect.poll(() => gate.admissionDelivery()).toBeDefined()
@@ -167,7 +167,7 @@ it.skipIf(webSnapshotMode() === 'record').each(['inbox-first', 'transcript-first
if (order === 'inbox-first') {
await expect.poll(() => gate.turnStartDelivery()).toBeDefined()
gate.releaseThrough(gate.turnStartDelivery()!)
- await page.locator('[data-turn-process]').last().filter({ hasText: 'Deep diving' }).waitFor()
+ await page.locator('[data-chat-flow-kind="turn-process"]').last().waitFor({ state: 'attached' })
await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 })
trace.push({ phase: 'turn-start', ...await placement(page) })
}
diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts
index 9a5782bd96..f598a285e8 100644
--- a/apps/web/tests/live-interactions.e2e.ts
+++ b/apps/web/tests/live-interactions.e2e.ts
@@ -158,7 +158,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
// hang (prefix chunks delivered to the loop) before the stop click.
await expect.poll(() => existsSync(marker), { timeout: 15_000 }).toBe(true)
await expect.poll(
- () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
+ () => page.getByRole('status').filter({ hasText: 'Deep diving' }).isVisible(),
{ timeout: 10_000 },
).toBe(true)
await page.locator('[data-streaming="true"]')
diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts
index 147a97b098..d0c7af8a78 100644
--- a/apps/web/tests/markdown-images.e2e.ts
+++ b/apps/web/tests/markdown-images.e2e.ts
@@ -23,7 +23,7 @@ import {
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
-import { newEnglishPage, saveFailureShot } from './support.ts'
+import { newEnglishPage, pinBrowserClock, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/markdown-images', import.meta.url))
const UI_EXPECTED = fileURLToPath(new URL('./expected/markdown-images/ui.expected.md', import.meta.url))
@@ -166,6 +166,7 @@ describe('web e2e: Markdown image rendering', () => {
let browser: Browser
let page: Page
let tripwire: ReturnType
+ let unpinBrowserClock: (() => void) | undefined
const mediaResponses = new Map()
beforeAll(async () => {
@@ -186,9 +187,11 @@ describe('web e2e: Markdown image rendering', () => {
const outsidePath = join(scaffold.persistenceRoot, 'outside.png')
await writeFile(outsidePath, PNG)
await writeFile(join(scaffold.workspaceCwd, 'active.html'), '
File preview
')
- await seedSession(scaffold, markdownImageFixture(imageOrigin.url, outsidePath), SEED_ID)
+ await seedSession(scaffold, markdownImageFixture(imageOrigin.url, outsidePath), SEED_ID, undefined,
+ { createdAt: WEB_FIXTURE_TIME - 60_000 })
browser = await chromium.launch()
page = await newEnglishPage(browser)
+ unpinBrowserClock = await pinBrowserClock(page)
tripwire = watchConsole(page)
page.on('response', (response) => {
const url = new URL(response.url())
@@ -201,6 +204,7 @@ describe('web e2e: Markdown image rendering', () => {
}, 120_000)
afterAll(async () => {
+ unpinBrowserClock?.()
await browser?.close()
await scaffold?.close()
if (imageOrigin !== undefined) await stopServer(imageOrigin.server)
diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts
index 8c6e7ea615..dc1df8058a 100644
--- a/apps/web/tests/message-actions.e2e.ts
+++ b/apps/web/tests/message-actions.e2e.ts
@@ -14,7 +14,7 @@ import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, parseSeedFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
-import { openSettings, newEnglishPage, saveFailureShot } from './support.ts'
+import { openSettings, newEnglishPage, pinBrowserClock, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-actions', import.meta.url))
// Borrowed read-only: this scenario needs any settled user+assistant pair, not
@@ -189,6 +189,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
let browser: Browser
let page: Page
let tripwire: ReturnType
+ let unpinBrowserClock: (() => void) | undefined
beforeAll(async () => {
scaffold = await launchWebScaffold({})
@@ -201,15 +202,21 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
expect(parseSeedFixture(raw).events.flatMap(event => event.type === 'request/header'
? [event.data.reason]
: []), 'adapted seed must carry an unchanged resume header').toEqual(['initial', 'resume'])
- await seedSession(scaffold, raw, SEED_ID)
+ // The fixture carries no times of its own, so the seed anchors them at
+ // `Date.now() - 60_000` unless the scenario names the shared fixture day.
+ // 90 s keeps the earliest rows in the `1min` bucket and the later ones,
+ // carried by their stream spans, inside `now` — what the fork golden records.
+ await seedSession(scaffold, raw, SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME - 90_000 })
browser = await chromium.launch()
page = await newEnglishPage(browser)
+ unpinBrowserClock = await pinBrowserClock(page)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
+ unpinBrowserClock?.()
await browser?.close()
await scaffold?.close()
})
diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts
index 68857956f3..a82a816230 100644
--- a/apps/web/tests/models-settings.e2e.ts
+++ b/apps/web/tests/models-settings.e2e.ts
@@ -345,6 +345,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await expect(scaffold.ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).resolves.toMatchObject({
inputModalities: ['image'],
})
+ // The saved notice confirms the browser directory refresh after the editor closes.
+ await dialog.getByText('已保存 Acme 网关 (acme-gateway)。', { exact: true }).waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '编辑 Acme 网关 (acme-gateway)' }).click()
await dialog.getByText('自定义设置').click()
await dialog.getByRole('button', { name: '模型选项 1' }).click()
@@ -386,6 +388,10 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await types.waitFor({ state: 'detached' })
await expect(scaffold.ctx.llm.resolveModelInfo('openai', 'gpt-6-astra')).resolves.toMatchObject({ inputModalities: ['text'] })
+ // The saved notice confirms the browser directory refresh after the editor closes.
+ // Reopening before it copies the previous selection into a new draft, which
+ // checkbox polling cannot correct.
+ await dialog.getByText('已保存 openai。', { exact: true }).waitFor({ timeout: 10_000 })
await edit.click()
await dialog.getByText('自定义设置').click()
await dialog.getByRole('button', { name: '模型选项 1' }).click()
diff --git a/apps/web/tests/plugin-install-cancel.e2e.ts b/apps/web/tests/plugin-install-cancel.e2e.ts
index 9e2294051e..1ab1e17dd0 100644
--- a/apps/web/tests/plugin-install-cancel.e2e.ts
+++ b/apps/web/tests/plugin-install-cancel.e2e.ts
@@ -4,12 +4,60 @@ import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
-import { chromium } from 'playwright'
+import { chromium, type Locator, type Page } from 'playwright'
import { expect, it } from 'vitest'
import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts'
import { ZH_BROWSER_LOCALE } from './support.ts'
-it('cancels installation through the UI, restores files, and offers the spec again', async () => {
+async function invalidInputStyles(page: Page, input: Locator) {
+ const styles = []
+ try {
+ for (const colorScheme of ['light', 'dark'] as const) {
+ await page.emulateMedia({ colorScheme })
+ await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null)
+ await input.focus()
+ await expect.poll(() => input.evaluate(element => document.activeElement === element)).toBe(true)
+ expect(await input.getAttribute('aria-invalid')).toBe('true')
+ const { focused, focusRingColor, errorBorderColor } = await input.evaluate((element) => {
+ const style = getComputedStyle(element)
+ const probe = document.createElement('span')
+ probe.style.display = 'none'
+ probe.style.color = style.getPropertyValue('--dsw-focus-ring-color').trim() || style.getPropertyValue('--dsw-alias-state-business-primary')
+ element.after(probe)
+ try {
+ const focusRingColor = getComputedStyle(probe).color
+ probe.style.color = style.getPropertyValue('--dsw-alias-state-error-primary')
+ return {
+ focused: { outline: style.outlineStyle, boxShadow: style.boxShadow, borderColor: style.borderColor },
+ focusRingColor,
+ errorBorderColor: getComputedStyle(probe).color,
+ }
+ } finally {
+ probe.remove()
+ }
+ })
+ expect(focused.outline).toBe('none')
+ expect(focused.boxShadow).toContain('inset')
+ expect(focused.boxShadow).toContain('0px 0px 0px 0.5px')
+ expect(focused.boxShadow).toContain(focusRingColor)
+ expect(focused.borderColor).toBe(errorBorderColor)
+ expect(focusRingColor).not.toBe(errorBorderColor)
+ await input.evaluate((element) => { (element as HTMLInputElement).blur() })
+ await expect.poll(() => input.evaluate(element => document.activeElement === element)).toBe(false)
+ const blurred = await input.evaluate((element) => {
+ const style = getComputedStyle(element)
+ return { outline: style.outlineStyle, boxShadow: style.boxShadow, borderColor: style.borderColor }
+ })
+ expect(blurred).toEqual({ outline: 'none', boxShadow: 'none', borderColor: focused.borderColor })
+ styles.push({ colorScheme, focused, blurred })
+ }
+ return styles
+ } finally {
+ await page.emulateMedia({ colorScheme: null })
+ }
+}
+
+it('cancels installation, retries and highlights the enabled plugin at 40% alpha, and recovers unknown results', async () => {
const scratch = await mkdtemp(join(tmpdir(), 'dsh-install-cancel-'))
const overlay = join(scratch, 'cordis.patch.yml')
await writeFile(overlay, `- id: plugin-manager\n config: ${JSON.stringify({ pnpmCommand: process.execPath })}\n`)
@@ -39,6 +87,7 @@ it('cancels installation through the UI, restores files, and offers the spec aga
`)
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: ZH_BROWSER_LOCALE })
const tripwire = watchConsole(page)
+ await page.clock.install()
await page.goto(scaffold.authenticatedUrl)
await page.waitForSelector('[class*="frame"]')
if (await page.getByRole('dialog', { name: '设置' }).count() > 0) await page.keyboard.press('Escape')
@@ -48,6 +97,29 @@ it('cancels installation through the UI, restores files, and offers the spec aga
// The dialog is named after its current screen, so it is found by role alone.
const dialog = page.getByRole('dialog')
await dialog.getByRole('textbox').fill('slow-package')
+ await dialog.getByRole('button', { name: /^安装源/ }).click()
+ const registryMenu = page.locator('[data-install-registry]')
+ const customAddress = registryMenu.getByRole('textbox', { name: '自定义地址', exact: true })
+ await customAddress.fill('invalid-registry')
+ await page.keyboard.press('Escape')
+ await registryMenu.waitFor({ state: 'hidden' })
+ await dialog.getByRole('button', { name: '安装', exact: true }).click()
+ await customAddress.waitFor({ state: 'visible' })
+ await expect.poll(() => customAddress.evaluate(element => document.activeElement === element)).toBe(true)
+ expect(await customAddress.getAttribute('aria-invalid')).toBe('true')
+ const registryStyles = await invalidInputStyles(page, customAddress)
+ const invalidRegistryAria = await captureStableAria(page, '[data-install-registry] > [data-checked="true"]', scaffold.workspaceCwd)
+ await registryMenu.getByRole('radio').first().check()
+ await page.keyboard.press('Escape')
+ await registryMenu.waitFor({ state: 'hidden' })
+ const packageName = dialog.getByRole('textbox', { name: '包名或地址', exact: true })
+ await packageName.fill('./relative')
+ await packageName.press('Enter')
+ await expect.poll(() => packageName.getAttribute('aria-invalid')).toBe('true')
+ const packageStyles = await invalidInputStyles(page, packageName)
+ await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/invalid-registry.expected.md', import.meta.url)),
+ `${invalidRegistryAria}\n\n${JSON.stringify({ registry: registryStyles, packageName: packageStyles }, null, 2)}`, webSnapshotMode())
+ await packageName.fill('slow-package')
// Hold request delivery and the real Host's cancellation reply independently.
// Cancellation reaches the Host before the install it names.
const cancellationArrived = Promise.withResolvers()
@@ -135,7 +207,54 @@ it('cancels installation through the UI, restores files, and offers the spec aga
await dialog.getByRole('button', { name: '查看安装详情', exact: true }).click()
await dialog.getByText('Retry completed', { exact: true }).waitFor()
expect(JSON.parse(await readFile(manifestPath, 'utf8'))).toMatchObject({ dependencies: { 'slow-package': '1.0.0' } })
- await dialog.getByRole('button', { name: '关闭', exact: true }).click()
+ // Freeze the real highlight's expiry timer and CSS first frame independently.
+ await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000))
+ const pausedHighlight = await page.addStyleTag({ content: '[data-plugin-highlight] { animation-play-state: paused !important; }' })
+ try {
+ await dialog.getByRole('button', { name: '立即启用', exact: true }).click()
+ await dialog.waitFor({ state: 'hidden' })
+ const card = panel.locator('[data-plugin-package="slow-package"][data-plugin-highlight]')
+ await card.waitFor({ state: 'visible' })
+ // `enableInstalled` marks the card and starts the directory reload the
+ // Host answers, so the highlight is observable before the reload
+ // commits the enabled bundle into the list. Measured 61 ms between the
+ // two locally; this poll waits for that commit, which the single read
+ // it replaces sampled inside.
+ await expect.poll(
+ () => card.getAttribute('data-plugin-status'),
+ { timeout: 10_000 },
+ ).toBe('running')
+ expect(await panel.locator('[data-plugin-highlight]').count()).toBe(1)
+ const styles = []
+ for (const colorScheme of ['light', 'dark'] as const) {
+ await page.emulateMedia({ colorScheme })
+ await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null)
+ for (const reducedMotion of ['no-preference', 'reduce'] as const) {
+ await page.emulateMedia({ reducedMotion })
+ const style = await card.evaluate((element) => {
+ const computed = getComputedStyle(element)
+ return { boxShadow: computed.boxShadow, animationName: computed.animationName, animationDuration: computed.animationDuration }
+ })
+ expect(style.boxShadow).toMatch(/(?:\/|,)\s*0\.4\)/)
+ expect(style.boxShadow).toContain('0px 0px 0px 2px')
+ if (reducedMotion === 'reduce') {
+ expect(style.animationName).toBe('none')
+ } else {
+ expect(style.animationName).toContain('dsh-plugin-highlight')
+ expect(style.animationDuration).toBe('2.4s')
+ }
+ styles.push({ colorScheme, reducedMotion, boxShadow: style.boxShadow, animated: style.animationName !== 'none' })
+ }
+ }
+ await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/highlight.expected.md', import.meta.url)),
+ `${await captureStableAria(page, '[data-plugin-package="slow-package"]', scaffold.workspaceCwd)}\n\n${JSON.stringify(styles, null, 2)}`, webSnapshotMode())
+ await page.clock.runFor(2400)
+ await card.waitFor({ state: 'detached' })
+ } finally {
+ await pausedHighlight.evaluate((element) => { element.parentNode?.removeChild(element) })
+ await page.emulateMedia({ colorScheme: null, reducedMotion: null })
+ await page.clock.resume()
+ }
await panel.getByRole('button', { name: '添加插件', exact: true }).click()
await dialog.getByRole('textbox').fill('recovered-package')
await page.route('**/api/pluginManager/installBundle', async (route) => {
diff --git a/apps/web/tests/plugin-install-registry.e2e.ts b/apps/web/tests/plugin-install-registry.e2e.ts
index 043dd70764..54a1dbe3a1 100644
--- a/apps/web/tests/plugin-install-registry.e2e.ts
+++ b/apps/web/tests/plugin-install-registry.e2e.ts
@@ -75,7 +75,7 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding
expect(await page.getByRole('radio').count()).toBe(0)
await registryToggle.click()
const options = page.getByRole('group', { name: '从哪个 npm 源下载插件', exact: true })
- const mirror = options.getByRole('radio', { name: '中国大陆镜像源(registry.npmmirror.com)', exact: true })
+ const mirror = options.getByRole('radio', { name: '中国大陆镜像源 registry.npmmirror.com', exact: true })
await expect.poll(async () => (await readFile(requestLog, 'utf8')).trim().split('\n').filter(Boolean)).toHaveLength(2)
const pingUrls = (await readFile(requestLog, 'utf8')).trim().split('\n').sort()
expect(pingUrls).toEqual(['https://registry.npmjs.org/-/ping', 'https://registry.npmmirror.com/-/ping'].map(url => JSON.stringify(url)))
@@ -84,7 +84,7 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-registry/fastest-default.expected.md', import.meta.url)),
await captureStableAria(page, '[data-install-registry]', scaffold.workspaceCwd), webSnapshotMode())
} else {
- expect(await options.getByRole('radio', { name: 'npm 官方源(registry.npmjs.org)', exact: true }).isChecked()).toBe(true)
+ expect(await options.getByRole('radio', { name: 'npm 官方源 registry.npmjs.org', exact: true }).isChecked()).toBe(true)
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-registry/official-default.expected.md', import.meta.url)),
await captureStableAria(page, '[data-install-registry]', scaffold.workspaceCwd), webSnapshotMode())
await mirror.click()
diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts
index cfa6a9a763..b7c375ec7e 100644
--- a/apps/web/tests/plugin-manager.e2e.ts
+++ b/apps/web/tests/plugin-manager.e2e.ts
@@ -2,14 +2,15 @@
// managed scaffold profile: installed bundles, their rows, and bundle enablement. Zero
// model calls: everything is client state, seeded Session state, profile files, and the settings
// document, so there is no fixture and a stray stream would fail loud on the open llm seam.
-import { readFile } from 'node:fs/promises'
+import { mkdtemp, readFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
-import type { Browser, Page } from 'playwright'
+import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { FiberState } from '@deepseek-ai/cordis'
import { OPTIONAL_BUNDLES } from '@deepseek-ai/dsh-app-boot'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
import { join } from 'node:path'
import {
SCAFFOLD_DEFAULTS_BUNDLE, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
@@ -93,6 +94,256 @@ describe('web e2e: plugin manager', () => {
return readFile(join(scaffold.harnessHome, ...segments), 'utf8').catch(() => '')
}
+ it('aligns the first-read skeleton with the loaded plugin cards', async () => {
+ const facts: string[] = []
+ let aria = ''
+ const shots = MODE === 'refresh' ? await mkdtemp(join(tmpdir(), 'dsh-plugin-loading-')) : undefined
+ for (const width of [1680, 1000]) {
+ const context = await browser.newContext({ viewport: { width, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+ const release = Promise.withResolvers()
+ const listBundles = scaffold.ctx.pluginManager.listBundles.bind(scaffold.ctx.pluginManager)
+ const reads: ReturnType[] = []
+ const spy = vi.spyOn(scaffold.ctx.pluginManager, 'listBundles').mockImplementation(() => {
+ const read = release.promise.then(() => listBundles())
+ reads.push(read)
+ return read
+ })
+ try {
+ const probe = await context.newPage()
+ const consoleWatch = watchConsole(probe)
+ await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+ await probe.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ await probe.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
+ await expect.poll(() => spy.mock.calls.length, { timeout: 10_000 }).toBeGreaterThan(0)
+ const panel = probe.locator('[data-plugin-panel]')
+ const skeleton = panel.locator('[data-plugin-loading]')
+ await skeleton.waitFor()
+ await probe.evaluate(() => document.fonts.ready)
+ expect(await skeleton.locator(':scope > ul > li').count()).toBe(4)
+ expect(await skeleton.getAttribute('role')).toBe('status')
+ expect(await skeleton.getAttribute('aria-label')).toBe('正在读取插件…')
+ expect(await panel.getAttribute('aria-busy')).toBe('true')
+ const actions = panel.locator(':scope > header button')
+ expect(await actions.count()).toBe(2)
+ for (const action of await actions.all()) expect(await action.isDisabled()).toBe(true)
+ const loadingAria = await captureStableAria(probe, '[data-plugin-panel]', scaffold.workspaceCwd)
+ if (aria === '') aria = loadingAria
+ else expect(loadingAria).toBe(aria)
+
+ const measure = (group: Locator) => group.evaluate((element) => {
+ const rect = (node: Element | null | undefined) => {
+ if (node === null || node === undefined) throw new Error('Missing plugin layout element')
+ const { x, y, width, height } = node.getBoundingClientRect()
+ return { x, y, width, height }
+ }
+ return {
+ pageHeader: rect(element.closest('[data-plugin-panel]')?.querySelector(':scope > header')),
+ groupHeader: rect(element.firstElementChild),
+ rows: Array.from(element.querySelectorAll(':scope > ul > li')).slice(0, 4).map((row) => {
+ const head = row.firstElementChild
+ const main = head?.children[1]
+ return {
+ row: rect(row), head: rect(head), icon: rect(head?.children[0]), main: rect(main),
+ titleRow: rect(main?.children[0]), title: rect(main?.children[0]?.firstElementChild),
+ description: rect(main?.children[1]), actions: rect(head?.children[2]),
+ }
+ }),
+ }
+ })
+ const loading = await measure(skeleton)
+ const blankActions = await skeleton.locator(':scope > ul > li > div > div:last-child').evaluateAll(nodes => nodes.map(node => ({
+ children: node.childElementCount,
+ background: getComputedStyle(node).backgroundColor,
+ width: node.getBoundingClientRect().width,
+ height: node.getBoundingClientRect().height,
+ })))
+ expect(blankActions).toEqual(Array.from({ length: 4 }, () => ({ children: 0, background: 'rgba(0, 0, 0, 0)', width: 36, height: 20 })))
+ if (shots !== undefined) {
+ for (const colorScheme of ['light', 'dark'] as const) {
+ await probe.emulateMedia({ colorScheme })
+ const path = join(shots, `loading-${width}-${colorScheme}.png`)
+ await panel.screenshot({ path, animations: 'disabled' })
+ console.log(`Plugin loading screenshot: ${path}`)
+ }
+ }
+ const motion = await skeleton.evaluate(element => element.getAnimations({ subtree: true }).map(animation => ({
+ duration: animation.effect?.getTiming().duration,
+ opacityOnly: animation.effect instanceof KeyframeEffect
+ && animation.effect.getKeyframes().every(frame => frame.opacity !== undefined && frame.transform === undefined),
+ })))
+ expect(motion.length).toBe(13)
+ expect(motion.every(animation => animation.duration === 2000 && animation.opacityOnly)).toBe(true)
+ await probe.emulateMedia({ reducedMotion: 'reduce' })
+ await expect.poll(() => skeleton.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0)
+ await probe.emulateMedia({ reducedMotion: 'no-preference', colorScheme: null })
+
+ release.resolve(undefined)
+ await skeleton.waitFor({ state: 'detached' })
+ expect(await panel.getAttribute('aria-busy')).toBe('false')
+ for (const action of await actions.all()) expect(await action.isDisabled()).toBe(false)
+ const official = panel.locator('[data-plugin-group="official"]')
+ await official.locator('[data-plugin-package]').first().waitFor()
+ const loaded = await measure(official)
+ expect(loaded.rows).toHaveLength(4)
+ const compare = (name: string, a: typeof loading.pageHeader, b: typeof loaded.pageHeader, axes: readonly (keyof typeof a)[] = ['x', 'y', 'width', 'height']) => {
+ for (const axis of axes) {
+ expect(Math.abs(a[axis] - b[axis]), `${width}px ${name}.${axis}: loading=${a[axis]}, loaded=${b[axis]}`).toBeLessThanOrEqual(0.1)
+ }
+ }
+ compare('pageHeader', loading.pageHeader, loaded.pageHeader)
+ compare('groupHeader', loading.groupHeader, loaded.groupHeader)
+ for (const [index, row] of loading.rows.entries()) {
+ const real = loaded.rows[index]!
+ for (const part of ['row', 'head', 'icon', 'main', 'titleRow', 'actions'] as const) compare(`row ${index + 1} ${part}`, row[part], real[part])
+ // Painted text bars are deliberately shorter than real copy; their line origins and heights align.
+ for (const part of ['title', 'description'] as const) compare(`row ${index + 1} ${part}`, row[part], real[part], ['x', 'y', 'height'])
+ }
+ facts.push(`${width}px: 4 rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px`)
+ expect(consoleWatch.pageErrors).toEqual([])
+ } finally {
+ release.resolve(undefined)
+ spy.mockRestore()
+ try {
+ await context.close()
+ } finally {
+ await Promise.allSettled(reads)
+ }
+ }
+ }
+ await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'loading.expected.md'), [
+ aria, '', ...facts,
+ 'Loading: header actions disabled; action spaces are empty 36×20 areas, with no switch placeholders',
+ 'Loaded: skeleton removed; header actions enabled',
+ 'Motion: 13 opacity-only pulses, 2000ms; reduced motion stops all pulses',
+ ].join('\n'), MODE)
+ })
+
+ it('delays refresh hints and keeps cards through manual refresh, failure, and retry', async () => {
+ const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+ const listBundles = scaffold.ctx.pluginManager.listBundles.bind(scaffold.ctx.pluginManager)
+ const reads: ReturnType[] = []
+ try {
+ const probe = await context.newPage()
+ const consoleWatch = watchConsole(probe)
+ onTestFailed(() => saveFailureShot(probe, 'web-e2e-plugin-manager-refresh'))
+ await probe.clock.install()
+ await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+ await probe.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
+ const panel = probe.locator('[data-plugin-panel]')
+ const refresh = panel.getByRole('button', { name: '刷新', exact: true })
+ const add = panel.getByRole('button', { name: '添加插件', exact: true })
+ await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor()
+ await expect.poll(() => refresh.isEnabled()).toBe(true)
+ await probe.evaluate(() => document.fonts.ready)
+ const cards = panel.locator('[data-plugin-package], [data-plugin-item]')
+ const cardText = await cards.allTextContents()
+ expect(cardText.length).toBeGreaterThan(0)
+ const tooltip = probe.getByRole('tooltip').filter({ hasText: '刷新' })
+ const trace: string[] = []
+ const recordRefresh = async (phase: string) => {
+ trace.push(phase, await refresh.ariaSnapshot(), JSON.stringify({
+ busy: await panel.getAttribute('aria-busy'),
+ spinners: await refresh.locator('[data-state="ongoing"]').count(),
+ cards: await cards.count(),
+ skeletons: await panel.locator('[data-plugin-loading]').count(),
+ alerts: await panel.getByRole('alert').allTextContents(),
+ retry: await panel.getByRole('button', { name: '重试', exact: true }).count(),
+ toasts: await probe.locator('body > [role="alert"]').allTextContents(),
+ }))
+ }
+ const bounds = await refresh.boundingBox()
+ if (bounds === null) throw new Error('Refresh button has no visible bounds')
+ await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000))
+ try {
+ await probe.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
+ await probe.clock.runFor(499)
+ expect(await tooltip.count()).toBe(0)
+ trace.push(`Hover at 499ms: ${await tooltip.count()} tooltips`)
+ await probe.clock.runFor(1)
+ await tooltip.waitFor()
+ trace.push('Hover at 500ms:', await tooltip.ariaSnapshot())
+ await probe.mouse.move(0, 999)
+ await tooltip.waitFor({ state: 'detached' })
+
+ await add.focus()
+ await probe.keyboard.press('Shift+Tab')
+ expect(await refresh.evaluate(element => element === document.activeElement)).toBe(true)
+ await probe.clock.runFor(499)
+ expect(await tooltip.count()).toBe(0)
+ trace.push(`Focus at 499ms: ${await tooltip.count()} tooltips`)
+ await probe.clock.runFor(1)
+ await tooltip.waitFor()
+ trace.push('Focus at 500ms:', await tooltip.ariaSnapshot())
+ await probe.keyboard.press('Tab')
+ await tooltip.waitFor({ state: 'detached' })
+ } finally {
+ await probe.clock.resume()
+ }
+
+ await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000))
+ const spy = vi.spyOn(scaffold.ctx.pluginManager, 'listBundles')
+ try {
+ {
+ // One failure round exercises the full path: kept cards and spinner while pending,
+ // then the failure toast with no inline error over the stale cards. Success and retry
+ // outcomes are the store's deterministic concern, covered by manager-store.client.spec.ts.
+ const outcome = 'failure' as const
+ const release = Promise.withResolvers()
+ const called = spy.mock.calls.length
+ spy.mockImplementationOnce(() => {
+ const read = release.promise.then(() => { throw new Error('Fixture plugin refresh failed') })
+ reads.push(read)
+ return read
+ })
+ try {
+ await refresh.click()
+ await expect.poll(() => spy.mock.calls.length).toBeGreaterThan(called)
+ expect(await refresh.isDisabled()).toBe(true)
+ expect(await refresh.getAttribute('aria-busy')).toBe('true')
+ expect(await panel.getAttribute('aria-busy')).toBe('true')
+ const spinner = refresh.locator('[data-state="ongoing"]')
+ await spinner.waitFor()
+ expect(await spinner.evaluate(element => element.getAnimations({ subtree: true }).length)).toBeGreaterThan(0)
+ expect(await cards.allTextContents()).toEqual(cardText)
+ expect(await panel.locator('[data-plugin-loading]').count()).toBe(0)
+ expect(await tooltip.count()).toBe(0)
+ expect(await panel.getByRole('alert').count()).toBe(0)
+ expect(await probe.locator('body > [role="alert"]').count()).toBe(0)
+ await recordRefresh(`${outcome}: pending`)
+
+ release.resolve(undefined)
+ await probe.clock.runFor(400)
+ await expect.poll(() => refresh.isEnabled()).toBe(true)
+ expect(await refresh.getAttribute('aria-busy')).toBe('false')
+ expect(await panel.getAttribute('aria-busy')).toBe('false')
+ expect(await spinner.count()).toBe(0)
+ expect(await cards.allTextContents()).toEqual(cardText)
+ expect(await panel.locator('[data-plugin-loading]').count()).toBe(0)
+ expect(await panel.getByRole('alert').count()).toBe(0)
+ expect(await panel.getByRole('button', { name: '重试', exact: true }).count()).toBe(0)
+ const toasts = probe.locator('body > [role="alert"]')
+ await expect.poll(() => toasts.allTextContents()).toEqual(['刷新失败,请重试'])
+ await recordRefresh(`${outcome}: settled`)
+ } finally {
+ release.resolve(undefined)
+ }
+ }
+ } finally {
+ spy.mockRestore()
+ await probe.clock.resume()
+ }
+ await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'refresh.expected.md'), trace.join('\n'), MODE)
+ expect(consoleWatch.pageErrors).toEqual([])
+ expect(consoleWatch.warnings).toEqual([])
+ } finally {
+ try {
+ await context.close()
+ } finally {
+ await Promise.allSettled(reads)
+ }
+ }
+ })
+
it('starts with an unavailable selected bundle and lets the user clear its error', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-missing-bundle'))
const panel = await openPluginsPanel()
@@ -123,6 +374,26 @@ describe('web e2e: plugin manager', () => {
await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor({ timeout: 20_000 })
const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' })
expect(await toggle.getAttribute('aria-checked')).toBe('false')
+ const card = panel.locator('[data-plugin-package="@fixture/bundle"]')
+ const open = card.getByRole('button', { name: '查看 @fixture/bundle', exact: true })
+ await open.hover()
+ const hoverRadius = await card.evaluate(element => getComputedStyle(element).borderRadius)
+ await toggle.focus()
+ await page.keyboard.press('Shift+Tab')
+ expect(await open.evaluate(element => element.matches(':focus-visible'))).toBe(true)
+ const focusRing = await open.evaluate((element) => {
+ const style = getComputedStyle(element, '::after')
+ return {
+ radius: style.borderRadius,
+ cardRadius: style.getPropertyValue('--dsw-radius-xl').trim(),
+ outlineStyle: style.outlineStyle,
+ outlineWidth: Number.parseFloat(style.outlineWidth),
+ }
+ })
+ expect(focusRing.radius).toBe(hoverRadius)
+ expect(focusRing.radius).toBe(focusRing.cardRadius)
+ expect(focusRing.outlineStyle).toBe('solid')
+ expect(focusRing.outlineWidth).toBeGreaterThan(0)
// The profile's own group holds its fixture bundle and the scaffold's defaults bundle; the installation's
// optional bundles open the Official group, followed by the official plugins that registered their
// configuration, and its other bundles stay off the page.
@@ -239,6 +510,8 @@ describe('web e2e: plugin manager', () => {
await panel.getByRole('button', { name: 'Back to plugins' }).click()
await panel.getByRole('button', { name: 'View Agent Teams', exact: true }).waitFor()
expect(await panel.getByRole('switch', { name: 'Enable Agent Teams', exact: true }).count()).toBe(1)
+ await panel.getByRole('button', { name: 'View Automation tasks', exact: true }).waitFor()
+ expect(await panel.getByText('Run tasks in your sessions at a set time or on a repeating schedule.', { exact: true }).count()).toBe(1)
// The official configuration pages follow the language too, from their own dictionary.
for (const title of ['Shell', 'Agent loop', 'Subagent', 'Web search']) {
await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor()
@@ -312,6 +585,172 @@ describe('web e2e: plugin manager', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
+ it('lists the Automation tasks rows and mounts them with the bundle switch', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-schedule'))
+ const panel = await openPluginsPanel()
+ const scheduleRows = () => [...scaffold.ctx.loader.entries()]
+ .filter(entry => ['time-context', 'schedule', 'ui-schedule'].includes(entry.options.id))
+ const running = () => scheduleRows().filter(entry => entry.fiber?.state === FiberState.ACTIVE).length
+ expect(running()).toBe(0)
+ await panel.getByRole('button', { name: '查看 自动化任务', exact: true }).click()
+ const rows = panel.locator('[data-plugin-rows]')
+ for (const title of ['时间感知', '任务调度', '任务界面']) {
+ await rows.locator('[data-plugin-row]', { hasText: title }).waitFor()
+ }
+ // A bundle that is off offers no row switches.
+ expect(await rows.getByRole('switch').count()).toBe(0)
+ const toggle = panel.getByRole('switch', { name: '启用 自动化任务', exact: true })
+ await toggle.click()
+ try {
+ await expect.poll(running, { timeout: 20_000 }).toBe(3)
+ await expect.poll(() => rows.locator('[data-plugin-row]', { hasText: '运行中' }).count(), { timeout: 20_000 }).toBe(3)
+ for (const title of ['时间感知', '任务调度', '任务界面']) {
+ await rows.getByRole('switch', { name: `启用组件 ${title}`, exact: true }).waitFor()
+ }
+ await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '自动化任务', exact: true }).waitFor()
+ } finally {
+ if (await toggle.getAttribute('aria-checked') === 'true') await toggle.click()
+ await expect.poll(running, { timeout: 20_000 }).toBe(0)
+ await panel.getByRole('button', { name: '返回插件列表' }).click()
+ }
+ expect(tripwire.pageErrors).toEqual([])
+ }, 60_000)
+
+ it('keeps IME confirmation Enter in install fields and submits only a plain Enter', async () => {
+ const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+ const manager = scaffold.ctx.pluginManager
+ const manifest = await homeFile('profiles', 'scaffold', 'package.json')
+ const registrySpy = vi.spyOn(manager, 'registries').mockResolvedValue({
+ registry: null, fallbackRegistries: [], resolved: 'https://registry.npmjs.org/',
+ })
+ const inspectSpy = vi.spyOn(manager, 'inspect').mockImplementation(async (spec, options) => ({
+ status: 'accepted', kind: 'registry', name: spec, version: '1.0.0', bundle: true, registry: options?.registry ?? null,
+ }))
+ const installSpy = vi.spyOn(manager, 'installBundle').mockImplementation(async spec => ({
+ changed: false, application: 'failed', stage: 'install', target: spec,
+ error: { code: 'operation-error', diagnostic: 'IME fixture: no package was installed' },
+ }))
+ try {
+ const probe = await context.newPage()
+ const consoleWatch = watchConsole(probe)
+ onTestFailed(() => saveFailureShot(probe, 'web-e2e-plugin-manager-ime-enter'))
+ await probe.clock.install()
+ await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+ await probe.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
+ const panel = probe.locator('[data-plugin-panel]')
+ await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor({ timeout: 20_000 })
+ const trace: string[] = ['Synthetic browser KeyboardEvents, not an OS input-method test.']
+ for (const target of ['package', 'custom registry'] as const) {
+ inspectSpy.mockClear()
+ installSpy.mockClear()
+ await panel.getByRole('button', { name: '添加插件', exact: true }).click()
+ const dialog = probe.getByRole('dialog', { name: '添加插件', exact: true })
+ await dialog.waitFor({ timeout: 10_000 })
+ const spec = target === 'package' ? 'ime-confirm-package' : 'ime-confirm-registry'
+ const specField = dialog.getByRole('textbox', { name: '包名或地址', exact: true })
+ await specField.fill(spec)
+ let field = specField
+ const customRegistry = 'https://registry.example.test/'
+ if (target === 'custom registry') {
+ const registryToggle = dialog.getByRole('button', { name: /^安装源/ })
+ await registryToggle.click()
+ const registry = probe.locator('[data-install-registry]')
+ const offered = registry.getByRole('radio', { name: 'npm 官方源 registry.npmjs.org', exact: true })
+ const custom = registry.getByRole('radio', { name: '自定义地址', exact: true })
+ field = registry.getByRole('textbox', { name: '自定义地址', exact: true })
+ expect(await offered.isChecked()).toBe(true)
+ expect(await custom.isChecked()).toBe(false)
+ await offered.focus()
+ await probe.keyboard.press('Tab')
+ expect(await field.evaluate(element => element === document.activeElement)).toBe(true)
+ expect(await offered.isChecked()).toBe(true)
+ expect(await custom.isChecked()).toBe(false)
+ await probe.keyboard.press('Shift+Tab')
+ expect(await offered.evaluate(element => element === document.activeElement)).toBe(true)
+ await probe.keyboard.press('Shift+Tab')
+ await registry.waitFor({ state: 'detached' })
+ expect(await registryToggle.evaluate(element => element === document.activeElement)).toBe(true)
+ await probe.keyboard.press('Enter')
+ await custom.focus()
+ await probe.keyboard.press('Space')
+ expect(await custom.isChecked()).toBe(true)
+ expect(await offered.isChecked()).toBe(false)
+ expect(await field.evaluate(element => element === document.activeElement)).toBe(true)
+ await field.fill(customRegistry)
+ await probe.keyboard.press('Shift+Tab')
+ expect(await custom.evaluate(element => element === document.activeElement)).toBe(true)
+ await probe.keyboard.press('Tab')
+ expect(await field.evaluate(element => element === document.activeElement)).toBe(true)
+ await probe.keyboard.press('Tab')
+ await registry.waitFor({ state: 'detached' })
+ expect(await registryToggle.evaluate(element => element === document.activeElement)).toBe(true)
+ await probe.keyboard.press('Enter')
+ await custom.focus()
+ await probe.keyboard.press('Tab')
+ expect(await field.evaluate(element => element === document.activeElement)).toBe(true)
+ expect(await custom.isChecked()).toBe(true)
+ }
+ const value = target === 'package' ? spec : customRegistry
+ const assertUnsubmitted = async (label: string) => {
+ expect(await field.inputValue()).toBe(value)
+ expect(await field.isEditable()).toBe(true)
+ expect(await dialog.getByRole('button', { name: '安装', exact: true }).isEnabled()).toBe(true)
+ expect(inspectSpy).not.toHaveBeenCalled()
+ expect(installSpy).not.toHaveBeenCalled()
+ trace.push(`${target} / ${label}: inspect=0, install=0; editable; value retained`, await field.ariaSnapshot())
+ }
+ await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000))
+ try {
+ for (const event of [
+ { label: 'isComposing=true', isComposing: true, keyCode: 13 },
+ { label: 'Safari isComposing=false, keyCode=229', isComposing: false, keyCode: 229 },
+ ]) {
+ await field.dispatchEvent('keydown', {
+ key: 'Enter', code: 'Enter', bubbles: true, cancelable: true,
+ isComposing: event.isComposing, keyCode: event.keyCode,
+ })
+ await assertUnsubmitted(event.label)
+ }
+ const unmarkedEnter = { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true, isComposing: false, keyCode: 13 }
+ await field.dispatchEvent('compositionstart')
+ await field.dispatchEvent('keydown', unmarkedEnter)
+ await assertUnsubmitted('compositionstart + unmarked Enter')
+ await field.dispatchEvent('compositionend')
+ await field.dispatchEvent('keydown', unmarkedEnter)
+ await assertUnsubmitted('compositionend + immediate unmarked Enter')
+ await probe.clock.runFor(9)
+ await field.dispatchEvent('keydown', unmarkedEnter)
+ await assertUnsubmitted('compositionend + 9ms unmarked Enter')
+ await probe.clock.runFor(2)
+ } finally {
+ await probe.clock.resume()
+ }
+ await field.press('Enter')
+ await expect.poll(() => inspectSpy.mock.calls.length, { timeout: 10_000 }).toBe(1)
+ await expect.poll(() => installSpy.mock.calls.length, { timeout: 10_000 }).toBe(1)
+ expect(inspectSpy.mock.calls[0]?.[0]).toBe(spec)
+ expect(installSpy.mock.calls[0]?.[0]).toBe(spec)
+ expect(installSpy.mock.calls[0]?.[1]).toMatchObject({ enabled: false, registry: target === 'package' ? null : customRegistry })
+ const failed = probe.getByRole('dialog', { name: '插件安装失败', exact: true })
+ await failed.waitFor({ timeout: 10_000 })
+ trace.push(`${target} / plain Enter: inspect=1, install=1`, await failed.ariaSnapshot())
+ await failed.getByRole('button', { name: '关闭', exact: true }).click()
+ await failed.waitFor({ state: 'hidden', timeout: 10_000 })
+ }
+ expect(await homeFile('profiles', 'scaffold', 'package.json')).toBe(manifest)
+ expect(consoleWatch.pageErrors).toEqual([])
+ trace.push('Profile manifest unchanged; controlled Host result performed no package installation.')
+ await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'ime-enter.expected.md'), trace.join('\n'), MODE)
+ } finally {
+ try { await context.close() }
+ finally {
+ registrySpy.mockRestore()
+ inspectSpy.mockRestore()
+ installSpy.mockRestore()
+ }
+ }
+ }, 60_000)
+
it('checks a spec before installing it and words what the check refused', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-install'))
const panel = await openPluginsPanel()
@@ -383,7 +822,8 @@ describe('web e2e: plugin manager', () => {
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
- 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md',
+ 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', 'loading.expected.md', 'refresh.expected.md',
+ 'ime-enter.expected.md',
])
})
})
diff --git a/apps/web/tests/ptc-round.e2e.ts b/apps/web/tests/ptc-round.e2e.ts
index 4c95f6f14a..96960dba6b 100644
--- a/apps/web/tests/ptc-round.e2e.ts
+++ b/apps/web/tests/ptc-round.e2e.ts
@@ -81,7 +81,8 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
await row.waitFor({ state: 'attached', timeout: 30_000 })
await expandOwningTurnProcess(page, row)
await row.waitFor({ state: 'visible' })
- await page.getByRole('button', { name: 'Preparing to run code', exact: true }).waitFor()
+ await row.getByText('Code', { exact: true }).waitFor()
+ expect(await row.getAttribute('data-tool')).toBe('run_code')
expect(sessionEvents.some(event => event.type === 'tool/call')).toBe(false)
expect(await row.getByRole('button').count()).toBe(0)
expect(await row.locator('pre').count()).toBe(0)
diff --git a/apps/web/tests/reasoning-preview.e2e.ts b/apps/web/tests/reasoning-preview.e2e.ts
index 0d5911edde..44300a4ca6 100644
--- a/apps/web/tests/reasoning-preview.e2e.ts
+++ b/apps/web/tests/reasoning-preview.e2e.ts
@@ -65,6 +65,39 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
const reasoning = page.locator('[data-variant="think"][data-state="running"]')
await expandOwningTurnProcess(page, reasoning)
await reasoning.waitFor()
+ const whale = page.locator('[data-chat-running] svg')
+ const animatedWhale = whale.locator('path:has(animate)')
+ const restingWhale = whale.locator('path:not(:has(animate))')
+ await animatedWhale.locator('animate').waitFor({ state: 'attached' })
+ const positions = await animatedWhale.evaluate(async (element) => {
+ const path = element as SVGPathElement
+ const svg = path.ownerSVGElement!
+ const time = svg.getCurrentTime()
+ svg.pauseAnimations()
+ try {
+ const positions: number[][] = []
+ for (const at of [0.3, 0.9, 3.3]) {
+ svg.setCurrentTime(at)
+ await new Promise(resolve => requestAnimationFrame(() => { resolve() }))
+ const point = path.getPointAtLength(path.getTotalLength() * 0.4)
+ positions.push([point.x, point.y])
+ }
+ return positions
+ } finally {
+ svg.setCurrentTime(time)
+ svg.unpauseAnimations()
+ }
+ })
+ expect(positions[1]).not.toEqual(positions[0])
+ expect(positions[2]).toEqual(positions[0])
+ expect(await animatedWhale.evaluate(element => getComputedStyle(element).display)).not.toBe('none')
+ expect(await restingWhale.evaluate(element => getComputedStyle(element).display)).toBe('none')
+ await page.emulateMedia({ reducedMotion: 'reduce' })
+ await expect.poll(() => animatedWhale.evaluate(element => getComputedStyle(element).display)).toBe('none')
+ await expect.poll(() => restingWhale.evaluate(element => getComputedStyle(element).display)).not.toBe('none')
+ await page.emulateMedia({ reducedMotion: 'no-preference' })
+ await expect.poll(() => animatedWhale.evaluate(element => getComputedStyle(element).display)).not.toBe('none')
+ await expect.poll(() => restingWhale.evaluate(element => getComputedStyle(element).display)).toBe('none')
expect(await reasoning.getAttribute('data-preview')).toBeNull()
first.proceed.resolve(undefined)
diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts
index d4518210b4..6381deb606 100644
--- a/apps/web/tests/reference-composer.e2e.ts
+++ b/apps/web/tests/reference-composer.e2e.ts
@@ -29,7 +29,7 @@ import {
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
+import { connectFreshWorkspace, newEnglishPage, pinBrowserClock, saveFailureShot, WEB_FIXTURE_TIME, writeComposerDraft } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/reference-composer', import.meta.url))
const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
@@ -142,14 +142,16 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
let browser: Browser
let page: Page
let tripwire: ReturnType
+ let unpinBrowserClock: (() => void) | undefined
beforeAll(async () => {
scaffold = await launchWebScaffold({})
- const targetCreatedAt = Date.now() - 60_000
+ const targetCreatedAt = WEB_FIXTURE_TIME - 60_000
await seedSession(scaffold, sourceSessionFixture(), SOURCE_SESSION_ID, undefined, { createdAt: targetCreatedAt - 1 })
await seedSession(scaffold, targetSessionFixture(), TARGET_SESSION_ID, undefined, { createdAt: targetCreatedAt })
browser = await chromium.launch()
page = await newEnglishPage(browser)
+ unpinBrowserClock = await pinBrowserClock(page)
tripwire = watchConsole(page)
// Fixture files land before the workspace connects so the Host's file
// index never races their creation (the connect helper mkdirs the same
@@ -169,6 +171,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
}, 120_000)
afterAll(async () => {
+ unpinBrowserClock?.()
await browser?.close()
await scaffold?.close()
})
diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts
index 84cd580b8b..37d946f38c 100644
--- a/apps/web/tests/schedule-after.e2e.ts
+++ b/apps/web/tests/schedule-after.e2e.ts
@@ -44,6 +44,9 @@ declare module '@deepseek-ai/dsh-llm' {
}
const MODE = webSnapshotMode()
+/** The optional Schedule bundle is the switch these scenarios turn on. */
+const SCHEDULE_BUNDLE = fileURLToPath(new URL('../../../packages/experimental/schedule-bundle/cordis.patch.yml', import.meta.url))
+const TIME_CONTEXT_EVERY_STEP = fileURLToPath(new URL('./fixtures/time-context-every-step.patch.yml', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/schedule-after', import.meta.url))
const AFTER_EXPECTED = join(SNAPSHOT_DIR, 'conversation.expected.md')
const AT_EXPECTED = join(SNAPSHOT_DIR, 'at-conversation.expected.md')
@@ -272,7 +275,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
beforeAll(async () => {
scaffold = await launchWebScaffold({
- extraOverlayPath: fileURLToPath(new URL('./fixtures/time-context-every-step.patch.yml', import.meta.url)),
+ extraOverlayPath: [SCHEDULE_BUNDLE, TIME_CONTEXT_EVERY_STEP],
})
scaffold.ctx.effect(
() => scaffold.ctx.llm.registerAdapter([AFTER_PROVIDER], afterAdapter),
@@ -459,9 +462,12 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
await row.waitFor({ timeout: 15_000 })
expect(await row.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
expect(await row.textContent()).toContain(AFTER_REPLY)
+ const triggerTitle = '[data-turn-trigger] > button > span:nth-child(2)'
+ await page.locator(triggerTitle).waitFor()
+ expect(await page.locator(triggerTitle).textContent()).toBe('Automation task')
await compareOrRefreshGolden(
AFTER_EXPECTED,
- await captureStableAria(page, selector, scaffold.workspaceCwd),
+ `${await captureStableAria(page, triggerTitle, scaffold.workspaceCwd)}\n\n${await captureStableAria(page, selector, scaffold.workspaceCwd)}`,
MODE,
)
await expectNoReminderEntry(page)
@@ -710,7 +716,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
beforeAll(async () => {
const fixture = await readFile(CATALOG_FIXTURE, 'utf8')
scaffold = await launchWebScaffold({
- extraOverlayPath: fileURLToPath(new URL('./fixtures/time-context-every-step.patch.yml', import.meta.url)),
+ extraOverlayPath: [SCHEDULE_BUNDLE, TIME_CONTEXT_EVERY_STEP],
})
await seedSession(scaffold, fixture, CATALOG_SESSION_ID, 'standard')
const records = foldScheduleEvents(fixture.trim().split('\n').slice(1).map(line => JSON.parse(line) as SessionEvent)).active
@@ -836,19 +842,20 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
it('reads stored tasks without a live Session and deletes them through the catalog', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-catalog'))
- const shipped = composeEntries([
+ const layers = [
loadOverlayPatches('Schedule catalog shipped roster', BASE_PATCH),
...WEB_PATCHES.map(file => loadOverlayPatches('Schedule catalog shipped roster', file)),
- ])
- expect(shipped.find(entry => entry.id === 'ui-schedule')).toMatchObject({
- name: '@deepseek-ai/dsh-client-ui-schedule',
- })
- expect(shipped.find(entry => entry.id === 'ui-schedule')?.disabled).toBe(true)
+ ]
+ const shipped = composeEntries(layers)
+ const withBundle = composeEntries([...layers, loadOverlayPatches('Schedule catalog bundle', SCHEDULE_BUNDLE)])
for (const row of [
{ id: 'time-context', name: '@deepseek-ai/dsh-time-context' },
{ id: 'schedule', name: '@deepseek-ai/dsh-schedule' },
+ { id: 'ui-schedule', name: '@deepseek-ai/dsh-client-ui-schedule' },
]) {
- expect(shipped.filter(entry => entry.id === row.id && entry.name === row.name)).toHaveLength(1)
+ // The shipped Web composition carries none of the rows; the bundle inserts each once, switched on.
+ expect(shipped.some(entry => entry.id === row.id)).toBe(false)
+ expect(withBundle.filter(entry => entry.id === row.id && entry.name === row.name && entry.disabled !== true)).toHaveLength(1)
}
await page.getByRole('button', { name: 'Automation tasks', exact: true }).click()
diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts
index 14278995c4..705098e2d4 100644
--- a/apps/web/tests/settings-chrome.e2e.ts
+++ b/apps/web/tests/settings-chrome.e2e.ts
@@ -71,6 +71,10 @@ describe('web e2e: settings modal and General preferences', () => {
await dialog.getByRole('button', { name: '工作区内修改' }).waitFor({ timeout: 10_000 })
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
+ await dialog.getByText('工作步骤展示', { exact: true }).locator('../..')
+ .getByRole('button', { name: '详细', exact: true }).waitFor({ timeout: 10_000 })
+ expect(await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'))
+ .not.toContain('transcriptView:')
const openDocument = dialog.getByRole('button', { name: '打开配置文件' })
await openDocument.waitFor({ timeout: 10_000 })
let openRequests = 0
@@ -111,16 +115,17 @@ describe('web e2e: settings modal and General preferences', () => {
// rewrite this surface's golden.
await dialog.getByRole('button', { name: '内置插件', exact: true }).click()
await dialog.getByRole('heading', { name: '内置插件', exact: true }).waitFor({ timeout: 10_000 })
- // Both groups start collapsed; the preset group's header still carries its display-only switcher.
+ // The preset group starts open under its display-only switcher; the global group starts folded.
const presetSwitcher = dialog.getByRole('button', { name: '选择要查看的 Agent 预设' })
await presetSwitcher.waitFor({ timeout: 10_000 })
// The shipped default's zh display name comes from the zh dictionaries.
expect(await presetSwitcher.textContent()).toBe('标准模式(默认)')
const presetToggle = dialog.getByRole('button', { name: '会话插件', exact: true })
- expect(await presetToggle.getAttribute('aria-expanded')).toBe('false')
- expect(await dialog.locator('[data-plugin-scope="preset"] [data-plugin-entry]').count()).toBe(0)
- await presetToggle.click()
- await dialog.getByRole('button', { name: /^全局/ }).click()
+ expect(await presetToggle.getAttribute('aria-expanded')).toBe('true')
+ expect(await dialog.locator('[data-plugin-scope="preset"] [data-plugin-entry]').count()).toBeGreaterThan(0)
+ const globalToggle = dialog.getByRole('button', { name: /^全局/ })
+ expect(await globalToggle.getAttribute('aria-expanded')).toBe('false')
+ await globalToggle.click()
const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR)
await pluginRow.waitFor({ timeout: 10_000 })
const expectedPluginCount = [...scaffold.ctx.loader.entries()]
@@ -132,11 +137,12 @@ describe('web e2e: settings modal and General preferences', () => {
// presets took over included, preset compositions excluded.
expect(await dialog.locator('[data-plugin-scope="global"] [data-plugin-entry]').count())
.toBe(expectedPluginCount)
- // The enablement tag is the row's collapsed status: an active fiber draws no
- // dot, so no global row names the active phase. Guard the assertion against
- // matching nothing because no row is enabled.
- expect(await dialog.locator('[data-plugin-scope="global"] [data-plugin-entry] button[aria-label$="已启用"]').count())
- .toBeGreaterThan(0)
+ // A plainly enabled row states its status only in its accessible name: it
+ // carries no tag, and an active fiber draws no dot, so no global row shows
+ // either. Guard the assertions against matching nothing because no row is enabled.
+ const enabledRows = dialog.locator('[data-plugin-scope="global"] [data-plugin-entry] button[aria-label$="已启用"]')
+ expect(await enabledRows.count()).toBeGreaterThan(0)
+ expect(await enabledRows.getByText('已启用', { exact: true }).count()).toBe(0)
expect(await dialog.locator('[data-plugin-scope="global"] [role="img"][aria-label="运行中"]').count()).toBe(0)
expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count'))
.toBe(String(expectedPluginCount))
@@ -159,10 +165,17 @@ describe('web e2e: settings modal and General preferences', () => {
] as const
for (const [entryId, status] of instanceRows) {
const row = dialog.locator(`[data-plugin-scope="preset"] [data-plugin-entry="${entryId}"]`)
- const trigger = row.getByRole('button', { name: `tool-subagent, ${entryId}, ${status}`, exact: true })
+ // An id that repeats the title adds nothing, so its card shows no chip and its name omits it.
+ const repeatsTitle = entryId === 'tool-subagent'
+ const name = repeatsTitle ? `tool-subagent, ${status}` : `tool-subagent, ${entryId}, ${status}`
+ const trigger = row.getByRole('button', { name, exact: true })
await trigger.waitFor({ timeout: 10_000 })
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
const identity = row.locator('code')
+ if (repeatsTitle) {
+ expect(await identity.count()).toBe(0)
+ continue
+ }
expect(await identity.textContent()).toBe(entryId)
expect(await identity.getAttribute('title')).toBe(entryId)
}
@@ -560,7 +573,7 @@ describe('web e2e: settings modal and General preferences', () => {
}, 90_000)
it.each([
- ['compact', '简洁'], ['detailed', '详细'], ['verbose', '完全展开'],
+ ['compact', '简洁'], ['standard', '标准'], ['verbose', '完全展开'],
] as const)('persists the %s work-details mode across reload', async (mode, label) => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-transcript-view'))
await openSettings(page, 'zh')
@@ -568,7 +581,7 @@ describe('web e2e: settings modal and General preferences', () => {
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByText('工作步骤展示', { exact: true }).waitFor({ timeout: 10_000 })
const details = dialog.getByText('工作步骤展示', { exact: true }).locator('../..')
- await details.getByRole('button', { name: '标准', exact: true }).click()
+ await details.getByRole('button', { name: '详细', exact: true }).click()
await page.getByRole('menuitem', { name: label, exact: true }).click()
await details.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 })
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'), { timeout: 5_000 })
@@ -585,10 +598,10 @@ describe('web e2e: settings modal and General preferences', () => {
await restoredDetails.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 })
await restoredDetails.getByRole('button', { name: label, exact: true }).click()
- await page.getByRole('menuitem', { name: '标准', exact: true }).click()
- await restoredDetails.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 })
+ await page.getByRole('menuitem', { name: '详细', exact: true }).click()
+ await restoredDetails.getByRole('button', { name: '详细', exact: true }).waitFor({ timeout: 10_000 })
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'), { timeout: 5_000 })
- .toContain('transcriptView: standard')
+ .toContain('transcriptView: detailed')
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
@@ -798,7 +811,8 @@ describe('web e2e: settings modal and General preferences', () => {
await withoutBrowser.goto(fresh.authenticatedUrl, { waitUntil: 'load' })
await openSettings(withoutBrowser, 'en')
const dialog = withoutBrowser.getByRole('dialog', { name: 'Settings' })
- await dialog.getByRole('button', { name: 'Detailed', exact: true }).waitFor()
+ await dialog.getByText('Work details', { exact: true }).locator('../..')
+ .getByRole('button', { name: 'Detailed', exact: true }).waitFor()
expect(await dialog.getByText('Open chat links in', { exact: true }).count()).toBe(0)
const snapshot = await captureStableAria(withoutBrowser, '[role="dialog"]', fresh.workspaceCwd, versionCapture)
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'dialog-no-browser.expected.md'), snapshot, MODE)
diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
index a2443a2e47..dac0e202c7 100644
--- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
+++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
@@ -8,12 +8,12 @@
- tab "Trajectory"
- text: Stream one TypeScript fence for the highlighting snapshot. {{clock}}
- button "Copy"
-- status: Deep diving...
-- button "Deep diving for {{duration}}" [disabled] [expanded]
- text: ts
- button "Wrap lines" [pressed]
- button "Copy"
- code: "const first: number = 1 const second = \"two\" let tail"
+- status: Deep diving...
+- text: Deep diving for {{duration}}...
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts
index 8b83176777..f2e34922f4 100644
--- a/apps/web/tests/steering.e2e.ts
+++ b/apps/web/tests/steering.e2e.ts
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page, WebSocketRoute } from 'playwright'
import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed, type MockInstance } from 'vitest'
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { expandAssistantStream } from '@deepseek-ai/dsh-llm'
@@ -17,7 +17,7 @@ import {
compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
-import { openSettings, connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import { openSettings, connectFreshWorkspace, newEnglishPage, pinBrowserClock, pinHostClock, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/steering', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
@@ -72,15 +72,22 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
let browser: Browser
let page: Page
let tripwire: ReturnType
+ let hostClock: MockInstance | undefined
+ let unpinBrowserClock: (() => void) | undefined
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
+ // This scenario types its own messages, so the Host stamps their times: both
+ // clocks must read the fixture day or a run across Asia/Shanghai midnight
+ // renders the same message with a date prefix.
+ hostClock = pinHostClock()
scaffold = await launchWebScaffold(MODE === 'record'
? {}
: { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS, compareReplaySession: true })
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
+ unpinBrowserClock = await pinBrowserClock(page)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -89,8 +96,15 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
}, 120_000)
afterAll(async () => {
- await browser?.close()
- await scaffold?.close()
+ try {
+ await browser?.close()
+ await scaffold?.close()
+ } finally {
+ // A failing close (unconsumed fixture, mismatched snapshot) must not
+ // leave the pinned clocks running for the suites after this one.
+ unpinBrowserClock?.()
+ hostClock?.mockRestore()
+ }
})
it('strictly steers one queued row; the interjection is logged, rendered, and obeyed', async () => {
@@ -381,9 +395,12 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
let browser: Browser
let page: Page
let tripwire: ReturnType
+ let hostClock: MockInstance | undefined
+ let unpinBrowserClock: (() => void) | undefined
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
+ hostClock = pinHostClock()
// The scenario boots a fresh session against the override-only fixture;
// the replay.override.json sidecar replaces the derived script, so the
// (deliberately absent) session.jsonl is never read.
@@ -399,6 +416,7 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
+ unpinBrowserClock = await pinBrowserClock(page)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -409,8 +427,13 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
afterAll(async () => {
releaseReplay.resolve(undefined)
disposeReplayBarrier?.()
- await browser?.close()
- await scaffold?.close()
+ try {
+ await browser?.close()
+ await scaffold?.close()
+ } finally {
+ unpinBrowserClock?.()
+ hostClock?.mockRestore()
+ }
})
it.skipIf(MODE === 'record')('queues two messages, then flushes both with an empty-draft Cmd+Enter', async () => {
@@ -424,6 +447,10 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
await input.fill(PROMPT)
await input.press('Enter')
+ // The turn must report busy before the next two sends: Enter's queue gesture
+ // resolved against a not-yet-running agent admits the message into the turn
+ // instead of publishing a Queue row.
+ await page.getByRole('button', { name: 'Stop generating' }).waitFor({ timeout: 10_000 })
await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
await input.fill(STEER_ONE)
await input.press('Enter')
diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts
index 0dc69acbbf..fd1babcbef 100644
--- a/apps/web/tests/support.ts
+++ b/apps/web/tests/support.ts
@@ -5,7 +5,7 @@ import { createRequire } from 'node:module'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Locator, Page } from 'playwright'
-import { expect } from 'vitest'
+import { expect, vi, type MockInstance } from 'vitest'
/** The built page under test; `pnpm run test:web` rebuilds it before running. */
export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url))
@@ -41,6 +41,57 @@ export const ZH_BROWSER_LOCALE = 'zh-CN'
/** Same-day anchor for seeded event times and the Asia/Shanghai browser clock. */
export const WEB_FIXTURE_TIME = Date.parse('2026-01-15T12:00:00+08:00')
+/** How often a pinned browser clock advances, in milliseconds. */
+const PINNED_CLOCK_STEP_MS = 250
+
+/**
+ * Pin the Host clock to the shared fixture day for one scenario suite.
+ *
+ * A rendered message clock gains a `clock.md` / `clock.ymd` date prefix as soon
+ * as the message's local day differs from the renderer's, so a scenario whose
+ * message times are stamped by the Host wall clock — a live composer send, or a
+ * seed anchored from `Date.now()` — renders a different aria line once the run
+ * spans `Asia/Shanghai` midnight. Reading the fixture day on both clocks makes
+ * that calendar fact part of the scenario rather than of the run.
+ *
+ * The pinned clock advances with real elapsed time, so turn deadlines, session
+ * ordering, and durations keep running.
+ * @returns the installed `Date.now` spy, restored by the caller in teardown.
+ */
+export function pinHostClock(): MockInstance {
+ const startedAt = performance.now()
+ return vi.spyOn(Date, 'now').mockImplementation(() =>
+ WEB_FIXTURE_TIME + Math.floor(performance.now() - startedAt))
+}
+
+/**
+ * Pin a scenario page's clock to the shared fixture day, advancing with real
+ * time. Pair it with {@link pinHostClock} for a scenario that stamps its own
+ * message times, and with `seedSession`'s `createdAt` for a seeded one.
+ *
+ * The frozen instant is re-applied from the anchor on an interval rather than
+ * left at one value, because product logic reads two `Date.now()` values to
+ * decide a minimum display hold or a deadline window; a permanently frozen
+ * clock stops supplying elapsed time, so a title that owes its golden settled
+ * text keeps rendering its previous one. Both clocks therefore advance at the
+ * real rate and stay within one step of each other.
+ * @param page - page whose clock is pinned.
+ * @returns the disposer that stops re-pinning; call it in teardown.
+ */
+export async function pinBrowserClock(page: Page): Promise<() => void> {
+ const startedAt = Date.now()
+ const step = async (): Promise => {
+ try {
+ await page.clock.setFixedTime(WEB_FIXTURE_TIME + (Date.now() - startedAt))
+ } catch {
+ // The page closed under the interval; teardown owns the last word.
+ }
+ }
+ await step()
+ const timer = setInterval(() => { void step() }, PINNED_CLOCK_STEP_MS)
+ return () => { clearInterval(timer) }
+}
+
/**
* Open the standard browser-test page advertising English before client boot.
* This keeps role locators and goldens deterministic while leaving the Host
@@ -94,8 +145,7 @@ export async function expandOwningTurnProcess(page: Page, target: Locator): Prom
const turn = await target.evaluate(element => element.closest('[data-chat-turn]')?.dataset.chatTurn)
if (turn !== undefined) {
const control = page.locator(`[data-turn-process="${turn}"]`)
- await control.waitFor({ state: 'visible', timeout: 10_000 })
- if (await control.getAttribute('aria-expanded') === 'false') await control.click()
+ if (await control.count() > 0 && await control.getAttribute('aria-expanded') === 'false') await control.click()
}
const group = target.locator('xpath=ancestor::*[@data-chat-group-key][1]')
const header = group.locator('[data-process-activity]').first()
diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts
index e00c958e26..f1b7b3684f 100644
--- a/apps/web/tests/turn-tail-actions.e2e.ts
+++ b/apps/web/tests/turn-tail-actions.e2e.ts
@@ -11,7 +11,7 @@ import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
-import type { Browser, Page } from 'playwright'
+import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterEach, describe, expect, it, onTestFailed, vi } from 'vitest'
import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
@@ -38,6 +38,22 @@ const MODE = webSnapshotMode()
const NARRATION = 'Reading the workspace now.'
const PROMPT = `Begin your reply with the plain sentence "${NARRATION}" as text, and in that same message call the bash tool with the command "echo alpha". After the tool result, reply with the single word DONE and stop.`
+/**
+ * Focus the Copy IconAction and wait for the tooltip the running golden records.
+ *
+ * `Tooltip` ignores focus while the last input came from a pointer, and any
+ * keydown clears that flag, so a focus that follows this scenario's own clicks
+ * raises no bubble. Clear the flag with a key that moves nothing, then focus
+ * and wait for the bubble rather than assuming its commit landed.
+ * @param page - page containing the Copy button.
+ * @param button - the Copy button to focus.
+ */
+async function expectFocusTooltip(page: Page, button: Locator): Promise {
+ await page.keyboard.press('Shift')
+ await button.focus()
+ await page.getByRole('tooltip', { name: 'Copy', exact: true }).waitFor({ timeout: 10_000 })
+}
+
describe('web e2e: assistant IconActions wait for the turn to end', () => {
let scaffold: WebScaffold | undefined
let browser: Browser | undefined
@@ -144,11 +160,9 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
// so the first step's message and tool result are already durable.
await expect.poll(() => existsSync(marker), { timeout: 20_000 }).toBe(true)
const runningProcess = page.locator('[data-turn-process]')
- expect(await runningProcess.count()).toBe(1)
- expect(await runningProcess.isDisabled()).toBe(true)
- expect(await runningProcess.getAttribute('aria-expanded')).toBe('true')
+ expect(await runningProcess.count()).toBe(0)
await expect.poll(
- () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
+ () => page.getByRole('status').filter({ hasText: 'Deep diving' }).isVisible(),
{ timeout: 10_000 },
).toBe(true)
await page.locator('[data-streaming="true"]')
@@ -159,7 +173,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
const copyButtons = page.getByRole('button', { name: 'Copy' })
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(1)
expect(await page.getByRole('button', { name: 'Branch into a new conversation' }).count()).toBe(0)
- await copyButtons.first().focus()
+ await expectFocusTooltip(page, copyButtons.first())
const running = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
await compareOrRefreshGolden(RUNNING_EXPECTED, running, MODE)
@@ -172,6 +186,8 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
await page.locator('[data-turn-process]').waitFor({ timeout: 10_000 })
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(2)
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
+ // The settled golden records no bubble: this focus follows the Stop click,
+ // which leaves the pointer owning the last input.
await copyButtons.last().focus()
const settledAria = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
await compareOrRefreshGolden(SETTLED_EXPECTED, settledAria, MODE)
@@ -244,7 +260,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
}, 60_000)
it.skipIf(MODE === 'record').each([
- ['compact', 'Compact'], ['detailed', 'Detailed'], ['verbose', 'Verbose'],
+ ['compact', 'Compact'], ['standard', 'Standard'], ['verbose', 'Verbose'],
] as const)('applies whole-Turn presentation in %s mode', async (mode, label) => {
await launch()
onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-process-setting'))
@@ -259,7 +275,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
await openSettings(page, 'en')
const dialog = page.getByRole('dialog', { name: 'Settings' })
await dialog.getByText('Work details', { exact: true }).locator('../..')
- .getByRole('button', { name: 'Standard', exact: true }).click()
+ .getByRole('button', { name: 'Detailed', exact: true }).click()
await page.getByRole('menuitem', { name: label, exact: true }).click()
await page.keyboard.press('Escape')
diff --git a/apps/web/tests/workspace-new-session-folding.e2e.ts b/apps/web/tests/workspace-new-session-folding.e2e.ts
index e23e0e9a80..176af733b0 100644
--- a/apps/web/tests/workspace-new-session-folding.e2e.ts
+++ b/apps/web/tests/workspace-new-session-folding.e2e.ts
@@ -71,9 +71,15 @@ describe('web e2e: blank New Session folding quota', () => {
const sidebar = page.getByRole('tree', { name: 'Sessions' })
await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 15_000 }).toBe(7)
expect(await sidebar.getByText('New Session', { exact: true }).count()).toBe(1)
- expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(6)
+ expect(await sidebar.getByText('Untitled', { exact: true }).count()).toBe(5)
const showMore = sidebar.getByRole('button', { name: 'Show 11 more sessions' })
await showMore.waitFor({ timeout: 15_000 })
+ // The sidebar golden records its workspace row hovered, which reveals the
+ // row actions `.projectRow:hover` otherwise hides. Chromium does not
+ // recompute `:hover` when a re-render moves rows under a pointer that does
+ // not move, so the state is set rather than inherited from the New Session
+ // click above.
+ await sidebar.getByRole('treeitem').first().hover()
await compareOrRefreshGolden(
SIDEBAR_EXPECTED,
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
@@ -82,7 +88,14 @@ describe('web e2e: blank New Session folding quota', () => {
await showMore.click()
await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 10_000 }).toBe(12)
- expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(11)
+ expect(await sidebar.getByText('Untitled', { exact: true }).count()).toBe(10)
+ // `first-batch.expected.md` records the first row the batch reveals
+ // hovered — the sixth session, which takes the former Show-more button's
+ // position. Under `:hover` its trailing cell swaps the relative time for
+ // the row actions. The `showMore.click()` above reflows the list beneath a
+ // stationary pointer, so the row that ends up there follows the layout,
+ // not the scenario; hover the row the golden records instead.
+ await sidebar.getByRole('treeitem').nth(7).hover()
await compareOrRefreshGolden(
join(EXPECTED_DIR, 'first-batch.expected.md'),
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
diff --git a/benchmarks/terminal-io/terminal-io.worker.ts b/benchmarks/terminal-io/terminal-io.worker.ts
index 954af19230..9858128d5d 100644
--- a/benchmarks/terminal-io/terminal-io.worker.ts
+++ b/benchmarks/terminal-io/terminal-io.worker.ts
@@ -59,7 +59,7 @@ async function measure(capacityBytes: number, mode: string): Promise 0) {
diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml
index efc66e50ef..06758c387e 100644
--- a/docs/architecture.i18n.yaml
+++ b/docs/architecture.i18n.yaml
@@ -24,8 +24,8 @@
en: 214c3c81c21df6f9
zh: 75d2cdc3c9fc9930
/deepseek-harness-architecture/turn-flow:
- en: e13500f6d5b614d6
- zh: 7e6acb96a520ded2
+ en: badc0ffea030cdce
+ zh: 3241f0b37c3ec067
/deepseek-harness-architecture/session-log:
en: e1e3a44140036f25
zh: 84c70c969e5ad216
diff --git a/docs/architecture.md b/docs/architecture.md
index 0919d65a30..b5dfb6571b 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -114,6 +114,8 @@ One inbox feeds the driver; injected context waits for a waking message. AgentLo
The loop sends immutable requests with live cancellation and reuses freeze evidence for frozen identities; [agent-loop](../packages/core/agent-loop/README.md) owns construction and cancellation causes.
+Failed steps [record missing tool results](../packages/core/agent-loop/README.md#understand-the-implementation).
+
Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle).
## Session log
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index bf3fb3b649..c7fdc96a90 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -118,6 +118,8 @@ turn/end
循环发送不可变请求,同时保持取消有效,仅对完全冻结的对象复用冻结证据;[agent-loop](../packages/core/agent-loop/README.zh.md) 负责请求构建与取消原因。
+失败步骤会[记录缺失的工具结果](../packages/core/agent-loop/README.zh.md#understand-the-implementation)。
+
详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。
## 会话日志
diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml
index a20cbf9cde..abf47a1663 100644
--- a/docs/capability-seams.i18n.yaml
+++ b/docs/capability-seams.i18n.yaml
@@ -6,5 +6,5 @@
en: 41d772192075e133
zh: 5ef3da69b4de1d8a
/capability-seams-and-core-services:
- en: 0d14d210d41c6770
- zh: 964e92962b7cfa88
+ en: 00ee1236e43f5538
+ zh: aed1f351bf657eea
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 21b4a8205b..4d6fd35fa2 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -112,11 +112,15 @@ flowchart LR
pkg_api_account_controller["api-account-controller"]
pkg_authorization["authorization"]
svc_authorization["ctx.authorization Authorization flow registry"]
+ pkg_client_product_analytics["client-product-analytics"]
+ svc_productAnalytics["ctx.productAnalytics Desktop interaction collection"]
+ pkg_otel["otel"]
+ svc_otel["ctx.otel Shared OTel reporting channels"]
pkg_host_product_telemetry_otel["host-product-telemetry-otel"]
+ pkg_session_telemetry_otel["session-telemetry-otel"]
svc_productTelemetry["ctx.productTelemetry Product usage event sender"]
pkg_session_telemetry["session-telemetry"]
svc_sessionTelemetry["ctx.sessionTelemetry Session telemetry seam"]
- pkg_session_telemetry_otel["session-telemetry-otel"]
pkg_storage["storage"]
svc_storage["ctx.storage Non-session storage hub"]
pkg_storage_json["storage-json"]
@@ -296,6 +300,7 @@ flowchart LR
pkg_client_connection --> svc_connection
pkg_client_file_upload --> svc_fileUploads
pkg_client_modules --> svc_clientModules
+ pkg_client_product_analytics --> svc_productAnalytics
pkg_client_ui_plugin_manager --> svc_pluginRegistryProbe
pkg_command_feedback --> svc_sessionFeedback
pkg_commands --> svc_commands
@@ -348,6 +353,7 @@ flowchart LR
pkg_mcp_resources --> svc_mcpResources
pkg_message_feedback --> svc_messageFeedback
pkg_office_to_pdf --> svc_officeToPdf
+ pkg_otel --> svc_otel
pkg_permission_presets --> svc_permissionPresets
pkg_plan_mode --> svc_planMode
pkg_plugin_manager --> svc_pluginManager
@@ -472,6 +478,8 @@ flowchart LR
svc_lsp --> pkg_tool_lsp
svc_mcpResources --> pkg_mcp_resources
svc_officeToPdf --> pkg_client_ui_sidebar_documentpreview
+ svc_otel --> pkg_host_product_telemetry_otel
+ svc_otel --> pkg_session_telemetry_otel
svc_pluginManager --> pkg_plugin_manager
svc_pluginManager --> pkg_ui_settings_plugin_inventory
svc_pluginRegistryProbe --> pkg_client_ui_plugin_manager
@@ -605,6 +613,8 @@ flowchart LR
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage. |
| `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The Host owns browser authorization and local credentials; UI consumers receive state without tokens. |
| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol. |
+| `ctx.productAnalytics` | `service` | [`client-product-analytics`](../packages/client/product-analytics) | - | - | - | Accepts selected Desktop events, enriches available login identity, and observes live compaction under the live Host collection policy. |
+| `ctx.otel` | `service` | [`otel`](../packages/telemetry/otel) | - | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel), [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | Product analytics and Session feedback adapters create independent reporting channels through one injected service. |
| `ctx.productTelemetry` | `service` | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | - | - | - | Exports explicitly submitted analytics events through OTLP/HTTP; mounting alone collects nothing. |
| `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. |
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md
index 996fc1b863..2068e7f369 100644
--- a/docs/capability-seams.zh.md
+++ b/docs/capability-seams.zh.md
@@ -114,11 +114,15 @@ flowchart LR
pkg_api_account_controller["api-account-controller"]
pkg_authorization["authorization"]
svc_authorization["ctx.authorization Authorization flow registry"]
+ pkg_client_product_analytics["client-product-analytics"]
+ svc_productAnalytics["ctx.productAnalytics Desktop interaction collection"]
+ pkg_otel["otel"]
+ svc_otel["ctx.otel Shared OTel reporting channels"]
pkg_host_product_telemetry_otel["host-product-telemetry-otel"]
+ pkg_session_telemetry_otel["session-telemetry-otel"]
svc_productTelemetry["ctx.productTelemetry Product usage event sender"]
pkg_session_telemetry["session-telemetry"]
svc_sessionTelemetry["ctx.sessionTelemetry Session telemetry seam"]
- pkg_session_telemetry_otel["session-telemetry-otel"]
pkg_storage["storage"]
svc_storage["ctx.storage Non-session storage hub"]
pkg_storage_json["storage-json"]
@@ -298,6 +302,7 @@ flowchart LR
pkg_client_connection --> svc_connection
pkg_client_file_upload --> svc_fileUploads
pkg_client_modules --> svc_clientModules
+ pkg_client_product_analytics --> svc_productAnalytics
pkg_client_ui_plugin_manager --> svc_pluginRegistryProbe
pkg_command_feedback --> svc_sessionFeedback
pkg_commands --> svc_commands
@@ -350,6 +355,7 @@ flowchart LR
pkg_mcp_resources --> svc_mcpResources
pkg_message_feedback --> svc_messageFeedback
pkg_office_to_pdf --> svc_officeToPdf
+ pkg_otel --> svc_otel
pkg_permission_presets --> svc_permissionPresets
pkg_plan_mode --> svc_planMode
pkg_plugin_manager --> svc_pluginManager
@@ -474,6 +480,8 @@ flowchart LR
svc_lsp --> pkg_tool_lsp
svc_mcpResources --> pkg_mcp_resources
svc_officeToPdf --> pkg_client_ui_sidebar_documentpreview
+ svc_otel --> pkg_host_product_telemetry_otel
+ svc_otel --> pkg_session_telemetry_otel
svc_pluginManager --> pkg_plugin_manager
svc_pluginManager --> pkg_ui_settings_plugin_inventory
svc_pluginRegistryProbe --> pkg_client_ui_plugin_manager
@@ -607,6 +615,8 @@ flowchart LR
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;settings controller 提供不含实际值的视图和只写存储。 |
| `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Host 负责浏览器授权和本地凭证;UI 使用方只接收不含 token 的状态。 |
| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | flow 由知道如何取得某份凭据的插件注册,并以其写入的记录为键;seam 拥有这段对话与"每个键同时只跑一次尝试"的生命周期,而非协议本身。 |
+| `ctx.productAnalytics` | `service` | [`client-product-analytics`](../packages/client/product-analytics) | - | - | - | 根据 Host 动态采集策略接收指定桌面事件、补充可用登录身份并监听实时压缩。 |
+| `ctx.otel` | `service` | [`otel`](../packages/telemetry/otel) | - | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel), [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | 产品埋点和 Session 反馈适配器通过同一个注入服务创建独立上报通道。 |
| `ctx.productTelemetry` | `service` | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | - | - | - | 通过 OTLP/HTTP 发送显式提交的分析事件;仅挂载插件不会采集信息。 |
| `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 |
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 7978a4afaf..d9a30858fd 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -493,6 +493,26 @@ export interface Config {
```
+
+
+
+## `@deepseek-ai/dsh-client-product-analytics`
+
+- `inject`: `deepseekAccount` · `productTelemetry`
+- `refs`: `Volatile` (`@deepseek-ai/cordis`)
+- `source`: [`packages/client/product-analytics/src/index.ts:14`](../packages/client/product-analytics/src/index.ts)
+
+```ts config-catalog
+/** Application-owned collection policy; no user settings surface. */
+export interface Config {
+ /** Live application collection policy; ordinary Web does not mount this service. */
+ enabled: Volatile
+ /** Running Desktop release, absent when unavailable. */
+ appVersion?: string
+}
+```
+
+
@@ -1435,7 +1455,8 @@ export interface Config {
## `@deepseek-ai/dsh-host-product-telemetry-otel`
-- `source`: [`packages/host/product-telemetry-otel/src/index.ts:37`](../packages/host/product-telemetry-otel/src/index.ts)
+- `inject`: `otel`
+- `source`: [`packages/host/product-telemetry-otel/src/index.ts:18`](../packages/host/product-telemetry-otel/src/index.ts)
```ts config-catalog
/** Collector routing, application identity, and bounded in-memory batch settings. */
@@ -2701,36 +2722,36 @@ export interface Config {
## `@deepseek-ai/dsh-session-telemetry-otel`
-- `inject`: `sessions`
+- `inject`: `sessions` · `otel`
- `refs`: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`)
-- `source`: [`packages/session/session-telemetry-otel/src/index.ts:100`](../packages/session/session-telemetry-otel/src/index.ts)
+- `source`: [`packages/session/session-telemetry-otel/src/index.ts:87`](../packages/session/session-telemetry-otel/src/index.ts)
```ts config-catalog
/**
- * Plugin configuration: one sharing policy, two verbatim SDK option objects,
- * and one DSH-owned shutdown bound. Uploading modes validate their endpoint
+ * Plugin configuration: sharing policy, SDK transport options, byte/count queue
+ * settings, and an overall shutdown bound. Uploading modes validate their endpoint
* and shutdown deadline at plugin load; `DISABLED` reads neither.
*/
export interface Config {
/** Defaults to `FEEDBACK_ONLY`: capture session history only when feedback is explicitly submitted. */
mode?: SessionTelemetryMode
/**
- * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
- * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
- * `compression`, `keepAlive`, …), owned and documented by the SDK. `url`
- * is the one field this package requires and validates itself.
+ * Explicit SDK HTTP transport settings, including optional routing headers.
+ * Ambient credentials are not inherited. URL is required while uploading.
*/
exporter?: OTLPExporterNodeConfigBase & {
/** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */
url?: string
}
/**
- * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
- * which this plugin fills); the SDK owns and documents these knobs.
+ * Count, queue, cadence, and per-request watchdog settings for the byte-bounded
+ * processor. A watchdog warning never releases an unsettled transport slot.
*/
processor?: Omit
/** Maximum time spent awaiting the SDK provider's complete shutdown path. */
shutdownTimeoutMillis?: number
+ /** Uncompressed OTLP request byte limit, at most 4,000,000. */
+ maxRequestBytes?: number
}
/** Session-sharing policy selected by {@link Config.mode}. */
@@ -3363,6 +3384,15 @@ export interface Config {
* regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`.
*/
handoffGraceMs?: number
+ /**
+ * Extra wait beyond `idleSilenceMs` and `handoffGraceMs`, once a prompt marker was seen but
+ * its printable tail has not arrived, before `inferred_idle` settles. The marker is written
+ * by the shell's own prompt function and the tail by the same render, so a missing tail is a
+ * delivery delay on a contended host rather than an absent prompt. Zero keeps the bound at
+ * `idleSilenceMs + handoffGraceMs`; any other value covers at least one `pollIntervalMs`, so a
+ * nonzero tolerance always contains a readiness poll.
+ */
+ promptTailGraceMs?: number
/** Absolute bound for one send and the complete pwsh startup sequence. */
timeoutMs?: number
/** Grace before teardown escalates to `SIGKILL`. */
@@ -4121,7 +4151,7 @@ export interface Config {
- `inject`: `web`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
-- `source`: [`packages/web/web-search-deepseek/src/index.ts:46`](../packages/web/web-search-deepseek/src/index.ts)
+- `source`: [`packages/web/web-search-deepseek/src/index.ts:49`](../packages/web/web-search-deepseek/src/index.ts)
```ts config-catalog
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
@@ -4354,6 +4384,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) |
| `@deepseek-ai/dsh-lsp` | — | [`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts) |
| `@deepseek-ai/dsh-mcp-resources` | `tools` | [`packages/mcp/mcp-resources/src/index.ts`](../packages/mcp/mcp-resources/src/index.ts) |
+| `@deepseek-ai/dsh-otel` | — | [`packages/telemetry/otel/src/index.ts`](../packages/telemetry/otel/src/index.ts) |
| `@deepseek-ai/dsh-sandbox-ssh` | `ssh` | [`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts) |
| `@deepseek-ai/dsh-session` | — | [`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts) |
| `@deepseek-ai/dsh-session-checkpoint-policy` | `llm` · `sessionPersistence` · `sessions` · `tools` | [`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts) |
@@ -4424,6 +4455,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
| `@deepseek-ai/dsh-deque` | — | [`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts) |
| `@deepseek-ai/dsh-experimental-agent-team-profile` | — | [`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts) |
| `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) |
+| `@deepseek-ai/dsh-experimental-schedule-bundle` | — | [`packages/experimental/schedule-bundle/src/index.ts`](../packages/experimental/schedule-bundle/src/index.ts) |
| `@deepseek-ai/dsh-experimental-voice-input-bundle` | — | [`packages/experimental/voice-input-bundle/src/index.ts`](../packages/experimental/voice-input-bundle/src/index.ts) |
| `@deepseek-ai/dsh-experimental-webworker-packer` | — | [`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts) |
| `@deepseek-ai/dsh-experimental-webworker-runtime` | — | [`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts) |
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 12383e538d..01f6f6d65e 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -495,6 +495,26 @@ export interface Config {
```
+
+
+
+## `@deepseek-ai/dsh-client-product-analytics`
+
+- `inject`: `deepseekAccount` · `productTelemetry`
+- `refs`: `Volatile` (`@deepseek-ai/cordis`)
+- `source`: [`packages/client/product-analytics/src/index.ts:14`](../packages/client/product-analytics/src/index.ts)
+
+```ts config-catalog
+/** Application-owned collection policy; no user settings surface. */
+export interface Config {
+ /** Live application collection policy; ordinary Web does not mount this service. */
+ enabled: Volatile
+ /** Running Desktop release, absent when unavailable. */
+ appVersion?: string
+}
+```
+
+
@@ -1437,7 +1457,8 @@ export interface Config {
## `@deepseek-ai/dsh-host-product-telemetry-otel`
-- `source`: [`packages/host/product-telemetry-otel/src/index.ts:37`](../packages/host/product-telemetry-otel/src/index.ts)
+- `inject`: `otel`
+- `source`: [`packages/host/product-telemetry-otel/src/index.ts:18`](../packages/host/product-telemetry-otel/src/index.ts)
```ts config-catalog
/** Collector routing, application identity, and bounded in-memory batch settings. */
@@ -2703,36 +2724,36 @@ export interface Config {
## `@deepseek-ai/dsh-session-telemetry-otel`
-- `inject`: `sessions`
+- `inject`: `sessions` · `otel`
- `refs`: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`)
-- `source`: [`packages/session/session-telemetry-otel/src/index.ts:100`](../packages/session/session-telemetry-otel/src/index.ts)
+- `source`: [`packages/session/session-telemetry-otel/src/index.ts:87`](../packages/session/session-telemetry-otel/src/index.ts)
```ts config-catalog
/**
- * Plugin configuration: one sharing policy, two verbatim SDK option objects,
- * and one DSH-owned shutdown bound. Uploading modes validate their endpoint
+ * Plugin configuration: sharing policy, SDK transport options, byte/count queue
+ * settings, and an overall shutdown bound. Uploading modes validate their endpoint
* and shutdown deadline at plugin load; `DISABLED` reads neither.
*/
export interface Config {
/** Defaults to `FEEDBACK_ONLY`: capture session history only when feedback is explicitly submitted. */
mode?: SessionTelemetryMode
/**
- * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
- * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
- * `compression`, `keepAlive`, …), owned and documented by the SDK. `url`
- * is the one field this package requires and validates itself.
+ * Explicit SDK HTTP transport settings, including optional routing headers.
+ * Ambient credentials are not inherited. URL is required while uploading.
*/
exporter?: OTLPExporterNodeConfigBase & {
/** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */
url?: string
}
/**
- * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
- * which this plugin fills); the SDK owns and documents these knobs.
+ * Count, queue, cadence, and per-request watchdog settings for the byte-bounded
+ * processor. A watchdog warning never releases an unsettled transport slot.
*/
processor?: Omit
/** Maximum time spent awaiting the SDK provider's complete shutdown path. */
shutdownTimeoutMillis?: number
+ /** Uncompressed OTLP request byte limit, at most 4,000,000. */
+ maxRequestBytes?: number
}
/** Session-sharing policy selected by {@link Config.mode}. */
@@ -3365,6 +3386,15 @@ export interface Config {
* regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`.
*/
handoffGraceMs?: number
+ /**
+ * Extra wait beyond `idleSilenceMs` and `handoffGraceMs`, once a prompt marker was seen but
+ * its printable tail has not arrived, before `inferred_idle` settles. The marker is written
+ * by the shell's own prompt function and the tail by the same render, so a missing tail is a
+ * delivery delay on a contended host rather than an absent prompt. Zero keeps the bound at
+ * `idleSilenceMs + handoffGraceMs`; any other value covers at least one `pollIntervalMs`, so a
+ * nonzero tolerance always contains a readiness poll.
+ */
+ promptTailGraceMs?: number
/** Absolute bound for one send and the complete pwsh startup sequence. */
timeoutMs?: number
/** Grace before teardown escalates to `SIGKILL`. */
@@ -4123,7 +4153,7 @@ export interface Config {
- `inject`: `web`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
-- `source`: [`packages/web/web-search-deepseek/src/index.ts:46`](../packages/web/web-search-deepseek/src/index.ts)
+- `source`: [`packages/web/web-search-deepseek/src/index.ts:49`](../packages/web/web-search-deepseek/src/index.ts)
```ts config-catalog
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
@@ -4356,6 +4386,7 @@ export interface Config {
| `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) |
| `@deepseek-ai/dsh-lsp` | — | [`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts) |
| `@deepseek-ai/dsh-mcp-resources` | `tools` | [`packages/mcp/mcp-resources/src/index.ts`](../packages/mcp/mcp-resources/src/index.ts) |
+| `@deepseek-ai/dsh-otel` | — | [`packages/telemetry/otel/src/index.ts`](../packages/telemetry/otel/src/index.ts) |
| `@deepseek-ai/dsh-sandbox-ssh` | `ssh` | [`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts) |
| `@deepseek-ai/dsh-session` | — | [`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts) |
| `@deepseek-ai/dsh-session-checkpoint-policy` | `llm` · `sessionPersistence` · `sessions` · `tools` | [`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts) |
@@ -4426,6 +4457,7 @@ export interface Config {
| `@deepseek-ai/dsh-deque` | — | [`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts) |
| `@deepseek-ai/dsh-experimental-agent-team-profile` | — | [`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts) |
| `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) |
+| `@deepseek-ai/dsh-experimental-schedule-bundle` | — | [`packages/experimental/schedule-bundle/src/index.ts`](../packages/experimental/schedule-bundle/src/index.ts) |
| `@deepseek-ai/dsh-experimental-voice-input-bundle` | — | [`packages/experimental/voice-input-bundle/src/index.ts`](../packages/experimental/voice-input-bundle/src/index.ts) |
| `@deepseek-ai/dsh-experimental-webworker-packer` | — | [`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts) |
| `@deepseek-ai/dsh-experimental-webworker-runtime` | — | [`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts) |
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 5312354d60..4385771ec8 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -60,10 +60,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:471`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
-| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
+| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:86`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
@@ -102,7 +102,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `internal/service` | - | [`agent-preset-registry`](../packages/preset/agent-preset-registry), `gateway` |
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) |
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
-| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `speech-to-text` |
+| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `product-analytics`, `speech-to-text` |
| `slots/changed` | `ui-renderer` (`emit`) | - |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index 6baa07d2d2..b3edadf12e 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -62,10 +62,10 @@
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:471`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
-| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
+| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:86`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
@@ -104,7 +104,7 @@
| `internal/service` | - | [`agent-preset-registry`](../packages/preset/agent-preset-registry), `gateway` |
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) |
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
-| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `speech-to-text` |
+| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `product-analytics`, `speech-to-text` |
| `slots/changed` | `ui-renderer` (`emit`) | - |
diff --git a/docs/module-graph.md b/docs/module-graph.md
index b005413ba3..0da940458a 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -154,6 +154,7 @@ flowchart TD
pkg_client_hmr["client-hmr"]
pkg_client_locale["client-locale"]
pkg_client_modules["client-modules"]
+ pkg_client_product_analytics["client-product-analytics"]
pkg_client_resources["client-resources"]
pkg_client_shortcuts["client-shortcuts"]
pkg_client_store["client-store"]
@@ -258,6 +259,7 @@ flowchart TD
pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
pkg_experimental_inspector["experimental-inspector"]
pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"]
+ pkg_experimental_schedule_bundle["experimental-schedule-bundle"]
pkg_experimental_speech_to_text["experimental-speech-to-text"]
pkg_experimental_speech_to_text_sensevoice["experimental-speech-to-text-sensevoice"]
pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
@@ -394,6 +396,9 @@ flowchart TD
pkg_subprocess_local["subprocess-local"]
pkg_win32_process["win32-process"]
end
+ subgraph group_telemetry["packages/telemetry"]
+ pkg_otel["otel"]
+ end
subgraph group_terminal["packages/terminal"]
pkg_terminal["terminal"]
pkg_terminal_bash["terminal-bash"]
@@ -509,6 +514,7 @@ flowchart TD
pkg_settings --> pkg_brand
pkg_settings --> pkg_invariants
pkg_settings --> pkg_session
+ pkg_otel --> pkg_session
pkg_session_snapshot --> pkg_http_proxy
pkg_session_snapshot --> pkg_session
pkg_fs --> pkg_brand
@@ -521,6 +527,7 @@ flowchart TD
pkg_hmr --> pkg_app_boot
pkg_hmr --> pkg_cmdline
pkg_experimental_speech_to_text --> pkg_settings
+ pkg_host_product_telemetry_otel --> pkg_otel
pkg_ptc_runtime --> pkg_sandbox
pkg_sandbox_local --> pkg_llm
pkg_sandbox_local --> pkg_sandbox
@@ -626,11 +633,6 @@ flowchart TD
pkg_goal --> pkg_session
pkg_goal --> pkg_session_projection
pkg_goal --> pkg_typert_protocol
- pkg_web_search_deepseek --> pkg_agent
- pkg_web_search_deepseek --> pkg_credentials
- pkg_web_search_deepseek --> pkg_launch_environment
- pkg_web_search_deepseek --> pkg_session
- pkg_web_search_deepseek --> pkg_web
pkg_file_reference --> pkg_agent
pkg_time_context --> pkg_agent
pkg_time_context --> pkg_invariants
@@ -718,6 +720,12 @@ flowchart TD
pkg_fs_sandbox --> pkg_fs_local
pkg_fs_sandbox --> pkg_sandbox
pkg_fs_sandbox --> pkg_sandbox_policy
+ pkg_web_search_deepseek --> pkg_agent
+ pkg_web_search_deepseek --> pkg_credentials
+ pkg_web_search_deepseek --> pkg_deepseek_account
+ pkg_web_search_deepseek --> pkg_launch_environment
+ pkg_web_search_deepseek --> pkg_session
+ pkg_web_search_deepseek --> pkg_web
pkg_api_account_controller --> pkg_agent
pkg_api_account_controller --> pkg_deepseek_account
pkg_api_account_controller --> pkg_typert_protocol
@@ -1036,6 +1044,7 @@ flowchart TD
pkg_session_telemetry_otel --> pkg_command_feedback
pkg_session_telemetry_otel --> pkg_llm
pkg_session_telemetry_otel --> pkg_message_feedback
+ pkg_session_telemetry_otel --> pkg_otel
pkg_session_telemetry_otel --> pkg_session
pkg_session_telemetry_otel --> pkg_session_telemetry
pkg_tool_bash --> pkg_agent
@@ -1394,6 +1403,7 @@ flowchart TD
| [`client-hmr`](../packages/client/hmr) | `client` | — |
| [`client-locale`](../packages/client/locale) | `client` | — |
| [`client-modules`](../packages/client/modules) | `client` | — |
+| [`client-product-analytics`](../packages/client/product-analytics) | `client` | — |
| [`client-resources`](../packages/client/resources) | `client` | — |
| [`client-shortcuts`](../packages/client/shortcuts) | `client` | — |
| [`client-store`](../packages/client/store) | `client` | — |
@@ -1452,6 +1462,7 @@ flowchart TD
| [`client-web`](../packages/client/web) | `client` | — |
| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — |
| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — |
+| [`experimental-schedule-bundle`](../packages/experimental/schedule-bundle) | `experimental` | — |
| [`experimental-voice-input-bundle`](../packages/experimental/voice-input-bundle) | `experimental` | — |
| [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — |
| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | — |
@@ -1460,7 +1471,6 @@ flowchart TD
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | — |
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — |
| [`host-open-in-app`](../packages/host/open-in-app) | `host` | — |
-| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | — |
| [`host-webserver`](../packages/host/webserver) | `host` | — |
| [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — |
| [`session-format`](../packages/session/session-format) | `session` | — |
@@ -1520,12 +1530,14 @@ flowchart TD
| [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) |
| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
+| [`otel`](../packages/telemetry/otel) | `telemetry` | [`session`](../packages/core/session) |
| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
| [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) |
+| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) |
| [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | `ptc-runtime` | [`sandbox`](../packages/sandbox/sandbox) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
@@ -1553,7 +1565,6 @@ flowchart TD
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
-| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) |
@@ -1573,6 +1584,7 @@ flowchart TD
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) |
| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
+| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
| [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) |
| [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) |
@@ -1635,7 +1647,7 @@ flowchart TD
| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`agent-preset`](../packages/preset/agent-preset) | `preset` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) |
-| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
+| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`otel`](../packages/telemetry/otel), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`fs-ssh`](../packages/ssh/fs-ssh) | `ssh` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`ssh`](../packages/ssh/ssh) |
diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md
index fe41849447..e023865c6b 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -156,6 +156,7 @@ flowchart TD
pkg_client_hmr["client-hmr"]
pkg_client_locale["client-locale"]
pkg_client_modules["client-modules"]
+ pkg_client_product_analytics["client-product-analytics"]
pkg_client_resources["client-resources"]
pkg_client_shortcuts["client-shortcuts"]
pkg_client_store["client-store"]
@@ -260,6 +261,7 @@ flowchart TD
pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
pkg_experimental_inspector["experimental-inspector"]
pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"]
+ pkg_experimental_schedule_bundle["experimental-schedule-bundle"]
pkg_experimental_speech_to_text["experimental-speech-to-text"]
pkg_experimental_speech_to_text_sensevoice["experimental-speech-to-text-sensevoice"]
pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
@@ -396,6 +398,9 @@ flowchart TD
pkg_subprocess_local["subprocess-local"]
pkg_win32_process["win32-process"]
end
+ subgraph group_telemetry["packages/telemetry"]
+ pkg_otel["otel"]
+ end
subgraph group_terminal["packages/terminal"]
pkg_terminal["terminal"]
pkg_terminal_bash["terminal-bash"]
@@ -511,6 +516,7 @@ flowchart TD
pkg_settings --> pkg_brand
pkg_settings --> pkg_invariants
pkg_settings --> pkg_session
+ pkg_otel --> pkg_session
pkg_session_snapshot --> pkg_http_proxy
pkg_session_snapshot --> pkg_session
pkg_fs --> pkg_brand
@@ -523,6 +529,7 @@ flowchart TD
pkg_hmr --> pkg_app_boot
pkg_hmr --> pkg_cmdline
pkg_experimental_speech_to_text --> pkg_settings
+ pkg_host_product_telemetry_otel --> pkg_otel
pkg_ptc_runtime --> pkg_sandbox
pkg_sandbox_local --> pkg_llm
pkg_sandbox_local --> pkg_sandbox
@@ -628,11 +635,6 @@ flowchart TD
pkg_goal --> pkg_session
pkg_goal --> pkg_session_projection
pkg_goal --> pkg_typert_protocol
- pkg_web_search_deepseek --> pkg_agent
- pkg_web_search_deepseek --> pkg_credentials
- pkg_web_search_deepseek --> pkg_launch_environment
- pkg_web_search_deepseek --> pkg_session
- pkg_web_search_deepseek --> pkg_web
pkg_file_reference --> pkg_agent
pkg_time_context --> pkg_agent
pkg_time_context --> pkg_invariants
@@ -720,6 +722,12 @@ flowchart TD
pkg_fs_sandbox --> pkg_fs_local
pkg_fs_sandbox --> pkg_sandbox
pkg_fs_sandbox --> pkg_sandbox_policy
+ pkg_web_search_deepseek --> pkg_agent
+ pkg_web_search_deepseek --> pkg_credentials
+ pkg_web_search_deepseek --> pkg_deepseek_account
+ pkg_web_search_deepseek --> pkg_launch_environment
+ pkg_web_search_deepseek --> pkg_session
+ pkg_web_search_deepseek --> pkg_web
pkg_api_account_controller --> pkg_agent
pkg_api_account_controller --> pkg_deepseek_account
pkg_api_account_controller --> pkg_typert_protocol
@@ -1038,6 +1046,7 @@ flowchart TD
pkg_session_telemetry_otel --> pkg_command_feedback
pkg_session_telemetry_otel --> pkg_llm
pkg_session_telemetry_otel --> pkg_message_feedback
+ pkg_session_telemetry_otel --> pkg_otel
pkg_session_telemetry_otel --> pkg_session
pkg_session_telemetry_otel --> pkg_session_telemetry
pkg_tool_bash --> pkg_agent
@@ -1396,6 +1405,7 @@ flowchart TD
| [`client-hmr`](../packages/client/hmr) | `client` | — |
| [`client-locale`](../packages/client/locale) | `client` | — |
| [`client-modules`](../packages/client/modules) | `client` | — |
+| [`client-product-analytics`](../packages/client/product-analytics) | `client` | — |
| [`client-resources`](../packages/client/resources) | `client` | — |
| [`client-shortcuts`](../packages/client/shortcuts) | `client` | — |
| [`client-store`](../packages/client/store) | `client` | — |
@@ -1454,6 +1464,7 @@ flowchart TD
| [`client-web`](../packages/client/web) | `client` | — |
| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — |
| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — |
+| [`experimental-schedule-bundle`](../packages/experimental/schedule-bundle) | `experimental` | — |
| [`experimental-voice-input-bundle`](../packages/experimental/voice-input-bundle) | `experimental` | — |
| [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — |
| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | — |
@@ -1462,7 +1473,6 @@ flowchart TD
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | — |
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — |
| [`host-open-in-app`](../packages/host/open-in-app) | `host` | — |
-| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | — |
| [`host-webserver`](../packages/host/webserver) | `host` | — |
| [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — |
| [`session-format`](../packages/session/session-format) | `session` | — |
@@ -1522,12 +1532,14 @@ flowchart TD
| [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) |
| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
+| [`otel`](../packages/telemetry/otel) | `telemetry` | [`session`](../packages/core/session) |
| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
| [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) |
+| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) |
| [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | `ptc-runtime` | [`sandbox`](../packages/sandbox/sandbox) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
@@ -1555,7 +1567,6 @@ flowchart TD
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
-| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) |
@@ -1575,6 +1586,7 @@ flowchart TD
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) |
| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
+| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
| [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) |
| [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) |
@@ -1637,7 +1649,7 @@ flowchart TD
| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`agent-preset`](../packages/preset/agent-preset) | `preset` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) |
-| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
+| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`otel`](../packages/telemetry/otel), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`fs-ssh`](../packages/ssh/fs-ssh) | `ssh` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`ssh`](../packages/ssh/ssh) |
diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml
index a7934d3e58..7051478b51 100644
--- a/docs/subsystems/README.i18n.yaml
+++ b/docs/subsystems/README.i18n.yaml
@@ -3,5 +3,5 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/README.md
/subsystems:
- en: 58466f6fd60755eb
- zh: ab46a374f5a3c3ed
+ en: ab6cd11804f6b644
+ zh: 7a82dc5b22c40703
diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md
index 479dbe7cb3..e2f8dc51e2 100644
--- a/docs/subsystems/README.md
+++ b/docs/subsystems/README.md
@@ -67,6 +67,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
| [conversation.md](conversation.md) | target-neutral Session-event assembly: Context identity, Location data, replay paths, view builders, and target-owned render nodes |
| [session-projection.md](session-projection.md) | the projection seam: `SessionProjectionMap`, the pure `ProjectionDefinition` unit, `ProjectionSnapshot`'s consistent cut, the change feed |
| [session-telemetry.md](session-telemetry.md) | the outbound session-reporting capability seam: `SessionTelemetryRecord`/`SessionTelemetrySeverity`, the `SessionTelemetrySink` contract, and the `session-telemetry/record` redact waterfall |
+| [otel.md](otel.md) | Shared ordinary-event and Session-log OTLP channels |
| [product-telemetry.md](product-telemetry.md) | Explicit product analytics submission and OTLP/HTTP transport |
> Type declarations and their JSDoc on these pages are source-equivalent and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Ordinary blocks preserve complete declarations; `public-api` blocks preserve body-stripped public class declarations. Cordis services and events use each page's generated **Cordis API** section.
diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md
index a6b9226df6..31f4489d43 100644
--- a/docs/subsystems/README.zh.md
+++ b/docs/subsystems/README.zh.md
@@ -67,6 +67,7 @@
| [conversation.md](conversation.zh.md) | 目标无关的会话事件组装:上下文标识、位置数据、回放路径、视图构建器与目标自有的渲染节点 |
| [session-projection.md](session-projection.zh.md) | 投影 seam:`SessionProjectionMap`、纯函数 `ProjectionDefinition` 单元、`ProjectionSnapshot` 的一致切面、变更馈送 |
| [session-telemetry.md](session-telemetry.zh.md) | 对外会话上报能力 seam:`SessionTelemetryRecord`/`SessionTelemetrySeverity`、`SessionTelemetrySink` 约定和 `session-telemetry/record` 脱敏 waterfall(瀑布式事件) |
+| [otel.md](otel.zh.md) | 共享普通事件与 Session 日志 OTLP 通道 |
| [product-telemetry.md](product-telemetry.zh.md) | 显式产品分析事件提交与 OTLP/HTTP 传输 |
> 这些页面上的类型声明及其 JSDoc 与源码等价,并由 `pnpm run verify-type-equiv` 检查漂移(见 [development.md](../development.zh.md#documenting-types-verbatim-ts-type-equiv))。普通块保留完整声明;`public-api` 块保留去除实现体的公开 class 声明。Cordis 服务与事件使用每页生成的 **Cordis API** 小节。
diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md
index decd5b088d..eb708f95f9 100644
--- a/docs/subsystems/credentials.md
+++ b/docs/subsystems/credentials.md
@@ -356,6 +356,12 @@ abstract rejectToken(token: string): Promise
* @returns a Host-only snapshot, or null while signed out or when the credential changed during the read.
*/
abstract getPlatformSession(): Promise
+
+/**
+ * Read existing login identity without creating a device or returning credentials.
+ * @returns optional device/account identifiers and the provider's OS version string.
+ */
+abstract getDeviceIdentity(): Promise<{ deviceId?: string; userId?: AccountUserId; osVersion: string }>
```
Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts)
diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md
index b649f3c1ff..9a6ce9f3fa 100644
--- a/docs/subsystems/credentials.zh.md
+++ b/docs/subsystems/credentials.zh.md
@@ -356,6 +356,12 @@ abstract rejectToken(token: string): Promise
* @returns a Host-only snapshot, or null while signed out or when the credential changed during the read.
*/
abstract getPlatformSession(): Promise
+
+/**
+ * Read existing login identity without creating a device or returning credentials.
+ * @returns optional device/account identifiers and the provider's OS version string.
+ */
+abstract getDeviceIdentity(): Promise<{ deviceId?: string; userId?: AccountUserId; osVersion: string }>
```
Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts)
diff --git a/docs/subsystems/otel.i18n.yaml b/docs/subsystems/otel.i18n.yaml
new file mode 100644
index 0000000000..7547678647
--- /dev/null
+++ b/docs/subsystems/otel.i18n.yaml
@@ -0,0 +1,7 @@
+# Bilingual-pair consistency record for otel.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write docs/subsystems/otel.md
+/otel-reporting:
+ en: adf67758d37c46f2
+ zh: 9e41b63531fa875a
diff --git a/docs/subsystems/otel.md b/docs/subsystems/otel.md
new file mode 100644
index 0000000000..bcba92f51b
--- /dev/null
+++ b/docs/subsystems/otel.md
@@ -0,0 +1,44 @@
+# OTel reporting
+
+English | [中文](otel.zh.md)
+
+The [OTel plugin](../../packages/telemetry/otel/README.md) registers `ctx.otel`, a shared factory for independent ordinary-event and Session-log channels. It owns SDK transport and batching implementations. Business consumers inject the service, supply explicit transport/resource/scope options, and own each returned channel's shutdown.
+
+UI feedback reaches the Session feedback services through their existing Remote APIs. Feedback recording authorizes the Session adapter's canonical-prefix capture and redaction before it reports through an OTel channel. Ordinary product consumers submit selected fields through the product adapter. The shared service has no automatic identity or capture policy, and channels never share queues or requests.
+
+The [product adapter](../../packages/host/product-telemetry-otel/README.md) and [Session adapter](../../packages/session/session-telemetry-otel/README.md) retain their deployment configuration and disposal deadlines. Consumers must not reuse a channel after shutdown. Mounting only the OTel service allocates no provider or transport.
+
+
+
+
+
+## Cordis API
+
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+
+
+### `ctx.otel` — `OTel`
+
+Shared transport provider. Mounting creates no queue, identity, or network connection.
+
+```ts cordis-catalog
+/**
+ * Create an independent ordinary-event channel with count-based batching.
+ * The injected consumer must drain it during its fiber disposal.
+ * @param options - transport, scope, resource, queue, and diagnostic settings selected by the consumer.
+ * @returns the caller-owned channel; no state is shared with other channels.
+ */
+createEventReporter(options: EventLogOptions): EventLogReporter
+
+/**
+ * Create an independent byte-bounded Session-log channel.
+ * Authorization and redaction precede reporting; the consumer owns shutdown and its outer deadline.
+ * @param options - transport, scope, resource, queue, and diagnostic settings selected by the consumer.
+ * @returns the caller-owned channel, preserving complete accepted events within the request byte ceiling.
+ */
+createSessionLogReporter(options: SessionLogOptions): SessionLogReporter
+```
+
+Source: [`packages/telemetry/otel/src/index.ts`](../../packages/telemetry/otel/src/index.ts)
+
diff --git a/docs/subsystems/otel.zh.md b/docs/subsystems/otel.zh.md
new file mode 100644
index 0000000000..dbe7f3cb9e
--- /dev/null
+++ b/docs/subsystems/otel.zh.md
@@ -0,0 +1,44 @@
+# OTel 上报
+
+[English](otel.md) | 中文
+
+[OTel 插件](../../packages/telemetry/otel/README.zh.md) 注册 `ctx.otel`,作为独立普通事件通道和 Session 日志通道的共享工厂。它持有 SDK 传输与聚合实现。业务调用方注入服务,显式提供传输、resource 和 scope 选项,并负责返回通道的关闭。
+
+UI 反馈通过现有 Remote API 到达 Session 反馈服务。记录反馈后,Session 适配器才获得规范日志前缀的捕获授权,完成脱敏后通过 OTel 通道上报。普通产品调用方通过产品适配器提交选定字段。共享服务没有自动身份或捕获策略,通道之间不共享队列或请求。
+
+[产品适配器](../../packages/host/product-telemetry-otel/README.zh.md) 和 [Session 适配器](../../packages/session/session-telemetry-otel/README.zh.md) 保留各自部署配置及销毁期限。通道关闭后调用方不得复用。只挂载 OTel 服务不会分配 provider 或 transport。
+
+
+
+
+
+## Cordis API
+
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+
+
+### `ctx.otel` — `OTel`
+
+Shared transport provider. Mounting creates no queue, identity, or network connection.
+
+```ts cordis-catalog
+/**
+ * Create an independent ordinary-event channel with count-based batching.
+ * The injected consumer must drain it during its fiber disposal.
+ * @param options - transport, scope, resource, queue, and diagnostic settings selected by the consumer.
+ * @returns the caller-owned channel; no state is shared with other channels.
+ */
+createEventReporter(options: EventLogOptions): EventLogReporter
+
+/**
+ * Create an independent byte-bounded Session-log channel.
+ * Authorization and redaction precede reporting; the consumer owns shutdown and its outer deadline.
+ * @param options - transport, scope, resource, queue, and diagnostic settings selected by the consumer.
+ * @returns the caller-owned channel, preserving complete accepted events within the request byte ceiling.
+ */
+createSessionLogReporter(options: SessionLogOptions): SessionLogReporter
+```
+
+Source: [`packages/telemetry/otel/src/index.ts`](../../packages/telemetry/otel/src/index.ts)
+
diff --git a/docs/subsystems/product-telemetry.i18n.yaml b/docs/subsystems/product-telemetry.i18n.yaml
index bd22924083..28b4e64b14 100644
--- a/docs/subsystems/product-telemetry.i18n.yaml
+++ b/docs/subsystems/product-telemetry.i18n.yaml
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/product-telemetry.md
/product-telemetry:
- en: e1a8950c3b4743ed
- zh: 3fff7e01a8f271e1
+ en: 04e9a32dfbe69355
+ zh: 0e6c78274dd97466
/product-telemetry/record-types:
en: bf5eadedbc896f5a
zh: 158fce1609a85d6d
diff --git a/docs/subsystems/product-telemetry.md b/docs/subsystems/product-telemetry.md
index d86540d5a2..00c70a45c8 100644
--- a/docs/subsystems/product-telemetry.md
+++ b/docs/subsystems/product-telemetry.md
@@ -2,7 +2,7 @@
English | [中文](product-telemetry.zh.md)
-The [product telemetry plugin](../../packages/host/product-telemetry-otel/README.md) sends explicitly selected analytics events through OTLP/HTTP. Its `productTelemetry` service owns submission only; product consumers own when an event occurs and which fields are approved. No Session data or identifiers are collected automatically.
+The [product telemetry plugin](../../packages/host/product-telemetry-otel/README.md) sends explicitly selected analytics events through OTLP/HTTP. Its `productTelemetry` service owns submission only; product consumers own when an event occurs and which fields are approved. The exporter collects no Session data or identifiers automatically. The [Desktop analytics consumer](../../packages/client/product-analytics/README.md) selects interaction and live compaction events, adds available login identity, and follows the Desktop launch-time collection switch. Ordinary Web clients do not collect product events.
`ProductTelemetryRecord` requires an event name, string body, and Unix-millisecond timestamp. Attributes accept scalar strings, numbers, booleans, and one-level objects of those values (`ProductTelemetryScalar`). Optional severity uses OTel severity numbers and defaults to INFO. Observation time is assigned when the record is enqueued.
@@ -11,24 +11,13 @@ Enqueue is synchronous and does not acknowledge delivery. The SDK owns batching
## Record types
```ts type-equiv
-/** Scalar values accepted by the collector's Arrow attributes map. */
-type ProductTelemetryScalar = string | number | boolean
+/** Scalar values accepted in product attributes. */
+type ProductTelemetryScalar = OTelEventScalar
```
```ts type-equiv
-/** Explicitly selected analytics fields; object values may contain scalars only. */
-interface ProductTelemetryRecord {
- /** Product/DA-owned event name. */
- eventName: string
- /** Human-readable summary; never a prompt, response, credential, or file contents. */
- body: string
- /** Event occurrence time in Unix milliseconds. Observation time is assigned on enqueue. */
- timestamp: number
- /** OTel severity; omitted values use INFO. */
- severityNumber?: SeverityNumber
- /** Business fields selected by the caller; no automatic device or account identity. */
- attributes?: Record>
-}
+/** Caller-selected ordinary analytics record. */
+type ProductTelemetryRecord = OTelEventRecord
```
Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/host/product-telemetry-otel/src/index.ts)
@@ -41,6 +30,36 @@ Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/hos
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+
+### `ctx.productAnalytics` — `ProductAnalytics`
+
+Authenticated event intake; disabled instances do not inspect identity or accept new events.
+
+```ts cordis-catalog
+/**
+ * Read the collection policy.
+ * @returns whether this Host currently accepts Desktop analytics.
+ */
+@Remote enabled(): boolean
+
+/**
+ * Stream the effective policy initially and after live configuration edits.
+ * @param signal - subscriber lifetime.
+ * @returns current policy values until cancellation or service disposal.
+ */
+@Remote({ mode: 'stream' }) async *watchPolicy(signal: AbortSignal): AsyncIterable
+
+/**
+ * Submit selected Desktop fields; missing identity is omitted and never generated.
+ * @param event - typed product event without message contents or credentials.
+ * @returns after local submission; no delivery or warehouse acknowledgement.
+ */
+@Remote async report(event: ProductEvent): Promise
+```
+
+Source: [`packages/client/product-analytics/src/index.ts`](../../packages/client/product-analytics/src/index.ts)
+
### `ctx.productTelemetry` — `ProductTelemetry`
diff --git a/docs/subsystems/product-telemetry.zh.md b/docs/subsystems/product-telemetry.zh.md
index 52ad119363..5b04bf6fdd 100644
--- a/docs/subsystems/product-telemetry.zh.md
+++ b/docs/subsystems/product-telemetry.zh.md
@@ -2,7 +2,7 @@
[English](product-telemetry.md) | 中文
-[产品埋点插件](../../packages/host/product-telemetry-otel/README.zh.md) 通过 OTLP/HTTP 发送明确选定的分析事件。`productTelemetry` 服务仅负责提交;产品消费方决定事件发生时机与获准采集的字段。不自动采集 Session 数据或标识。
+[产品埋点插件](../../packages/host/product-telemetry-otel/README.zh.md) 通过 OTLP/HTTP 发送明确选定的分析事件。`productTelemetry` 服务仅负责提交;产品消费方决定事件发生时机与获准采集的字段。导出器不自动采集 Session 数据或标识。[桌面埋点消费方](../../packages/client/product-analytics/README.zh.md)选择交互与实时压缩事件,补充可用的登录身份,并遵守桌面启动时的采集开关。普通 Web 客户端不采集产品事件。
`ProductTelemetryRecord` 要求事件名称、字符串 body 和 Unix 毫秒时间戳。属性接受字符串、数字、布尔标量,以及这些值组成的单层对象(`ProductTelemetryScalar`)。可选的严重程度使用 OTel 严重程度数字,默认为 INFO。记录入队时填写观测时间。
@@ -11,24 +11,13 @@
## 记录类型
```ts type-equiv
-/** Scalar values accepted by the collector's Arrow attributes map. */
-type ProductTelemetryScalar = string | number | boolean
+/** Scalar values accepted in product attributes. */
+type ProductTelemetryScalar = OTelEventScalar
```
```ts type-equiv
-/** Explicitly selected analytics fields; object values may contain scalars only. */
-interface ProductTelemetryRecord {
- /** Product/DA-owned event name. */
- eventName: string
- /** Human-readable summary; never a prompt, response, credential, or file contents. */
- body: string
- /** Event occurrence time in Unix milliseconds. Observation time is assigned on enqueue. */
- timestamp: number
- /** OTel severity; omitted values use INFO. */
- severityNumber?: SeverityNumber
- /** Business fields selected by the caller; no automatic device or account identity. */
- attributes?: Record>
-}
+/** Caller-selected ordinary analytics record. */
+type ProductTelemetryRecord = OTelEventRecord
```
Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/host/product-telemetry-otel/src/index.ts)
@@ -41,6 +30,36 @@ Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/hos
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+
+### `ctx.productAnalytics` — `ProductAnalytics`
+
+Authenticated event intake; disabled instances do not inspect identity or accept new events.
+
+```ts cordis-catalog
+/**
+ * Read the collection policy.
+ * @returns whether this Host currently accepts Desktop analytics.
+ */
+@Remote enabled(): boolean
+
+/**
+ * Stream the effective policy initially and after live configuration edits.
+ * @param signal - subscriber lifetime.
+ * @returns current policy values until cancellation or service disposal.
+ */
+@Remote({ mode: 'stream' }) async *watchPolicy(signal: AbortSignal): AsyncIterable
+
+/**
+ * Submit selected Desktop fields; missing identity is omitted and never generated.
+ * @param event - typed product event without message contents or credentials.
+ * @returns after local submission; no delivery or warehouse acknowledgement.
+ */
+@Remote async report(event: ProductEvent): Promise
+```
+
+Source: [`packages/client/product-analytics/src/index.ts`](../../packages/client/product-analytics/src/index.ts)
+
### `ctx.productTelemetry` — `ProductTelemetry`
diff --git a/docs/subsystems/schedule.i18n.yaml b/docs/subsystems/schedule.i18n.yaml
index d6d12d5a95..614eb31d1e 100644
--- a/docs/subsystems/schedule.i18n.yaml
+++ b/docs/subsystems/schedule.i18n.yaml
@@ -9,8 +9,8 @@
en: 41cd197b5c74bfa3
zh: 18ed11259036d726
/host-wide-schedule/absolute-time-input:
- en: e730fda52ba2854f
- zh: 01a6c682e327a133
+ en: 8186d070ab412545
+ zh: 968b752df9e03b68
/host-wide-schedule/daily-wall-clock-input:
en: 2d249edc3dfccb8a
zh: 33fecda132b03b84
@@ -27,8 +27,8 @@
en: 0656173de50ee132
zh: 54176540371d0f28
/host-wide-schedule/web-catalog:
- en: 39f24636dd788034
- zh: ba3ff541fee5854a
+ en: 9955c4857340103a
+ zh: edfa0491c8a08db7
/host-wide-schedule/timing-edits:
en: ce5d2095a3ad2e82
zh: 167b3715f8eaffc1
diff --git a/docs/subsystems/schedule.md b/docs/subsystems/schedule.md
index 0784c0b92a..4d8f4a1c0e 100644
--- a/docs/subsystems/schedule.md
+++ b/docs/subsystems/schedule.md
@@ -116,7 +116,7 @@ interface LocalAtInput {
type AtInput = string | LocalAtInput
```
-The shipped Web bundle mounts time-context, which samples the browser's IANA zone for every prompt. Time-context tells the model to interpret otherwise-unqualified natural-language dates and times in that request-local zone when the open turn has one unambiguous browser zone; mixed or missing browser-zone records tell the model to ask. That guidance is not a durable Session default: the model must still pass an offset in the string form or `time_zone` in the local form, and Schedule never reads browser, Session, process, or model context.
+The shipped Web composition carries no `time-context` row; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle`, enabled from the Plugins page, inserts and mounts time-context, which samples the browser's IANA zone for every prompt. Time-context tells the model to interpret otherwise-unqualified natural-language dates and times in that request-local zone when the open turn has one unambiguous browser zone; mixed or missing browser-zone records tell the model to ask. That guidance is not a durable Session default: the model must still pass an offset in the string form or `time_zone` in the local form, and Schedule never reads browser, Session, process, or model context.
Schedule rejects invalid offsets and zones, offset-free strings, non-future targets, and local times inside daylight-saving gaps. A daylight-saving overlap chooses its first, earlier instant. Successful creation stores only canonical UTC `scheduledAt`, so replay never depends on ambient time-zone state.
@@ -339,7 +339,7 @@ type ScheduleCatalogEntry = ScheduleRecord & {
The Remote `schedule.list({ sessionId })`, model `schedule_list`, and Session-header catalog return only active tasks. A model view's derived timing `state` remains distinct from stored lifecycle `status`. Deletion uses `schedule.delete({ sessionId, id })` with the entry's original binding; a mismatched binding returns not found. Model tools supply the current Agent's Session, whereas the global user interface supplies the selected task's binding. The binding check alone does not establish caller authorization. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again.
-The Web bundle mounts `ui-schedule` with the Host capability. The [client package](../../packages/client/ui-schedule/README.md) owns the catalog, empty state, and deletion controls. The page separately filters all, active, and inactive tasks, retains inactive details and an original-Session control in the detail tab strip, and requires explicit confirmed deletion. Rules and Delivery records separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution.
+The shipped Web composition carries no `ui-schedule` row; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle`, enabled from the Plugins page, inserts and mounts `ui-schedule` with the Host capability. The [client package](../../packages/client/ui-schedule/README.md) owns the catalog, empty state, and deletion controls. The page separately filters all, active, and inactive tasks, retains inactive details and an original-Session control in the detail tab strip, and requires explicit confirmed deletion. Rules and Delivery records separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution.
## Timing edits
diff --git a/docs/subsystems/schedule.zh.md b/docs/subsystems/schedule.zh.md
index d1a726fa97..eff76ade44 100644
--- a/docs/subsystems/schedule.zh.md
+++ b/docs/subsystems/schedule.zh.md
@@ -116,7 +116,7 @@ interface LocalAtInput {
type AtInput = string | LocalAtInput
```
-发布的 Web bundle 会挂载 time-context,它为每条提示词采样浏览器的 IANA 时区。当 open turn 只有一个无歧义的浏览器时区时,Time-context 会告诉模型按该请求本地时区解释未明确限定时区的自然语言日期和时间;浏览器时区记录混合或缺失时,则告诉模型询问用户。该指引不是持久 Session 默认值:模型仍必须在字符串形式中传入偏移量,或在本地形式中传入 `time_zone`;Schedule 绝不会读取浏览器、Session、进程或模型上下文。
+随发行版交付的 Web 组合不含 `time-context` 行;在插件管理页启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 会插入并挂载 time-context,它为每条提示词采样浏览器的 IANA 时区。当 open turn 只有一个无歧义的浏览器时区时,Time-context 会告诉模型按该请求本地时区解释未明确限定时区的自然语言日期和时间;浏览器时区记录混合或缺失时,则告诉模型询问用户。该指引不是持久 Session 默认值:模型仍必须在字符串形式中传入偏移量,或在本地形式中传入 `time_zone`;Schedule 绝不会读取浏览器、Session、进程或模型上下文。
Schedule 会拒绝无效偏移量与时区、不带偏移量的字符串、非未来目标,以及落在夏令时缺口内的本地时间。遇到夏令时重叠时,会选择第一次出现的较早时点。创建成功后只存储规范化后的 UTC `scheduledAt`,因此回放绝不依赖环境时区状态。
@@ -339,7 +339,7 @@ type ScheduleCatalogEntry = ScheduleRecord & {
Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。
-Web bundle 将 `ui-schedule` 与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“发送记录”将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。
+随发行版交付的 Web 组合不含 `ui-schedule` 行;在插件管理页启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 会插入它并与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“发送记录”将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。
## 修改时间
diff --git a/docs/subsystems/session-telemetry.i18n.yaml b/docs/subsystems/session-telemetry.i18n.yaml
index b2c2f42fb9..553e00308c 100644
--- a/docs/subsystems/session-telemetry.i18n.yaml
+++ b/docs/subsystems/session-telemetry.i18n.yaml
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session-telemetry.md
/sessiontelemetrybackend:
- en: d400c8668cd76829
- zh: 67689676c19daa7f
+ en: b82e4db22a4d58aa
+ zh: 439181fa2c27542d
/sessiontelemetrybackend/the-logical-record:
en: 9695489076496a76
zh: 457c54698bc38c2f
diff --git a/docs/subsystems/session-telemetry.md b/docs/subsystems/session-telemetry.md
index 18fe935e5e..4b99fc8ff9 100644
--- a/docs/subsystems/session-telemetry.md
+++ b/docs/subsystems/session-telemetry.md
@@ -2,7 +2,7 @@
English | [中文](session-telemetry.zh.md)
-Outbound session reporting is split as a [capability seam](../capability-seams.md): the Service Definition and capture coordinator ([dsh-session-telemetry](../../packages/session/session-telemetry), `ctx.sessionTelemetry`) own complete canonical-event capture, the `session-telemetry/record` redaction waterfall, the handoff cursor, and the minimal backend contract; the Service Provider a deployment loads ([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)) is the OpenTelemetry JS SDK's log pipeline configured verbatim. It is one optional capability, not part of the agent-loop spine, and nothing here reaches a model request. The boundary axiom — the harness's aspect ends at `emit()`; batching, retry, queueing, and loss policy belong to the reporting SDK — and the rejected alternatives are pinned in the [revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md); the capture and cursor contracts live in the [Service Definition README](../../packages/session/session-telemetry/README.md).
+The [capture service](../../packages/session/session-telemetry/README.md) owns canonical-event capture, redaction, and handoff cursors. The [feedback backend](../../packages/session/session-telemetry-otel/README.md) authorizes Session prefixes and owns their independent queue, per-request scheduling, and 4,000,000-byte ceiling. No reporting data reaches model requests.
Source: [`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts)
@@ -29,6 +29,8 @@ type SessionTelemetrySeverity = 'info' | 'warn' | 'error'
* identity so they can never be mistaken for ledger rows.
*/
interface SessionTelemetryRecord {
+ /** Canonical envelope without data; body carries the separately redacted payload. Absent for operational records. */
+ sourceEvent?: { sessionId: SessionId; envelope: Omit }
/** Ledger (session-log mirror) or ops (operational signal) channel; backends keep the two under separate instrumentation scopes. */
channel: 'ledger' | 'ops'
/** Unix epoch milliseconds — the source event's append time for ledger records, the emission time for ops records. */
diff --git a/docs/subsystems/session-telemetry.zh.md b/docs/subsystems/session-telemetry.zh.md
index 1b3807d2e3..00480938d9 100644
--- a/docs/subsystems/session-telemetry.zh.md
+++ b/docs/subsystems/session-telemetry.zh.md
@@ -2,7 +2,7 @@
[English](session-telemetry.md) | 中文
-对外的会话上报拆分为一项[能力 seam](../capability-seams.zh.md):Service Definition 与捕获协调器([dsh-session-telemetry](../../packages/session/session-telemetry),`ctx.sessionTelemetry`)拥有完整的权威事件捕获、`session-telemetry/record` 脱敏 waterfall(瀑布式事件)、handoff 游标与最小后端约定;部署方加载的 Service Provider([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel))则是原样配置的 OpenTelemetry JS SDK 日志流水线。它是一项可选能力,不属于 agent loop(智能体循环)主干,这里也没有任何内容会进入模型请求。边界公理(harness 的职责止于 `emit()`;批处理、重试、排队与丢失策略都属于上报 SDK)连同被否决的替代方案,均已在[复活 Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md)中定案;捕获与游标约定见 [Service Definition README](../../packages/session/session-telemetry/README.zh.md)。
+[捕获服务](../../packages/session/session-telemetry/README.zh.md) 负责规范事件捕获、脱敏和交接游标。[反馈后端](../../packages/session/session-telemetry-otel/README.zh.md) 授权 Session 前缀,并负责独立队列、逐请求调度及 4,000,000 字节上限。上报数据不会进入模型请求。
源码:[`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts)
@@ -29,6 +29,8 @@ type SessionTelemetrySeverity = 'info' | 'warn' | 'error'
* identity so they can never be mistaken for ledger rows.
*/
interface SessionTelemetryRecord {
+ /** Canonical envelope without data; body carries the separately redacted payload. Absent for operational records. */
+ sourceEvent?: { sessionId: SessionId; envelope: Omit }
/** Ledger (session-log mirror) or ops (operational signal) channel; backends keep the two under separate instrumentation scopes. */
channel: 'ledger' | 'ops'
/** Unix epoch milliseconds — the source event's append time for ledger records, the emission time for ops records. */
diff --git a/docs/user/guide/schedule.i18n.yaml b/docs/user/guide/schedule.i18n.yaml
index a6033b0b82..1a1a1c08f9 100644
--- a/docs/user/guide/schedule.i18n.yaml
+++ b/docs/user/guide/schedule.i18n.yaml
@@ -12,8 +12,8 @@
en: b3b2a6e0ce02bdee
zh: 2ddb168de79fd19c
/schedule-reminders/create-reminders:
- en: 70ae56895847c453
- zh: 19b38057c7462123
+ en: 68763ffeedaeea3c
+ zh: 739af012a337d448
/schedule-reminders/inspect-and-delete-tasks:
en: a44fc1aabc2ab957
zh: 31f444d4c7fd5c7a
diff --git a/docs/user/guide/schedule.md b/docs/user/guide/schedule.md
index b0058b0cba..97db8089b0 100644
--- a/docs/user/guide/schedule.md
+++ b/docs/user/guide/schedule.md
@@ -17,7 +17,7 @@ Create reminders in a conversation, then inspect active and inactive tasks and e
## Create reminders
-The shipped Web profile mounts Schedule with the clock context that gives the model the current time and the browser's zone. Configure a model provider before asking it to create reminders.
+The shipped Web profile does not include Schedule. Open Plugins and enable Automation tasks in the Official group; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` then adds Schedule with the clock context that gives the model the current time and the browser's zone. Configure a model provider before asking it to create reminders.
Ask the model to create, list, edit, or delete reminders. It uses `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` (update changes one reminder in place and keeps its id and saved delivery records); the Automation tasks page's New action opens a New Session for a creation instead, with the request already written in its composer. Supported choices are a one-time delay in positive whole seconds, an absolute date and time, a fixed interval of at least one minute, a daily local time with an IANA time zone, a weekly local time with an IANA time zone and ISO weekdays from Monday 1 through Sunday 7, or a five-field cron expression with an explicit IANA time zone, stored in canonical form.
diff --git a/docs/user/guide/schedule.zh.md b/docs/user/guide/schedule.zh.md
index 60829d82ae..fcfbfc1082 100644
--- a/docs/user/guide/schedule.zh.md
+++ b/docs/user/guide/schedule.zh.md
@@ -17,7 +17,7 @@
## 创建提醒
-发布的 Web profile 会挂载 Schedule 及其时钟上下文,后者把当前时间和浏览器时区交给模型。在请求模型创建提醒前,先配置模型 provider。
+发布的 Web profile 不含 Schedule。打开插件管理页,在“官方”分组中启用“自动化任务”;可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 随即加入 Schedule 及其时钟上下文,后者把当前时间和浏览器时区交给模型。在请求模型创建提醒前,先配置模型 provider。
向模型提出创建、列出、修改或删除提醒的请求。模型使用 `schedule_create`、`schedule_list`、`schedule_update` 和 `schedule_delete`(更新原地修改一条提醒,保留其 id 与已保存的投递记录);「自动化任务」页面的「新建」操作则为创建打开一个新会话,其 composer 已写好该请求。支持正整数秒的一次性延迟、绝对日期时间、至少一分钟的固定间隔、带 IANA 时区的每日本地时间、带 IANA 时区和 ISO 星期(周一 1 至周日 7)的每周本地时间,以及带显式 IANA 时区的五字段 cron 表达式(表达式以规范化形式保存)。
diff --git a/package.json b/package.json
index 81a370d4c4..4317f7dc94 100644
--- a/package.json
+++ b/package.json
@@ -181,8 +181,8 @@
"gen-third-party-notices": "tsx scripts/gen-third-party-notices.ts",
"verify-third-party-notices": "tsx scripts/gen-third-party-notices.ts --check",
"gen-module-graph": "tsx scripts/gen-module-graph.ts",
- "gen-scoped-events": "tsx scripts/gen-scoped-events.ts",
- "verify-scoped-events": "tsx scripts/gen-scoped-events.ts --check",
+ "gen-scoped-events": "node --max-old-space-size=4096 --import tsx/esm scripts/gen-scoped-events.ts",
+ "verify-scoped-events": "node --max-old-space-size=4096 --import tsx/esm scripts/gen-scoped-events.ts --check",
"verify-module-graph": "tsx scripts/gen-module-graph.ts --check",
"constraints": "tsx scripts/check-workspace-constraints.ts",
"doc-sync": "tsx scripts/run-gates.ts doc-sync",
diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml
index d091e6d74c..f9879d6dcd 100644
--- a/packages/README.i18n.yaml
+++ b/packages/README.i18n.yaml
@@ -15,8 +15,8 @@
en: ffc55792d6e922e1
zh: 83bd759638857e2d
/packages/package-groups:
- en: 2818a70fa6bbb1b6
- zh: 800b34102e82c192
+ en: d865abbfa65d1d4f
+ zh: ea079b795b51dd55
/packages/release-expectations:
en: 1ab880da3642bde1
zh: 6e168322f026a9cf
diff --git a/packages/README.md b/packages/README.md
index 54c4cdc758..cfb5ea6c74 100644
--- a/packages/README.md
+++ b/packages/README.md
@@ -34,6 +34,7 @@ Every package lives in exactly one group; new packages join existing groups, and
| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle |
| [`schedule/`](schedule/README.md) | Host-owned scheduled follow-ups |
| [`feedback/`](feedback/README.md) | Human feedback capture and command |
+| [`telemetry/`](telemetry/README.md) | Shared Cordis OTel reporting channels |
| [`identity/`](identity/README.md) | Shared anonymous identity |
| [`llm/`](llm/README.md) | LLM capability family: abstract service + provider adapters |
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition + local process-tree provider |
diff --git a/packages/README.zh.md b/packages/README.zh.md
index 9ea4426082..2edb62d8cd 100644
--- a/packages/README.zh.md
+++ b/packages/README.zh.md
@@ -34,6 +34,7 @@ harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会
| [`goal/`](goal/README.zh.md) | 同会话 goal 的持久化与生命周期 |
| [`schedule/`](schedule/README.zh.md) | Host 拥有的定时后续操作 |
| [`feedback/`](feedback/README.zh.md) | 人类反馈的采集与命令 |
+| [`telemetry/`](telemetry/README.zh.md) | 共享 Cordis OTel 上报通道 |
| [`identity/`](identity/README.zh.md) | 共享匿名身份 |
| [`llm/`](llm/README.zh.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 |
| [`subprocess/`](subprocess/README.zh.md) | 子进程能力系列:Service Definition + 本地进程树提供方 |
diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml
index 16ee438803..93d585f2c4 100644
--- a/packages/api/remotes/README.i18n.yaml
+++ b/packages/api/remotes/README.i18n.yaml
@@ -6,8 +6,8 @@
en: 1c32778606f3027a
zh: db7994fdb50333c8
/deepseek-ai-dsh-api-remotes:
- en: b5349178e12f23fe
- zh: dfdb4de9d99fbc7b
+ en: f0e152bd9c846ec4
+ zh: dba8ab5fa1181276
/deepseek-ai-dsh-api-remotes/summary:
en: 3c54c157bb823fd0
zh: 0db6778e94ae671c
diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md
index 78a8d8fc06..20d818b659 100644
--- a/packages/api/remotes/README.md
+++ b/packages/api/remotes/README.md
@@ -7,6 +7,8 @@ kind: "package-reference"
English | [中文](README.zh.md)
+Desktop analytics follows the [product collection policy](../../client/product-analytics/README.md), including its live application setting. Web usage is excluded.
+
## Summary
Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns the forwarded-event selection and registers its application event source with API Gateway; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries.
diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md
index cd09dbb7a3..b3bda48bac 100644
--- a/packages/api/remotes/README.zh.md
+++ b/packages/api/remotes/README.zh.md
@@ -7,6 +7,8 @@ kind: "package-reference"
[English](README.md) | 中文
+桌面埋点遵循[产品采集策略](../../client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。
+
## 概述
为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口拥有转发事件名单并向 API Gateway 注册应用事件 source;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。
diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json
index 0ac418c432..09b64bebe7 100644
--- a/packages/api/remotes/package.json
+++ b/packages/api/remotes/package.json
@@ -94,6 +94,7 @@
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*",
"@deepseek-ai/dsh-office-to-pdf": "workspace:*",
"@deepseek-ai/dsh-api-account-controller": "workspace:*",
- "@deepseek-ai/dsh-deepseek-account": "workspace:*"
+ "@deepseek-ai/dsh-deepseek-account": "workspace:*",
+ "@deepseek-ai/dsh-client-product-analytics": "workspace:*"
}
}
diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts
index 7262b843a7..535d7a5243 100644
--- a/packages/api/remotes/src/client/index.ts
+++ b/packages/api/remotes/src/client/index.ts
@@ -1,6 +1,8 @@
/** Platform-neutral assembly of generated Host Remote contributions. */
import type { Context } from '@deepseek-ai/cordis'
+import productAnalyticsRemote from '@deepseek-ai/dsh-client-product-analytics/remote'
+export type {} from '@deepseek-ai/dsh-client-product-analytics/remote'
import agentPresetsRemote from '@deepseek-ai/dsh-agent-preset-registry/remote'
import commandsRemote from '@deepseek-ai/dsh-commands/remote'
import accountRemote from '@deepseek-ai/dsh-api-account-controller/remote'
@@ -175,7 +177,8 @@ export async function apply(ctx: Context): Promise<() => Promise> {
const disposers: Array<() => Promise> = []
try {
for (const contribution of [
- agentPresetsRemote, commandsRemote, settingsControllerRemote, accountRemote, goalsRemote, llmRemote, dynamicRemote, scheduleRemote,
+ productAnalyticsRemote, agentPresetsRemote, commandsRemote, settingsControllerRemote, accountRemote,
+ goalsRemote, llmRemote, dynamicRemote, scheduleRemote,
pluginInventoryRemote, pluginManagerRemote, pluginRegistryProbeRemote, messageFeedbackRemote, sessionFeedbackRemote,
fileUploadsRemote, sessionReferencesRemote,
permissionPresetsRemote, subagentsRemote, sessionRemote, jobRemote, workspaceRemote, workspaceFilesRemote, terminalRemote,
diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json
index 2e74f687da..279c07025f 100644
--- a/packages/api/remotes/tsconfig.client.json
+++ b/packages/api/remotes/tsconfig.client.json
@@ -11,6 +11,7 @@
"src/types.ts"
],
"references": [
+ { "path": "../../client/product-analytics/tsconfig.client.json" },
{
"path": "../../../vendor/cordis"
},
diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml
index c1f577ec93..5878d2f71b 100644
--- a/packages/api/session-controller/README.i18n.yaml
+++ b/packages/api/session-controller/README.i18n.yaml
@@ -6,8 +6,8 @@
en: 82789442228fc62a
zh: f1d511ad838187de
/session-controller:
- en: fb78499ee63f41b9
- zh: 6fff916b1fa26314
+ en: c7a4c58ea6d3437b
+ zh: 45c2b472a074bb1b
/session-controller/summary:
en: 4914824c347fdab5
zh: 684a7b7fa970c39f
diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md
index d1ece35b6b..be3f9a0bf5 100644
--- a/packages/api/session-controller/README.md
+++ b/packages/api/session-controller/README.md
@@ -6,6 +6,8 @@ kind: "package-reference"
English | [中文](README.zh.md)
+Desktop analytics follows the [product collection policy](../../client/product-analytics/README.md), including its launch-time switch. Web usage is excluded.
+
## Summary
`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, human background-job kill, workspace-path opening, user-invocable skill discovery, and Agent-scoped file references. Use it through API Gateway when a Client needs operations addressed by a Session.
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md
index ae4c924b1f..de40067e39 100644
--- a/packages/api/session-controller/README.zh.md
+++ b/packages/api/session-controller/README.zh.md
@@ -6,6 +6,8 @@ kind: "package-reference"
[English](README.md) | 中文
+桌面埋点遵循[产品采集策略](../../client/product-analytics/README.zh.md)及其启动时开关,不包含 Web 使用情况。
+
## 概述
`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、人工后台任务终止、工作区路径打开、用户可调用 skill(技能)发现和 Agent(智能体)范围的文件引用。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。
diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts
index 8f016ab711..076188c1e0 100644
--- a/packages/api/session-controller/src/client/contract/sessions.ts
+++ b/packages/api/session-controller/src/client/contract/sessions.ts
@@ -125,11 +125,16 @@ export interface ISessions {
* seq (a real event seq the caller already knows; a cut inside an open
* turn is balanced Host-side with synthetic closers, and omission selects
* the latest completed-turn prefix), and whether to increment an
- * inherited durable title before resolving.
+ * inherited durable title before resolving. `onCreated` observes the catalogued child before that optional rename.
* @returns the child session id.
* @throws when the fork fails, or when a requested child-title rename fails after creation.
*/
- fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise
+ fork(opts: {
+ sessionId: SessionId
+ atSeq?: number
+ increaseTitle?: boolean
+ onCreated?: (childId: SessionId) => void
+ }): Promise
/**
* Borrow an already-retained Agent-scoped Context without extending its lifetime.
* @param id - session id.
diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts
index 48d8f6a533..8166b6f32d 100644
--- a/packages/api/session-controller/src/client/sessions/service.ts
+++ b/packages/api/session-controller/src/client/sessions/service.ts
@@ -452,6 +452,7 @@ export class ClientSessions implements ISessions {
sessionId: SessionId
atSeq?: number
increaseTitle?: boolean
+ onCreated?: (childId: SessionId) => void
}): Promise {
const sourceTitle = opts.increaseTitle
? this.list.getSnapshot().byId[opts.sessionId]?.title
@@ -463,6 +464,7 @@ export class ClientSessions implements ISessions {
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
this.projectList()
const childId = result.value.sessionId
+ opts.onCreated?.(childId)
if (sourceTitle !== undefined) {
const renamed = await this.manager.rename(childId, increasedForkTitle(sourceTitle))
if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`)
diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts
index ef3012c393..e25339bc93 100644
--- a/packages/api/session-controller/tests/sessions-service.client.spec.ts
+++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts
@@ -983,8 +983,10 @@ describe('fork', () => {
b.mock.remote.session.fork.mockResolvedValue(ok({ sessionId: sid('child') }))
b.mock.remote.session.rename.mockResolvedValue(err(new RemoteError('session/title-invalid', 'rejected', { sessionId: sid('child') })))
- await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true }))
+ const onCreated = vi.fn()
+ await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true, onCreated }))
.rejects.toThrow('fork child rename failed: session/title-invalid: rejected')
+ expect(onCreated).toHaveBeenCalledExactlyOnceWith('child')
expect(b.svc.list.getSnapshot().byId[sid('child')]).toBeDefined()
expect(b.svc.binding(sid('child'))).toBeUndefined()
expect(b.svc.retainInfo(sid('child')).getSnapshot().referenceCount).toBe(0)
diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md
index e9bae8f9be..aae8f44cc6 100644
--- a/packages/boot/app-boot/README.md
+++ b/packages/boot/app-boot/README.md
@@ -194,7 +194,6 @@ Boot itself changes no request prefix. `addHarnessSourceSection` places its sour
-
These limits describe when this boot library is a poor fit or needs special care. They are current package constraints, not a task backlog.
- **Runtime resolution depends on Node internals** — supported Node versions require the native builtin-access addon and executable compatibility coverage. Only built Harness-owned Workers receive the runtime resolution bootstrap; third-party Workers and custom `vm` linkers keep native resolution.
diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md
index ff4034d31e..9cd18a99fa 100644
--- a/packages/boot/app-boot/README.zh.md
+++ b/packages/boot/app-boot/README.zh.md
@@ -194,7 +194,6 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如
-
这些限制说明此启动库在何时不合适,或何时需要特别注意。它们是当前包约束,不是任务积压。
- **运行时解析依赖 Node 内部机制**——受支持的 Node 版本需要 native builtin access addon 和可执行兼容验证。只有构建后的 Harness 自有 Worker 接收 runtime resolution bootstrap;第三方 Worker 与自定义 `vm` linker 保持原生解析。
diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts
index da580c762e..e17e125a75 100644
--- a/packages/boot/app-boot/src/profile.ts
+++ b/packages/boot/app-boot/src/profile.ts
@@ -214,6 +214,7 @@ export const OPTIONAL_BUNDLES: readonly string[] = [
'@deepseek-ai/dsh-experimental-agent-team-profile',
'@deepseek-ai/dsh-experimental-voice-input-bundle',
'@deepseek-ai/dsh-experimental-auto-review',
+ '@deepseek-ai/dsh-experimental-schedule-bundle',
]
const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer:
diff --git a/packages/boot/plugin-manager/tests/git-environment.ts b/packages/boot/plugin-manager/tests/git-environment.ts
new file mode 100644
index 0000000000..fbb6d4ad24
--- /dev/null
+++ b/packages/boot/plugin-manager/tests/git-environment.ts
@@ -0,0 +1,28 @@
+/**
+ * Keep host Git command-line configuration out of the Git children these specs spawn.
+ *
+ * Git exports its indexed command-line configuration (`GIT_CONFIG_COUNT` with
+ * `GIT_CONFIG_KEY_n` / `GIT_CONFIG_VALUE_n`, or the older `GIT_CONFIG_PARAMETERS`)
+ * to `git -c` calls and to hook children. The shared subprocess scrub removes
+ * credential-shaped names, so a child that inherits the counter without its key
+ * fails before it reads any configuration file (`error: missing config key
+ * GIT_CONFIG_KEY_0`). These specs judge profile behavior rather than host state,
+ * so they run Git without the host's group.
+ */
+
+import { vi } from 'vitest'
+
+/** Names Git uses for its indexed command-line configuration. */
+const COMMAND_LINE_CONFIG_NAME = /^GIT_CONFIG_(?:COUNT|PARAMETERS|KEY_\d+|VALUE_\d+)$/u
+
+/**
+ * Delete Git's command-line configuration group from this process's environment.
+ * @returns a disposer restoring every environment entry the call removed.
+ */
+export function isolateGitCommandLineConfig(): () => void {
+ for (const name of Object.keys(process.env)) {
+ if (COMMAND_LINE_CONFIG_NAME.test(name)) vi.stubEnv(name, undefined)
+ }
+ // `unstubAllEnvs` is the documented inverse of `stubEnv`; neither spec stubs another name.
+ return () => { vi.unstubAllEnvs() }
+}
diff --git a/packages/boot/plugin-manager/tests/github-connection.spec.ts b/packages/boot/plugin-manager/tests/github-connection.spec.ts
index 673fb7c733..33dd34e067 100644
--- a/packages/boot/plugin-manager/tests/github-connection.spec.ts
+++ b/packages/boot/plugin-manager/tests/github-connection.spec.ts
@@ -8,15 +8,21 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import * as subprocess from 'execa'
-import { expect, it, onTestFinished, vi } from 'vitest'
+import { afterAll, beforeAll, expect, it, onTestFinished, vi } from 'vitest'
import { checkGithubConnection } from '../src/github-connection.ts'
import { parseInstallSpec } from '../src/install-spec.ts'
+import { isolateGitCommandLineConfig } from './git-environment.ts'
vi.mock('execa', async (original) => {
const module = await original()
return { ...module, execa: vi.fn(module.execa) }
})
+// These cases spawn real Git, so the host's command-line configuration group must not reach its children.
+let restoreGitCommandLineConfig: () => void
+beforeAll(() => { restoreGitCommandLineConfig = isolateGitCommandLineConfig() })
+afterAll(() => { restoreGitCommandLineConfig() })
+
async function fixture() {
const dir = await mkdtemp(join(tmpdir(), 'dsh-github-check-'))
onTestFinished(() => rm(dir, { recursive: true, force: true }))
diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts
index caaf31f25b..81acd6ddf8 100644
--- a/packages/boot/plugin-manager/tests/manager.spec.ts
+++ b/packages/boot/plugin-manager/tests/manager.spec.ts
@@ -9,7 +9,7 @@ import { tmpdir } from 'node:os'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
import type { Context } from '@deepseek-ai/cordis'
-import { expect, it, onTestFinished, vi } from 'vitest'
+import { afterAll, beforeAll, expect, it, onTestFinished, vi } from 'vitest'
import {
boot, composeEntries, initProfile, loadProfileDirectory, readProfilePatches, readProfileManifest,
reconcileProfilePatches, OPTIONAL_BUNDLES, PluginPackages, readPluginMeta, getDshRuntimeVersion,
@@ -23,6 +23,12 @@ import { Group } from '@deepseek-ai/cordis-plugin-loader'
import * as operations from '../src/operations.ts'
import * as githubConnection from '../src/github-connection.ts'
import { parse, parseDocument } from 'yaml'
+import { isolateGitCommandLineConfig } from './git-environment.ts'
+
+// These cases install through real Git and pnpm, so the host's command-line configuration group must not reach their children.
+let restoreGitCommandLineConfig: () => void
+beforeAll(() => { restoreGitCommandLineConfig = isolateGitCommandLineConfig() })
+afterAll(() => { restoreGitCommandLineConfig() })
async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}, packageManager?: ProfileContext['packageManager'], prepareFiles?: (dir: string) => void) {
const temporaryHome = mkdtempSync(join(tmpdir(), 'plugin-manager-'))
diff --git a/packages/boot/plugin-manager/tests/operations.spec.ts b/packages/boot/plugin-manager/tests/operations.spec.ts
index 41009ec9ed..9a062e8af9 100644
--- a/packages/boot/plugin-manager/tests/operations.spec.ts
+++ b/packages/boot/plugin-manager/tests/operations.spec.ts
@@ -598,22 +598,50 @@ function runRecord(dir: string): string {
return join(dir, '.plugin-manager', 'run.json')
}
-/** A run that exits after `ms`, reading the profile's run record while it is still running. */
-function observingChild(dir: string, pid: number, ms = 100) {
+/**
+ * The record an operation writes for the run it started, read once the record
+ * appears. The write follows the run's launch, so a bound this far above one
+ * atomic file write keeps a missing record reporting as the assertion that
+ * names it rather than as a runner timeout.
+ */
+const RECORD_WAIT_MS = 2_000
+
+/**
+ * A run that stays in flight until the test has read the profile's run record,
+ * so the read observes the run and cannot race its end. `spawned` is the
+ * mocked launcher's hand-over: the wait for the record starts when the run
+ * does, not before the operation reached it under a loaded runner.
+ */
+function observingChild(dir: string, pid: number, waitMs = RECORD_WAIT_MS) {
const stdout = new PassThrough()
const stderr = new PassThrough()
const raw = new EventEmitter()
const observed: { record: string | undefined } = { record: undefined }
+ const spawned = Promise.withResolvers()
const done = new Promise((resolve) => {
- setTimeout(() => {
- observed.record = existsSync(runRecord(dir)) ? readFileSync(runRecord(dir), 'utf8') : undefined
+ void spawned.promise.then(async () => {
+ observed.record = await recordedRun(dir, waitMs)
stdout.end()
stderr.end()
raw.emit('exit', 0, null)
resolve({ exitCode: 0, failed: false, stdout: '', stderr: '' })
- }, ms)
+ })
})
- return { child: Object.assign(done, { stdout, stderr, nodeChildProcess: raw, pid }), observed }
+ return {
+ child: Object.assign(done, { stdout, stderr, nodeChildProcess: raw, pid }),
+ observed,
+ spawned: () => { spawned.resolve(undefined) },
+ }
+}
+
+/** The written run record, or undefined once `waitMs` passes without one. */
+async function recordedRun(dir: string, waitMs: number): Promise {
+ const deadline = Date.now() + waitMs
+ for (;;) {
+ if (existsSync(runRecord(dir))) return readFileSync(runRecord(dir), 'utf8')
+ if (Date.now() >= deadline) return undefined
+ await new Promise(resolve => setTimeout(resolve, 1))
+ }
}
it.each([8192, 30])('refuses to run while a run recorded by an exited operation is still active (output bound %i)', async (outputBytes) => {
@@ -667,8 +695,8 @@ it.each(['not json', 'null', '{"pid":0,"grouped":false}', '{"pid":12,"grouped":"
it.each(['cli', 'service'] as const)('records a %s run while it runs and removes the record once it ends', async (execution) => {
const { dir, context } = fixture()
- const { child, observed } = observingChild(dir, 4242)
- command.run.mockImplementationOnce(() => child)
+ const { child, observed, spawned } = observingChild(dir, 4242)
+ command.run.mockImplementationOnce(() => { spawned(); return child })
const outcome = await runProfilePnpm(context, ['list'], { execution, outputBytes: 8192 })
expect(outcome.exitCode).toBe(0)
expect(JSON.parse(observed.record ?? 'null')).toEqual({
@@ -683,7 +711,9 @@ it('records the install that repairs a refused installation', async () => {
const base = command.run.getMockImplementation() as (...call: unknown[]) => Promise & FakeChild
const repair = observingChild(dir, 4343)
command.run.mockImplementation((...call: unknown[]) => {
- return (call[1] as readonly string[]).includes('--config.lockfile=false') ? repair.child : base(...call)
+ if (!(call[1] as readonly string[]).includes('--config.lockfile=false')) return base(...call)
+ repair.spawned()
+ return repair.child
})
const outcome = await runProfilePnpm(context, ['add', 'incompatible'], { execution: 'service', outputBytes: 8192 })
expect(outcome.exitCode).toBe(1)
diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml
index db6701a9b6..9c35ebf5c2 100644
--- a/packages/bundle/base/cordis.patch.yml
+++ b/packages/bundle/base/cordis.patch.yml
@@ -185,6 +185,9 @@
writeEveryEvents: 200
writeIntervalMs: 5000
+ - id: otel
+ name: '@deepseek-ai/dsh-otel'
+
# OTel releases a Session-log prefix only after explicit user feedback,
# regardless of model provider. Ordinary activity never triggers capture.
# DSH_TELEMETRY_OTLP_URL overrides the production endpoint. A non-empty
@@ -192,22 +195,20 @@
# process out (the launchers patch the row disabled; config cannot disable
# a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.anonymous-user-id,
# random UUID; delete the file to reset the identity) as the Resource's
- # user.id. The exporter/processor values normally bound the shutdown drain
- # to ~1s against an unreachable collector: exporter.timeoutMillis is both
- # the per-attempt socket timeout and the retry deadline (1s effectively
- # disables the SDK's 5-try backoff), while maxExportBatchSize == maxQueueSize
- # (both explicit) makes the drain a single batch. The SDK awaits
- # exporter.forceFlush() outside exportTimeoutMillis, so the backend's 3s
- # shutdownTimeoutMillis is the load-bearing outer bound when a transport
- # promise never settles. Every CLI exit path drains it by disposing the root
- # on SIGINT/SIGTERM.
+ # user.id. Byte-bounded batching may turn an authorized prefix into
+ # several HTTP requests. Requests run serially; each has its own 1s
+ # transport timeout and 1.5s watchdog. A watchdog does not release the
+ # active request slot. The outer 3s shutdown deadline stops queued sends;
+ # large prefixes or an unsettled transport may leave records unsent.
+ # Every CLI exit path drains by disposing the root on SIGINT/SIGTERM.
- id: session-telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
mode: !!js process.env.DSH_TELEMETRY_MODE || 'FEEDBACK_ONLY'
shutdownTimeoutMillis: 3000
+ maxRequestBytes: 4000000
exporter:
- url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'
+ url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://dsh-otel-collector.deepseeksvc.com/v1/logs'
compression: gzip
timeoutMillis: 1000
processor:
diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json
index e5b0025eaa..51b37cf282 100644
--- a/packages/bundle/base/package.json
+++ b/packages/bundle/base/package.json
@@ -125,7 +125,8 @@
"@deepseek-ai/dsh-authorization": "workspace:*",
"@deepseek-ai/dsh-deepseek-account-platform": "workspace:*",
"@deepseek-ai/dsh-config-editor": "workspace:*",
- "@deepseek-ai/dsh-settings": "workspace:*"
+ "@deepseek-ai/dsh-settings": "workspace:*",
+ "@deepseek-ai/dsh-otel": "workspace:*"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~"
diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml
index c958afc209..161739e50d 100644
--- a/packages/bundle/web-app/README.i18n.yaml
+++ b/packages/bundle/web-app/README.i18n.yaml
@@ -6,8 +6,8 @@
en: af2e4e5296596e8f
zh: 70c0b5ac93209d00
/deepseek-ai-dsh-web-app:
- en: f4da95d498221fcd
- zh: 6cc704e7aa6f376d
+ en: 8523b283e794cf59
+ zh: 7764704f7e98f19b
/deepseek-ai-dsh-web-app/summary:
en: 0ebb6c3ca8c0a84e
zh: 4d85e02c769ddae0
@@ -21,8 +21,8 @@
en: b7ab01cfe9fed40f
zh: 23703ab7727c3ab5
/deepseek-ai-dsh-web-app/use-this-package/configuration:
- en: 4f18797d9de48038
- zh: 62387505417e4813
+ en: a93760e9e451faa6
+ zh: 5dab82dd20aa952f
/deepseek-ai-dsh-web-app/use-this-package/lan-access-and-trusted-hosts:
en: 2522c806db384ddc
zh: 7e96f11194af9f9f
diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md
index a798b7f506..952ff9cafa 100644
--- a/packages/bundle/web-app/README.md
+++ b/packages/bundle/web-app/README.md
@@ -7,6 +7,8 @@ kind: "package-bundle"
English | [中文](README.zh.md)
+Desktop analytics follows the [product collection policy](../../client/product-analytics/README.md), including its live application setting. Web usage is excluded.
+
## Summary
Run `dsh --profile web` to open an interactive browser GUI with chat, model and settings management, and session history. It uses the same model access, tools, and safety defaults as other dsh surfaces. Startup prints an authenticated URL and normally opens it in the default browser; SSH sessions and `--no-open` leave the URL for manual opening. You can change the port and allow extra hosts, but cannot bind all network interfaces. Choose this package for interactive browser work; use `dsh-headless` for one-shot command-line tasks.
@@ -51,7 +53,7 @@ Most users never set these; the command-line flags feed the four settings below
| `surfaceContext` | `true` | Give the agent GUI-orientation context and expose `DSH_WEB_URL` to its shell commands |
| `trustedHosts` | `[]` | Extra hosts allowed to reach the GUI from the network |
-The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-app) is the exhaustive source for every accepted field and its JSDoc. The shipped composition disables `schedule`, `ui-schedule`, and `time-context` by default.
+The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-app) is the exhaustive source for every accepted field and its JSDoc. The shipped composition carries no `time-context`, `schedule`, or `ui-schedule` row; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` inserts those three rows from the Plugins page.
### LAN access and trusted hosts
diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md
index 9ea8e7e868..a82057b09f 100644
--- a/packages/bundle/web-app/README.zh.md
+++ b/packages/bundle/web-app/README.zh.md
@@ -7,6 +7,8 @@ kind: "package-bundle"
[English](README.md) | 中文
+桌面埋点遵循[产品采集策略](../../client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。
+
## 概述
运行 `dsh --profile web`,打开提供聊天、模型与设置管理以及会话历史的交互式浏览器 GUI。它使用与其他 dsh 表层相同的模型访问、工具与安全默认值。启动时会打印带认证信息的 URL,通常还会在默认浏览器中打开;SSH 会话和 `--no-open` 会保留该 URL,供你手动打开。你可以更改端口并允许额外主机,但不能绑定所有网络接口。需要在浏览器中交互式工作时选择本包;一次性的命令行任务应使用 `dsh-headless`。
@@ -51,7 +53,7 @@ dsh --profile web --no-open --port 8080
| `surfaceContext` | `true` | 给 agent 提供 GUI 定位上下文,并把 `DSH_WEB_URL` 暴露给其 shell 命令 |
| `trustedHosts` | `[]` | 允许从网络访问 GUI 的额外主机 |
-生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-app)是每个受支持字段及其 JSDoc 的穷尽式真源。 随发行版交付的组合默认禁用 `schedule`、`ui-schedule` 和 `time-context`。
+生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-app)是每个受支持字段及其 JSDoc 的穷尽式真源。 随发行版交付的组合不含 `time-context`、`schedule` 和 `ui-schedule` 行,可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 可在插件管理页插入这三行。
### LAN 访问与可信主机
diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml
index c98289bc3e..0659d73674 100644
--- a/packages/bundle/web-app/cordis.patch.yml
+++ b/packages/bundle/web-app/cordis.patch.yml
@@ -42,6 +42,24 @@
# `dsh.client` rows are the browser roster the modules node half scans into
# window.__DSH_BOOT__; the modules row is simultaneously a host row.
- insert:
+ - id: desktop-product-telemetry
+ name: '@deepseek-ai/dsh-host-product-telemetry-otel'
+ disabled: !!js "ctx.get('profileContext')?.name !== 'desktop'"
+ config:
+ timeoutMillis: 1000
+ exportTimeoutMillis: 1500
+ shutdownTimeoutMillis: 2000
+ serviceName: deepseek-harness-desktop
+ serviceVersion: !!js process.env.DSH_CLIENT_VERSION
+ endpoint: !!js process.env.DSH_PRODUCT_ANALYTICS_OTLP_URL
+
+ - id: product-analytics
+ name: '@deepseek-ai/dsh-client-product-analytics'
+ disabled: !!js "ctx.get('profileContext')?.name !== 'desktop'"
+ config:
+ enabled: true
+ appVersion: !!js process.env.DSH_CLIENT_VERSION
+
# Host-owned opt-in sampled when a new Web session receives its preset
# delegation tools. The Plugins page edits this settings namespace.
- id: subagent-model-selection-settings
@@ -113,19 +131,6 @@
- id: workspace-files
name: '@deepseek-ai/dsh-api-workspace-files'
- # Durable Host-wide reminders. The Schedule service owns the task store and
- # delivers each due occurrence as a follow-up in its original Session;
- # `time-context` supplies the current time, browser zone, and elapsed time
- # the model needs to resolve an unqualified date or time. The browser half
- # is the `ui-schedule` row below.
- - id: time-context
- name: '@deepseek-ai/dsh-time-context'
- disabled: true
-
- - id: schedule
- name: '@deepseek-ai/dsh-schedule'
- disabled: true
-
# Configuration-surface reads and writes over Typert Remote. Each method
# reports an actionable error when its settings-domain provider is absent.
- id: ui-settings-account
@@ -365,12 +370,6 @@
- id: ui-jobs
name: '@deepseek-ai/dsh-client-ui-jobs'
- # Task management and Session reminder catalog over the Host Schedule
- # service mounted above.
- - id: ui-schedule
- name: '@deepseek-ai/dsh-client-ui-schedule'
- disabled: true
-
# Goal surface: GoalBar in the input dock over the goal session projection.
- id: ui-goal
name: '@deepseek-ai/dsh-client-ui-goal'
diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json
index fb56be4231..accaa7132e 100644
--- a/packages/bundle/web-app/package.json
+++ b/packages/bundle/web-app/package.json
@@ -91,7 +91,6 @@
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*",
"@deepseek-ai/dsh-client-ui-reference": "workspace:*",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
- "@deepseek-ai/dsh-client-ui-schedule": "workspace:*",
"@deepseek-ai/dsh-client-ui-session": "workspace:*",
"@deepseek-ai/dsh-client-ui-settings": "workspace:*",
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:*",
@@ -138,7 +137,6 @@
"@deepseek-ai/dsh-persona": "workspace:*",
"@deepseek-ai/dsh-plan-mode": "workspace:*",
"@deepseek-ai/dsh-plugin-manager": "workspace:*",
- "@deepseek-ai/dsh-schedule": "workspace:*",
"@deepseek-ai/dsh-session-log-export": "workspace:*",
"@deepseek-ai/dsh-session-reference": "workspace:*",
"@deepseek-ai/dsh-session-stats": "workspace:*",
@@ -147,7 +145,6 @@
"@deepseek-ai/dsh-subprocess": "workspace:*",
"@deepseek-ai/dsh-terminal": "workspace:*",
"@deepseek-ai/dsh-terminal-bash": "workspace:*",
- "@deepseek-ai/dsh-time-context": "workspace:*",
"@deepseek-ai/dsh-tool-ask-user": "workspace:*",
"@deepseek-ai/dsh-tool-bash": "workspace:*",
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:*",
@@ -176,7 +173,9 @@
"@deepseek-ai/dsh-api-account-controller": "workspace:*",
"@deepseek-ai/dsh-client-ui-settings-account": "workspace:*",
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
- "@deepseek-ai/dsh-client-ui-shortcuts": "workspace:*"
+ "@deepseek-ai/dsh-client-ui-shortcuts": "workspace:*",
+ "@deepseek-ai/dsh-client-product-analytics": "workspace:*",
+ "@deepseek-ai/dsh-host-product-telemetry-otel": "workspace:*"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -196,6 +195,7 @@
"@deepseek-ai/dsh-shell-env": "workspace:*",
"@deepseek-ai/dsh-system-prompt": "workspace:*",
"@deepseek-ai/dsh-typert-registry": "workspace:*",
- "@deepseek-ai/libreoffice-kit": "^0.1.1"
+ "@deepseek-ai/libreoffice-kit": "^0.1.1",
+ "@deepseek-ai/dsh-otel": "workspace:*"
}
}
diff --git a/packages/bundle/web-app/tests/product-analytics.spec.ts b/packages/bundle/web-app/tests/product-analytics.spec.ts
new file mode 100644
index 0000000000..178d775d6f
--- /dev/null
+++ b/packages/bundle/web-app/tests/product-analytics.spec.ts
@@ -0,0 +1,80 @@
+import OTel from '@deepseek-ai/dsh-otel'
+/** Desktop-only collector policy and bounded shutdown against an unresponsive receiver. */
+import { createServer } from 'node:http'
+import { once } from 'node:events'
+import { fileURLToPath } from 'node:url'
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
+import * as Telemetry from '@deepseek-ai/dsh-host-product-telemetry-otel'
+import Analytics from '@deepseek-ai/dsh-client-product-analytics'
+import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
+
+afterEach(() => { vi.unstubAllEnvs() })
+
+it.each(['desktop', 'web'])('limits collection and its shutdown to the Desktop launch: %s', async (profile) => {
+ const received = Promise.withResolvers()
+ const server = createServer((req) => { req.resume(); req.once('end', () => { received.resolve(undefined) }) })
+ server.listen(0, '127.0.0.1')
+ await once(server, 'listening')
+ onTestFinished(async () => { const closed = once(server, 'close'); server.close(); server.closeAllConnections(); await closed })
+ const address = server.address()
+ if (address === null || typeof address === 'string') throw new Error('missing collector address')
+ vi.stubEnv('DSH_CLIENT_VERSION', 'test-version')
+ vi.stubEnv('DSH_PRODUCT_ANALYTICS_OTLP_URL', `http://127.0.0.1:${address.port}/logs`)
+ const ctx = new Context()
+ onTestFinished(() => ctx.fiber.dispose())
+ ctx.provide('profileContext', {
+ name: profile, dir: '/profile', patchPath: '/profile/cordis.patch.yml', installAnchor: '/profile/package.json',
+ cwd: '/workspace', home: '/home', startedBundles: [], overlays: [], telemetryDisabledEnv: undefined,
+ })
+ const identity = vi.fn().mockResolvedValue(undefined)
+ ctx.provide('deepseekAccount', { getDeviceIdentity: identity } as never)
+ await ctx.plugin(OTel)
+ ctx.baseUrl = 'file:///'
+ await ctx.plugin(Loader).await()
+ ctx.loader.builtins.telemetry = Telemetry
+ ctx.loader.builtins.analytics = Analytics
+ const rows = loadOverlayPatches('analytics', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url)))
+ .flatMap(patch => patch.insert ?? []).filter(row => row.id === 'desktop-product-telemetry' || row.id === 'product-analytics')
+ const entries = rows.map(row => ({ ...row, name: row.id === 'desktop-product-telemetry' ? 'cordis:telemetry' : 'cordis:analytics' }))
+ await ctx.loader.root.update(entries)
+ await ctx.loader.await()
+ const entry = ctx.loader.resolve('desktop-product-telemetry')
+ expect(entry.disabled).toBe(profile !== 'desktop')
+ if (profile !== 'desktop') {
+ expect(ctx.get('productTelemetry')).toBeUndefined()
+ expect(ctx.get('productAnalytics')).toBeUndefined()
+ return
+ }
+ expect(rows[0]!.config).toMatchObject({ timeoutMillis: 1000, exportTimeoutMillis: 1500, shutdownTimeoutMillis: 2000 })
+ const analytics = ctx.productAnalytics
+ const analyticsFiber = ctx.loader.resolve('product-analytics').fiber
+ const telemetryFiber = entry.fiber
+ const lifetime = new AbortController()
+ onTestFinished(() => { lifetime.abort() })
+ const policy = analytics.watchPolicy(lifetime.signal)[Symbol.asyncIterator]()
+ expect(await policy.next()).toEqual({ value: true, done: false })
+ for (const enabled of [false, true]) {
+ const changed = policy.next()
+ await ctx.loader.root.update(entries.map(row => row.id === 'product-analytics'
+ ? { ...row, config: { ...row.config as Record, enabled } } : row))
+ await ctx.loader.await()
+ expect(await changed).toEqual({ value: enabled, done: false })
+ expect(ctx.loader.resolve('product-analytics').fiber === analyticsFiber).toBe(true)
+ expect(entry.fiber === telemetryFiber).toBe(true)
+ if (!enabled) {
+ await analytics.report({ eventName: 'desktop_app_launch', timestamp: 1, attributes: {} })
+ expect(identity).not.toHaveBeenCalled()
+ }
+ }
+ const emit = vi.spyOn(ctx.productTelemetry, 'emit')
+ await analytics.report({ eventName: 'desktop_app_launch', timestamp: 1, attributes: {} })
+ expect(emit).toHaveBeenCalledWith(expect.objectContaining({ attributes: { app_version: 'test-version' } }))
+ lifetime.abort()
+ await policy.return?.()
+ ctx.productTelemetry.emit({ eventName: 'desktop_upgrade_install_restart_click', body: 'upgrade', timestamp: Date.now() })
+ const disposal = entry.fiber!.dispose()
+ await received.promise
+ await disposal
+})
diff --git a/packages/client/product-analytics/README.i18n.yaml b/packages/client/product-analytics/README.i18n.yaml
new file mode 100644
index 0000000000..0847f8f241
--- /dev/null
+++ b/packages/client/product-analytics/README.i18n.yaml
@@ -0,0 +1,34 @@
+# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write packages/client/product-analytics/README.md
+/:
+ en: 385fffb5f6f81222
+ zh: ea1d23fccd3b2b84
+/deepseek-ai-dsh-client-product-analytics:
+ en: bfcd910889485da9
+ zh: 74e27b92b9fafc33
+/deepseek-ai-dsh-client-product-analytics/summary:
+ en: 4b48b9e5de3a0e97
+ zh: cc06154eef4c4f6a
+/deepseek-ai-dsh-client-product-analytics/table-of-contents:
+ en: 2fee9973f19f9401
+ zh: 8ab4b55413cb8408
+/deepseek-ai-dsh-client-product-analytics/use-this-package:
+ en: 6bd7bf60e279ba1c
+ zh: afd55f06585c6462
+/deepseek-ai-dsh-client-product-analytics/understand-the-implementation:
+ en: 416b7e925f0852c9
+ zh: c0b83bacf18e91b1
+/deepseek-ai-dsh-client-product-analytics/model-experience:
+ en: 291751b16b8a37e7
+ zh: 3b38008f4364ec41
+/deepseek-ai-dsh-client-product-analytics/model-experience/kv-cache-effect:
+ en: 1f5125831808e7a9
+ zh: 97566fdde332df39
+/deepseek-ai-dsh-client-product-analytics/known-limitations-and-deferred-work:
+ en: a33ad31745fd5240
+ zh: 8b579cfc4c9b8155
+/deepseek-ai-dsh-client-product-analytics/known-limitations-and-deferred-work/dev-note:
+ en: 0a1253c6f7b5e8b9
+ zh: 9f56528562c7c914
diff --git a/packages/client/product-analytics/README.md b/packages/client/product-analytics/README.md
new file mode 100644
index 0000000000..307534adb4
--- /dev/null
+++ b/packages/client/product-analytics/README.md
@@ -0,0 +1,67 @@
+---
+description: "Configure Desktop product analytics, identity fields, and event timing without collecting Web usage."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-product-analytics
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Desktop reports selected interactions through the existing OTel product exporter by default, without a user-facing control. Ordinary Web clients never submit these events, and missing login identity is omitted.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+
+
+## Use this package
+
+Desktop mounts Analytics and its required Telemetry exporter. The `product-analytics` settings namespace owns the live `enabled` field, which defaults to `true`; configure it through the existing Cordis Config / settings mechanism. There is no user-facing control. Ordinary Web mounts neither service. Disabled collection reads no analytics identity and accepts no new events; the exporter remains mounted and may finish exporting records already queued. Session-feedback telemetry has its own policy.
+
+The renderer and Electron subscribe to the Host policy through the existing authenticated stream, including reconnection. Electron also reads the initial policy before native launch reporting. Welcome obtains the current policy over IPC. The Host checks its current volatile configuration again at event intake and after identity lookup. `DSH_PRODUCT_ANALYTICS_OTLP_URL` overrides the export destination for isolated collectors. The [exporter](../../host/product-telemetry-otel/README.md) owns batching, retry, and shutdown delivery.
+
+Common fields are `device_id`, `user_id`, `os_version`, and `app_version`. Device identity reuses the existing login record without generating one. The Host reads credential-free device, account, and OS fields through `deepseekAccount.getDeviceIdentity()`. Missing values are omitted; API keys, account tokens, prompts, and responses are never event fields.
+
+Electron forwards its build-inlined `DSH_CLIENT_VERSION` to the Host; analytics and the exporter reuse this client version. The exporter requires it. Desktop export, batch, and shutdown deadlines are 1000, 1500, and 2000 milliseconds, below the Host exit budget.
+
+The [event types](src/events.ts) own names and allowed fields. Authentication events cover the native welcome page only; workspace login after API-key entry is excluded. Views count visible page entry, including reshown native welcome windows; transient onboarding loading does not repeat the same page impression, and closing an onboarding popup reports `button_name=close`. Funded Continue uses `continue`. Message submission retains its original occurrence timestamp and captures `msg_type=default|queue|steer`, model, explicit effort, and `run_mode` before asynchronous command adjudication; only the ordinary message path reports before reference serialization, so handled and claimed commands are excluded. Later serialization, attachment, or send failures do not retract this attempt; queue execution does not count again. Attachment-only submissions follow the same rule. Plan takes precedence over an active goal. Capture and reporting failures cannot interrupt submission. Message submissions and model or effort switches omit `session_id` while the Session is blank. Model and plugin switches report only accepted changes. Fork events carry the created child ID and the source IDs, before the optional child-title update; failed creation emits nothing.
+
+Only `plugin_toggle` carries `plugin_type`: plugin rows use `plugin`, and packages use `bundle`. Installation reports `is_success`; cancellation uses `is_success=false` and `error_reason=user_cancelled`, and an absent recovery result uses `is_success=false` and `error_reason=unknown_result`. Duration is milliseconds from the install click through the terminal result, including validation, inspection, and internal registry retries. `input_value` retains registry package identifiers and plain versions only; Git inputs become `[git]`, other URLs `[url]`, and paths or unrecognized inputs `[path-or-other]`. Both click and result use this redacted value. Reopening a hidden install dialog counts another `plugin_add_button_click`. `is_builtin` means installation-supplied (`installed=false`), not whether a profile explicitly depends on the package. Explicit build-approval retry starts a new attempt. Restart-required is success. A temporary disconnect retains the pending operation; only an absent recovery result reports `unknown_result`.
+
+
+## Understand the implementation
+
+
+Implementation internals — click to expand
+
+Presentational components receive callbacks. Shared Client services resolve the optional Desktop analytics sender, which requires both the native preload marker and the synchronized Host policy. The sender retains its own RPC context when called by other plugins. Native actions use the authenticated Host API; its generated Typert validator accepts only the typed event fields. The Host enriches identity immediately before queueing. It observes live compaction events without replaying Session history. No runtime invariant companion is published because collection has no independent delivery acknowledgement to compare.
+
+
+
+
+## Model Experience
+
+None, as analytics adds no model context or Session events.
+
+#### KV Cache effect
+
+None; collection does not modify model requests.
+
+## Known Limitations and Deferred Work
+
+
+
+Delivery is best effort.
+
+- Disabling stops new collection; it does not clear the exporter queue.
+- Renderer failures are discarded, and the exporter has no durable outbox or warehouse acknowledgement.
+- Native startup reporting waits for Host authentication; a process that fails before Host readiness cannot report its launch.
+
+### Dev Note
+
+None.
diff --git a/packages/client/product-analytics/README.zh.md b/packages/client/product-analytics/README.zh.md
new file mode 100644
index 0000000000..1a87f9fd0c
--- /dev/null
+++ b/packages/client/product-analytics/README.zh.md
@@ -0,0 +1,67 @@
+---
+description: "配置桌面端产品埋点、身份字段和事件时机,不采集 Web 使用情况。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-product-analytics
+
+[English](README.md) | 中文
+
+## 概述
+
+桌面端默认通过现有 OTel 产品导出器上报指定交互,不提供用户操作入口。普通 Web 客户端不会提交这些事件,缺失的登录身份字段会省略。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [了解实现](#understand-the-implementation)
+- [模型体验](#model-experience)
+- [已知限制与后续工作](#known-limitations-and-deferred-work)
+
+
+## 使用本包
+
+桌面端同时装配 Analytics 与必需的 Telemetry 导出器。`product-analytics` settings 命名空间持有动态 `enabled` 字段,默认 `true`,通过现有 Cordis Config / settings 机制配置,暂不提供用户操作入口。普通 Web 不装配这两个服务。关闭后不读取埋点身份、不接收新事件;导出器仍保持挂载,已入队事件可以继续导出。会话反馈遥测采用独立策略。
+
+渲染端和 Electron 通过现有认证流订阅 Host 策略变化及重连后的值;Electron 在原生启动上报前还会读取初始策略。欢迎窗口通过 IPC 获取当前策略。Host 在接收事件及读取身份后都检查当前 volatile 配置。`DSH_PRODUCT_ANALYTICS_OTLP_URL` 可覆盖导出目的地,用于隔离的接收端。[导出器](../../host/product-telemetry-otel/README.zh.md)负责批量发送、重试和退出时的交付。
+
+公共字段为 `device_id`、`user_id`、`os_version` 和 `app_version`。设备身份复用现有登录记录,不会生成新标识。Host 通过 `deepseekAccount.getDeviceIdentity()` 读取不含凭据的设备、账户和操作系统字段。缺失值会省略;API key、账户令牌、提示词和模型回复都不是事件字段。
+
+Electron 将构建内联的 `DSH_CLIENT_VERSION` 传给 Host;埋点与导出器复用这一客户端版本,导出器要求该值存在。桌面导出、批次与关闭期限分别为 1000、1500 和 2000 毫秒,均短于 Host 退出预算。
+
+认证事件仅覆盖原生欢迎页;通过 API Key 进入工作区后再登录的场景不在采集范围内。[事件类型](src/events.ts)定义名称和允许的字段。页面曝光按实际进入可见页面计数,包括重新显示的原生欢迎窗口;onboarding 短暂进入加载状态不会重复计算同一页面曝光,关闭 onboarding 弹窗上报 `button_name=close`。有余额时的继续按钮使用 `continue`。消息提交保留最初发生时间,并在异步命令裁决前采集 `msg_type=default|queue|steer`、模型、显式思考强度和 `run_mode`;仅普通消息路径在引用序列化之前上报,已处理或认领的命令不计入。后续序列化、附件处理或发送失败不撤销此次计数,排队后的执行也不重复计数;纯附件提交遵循相同规则。计划模式优先于活跃目标。采集和上报异常不会中断提交。消息提交以及模型或思考强度切换在空白会话中均省略 `session_id`。模型与插件切换仅在变更被接受后上报。分叉事件携带已创建的子会话 ID 和来源 ID,在可选的子会话标题更新前上报;创建失败不产生事件。
+
+只有 `plugin_toggle` 携带 `plugin_type`:插件条目使用 `plugin`,插件包使用 `bundle`。安装通过 `is_success` 表达结果;取消使用 `is_success=false` 和 `error_reason=user_cancelled`,恢复查询确认无结果时使用 `is_success=false` 和 `error_reason=unknown_result`。耗时单位为毫秒,从点击安装开始,到最终结果结束,包含校验、检查输入和内部源重试。`input_value` 仅保留 registry 包标识和普通版本;Git 输入记为 `[git]`,其他 URL 记为 `[url]`,路径及无法识别的输入记为 `[path-or-other]`。点击和结果均使用脱敏后的值。重新打开已隐藏的安装弹窗会再次计为 `plugin_add_button_click`。`is_builtin` 表示由安装供应(`installed=false`),不表示 profile 是否显式依赖该包。显式批准构建后的重试开启新的一次尝试。需要重启视为成功。临时断线保留待定操作;只有恢复查询确认没有结果时才上报 `unknown_result`。
+
+
+## 了解实现
+
+
+实现细节——点击展开
+
+展示组件接收回调。共享 Client 服务读取可选的桌面埋点发送器,后者要求同时存在原生 preload 标识和 同步的 Host 采集策略。发送器在被其他插件调用时保留自身的 RPC 上下文。原生操作使用已认证的 Host API,其生成的 Typert 校验器只接受类型中声明的事件字段。Host 在入队前补充身份信息,并监听实时压缩事件,不重放会话历史。本包不发布运行时不变量伴随模块,因为采集没有可用于比较的独立交付确认。
+
+
+
+
+## 模型体验
+
+无;埋点不增加模型上下文或会话事件。
+
+#### KV 缓存影响
+
+无;采集不修改模型请求。
+
+## 已知限制与后续工作
+
+
+
+交付采用尽力而为的方式。
+
+- 关闭只停止新采集,不清空导出器队列。
+- 渲染进程的发送失败会被丢弃,导出器没有持久化待发队列或数据仓库确认。
+- 原生启动事件等待 Host 认证完成;若进程在 Host 就绪前失败,则无法上报启动事件。
+
+### 开发备注
+
+无。
diff --git a/packages/client/product-analytics/package.json b/packages/client/product-analytics/package.json
new file mode 100644
index 0000000000..b3e2fffa7d
--- /dev/null
+++ b/packages/client/product-analytics/package.json
@@ -0,0 +1,82 @@
+{
+ "name": "@deepseek-ai/dsh-client-product-analytics",
+ "description": "Desktop product event collection and authenticated Host reporting",
+ "version": "0.1.7-rc.2",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+ "directory": "packages/client/product-analytics"
+ },
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./client": {
+ "types": "./lib/types/client/index.d.ts",
+ "default": "./lib/client.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json",
+ "./typert": {
+ "types": "./lib/typert.host.d.ts",
+ "default": "./lib/typert.host.js"
+ },
+ "./remote": {
+ "types": "./lib/typert.remote-client.d.ts",
+ "default": "./lib/typert.remote-client.js"
+ },
+ "./types": {
+ "types": "./lib/types/events.d.ts"
+ }
+ },
+ "dsh": {
+ "client": {
+ "inject": [
+ "@deepseek-ai/dsh-api-gateway"
+ ],
+ "platform": "web"
+ }
+ },
+ "scripts": {
+ "bundle": "tsdown",
+ "watch": "tsdown --watch"
+ },
+ "license": "MIT",
+ "peerDependencies": {
+ "@deepseek-ai/cordis": "workspace:~"
+ },
+ "devDependencies": {
+ "@deepseek-ai/cordis": "workspace:~",
+ "@deepseek-ai/dsh-api-gateway": "workspace:*",
+ "@deepseek-ai/dsh-compaction": "workspace:*",
+ "@deepseek-ai/dsh-llm": "workspace:*",
+ "@deepseek-ai/dsh-session": "workspace:*",
+ "@deepseek-ai/dsh-host-product-telemetry-otel": "workspace:*",
+ "@deepseek-ai/dsh-deepseek-account": "workspace:*",
+ "@deepseek-ai/dsh-host-webserver": "workspace:*",
+ "@deepseek-ai/dsh-settings": "workspace:*",
+ "@deepseek-ai/cordis-plugin-loader": "workspace:~",
+ "@deepseek-ai/dsh-otel": "workspace:*"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/client.js",
+ "lib/types/**/*.d.ts",
+ "lib/typert.host.js",
+ "lib/typert.host.d.ts",
+ "lib/typert.remote-client.js",
+ "lib/typert.remote-client.d.ts"
+ ],
+ "dependencies": {
+ "@deepseek-ai/dsh-typert-protocol": "workspace:*",
+ "@deepseek-ai/schemastery": "workspace:~",
+ "zod": "^4.4.3"
+ }
+}
diff --git a/packages/client/product-analytics/src/client/index.ts b/packages/client/product-analytics/src/client/index.ts
new file mode 100644
index 0000000000..dfa990bfe6
--- /dev/null
+++ b/packages/client/product-analytics/src/client/index.ts
@@ -0,0 +1,60 @@
+/** Desktop renderer analytics sender; browser applications have no collection capability. */
+import { Service, type Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-api-gateway/client'
+export type {} from '@deepseek-ai/dsh-client-product-analytics/remote'
+import type { ProductEvent, ProductEventMap } from '../events.ts'
+export type { ProductEvent, ProductEventMap, TrackProductEvent } from '../events.ts'
+
+declare module '@deepseek-ai/cordis' {
+ interface Context {
+ productAnalytics: DesktopAnalytics
+ }
+}
+
+class DesktopAnalytics extends Service {
+ private collecting = false
+ // RPC dispatch stays in the provider context when Cordis traces a consumer call.
+ private readonly submit: (event: ProductEvent) => Promise
+
+ constructor(ctx: Context) {
+ super(ctx, 'productAnalytics')
+ this.submit = async (event) => {
+ try { await ctx.remote.productAnalytics.report(event) } catch (_error) {
+ // Missing or disconnected Remote services must not interrupt the interaction.
+ }
+ }
+ if (!('dshDesktop' in globalThis)) return
+ const stream = ctx.remote.$stream({
+ name: 'product analytics policy', open: signal => ctx.remote.productAnalytics.watchPolicy(signal),
+ ended: () => new Error('product analytics policy stream ended'),
+ carrierFailed: () => { this.collecting = false },
+ })
+ ctx.effect(() => () => { this.collecting = false; return stream.dispose() })
+ void (async () => {
+ for await (const frame of stream) { this.collecting = frame.value; frame.accept() }
+ })().catch(() => { this.collecting = false })
+ }
+
+ /** Whether the synchronized Host configuration currently permits collection. */
+ get enabled(): boolean {
+ return this.collecting
+ }
+
+ /**
+ * Send an event without retaining it for reconnect or later enablement.
+ * @param name - event name.
+ * @param attributes - approved business fields.
+ * @param timestamp - occurrence time; defaults to the current time.
+ */
+ track(name: K, attributes: ProductEventMap[K], timestamp = Date.now()): void {
+ if (!this.enabled) return
+ const event = { eventName: name, attributes, timestamp } as ProductEvent
+ void this.submit(event)
+ }
+}
+
+/** Analytics consumes authenticated RPC and its reconnecting policy stream. */
+export const inject = ['remote', 'remote.productAnalytics']
+
+/** @param ctx - browser application context. */
+export function apply(ctx: Context): void { ctx.plugin(DesktopAnalytics) }
diff --git a/packages/client/product-analytics/src/events.ts b/packages/client/product-analytics/src/events.ts
new file mode 100644
index 0000000000..7fc64a6bcc
--- /dev/null
+++ b/packages/client/product-analytics/src/events.ts
@@ -0,0 +1,61 @@
+/** Desktop analytics fields selected by product event owners. */
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
+
+/** Event names and their selected product attributes. */
+export interface ProductEventMap {
+ desktop_app_launch: Record
+ auth_page_view: Record
+ auth_page_click: { button_name: 'sign_in' | 'api-key' }
+ api_key_save_click: Record
+ onboarding_page_view: { page_name: OnboardingPage }
+ onboarding_page_click: {
+ page_name: OnboardingPage
+ button_name: 'next' | 'back' | 'skip' | 'charge' | 'later' | 'continue'
+ selected_content?: 'office' | 'code' | 'code_office' | 'focus_result' | 'key_detail' | 'full_process'
+ }
+ onboarding_popup_view: { popup_name: 'skip_charge' | 'skip_setting' }
+ onboarding_popup_click: { popup_name: 'skip_charge' | 'skip_setting'; button_name: 'charge' | 'know' | 'enter' | 'setting' | 'close' }
+ desktop_upgrade_click: Record
+ desktop_upgrade_download_result: { is_success: boolean; error_reason?: string }
+ desktop_upgrade_install_restart_click: Record
+ send_button_click: {
+ session_id?: SessionId
+ model_name?: string
+ thinking_effort?: string
+ run_mode: 'plan' | 'goal' | 'default'
+ msg_type: 'default' | 'steer' | 'queue'
+ }
+ model_switch: { session_id?: SessionId; switch_from: string; switch_to: string }
+ thinking_level_switch: { session_id?: SessionId; switch_from: string; switch_to: string; model_name: string }
+ context_compression: { session_id: SessionId; trigger_type: 'auto' | 'manual' }
+ branch_session_click: {
+ session_id: SessionId
+ parent_session_id: SessionId
+ parent_message_id?: MessageId
+ click_position: 'footer' | 'sidebar'
+ }
+ sidebar_menu_click: { menu_name: 'plugin' | 'cron' }
+ plugin_toggle: { plugin_name: string; plugin_type: 'plugin' | 'bundle'; is_enabled: boolean; is_builtin: boolean }
+ plugin_add_button_click: Record
+ plugin_install_click: { input_value: string }
+ install_plugin_result: {
+ input_value: string
+ is_success: boolean
+ error_reason?: string
+ duration: number
+ plugin_name?: string
+ }
+ confirm_uninstall_plugin: { plugin_name: string }
+}
+
+/** Public page names independent of internal onboarding state names. */
+export type OnboardingPage = 'onboarding_welcome' | 'onboarding_recharge' | 'onboarding_use_case' | 'onboarding_process'
+
+/** Correlated event name and attributes for the authenticated analytics RPC. */
+export type ProductEvent = {
+ [K in keyof ProductEventMap]: { eventName: K; attributes: ProductEventMap[K]; timestamp: number }
+}[keyof ProductEventMap]
+
+/** Callback passed into presentation components; omitted when collection is disabled. */
+export type TrackProductEvent = (name: K, attributes: ProductEventMap[K]) => void
diff --git a/packages/client/product-analytics/src/index.ts b/packages/client/product-analytics/src/index.ts
new file mode 100644
index 0000000000..3eae234d57
--- /dev/null
+++ b/packages/client/product-analytics/src/index.ts
@@ -0,0 +1,101 @@
+/** Desktop-only analytics RPC and live compaction collection. */
+import { type Context, type Volatile } from '@deepseek-ai/cordis'
+import z from '@deepseek-ai/schemastery'
+import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+import type {} from '@deepseek-ai/dsh-host-product-telemetry-otel'
+import type {} from '@deepseek-ai/dsh-deepseek-account'
+import type {} from '@deepseek-ai/dsh-settings'
+import type {} from '@deepseek-ai/cordis-plugin-loader'
+import type {} from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-compaction/types'
+import type { ProductEvent } from './events.ts'
+
+/** Application-owned collection policy; no user settings surface. */
+export interface Config {
+ /** Live application collection policy; ordinary Web does not mount this service. */
+ enabled: Volatile
+ /** Running Desktop release, absent when unavailable. */
+ appVersion?: string
+}
+
+declare module '@deepseek-ai/cordis' {
+ interface Context {
+ productAnalytics: ProductAnalytics
+ }
+}
+
+/** Authenticated event intake; disabled instances do not inspect identity or accept new events. */
+export default class ProductAnalytics extends TypertRemoteService {
+ static inject = ['deepseekAccount', 'productTelemetry']
+ static Config = z.object({ enabled: z.boolean().default(true).volatile(), appVersion: z.string() })
+ private active = true
+ private readonly listeners = new Set<() => void>()
+
+ constructor(ctx: Context, private readonly config: Config) {
+ super(ctx, 'productAnalytics')
+ ctx.effect(() => () => { this.active = false; for (const listener of this.listeners) listener() })
+ ctx.on('loader/volatile-update', () => { for (const listener of this.listeners) listener() })
+ ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) })
+ ctx.on('session/event', (session, event) => {
+ if (event.type !== 'compaction/start') return
+ void this.report({ eventName: 'context_compression', timestamp: Date.now(),
+ attributes: { session_id: session.id, trigger_type: event.data.turn === null ? 'manual' : 'auto' } })
+ })
+ }
+
+ /**
+ * Read the collection policy.
+ * @returns whether this Host currently accepts Desktop analytics.
+ */
+ @Remote
+ enabled(): boolean { return this.active && this.config.enabled.get() }
+
+ /**
+ * Stream the effective policy initially and after live configuration edits.
+ * @param signal - subscriber lifetime.
+ * @returns current policy values until cancellation or service disposal.
+ */
+ @Remote({ mode: 'stream' })
+ async *watchPolicy(signal: AbortSignal): AsyncIterable {
+ let update = Promise.withResolvers()
+ const changed = (): void => { update.resolve() }
+ this.listeners.add(changed)
+ signal.addEventListener('abort', changed, { once: true })
+ try {
+ while (this.active && !signal.aborted) {
+ yield this.enabled()
+ await update.promise
+ update = Promise.withResolvers()
+ }
+ } finally {
+ this.listeners.delete(changed)
+ signal.removeEventListener('abort', changed)
+ }
+ }
+
+ /**
+ * Submit selected Desktop fields; missing identity is omitted and never generated.
+ * @param event - typed product event without message contents or credentials.
+ * @returns after local submission; no delivery or warehouse acknowledgement.
+ */
+ @Remote
+ async report(event: ProductEvent): Promise {
+ if (!this.enabled()) return
+ try {
+ const identity = await this.ctx.deepseekAccount.getDeviceIdentity().catch(() => undefined)
+ if (!this.enabled()) return
+ this.ctx.productTelemetry.emit({
+ ...event, body: event.eventName,
+ attributes: {
+ ...event.attributes,
+ ...identity?.deviceId === undefined ? {} : { device_id: identity.deviceId },
+ ...identity?.userId === undefined ? {} : { user_id: identity.userId },
+ ...this.config.appVersion === undefined ? {} : { app_version: this.config.appVersion },
+ ...identity === undefined ? {} : { os_version: identity.osVersion },
+ },
+ })
+ } catch (error) {
+ this.ctx.logger.warn('Product analytics submission failed', error)
+ }
+ }
+}
diff --git a/packages/client/product-analytics/tests/collection.client.spec.ts b/packages/client/product-analytics/tests/collection.client.spec.ts
new file mode 100644
index 0000000000..bdf5e7e5c5
--- /dev/null
+++ b/packages/client/product-analytics/tests/collection.client.spec.ts
@@ -0,0 +1,121 @@
+import { Context, Service } from '@deepseek-ai/cordis'
+import { afterEach, expect, it, vi } from 'vitest'
+import type { RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client'
+import * as Analytics from '../src/client/index.ts'
+
+const cleanup: (() => Promise)[] = []
+afterEach(async () => {
+ for (const dispose of cleanup.splice(0).reverse()) await dispose()
+ vi.unstubAllGlobals()
+})
+async function setup(enabled = true) {
+ const ctx = new Context()
+ cleanup.push(() => ctx.fiber.dispose())
+ const report = vi.fn().mockResolvedValue({ ok: true, value: undefined })
+ const values = [enabled]
+ let wake: (() => void) | undefined
+ let disposed = false
+ let failure: Error | undefined
+ let options: RemoteStreamOptions | undefined
+ const stream = {
+ async *[Symbol.asyncIterator]() {
+ while (!disposed) {
+ if (failure !== undefined) throw failure
+ const value = values.shift()
+ if (value !== undefined) { yield { value, accept: () => {} }; continue }
+ await new Promise((resolve) => { wake = resolve })
+ }
+ },
+ dispose: async () => { disposed = true; wake?.() },
+ }
+ const remote = {
+ productAnalytics: { report, watchPolicy: vi.fn() },
+ $stream: (value: RemoteStreamOptions) => { options = value; return stream },
+ }
+ class TracedRemote extends Service {
+ constructor(ctx: Context) { super(ctx, 'remote') }
+ $stream = remote.$stream
+ }
+ await ctx.plugin(TracedRemote)
+ await ctx.plugin({ apply(child: Context) { child.provide('remote.productAnalytics', remote.productAnalytics as never) } })
+ await ctx.plugin(Analytics)
+ if ('dshDesktop' in globalThis) await vi.waitFor(() => { expect(ctx.productAnalytics.enabled).toBe(enabled) })
+ return { ctx, report, options: () => options!,
+ async fail() { failure = new Error('terminal'); wake?.(); await vi.waitFor(() => { expect(ctx.productAnalytics.enabled).toBe(false) }) },
+ async policy(enabled: boolean) {
+ values.push(enabled); wake?.()
+ await vi.waitFor(() => { expect(ctx.productAnalytics.enabled).toBe(enabled) })
+ } }
+}
+it('never collects in Web even when the Host advertises analytics', async () => {
+ const b = await setup()
+ expect(b.ctx.productAnalytics.enabled).toBe(false)
+ b.ctx.productAnalytics.track('desktop_app_launch', {})
+ expect(b.report).not.toHaveBeenCalled()
+})
+it('drops disabled events and has no backfill when enabled', async () => {
+ vi.stubGlobal('dshDesktop', {})
+ const b = await setup(false)
+ b.ctx.productAnalytics.track('auth_page_view', {})
+ expect(b.report).not.toHaveBeenCalled()
+ await b.policy(true)
+ b.ctx.productAnalytics.track('auth_page_click', { button_name: 'sign_in' })
+ expect(b.report).toHaveBeenCalledExactlyOnceWith({ eventName: 'auth_page_click', timestamp: expect.any(Number) as number, attributes: { button_name: 'sign_in' } })
+})
+it('does not retry or propagate transport rejection', async () => {
+ vi.stubGlobal('dshDesktop', {})
+ const b = await setup()
+ b.report.mockRejectedValueOnce(new Error('offline'))
+ b.ctx.productAnalytics.track('auth_page_view', {})
+ await Promise.resolve()
+ expect(b.report).toHaveBeenCalledTimes(1)
+})
+
+it('does not interrupt an action when Remote access fails synchronously', async () => {
+ vi.stubGlobal('dshDesktop', {})
+ const b = await setup()
+ b.report.mockImplementationOnce(() => { throw new Error('Remote namespace detached') })
+ expect(() => { b.ctx.productAnalytics.track('auth_page_view', {}) }).not.toThrow()
+ await Promise.resolve()
+ expect(b.report).toHaveBeenCalledOnce()
+})
+
+it('keeps the supplied occurrence time and stops new events on policy changes', async () => {
+ vi.stubGlobal('dshDesktop', {})
+ const b = await setup()
+ b.ctx.productAnalytics.track('auth_page_view', {}, 123)
+ expect(b.report).toHaveBeenCalledWith({ eventName: 'auth_page_view', attributes: {}, timestamp: 123 })
+ await b.policy(false)
+ b.ctx.productAnalytics.track('auth_page_view', {})
+ expect(b.report).toHaveBeenCalledOnce()
+})
+
+it('fails closed on stream loss and accepts a fresh policy after reconnection', async () => {
+ vi.stubGlobal('dshDesktop', {})
+ const b = await setup()
+ b.options().open(new AbortController().signal)
+ expect(b.options().ended(true).message).toContain('policy stream ended')
+ b.options().carrierFailed?.(new Error('offline'))
+ expect(b.ctx.productAnalytics.enabled).toBe(false)
+ await b.policy(true)
+ await b.fail()
+})
+
+it('reports from a consumer without granting it the analytics Remote namespace', async () => {
+ vi.stubGlobal('dshDesktop', {})
+ const b = await setup()
+ let click!: () => void
+ await b.ctx.plugin({
+ inject: ['remote'],
+ apply(ctx: Context) {
+ click = () => {
+ expect(ctx.get('productAnalytics')?.enabled).toBe(true)
+ ctx.get('productAnalytics')?.track('sidebar_menu_click', { menu_name: 'plugin' })
+ }
+ },
+ })
+ click()
+ expect(b.report).toHaveBeenCalledExactlyOnceWith({
+ eventName: 'sidebar_menu_click', timestamp: expect.any(Number) as number, attributes: { menu_name: 'plugin' },
+ })
+})
diff --git a/packages/client/product-analytics/tests/collection.host.spec.ts b/packages/client/product-analytics/tests/collection.host.spec.ts
new file mode 100644
index 0000000000..c7878a8940
--- /dev/null
+++ b/packages/client/product-analytics/tests/collection.host.spec.ts
@@ -0,0 +1,161 @@
+import OTel from '@deepseek-ai/dsh-otel'
+import { Context } from '@deepseek-ai/cordis'
+import { createServer } from 'node:http'
+import { once } from 'node:events'
+import { afterEach, expect, it, vi } from 'vitest'
+import ProductTelemetry, { Config as TelemetryConfig, type ProductTelemetryRecord } from '@deepseek-ai/dsh-host-product-telemetry-otel'
+import { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
+import { CompactionId } from '@deepseek-ai/dsh-compaction'
+import Analytics from '../src/index.ts'
+
+const cleanup: (() => Promise)[] = []
+afterEach(async () => { for (const dispose of cleanup.splice(0).reverse()) await dispose() })
+
+async function setup(enabled: boolean) {
+ const ctx = new Context()
+ cleanup.push(() => ctx.fiber.dispose())
+ const getDeviceIdentity = vi.fn().mockResolvedValue({ deviceId: 'login-device', userId: 'user-1', osVersion: 'fixture-os', ignored: 'private-field' })
+ const emit = vi.fn<(record: ProductTelemetryRecord) => void>()
+ ctx.provide('deepseekAccount', { getDeviceIdentity } as never)
+ ctx.provide('webServer', {} as never)
+ ctx.provide('productTelemetry', { emit } as never)
+ const fiber = await ctx.plugin(Analytics, { enabled, appVersion: 'test-version' })
+ return { ctx, fiber, getDeviceIdentity, emit }
+}
+
+it('disabled collection does not read identity or submit an event', async () => {
+ const b = await setup(false)
+ expect(b.ctx.productAnalytics.enabled()).toBe(false)
+ await b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 100, attributes: {} })
+ expect(b.getDeviceIdentity).not.toHaveBeenCalled()
+ expect(b.emit).not.toHaveBeenCalled()
+})
+
+it('reuses login identity and copies only approved common fields', async () => {
+ const b = await setup(true)
+ await b.ctx.productAnalytics.report({ eventName: 'auth_page_click', timestamp: 100, attributes: { button_name: 'sign_in' } })
+ expect(b.getDeviceIdentity).toHaveBeenCalledWith()
+ expect(b.emit).toHaveBeenCalledExactlyOnceWith({
+ eventName: 'auth_page_click', body: 'auth_page_click', timestamp: 100,
+ attributes: { button_name: 'sign_in', device_id: 'login-device', user_id: 'user-1', app_version: 'test-version', os_version: expect.any(String) as string },
+ })
+ expect(JSON.stringify(b.emit.mock.calls)).not.toContain('private-')
+})
+
+it('missing identity does not discard an otherwise valid event', async () => {
+ const b = await setup(true)
+ b.getDeviceIdentity.mockRejectedValueOnce(new Error('credential store unavailable'))
+ await b.ctx.productAnalytics.report({ eventName: 'plugin_add_button_click', timestamp: 100, attributes: {} })
+ expect(b.emit.mock.calls[0]?.[0].attributes).toEqual({ app_version: 'test-version' })
+})
+
+it('unload suppresses an identity lookup that settles after disposal', async () => {
+ const b = await setup(true)
+ const pending = Promise.withResolvers()
+ b.getDeviceIdentity.mockReturnValueOnce(pending.promise)
+ const reporting = b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 100, attributes: {} })
+ await b.fiber.dispose()
+ pending.resolve(undefined)
+ await reporting
+ expect(b.emit).not.toHaveBeenCalled()
+})
+
+it('writes the selected event through the real exporter to an isolated collector', async () => {
+ const captures: string[] = []
+ const server = createServer((req, res) => {
+ const chunks: Buffer[] = []
+ req.on('data', (chunk: Buffer) => chunks.push(chunk))
+ req.on('end', () => { captures.push(Buffer.concat(chunks).toString()); res.end('{}') })
+ })
+ server.listen(0, '127.0.0.1')
+ await once(server, 'listening')
+ cleanup.push(async () => { const done = once(server, 'close'); server.close(); server.closeAllConnections(); await done })
+ const address = server.address()
+ if (address === null || typeof address === 'string') throw new Error('missing collector port')
+ const ctx = new Context()
+ cleanup.push(() => ctx.fiber.dispose())
+ ctx.provide('credentials', { readRecord: async () => undefined } as never)
+ ctx.provide('deepseekAccount', { getDeviceIdentity: async () => undefined } as never)
+ ctx.provide('webServer', {} as never)
+ await ctx.plugin(OTel)
+ const exporter = await ctx.plugin(ProductTelemetry, TelemetryConfig({ endpoint: `http://127.0.0.1:${address.port}/v1/logs`, serviceName: 'test', serviceVersion: '1', compression: 'none' }))
+ await ctx.plugin(Analytics, { enabled: true })
+ await ctx.productAnalytics.report({ eventName: 'api_key_save_click', timestamp: 1_800_000_000_000, attributes: {} })
+ await exporter.dispose()
+ expect(captures).toHaveLength(1)
+ expect(JSON.parse(captures[0]!)).toMatchObject({ resourceLogs: [{ scopeLogs: [{ logRecords: [{ eventName: 'api_key_save_click', timeUnixNano: '1800000000000000000' }] }] }] })
+ expect(captures[0]).not.toContain('device_id')
+ expect(captures[0]).not.toContain('app_version')
+})
+
+it.each([true, false])('collects live manual and automatic compaction only when enabled: %s', async (enabled) => {
+ const b = await setup(enabled)
+ const session = Session.create(SessionId('analytics-compaction'))
+ b.ctx.emit('session/event', session, { type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } })
+ for (const turn of [null, 1]) {
+ b.ctx.emit('session/event', session, { type: 'compaction/start', seq: SessionSeq(1), time: 1,
+ data: { compactionId: CompactionId('analytics-compaction'), turn } })
+ }
+ if (enabled) await vi.waitFor(() => { expect(b.emit).toHaveBeenCalledTimes(2) })
+ expect(b.emit.mock.calls.map(([event]) => event.attributes?.trigger_type)).toEqual(enabled ? ['manual', 'auto'] : [])
+ expect(b.getDeviceIdentity).toHaveBeenCalledTimes(enabled ? 2 : 0)
+})
+
+it('omits an unavailable account and isolates exporter submission failure', async () => {
+ const b = await setup(true)
+ b.getDeviceIdentity.mockRejectedValueOnce(new Error('account unavailable'))
+ await b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 1, attributes: {} })
+ expect(b.emit.mock.calls[0]![0].attributes).not.toHaveProperty('user_id')
+ b.emit.mockImplementationOnce(() => { throw new Error('exporter unavailable') })
+ await expect(b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 2, attributes: {} })).resolves.toBeUndefined()
+})
+
+
+it('requires the exporter even when collection is disabled', async () => {
+ const ctx = new Context()
+ cleanup.push(() => ctx.fiber.dispose())
+ ctx.provide('deepseekAccount', { getDeviceIdentity: async () => undefined } as never)
+ const fiber = ctx.plugin(Analytics, { enabled: false })
+ await Promise.resolve()
+ expect(ctx.get('productAnalytics')).toBeUndefined()
+ ctx.provide('productTelemetry', { emit: vi.fn() } as never)
+ await fiber
+ expect(ctx.productAnalytics.enabled()).toBe(false)
+})
+
+it('streams live policy changes and rejects intake disabled during identity lookup', async () => {
+ const b = await setup(true)
+ const lifetime = new AbortController()
+ const iterator = b.ctx.productAnalytics.watchPolicy(lifetime.signal)[Symbol.asyncIterator]()
+ expect(await iterator.next()).toEqual({ value: true, done: false })
+ const identity = Promise.withResolvers()
+ b.getDeviceIdentity.mockReturnValueOnce(identity.promise)
+ const report = b.ctx.productAnalytics.report({ eventName: 'auth_page_view', timestamp: 1, attributes: {} })
+ const next = iterator.next()
+ // Loader commits the stable Config reference before publishing this notification.
+ const { updateVolatile, createVolatile } = await import('../../../../vendor/cosmokit/src/volatile.ts')
+ const config = b.fiber.config as import('../src/index.ts').Config
+ updateVolatile(config.enabled, createVolatile(false))
+ b.fiber.ctx.emit('loader/volatile-update', [['enabled']])
+ expect(await next).toEqual({ value: false, done: false })
+ identity.resolve(undefined)
+ await report
+ expect(b.emit).not.toHaveBeenCalled()
+ const closed = iterator.next()
+ lifetime.abort()
+ expect(await closed).toEqual({ value: undefined, done: true })
+})
+
+it('wakes pending policy readers on disposal and registers hidden settings presentation', async () => {
+ const b = await setup(true)
+ const dispose = vi.fn()
+ const configure = vi.fn(() => dispose)
+ b.ctx.provide('settings', { configure } as never)
+ await vi.waitFor(() => { expect(configure).toHaveBeenCalledWith({ auto: false }, b.fiber) })
+ const iterator = b.ctx.productAnalytics.watchPolicy(new AbortController().signal)[Symbol.asyncIterator]()
+ await iterator.next()
+ const pending = iterator.next()
+ await b.fiber.dispose()
+ expect(await pending).toEqual({ done: true, value: undefined })
+ expect(dispose).toHaveBeenCalledOnce()
+})
diff --git a/packages/client/product-analytics/tsconfig.client.json b/packages/client/product-analytics/tsconfig.client.json
new file mode 100644
index 0000000000..760e4bc222
--- /dev/null
+++ b/packages/client/product-analytics/tsconfig.client.json
@@ -0,0 +1,26 @@
+{
+ "extends": "../../../tsconfig.base.client.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types",
+ "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
+ },
+ "files": [
+ "src/client/index.ts",
+ "src/events.ts"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../api/gateway/tsconfig.client.json"
+ },
+ {
+ "path": "../../core/session"
+ },
+ {
+ "path": "../../llm/llm"
+ }
+ ]
+}
diff --git a/packages/client/product-analytics/tsconfig.host.json b/packages/client/product-analytics/tsconfig.host.json
new file mode 100644
index 0000000000..487bb221ac
--- /dev/null
+++ b/packages/client/product-analytics/tsconfig.host.json
@@ -0,0 +1,50 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types",
+ "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
+ },
+ "files": [
+ "src/index.ts",
+ "src/events.ts"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../../vendor/schemastery"
+ },
+ {
+ "path": "../../typert/protocol"
+ },
+ {
+ "path": "../../host/product-telemetry-otel"
+ },
+ {
+ "path": "../../credentials/credentials"
+ },
+ {
+ "path": "../../credentials/deepseek-account"
+ },
+ {
+ "path": "../../host/webserver"
+ },
+ {
+ "path": "../../core/session"
+ },
+ {
+ "path": "../../compaction/compaction"
+ },
+ {
+ "path": "../../llm/llm"
+ },
+ {
+ "path": "../../settings/settings"
+ },
+ {
+ "path": "../../../vendor/loader"
+ }
+ ]
+}
diff --git a/packages/client/product-analytics/tsconfig.json b/packages/client/product-analytics/tsconfig.json
new file mode 100644
index 0000000000..2eca820546
--- /dev/null
+++ b/packages/client/product-analytics/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "files": [],
+ "references": [
+ {
+ "path": "./tsconfig.host.json"
+ },
+ {
+ "path": "./tsconfig.client.json"
+ }
+ ]
+}
diff --git a/packages/client/product-analytics/tsdown.config.ts b/packages/client/product-analytics/tsdown.config.ts
new file mode 100644
index 0000000000..4479f52fe2
--- /dev/null
+++ b/packages/client/product-analytics/tsdown.config.ts
@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-product-analytics', ['lib/types/index.js'], { hostPhase: true })
diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml
index 43fe0e4ac9..f7077d438f 100644
--- a/packages/client/ui-chat/README.i18n.yaml
+++ b/packages/client/ui-chat/README.i18n.yaml
@@ -6,8 +6,8 @@
en: d88f75fc0178ba53
zh: 52bc7ae9551ab1cf
/deepseek-ai-dsh-client-ui-chat:
- en: a0be18d7053df6b0
- zh: 28c76e2cc32b7494
+ en: 02bd72c12ada3218
+ zh: 331b3a38b3ddfc6e
/deepseek-ai-dsh-client-ui-chat/summary:
en: fba5b2fb8080e2dd
zh: 32c337123b7dfbcb
diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md
index b3343fd9c3..297b1b3d69 100644
--- a/packages/client/ui-chat/README.md
+++ b/packages/client/ui-chat/README.md
@@ -6,6 +6,8 @@ kind: "package-reference"
English | [中文](README.zh.md)
+Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded.
+
## Summary
Use this package to render a browser chat from recorded Session conversations, including historical images, localized actions, and restored scroll position. Work-details modes control reasoning previews and process visibility without hiding final answers; Verbose keeps completed-turn process rows visible. Local transcript and steering submissions appear immediately, remain in their original surface, and disappear atomically when authoritative Session records arrive, while queued submissions stay outside Chat. The package does not assemble or modify model requests.
diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md
index b524f85580..d2582913b3 100644
--- a/packages/client/ui-chat/README.zh.md
+++ b/packages/client/ui-chat/README.zh.md
@@ -6,6 +6,8 @@ kind: "package-reference"
[English](README.md) | 中文
+桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。
+
## 概述
使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。工作步骤展示模式控制思考预览与过程显隐,不隐藏最终答案;完全展开模式保持已完成轮次的过程行可见。本地 transcript(文本记录)与 steering(中途引导)提交会立即显示并保留在原区域,在权威会话记录到达时原子地消失,而排队中的提交始终不进入 Chat。本包不组装或修改模型请求。
diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json
index 50b19f4368..bd51186a1b 100644
--- a/packages/client/ui-chat/package.json
+++ b/packages/client/ui-chat/package.json
@@ -92,7 +92,8 @@
"@types/react-dom": "~18.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "@deepseek-ai/dsh-settings": "workspace:*"
+ "@deepseek-ai/dsh-settings": "workspace:*",
+ "@deepseek-ai/dsh-client-product-analytics": "workspace:*"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:~"
diff --git a/packages/client/ui-chat/src/chat-settings.ts b/packages/client/ui-chat/src/chat-settings.ts
index 7335a49db5..c4c6ce3b95 100644
--- a/packages/client/ui-chat/src/chat-settings.ts
+++ b/packages/client/ui-chat/src/chat-settings.ts
@@ -15,7 +15,7 @@ export const TRANSCRIPT_VIEW_MODES = ['compact', 'standard', 'detailed', 'verbos
export type TranscriptViewMode = typeof TRANSCRIPT_VIEW_MODES[number]
/**
- * Saved value from the two-mode generation of this setting. Read as `standard`;
+ * Saved value from the two-mode generation of this setting. Read as `detailed`;
* never offered as a choice and never written back.
*/
export const LEGACY_TRANSCRIPT_VIEW_MODE = 'normal'
@@ -28,8 +28,8 @@ const TRANSCRIPT_VIEW_SETTING_VALUES = [
...TRANSCRIPT_VIEW_MODES, LEGACY_TRANSCRIPT_VIEW_MODE, LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE,
] as const
-/** Standard process summaries for users without an explicit preference. */
-export const DEFAULT_TRANSCRIPT_VIEW_MODE: TranscriptViewMode = 'standard'
+/** Default work details for non-Desktop Web clients. */
+export const DEFAULT_TRANSCRIPT_VIEW_MODE: TranscriptViewMode = 'detailed'
/** Performance and usage detail levels accepted by user settings. */
export const PERFORMANCE_USAGE_MODES = ['compact', 'detailed'] as const
@@ -48,8 +48,8 @@ export const DEFAULT_LINK_OPENING: LinkOpening = 'sidebar'
/** Durable Chat section shared by the Host schema and browser scope. */
export interface ChatSettings {
- /** Work-details preference; legacy values are accepted only from existing saved settings. */
- transcriptView: TranscriptViewMode | typeof LEGACY_TRANSCRIPT_VIEW_MODE | typeof LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE
+ /** Work-details preference; absence uses the client default, and legacy saved values remain accepted. */
+ transcriptView?: TranscriptViewMode | typeof LEGACY_TRANSCRIPT_VIEW_MODE | typeof LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE | null
/** Detail level for composer statistics and completed-Turn usage. */
performanceUsage: PerformanceUsageMode
/** Default destination for Chat HTTP(S) links. */
@@ -60,8 +60,8 @@ export interface ChatSettings {
export const ChatSettingsFields = {
linkOpening: z.union(['sidebar', 'new-tab']).default(DEFAULT_LINK_OPENING),
performanceUsage: z.union([...PERFORMANCE_USAGE_MODES]).default(DEFAULT_PERFORMANCE_USAGE),
- // Missing and unrecognized modes both use Standard.
- [TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_SETTING_VALUES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE).loose(),
+ // Missing and unrecognized modes defer to the client's default.
+ [TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_SETTING_VALUES]).loose(),
}
/** Schema for shared configuration values. */
diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts
index 8c86d6e918..15752f6c70 100644
--- a/packages/client/ui-chat/src/client/apply.ts
+++ b/packages/client/ui-chat/src/client/apply.ts
@@ -1,4 +1,5 @@
/** Register the Chat Conversation target, renderers, stats, and details surface. */
+import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
import type { Context } from '@deepseek-ai/cordis'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
@@ -36,7 +37,7 @@ import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/Tr
import { createChatStore } from './stores.ts'
import { TranscriptViewPolicy } from './transcript-view.ts'
import { derivePresentationPolicy } from './presentation-policy.ts'
-import { CHAT_SETTINGS_NAMESPACE, DEFAULT_LINK_OPENING, type ChatSettings } from '../chat-settings.ts'
+import { CHAT_SETTINGS_NAMESPACE, DEFAULT_LINK_OPENING, DEFAULT_TRANSCRIPT_VIEW_MODE, type ChatSettings } from '../chat-settings.ts'
import { LinkOpeningRow, type LinkOpeningRowInjected } from './settings/LinkOpeningRow.tsx'
import { PerformanceUsageRow, type PerformanceUsageRowInjected } from './settings/PerformanceUsageRow.tsx'
import { PerformanceUsagePolicy } from './performance-usage.ts'
@@ -145,7 +146,7 @@ export function apply(ctx: Context): void {
}),
}, LinkOpeningRow))
})
- const transcriptView = new TranscriptViewPolicy(chatSettings)
+ const transcriptView = new TranscriptViewPolicy(chatSettings, 'dshDesktop' in globalThis ? 'standard' : DEFAULT_TRANSCRIPT_VIEW_MODE)
const presentation = derivePresentationPolicy(transcriptView.mode)
const performancePolicy = new PerformanceUsagePolicy(chatSettings)
ctx.effect(() => () => { transcriptView.dispose(); performancePolicy.dispose() })
@@ -245,7 +246,11 @@ export function apply(ctx: Context): void {
read: () => chatScrollPositions.get(sessionId) ?? null,
},
forkAt: (seq) => {
- ctx.sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })
+ const turn = [...chat.getSnapshot().timeline.turns.values()].find(turn => turn.end?.seq === seq)
+ const messageId = turn?.data.get('turn-tail')?.closing?.finalNode.messageId
+ ctx.sessions.fork({ sessionId, atSeq: seq, increaseTitle: true, onCreated: (childId) => {
+ ctx.get('productAnalytics')?.track('branch_session_click', { session_id: childId, parent_session_id: sessionId, ...messageId === undefined ? {} : { parent_message_id: messageId }, click_position: 'footer' })
+ } })
.then((childId) => { ctx.uiWorkspace.openSession(childId) })
.catch(() => {
// Fork or child-title failure leaves the source view unchanged.
diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css
index 7470e195c3..cc4cc3222d 100644
--- a/packages/client/ui-chat/src/client/chat/ChatView.module.css
+++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css
@@ -101,6 +101,67 @@
line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px));
}
+.running {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ --dsh-chat-flow-gap: 12px;
+ color: var(--dsw-alias-label-deep-diving);
+ font-size: calc(var(--dsh-content-font-size, 14px) - 2px);
+ line-height: calc(22px + var(--dsh-content-font-delta, 0px));
+}
+
+/* The last visible row before the status must contain output, not an input.
+ Hidden and empty seats do not separate a user message from the status. */
+.column > :nth-last-child(2 of :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))):is(
+ .flowItem:not([data-chat-flow-kind="user"]):not([data-chat-flow-kind="steering"]):not([data-chat-flow-kind="turn-trigger"]),
+ [data-chat-group-key]
+) ~ .running > .runningDivider {
+ display: block;
+}
+
+.runningDivider {
+ display: none;
+ width: 100%;
+ height: 0.5px;
+ margin: 8px 0 10px;
+ background: color-mix(in srgb, var(--dsw-alias-border-l1) 75%, var(--dsw-alias-border-l2));
+ pointer-events: none;
+}
+
+.runningContent {
+ display: inline-flex;
+ align-items: center;
+ min-width: 0;
+ gap: 6px;
+}
+
+.runningText {
+ min-width: 0;
+ font-variant-numeric: tabular-nums;
+}
+
+.runningIcon {
+ display: inline-flex;
+ flex: none;
+ width: calc(14px + var(--dsh-content-font-delta, 0px));
+ height: calc(14px + var(--dsh-content-font-delta, 0px));
+}
+
+.runningWhaleStill {
+ display: none;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .runningWhaleAnimated {
+ display: none;
+ }
+
+ .runningWhaleStill {
+ display: initial;
+ }
+}
+
.openError {
color: var(--dsw-alias-state-error-primary);
font-size: var(--dsh-content-font-size-secondary, 13px);
diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx
index 0514068aed..460f98b014 100644
--- a/packages/client/ui-chat/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx
@@ -18,6 +18,7 @@ import { ChatGroupSeat } from './ChatGroupSeat.tsx'
import { chatRenderKey } from './render-entry.ts'
import { assertNever } from '@deepseek-ai/dsh-util-values'
import { TurnNavigator } from './TurnNavigator.tsx'
+import { RunningStatus } from './RunningStatus.tsx'
import { mergeTurnRailItems } from './turn-rail-items.ts'
import { useChatScroll } from './use-chat-scroll.ts'
import { fileMediaUrl, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
@@ -111,6 +112,13 @@ export function ChatView({
// both the data and its change signal: the array identity moves only when a
// Turn enters, leaves, or changes its preview.
const turnNavigationItems = useChat(s => s.navigation.items())
+ const latestTurnAnchor = turnNavigationItems.at(-1)?.anchorKey
+ const runningStartTime = useChatNode(latestTurnAnchor ?? '', (node) => {
+ const location = node?.location
+ return location?.kind === 'turn' || location?.kind === 'step'
+ ? location.turn.status === 'open' ? location.turn.start?.time : undefined
+ : undefined
+ })
// Host-computed whole-log outline; the merge is view-layer only (the
// conversation snapshot never carries projection values).
const turnOutline = useProjection('turnOutline')
@@ -269,6 +277,7 @@ export function ChatView({
t={t}
/>
+ {running && }
{/* No pending placeholders: questions (ui-user-questions) and approvals
(ApprovalPanel) both take over the composer, so a flow card would
double-render the same wait. */}
diff --git a/packages/client/ui-chat/src/client/chat/RunningStatus.tsx b/packages/client/ui-chat/src/client/chat/RunningStatus.tsx
new file mode 100644
index 0000000000..3d51fcf46f
--- /dev/null
+++ b/packages/client/ui-chat/src/client/chat/RunningStatus.tsx
@@ -0,0 +1,41 @@
+/** Running Turn clock isolated from the transcript's render cycle. */
+import { memo, useEffect, useState } from 'react'
+import { TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ChatViewSlotProps } from '../contract/slots.ts'
+import { formatLiveRunDuration, LIVE_RUN_CLOCK_INTERVAL_MS } from './message-chrome.ts'
+import { RunningWhaleTail } from './RunningWhaleTail.tsx'
+import a11yCss from './accessibility.module.css'
+import css from './ChatView.module.css'
+
+interface RunningStatusProps {
+ readonly startTime: number | undefined
+ readonly t: ChatViewSlotProps['t']
+}
+
+/**
+ * Show live elapsed time after the current Turn's content without announcing ticks.
+ * @param props - Current Turn start time and localized copy.
+ * @returns the blue running indicator; mount only while the Session is running.
+ */
+export const RunningStatus = memo(function RunningStatus({ startTime, t }: RunningStatusProps) {
+ const [now, setNow] = useState(Date.now)
+ useEffect(() => {
+ if (startTime === undefined) return
+ setNow(Date.now())
+ const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS)
+ return () => { clearInterval(timer) }
+ }, [startTime])
+ const label = startTime === undefined ? t('chat.deepDiving') : t('chat.deepDivingFor', {
+ duration: formatLiveRunDuration(Math.max(1000, now - startTime), t),
+ })
+ return (
+
+ {t('chat.deepDiving')}
+
+
+
+ {label}
+
+
+ )
+})
diff --git a/packages/client/ui-chat/src/client/chat/RunningWhaleTail.tsx b/packages/client/ui-chat/src/client/chat/RunningWhaleTail.tsx
new file mode 100644
index 0000000000..0f3797f62c
--- /dev/null
+++ b/packages/client/ui-chat/src/client/chat/RunningWhaleTail.tsx
@@ -0,0 +1,49 @@
+/** Native SVG contour animation for the whale Chat shows while the Session runs. */
+import css from './ChatView.module.css'
+
+const REST_PATH = 'M8.844 13.742C8.967 12.328 8.45 10.4 8.45 9.65C8.45 8.94 8.88 8.43 9.6 8.43C11.285 8.43 12.106 8.281 12.685 8.104C13.71 7.791 14.585 6.768 15.055 5.945C15.137 5.803 14.99 5.641 14.829 5.671C13.829 5.86 12.828 5.376 11.827 4.978C10.659 4.514 9.491 4.707 8.935 4.876C8.805 4.915 8.658 4.819 8.636 4.686C8.468 3.643 7.405 2.615 5.498 2.238C4.54 2.048 3.748 1.574 3.347 1.202C3.252 1.113 3.088 1.125 3.03 1.242C2.628 2.059 2.168 3.82 5.248 6.115C5.82 6.494 6.31 6.785 6.574 7.637C6.72 8.104 6.157 9.168 6.061 9.368C5.157 11.27 5.089 12.19 4.926 13.742'
+
+/** Same cubic segments in every pose keep both tail-base endpoints stationary. */
+const MOTION_PATHS = [
+ REST_PATH,
+ REST_PATH,
+ 'M8.844 13.742C8.967 12.328 8.453 10.404 8.455 9.655C8.457 8.946 8.89 8.438 9.61 8.441C11.295 8.448 12.116 8.302 12.696 8.127C13.722 7.818 14.601 6.796 15.074 5.97C15.156 5.827 15.01 5.663 14.849 5.693C13.848 5.881 12.848 5.391 11.849 4.989C10.681 4.521 9.513 4.712 8.956 4.88C8.827 4.919 8.679 4.823 8.658 4.689C8.492 3.645 7.429 2.615 5.523 2.237C4.565 2.047 3.773 1.572 3.373 1.201C3.277 1.112 3.113 1.124 3.056 1.241C2.653 2.057 2.193 3.808 5.267 6.11C5.837 6.49 6.326 6.782 6.587 7.636C6.73 8.103 6.164 9.165 6.068 9.365C5.157 11.265 5.089 12.19 4.926 13.742',
+ 'M8.844 13.742C8.967 12.328 8.476 10.437 8.498 9.697C8.519 8.997 8.969 8.506 9.689 8.533C11.373 8.596 12.198 8.481 12.784 8.327C13.82 8.055 14.734 7.049 15.232 6.219C15.318 6.075 15.177 5.903 15.015 5.93C14.009 6.092 13.025 5.562 12.037 5.128C10.883 4.62 9.709 4.785 9.148 4.939C9.018 4.975 8.873 4.874 8.856 4.739C8.717 3.685 7.676 2.629 5.778 2.213C4.824 2.004 4.041 1.515 3.649 1.136C3.555 1.045 3.391 1.054 3.331 1.17C2.912 1.978 2.423 3.678 5.425 6.063C5.984 6.458 6.463 6.766 6.695 7.627C6.823 8.099 6.221 9.144 6.12 9.342C5.154 11.226 5.089 12.19 4.926 13.742',
+ 'M8.844 13.742C8.967 12.328 8.473 10.432 8.492 9.69C8.51 8.989 8.957 8.495 9.676 8.519C11.361 8.573 12.186 8.453 12.771 8.298C13.805 8.023 14.714 7.034 15.213 6.234C15.299 6.095 15.158 5.929 14.996 5.954C13.99 6.106 13.007 5.59 12.021 5.158C10.87 4.655 9.696 4.804 9.134 4.952C9.003 4.987 8.859 4.886 8.842 4.752C8.712 3.706 7.688 2.639 5.797 2.19C4.847 1.964 4.073 1.459 3.688 1.072C3.595 0.98 3.431 0.986 3.369 1.101C2.936 1.901 2.407 3.656 5.406 6.06C5.964 6.459 6.443 6.767 6.679 7.628C6.808 8.099 6.212 9.147 6.111 9.346C5.155 11.233 5.089 12.19 4.926 13.742',
+ 'M8.844 13.742C8.967 12.328 8.457 10.41 8.463 9.662C8.469 8.955 8.904 8.45 9.624 8.458C11.309 8.475 12.131 8.335 12.713 8.167C13.741 7.87 14.627 6.885 15.113 6.112C15.198 5.978 15.055 5.821 14.893 5.846C13.889 6.004 12.898 5.52 11.908 5.109C10.752 4.629 9.579 4.78 9.019 4.932C8.889 4.967 8.744 4.868 8.726 4.736C8.587 3.701 7.565 2.64 5.676 2.183C4.726 1.954 3.954 1.444 3.57 1.055C3.479 0.962 3.314 0.967 3.252 1.082C2.815 1.881 2.27 3.708 5.305 6.084C5.87 6.477 6.353 6.777 6.607 7.633C6.747 8.102 6.174 9.161 6.077 9.361C5.156 11.258 5.089 12.19 4.926 13.742',
+ 'M8.844 13.742C8.967 12.328 8.425 10.366 8.403 9.607C8.383 8.888 8.794 8.361 9.513 8.335C11.197 8.276 12.012 8.096 12.585 7.897C13.598 7.547 14.436 6.515 14.883 5.708C14.961 5.569 14.81 5.417 14.65 5.45C13.655 5.661 12.641 5.221 11.631 4.858C10.452 4.434 9.289 4.648 8.737 4.828C8.608 4.871 8.459 4.779 8.434 4.647C8.242 3.618 7.163 2.612 5.252 2.261C4.29 2.084 3.492 1.618 3.087 1.252C2.99 1.164 2.826 1.178 2.77 1.296C2.378 2.118 1.937 3.949 5.086 6.167C5.669 6.531 6.167 6.806 6.461 7.649C6.623 8.111 6.097 9.191 6.008 9.393C5.161 11.31 5.089 12.19 4.926 13.742',
+ 'M8.844 13.742C8.967 12.328 8.426 10.368 8.407 9.61C8.388 8.892 8.8 8.365 9.52 8.342C11.204 8.286 12.019 8.107 12.592 7.907C13.605 7.553 14.443 6.483 14.878 5.614C14.954 5.464 14.8 5.302 14.641 5.34C13.649 5.577 12.629 5.12 11.611 4.759C10.422 4.338 9.264 4.59 8.716 4.786C8.589 4.832 8.437 4.742 8.409 4.608C8.193 3.567 7.074 2.592 5.15 2.321C4.183 2.185 3.365 1.758 2.945 1.409C2.845 1.326 2.682 1.347 2.631 1.467C2.275 2.305 1.917 4.029 5.086 6.183C5.673 6.536 6.174 6.807 6.469 7.649C6.63 8.11 6.101 9.189 6.012 9.391C5.16 11.307 5.089 12.19 4.926 13.742',
+ 'M8.844 13.742C8.967 12.328 8.439 10.385 8.43 9.631C8.421 8.917 8.843 8.399 9.563 8.388C11.248 8.362 12.066 8.198 12.642 8.008C13.661 7.672 14.517 6.601 14.963 5.717C15.04 5.564 14.889 5.396 14.729 5.433C13.735 5.663 12.719 5.181 11.705 4.804C10.52 4.364 9.36 4.611 8.81 4.803C8.682 4.847 8.531 4.755 8.505 4.621C8.297 3.572 7.181 2.589 5.257 2.315C4.29 2.178 3.473 1.751 3.053 1.402C2.953 1.319 2.79 1.34 2.739 1.46C2.382 2.297 2.024 3.975 5.165 6.16C5.747 6.519 6.244 6.796 6.524 7.643C6.678 8.106 6.131 9.178 6.038 9.379C5.159 11.288 5.089 12.19 4.926 13.742',
+ 'M8.844 13.742C8.967 12.328 8.462 10.417 8.472 9.671C8.481 8.965 8.921 8.464 9.64 8.476C11.325 8.505 12.149 8.371 12.731 8.204C13.761 7.908 14.653 6.889 15.135 6.057C15.218 5.913 15.074 5.746 14.913 5.775C13.91 5.954 12.916 5.448 11.919 5.034C10.757 4.551 9.586 4.735 9.028 4.899C8.898 4.937 8.752 4.839 8.732 4.705C8.575 3.656 7.517 2.618 5.613 2.231C4.655 2.037 3.865 1.56 3.467 1.186C3.372 1.097 3.208 1.108 3.149 1.225C2.743 2.039 2.278 3.763 5.327 6.093C5.893 6.478 6.379 6.776 6.629 7.632C6.766 8.101 6.186 9.157 6.088 9.356C5.156 11.25 5.089 12.19 4.926 13.742',
+ 'M8.844 13.742C8.967 12.328 8.463 10.418 8.474 9.673C8.485 8.968 8.925 8.468 9.645 8.482C11.33 8.513 12.153 8.381 12.736 8.218C13.766 7.929 14.661 6.94 15.152 6.153C15.237 6.017 15.095 5.856 14.933 5.881C13.928 6.038 12.94 5.541 11.951 5.122C10.796 4.633 9.623 4.786 9.062 4.937C8.932 4.972 8.787 4.873 8.77 4.739C8.633 3.699 7.608 2.638 5.717 2.189C4.766 1.963 3.993 1.457 3.607 1.07C3.515 0.977 3.351 0.983 3.288 1.098C2.855 1.899 2.321 3.692 5.344 6.075C5.906 6.47 6.388 6.773 6.635 7.631C6.771 8.101 6.189 9.155 6.09 9.355C5.156 11.248 5.089 12.19 4.926 13.742',
+ 'M8.844 13.742C8.967 12.328 8.436 10.381 8.425 9.626C8.414 8.911 8.833 8.391 9.553 8.377C11.238 8.344 12.055 8.178 12.631 7.987C13.649 7.65 14.502 6.601 14.953 5.753C15.031 5.607 14.88 5.446 14.72 5.48C13.725 5.696 12.712 5.229 11.702 4.853C10.522 4.414 9.359 4.64 8.807 4.824C8.679 4.867 8.529 4.774 8.504 4.641C8.308 3.599 7.214 2.601 5.297 2.282C4.333 2.122 3.527 1.674 3.115 1.315C3.017 1.229 2.854 1.246 2.799 1.365C2.423 2.193 2.02 3.939 5.152 6.154C5.733 6.518 6.229 6.797 6.512 7.644C6.666 8.107 6.124 9.18 6.032 9.382C5.159 11.292 5.089 12.19 4.926 13.742',
+ 'M8.844 13.742C8.967 12.328 8.449 10.399 8.448 9.648C8.447 8.938 8.876 8.427 9.596 8.426C11.282 8.423 12.102 8.272 12.681 8.092C13.705 7.774 14.577 6.731 15.041 5.876C15.121 5.728 14.973 5.562 14.812 5.595C13.813 5.8 12.808 5.309 11.802 4.916C10.628 4.456 9.462 4.671 8.909 4.849C8.78 4.89 8.631 4.795 8.608 4.661C8.425 3.613 7.339 2.602 5.424 2.27C4.461 2.103 3.658 1.649 3.249 1.287C3.152 1.201 2.988 1.217 2.933 1.335C2.55 2.161 2.137 3.872 5.235 6.128C5.81 6.5 6.302 6.787 6.569 7.638C6.716 8.104 6.154 9.168 6.059 9.369C5.157 11.272 5.089 12.19 4.926 13.742',
+ 'M8.844 13.742C8.967 12.328 8.472 10.431 8.491 9.69C8.509 8.988 8.956 8.494 9.676 8.517C11.36 8.571 12.185 8.451 12.77 8.294C13.804 8.016 14.712 7.011 15.207 6.188C15.293 6.046 15.151 5.876 14.989 5.903C13.984 6.066 12.998 5.545 12.008 5.115C10.853 4.614 9.68 4.779 9.119 4.934C8.989 4.97 8.844 4.869 8.826 4.735C8.686 3.684 7.646 2.629 5.748 2.211C4.793 2.001 4.012 1.511 3.62 1.13C3.526 1.039 3.362 1.048 3.302 1.164C2.882 1.971 2.388 3.691 5.399 6.068C5.96 6.463 6.44 6.768 6.677 7.628C6.807 8.099 6.211 9.147 6.111 9.346C5.155 11.233 5.089 12.19 4.926 13.742',
+ 'M8.844 13.742C8.967 12.328 8.47 10.428 8.487 9.686C8.503 8.983 8.949 8.488 9.668 8.509C11.353 8.558 12.178 8.435 12.762 8.279C13.795 8.002 14.701 7.021 15.2 6.237C15.286 6.102 15.145 5.939 14.983 5.963C13.977 6.11 12.994 5.602 12.009 5.173C10.859 4.672 9.685 4.813 9.122 4.959C8.992 4.993 8.848 4.892 8.832 4.758C8.705 3.717 7.69 2.645 5.804 2.178C4.855 1.943 4.087 1.43 3.705 1.039C3.613 0.946 3.449 0.95 3.386 1.064C2.945 1.861 2.395 3.646 5.393 6.06C5.951 6.46 6.43 6.768 6.668 7.628C6.799 8.1 6.206 9.149 6.106 9.348C5.155 11.237 5.089 12.19 4.926 13.742',
+ 'M8.844 13.742C8.967 12.328 8.457 10.409 8.462 9.662C8.468 8.954 8.903 8.449 9.623 8.456C11.308 8.472 12.13 8.332 12.711 8.164C13.739 7.866 14.625 6.882 15.111 6.111C15.195 5.978 15.052 5.821 14.891 5.846C13.886 6.003 12.896 5.521 11.906 5.11C10.75 4.63 9.577 4.781 9.017 4.932C8.886 4.967 8.742 4.869 8.724 4.736C8.585 3.702 7.564 2.64 5.675 2.182C4.725 1.952 3.954 1.441 3.571 1.052C3.479 0.959 3.315 0.964 3.252 1.078C2.815 1.877 2.267 3.708 5.303 6.084C5.867 6.477 6.351 6.777 6.606 7.633C6.745 8.102 6.173 9.161 6.076 9.361C5.156 11.259 5.089 12.19 4.926 13.742',
+ 'M8.844 13.742C8.967 12.328 8.432 10.376 8.418 9.62C8.404 8.904 8.82 8.382 9.54 8.364C11.225 8.323 12.041 8.152 12.616 7.961C13.633 7.623 14.483 6.603 14.94 5.806C15.019 5.669 14.87 5.516 14.71 5.547C13.712 5.744 12.704 5.295 11.699 4.92C10.525 4.482 9.359 4.68 8.805 4.853C8.676 4.894 8.527 4.801 8.504 4.668C8.325 3.638 7.262 2.618 5.355 2.239C4.397 2.048 3.605 1.57 3.206 1.198C3.11 1.109 2.946 1.12 2.888 1.238C2.485 2.054 2.016 3.888 5.137 6.146C5.716 6.517 6.211 6.799 6.496 7.645C6.652 8.109 6.115 9.184 6.024 9.385C5.159 11.298 5.089 12.19 4.926 13.742',
+ 'M8.844 13.742C8.967 12.328 8.429 10.371 8.411 9.614C8.394 8.897 8.808 8.372 9.528 8.351C11.212 8.301 12.028 8.125 12.602 7.927C13.616 7.578 14.458 6.518 14.899 5.663C14.975 5.515 14.823 5.354 14.664 5.391C13.67 5.619 12.653 5.161 11.638 4.796C10.453 4.37 9.293 4.61 8.743 4.801C8.616 4.846 8.465 4.755 8.438 4.622C8.23 3.582 7.123 2.596 5.202 2.303C4.236 2.156 3.424 1.718 3.007 1.364C2.908 1.28 2.745 1.299 2.692 1.419C2.326 2.252 1.947 3.995 5.104 6.173C5.689 6.531 6.188 6.804 6.479 7.648C6.639 8.11 6.107 9.187 6.017 9.389C5.16 11.304 5.089 12.19 4.926 13.742',
+ 'M8.844 13.742C8.967 12.328 8.438 10.384 8.428 9.629C8.419 8.915 8.839 8.397 9.559 8.384C11.244 8.356 12.062 8.191 12.638 8.001C13.657 7.664 14.512 6.597 14.959 5.721C15.036 5.57 14.884 5.403 14.725 5.44C13.73 5.667 12.715 5.189 11.701 4.813C10.518 4.373 9.357 4.616 8.807 4.806C8.679 4.851 8.528 4.759 8.502 4.625C8.296 3.577 7.184 2.591 5.262 2.309C4.295 2.167 3.481 1.736 3.062 1.385C2.962 1.301 2.799 1.321 2.748 1.441C2.387 2.276 2.02 3.969 5.16 6.159C5.742 6.519 6.239 6.797 6.52 7.643C6.674 8.107 6.128 9.179 6.036 9.38C5.159 11.289 5.089 12.19 4.926 13.742',
+ 'M8.844 13.742C8.967 12.328 8.45 10.4 8.45 9.65C8.451 8.941 8.881 8.431 9.601 8.431C11.286 8.432 12.107 8.283 12.686 8.107C13.711 7.796 14.587 6.785 15.061 5.98C15.143 5.841 14.997 5.681 14.836 5.71C13.835 5.89 12.836 5.41 11.838 5.011C10.673 4.544 9.503 4.725 8.946 4.89C8.816 4.928 8.67 4.832 8.649 4.698C8.488 3.659 7.437 2.622 5.536 2.221C4.579 2.02 3.793 1.534 3.398 1.157C3.303 1.067 3.139 1.077 3.08 1.193C2.667 2.005 2.182 3.794 5.253 6.109C5.824 6.491 6.312 6.784 6.576 7.636C6.72 8.104 6.157 9.167 6.062 9.368C5.157 11.27 5.089 12.19 4.926 13.742',
+ REST_PATH,
+ REST_PATH,
+].join(';')
+const MOTION_TIMES = '0;0.028571;0.057143;0.095238;0.114286;0.133333;0.171429;0.2;0.219048;0.257143;0.285714;0.380952;0.409524;0.447619;0.47619;0.495238;0.52381;0.552381;0.571429;0.638095;0.8;1'
+
+/**
+ * Loop the tail every three seconds after a 0.3s delay.
+ * @returns animated and resting contours switched by the reduced-motion stylesheet.
+ */
+export function RunningWhaleTail() {
+ return (
+
+
+
+ )
+}
diff --git a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx
index 71dc44693b..d1e0fc5eae 100644
--- a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx
+++ b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx
@@ -1,8 +1,8 @@
-import { memo, useEffect, useState } from 'react'
+import { memo } from 'react'
import { IconChevronDownOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatNodeViewProps } from '../contract/slots.ts'
import { turnProcessAlwaysOpen } from '../contract/turn-process.ts'
-import { formatLiveRunDuration, formatRunDuration, LIVE_RUN_CLOCK_INTERVAL_MS } from './message-chrome.ts'
+import { formatRunDuration } from './message-chrome.ts'
import a11yCss from './accessibility.module.css'
import css from './TurnProcessNodeView.module.css'
@@ -15,33 +15,21 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
? node.location.turn
: undefined
- const [now, setNow] = useState(Date.now)
- const ticking = turn?.status === 'open' && turn.start !== undefined
- useEffect(() => {
- if (!ticking) return
- setNow(Date.now())
- const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS)
- return () => { clearInterval(timer) }
- }, [ticking])
- if (turn?.start === undefined && turn?.status !== 'closed') return null
+ if (turn?.status !== 'closed') return null
const canCollapse = turnProcess.foldable && turnProcess.hasContent && !turnProcessAlwaysOpen(node)
- const running = turn.status === 'open'
const reason = turn.end?.data.reason.kind
- const elapsedMs = turn.start === undefined ? undefined
- : Math.max(1000, (turn.end?.time ?? now) - turn.start.time)
+ const elapsedMs = turn.start === undefined || turn.end === undefined ? undefined
+ : Math.max(1000, turn.end.time - turn.start.time)
const duration = elapsedMs === undefined ? undefined
- : running ? formatLiveRunDuration(elapsedMs, t) : formatRunDuration(elapsedMs, t)
+ : formatRunDuration(elapsedMs, t)
// Other end reasons retain elapsed time; only cancellation and failure replace it.
- const label = running
- ? duration === undefined ? t('chat.deepDiving') : t('message.turnProcess.deepDivingFor', { duration })
- : reason === 'aborted' ? t('message.stopped')
- : reason === 'error' ? t('message.turnProcess.failed')
- : duration === undefined ? t('message.turnProcess.worked')
- : t('message.turnProcess.took', { duration })
- const announcement = running ? t('chat.deepDiving')
- : reason === 'aborted' ? t('message.stopped')
- : reason === 'error' ? t('message.turnProcess.failed')
- : t('message.turnProcess.worked')
+ const label = reason === 'aborted' ? t('message.stopped')
+ : reason === 'error' ? t('message.turnProcess.failed')
+ : duration === undefined ? t('message.turnProcess.worked')
+ : t('message.turnProcess.took', { duration })
+ const announcement = reason === 'aborted' ? t('message.stopped')
+ : reason === 'error' ? t('message.turnProcess.failed')
+ : t('message.turnProcess.worked')
return (
<>
{announcement}
diff --git a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx
index 7df6e718d0..afa2106ca9 100644
--- a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx
+++ b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx
@@ -1,7 +1,7 @@
/** An independent, expandable notice explaining a non-human Turn trigger. */
import { useId, useState, type ComponentType } from 'react'
import {
- IconAgentPresetOutlineRegular, IconAlarmClockOutlineRegular, IconBranchOutlineRegular,
+ IconAgentPresetOutlineRegular, IconClockOutlineRegular, IconBranchOutlineRegular,
IconChevronDownOutlineRegular, IconContextInjectionOutlineRegular, IconCordisPluginOutlineRegular,
IconGoalOutlineRegular, IconGlobeOutlineRegular, IconPaperPlaneOutlineRegular, IconQueueOutlineRegular,
type IconProps,
@@ -20,7 +20,7 @@ const TRIGGER_ICONS: Record> = {
subagent: IconAgentPresetOutlineRegular,
github: IconBranchOutlineRegular,
webhook: IconGlobeOutlineRegular,
- schedule: IconAlarmClockOutlineRegular,
+ schedule: IconClockOutlineRegular,
job: IconQueueOutlineRegular,
plugin: IconCordisPluginOutlineRegular,
}
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml
index 3017e3eaed..7255b38160 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml
+++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml
@@ -24,14 +24,17 @@
en: 91b35cbebe8c2a1d
zh: a4fee6d7102bc874
/chat-conversation-node-rules/whole-turn-folding:
- en: 8db4991626210708
- zh: e18c8aa3cb434980
+ en: 69989cc6a42c137a
+ zh: ceca703426315b88
/chat-conversation-node-rules/display-modes:
- en: 42342a06ef189075
- zh: 1a554b50125fcdda
+ en: ada5a60d2c46f6a8
+ zh: f3eaeb5cb4d9b3de
+/chat-conversation-node-rules/display-modes/defaults-and-saving-preferences:
+ en: a47e16132ba3c736
+ zh: fbed2b6f43a93287
/chat-conversation-node-rules/display-modes/group-title-rules:
- en: a6d29a5ea3fd2362
- zh: dbd07bf82cdd61b1
+ en: 49fc1aa60cbd239e
+ zh: 44b0f9a96ec5b112
/chat-conversation-node-rules/process-grouping:
en: 276a97b95610f5e8
zh: 7627b2afb0a7c0f4
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md
index 12c63b5497..da4d691a00 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/README.md
+++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md
@@ -78,7 +78,7 @@ The Turn control follows all its opening inputs, including human steering and no
| Non-human message in an idle `next-step` claim | Turn-trigger notice only in Step 1, with a loaded Turn start, a claim after that start, no `next-turn` claim in this Turn, and no human in the same `next-step` batch. |
| Other non-human input | Ordinary Context, retained in the Node Store but omitted from Chat. Unclaimed, canceled, or requeued messages do not establish a waking claim. |
-Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs`, and `cordis-host-runner` identify scheduled work, background work, and plugin updates. Goal, agent, team, subagent, and webhook sources have their own titles; a webhook with `provider: github` uses the GitHub title. Unknown sources use the generic execution-request title. Expanding the notice shows its recorded body; it does not imply successful execution.
+Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs`, and `cordis-host-runner` identify scheduled work, background work, and plugin updates. Schedule notices are labeled Automation task and use the same plain clock icon as the Automation tasks entry. Goal, agent, team, subagent, and webhook sources have their own titles; a webhook with `provider: github` uses the GitHub title. Unknown sources use the generic execution-request title. Expanding the notice shows its recorded body; it does not imply successful execution.
| Turn condition | Current behavior |
|---|---|
@@ -99,7 +99,7 @@ Clicking Load older anchors the first visible content item below that button in
Paging adds older content above the retained anchor without jumping to the new top. A capped group absorbs the displacement within its scroll range; the outer transcript absorbs the remainder, including when the group first reaches its cap. If the available scroll range is insufficient, compensation stops at the actual limit without adding bottom space. Later content growth keeps the same anchor until a reading gesture or explicit navigation releases it. Typing or clicking within the composer and non-scrolling transcript keys retain the anchor. If the reader scrolls while a page is loading, scrolling takes priority and its settled reading position becomes the new paging anchor.
-A running clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. Completion fixes the duration; cancellation and failure replace it with their status. Lifecycle changes have a polite announcement; clock ticks do not. This control is Chat's only Turn-level running indicator.
+While the Session runs, Chat appends a Session-level indicator at the bottom of the current transcript, after pending submission or steering rows: a whale tail beside shimmering elapsed time. Before the current Turn start time arrives, the indicator shows status text without a duration. The clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. When the Session stops running, the indicator disappears; the closed Turn process control shows the fixed duration, cancellation, or failure status. Lifecycle changes have a polite announcement; clock ticks do not. The whale is hidden from assistive technology and remains static under reduced motion.
Automatic collapse keeps the process open if hiding it would hide keyboard focus. Manual closing focuses the process control before hiding its members. Closing a whole Turn resets its groups and inner reasoning/tool disclosures; it does not reset unrelated renderer state. Browser find can reveal searchable hidden content.
@@ -108,7 +108,7 @@ Automatic collapse keeps the process open if hiding it would hide keyboard focus
## Display modes
-Settings → General → Work details offers `compact`, `standard` (default), `detailed`, and `verbose`; its description is “Choose how much detail to show for tool calls”. A saved `normal` reads as `standard`, and a saved `expanded` reads as `detailed`, without automatic write-back. Existing `detailed` remains `detailed`. Missing or invalid values, including the period before Host settings arrive, use `standard`; invalid values in other settings still fail validation.
+Settings → General → Work details offers four modes for choosing how much tool-call detail to show: Compact `compact`, Standard `standard`, Detailed `detailed`, and Verbose `verbose`.
| Behavior | Compact | Standard | Detailed | Verbose |
|---|---|---|---|---|
@@ -118,10 +118,20 @@ Settings → General → Work details offers `compact`, `standard` (default), `d
| Individual reasoning and tool bodies | Manual expansion | Manual expansion | Manual expansion | Manual expansion |
| Eligible completed Turn | Initially collapsed | Initially collapsed | Initially collapsed | Always open; duration/status header cannot collapse it |
-A closed group's header names the first three categories from its ranked summary, without displaying counts. A group without categories uses the thinking label. A running header names its live tool category, otherwise thinking; Standard appends live detail. Live titles remain visible for at least 150ms, retaining only the newest pending title.
+### Defaults and saving preferences
+
+Work details uses the Host setting `ui-chat.transcriptView`. The client reads it according to these rules:
+
+- Saved values for the four modes are used directly, regardless of changes to defaults.
+- Legacy `normal` and `expanded` values display as `detailed`, without automatically rewriting the saved values on disk.
+- When the setting is missing, `null`, or invalid, Desktop uses `standard` and non-Desktop Web uses `detailed`. Each client also uses its default before Host settings arrive; invalid values in other settings still fail validation.
+
+Reading a default does not automatically save configuration. When the user changes modes, the selected value is saved through Host settings; memory mode does not write to disk. Desktop onboarding writes preferences according to its rules when completed or skipped.
### Group-title rules
+A closed group's header names the first three categories from its ranked summary, without displaying counts. A group without categories uses the thinking label. A running header names its live tool category, otherwise thinking; Standard appends live detail. Live titles remain visible for at least 150ms, retaining only the newest pending title.
+
All three stages share the tool-name classification below. A preparing Tool node uses its category's preparation label: read files for `read`, read images for `read_image`, write files for `write`, edit files for `edit` and `apply_patch`, and update the plan for `todo_write` and goal tools. Only the generic “Preparing tool calls” category appends the wire tool name in Standard mode; other categories omit it. It contributes one call without parsing arguments and renders one non-expandable row. A named live delta can create this node; historical calls start directly from tool/call without replaying preparation.
The labels below describe recorded activity, not successful outcomes. For example, a failed read still participates in the “Read files” category.
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md
index 341c2826a1..ab4b3c05f4 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md
+++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md
@@ -78,7 +78,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
| 空闲时从 `next-step` 领取的非人工消息 | 仅在 Step 1、已加载轮次起点、领取发生于起点之后、本轮没有 `next-turn` 领取、同一 `next-step` 批次没有人工消息时,作为轮次触发通知。 |
| 其他非人工输入 | 普通 Context,保留在 Node Store,但不显示在 Chat 中。未领取、已取消或重新入队的消息不构成唤醒领取。 |
-触发通知的标题与图标使用记录中的 `source.kind`:`schedule`、`tool-jobs`、`cordis-host-runner` 分别表示定时任务、后台任务和插件更新。目标、Agent、团队、子任务和 webhook 来源有各自的标题;`provider: github` 的 webhook 使用 GitHub 标题。未知来源显示通用执行请求标题。展开通知显示记录的正文,不代表执行成功。
+触发通知的标题与图标使用记录中的 `source.kind`:`schedule`、`tool-jobs`、`cordis-host-runner` 分别表示定时任务、后台任务和插件更新。定时任务通知显示为「自动化任务」,与「自动化任务」入口使用同一个普通时钟图标。目标、Agent、团队、子任务和 webhook 来源有各自的标题;`provider: github` 的 webhook 使用 GitHub 标题。未知来源显示通用执行请求标题。展开通知显示记录的正文,不代表执行成功。
| 轮次条件 | 当前行为 |
|---|---|
@@ -99,7 +99,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
分页把更早内容加到保留的锚点上方,不主动跳到新内容顶部。限高组先在自己的滚动范围内吸收位移,剩余部分由外层文本滚动补偿,包括组正文刚达到限高的情况。可滚动范围不足时,补偿停在实际边界,不增加底部空白。后续内容撑高沿用同一个锚点,直到阅读手势或显式导航释放它。在 composer 中打字或点击,以及 transcript 内的非滚动按键,保留锚点。分页请求期间若读者滚动,优先服从滚动,停止后以新的阅读位置作为分页锚点。
-运行时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。完成后固定时长,取消与失败使用对应状态替代。生命周期变化有礼貌播报,时钟跳动不播报。该控件是 Chat 唯一的轮次级运行指示。
+会话运行期间,Chat 在当前 transcript 最底部、待提交或 steering 行之后附加 Session 级运行指示:鲸鱼摆尾旁显示闪烁的已用时间。当前 Turn 的开始时间到达前,运行指示只显示状态文字,不显示时长。时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。会话停止运行后,该指示消失;已结束 Turn 的过程控件显示固定时长、取消或失败状态。生命周期变化有礼貌播报,时钟跳动不播报。鲸鱼对辅助技术隐藏,启用减少动态效果时保持静态。
自动收起若会隐藏键盘焦点,则保持过程展开。手动收起先将焦点移到过程控件,再隐藏成员。收起整轮会重置内部组及推理、工具的开合,不重置渲染器的其他状态。浏览器查找可以显示可搜索的隐藏内容。
@@ -108,7 +108,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
## 展示模式
-设置 → 通用设置 → 工作步骤展示提供 `compact`、`standard`(默认)、`detailed` 和 `verbose`,说明为「选择希望看到多少工具调用细节」。旧值 `normal` 读取为 `standard`,`expanded` 读取为 `detailed`,不自动回写;已有 `detailed` 保持不变。未配置、无效值以及 Host 设置到达前均使用 `standard`;其他设置的无效值仍会校验失败。
+设置 → 通用设置 → 工作步骤展示提供简洁 `compact`、标准 `standard`、详细 `detailed` 和完全展开 `verbose` 四种模式,用于选择希望看到多少工具调用细节。
| 行为 | 简洁 | 标准 | 详细 | 完全展开 |
|---|---|---|---|---|
@@ -118,10 +118,20 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
| 单个推理与工具正文 | 手动展开 | 手动展开 | 手动展开 | 手动展开 |
| 符合条件的已完成轮次 | 初始收起 | 初始收起 | 初始收起 | 始终展开;时长或状态抬头不能收起它 |
-已结束组的组头展示排序摘要的前三类,不显示次数;没有类别时使用思考标题。运行中的组头展示实时工具类别,没有运行工具时使用思考标题;标准模式附加实时详情。实时标题至少保留 150ms,只保留最新待显示标题。
+### 默认值与配置保存
+
+工作步骤展示对应 Host 设置 `ui-chat.transcriptView`,由客户端按以下规则读取:
+
+- 已保存的四种模式直接使用,不受默认值变化影响。
+- 旧值 `normal` 和 `expanded` 均按 `detailed` 展示,不自动改写磁盘中的值。
+- 未配置、值为 `null` 或无效时,Desktop 使用 `standard`,非 Desktop Web 使用 `detailed`。Host 设置到达前也使用各自的默认值;其他设置的无效值仍会校验失败。
+
+读取默认值不会自动保存配置。用户切换模式时,通过 Host 设置保存所选值;内存模式不写磁盘。Desktop onboarding 完成或跳过时,按引导规则写入偏好。
### 组标题规则
+已结束组的组头展示排序摘要的前三类,不显示次数;没有类别时使用思考标题。运行中的组头展示实时工具类别,没有运行工具时使用思考标题;标准模式附加实时详情。实时标题至少保留 150ms,只保留最新待显示标题。
+
三个阶段共用下方工具名分类。准备中的 Tool 节点使用对应类别的准备文案;`read` 为「准备读取文件」,`read_image` 为「准备读取图片」,`write` 为「准备写入文件」,`edit` 和 `apply_patch` 为「准备编辑文件」,`todo_write` 及目标工具为「准备更新计划」。只有通用类别的「准备调用工具」在标准模式中追加协议工具名,其他类别不追加。它计为一次调用,不解析参数,只渲染不可展开的一行。具名实时 delta 可以创建该节点;历史调用直接从 tool/call 开始,不重现准备过程。
以下文案描述已记录的活动,不保证操作成功;例如失败的读取仍参与「已读取文件」类别。
diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts
index 53a4999b11..dbec14300e 100644
--- a/packages/client/ui-chat/src/client/locale.ts
+++ b/packages/client/ui-chat/src/client/locale.ts
@@ -57,12 +57,11 @@ export const zh = {
'message.trigger.subagent': '子任务状态更新',
'message.trigger.github': '收到 GitHub 事件',
'message.trigger.webhook': '收到外部事件',
- 'message.trigger.schedule': '定时任务',
+ 'message.trigger.schedule': '自动化任务',
'message.trigger.job': '后台任务状态更新',
'message.trigger.plugin': '插件状态更新',
'message.trigger.explanation': '这条通知触发了本轮回复。',
'message.turnProcess.worked': '已完成工作',
- 'message.turnProcess.deepDivingFor': '深度求索中,用时{duration}',
'message.turnProcess.took': '用时 {duration}',
'message.turnProcess.failed': '处理失败',
'view.chat': '对话',
@@ -87,7 +86,8 @@ export const zh = {
'chat.loadError': '历史加载失败:{message}({code})',
'chat.loadOlder': '加载更早',
'chat.toBottom': '回到底部',
- 'chat.deepDiving': '深度求索中',
+ 'chat.deepDiving': '深度求索中...',
+ 'chat.deepDivingFor': '深度求索中,用时 {duration}...',
'chat.turnNavigation.label': '轮次导航',
'chat.turnNavigation.jump': '跳转到第 {turn} 轮',
'chat.turnNavigation.jumpLoad': '加载并跳转到第 {turn} 轮',
@@ -250,12 +250,11 @@ export const en = {
'message.trigger.subagent': 'Subtask status updated',
'message.trigger.github': 'GitHub event received',
'message.trigger.webhook': 'External event received',
- 'message.trigger.schedule': 'Scheduled task',
+ 'message.trigger.schedule': 'Automation task',
'message.trigger.job': 'Background task updated',
'message.trigger.plugin': 'Plugin status updated',
'message.trigger.explanation': 'This notification triggered this response.',
'message.turnProcess.worked': 'Worked',
- 'message.turnProcess.deepDivingFor': 'Deep diving for {duration}',
'message.turnProcess.took': 'Took {duration}',
'message.turnProcess.failed': 'Failed',
'view.chat': 'Chat',
@@ -281,6 +280,7 @@ export const en = {
'chat.loadOlder': 'Load earlier',
'chat.toBottom': 'Back to bottom',
'chat.deepDiving': 'Deep diving...',
+ 'chat.deepDivingFor': 'Deep diving for {duration}...',
'chat.turnNavigation.label': 'Turn navigation',
'chat.turnNavigation.jump': 'Jump to turn {turn}',
'chat.turnNavigation.jumpLoad': 'Load and jump to turn {turn}',
diff --git a/packages/client/ui-chat/src/client/transcript-view.ts b/packages/client/ui-chat/src/client/transcript-view.ts
index e7f6cc4fde..a9ed31f0e4 100644
--- a/packages/client/ui-chat/src/client/transcript-view.ts
+++ b/packages/client/ui-chat/src/client/transcript-view.ts
@@ -10,13 +10,18 @@ import {
/** Live work-details preference consumed by Chat and its Settings row. */
export class TranscriptViewPolicy {
private readonly unsubscribe: () => void
- /** Reactive current mode; defaults to Standard before Host settings arrive. */
- readonly mode: SnapshotStore = createSnapshotStore(DEFAULT_TRANSCRIPT_VIEW_MODE)
+ /** Reactive current mode, including the client default before Host settings arrive. */
+ readonly mode: SnapshotStore
/**
* @param host - durable Chat settings scope.
+ * @param defaultMode - presentation used without an explicit saved mode.
*/
- constructor(private readonly host: ConfigForm) {
+ constructor(
+ private readonly host: ConfigForm,
+ private readonly defaultMode: TranscriptViewMode = DEFAULT_TRANSCRIPT_VIEW_MODE,
+ ) {
+ this.mode = createSnapshotStore(defaultMode)
this.unsubscribe = host.subscribe(() => { this.adopt() })
this.adopt()
}
@@ -39,8 +44,8 @@ export class TranscriptViewPolicy {
const section = this.host.getSnapshot().value
if (section === undefined) return
const saved = section.transcriptView
- const mode = saved === LEGACY_TRANSCRIPT_VIEW_MODE ? 'standard'
- : saved === LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE ? 'detailed' : saved
+ const mode = saved === LEGACY_TRANSCRIPT_VIEW_MODE || saved === LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE
+ ? 'detailed' : saved ?? this.defaultMode
if (this.mode.getSnapshot() !== mode) this.mode.set(mode)
}
}
diff --git a/packages/client/ui-chat/src/index.ts b/packages/client/ui-chat/src/index.ts
index b919f5786a..2380110127 100644
--- a/packages/client/ui-chat/src/index.ts
+++ b/packages/client/ui-chat/src/index.ts
@@ -2,7 +2,7 @@
import type {} from '@deepseek-ai/dsh-settings'
import type { Volatile, Context } from '@deepseek-ai/cordis'
-import type { LinkOpening, TranscriptViewMode, PerformanceUsageMode } from './chat-settings.ts'
+import type { ChatSettings, LinkOpening, PerformanceUsageMode } from './chat-settings.ts'
import z from '@deepseek-ai/schemastery'
import { TRANSCRIPT_VIEW_FIELD } from './chat-settings.ts'
@@ -17,7 +17,7 @@ export {
/** Runtime preferences projected to the browser. */
export interface Config {
/** Completed turn transcript presentation. */
- transcriptView: Volatile
+ transcriptView: Volatile
/** Performance and usage detail level. */
performanceUsage: Volatile
/** Default destination for Chat HTTP(S) links. */
diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx
index 04dc15407d..ad948ff2ff 100644
--- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx
+++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx
@@ -1,3 +1,4 @@
+import { MessageId } from '@deepseek-ai/dsh-llm/brand'
// @vitest-environment jsdom
/** Chat inject factories exercised over independently mounted Conversation and Chat plugins. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -170,18 +171,36 @@ describe('Chat inject API', () => {
void injected.loadThrough(SessionSeq(42))
expect(b.session.loadThrough).toHaveBeenCalledWith(42)
+ const track = vi.fn()
+ b.runtime.ctx.provide('productAnalytics', { track } as never)
+ const forkCreated = vi.spyOn(b.runtime.sessions, 'fork').mockImplementation(async (input) => { input.onCreated?.(ROOT); return ROOT })
+ injected.forkAt(17)
+ expect(track).toHaveBeenCalledWith('branch_session_click', { session_id: ROOT, parent_session_id: ROOT, click_position: 'footer' })
+ await b.runtime.sessions.replaceEvents(ROOT, [
+ { type: 'event', event: { type: 'turn/start', seq: SessionSeq(10), time: 10, data: { turn: 1 } } },
+ { type: 'event', event: { type: 'step/start', seq: SessionSeq(11), time: 11, data: { turn: 1, step: 1 } } },
+ { type: 'event', event: { type: 'assistant/message', seq: SessionSeq(12), time: 12, surfaceOp: 'append', data: {
+ turn: 1, step: 1, stream: [], message: { id: MessageId('reply'), role: 'assistant', content: [{ type: 'text', text: 'answer' }], source: { kind: 'model', provider: 'fixture', model: 'fixture' } },
+ } } },
+ { type: 'event', event: { type: 'turn/end', seq: SessionSeq(17), time: 17, data: { turn: 1, reason: { kind: 'completed' } } } },
+ ])
+ injected.forkAt(17)
+ expect(track).toHaveBeenLastCalledWith('branch_session_click', { session_id: ROOT, parent_session_id: ROOT, parent_message_id: 'reply', click_position: 'footer' })
+ forkCreated.mockRestore()
injected.forkAt(17)
await vi.waitFor(() => {
expect(b.openSession).toHaveBeenCalledWith(ROOT)
})
expect(b.runtime.sessions.calls).toContainEqual({
- method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }],
+ method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true, onCreated: expect.any(Function) as (childId: SessionId) => void }],
})
const fork = vi.spyOn(b.runtime.sessions, 'fork').mockRejectedValueOnce(new Error('fork failed'))
injected.forkAt(18)
await vi.waitFor(() => {
- expect(fork).toHaveBeenCalledWith({ sessionId: ROOT, atSeq: 18, increaseTitle: true })
+ expect(fork).toHaveBeenCalledWith({
+ sessionId: ROOT, atSeq: 18, increaseTitle: true, onCreated: expect.any(Function) as (childId: SessionId) => void,
+ })
})
await b.runtime.dispose()
})
diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx
index d9fed8b140..98df8ebccc 100644
--- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { act, fireEvent, render } from '@testing-library/react'
import {
SlotTestRuntime, stubConfigForm, usePinnedBrowserLanguages,
@@ -133,22 +133,29 @@ describe('Chat apply wiring', () => {
await b.runtime.dispose()
})
- it('mirrors the Host transcript preference into its Settings row', async () => {
+ it.each([
+ { desktop: false, initial: 'detailed', choice: 'standard' },
+ { desktop: true, initial: 'standard', choice: 'detailed' },
+ ] as const)('mirrors the Host transcript preference into its Settings row (desktop: $desktop)', async ({ desktop, initial, choice }) => {
+ if (desktop) {
+ vi.stubGlobal('dshDesktop', {})
+ onTestFinished(() => { vi.unstubAllGlobals() })
+ }
const b = await bench()
+ onTestFinished(() => b.runtime.dispose())
const row = b.runtime.slots.entries('settings.general.item')
.find(entry => entry.options.id === 'transcript-view')!
const face = (row.inject as unknown as () => TranscriptViewRowInjected)()
- expect(face.hooks.transcriptView.getSnapshot()).toBe('standard')
- face.setTranscriptView('detailed')
- expect(face.hooks.transcriptView.getSnapshot()).toBe('detailed')
- expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'detailed')
+ expect(face.hooks.transcriptView.getSnapshot()).toBe(initial)
+ face.setTranscriptView(choice)
+ expect(face.hooks.transcriptView.getSnapshot()).toBe(choice)
+ expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', choice)
b.chatSettings.publish({
status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 1, writable: true,
})
expect(face.hooks.transcriptView.getSnapshot()).toBe('compact')
- await b.runtime.dispose()
})
it('shares the accepted performance preference with settings, composer, and turn tails', async () => {
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index 3bda5cad62..f27271b3b5 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -505,6 +505,10 @@ function turnProcessControl(container: HTMLElement): HTMLButtonElement | null {
return container.querySelector('[data-turn-process]')
}
+function runningContent(container: HTMLElement): HTMLElement | null {
+ return container.querySelector('[data-chat-running] > :last-child')
+}
+
function withClock(time: number, run: () => void): () => void {
return () => {
vi.useFakeTimers()
@@ -1619,9 +1623,10 @@ describe('ChatView', () => {
fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('interrupt now')
expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
- expect(turnProcessControl(view.container)?.textContent).toBe('深度求索中,用时2秒')
+ expect(turnProcessControl(view.container)).toBeNull()
+ expect(runningContent(view.container)?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
expect(view.getByRole('status').compareDocumentPosition(view.getByText('interrupt now'))
- & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
+ & Node.DOCUMENT_POSITION_PRECEDING).not.toBe(0)
act(() => {
h.setSession({ testInbox: { 'next-turn': [queued], 'next-step': [] } })
@@ -2271,7 +2276,8 @@ describe('ChatView', () => {
const h = makeHarness({ chat: initial }, { running: true })
const view = render()
expect(renderedFlowKinds(view.container)).toEqual(['user', 'turn-process'])
- expect(turnProcessControl(view.container)?.textContent).toBe('深度求索中,用时4秒')
+ expect(turnProcessControl(view.container)).toBeNull()
+ expect(runningContent(view.container)?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBeNull()
act(() => {
@@ -2453,11 +2459,9 @@ describe('ChatView', () => {
turnTimings: new Map([[1, { startTime: 0 }]]),
})
const view = render()
- const liveToggle = view.getByRole('button', { name: '深度求索中,用时3秒' }) as HTMLButtonElement
- expect(liveToggle.disabled).toBe(true)
- expect(liveToggle.getAttribute('aria-expanded')).toBe('true')
+ expect(turnProcessControl(view.container)).toBeNull()
+ expect(runningContent(view.container)?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
- fireEvent.click(liveToggle)
expect(processRow.getAttribute('hidden')).toBeNull()
act(() => {
@@ -2469,7 +2473,7 @@ describe('ChatView', () => {
})
})
const toggle = turnProcessControl(view.container)!
- expect(toggle).toBe(liveToggle)
+ expect(runningContent(view.container)).toBeNull()
expect(toggle.textContent).toBe('用时 5秒')
expect(toggle.disabled).toBe(false)
expect(toggle.getAttribute('aria-expanded')).toBe('false')
@@ -3203,10 +3207,9 @@ describe('ChatView', () => {
const view = render()
expect(view.getByTestId('tool-seat-r1')).toBeTruthy()
expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' })
- expect(view.getByRole('status').textContent).toBe('深度求索中')
- const toggle = view.getByRole('button', { name: '深度求索中,用时2秒' }) as HTMLButtonElement
- expect(toggle.disabled).toBe(true)
- expect(toggle.getAttribute('aria-expanded')).toBe('true')
+ expect(view.getByRole('status').textContent).toBe('深度求索中...')
+ expect(turnProcessControl(view.container)).toBeNull()
+ expect(runningContent(view.container)?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
}))
it('keeps the Tool renderer mounted when a running call settles into log order', () => {
@@ -3265,13 +3268,13 @@ describe('ChatView', () => {
)
const view = render()
const status = view.getByRole('status')
- const toggle = view.getByRole('button', { name: '深度求索中,用时2分5秒' }) as HTMLButtonElement
- expect(toggle).toBe(turnProcessControl(view.container))
- expect(toggle.disabled).toBe(true)
- expect(status.textContent).toBe('深度求索中')
+ const content = runningContent(view.container)!
+ expect(content.textContent).toMatch(/^深度求索中,用时 \d+分\d+秒\.\.\.$/)
+ expect(turnProcessControl(view.container)).toBeNull()
+ expect(status.textContent).toBe('深度求索中...')
expect(status.getAttribute('aria-live')).toBe('polite')
expect(status.getAttribute('aria-atomic')).toBe('true')
- expect(toggle.closest('[aria-live]')).toBeNull()
+ expect(content.closest('[aria-live]')).toBeNull()
expect(view.container.querySelector('[data-chat-flow-kind="assistant-step"]')).toBeNull()
act(() => {
h.setSession({ testInbox: { 'next-turn': [], 'next-step': [{
@@ -3281,21 +3284,23 @@ describe('ChatView', () => {
role: 'user', source: { kind: 'user' },
}] } })
})
- expect(toggle.textContent).toBe('深度求索中,用时2分5秒')
+ expect(content.textContent).toMatch(/^深度求索中,用时 \d+分\d+秒\.\.\.$/)
+ const beforeTick = content.textContent
act(() => { vi.advanceTimersByTime(2_000) })
- expect(toggle.textContent).toBe('深度求索中,用时2分7秒')
+ expect(content.textContent).toMatch(/^深度求索中,用时 \d+分\d+秒\.\.\.$/)
+ expect(content.textContent).not.toBe(beforeTick)
expect(view.getByRole('status')).toBe(status)
- expect(status.textContent).toBe('深度求索中')
+ expect(status.textContent).toBe('深度求索中...')
act(() => {
h.setSession({ testInbox: { 'next-turn': [], 'next-step': [] } })
h.setChat({ nodes: [trigger, { ...steering(2, 'also', 1), time: 128_000 }] })
})
- expect(turnProcessControl(view.container)).toBe(toggle)
- expect(toggle.textContent).toBe('深度求索中,用时2分7秒')
+ expect(runningContent(view.container)).toBe(content)
+ expect(content.textContent).toMatch(/^深度求索中,用时 \d+分\d+秒\.\.\.$/)
expect(view.getByText('also').closest('[data-pending-steering]')).toBeNull()
act(() => { vi.advanceTimersByTime(1_000) })
- expect(toggle.textContent).toBe('深度求索中,用时2分8秒')
- expect(status.textContent).toBe('深度求索中')
+ expect(content.textContent).toMatch(/^深度求索中,用时 \d+分\d+秒\.\.\.$/)
+ expect(status.textContent).toBe('深度求索中...')
view.unmount()
expect(vi.getTimerCount()).toBe(0)
}))
@@ -3308,12 +3313,15 @@ describe('ChatView', () => {
{ running: true },
)
const view = render()
- const toggle = view.getByRole('button', { name: '深度求索中,用时59分59秒' })
+ const content = runningContent(view.container)!
+ expect(content.textContent).toMatch(/^深度求索中,用时 \d+分\d+秒\.\.\.$/)
act(() => { vi.advanceTimersByTime(1_000) })
- expect(toggle.textContent).toBe('深度求索中,用时1小时00分0秒')
+ expect(content.textContent).toMatch(/^深度求索中,用时 \d+小时\d+分\d+秒\.\.\.$/)
+ const hourlyText = content.textContent
act(() => { vi.advanceTimersByTime(303_000) })
- expect(toggle.textContent).toBe('深度求索中,用时1小时05分3秒')
- expect(view.getByRole('status').textContent).toBe('深度求索中')
+ expect(content.textContent).toMatch(/^深度求索中,用时 \d+小时\d+分\d+秒\.\.\.$/)
+ expect(content.textContent).not.toBe(hourlyText)
+ expect(view.getByRole('status').textContent).toBe('深度求索中...')
view.unmount()
expect(vi.getTimerCount()).toBe(0)
}))
@@ -4089,4 +4097,18 @@ describe('ChatView', () => {
expect(failedView.getByText('Compaction cancelled.')).toBeTruthy()
expect(failedView.container.querySelector('[data-state="error"]')).not.toBeNull()
})
+
+ it('appends the running status at the transcript bottom only while the Session runs', () => {
+ const h = makeHarness({ nodes: [user(1, 'do the thing'), assistant(2, 'working')] }, { running: true })
+ const view = render()
+ const column = view.container.querySelector('[data-chat-flow]')!
+ const status = column.querySelector('[data-chat-running]')
+ expect(column.lastElementChild).toBe(status)
+ expect(within(status as HTMLElement).getByRole('status').textContent).toBe('深度求索中...')
+ expect(status?.lastElementChild?.textContent).toBe('深度求索中...')
+ expect(status?.querySelector('svg')?.parentElement?.getAttribute('aria-hidden')).toBe('true')
+ expect(status?.querySelector('animate')).not.toBeNull()
+ act(() => { h.setSession({ running: false }) })
+ expect(column.querySelector('[data-chat-running]')).toBeNull()
+ })
})
diff --git a/packages/client/ui-chat/tests/config.host.spec.ts b/packages/client/ui-chat/tests/config.host.spec.ts
index e8591c98b1..a7fa8fa80a 100644
--- a/packages/client/ui-chat/tests/config.host.spec.ts
+++ b/packages/client/ui-chat/tests/config.host.spec.ts
@@ -4,7 +4,7 @@ import * as HostPlugin from '../src/index.ts'
import { liveConfig, omitsGeneratedPage } from '../../../settings/settings/tests/live-config.ts'
import { plainConfig } from '../../../settings/settings/src/schema.ts'
import {
- DEFAULT_TRANSCRIPT_VIEW_MODE, Config, apply,
+ Config, apply,
} from '../src/index.ts'
@@ -14,7 +14,7 @@ describe('ui-chat Host settings', () => {
const configuration = await liveConfig(ctx, { Config, apply })
const { fiber } = configuration
- expect(plainConfig(configuration.fiber.config)).toEqual({ transcriptView: DEFAULT_TRANSCRIPT_VIEW_MODE, performanceUsage: 'detailed', linkOpening: 'sidebar' })
+ expect(plainConfig(configuration.fiber.config)).toEqual({ performanceUsage: 'detailed', linkOpening: 'sidebar' })
await configuration.update({ transcriptView: 'normal' })
expect(plainConfig(configuration.fiber.config)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed', linkOpening: 'sidebar' })
for (const mode of ['expanded', 'compact', 'standard', 'detailed', 'verbose']) {
@@ -23,7 +23,7 @@ describe('ui-chat Host settings', () => {
}
for (const mode of ['dense', '', 42, false, {}, null, undefined]) {
await configuration.update({ transcriptView: mode })
- expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: 'standard' })
+ expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: mode === null ? null : undefined })
}
await configuration.update({ performanceUsage: 'compact' })
expect(plainConfig(configuration.fiber.config)).toMatchObject({ performanceUsage: 'compact' })
diff --git a/packages/client/ui-chat/tests/running-status.client.spec.tsx b/packages/client/ui-chat/tests/running-status.client.spec.tsx
new file mode 100644
index 0000000000..b206fd26d2
--- /dev/null
+++ b/packages/client/ui-chat/tests/running-status.client.spec.tsx
@@ -0,0 +1,69 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, render } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
+import { RunningStatus } from '../src/client/chat/RunningStatus.tsx'
+import { zh } from '../src/client/locale.ts'
+
+const t = makeTranslate(zh, commonZh)
+
+beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(5_000) })
+afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
+
+function statusHarness(startTime?: number) {
+ const view = render()
+ return {
+ ...view,
+ content: () => view.container.querySelector('[data-chat-running] > :last-child'),
+ set: (nextStartTime?: number) => { view.rerender() },
+ }
+}
+
+describe('RunningStatus', () => {
+ it('waits for an open Turn start before allocating its clock', () => {
+ const view = statusHarness()
+ expect(view.content()?.textContent).toBe('深度求索中...')
+ expect(vi.getTimerCount()).toBe(0)
+ view.set(1_000)
+ expect(view.content()?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
+ expect(view.container.querySelectorAll('[data-text-shimmer]')).toHaveLength(1)
+ expect(vi.getTimerCount()).toBe(1)
+ view.set()
+ expect(view.content()?.textContent).toBe('深度求索中...')
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it('keeps the indicator mounted while a continuous run moves to the next Turn', () => {
+ const view = statusHarness(1_000)
+ const content = view.content()
+ const status = view.getByRole('status')
+ const initialText = content?.textContent
+ act(() => { vi.advanceTimersByTime(2_000) })
+ expect(content?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
+ expect(content?.textContent).not.toBe(initialText)
+ view.set(7_000)
+ expect(view.content()).toBe(content)
+ expect(content?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
+ expect(vi.getTimerCount()).toBe(1)
+ const nextTurnText = content?.textContent
+ act(() => { vi.advanceTimersByTime(2_000) })
+ expect(content?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
+ expect(content?.textContent).not.toBe(nextTurnText)
+ expect(view.getByRole('status')).toBe(status)
+ expect(status.textContent).toBe('深度求索中...')
+ view.unmount()
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it('keeps the duration nonnegative when the start is ahead of the local clock', () => {
+ const view = statusHarness(6_000)
+ expect(view.content()?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
+ expect(view.content()?.textContent).not.toContain('-')
+ act(() => { vi.advanceTimersByTime(3_000) })
+ expect(view.content()?.textContent).toMatch(/^深度求索中,用时 \d+秒\.\.\.$/)
+ expect(view.content()?.textContent).not.toContain('-')
+ })
+
+})
diff --git a/packages/client/ui-chat/tests/running-whale-tail.client.spec.tsx b/packages/client/ui-chat/tests/running-whale-tail.client.spec.tsx
new file mode 100644
index 0000000000..f2a06bd9a2
--- /dev/null
+++ b/packages/client/ui-chat/tests/running-whale-tail.client.spec.tsx
@@ -0,0 +1,32 @@
+// @vitest-environment jsdom
+
+import { cleanup, render } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import { RunningWhaleTail } from '../src/client/chat/RunningWhaleTail.tsx'
+
+afterEach(cleanup)
+
+describe('RunningWhaleTail', () => {
+ it('renders matching animated and resting contours for the stylesheet to switch', () => {
+ const view = render()
+ expect(view.container.firstElementChild?.getAttribute('aria-hidden')).toBe('true')
+ expect(view.container.querySelector('svg')?.getAttribute('viewBox')).toBe('0 0 16 16')
+ const contours = view.container.querySelectorAll('path')
+ expect(contours).toHaveLength(2)
+ expect(contours[1]?.getAttribute('d')).toBe(contours[0]?.getAttribute('d'))
+ const animation = view.container.querySelector('animate')
+ expect(animation?.getAttribute('attributeName')).toBe('d')
+ expect(animation?.getAttribute('begin')).toBe('0.3s')
+ expect(animation?.getAttribute('dur')).toBe('3s')
+ expect(animation?.getAttribute('repeatCount')).toBe('indefinite')
+ const paths = animation!.getAttribute('values')!.split(';')
+ const times = animation!.getAttribute('keyTimes')!.split(';').map(Number)
+ expect(paths.length).toBeGreaterThan(1)
+ expect(paths).toHaveLength(times.length)
+ expect(paths[0]).toBe(view.container.querySelector('path')?.getAttribute('d'))
+ expect(paths.at(-1)).toBe(paths[0])
+ expect(times[0]).toBe(0)
+ expect(times.at(-1)).toBe(1)
+ expect(times.slice(1).every((time, i) => time > times[i]!)).toBe(true)
+ })
+})
diff --git a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts
index 28a9212054..829dad2fe4 100644
--- a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts
+++ b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts
@@ -5,7 +5,7 @@ import type { ChatSettings } from '../src/chat-settings.ts'
import { TranscriptViewPolicy } from '../src/client/transcript-view.ts'
describe('TranscriptViewPolicy', () => {
- it('defaults to Standard and publishes explicit choices before persistence settles', () => {
+ it('defaults to Detailed and publishes explicit choices before persistence settles', () => {
const host = stubConfigForm()
const observed: string[] = []
let current = (): string => 'unconstructed'
@@ -19,30 +19,49 @@ describe('TranscriptViewPolicy', () => {
const policy = new TranscriptViewPolicy(scope)
current = () => policy.mode.getSnapshot()
- expect(policy.mode.getSnapshot()).toBe('standard')
- policy.setMode('detailed')
expect(policy.mode.getSnapshot()).toBe('detailed')
- expect(observed).toEqual(['transcriptView=detailed:detailed'])
- expect(host.set).toHaveBeenCalledWith('transcriptView', 'detailed')
+ policy.setMode('standard')
+ expect(policy.mode.getSnapshot()).toBe('standard')
+ expect(observed).toEqual(['transcriptView=standard:standard'])
+ expect(host.set).toHaveBeenCalledWith('transcriptView', 'standard')
})
- it('adopts Host state, reads normal as Standard, and ignores identical writes', () => {
+ it.each(['standard', 'detailed'] as const)('uses %s for missing preferences without writing a default', (defaultMode) => {
const host = stubConfigForm()
- const policy = new TranscriptViewPolicy(host.scope)
-
- host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true })
- expect(policy.mode.getSnapshot()).toBe('standard')
- policy.setMode('standard')
+ const policy = new TranscriptViewPolicy(host.scope, defaultMode)
+ expect(policy.mode.getSnapshot()).toBe(defaultMode)
+ host.publish({ value: { linkOpening: 'sidebar', performanceUsage: 'detailed' } })
+ expect(policy.mode.getSnapshot()).toBe(defaultMode)
+ host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' } })
+ expect(policy.mode.getSnapshot()).toBe('compact')
+ host.publish({ value: { linkOpening: 'sidebar', transcriptView: null, performanceUsage: 'detailed' } })
+ expect(policy.mode.getSnapshot()).toBe(defaultMode)
expect(host.set).not.toHaveBeenCalled()
+ policy.dispose()
+ })
+
+ it.each(['normal', 'expanded'] as const)('reads a later Host %s setting as Detailed without writing it back', (mode) => {
+ const host = stubConfigForm()
+ const policy = new TranscriptViewPolicy(host.scope, 'standard')
+
+ host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: mode, performanceUsage: 'detailed' }, revision: 1, writable: true })
+ expect(policy.mode.getSnapshot()).toBe('detailed')
+ expect(host.set).not.toHaveBeenCalled()
+ policy.setMode('detailed')
+ expect(host.set).not.toHaveBeenCalled()
+
+ policy.setMode('standard')
+ expect(policy.mode.getSnapshot()).toBe('standard')
+ expect(host.set).toHaveBeenCalledExactlyOnceWith('transcriptView', 'standard')
host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 2 })
expect(policy.mode.getSnapshot()).toBe('compact')
})
- it('adopts an accepted section standing at construction', () => {
+ it.each(['normal', 'expanded'] as const)('reads an initial Host %s setting as Detailed without writing it back', (mode) => {
const host = stubConfigForm()
- host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'expanded', performanceUsage: 'detailed' }, revision: 1, writable: true })
- expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('detailed')
+ host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: mode, performanceUsage: 'detailed' }, revision: 1, writable: true })
+ expect(new TranscriptViewPolicy(host.scope, 'standard').mode.getSnapshot()).toBe('detailed')
expect(host.set).not.toHaveBeenCalled()
})
diff --git a/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx b/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx
index 851111fd14..3b0ac5596c 100644
--- a/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx
+++ b/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx
@@ -2,11 +2,12 @@
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { IconClockOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatNode } from '../src/client/contract/chat-nodes.ts'
import { TurnTriggerNodeView } from '../src/client/chat/TurnTriggerNodeView.tsx'
import { turnTriggerDetails } from '../src/client/chat/turn-trigger.ts'
import { contextForm, contextProducer } from '../src/client/conversation-nodes/event-projection.ts'
-import { en } from '../src/client/locale.ts'
+import { en, zh } from '../src/client/locale.ts'
afterEach(cleanup)
@@ -24,7 +25,7 @@ function trigger(source: unknown): ChatNode<'turn-trigger'> {
describe('Turn trigger notices', () => {
it.each([
- [{ kind: 'schedule' }, 'schedule', 'Scheduled task'],
+ [{ kind: 'schedule' }, 'schedule', 'Automation task'],
[{ kind: 'tool-jobs' }, 'job', 'Background task updated'],
[{ kind: 'cordis-host-runner' }, 'plugin', 'Plugin status updated'],
[{ kind: 'goal' }, 'goal', 'Continuing goal'],
@@ -45,6 +46,19 @@ describe('Turn trigger notices', () => {
expect(view.container.querySelector('time')?.dateTime).toBe('2023-11-14T22:13:20.000Z')
})
+ it.each([
+ { locale: en, title: 'Automation task' },
+ { locale: zh, title: '自动化任务' },
+ ])('uses the automation task label $title and shared clock', ({ locale, title }) => {
+ const node = trigger({ kind: 'schedule' })
+ const view = render()
+ expect(view.getByRole('button').textContent).toContain(title)
+ const reference = render()
+ expect(view.getByRole('button').querySelector('svg')?.outerHTML)
+ .toBe(reference.container.querySelector('svg')?.outerHTML)
+ expect(node.data.source).toEqual({ kind: 'schedule' })
+ })
+
it('opens the recorded notice body and closes it independently of the Turn', () => {
const view = render()
const button = view.getByRole('button')
diff --git a/packages/client/ui-chat/tsconfig.client.json b/packages/client/ui-chat/tsconfig.client.json
index a0f8a5047f..e03a2b0a90 100644
--- a/packages/client/ui-chat/tsconfig.client.json
+++ b/packages/client/ui-chat/tsconfig.client.json
@@ -113,6 +113,9 @@
},
{
"path": "../ui-input-trigger"
+ },
+ {
+ "path": "../product-analytics/tsconfig.client.json"
}
],
"exclude": [
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index 0aa0c1f613..8da175dc89 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -6,8 +6,8 @@
en: 0ca51f37788dcbdb
zh: 14d73c6bc8d4e17f
/deepseek-ai-dsh-client-ui-conversation:
- en: 71d52109076d859d
- zh: 8e63171fbd9bde48
+ en: 77ecbd074ef3a3ce
+ zh: 2c0e2b720e3b201e
/deepseek-ai-dsh-client-ui-conversation/summary:
en: bac0bf0d7330f3b1
zh: 84a895f4b26d8490
@@ -18,8 +18,8 @@
en: 13ae68865fa315d7
zh: c152f020abf20f35
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
- en: ea2f1e81b1f9a38a
- zh: a2d4745b3e93e5a9
+ en: 7f1d7a29d2238f3a
+ zh: f7a66d2f34a1cd42
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382
zh: 824b84077432b559
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index 539cdd6cfb..99e42d3017 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -7,6 +7,8 @@ kind: "package-reference"
English | [中文](README.zh.md)
+Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded.
+
## Summary
`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller `SessionEventLikeEntry` feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. It also owns the per-session durable image URL cache: `ctx.uiConversation.imageUrl(sessionId, attachment)` resolves one session-authorized browser URL per attachment and revokes it with the Session binding, so every Conversation target shares one `session.attachment` read. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers.
@@ -74,6 +76,8 @@ The resident composer survives no-Session and Session transitions. Whitespace hi
Plain Enter uses the configured delivery mode, exactly Ctrl+Enter or Cmd+Enter uses its complement, and Shift+Enter inserts a line break. Enter with Alt, AltGraph, both Ctrl and Cmd, or Shift plus Ctrl/Cmd leaves the draft and command menu unchanged and keeps the DOM event available to application shortcuts. The Conversation plugin registers the fixed send, newline, complementary-delivery, command-menu, and reference-menu rows and reserves their bindings until it unloads.
+Each composer attempt carries its original occurrence time, gesture, delivery intent, and Session model/run-state snapshot through asynchronous command arbitration. The ordinary-message notification fires once before reference serialization, including attachment-only submissions; handled or claimed commands do not notify. Snapshot and notification failures cannot block sending.
+
Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id.
Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text.
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index 85bd93d0a2..353d8be2db 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -7,6 +7,8 @@ kind: "package-reference"
[English](README.md) | 中文
+桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。
+
## 概述
`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller 的 `SessionEventLikeEntry` feed,通过 `ctx.uiConversation` 暴露不依赖 React 的注册表与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。它还拥有按会话的持久化图片 URL 缓存:`ctx.uiConversation.imageUrl(sessionId, attachment)` 为每个附件解析一个经会话授权的浏览器 URL,并随 Session binding 释放而撤销,因此所有 Conversation target 共享一次 `session.attachment` 读取。Chat 等具体 target 位于独立包,由各自包注册 Definition、快照 builder、View 和 renderer。
@@ -74,6 +76,8 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
普通 Enter 使用已配置的投递模式,严格的 Ctrl+Enter 或 Cmd+Enter 使用互补模式,Shift+Enter 插入换行。带 Alt、AltGraph、同时带 Ctrl 与 Cmd,或 Shift 与 Ctrl/Cmd 的 Enter 保持草稿和指令菜单不变,并将 DOM 事件留给应用快捷键。Conversation 插件注册发送、换行、互补投递、命令菜单和引用菜单的固定条目,并在卸载前保留这些键位。
+每次输入框提交都携带原始发生时间、操作来源、投递意图以及会话模型和运行状态快照,贯穿异步命令裁决。普通消息通知在引用序列化前发出一次,纯附件提交也遵循这一规则;已处理或认领的命令不发出通知。快照读取与通知消费异常不能阻断发送。
+
默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。
排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。
diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json
index 9028a48508..c30f69d0b9 100644
--- a/packages/client/ui-conversation/package.json
+++ b/packages/client/ui-conversation/package.json
@@ -96,7 +96,8 @@
"@lexical/history": "^0.49.0",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-settings": "workspace:*",
- "@deepseek-ai/dsh-client-shortcuts": "workspace:*"
+ "@deepseek-ai/dsh-client-shortcuts": "workspace:*",
+ "@deepseek-ai/dsh-client-product-analytics": "workspace:*"
},
"files": [
"lib/index.js",
diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
index 942444d3b7..0fc9b2560c 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -1,4 +1,5 @@
/** Registers the target-neutral Conversation assembly, shell, input, and docks. */
+import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { ISessions, SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
diff --git a/packages/client/ui-conversation/src/client/contract/composer-submission.ts b/packages/client/ui-conversation/src/client/contract/composer-submission.ts
index 23d9df94c1..2d9e2cc039 100644
--- a/packages/client/ui-conversation/src/client/contract/composer-submission.ts
+++ b/packages/client/ui-conversation/src/client/contract/composer-submission.ts
@@ -9,3 +9,19 @@ export type InputSubmitMode = BusyEnterBehavior
/** Keyboard gesture whose delivery mode the submission policy resolves. */
export type ComposerSubmitGesture = 'enter' | 'accelerated'
+
+/** Session facts captured when a message submission starts, before asynchronous command arbitration. */
+export interface MessageSubmissionState {
+ readonly sessionId?: import('@deepseek-ai/dsh-session/types').SessionId
+ readonly model?: { readonly provider: string; readonly name: string; readonly effort?: string }
+ readonly runMode: 'default' | 'plan' | 'goal'
+ readonly running: boolean
+}
+
+/** Immutable occurrence and delivery intent carried by one composer attempt. */
+export interface MessageSubmission {
+ readonly timestamp: number
+ readonly source?: 'click' | 'enter'
+ readonly mode: InputSubmitMode
+ readonly state?: MessageSubmissionState
+}
diff --git a/packages/client/ui-conversation/src/client/contract/draft-editor.ts b/packages/client/ui-conversation/src/client/contract/draft-editor.ts
index d5682c97a6..4aaae89bc6 100644
--- a/packages/client/ui-conversation/src/client/contract/draft-editor.ts
+++ b/packages/client/ui-conversation/src/client/contract/draft-editor.ts
@@ -40,7 +40,7 @@ export interface ComposerKeyboard {
/** The shell-owned Lexical editor the composer binds its contenteditable to. */
readonly editor: LexicalEditor
/** Submit with an explicit delivery mode resolved by the submission policy (Enter gestures and the primary Send button). */
- submit(mode: InputSubmitMode): void
+ submit(mode: InputSubmitMode, source?: 'click' | 'enter'): void
/**
* Steer every still-pending queued message into the running turn (the
* empty-draft accelerated-Enter gesture; the queue dock's per-row steer
diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts
index 4036736374..7b792d2760 100644
--- a/packages/client/ui-conversation/src/client/contract/input.ts
+++ b/packages/client/ui-conversation/src/client/contract/input.ts
@@ -11,7 +11,7 @@ import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { ArbitrateKey, ArbitrateOutcome, Occurrence, ReferenceInsert, TokenSpan } from './draft-editor.ts'
-import type { InputSubmitMode } from './composer-submission.ts'
+import type { InputSubmitMode, MessageSubmission } from './composer-submission.ts'
/** Attachment payload passed to a claimed command submission. */
export type SubmitAttachment =
@@ -186,7 +186,7 @@ export interface SessionInput extends InputTarget {
* THE complexity sink: enter adjudication, submit transaction, and the default sink live inside.
* @param mode - delivery intent retained through asynchronous adjudication and serialization.
*/
- submit(mode?: InputSubmitMode): void
+ submit(mode?: InputSubmitMode, source?: 'click' | 'enter'): void
/**
* Surface a notice outside the machine's own effect stream: detached
* command results and business notifications render through here.
@@ -283,6 +283,8 @@ export interface SubmitAttempt {
readonly draftSnapshot: string
/** Default-message delivery intent retained while slash adjudication is pending. */
readonly mode: InputSubmitMode
+ /** Original message occurrence, retained across command arbitration. */
+ readonly submission?: MessageSubmission
}
/**
@@ -296,7 +298,7 @@ export type InputEvent =
/** The editor applied a claim-token replacement: enter claimed. */
| { readonly type: 'claim'; readonly claim: CommandClaim }
/** Enter submission with the current clipboard projection. */
- | { readonly type: 'enter'; readonly mode: InputSubmitMode; readonly draft: string }
+ | { readonly type: 'enter'; readonly mode: InputSubmitMode; readonly draft: string; readonly submission?: MessageSubmission }
| { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
| { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
/** Settlement carries the live clipboard projection for suffix-retention and claim re-entry decisions. */
diff --git a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts
index 89218a887c..f42b3a42be 100644
--- a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts
+++ b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts
@@ -134,7 +134,7 @@ export function installDraftKeymap(
g.running,
accelerated ? 'accelerated' : 'enter',
g.steeringAvailable,
- ))
+ ), 'enter')
},
intakeFiles: (files, directories) => { gate.current.intakeFiles(files, directories) },
pasteText: (text) => {
diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts
index 3cd956bf76..79aa620f34 100644
--- a/packages/client/ui-conversation/src/client/input/facade.ts
+++ b/packages/client/ui-conversation/src/client/input/facade.ts
@@ -22,7 +22,7 @@ import type {
import type {
ArbitrateKey, ArbitrateOutcome, ComposerKeyboard, Occurrence, ReferenceInsert, TokenSpan,
} from '../contract/draft-editor.ts'
-import type { InputSubmitMode } from '../contract/composer-submission.ts'
+import type { InputSubmitMode, MessageSubmission, MessageSubmissionState } from '../contract/composer-submission.ts'
import { SubmitMachine } from './machine.ts'
import { DraftEditorRuntime } from './editor/runtime.ts'
import type { EditorProjection } from './editor/projection.ts'
@@ -41,6 +41,10 @@ export interface PopupDismissFace {
export interface SessionInputDeps {
/** Session-scope ctx handed to claim.submit transactions. */
actx: Context
+ /** Snapshot the Session facts before asynchronous command arbitration. */
+ submissionState?: () => MessageSubmissionState
+ /** Notify one ordinary message attempt before reference serialization. */
+ messageSubmitted?: (submission: MessageSubmission) => void
/** Enter adjudication face resolver; absent/undefined answer = every '/' line falls to the default sink. */
inputTriggers?: (() => InputTriggerController | undefined) | undefined
/** PopupSelect shell face resolver (dismissal on submit lock / escape). */
@@ -290,7 +294,16 @@ export class SessionInputShell implements SessionInput {
* (adjudicating/submitting) force-closes the transient layers: the popup
* dismisses and the menu tracks frozen.
*/
- submit(mode: InputSubmitMode = 'queue'): void {
+ submit(mode: InputSubmitMode = 'queue', source?: 'click' | 'enter'): void {
+ if (this.disposed) return
+ const timestamp = Date.now()
+ let state: MessageSubmissionState | undefined
+ if (this.snapshot.phase === 'plain' && (this.snapshot.draft.trim() !== '' || this.attachmentIds.length > 0)) {
+ try { state = this.deps.submissionState?.() } catch (_error) { /* Optional Session observations cannot interrupt submission. */ }
+ }
+ const submission: MessageSubmission = Object.freeze({
+ timestamp, mode, ...source === undefined ? {} : { source }, ...state === undefined ? {} : { state },
+ })
if (this.snapshot.draft.trim() === '' && this.attachmentIds.length > 0) {
if (this.snapshot.phase === 'plain') {
const attachmentIds = [...this.attachmentIds]
@@ -299,6 +312,7 @@ export class SessionInputShell implements SessionInput {
const flight = this.attachmentFlightSeq
this.attachmentFlights.set(flight, { controller, attachmentIds })
this.commitSend(attachmentIds)
+ this.notifySubmission(submission)
void this.deps.defaultSink('', attachmentIds, mode, controller.signal).then((outcome) => {
if (this.disposed || !this.attachmentFlights.delete(flight)) return
if (outcome.kind === 'success') return
@@ -321,7 +335,7 @@ export class SessionInputShell implements SessionInput {
this.notify('error', this.deps.commandAttachments.unsupportedNotice(before.claim?.token ?? before.draft))
return
}
- this.dispatchRun(({ type: 'enter', mode, draft: this.projection.clipboardText }))
+ this.dispatchRun({ type: 'enter', mode, draft: this.projection.clipboardText, submission })
const phase = this.snapshot.phase
if (phase === 'adjudicating' || phase === 'submitting') {
this.deps.popup?.()?.dismiss()
@@ -572,7 +586,8 @@ export class SessionInputShell implements SessionInput {
/** Dispatch + execute, refreshing the claim decoration when the styled token flips. */
private dispatchRun(ev: Parameters[0]): void {
const beforeToken = this.activeClaimToken()
- this.run(this.core.dispatch(ev))
+ const effects = this.core.dispatch(ev)
+ this.run(effects)
if (this.activeClaimToken() !== beforeToken) this.draftEditor.refreshClaimDecoration()
}
@@ -633,6 +648,7 @@ export class SessionInputShell implements SessionInput {
draft: string,
mode: InputSubmitMode,
): void {
+ this.notifySubmission(attempt.submission)
const attachmentIds = [...this.attachmentIds]
this.attachmentIds = []
const occurrences = this.projection.occurrences
@@ -677,6 +693,11 @@ export class SessionInputShell implements SessionInput {
)
}
+ private notifySubmission(submission: MessageSubmission | undefined): void {
+ if (submission === undefined) return
+ try { this.deps.messageSubmitted?.(submission) } catch (_error) { /* Notification consumers cannot interrupt submission. */ }
+ }
+
/** Settle one detached default send independently of other sends. */
private settleSink(
attempt: SubmitAttempt,
diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts
index e062a8050f..8a908bb7b0 100644
--- a/packages/client/ui-conversation/src/client/input/hub.ts
+++ b/packages/client/ui-conversation/src/client/input/hub.ts
@@ -7,6 +7,10 @@
* listeners on each Session context and owns the default-sink choreography: every session is a
* real host entity, so the sink is one unconditional prompt path.
*/
+import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
+import type { ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types'
+import type { PlanProjection } from '@deepseek-ai/dsh-plan-mode/types'
+import type { GoalProjection } from '@deepseek-ai/dsh-goal/types'
import type { Context } from '@deepseek-ai/cordis'
import type {
ISessions, SessionBinding, SessionFace,
@@ -23,6 +27,7 @@ import type { ComposerKeyboard } from '../contract/draft-editor.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
import type { PopupDismissFace } from './facade.ts'
import { SessionInputShell } from './facade.ts'
+import { reportMessageSubmission } from './submission-analytics.ts'
/** Structural command face for per-session popup resolution. */
interface CommandFace {
@@ -90,6 +95,23 @@ export class InputHub implements SessionInputResolver {
const { session, ctx: actx } = binding
const shell = new SessionInputShell({
actx,
+ submissionState: () => {
+ const state = session.getSnapshot()
+ const model = session.projections.faceOf('modelSelection').getSnapshot() as ModelSelectionProjection | undefined
+ const plan = session.projections.faceOf('plan').getSnapshot() as PlanProjection | undefined
+ const goal = session.projections.faceOf('goal').getSnapshot() as GoalProjection | null | undefined
+ const selection = model?.next ?? model?.lastUsed
+ return Object.freeze({
+ ...state.blank ? {} : { sessionId: state.sessionId },
+ ...selection == null ? {} : { model: Object.freeze({
+ provider: selection.provider, name: selection.model,
+ ...selection.reasoningEffort === undefined ? {} : { effort: selection.reasoningEffort },
+ }) },
+ runMode: plan?.active ? 'plan' : goal?.goal.phase === 'active' ? 'goal' : 'default',
+ running: state.running,
+ })
+ },
+ messageSubmitted: (submission) => { reportMessageSubmission(this.rootCtx, submission) },
inputTriggers: () => this.controller(actx),
popup: () => this.popup(actx),
inbox: session.projections.faceOf('inbox') as ObservableSnapshot,
diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts
index 7d1e503777..910a0a66fe 100644
--- a/packages/client/ui-conversation/src/client/input/machine.ts
+++ b/packages/client/ui-conversation/src/client/input/machine.ts
@@ -7,7 +7,7 @@
* at Enter, so the editor can clear immediately and accept another message
* while earlier admissions remain in flight.
*/
-import type { InputSubmitMode } from '../contract/composer-submission.ts'
+import type { InputSubmitMode, MessageSubmission } from '../contract/composer-submission.ts'
import type { CommandClaim, InputEffect, InputEvent, InputState, SubmitAttempt } from '../contract/input.ts'
/** Exhaustiveness backstop for the closed InputEvent union. */
@@ -77,7 +77,7 @@ export class SubmitMachine {
switch (ev.type) {
case 'draft-changed': return this.onDraftChanged(ev.draft)
case 'claim': return this.onClaim(ev.claim)
- case 'enter': return this.onEnter(ev.mode, ev.draft)
+ case 'enter': return this.onEnter(ev.mode, ev.draft, ev.submission)
case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
case 'submit-settled': return this.onSubmitSettled(ev)
@@ -106,28 +106,28 @@ export class SubmitMachine {
}
/** Mint an attempt and controller without assigning its lifecycle owner. */
- private mintAttempt(mode: InputSubmitMode, draft: string): {
+ private mintAttempt(mode: InputSubmitMode, draft: string, submission?: MessageSubmission): {
readonly attempt: SubmitAttempt
readonly controller: AbortController
} {
const controller = new AbortController()
this.seq += 1
return {
- attempt: { seq: this.seq, signal: controller.signal, draftSnapshot: draft, mode },
+ attempt: { seq: this.seq, signal: controller.signal, draftSnapshot: draft, mode, ...submission === undefined ? {} : { submission } },
controller,
}
}
/** Mint the frozen command/adjudication attempt. */
- private beginAttempt(mode: InputSubmitMode, draft: string): SubmitAttempt {
- const flight = this.mintAttempt(mode, draft)
+ private beginAttempt(mode: InputSubmitMode, draft: string, submission?: MessageSubmission): SubmitAttempt {
+ const flight = this.mintAttempt(mode, draft, submission)
this.inflight = flight
return flight.attempt
}
/** Mint an ordinary send that leaves the phase plain. */
- private beginDetached(mode: InputSubmitMode, draft: string): SubmitAttempt {
- const flight = this.mintAttempt(mode, draft)
+ private beginDetached(mode: InputSubmitMode, draft: string, submission?: MessageSubmission): SubmitAttempt {
+ const flight = this.mintAttempt(mode, draft, submission)
this.detached.set(flight.attempt.seq, flight.controller)
this.claim = undefined
this.phase = 'plain'
@@ -142,21 +142,21 @@ export class SubmitMachine {
]
}
- private onEnter(mode: InputSubmitMode, draft: string): readonly InputEffect[] {
+ private onEnter(mode: InputSubmitMode, draft: string, submission?: MessageSubmission): readonly InputEffect[] {
if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
if (this.phase === 'claimed' && this.claim !== undefined) {
- const attempt = this.beginAttempt(mode, draft)
+ const attempt = this.beginAttempt(mode, draft, submission)
this.phase = 'submitting'
return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(draft, this.claim.token) }]
}
const trimmed = draft.trim()
if (trimmed === '') return []
if (trimmed.startsWith('/')) {
- const attempt = this.beginAttempt(mode, draft)
+ const attempt = this.beginAttempt(mode, draft, submission)
this.phase = 'adjudicating'
return [{ type: 'adjudicate', attempt, draft }]
}
- return this.detachedEffects(this.beginDetached(mode, draft))
+ return this.detachedEffects(this.beginDetached(mode, draft, submission))
}
private onAdjudicated(
diff --git a/packages/client/ui-conversation/src/client/input/submission-analytics.ts b/packages/client/ui-conversation/src/client/input/submission-analytics.ts
new file mode 100644
index 0000000000..a0f1a8e1c9
--- /dev/null
+++ b/packages/client/ui-conversation/src/client/input/submission-analytics.ts
@@ -0,0 +1,21 @@
+/** Maps ordinary message occurrences to Desktop product events. */
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
+import type { MessageSubmission } from '../contract/composer-submission.ts'
+
+/**
+ * Report the original message occurrence without reading newer Session facts.
+ * @param ctx - client context.
+ * @param submission - original occurrence and Session snapshot.
+ */
+export function reportMessageSubmission(ctx: Context, submission: MessageSubmission): void {
+ const { state, timestamp, mode } = submission
+ if (state === undefined) return
+ const model = state.model
+ ctx.get('productAnalytics')?.track('send_button_click', {
+ ...state.sessionId === undefined ? {} : { session_id: state.sessionId },
+ ...model === undefined ? {} : { model_name: `${model.provider}/${model.name}`, ...model.effort === undefined ? {} : { thinking_effort: model.effort } },
+ run_mode: state.runMode,
+ msg_type: state.running ? mode : 'default',
+ }, timestamp)
+}
diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts
index 88ae0697f7..455198ce82 100644
--- a/packages/client/ui-conversation/src/client/locales.ts
+++ b/packages/client/ui-conversation/src/client/locales.ts
@@ -154,10 +154,10 @@ export const zh = {
'tool.title.searchSessions': '搜索会话',
'tool.title.traceSession': '追踪会话',
'tool.title.listModels': '查看可用模型',
- 'tool.title.subagent': '创建代理',
- 'tool.title.listAgents': '查看代理',
+ 'tool.title.subagent': '创建子智能体',
+ 'tool.title.listAgents': '查看子智能体',
'tool.title.sendMessage': '发送消息',
- 'tool.title.interruptAgent': '中断代理',
+ 'tool.title.interruptAgent': '中断智能体',
'tool.title.listJobs': '查看后台任务',
'tool.title.readJob': '读取任务输出',
'tool.title.killJob': '取消后台任务',
@@ -230,14 +230,14 @@ export const zh = {
'detail.field.turn': '轮次',
'detail.field.step': '步骤',
'detail.field.callId': '调用 ID',
- 'detail.field.agents': '启动代理数',
+ 'detail.field.agents': '启动智能体数',
'detail.field.result': '结果',
'detail.field.parent': '父级',
'detail.field.depth': '层级',
'detail.field.exitCode': '退出码',
'detail.field.signal': '信号',
'detail.field.previousStatus': '中断前状态',
- 'detail.field.agent': '代理 ID',
+ 'detail.field.agent': '智能体 ID',
'detail.field.job': '任务 ID',
'detail.field.task': '任务内容',
'detail.field.processGroup': '进程组',
@@ -245,7 +245,7 @@ export const zh = {
'detail.field.bestMatch': '最相关事件',
'detail.field.target': '目标事件',
'detail.field.surface': '记录状态',
- 'detail.agents.count': '{count} 个代理',
+ 'detail.agents.count': '{count} 个智能体',
'detail.jobs.count': '{count} 个后台任务',
'detail.terminals.count': '{count} 个终端',
'detail.tasks.count': '{count} 个团队任务',
@@ -264,15 +264,15 @@ export const zh = {
'detail.wait.title': '子智能体状态',
'detail.wait.timeout': '等待超时',
'detail.wait.changed': '检测到变化',
- 'detail.agent.reply': '代理回复',
+ 'detail.agent.reply': '智能体回复',
'detail.models.title': '可用模型',
'detail.output.lines': '第 {begin}–{end} 行,共 {total} 行',
'detail.output.truncated': '输出已截断',
'detail.providers.count': '{count} 个检查提供方',
'detail.plugins.count': '{count} 个动态插件',
'detail.workflow.script': '工作流脚本',
- 'detail.ralph.reportedComplete': '代理报告完成',
- 'detail.ralph.reportedBlocker': '代理报告受阻',
+ 'detail.ralph.reportedComplete': '智能体报告完成',
+ 'detail.ralph.reportedBlocker': '智能体报告受阻',
'detail.ralph.limit': '已达到轮次上限',
'detail.report.nextSteps': '待完成事项',
'detail.trace.replacedBy': '被替换为',
diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
index f004aba1e9..2361d96c99 100644
--- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
@@ -318,7 +318,7 @@ export const InputBar = memo(function InputBar({
}
if (keyboard === undefined) return // absent machine: the button is disabled
/* v8 ignore next -- defensive: the primary button is disabled for empty, disabled, and pending-upload states. */
- if (!empty && !disabled && !machineBusy && !uploadsPending) keyboard.submit(primarySubmitMode)
+ if (!empty && !disabled && !machineBusy && !uploadsPending) keyboard.submit(primarySubmitMode, 'click')
}
// Claim ghost hint: rendered by CSS as generated content after the last
diff --git a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts
index 57f10879de..e25e3e6754 100644
--- a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts
+++ b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts
@@ -5,7 +5,7 @@
*/
import { describe, expect, it, vi } from 'vitest'
import type { Context } from '@deepseek-ai/cordis'
-import type { InputTriggerController, SubmitOutcome } from '../src/client/contract/input.ts'
+import type { InputTriggerController, SubmitOutcome, PickOutcome } from '../src/client/contract/input.ts'
import { SessionInputShell } from '../src/client/input/facade.ts'
import type { DraftAttachmentId } from '../src/client/contract/input.ts'
@@ -210,17 +210,20 @@ describe('reference submission', () => {
describe('submit transaction hardening', () => {
it('sends one image-only prompt per settlement, ignoring Enter during the round-trip', async () => {
+ const submitted = vi.fn()
let settle!: (outcome: SubmitOutcome) => void
const sink = vi.fn(() => new Promise((resolve) => { settle = resolve }))
const shell = new SessionInputShell({
actx: {} as Context,
defaultSink: sink,
commandAttachments,
+ messageSubmitted: submitted,
})
expect(shell.addAttachments(['img-1' as DraftAttachmentId])).toBe(true)
shell.submit('queue')
shell.submit('queue')
expect(sink).toHaveBeenCalledTimes(1)
+ expect(submitted).toHaveBeenCalledOnce()
settle({ kind: 'success' })
await vi.waitFor(() => {
expect(shell.snapshot.attachmentIds).toEqual([])
@@ -229,6 +232,9 @@ describe('submit transaction hardening', () => {
expect(shell.addAttachments(['img-2' as DraftAttachmentId])).toBe(true)
shell.submit('queue')
expect(sink).toHaveBeenCalledTimes(2)
+ expect(submitted).toHaveBeenCalledTimes(2)
+ settle({ kind: 'success' })
+ shell.dispose()
})
it('retains an image-only rejection without duplicating its prompt error notice', async () => {
@@ -283,3 +289,114 @@ describe('submit transaction hardening', () => {
expect(track).toHaveBeenCalledWith('@src/', 5, { tier: 'plain' }, shell.snapshot.draftRev)
})
})
+
+
+it('captures click and Enter submission intent before async admission, excluding empty submits', async () => {
+ const report = vi.fn()
+ const submissionState = vi.fn(() => ({ runMode: 'default' as const, running: false }))
+ const shell = new SessionInputShell({ actx: {} as Context, defaultSink: async () => ({ kind: 'success' }), commandAttachments, submissionState, messageSubmitted: report })
+ try {
+ shell.submit('queue', 'click')
+ expect(submissionState).not.toHaveBeenCalled()
+ shell.setDraft('first')
+ shell.submit('steer', 'click')
+ expect(report).toHaveBeenCalledWith(expect.objectContaining({ mode: 'steer', source: 'click' }))
+ await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') })
+ shell.setDraft('second')
+ shell.submit('queue', 'enter')
+ expect(report).toHaveBeenLastCalledWith(expect.objectContaining({ mode: 'queue', source: 'enter' }))
+ expect(report).toHaveBeenCalledTimes(2)
+ } finally { shell.dispose() }
+})
+
+
+it.each(['capture', 'report'])('analytics %s failure does not interrupt a message', async (stage) => {
+ const sink = vi.fn(async (): Promise => ({ kind: 'success' }))
+ const fail = () => { throw new Error('analytics unavailable') }
+ const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, commandAttachments,
+ ...stage === 'capture' ? { submissionState: fail } : { messageSubmitted: fail } })
+ try {
+ shell.setDraft('message')
+ expect(() => { shell.submit('queue', 'click') }).not.toThrow()
+ expect(sink).toHaveBeenCalledTimes(1)
+ await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') })
+ } finally { shell.dispose() }
+})
+
+
+it.each(['handled', 'claim', 'message'] as const)('counts only a message after asynchronous slash adjudication: %s', async (kind) => {
+ const pending = Promise.withResolvers()
+ const report = vi.fn()
+ const submissionState = vi.fn(() => ({ runMode: 'default' as const, running: false }))
+ const command = vi.fn(async (): Promise => ({ kind: 'success' }))
+ const sink = vi.fn(async (): Promise => ({ kind: 'success' }))
+ const inputTriggers: InputTriggerController = {
+ launcher: { getSnapshot: () => null, subscribe: () => () => {} },
+ lexicon: { getSnapshot: () => new Map(), subscribe: () => () => {} },
+ track: () => {}, arbitrate: () => 'pass', onSpace: () => false,
+ serializeReference: async () => '', openReference: () => false, toggleSource: () => {},
+ adjudicate: () => pending.promise,
+ }
+ const shell = new SessionInputShell({ actx: {} as Context, inputTriggers: () => inputTriggers,
+ submissionState, messageSubmitted: report, defaultSink: sink, commandAttachments })
+ try {
+ shell.setDraft('/compact')
+ shell.submit('queue', 'enter')
+ expect(report).not.toHaveBeenCalled()
+ pending.resolve(kind === 'handled' ? 'handled' : kind === 'claim' ? { claim: { name: 'compact', token: '/compact', submit: command } } : undefined)
+ await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') })
+ expect(report).toHaveBeenCalledTimes(kind === 'message' ? 1 : 0)
+ expect(sink).toHaveBeenCalledTimes(kind === 'message' ? 1 : 0)
+ expect(submissionState).toHaveBeenCalledOnce()
+ shell.setDraft('programmatic')
+ shell.actions.submit()
+ expect(report).toHaveBeenLastCalledWith(expect.objectContaining({ mode: 'queue' }))
+ expect(report.mock.calls.at(-1)?.[0]).not.toHaveProperty('source')
+ } finally { shell.dispose() }
+})
+
+it('retains occurrence time and Session facts across arbitration and independent failed sends', async () => {
+ const pending = Promise.withResolvers()
+ const submitted = vi.fn()
+ const firstSend = Promise.withResolvers()
+ const secondSend = Promise.withResolvers()
+ const sink = vi.fn().mockReturnValueOnce(firstSend.promise).mockReturnValueOnce(secondSend.promise)
+ let running = true
+ let now = 100
+ const clock = vi.spyOn(Date, 'now').mockImplementation(() => now)
+ const shell = new SessionInputShell({ actx: {} as Context, commandAttachments,
+ submissionState: () => Object.freeze({ runMode: running ? 'plan' : 'default', running }),
+ messageSubmitted: submitted,
+ inputTriggers: () => ({
+ launcher: { getSnapshot: () => null, subscribe: () => () => {} },
+ lexicon: { getSnapshot: () => new Map(), subscribe: () => () => {} },
+ track: () => {}, arbitrate: () => 'pass', onSpace: () => false,
+ serializeReference: async () => '', openReference: () => false, toggleSource: () => {},
+ adjudicate: () => pending.promise,
+ }),
+ defaultSink: sink,
+ })
+ try {
+ shell.setDraft('/ordinary')
+ shell.submit('steer', 'click')
+ running = false
+ now = 200
+ expect(submitted).not.toHaveBeenCalled()
+ pending.resolve(undefined)
+ await vi.waitFor(() => { expect(submitted).toHaveBeenCalledOnce() })
+ expect(submitted).toHaveBeenLastCalledWith({ timestamp: 100, source: 'click', mode: 'steer', state: { running: true, runMode: 'plan' } })
+ shell.setDraft('second')
+ shell.actions.submit()
+ expect(submitted).toHaveBeenLastCalledWith({ timestamp: 200, mode: 'queue', state: { running: false, runMode: 'default' } })
+ await vi.waitFor(() => { expect(sink).toHaveBeenCalledTimes(2) })
+ secondSend.resolve({ kind: 'error', text: 'second rejected' })
+ firstSend.resolve({ kind: 'error', text: 'first rejected' })
+ await Promise.all([firstSend.promise, secondSend.promise])
+ expect(submitted).toHaveBeenCalledTimes(2)
+ } finally {
+ firstSend.resolve({ kind: 'success' })
+ secondSend.resolve({ kind: 'success' })
+ shell.dispose()
+ clock.mockRestore()
+ }
+})
diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts
index 280f684945..6665284ae4 100644
--- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts
+++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts
@@ -876,3 +876,39 @@ describe('InputHub queue steering (empty-draft accelerated Enter)', () => {
await b.runtime.dispose()
})
})
+
+
+it('captures run mode and queue intent at submission before later projection changes', async () => {
+ const b = await bench()
+ const track = vi.fn()
+ b.runtime.ctx.provide('productAnalytics', { enabled: true, track } as never)
+ try {
+ await b.runtime.sessions.setProjection('s1', 'plan', { active: true, pending: false })
+ await b.runtime.sessions.setProjection('s1', 'goal', { goal: { phase: 'active' } })
+ await b.runtime.sessions.setProjection('s1', 'modelSelection', { next: { provider: 'fixture', model: 'model', reasoningEffort: 'high' }, lastUsed: null })
+ await b.runtime.sessions.updateSessionSnapshot('s1', (state) => { state.running = true; state.blank = false })
+ b.shell.setDraft('private prompt')
+ b.shell.submit('queue', 'click')
+ expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', {
+ session_id: 's1', model_name: 'fixture/model', thinking_effort: 'high', run_mode: 'plan', msg_type: 'queue',
+ }, expect.any(Number))
+ await b.runtime.sessions.setProjection('s1', 'plan', { active: false, pending: false })
+ expect(track).toHaveBeenCalledTimes(1)
+ expect(JSON.stringify(track.mock.calls)).not.toContain('private prompt')
+ } finally { await b.runtime.dispose() }
+})
+
+it.each([
+ { running: false, mode: 'queue', expected: 'default' },
+ { running: true, mode: 'steer', expected: 'steer' },
+] as const)('reports msg_type=$expected without gesture fields', async ({ running, mode, expected }) => {
+ const b = await bench()
+ const track = vi.fn()
+ b.runtime.ctx.provide('productAnalytics', { enabled: true, track } as never)
+ try {
+ await b.runtime.sessions.updateSessionSnapshot('s1', (state) => { state.running = running; state.blank = true })
+ b.shell.setDraft('message')
+ b.shell.submit(mode, 'enter')
+ expect(track).toHaveBeenCalledExactlyOnceWith('send_button_click', { run_mode: 'default', msg_type: expected }, expect.any(Number))
+ } finally { await b.runtime.dispose() }
+})
diff --git a/packages/client/ui-conversation/tsconfig.client.json b/packages/client/ui-conversation/tsconfig.client.json
index 09831f89e9..0339eebbe6 100644
--- a/packages/client/ui-conversation/tsconfig.client.json
+++ b/packages/client/ui-conversation/tsconfig.client.json
@@ -101,6 +101,9 @@
},
{
"path": "../shortcuts/tsconfig.client.json"
+ },
+ {
+ "path": "../product-analytics/tsconfig.client.json"
}
],
"exclude": [
diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml
index 4c6a280c90..65c04a8b64 100644
--- a/packages/client/ui-model-selection/README.i18n.yaml
+++ b/packages/client/ui-model-selection/README.i18n.yaml
@@ -6,8 +6,8 @@
en: f39813648aeef5a0
zh: eea25094e8bfc031
/deepseek-ai-dsh-client-ui-model-selection:
- en: 5e007d579a0e22f0
- zh: edfff0d021712077
+ en: 4cb6feb12e46f3a0
+ zh: eea39dd76fe1fa8b
/deepseek-ai-dsh-client-ui-model-selection/summary:
en: 2fb361ef704528fb
zh: 2537d2afb4d2cb45
diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md
index 6c316e1781..f1da11e94a 100644
--- a/packages/client/ui-model-selection/README.md
+++ b/packages/client/ui-model-selection/README.md
@@ -7,6 +7,8 @@ kind: "package-reference"
English | [中文](README.zh.md)
+Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded.
+
## Summary
The Web GUI lets users switch the model and reasoning effort for an existing session through either the `/model` popup or the composer's model control. Both surfaces present the same provider-grouped choices, and the selected model determines the available effort names and default. A complete selection applies to the next request; a running step keeps the model and effort it started with. If the selected model is unavailable, the composer stays disabled until the user selects an available model or that exact model becomes available again.
diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md
index de9ec9a522..42719f4476 100644
--- a/packages/client/ui-model-selection/README.zh.md
+++ b/packages/client/ui-model-selection/README.zh.md
@@ -7,6 +7,8 @@ kind: "package-reference"
[English](README.md) | 中文
+桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。
+
## 概述
Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有会话使用的模型与推理(reasoning)强度。两个界面呈现同一组按提供方分组的选择;所选模型决定可用的推理强度名称与默认值。完整选择从下一次请求开始生效;运行中的步骤保留其启动时的模型与推理强度。所选模型不可用时,composer 保持停用,直到用户选择可用模型或同一模型恢复可用。
diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json
index 412434fc61..821c345ab9 100644
--- a/packages/client/ui-model-selection/package.json
+++ b/packages/client/ui-model-selection/package.json
@@ -65,7 +65,8 @@
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
"@deepseek-ai/dsh-client-ui-session": "workspace:*",
- "clsx": "^2.1.1"
+ "clsx": "^2.1.1",
+ "@deepseek-ai/dsh-client-product-analytics": "workspace:*"
},
"files": [
"lib/index.js",
diff --git a/packages/client/ui-model-selection/src/client/directory.ts b/packages/client/ui-model-selection/src/client/directory.ts
index 845a85e66d..41f67832a0 100644
--- a/packages/client/ui-model-selection/src/client/directory.ts
+++ b/packages/client/ui-model-selection/src/client/directory.ts
@@ -4,6 +4,7 @@
* Session's durable selection projection, then submit through the same
* selectModel call. A switch made in either entry updates this shared state.
*/
+import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client'
import type {
ModelCatalogFailure, ModelProviderGroup, ModelSelection, ModelSelectionProjection,
} from '@deepseek-ai/dsh-api-session-controller/types'
@@ -52,6 +53,8 @@ export class ModelDirectory {
* @param available - whether this session may use Agent-bound model RPCs.
* @param catalog - Host-generation catalog shared by every Session.
* @param projected - durable model selection projected from Session history.
+ * @param isBlank - whether this Session has no first message yet.
+ * @param track - desktop-only callback after a successful user selection.
*/
constructor(
private readonly sessions: Pick,
@@ -59,6 +62,8 @@ export class ModelDirectory {
private readonly available: () => boolean,
private readonly catalog: ModelCatalogDirectory,
private readonly projected: ObservableSnapshot,
+ private readonly isBlank: () => boolean,
+ private readonly track?: TrackProductEvent,
) {
this.unsubscribeCatalog = catalog.store.subscribe(() => { this.syncInputs() })
this.unsubscribeSelection = projected.subscribe(() => { this.syncInputs() })
@@ -85,6 +90,9 @@ export class ModelDirectory {
*/
async select(selection: ModelSelection): Promise> {
this.assertAvailable()
+ const previous = this.store.getSnapshot().current
+ const previousEffort = previous?.reasoningEffort ?? (previous === null ? undefined : this.catalog.reasoningFor(previous)?.defaultEffort)
+ const nextEffort = selection.reasoningEffort ?? this.catalog.reasoningFor(selection)?.defaultEffort
const generation = ++this.generation
this.store.update((s) => { s.status = 'selecting'; s.pending = selection; s.error = null })
const result = await this.sessions.selectModel({
@@ -106,6 +114,14 @@ export class ModelDirectory {
})
return result
}
+ if (previous !== null) {
+ const from = `${previous.provider}/${previous.model}`
+ const to = `${selection.provider}/${selection.model}`
+ if (from !== to) this.track?.('model_switch', { ...this.isBlank() ? {} : { session_id: this.sessionId }, switch_from: from, switch_to: to })
+ if (from === to && previousEffort !== nextEffort) this.track?.('thinking_level_switch', {
+ ...this.isBlank() ? {} : { session_id: this.sessionId }, model_name: to, switch_from: previousEffort ?? 'default', switch_to: nextEffort ?? 'default',
+ })
+ }
this.store.update((s) => { s.status = 'ready'; s.pending = null; s.error = null })
this.syncInputs()
return { ok: true, value: undefined }
diff --git a/packages/client/ui-model-selection/src/client/service.ts b/packages/client/ui-model-selection/src/client/service.ts
index 1e8fe46c0a..18fa88cc86 100644
--- a/packages/client/ui-model-selection/src/client/service.ts
+++ b/packages/client/ui-model-selection/src/client/service.ts
@@ -12,6 +12,7 @@
* strings, and it models global+shadow named registries — this is a
* per-session singleton with no global layer to merge.
*/
+import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
@@ -77,6 +78,8 @@ export class ModelDirectoryResolver extends Service {
() => sessions.subagentAddress(sessionId) === undefined,
this.catalog,
binding.session.projections.faceOf('modelSelection'),
+ () => binding.session.getSnapshot().blank,
+ (name, attributes) => this.ctx.get('productAnalytics')?.track(name, attributes),
)
live.directories.set(binding, directory)
actx.effect(() => () => {
diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts
index c284a596dd..1896332c02 100644
--- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts
+++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts
@@ -138,7 +138,11 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
const scopes = new Map()
const bindings = new Map SnapshotStore } }
+ session: {
+ sessionId: SessionId
+ getSnapshot: () => { blank: boolean }
+ projections: { faceOf: () => SnapshotStore }
+ }
ctx: Context
}>()
const addressed = new Set()
@@ -149,10 +153,12 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
: undefined,
})
+ const track = vi.fn()
+ ctx.provide('productAnalytics', { enabled: true, track } as never)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
await ctx.plugin(function probe() {}).await()
- const mint = (key: string) => {
+ const mint = (key: string, blank = false) => {
const id = sid(key)
const handle = createScope(ctx, id)
scopes.set(id, handle.ctx)
@@ -163,7 +169,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
projections.set(id, projection)
const binding = {
sessionId: id,
- session: { sessionId: id, projections: { faceOf: () => projection } },
+ session: { sessionId: id, getSnapshot: () => ({ blank }), projections: { faceOf: () => projection } },
ctx: handle.ctx,
}
bindings.set(id, binding)
@@ -173,7 +179,7 @@ async function bench(locale: 'zh' | 'en' = 'zh') {
return { ...handle, projection }
}
return {
- ctx, fiber, mint, calls, remote,
+ ctx, fiber, mint, calls, remote, track,
contribution: () => contribution!,
popup: (): PopupSelectSpec => {
const ui = contribution!.ui
@@ -524,3 +530,23 @@ describe('ui-model-selection dual entry', () => {
expect(b.calls).toEqual({ models: 2, select: 0 })
})
})
+
+
+it.each([false, true])('reports accepted switches with blank=%s and no refused switch', async (blank) => {
+ const b = await bench('en')
+ const scope = b.mint('analytics', blank)
+ try {
+ const face = b.seat().inject!(sid('analytics'))
+ await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'max' })
+ expect(b.track).toHaveBeenCalledWith('model_switch', { ...blank ? {} : { session_id: 'analytics' }, switch_from: 'deepseek-official/deepseek-v4-flash', switch_to: 'deepseek-official/deepseek-v4-pro' })
+ b.track.mockClear()
+ await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-pro', reasoningEffort: 'high' })
+ expect(b.track).toHaveBeenCalledExactlyOnceWith('thinking_level_switch', {
+ ...blank ? {} : { session_id: 'analytics' }, model_name: 'deepseek-official/deepseek-v4-pro', switch_from: 'max', switch_to: 'high',
+ })
+ b.track.mockClear()
+ b.rejectSelection()
+ await face.select({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
+ expect(b.track).not.toHaveBeenCalled()
+ } finally { await scope.fiber.dispose(); await b.ctx.fiber.dispose() }
+})
diff --git a/packages/client/ui-model-selection/tsconfig.json b/packages/client/ui-model-selection/tsconfig.json
index ee4db71295..82f037f52a 100644
--- a/packages/client/ui-model-selection/tsconfig.json
+++ b/packages/client/ui-model-selection/tsconfig.json
@@ -52,6 +52,9 @@
},
{
"path": "../../util/values"
+ },
+ {
+ "path": "../product-analytics/tsconfig.client.json"
}
]
}
diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml
index 893f4e8405..c4a2f1d3b1 100644
--- a/packages/client/ui-plugin-manager/README.i18n.yaml
+++ b/packages/client/ui-plugin-manager/README.i18n.yaml
@@ -6,8 +6,8 @@
en: 65c981be6a40344b
zh: 468873de4e2db85f
/deepseek-ai-dsh-client-ui-plugin-manager:
- en: cab59fd44398760a
- zh: ccc3e3809c2bc634
+ en: 719d90bd71d7b957
+ zh: 492767e7ea9f88f8
/deepseek-ai-dsh-client-ui-plugin-manager/summary:
en: 0dc67850ca5358de
zh: 4ff1bb5bdc184e3e
@@ -15,11 +15,11 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package:
- en: ca2748d14de42df7
- zh: 51225f56acb1bada
+ en: 555eccf8bfdd5029
+ zh: e0886cbf6e62bfe3
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle:
- en: 130b84453fcb0f8c
- zh: f03976813657b35b
+ en: 915fdd040b4d211c
+ zh: d77e0846f9cbc334
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle:
en: abc90531efa889d5
zh: 0562570f85e69c35
diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md
index 09580f5cbe..f11efd5b2a 100644
--- a/packages/client/ui-plugin-manager/README.md
+++ b/packages/client/ui-plugin-manager/README.md
@@ -7,6 +7,8 @@ kind: "package-reference"
English | [中文](README.zh.md)
+Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded.
+
## Summary
Use the **Plugins** entry in the Web sidebar to manage the profile's installed bundles and the official bundles the installation ships switched off. Switch bundles and their rows on and off, install a bundle after the Host has read what the spec names, watch pnpm's output, stop a run, and enable what it added. Uninstalling asks for confirmation. A plugin that registers a configuration page is edited here, on its own page; Settings keeps the read-only inventory.
@@ -29,13 +31,17 @@ Select **Plugins** in the sidebar. The page reads the inventory and the bundles
Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` at that plugin address; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the image declared by their own `package.json.icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information.
+The first list read shows a short Official-heading placeholder and four skeleton cards, using the real cards' row, icon, and text-line spacing, with the action area left blank. They use the same opacity pulse as Session search and remain static with reduced motion. The accessible loading label and read timing are unchanged; refreshes retain the existing list, and installation and component-loading indicators keep their own states.
+
+The **Refresh** button shows its tooltip after 500 ms of hovering or keyboard focus. During a manual refresh, the button shows a spinner and stays disabled until all pending reads settle and at least 400 ms have elapsed, preventing a brief spinner flash; repeated refresh requests are ignored and the existing list remains visible. Success shows no toast. With a loaded list, a failed manual refresh keeps the cached content and shows a failure toast, including after leaving Plugins; select **Refresh** again to retry. Before any successful list read, failures show an in-place message with **Retry**. Background reads do not show the refresh spinner.
+
### Installing a bundle
On first use, when pnpm uses the unconfigured official npm registry and npmmirror is offered, the Host probes both registries in parallel. The first successful HTTPS ping response selects the initial source. The dialog preserves remembered choices, manual selections, explicit manager configuration, and custom or unknown pnpm registries. An install clicked during initial probing waits for that bounded operation; a late result cannot overwrite a manual choice or reopen a closed dialog.
The Host sends GET requests to `https://registry.npmjs.org/-/ping` and `https://registry.npmmirror.com/-/ping` through its normal fetch proxy. A 2xx response wins; redirects and failures do not. The remaining request is cancelled, both response bodies are discarded, and cleanup completes before the result returns. The default deadline is 1500 ms and results, including unavailable results, are cached for five minutes. Configure `registryProbeTimeoutMs` and `registryProbeCacheTtlMs` on `ui-plugin-manager`; `registryProbeEnabled: false` disables probing. If both requests fail or time out, the existing default remains selected. No IP-location service or Session content is involved.
-**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. **Install guide and examples** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate.
+**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. Enter in the package or custom-registry field submits the install request, but Enter used to confirm an input-method candidate does not. **Install guide and examples** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options use an opaque selector panel, fill rows only on hover, and show hosts in tertiary text without parentheses. The custom-address area highlights on hover; clicking anywhere in it selects the custom registry and focuses its input, whose focus style matches the package field. Keyboard focus alone preserves the selected registry; activating the custom radio or editing the address selects the custom registry. Tab moves from the selected radio to the address, and Shift+Tab moves back; leaving either end closes the selector and returns focus to **Registry**. Switching to an offered registry retains the custom URL draft for switching back. Submitting an invalid custom address blocks installation, opens the registry selector, and focuses the address. Invalid package and custom-address fields keep a red error border and add the shared blue inset focus ring on focus. The options float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate.
During preparation and download, **Cancel install** asks the Host to stop the run and waits for confirmation. Loading the bundle cannot be cancelled. Closing with ×, Escape, or the backdrop hides the dialog immediately and requests cancellation when possible. **View installation** reopens the same task with its output; another installation cannot start while its outcome is pending or unconfirmed. Confirmed cancellation returns to the spec and shows a toast; the manifest and lockfile are restored, while downloaded files can remain. A lost installation response triggers a request to recover the result; **Check installation status** and reconnect retry that request. If the Host has no active request, **Installation result unavailable** allows returning to editing after checking the plugin list. A cancellation sent before acceptance waits and retries automatically; a failed cancellation can be retried manually. Hidden tasks report their outcome through toasts without reopening the dialog.
diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md
index 93f764df98..c8a68347ea 100644
--- a/packages/client/ui-plugin-manager/README.zh.md
+++ b/packages/client/ui-plugin-manager/README.zh.md
@@ -7,6 +7,8 @@ kind: "package-reference"
[English](README.md) | 中文
+桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。
+
## 概述
使用 Web 侧栏的**插件**入口管理 profile 已安装的组合包,以及安装随附、默认关闭的官方组合包。可以启停组合包及其行、在 Host 读出 spec 指向什么之后安装组合包、查看 pnpm 输出、停止一次运行,并启用它新增的包。卸载会要求确认。注册了配置页的插件在这里、在它自己的页面上编辑;设置里只保留只读的插件列表。
@@ -29,13 +31,17 @@ kind: "package-reference"
已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到该插件地址下可访问的 `package.json`;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示各自 `package.json.icon` 声明的图片;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。
+首次读取列表时,页面显示一条较短的「官方」标题占位和四行骨架卡片,沿用真实卡片的行、图标和文字间距,操作区域留白。骨架使用与 Session 搜索相同的透明度呼吸动效,在减少动态效果模式下保持静止。无障碍加载文案和读取时机不变;刷新时保留已有列表,安装及组件加载仍使用各自的状态提示。
+
+**刷新**按钮在悬停或键盘聚焦 500 毫秒后显示提示。手动刷新期间,按钮显示旋转加载指示并保持禁用,直到所有待处理读取结束且至少经过 400 毫秒,避免加载指示一闪而过;重复刷新请求会被忽略,已有列表保持可见。成功不显示 toast。已有列表时,手动刷新失败保留缓存内容并显示失败 toast,即使已离开插件页也能看到;再次选择**刷新**即可重试。在首次成功读取列表之前,失败仍原位显示提示与**重试**。后台读取不显示刷新按钮的加载指示。
+
### 安装一个组合包
首次使用时,如果未显式配置安装源、pnpm 使用官方 npm 源,且列表提供 npmmirror,Host 会并发探测这两个源,选择最先成功响应 HTTPS ping 的源。已记住的选择、用户手选、管理器显式配置,以及自定义或未知的 pnpm 源均保留。在初次探测期间点击安装会等待这次有时限的操作;迟到结果不会覆盖手选或重新打开已关闭的对话框。
Host 通过普通 fetch 代理向 `https://registry.npmjs.org/-/ping` 和 `https://registry.npmmirror.com/-/ping` 发送 GET 请求,以最先返回 2xx 的源为结果;重定向和失败不参与选择。随后取消另一条请求、丢弃两个响应体,并在清理完成后返回。默认时限为 1500 毫秒,包括不可用结果在内的探测结果均缓存五分钟。在 `ui-plugin-manager` 上配置 `registryProbeTimeoutMs` 和 `registryProbeCacheTtlMs`;`registryProbeEnabled: false` 关闭探测。两条请求都失败或超时时保留现有默认源。不使用 IP 地区服务,也不发送 Session 内容。
-**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。输入框下方的**插件安装引导和示例**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。
+**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。在包名或自定义安装源输入框中按 Enter 会提交安装请求,但用于确认输入法候选词的 Enter 不会触发安装。输入框下方的**插件安装引导和示例**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项使用不透明选择面板,仅悬停行使用底色,主机名不加括号并使用三级文字色。自定义地址区域在悬停时高亮;点击其中任意位置会选中自定义源并聚焦输入框,焦点样式与包名输入框一致。仅通过键盘聚焦不会改变已选安装源;激活自定义单选项或编辑地址才会选中自定义源。Tab 从已选单选项移到地址框,Shift+Tab 反向返回;从任一端移出时关闭选择器,并将焦点交还**安装源**按钮。切到预设源时保留自定义地址草稿,切回后继续使用。提交无效的自定义地址时会阻止安装,展开安装源选择器并聚焦地址框。包名和自定义地址输入框无效时保留红色错误边框,聚焦时加上共享的蓝色内嵌焦点环。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。
准备和下载期间,**取消安装**会请求 Host 停止运行并等待确认。加载组合包的阶段不可取消。点击 ×、按 Escape 或点击遮罩会立即隐藏对话框,并在可以取消时请求取消。**查看安装任务**会重新打开同一任务并保留输出;结果待定或尚未确认时不能发起另一项安装。确认取消后回到 spec 输入界面并显示 toast;manifest 与 lockfile 已恢复,已下载文件可能保留。安装响应丢失后会请求恢复结果;**核对安装状态**和重连会重试该请求。Host 已无活动请求时,**未能获取安装结果**允许检查插件列表后返回编辑。早于接收确认的取消请求会等待并自动重试;取消失败可手动重试。隐藏的任务通过 toast 通知结果,不会重新弹出对话框。
diff --git a/packages/client/ui-plugin-manager/package.json b/packages/client/ui-plugin-manager/package.json
index 030e7d490f..7620948e98 100644
--- a/packages/client/ui-plugin-manager/package.json
+++ b/packages/client/ui-plugin-manager/package.json
@@ -72,7 +72,8 @@
"react-dom": "^18.2.0",
"@deepseek-ai/dsh-util-crypto": "workspace:*",
"@deepseek-ai/dsh-client-ui-settings": "workspace:*",
- "@deepseek-ai/dsh-plugin-manager": "workspace:*"
+ "@deepseek-ai/dsh-plugin-manager": "workspace:*",
+ "@deepseek-ai/dsh-client-product-analytics": "workspace:*"
},
"files": [
"lib/index.js",
diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css
index 9fafda562b..09810fe85b 100644
--- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css
+++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css
@@ -146,7 +146,7 @@
@keyframes dsh-plugin-highlight {
0%,
55% {
- box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent);
}
100% {
@@ -157,7 +157,7 @@
@media (prefers-reduced-motion: reduce) {
.card[data-plugin-highlight] {
animation: none;
- box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent);
}
}
@@ -271,7 +271,7 @@
content: '';
position: absolute;
inset: 0;
- border-radius: inherit;
+ border-radius: var(--dsw-radius-xl);
}
.cardOpen:focus-visible {
@@ -293,6 +293,46 @@
overflow: hidden;
}
+/* Match Session search's pulse without coupling the two feature packages. */
+.skeletonFill {
+ background: var(--dsw-alias-bg-skeleton);
+ animation: dsh-plugin-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite;
+}
+
+/* Keep each real text line's height; center the shorter painted bar within it. */
+.skeletonText {
+ display: flex;
+ align-items: center;
+ height: 1lh;
+}
+
+.skeletonBar {
+ width: 100%;
+ height: 12px;
+ border-radius: var(--dsw-radius-xs);
+}
+
+.skeletonHeading { width: 48px; }
+.skeletonTitle { width: min(144px, 60%); }
+.skeletonDescription { width: min(280px, 85%); }
+.skeletonIcon { border-color: transparent; }
+
+/* Keep text-column metrics while leaving the action area blank. */
+.skeletonActions {
+ width: 36px;
+ height: 20px;
+}
+
+@keyframes dsh-plugin-skeleton {
+ 0% { opacity: 1; }
+ 40% { opacity: 0.6; }
+ 80%, 100% { opacity: 1; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .skeletonFill { animation: none; }
+}
+
.cardEnd {
position: relative;
z-index: 1;
@@ -462,13 +502,21 @@
color: var(--dsw-alias-label-primary);
}
-.installField input[type='text']:focus {
+.installField input[type='text']:focus,
+.registryCustomField:focus {
border-color: var(--dsw-alias-state-business-primary);
box-shadow: inset 0 0 0 0.5px var(--dsw-alias-state-business-primary);
}
-.installField input[aria-invalid='true'] {
+.installField input[type='text'][aria-invalid='true'],
+.registryCustomField[aria-invalid='true'] {
border-color: var(--dsw-alias-state-error-primary);
+ box-shadow: none;
+}
+
+.installField input[type='text'][aria-invalid='true']:focus,
+.registryCustomField[aria-invalid='true']:focus {
+ box-shadow: inset 0 0 0 0.5px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
}
/* The guide under the field: opened by a text button, it says what an ID is and shows the three forms with examples. */
@@ -610,10 +658,10 @@
display: inline-flex;
align-items: center;
gap: 6px;
- height: 26px;
+ height: 28px;
padding: 0 8px 0 10px;
border: 0.5px solid var(--dsw-alias-border-l4);
- border-radius: var(--dsw-radius-sm);
+ border-radius: var(--dsw-radius-md);
background: var(--dsw-alias-bg-layer-2);
font: inherit;
font-size: 12.5px;
@@ -655,12 +703,12 @@
box-sizing: border-box;
width: min(440px, calc(100vw - 24px));
margin: 0;
- padding: 8px 10px 12px;
+ padding: 8px;
border: 0;
- border-radius: var(--dsw-radius-lg);
+ /* An opaque layer keeps the registry form controls readable over the dialog. */
background: var(--dsw-alias-bg-layer-2);
- /* The neutral stroke is the elevation's hairline, not a border beside it. */
- --dsw-elevation-stroke-color: var(--dsw-alias-border-l3);
+ border-radius: var(--dsw-radius-lg);
+ --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
box-shadow: var(--dsw-elevation-prominent);
min-width: 0;
}
@@ -670,14 +718,13 @@
flex-direction: column;
gap: 4px;
padding: 8px 10px;
- border: 0.5px solid transparent;
- border-radius: var(--dsw-radius-lg);
+ border: 0;
+ border-radius: var(--dsw-radius-md);
cursor: pointer;
}
-.registryOption[data-checked='true'] {
- border-color: color-mix(in srgb, var(--dsw-alias-brand-primary) 40%, transparent);
- background: var(--dsw-alias-bg-layer-2);
+.registryOption:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
}
/* An offered registry is one row: the radio beside its title and the line under it. */
@@ -720,6 +767,7 @@ label.registryOption {
}
.registryCustomField {
+ outline: none;
height: 32px;
margin-left: 26px;
padding: 0 10px;
@@ -731,10 +779,6 @@ label.registryOption {
color: var(--dsw-alias-label-primary);
}
-.registryCustomField[aria-invalid='true'] {
- border-color: var(--dsw-alias-state-error-primary);
-}
-
/* The typed address's field, refusal, and line sit under its title, past the radio and its gap. */
.registryOption > .inputError,
.registryOption > .registryHint {
diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
index 183ed40fae..ced0d9c348 100644
--- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
+++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
@@ -16,9 +16,9 @@ import {
Button, IconCheckCircleFillRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineMedium,
IconChevronRightOutlineRegular, IconCloseOutlineMedium,
IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular,
- IconWarningOutlineRegular, Input, Modal,
+ IconWarningOutlineRegular, Input, Modal, pointerModality,
PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal,
- StateDot, Switch, Tag, TerminalBlock, Toast, useAnchoredPosition, useDismissOnOutsidePointer,
+ StateDot, Switch, Tag, TerminalBlock, Toast, Tooltip, useAnchoredPosition, useDismissOnOutsidePointer,
type IconProps, type StateDotState, type TerminalBlockLabels,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
@@ -323,6 +323,39 @@ function CardHead({ title, t, onOpen, icon, tags, description, end }: {
)
}
+/** First-read placeholders share the Official group's card and text-line layout. */
+function ListSkeleton({ label }: { readonly label: string }): ReactNode {
+ return (
+
+
@@ -1317,11 +1411,11 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
{renderGroup('bundles', t('bundlesTitle'), mine.map(packageCard))}
{/* A failed package read trails the groups it left incomplete: right under Official on a
first-load failure, and after the kept cards when a refresh fails over stale data. */}
- {state.status === 'error'
+ {state.status === 'error' && !refreshing
? (
diff --git a/packages/client/ui-plugin-manager/src/client/PluginRefreshToast.tsx b/packages/client/ui-plugin-manager/src/client/PluginRefreshToast.tsx
new file mode 100644
index 0000000000..b573026f52
--- /dev/null
+++ b/packages/client/ui-plugin-manager/src/client/PluginRefreshToast.tsx
@@ -0,0 +1,32 @@
+/** Refresh failure feedback hosted outside the Plugins panel's lifetime. */
+import type { ReactNode } from 'react'
+import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
+import type { PluginManagerFace } from './manager-store.ts'
+import { noticeText } from './presentation.ts'
+
+/** The controller's shared notice source and dismissal action. */
+export type PluginRefreshToastFace = Pick & {
+ hooks: Pick
+}
+
+/** Render inputs bound from the shared controller and plugin dictionary. */
+export type PluginRefreshToastProps = InjectFace & PropsLocale<'pluginManager'>
+
+/**
+ * Display refresh failures even after navigation leaves the Plugins panel.
+ * @param props - shared notice hook, dismissal action, and locale seat.
+ * @returns the refresh failure toast, or null for other notices.
+ */
+export function PluginRefreshToast({ usePluginManager, dismissNotice, t }: PluginRefreshToastProps): ReactNode {
+ const notice = usePluginManager(state => state.notice)
+ if (notice?.kind !== 'refresh-failed') return null
+ return (
+ }
+ onDone={dismissNotice}
+ />
+ )
+}
diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts
index 0bceeabe9d..a568db4666 100644
--- a/packages/client/ui-plugin-manager/src/client/index.ts
+++ b/packages/client/ui-plugin-manager/src/client/index.ts
@@ -6,7 +6,7 @@
* A plugin that carries its own configuration renders it on this page through
* the slots the page declares (`slot-contract.ts`).
*/
-
+import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
// Type-only: the root `main` keyed slot the page registers into, declared by
@@ -21,6 +21,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
// through the owning package's client-safe types subpath).
import type {} from '@deepseek-ai/dsh-plugin-manager/types'
import { PluginManagerPage } from './PluginManagerPage.tsx'
+import { PluginRefreshToast, type PluginRefreshToastFace } from './PluginRefreshToast.tsx'
import { PluginsPanelIcon } from './PluginsPanelIcon.tsx'
import { configLedgerSource } from './config-ledger.ts'
import { PluginManagerController } from './manager-store.ts'
@@ -97,6 +98,14 @@ export function apply(ctx: ClientContext): void {
// the page's own; a plugin's configuration arrives through the slots the
// page declares here, so the page never names a configurable plugin.
const configLedger = configLedgerSource(ctx)
+ const face = controller.inject(configLedger, text => ctx.locale.resolveText(text))
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
+ name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS,
+ inject: (): PluginRefreshToastFace => ({
+ hooks: { pluginManager: face.hooks.pluginManager },
+ dismissNotice: face.dismissNotice,
+ }),
+ }, PluginRefreshToast))
ctx.slots.inject('main', function* () {
const handle = createNavigationStore(), instance = handle.create()
const store: typeof handle = { ...handle, create: () => instance }
@@ -105,7 +114,7 @@ export function apply(ctx: ClientContext): void {
key: PANEL_ID,
locale: NS,
store,
- inject: () => controller.inject(configLedger, text => ctx.locale.resolveText(text)),
+ inject: () => face,
children: {
'plugins.item': { kind: 'list', scope: 'root' },
'plugins.bundle.activation': { kind: 'keyed', scope: 'root' },
diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts
index 3c4fb304f0..e28336d4e6 100644
--- a/packages/client/ui-plugin-manager/src/client/locales.ts
+++ b/packages/client/ui-plugin-manager/src/client/locales.ts
@@ -10,6 +10,7 @@ export const zh = {
unavailable: '本部署没有可管理的 profile,无法安装或启停插件。',
retry: '重试',
refresh: '刷新',
+ refreshError: '刷新失败,请重试',
empty: '还没有安装任何插件。',
addPlugin: '添加插件',
restartNotice: '更改将在下次启动生效',
@@ -48,11 +49,11 @@ export const zh = {
installTitle: '添加插件',
installDescription: '输入插件的包名、GitHub 仓库地址或本地目录路径。',
installSpecLabel: '包名或地址',
- installSpecPlaceholder: '例如 @deepseek-ai/dsh-subagent-codex',
+ installSpecPlaceholder: '例如 dsh-plugin-whale-pet',
installGuideToggle: '插件安装引导和示例',
installGuideHide: '收起引导',
installGuideIdTitle: '包名',
- installGuideIdExample: '@deepseek-ai/dsh-subagent-codex',
+ installGuideIdExample: 'dsh-plugin-whale-pet',
installGuideIdHint: '插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。',
installGuideGitTitle: 'GitHub 仓库地址',
installGuideGitExample: 'https://github.com/author/dsh-plugin',
@@ -69,10 +70,9 @@ export const zh = {
registryDefault: '默认安装源',
registryOfficial: 'npm 官方源',
registryNpmmirror: '中国大陆镜像源',
- registryWithHost: '{name}({host})',
registryCustom: '自定义地址',
registryCustomPlaceholder: 'https://npm.example.com/',
- registryCustomHint: '公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里',
+ registryCustomHint: '填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。',
registryCustomInvalid: '请输入以 http:// 或 https:// 开头的地址',
registryListSeparator: '、',
installRun: '安装',
@@ -203,6 +203,7 @@ export const en = {
unavailable: 'This deployment runs without a manageable profile, so plugins cannot be installed or switched here.',
retry: 'Retry',
refresh: 'Refresh',
+ refreshError: 'Refresh failed. Please try again.',
empty: 'No plugins are installed yet.',
addPlugin: 'Add plugin',
restartNotice: 'The change takes effect at the next start',
@@ -241,11 +242,11 @@ export const en = {
installTitle: 'Add plugin',
installDescription: 'Enter the plugin\'s package name, GitHub repository address, or local directory path.',
installSpecLabel: 'Package name or address',
- installSpecPlaceholder: 'for example @deepseek-ai/dsh-subagent-codex',
+ installSpecPlaceholder: 'for example dsh-plugin-whale-pet',
installGuideToggle: 'Install guide and examples',
installGuideHide: 'Hide the guide',
installGuideIdTitle: 'Package name',
- installGuideIdExample: '@deepseek-ai/dsh-subagent-codex',
+ installGuideIdExample: 'dsh-plugin-whale-pet',
installGuideIdHint: 'The plugin package name is the npm package name (like dsh-xxx or @author/plugin): the part after dsh plugin add or pnpm add in a community plugin\'s README install command.',
installGuideGitTitle: 'GitHub repository address',
installGuideGitExample: 'https://github.com/author/dsh-plugin',
@@ -262,10 +263,9 @@ export const en = {
registryDefault: 'Default registry',
registryOfficial: 'Official npm registry',
registryNpmmirror: 'Mainland China mirror',
- registryWithHost: '{name} ({host})',
registryCustom: 'Custom address',
registryCustomPlaceholder: 'https://npm.example.com/',
- registryCustomHint: 'A company or private npm registry; for one that needs a login, keep the credentials in ~/.npmrc on this machine',
+ registryCustomHint: 'Enter an internal or private npm registry address starting with http:// or https://. If it requires a login, store the credentials in ~/.npmrc on this machine.',
registryCustomInvalid: 'Enter an address starting with http:// or https://',
registryListSeparator: ', ',
installRun: 'Install',
diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts
index ab1c3aa038..2f319d01ea 100644
--- a/packages/client/ui-plugin-manager/src/client/manager-store.ts
+++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts
@@ -1,3 +1,4 @@
+import { sanitizeInstallInput } from './sanitize-install-input.ts'
/**
* The plugin manager's state: the Host's bundles joined with its plugin
* entries, the action in flight, the install run, and the confirmation an
@@ -5,7 +6,7 @@
* after each action and after every `plugin-manager/changed` event, so a
* change made on another surface shows here without a manual refresh.
*/
-
+import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {
@@ -41,6 +42,7 @@ export type ManagerNotice =
| { readonly kind: 'restart'; readonly packageName: string; readonly seq: number }
| { readonly kind: 'overridden'; readonly packageName: string; readonly seq: number }
| { readonly kind: 'cancelled'; readonly seq: number }
+ | { readonly kind: 'refresh-failed'; readonly seq: number }
| { readonly kind: 'install'; readonly outcome: 'done' | 'failed' | 'unconfirmed' | 'applying' | 'unknown'; readonly seq: number }
| {
readonly kind: 'failed'
@@ -105,6 +107,9 @@ export type RegistryChoice =
/** The choice shown until the Host has said which registry it asks first: the one pnpm's own configuration names. */
const OFFICIAL_REGISTRY: RegistryChoice = { kind: 'offered', registry: null }
+/** Hold the manual-refresh spinner at least this long so a fast read does not flash it. */
+const REFRESH_SPINNER_MIN_MS = 400
+
/**
* The registry a choice asks, as the Host's install plan compares registries: pnpm's own configuration stands for
* the URL it names, once the Host has read it.
@@ -269,6 +274,12 @@ export interface ConfirmState {
export interface PluginManagerState {
/** `unavailable` when the Host runs without a managed profile; `error` keeps the last packages. */
readonly status: 'idle' | 'loading' | 'ready' | 'error' | 'unavailable'
+ /**
+ * Manual refresh feedback: `refreshing` until reads settle and the 400 ms minimum elapses;
+ * `failed` after a failed refresh without cached inventory; otherwise `idle`, including
+ * cached refresh failures reported by toast. Background reads do not start the spinner.
+ */
+ readonly refreshStatus: 'idle' | 'refreshing' | 'failed'
readonly packages: readonly PackageView[]
/** Package names and row keys with an action crossing the wire. */
readonly busy: readonly string[]
@@ -492,11 +503,14 @@ export class PluginManagerController {
private rerun = false
private generation = 0
private disposed = false
+ /** A successful managed-profile read remains usable even when it returned no bundles. */
+ private hasCachedInventory = false
private pendingConfirm: (() => Promise) | undefined
/** Cancels the check the dialog has in flight. */
private inspectAbort: AbortController | undefined
private request: InstallRequest | undefined
private noticeSeq = 0
+ private analyticsAttempt: { input: string; started: number } | undefined
private registryRead: RegistryRead | undefined
/** The registry last used from this browser, kept across dialogs and page loads; null until one was used. */
private readonly registryMemory: SnapshotStore = createSnapshotStore(null, {
@@ -510,7 +524,7 @@ export class PluginManagerController {
private readonly ctx: ClientContext,
) {
this.store = createSnapshotStore({
- status: 'idle', packages: [], busy: [], notice: null,
+ status: 'idle', refreshStatus: 'idle', packages: [], busy: [], notice: null,
install: IDLE_INSTALL, confirm: null, highlight: null,
})
}
@@ -543,8 +557,9 @@ export class PluginManagerController {
hooks: { pluginManager: this.store, configLedger, configurations: this.ctx.configForms.describe() },
configForm: id => this.ctx.configForms.get(id),
ensure: () => { if (this.getSnapshot().status === 'idle') void this.load() },
- refresh: () => { void this.load() },
+ refresh: () => { void this.refresh() },
openInstall: () => {
+ this.ctx.get('productAnalytics')?.track('plugin_add_button_click', {})
const install = this.getSnapshot().install
if (install.requestId === undefined) {
// A new dialog starts from the registry last used here and reads what the Host offers; a hidden install reopens as it is.
@@ -563,6 +578,7 @@ export class PluginManagerController {
void this.cancelInstall()
return
}
+ if (this.getSnapshot().install.phase === 'checking') this.finishAnalytics('cancelled')
this.abortInspect()
this.registryRead = undefined
this.patch({ install: IDLE_INSTALL })
@@ -604,11 +620,14 @@ export class PluginManagerController {
clearHighlight: () => { if (this.getSnapshot().highlight !== null) this.patch({ highlight: null }) },
setEnabled: (packageName, enabled) => {
void this.run(packageName, { packageName, action: enabled ? 'enable' : 'disable' }, async () => {
- this.applied(await this.ctx.remote.pluginManager.setBundleEnabled(packageName, enabled), packageName)
+ const result = await this.ctx.remote.pluginManager.setBundleEnabled(packageName, enabled)
+ this.applied(result, packageName)
+ if (result.ok && (result.value.application === 'applied' || result.value.application === 'restart-required')) this.trackToggle(packageName, enabled)
})
},
uninstall: (packageName) => {
this.pendingConfirm = () => this.run(packageName, { packageName, action: 'uninstall' }, async () => {
+ this.ctx.get('productAnalytics')?.track('confirm_uninstall_plugin', { plugin_name: packageName })
this.applied(await this.ctx.remote.pluginManager.removeBundle(packageName), packageName)
})
this.patch({ confirm: { action: 'uninstall', packageName } })
@@ -617,7 +636,9 @@ export class PluginManagerController {
cancelConfirm: () => { this.pendingConfirm = undefined; this.patch({ confirm: null }) },
setRowEnabled: (entryId, enabled) => {
void this.run(rowKey(entryId), { packageName: entryId, action: enabled ? 'rowEnable' : 'rowDisable' }, async () => {
- this.applied(await this.ctx.remote.pluginManager.setPluginEnabled(entryId, enabled), entryId)
+ const result = await this.ctx.remote.pluginManager.setPluginEnabled(entryId, enabled)
+ this.applied(result, entryId)
+ if (result.ok && (result.value.application === 'applied' || result.value.application === 'restart-required')) this.trackToggle(entryId, enabled, true)
})
},
dismissNotice: () => { this.patch({ notice: null }) },
@@ -710,6 +731,36 @@ export class PluginManagerController {
return run
}
+ /** Keep manual refresh feedback until its coalesced reads settle, without clearing cached cards. */
+ private async refresh(): Promise {
+ if (this.disposed || this.getSnapshot().refreshStatus === 'refreshing') return
+ const startedAt = Date.now()
+ this.patch({
+ refreshStatus: 'refreshing',
+ ...this.getSnapshot().notice?.kind === 'refresh-failed' ? { notice: null } : {},
+ })
+ try {
+ await this.load()
+ } catch (_error) {
+ // A rejected transport request leaves the cached cards available for retry.
+ this.patch({ status: 'error' })
+ } finally {
+ // Hold the spinner to its minimum so a fast read does not flash it, then settle.
+ const remaining = REFRESH_SPINNER_MIN_MS - (Date.now() - startedAt)
+ if (remaining > 0) await new Promise((resolve) => { setTimeout(resolve, remaining) })
+ this.settleRefresh()
+ }
+ }
+
+ /** Publish refresh feedback only before disposal. */
+ private settleRefresh(): void {
+ if (this.disposed) return
+ const failed = this.getSnapshot().status === 'error'
+ this.patch(failed && this.hasCachedInventory
+ ? { status: 'ready', refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: ++this.noticeSeq } }
+ : { refreshStatus: failed ? 'failed' : 'idle' })
+ }
+
private async read(): Promise {
try {
do {
@@ -725,6 +776,7 @@ export class PluginManagerController {
continue
}
if (inventory.value.managementAvailable !== true) {
+ this.hasCachedInventory = false
this.patch({ status: 'unavailable', packages: [] })
continue
}
@@ -737,8 +789,10 @@ export class PluginManagerController {
this.patch({ status: 'error' })
continue
}
+ this.hasCachedInventory = true
this.patch({
status: 'ready',
+ refreshStatus: this.getSnapshot().refreshStatus === 'refreshing' ? 'refreshing' : 'idle',
packages: sortPackages(bundles.value.map(bundle => packageView(bundle, plugins.value))),
})
} while (this.shouldRerun())
@@ -779,15 +833,21 @@ export class PluginManagerController {
const install = state.install
const spec = install.spec.trim()
if (install.phase === 'checking' || isInstallPending(install.phase) || spec === '') return
+ if (this.ctx.get('productAnalytics')?.enabled) {
+ this.analyticsAttempt = { input: sanitizeInstallInput(spec), started: Date.now() }
+ this.ctx.get('productAnalytics')?.track('plugin_install_click', { input_value: sanitizeInstallInput(spec) })
+ }
// A name the list already shows is refused at once, before the Host is asked.
if (state.packages.some(pkg => pkg.name === spec)) {
+ this.finishAnalytics('failed', 'already-installed')
this.patchInstall({ phase: 'idle', inputError: { problem: 'already-installed', reason: spec } })
return
}
const choice = install.registry
const typed = choice.kind === 'custom' ? choice.url.trim() : undefined
if (typed !== undefined && !REGISTRY_URL.test(typed)) {
- this.patchInstall({ phase: 'idle', registryError: true })
+ this.finishAnalytics('failed', 'invalid-registry')
+ this.patchInstall({ phase: 'idle', registryError: true, registryOpen: true })
return
}
this.abortInspect()
@@ -810,11 +870,13 @@ export class PluginManagerController {
if (this.gone(controller.signal)) return
this.inspectAbort = undefined
if (!inspected.ok) {
+ this.finishAnalytics('failed', inspected.error.code)
this.patchInstall({ phase: 'idle', inputError: { problem: 'unknown', reason: inspected.error.message } })
return
}
if (inspected.value.status === 'refused') {
const { problem, reason, registries } = inspected.value
+ this.finishAnalytics('failed', problem)
this.patchInstall({ phase: 'idle', inputError: { problem, reason, ...registries === undefined ? {} : { registries } } })
return
}
@@ -865,12 +927,14 @@ export class PluginManagerController {
return
}
this.request = undefined
+ this.finishAnalytics('unknown')
this.patchInstall({ phase: 'unknown', failure: null, runs: settledRuns(this.getSnapshot().install.runs, null) })
this.notifyHiddenInstall('unknown')
void this.load()
}
private settleInstall(result: ChangeResult): void {
+ this.finishAnalytics(result.application === 'failed' ? 'failed' : result.application === 'cancelled' ? 'cancelled' : 'success', result.application === 'failed' ? result.error?.code : undefined, result.bundle)
const { runs, attempts } = this.getSnapshot().install
this.request = undefined
// The Host's answer names every registry it asked, whether or not each attempt's announcement arrived.
@@ -916,6 +980,7 @@ export class PluginManagerController {
const install = this.getSnapshot().install
const pending = install.failure?.pendingBuilds
if (install.phase !== 'failed' || install.subject === null || pending === undefined || pending.length === 0) return
+ if (this.ctx.get('productAnalytics')?.enabled) this.analyticsAttempt = { input: sanitizeInstallInput(install.subject.spec), started: Date.now() }
await this.startInstall(install.subject, pending)
}
@@ -927,6 +992,7 @@ export class PluginManagerController {
private async cancelInstall(): Promise {
const install = this.getSnapshot().install
if (install.phase === 'checking' || install.phase === 'failed' || install.phase === 'unknown') {
+ if (install.phase === 'checking') this.finishAnalytics('cancelled')
this.abortInspect()
this.offerSpecAgain()
return
@@ -953,6 +1019,7 @@ export class PluginManagerController {
return
}
if (result.value.status === 'cancelled') {
+ this.finishAnalytics('cancelled')
this.offerSpecAgain({ kind: 'cancelled', seq: ++this.noticeSeq })
void this.load()
} else if (result.value.status === 'too-late') {
@@ -1006,6 +1073,7 @@ export class PluginManagerController {
if (this.disposed) return
try {
this.applied(result, name)
+ if (result.ok && (result.value.application === 'applied' || result.value.application === 'restart-required')) this.trackToggle(name, true)
} catch (error) {
this.patch({ notice: failedNotice(error, { packageName: name, action: 'enable' }, ++this.noticeSeq) })
}
@@ -1062,6 +1130,27 @@ export class PluginManagerController {
}
}
+ private trackToggle(name: string, enabled: boolean, row = false): void {
+ const bundle = this.getSnapshot().packages.find(pkg => row ? pkg.rows.some(item => item.entryId === name) : pkg.name === name)
+ const pluginName = row ? bundle?.rows.find(item => item.entryId === name)?.moduleName : name
+ if (bundle === undefined || pluginName === undefined) return
+ this.ctx.get('productAnalytics')?.track('plugin_toggle', {
+ plugin_name: pluginName, plugin_type: row ? 'plugin' : 'bundle', is_enabled: enabled, is_builtin: !bundle.installed,
+ })
+ }
+
+ private finishAnalytics(status: 'success' | 'failed' | 'cancelled' | 'unknown', reason?: string, name?: string): void {
+ const attempt = this.analyticsAttempt
+ this.analyticsAttempt = undefined
+ if (attempt === undefined || this.disposed) return
+ const errorReason = status === 'cancelled' ? 'user_cancelled' : status === 'unknown' ? 'unknown_result' : reason
+ this.ctx.get('productAnalytics')?.track('install_plugin_result', {
+ input_value: attempt.input, is_success: status === 'success',
+ duration: Math.max(0, Date.now() - attempt.started),
+ ...errorReason === undefined ? {} : { error_reason: errorReason }, ...name === undefined ? {} : { plugin_name: name },
+ })
+ }
+
private patch(next: Partial): void {
if (this.disposed) return
this.store.set({ ...this.getSnapshot(), ...next })
diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts
index 292d604386..727d893729 100644
--- a/packages/client/ui-plugin-manager/src/client/presentation.ts
+++ b/packages/client/ui-plugin-manager/src/client/presentation.ts
@@ -143,6 +143,7 @@ export function noticeText(notice: ManagerNotice, t: Translate): string {
case 'restart': return t('restartNotice')
case 'overridden': return t('overriddenNotice', { name: notice.packageName })
case 'cancelled': return t('installCancelled')
+ case 'refresh-failed': return t('refreshError')
case 'install': return t(({
done: 'installBackgroundDone', failed: 'installBackgroundFailed',
unconfirmed: 'installBackgroundUnconfirmed', applying: 'installBackgroundApplying', unknown: 'installBackgroundUnknown',
diff --git a/packages/client/ui-plugin-manager/src/client/sanitize-install-input.ts b/packages/client/ui-plugin-manager/src/client/sanitize-install-input.ts
new file mode 100644
index 0000000000..c9caed9767
--- /dev/null
+++ b/packages/client/ui-plugin-manager/src/client/sanitize-install-input.ts
@@ -0,0 +1,13 @@
+/** Privacy-safe installation input classification shared by click and result events. */
+
+/**
+ * Keep registry package names and plain versions; classify other installer inputs without their contents.
+ * @param spec - user-entered installation spec.
+ * @returns an identifier safe to send without URL credentials, tokens, or local paths.
+ */
+export function sanitizeInstallInput(spec: string): string {
+ if (/^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*(?:@[a-z0-9.*^~+<>=| -]+)?$/iu.test(spec)) return spec
+ if (/^(?:git[+:]|git@|github:|gitlab:|bitbucket:)/iu.test(spec)) return '[git]'
+ if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(spec)) return '[url]'
+ return '[path-or-other]'
+}
diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx
index f9cff227dc..155a15c69b 100644
--- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx
+++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx
@@ -12,6 +12,7 @@ import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-t
import * as settings from '@deepseek-ai/dsh-client-ui-settings/client'
import { apply, inject, NS, PANEL_ID } from '../src/client/index.ts'
import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx'
+import { PluginRefreshToast, type PluginRefreshToastFace } from '../src/client/PluginRefreshToast.tsx'
import { PluginsPanelIcon } from '../src/client/PluginsPanelIcon.tsx'
import type { PluginManagerFace } from '../src/client/manager-store.ts'
@@ -54,6 +55,7 @@ function declare(slots: SlotRegistry): () => void {
name: 'root',
children: {
'main': { kind: 'keyed', scope: 'root' },
+ 'shell.overlay': { kind: 'list', scope: 'root' },
'sidebar.panellist': { kind: 'list', scope: 'root' },
},
} as never, () => null)
@@ -80,6 +82,41 @@ describe('ui-plugin-manager browser plugin', () => {
expect(navigation.getSnapshot()).toEqual({ view: { kind: 'package', name: 'dsh-navigation-test' } })
})
+ it('shares refresh failures with the overlay after navigation and releases both registrations across reloads', async () => {
+ const b = await bench()
+ const fiber = b.ctx.plugin({ inject: [...inject], apply })
+ await fiber.await()
+ expect(b.slots.entries('shell.overlay')).toHaveLength(0)
+ const removeRoot = declare(b.slots)
+ const mainInjected: object = b.slots.entries('main')[0]!.inject!()
+ const face = mainInjected as PluginManagerFace
+ const overlayInjected: object = b.slots.entries('shell.overlay')[0]!.inject!()
+ const overlay = overlayInjected as PluginRefreshToastFace
+ expect(overlay.hooks.pluginManager).toBe(face.hooks.pluginManager)
+ face.ensure()
+ await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') })
+ b.selectPanel(PANEL_ID)
+ b.list.mockRejectedValueOnce(new Error('offline'))
+ face.refresh()
+ b.selectPanel(null)
+ await vi.waitFor(() => { expect(overlay.hooks.pluginManager.getSnapshot().notice?.kind).toBe('refresh-failed') })
+ overlay.dismissNotice()
+ expect(face.hooks.pluginManager.getSnapshot().notice).toBeNull()
+ removeRoot()
+ expect(b.slots.entries('shell.overlay')).toHaveLength(0)
+ expect(b.slots.entries('main')).toHaveLength(0)
+ declare(b.slots)
+ const remountedInjected: object = b.slots.entries('shell.overlay')[0]!.inject!()
+ expect((remountedInjected as PluginRefreshToastFace).hooks.pluginManager).toBe(face.hooks.pluginManager)
+ await fiber.dispose()
+ expect(b.slots.entries('shell.overlay')).toHaveLength(0)
+ expect(b.slots.entries('main')).toHaveLength(0)
+ await b.ctx.plugin({ inject: [...inject], apply }).await()
+ const reloadedInjected: object = b.slots.entries('shell.overlay')[0]!.inject!()
+ expect((reloadedInjected as PluginRefreshToastFace).hooks.pluginManager).not.toBe(face.hooks.pluginManager)
+ expect((reloadedInjected as PluginRefreshToastFace).hooks.pluginManager.getSnapshot().notice).toBeNull()
+ })
+
it('declares only the services the page and its Remote methods use', () => {
expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout'])
})
@@ -116,7 +153,18 @@ describe('ui-plugin-manager browser plugin', () => {
for (const name of ['plugins.detail.actions', 'plugins.detail.badge', 'plugins.detail.section'] as const) {
expect(b.slots.spec(name)).toMatchObject({ kind: 'list', scope: 'root' })
}
- const face = (entry.inject as unknown as () => PluginManagerFace)()
+ const injected: object = entry.inject!()
+ const face = injected as PluginManagerFace
+ const overlay = b.slots.entries('shell.overlay')[0]!
+ expect(overlay.component).toBe(PluginRefreshToast)
+ expect(overlay.options).toMatchObject({ id: 'plugin-manager.refresh-toast' })
+ expect(overlay.locale).toBe(NS)
+ const overlayInjected: object = overlay.inject!()
+ const overlayFace = overlayInjected as PluginRefreshToastFace
+ expect(overlayFace.hooks.pluginManager).toBe(face.hooks.pluginManager)
+ expect(overlayFace.dismissNotice).toBe(face.dismissNotice)
+ expect(Object.keys(overlayFace).sort()).toEqual(['dismissNotice', 'hooks'])
+ expect(Object.keys(overlayFace.hooks)).toEqual(['pluginManager'])
const text = { en: 'Local tools', zh: '本地工具' }
expect(face.resolveText(text)).toBe('本地工具')
b.locale.setLocale('en')
@@ -146,6 +194,7 @@ describe('ui-plugin-manager browser plugin', () => {
await fiber.dispose()
expect(b.ctx.get('pluginNavigation')).toBeUndefined()
expect(b.slots.entries('main')).toHaveLength(0)
+ expect(b.slots.entries('shell.overlay')).toHaveLength(0)
expect(b.slots.entries('sidebar.panellist')).toHaveLength(0)
b.remote.emit('plugin-manager/changed', [{ reason: 'install' }])
await Promise.resolve()
diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx
index 09303b9e7a..beaa13ac3d 100644
--- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx
+++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import type { ConfigPageForm } from '../src/client/slot-contract.ts'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
-import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type { PluginEntryId, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client'
@@ -11,6 +11,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { StrictMode, type ReactNode } from 'react'
import { createNavigationStore } from '../src/client/navigation-store.ts'
import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx'
+import { PluginRefreshToast } from '../src/client/PluginRefreshToast.tsx'
import type { PluginManagerPageProps } from '../src/client/index.ts'
import type { ConfigLedger } from '../src/client/config-ledger.ts'
import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts'
@@ -54,9 +55,9 @@ const OFFICIAL = 'https://registry.npmjs.org/'
const REGISTRIES = { registry: null, fallbackRegistries: [MIRROR], resolved: OFFICIAL }
/** pnpm's own registry as the options label it: by npm's own name once read, the neutral default name until then. */
-const OFFICIAL_OPTION = en.registryWithHost.replace('{name}', en.registryOfficial).replace('{host}', 'registry.npmjs.org')
-const UNREAD_OPTION = en.registryWithHost.replace('{name}', en.registryDefault).replace('{host}', 'registry.npmjs.org')
-const MIRROR_OPTION = en.registryWithHost.replace('{name}', en.registryNpmmirror).replace('{host}', 'registry.npmmirror.com')
+const OFFICIAL_OPTION = `${en.registryOfficial} registry.npmjs.org`
+const UNREAD_OPTION = `${en.registryDefault} registry.npmjs.org`
+const MIRROR_OPTION = `${en.registryNpmmirror} registry.npmmirror.com`
const IDLE_INSTALL: InstallState = {
open: false, spec: '', registries: null, registry: { kind: 'offered', registry: null }, registryOpen: false, registryError: false, attempts: null,
@@ -66,6 +67,7 @@ const IDLE_INSTALL: InstallState = {
const READY: PluginManagerState = {
status: 'ready',
+ refreshStatus: 'idle',
packages: [],
busy: [],
notice: null,
@@ -163,9 +165,18 @@ function renderTab(
return body(owner.view, owner, 'form' in owner ? owner.form as ConfigPageForm | undefined : undefined)
},
}
- const { rerender, unmount } = render()
+ let showPage = true
+ let currentT = t
+ const contents = () => (
+ <>
+ {showPage ? : null}
+
+ >
+ )
+ const { rerender, unmount } = render(contents())
return {
navigation,
+ hidePage: () => { showPage = false; rerender(contents()) },
props,
unmount,
store,
@@ -173,7 +184,8 @@ function renderTab(
set: (next: Partial) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) },
setLanguage: (dict: typeof en) => {
locale.setLocale(dict === zh ? 'zh' : 'en')
- rerender()
+ currentT = translate(dict)
+ rerender(contents())
},
}
}
@@ -203,9 +215,14 @@ describe('PluginManagerPage', () => {
it('asks the store once mounted and renders the loading, unavailable, error, and empty states', () => {
const { actions, set } = renderTab({ status: 'loading' })
expect(actions.ensure).toHaveBeenCalledTimes(1)
- expect(screen.getByText(en.loading).querySelector('[data-state="ongoing"]')).not.toBeNull()
+ const loading = screen.getByRole('status', { name: en.loading })
+ expect(loading.querySelectorAll('li')).toHaveLength(4)
+ expect(loading.querySelector('ul')?.getAttribute('aria-hidden')).toBe('true')
+ expect(loading.querySelector('button, input, [data-state="ongoing"]')).toBeNull()
+ expect(document.querySelector('[data-plugin-panel]')?.getAttribute('aria-busy')).toBe('true')
expect(screen.getByRole('button', { name: en.addPlugin })).toHaveProperty('disabled', true)
set({ status: 'unavailable' })
+ expect(screen.queryByRole('status', { name: en.loading })).toBeNull()
expect(screen.getByRole('status').querySelector('[data-state="idle"]')).not.toBeNull()
set({ status: 'error' })
expect(screen.getByRole('alert').querySelector('[data-state="error"]')).not.toBeNull()
@@ -219,6 +236,134 @@ describe('PluginManagerPage', () => {
expect(actions.openInstall).toHaveBeenCalledTimes(1)
})
+ it('shows skeletons only while loading and keeps ready cards during refresh and switching', () => {
+ const { actions, set } = renderTab({ status: 'idle' })
+ expect(screen.queryByRole('status', { name: en.loading })).toBeNull()
+ set({ status: 'loading' })
+ expect(screen.getByRole('status', { name: en.loading })).toBeTruthy()
+ set({ status: 'ready', packages: [pkg()] })
+ expect(screen.queryByRole('status', { name: en.loading })).toBeNull()
+ expect(document.querySelector('[data-plugin-panel]')?.getAttribute('aria-busy')).toBe('false')
+ fireEvent.click(screen.getByRole('button', { name: en.refresh }))
+ expect(actions.refresh).toHaveBeenCalledTimes(1)
+ set({ busy: ['dsh-better-sidebar'] })
+ expect(screen.queryByRole('status', { name: en.loading })).toBeNull()
+ expect(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') })).toBeTruthy()
+ expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'dsh-better-sidebar') })).toHaveProperty('disabled', true)
+ })
+
+ it('shows refresh progress on the disabled button while preserving cards, then restores it without a toast', () => {
+ const { actions, set } = renderTab({ packages: [pkg()] })
+ const refresh = screen.getByRole('button', { name: en.refresh })
+ const detailName = en.openDetail.replace('{name}', 'dsh-better-sidebar')
+ const card = screen.getByRole('button', { name: detailName })
+ expect(refresh).toHaveProperty('disabled', false)
+ expect(refresh.getAttribute('title')).toBeNull()
+ fireEvent.click(refresh)
+ expect(actions.refresh).toHaveBeenCalledOnce()
+ set({ refreshStatus: 'refreshing' })
+ expect(refresh).toHaveProperty('disabled', true)
+ expect(refresh.getAttribute('aria-busy')).toBe('true')
+ expect(refresh.querySelector('[data-state="ongoing"]')).not.toBeNull()
+ expect(screen.getByRole('button', { name: detailName })).toBe(card)
+ expect(screen.queryByRole('status', { name: en.loading })).toBeNull()
+ expect(screen.queryByRole('alert')).toBeNull()
+ fireEvent.click(refresh)
+ expect(actions.refresh).toHaveBeenCalledOnce()
+ set({ refreshStatus: 'idle' })
+ expect(refresh).toHaveProperty('disabled', false)
+ expect(refresh.getAttribute('aria-busy')).toBe('false')
+ expect(refresh.querySelector('[data-state="ongoing"]')).toBeNull()
+ expect(screen.getByRole('button', { name: detailName })).toBe(card)
+ expect(screen.queryByRole('alert')).toBeNull()
+ expect(actions.dismissNotice).not.toHaveBeenCalled()
+ })
+
+ it.each(['list', 'detail'] as const)('keeps cached %s content with one localized refresh toast and no inline retry', (view) => {
+ const { set, setLanguage } = renderTab({ packages: [pkg()] })
+ if (view === 'detail') fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
+ const content = document.querySelector(view === 'detail' ? '[data-plugin-detail]' : '[data-plugin-package]')
+ for (const dict of [en, zh]) {
+ setLanguage(dict)
+ set({ status: 'ready', refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: 1 } })
+ expect(screen.getAllByRole('alert')).toHaveLength(1)
+ const toast = screen.getByRole('alert')
+ expect(toast.textContent).toBe(dict.refreshError)
+ expect(toast.parentElement).toBe(document.body)
+ expect(document.querySelector('[data-plugin-panel]')?.contains(toast)).toBe(false)
+ expect(screen.queryByRole('button', { name: dict.retry })).toBeNull()
+ expect(content?.isConnected).toBe(true)
+ set({ refreshStatus: 'refreshing', notice: null })
+ expect(screen.queryByRole('alert')).toBeNull()
+ expect(content?.isConnected).toBe(true)
+ set({ refreshStatus: 'idle' })
+ expect(screen.queryByRole('alert')).toBeNull()
+ }
+ })
+
+ it.each(['list', 'configuration detail'] as const)('keeps an uncached refresh failure inline in %s and hides it during retry', (view) => {
+ const { actions, set, setLanguage } = renderTab(
+ { status: 'error', refreshStatus: 'failed' },
+ { items: [{ id: 'bash', label: 'Shell' }] },
+ )
+ if (view === 'configuration detail') fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Shell') }))
+ for (const dict of [en, zh]) {
+ setLanguage(dict)
+ set({ status: 'error', refreshStatus: 'failed' })
+ const alert = screen.getByRole('alert')
+ expect(alert.textContent).toBe(dict.refreshError)
+ expect(document.querySelector('[data-plugin-panel]')?.contains(alert)).toBe(true)
+ fireEvent.click(screen.getByRole('button', { name: dict.retry }))
+ set({ refreshStatus: 'refreshing' })
+ expect(screen.queryByRole('alert')).toBeNull()
+ expect(screen.queryByRole('button', { name: dict.retry })).toBeNull()
+ }
+ expect(actions.refresh).toHaveBeenCalledTimes(2)
+ })
+
+ it.each(['before', 'after'] as const)('keeps the refresh toast when navigation leaves %s the failure arrives', (navigation) => {
+ vi.useFakeTimers()
+ try {
+ const { actions, set, hidePage } = renderTab({ packages: [pkg()] })
+ set({ refreshStatus: 'refreshing' })
+ if (navigation === 'before') hidePage()
+ set({ refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: 1 } })
+ const toast = screen.getByRole('alert')
+ expect(toast.textContent).toBe(en.refreshError)
+ if (navigation === 'after') hidePage()
+ expect(document.querySelector('[data-plugin-panel]')).toBeNull()
+ expect(screen.getByRole('alert')).toBe(toast)
+ expect(toast.style.getPropertyValue('--dsh-toast-hold')).toBe('3000ms')
+ act(() => { vi.advanceTimersByTime(3_999) })
+ expect(actions.dismissNotice).not.toHaveBeenCalled()
+ act(() => { vi.advanceTimersByTime(1) })
+ expect(actions.dismissNotice).toHaveBeenCalledOnce()
+ set({ notice: null })
+ expect(screen.queryByRole('alert')).toBeNull()
+ } finally {
+ cleanup()
+ vi.useRealTimers()
+ }
+ })
+
+ it('gives a repeated refresh failure its own toast lifetime without extending it for locale changes', () => {
+ vi.useFakeTimers()
+ try {
+ const { actions, set, setLanguage } = renderTab({ notice: { kind: 'refresh-failed', seq: 1 } })
+ act(() => { vi.advanceTimersByTime(2_000) })
+ set({ notice: { kind: 'refresh-failed', seq: 2 } })
+ act(() => { vi.advanceTimersByTime(2_000) })
+ expect(actions.dismissNotice).not.toHaveBeenCalled()
+ setLanguage(zh)
+ expect(screen.getByRole('alert').textContent).toBe(zh.refreshError)
+ act(() => { vi.advanceTimersByTime(2_000) })
+ expect(actions.dismissNotice).toHaveBeenCalledOnce()
+ } finally {
+ cleanup()
+ vi.useRealTimers()
+ }
+ })
+
it('keeps the read failure and its retry visible while a detail page is open', () => {
const { actions, set } = renderTab({ packages: [pkg()] })
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'dsh-better-sidebar') }))
@@ -735,6 +880,19 @@ describe('PluginManagerPage', () => {
expect(document.querySelector('[data-plugin-name]')?.textContent).toBe(name)
})
+ it.each([
+ { locale: en, placeholder: 'for example dsh-plugin-whale-pet' },
+ { locale: zh, placeholder: '例如 dsh-plugin-whale-pet' },
+ ])('uses the package example in $placeholder and the install guide', ({ locale, placeholder }) => {
+ const { actions, setLanguage } = renderTab({ install: { ...IDLE_INSTALL, open: true } })
+ setLanguage(locale)
+ expect(screen.getByPlaceholderText(placeholder)).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: locale.installGuideToggle }))
+ expect(screen.getByText('dsh-plugin-whale-pet')).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: locale.installGuideFillAria.replace('{example}', 'dsh-plugin-whale-pet') }))
+ expect(actions.editInstallSpec).toHaveBeenCalledExactlyOnceWith('dsh-plugin-whale-pet')
+ })
+
it('opens a guide under the field and drops an example into it', () => {
const { actions } = renderTab({ install: { ...IDLE_INSTALL, open: true } })
expect(screen.queryByText(en.installGuideIdHint)).toBeNull()
@@ -857,6 +1015,100 @@ describe('PluginManagerPage', () => {
expect(within(detail).getByText(en.rowStateIdle)).toBeTruthy()
})
+ describe.each(['package', 'custom registry'] as const)('%s input IME confirmation', (input) => {
+ beforeEach(() => { vi.useFakeTimers() })
+ afterEach(() => { cleanup(); vi.useRealTimers() })
+ const inputName = input === 'package' ? en.installSpecLabel : en.registryCustom
+ const open: InstallState = {
+ ...IDLE_INSTALL, open: true, spec: 'dsh-new', registryOpen: true,
+ registry: { kind: 'custom', url: 'https://npm.corp.example/' },
+ }
+ const enter = (field: HTMLElement) => {
+ // oxlint-disable-next-line typescript/no-deprecated -- Exercise native Enter's legacy keyCode in the IME regression.
+ const event = new KeyboardEvent('keydown', { key: 'Enter', isComposing: false, keyCode: 13, bubbles: true, cancelable: true })
+ fireEvent(field, event)
+ expect(event.defaultPrevented).toBe(false)
+ }
+
+ it('guards active composition and the first 10ms after compositionend without native flags', () => {
+ const { actions } = renderTab({ install: open })
+ const field = screen.getByRole('textbox', { name: inputName })
+ fireEvent.compositionStart(field)
+ enter(field)
+ expect(actions.runInstall).not.toHaveBeenCalled()
+ fireEvent.compositionEnd(field)
+ enter(field)
+ act(() => { vi.advanceTimersByTime(9) })
+ enter(field)
+ expect(actions.runInstall).not.toHaveBeenCalled()
+ act(() => { vi.advanceTimersByTime(1) })
+ enter(field)
+ expect(actions.runInstall).toHaveBeenCalledOnce()
+ })
+
+ it('keeps composition state independent from the other input', () => {
+ const { actions } = renderTab({ install: open })
+ const field = screen.getByRole('textbox', { name: inputName })
+ const other = screen.getByRole('textbox', { name: input === 'package' ? en.registryCustom : en.installSpecLabel })
+ fireEvent.compositionStart(field)
+ enter(other)
+ expect(actions.runInstall).toHaveBeenCalledOnce()
+ enter(field)
+ expect(actions.runInstall).toHaveBeenCalledOnce()
+ })
+
+ it.each(['active', 'ended'] as const)('clears %s composition on blur and dialog reopen', (phase) => {
+ const { actions, set } = renderTab({ install: open })
+ const field = screen.getByRole('textbox', { name: inputName })
+ fireEvent.compositionStart(field)
+ if (phase === 'ended') fireEvent.compositionEnd(field)
+ fireEvent.blur(field)
+ enter(field)
+ expect(actions.runInstall).toHaveBeenCalledOnce()
+ fireEvent.compositionStart(field)
+ if (phase === 'ended') fireEvent.compositionEnd(field)
+ set({ install: { ...open, open: false } })
+ set({ install: open })
+ enter(screen.getByRole('textbox', { name: inputName }))
+ expect(actions.runInstall).toHaveBeenCalledTimes(2)
+ })
+
+ if (input === 'custom registry') {
+ it('clears composition when the registry menu closes and reopens', () => {
+ const { actions, set } = renderTab({ install: open })
+ fireEvent.compositionStart(screen.getByRole('textbox', { name: inputName }))
+ set({ install: { ...open, registryOpen: false } })
+ set({ install: open })
+ enter(screen.getByRole('textbox', { name: inputName }))
+ expect(actions.runInstall).toHaveBeenCalledOnce()
+ })
+ }
+
+ it.each([
+ { signal: 'isComposing', isComposing: true, keyCode: 13 },
+ { signal: 'legacy keyCode 229', isComposing: false, keyCode: 229 },
+ ])('leaves $signal Enter to the IME and installs on the next ordinary Enter', ({ isComposing, keyCode }) => {
+ const { actions } = renderTab({
+ install: {
+ ...IDLE_INSTALL, open: true, spec: 'dsh-new', registryOpen: input === 'custom registry',
+ registry: { kind: 'custom', url: 'https://npm.corp.example/' },
+ },
+ })
+ const field = screen.getByRole('textbox', { name: input === 'package' ? en.installSpecLabel : en.registryCustom })
+ fireEvent.compositionStart(field)
+ if (!isComposing) fireEvent.compositionEnd(field)
+ // oxlint-disable-next-line typescript/no-deprecated -- Exercise the legacy IME signal when isComposing is false.
+ const confirm = new KeyboardEvent('keydown', { key: 'Enter', isComposing, keyCode, bubbles: true, cancelable: true })
+ fireEvent(field, confirm)
+ expect(actions.runInstall).not.toHaveBeenCalled()
+ expect(confirm.defaultPrevented).toBe(false)
+ if (isComposing) fireEvent.compositionEnd(field)
+ act(() => { vi.advanceTimersByTime(10) })
+ fireEvent.keyDown(field, { key: 'Enter', isComposing: false, keyCode: 13 })
+ expect(actions.runInstall).toHaveBeenCalledOnce()
+ })
+ })
+
it('takes a spec, checks it, and words what the check refused', () => {
const { actions, set } = renderTab({ install: { ...IDLE_INSTALL, open: true } })
expect(screen.getByText(en.installDescription)).toBeTruthy()
@@ -1197,6 +1449,86 @@ describe('PluginManagerPage', () => {
}
})
+ it('selects the custom registry from its whole area and focuses the address without clearing it', () => {
+ const open = { ...IDLE_INSTALL, open: true, registryOpen: true, registries: REGISTRIES }
+ const { actions, set } = renderTab({ install: open })
+ const field = screen.getByRole('textbox', { name: en.registryCustom })
+ const label = screen.getByRole('radio', { name: en.registryCustom }).parentElement!
+ const area = label.parentElement!
+ expect(field).toHaveProperty('disabled', false)
+ for (const target of [area, label, screen.getByText(en.registryCustomHint)]) {
+ field.blur()
+ set({ install: open })
+ actions.chooseRegistry.mockClear()
+ fireEvent.pointerDown(target)
+ fireEvent.click(target)
+ expect(actions.chooseRegistry).toHaveBeenCalledExactlyOnceWith({ kind: 'custom', url: '' })
+ expect(document.activeElement).toBe(field)
+ }
+ set({ install: { ...open, registry: { kind: 'custom', url: 'https://npm.corp/' } } })
+ field.blur()
+ actions.chooseRegistry.mockClear()
+ field.focus()
+ fireEvent.click(field)
+ fireEvent.click(area)
+ expect(document.activeElement).toBe(field)
+ expect(field).toHaveProperty('value', 'https://npm.corp/')
+ expect(actions.chooseRegistry).not.toHaveBeenCalled()
+ field.blur()
+ fireEvent.click(screen.getByRole('radio', { name: OFFICIAL_OPTION }))
+ set({ install: open })
+ expect(field).toHaveProperty('value', 'https://npm.corp/')
+ fireEvent.click(screen.getByRole('radio', { name: MIRROR_OPTION }))
+ set({ install: { ...open, registry: { kind: 'offered', registry: MIRROR } } })
+ fireEvent.click(area)
+ expect(actions.chooseRegistry).toHaveBeenLastCalledWith({ kind: 'custom', url: 'https://npm.corp/' })
+ expect(document.activeElement).toBe(field)
+ })
+
+ it('keeps the selected registry on keyboard focus and selects custom through its radio', () => {
+ const open = { ...IDLE_INSTALL, open: true, registryOpen: true, registries: REGISTRIES }
+ const { actions } = renderTab({ install: open })
+ const field = screen.getByRole('textbox', { name: en.registryCustom })
+ const official = screen.getByRole('radio', { name: OFFICIAL_OPTION })
+ official.focus()
+ fireEvent.keyDown(official, { key: 'Tab' })
+ expect(document.activeElement).toBe(field)
+ expect(official).toHaveProperty('checked', true)
+ expect(actions.chooseRegistry).not.toHaveBeenCalled()
+ fireEvent.keyDown(field, { key: 'Tab', shiftKey: true })
+ expect(document.activeElement).toBe(official)
+ fireEvent.keyDown(official, { key: 'Tab', shiftKey: true })
+ const toggle = screen.getByRole('button', { name: `${en.registryToggle} ${en.registryOfficial}` })
+ expect(document.activeElement).toBe(toggle)
+ expect(actions.toggleRegistryOptions).toHaveBeenCalledOnce()
+ actions.toggleRegistryOptions.mockClear()
+ field.focus()
+ fireEvent.keyDown(field, { key: 'Tab' })
+ expect(document.activeElement).toBe(toggle)
+ expect(actions.toggleRegistryOptions).toHaveBeenCalledOnce()
+ const custom = screen.getByRole('radio', { name: en.registryCustom })
+ custom.focus()
+ fireEvent.keyDown(custom, { key: ' ' })
+ fireEvent.click(custom)
+ expect(actions.chooseRegistry).toHaveBeenCalledExactlyOnceWith({ kind: 'custom', url: '' })
+ expect(document.activeElement).toBe(field)
+ })
+
+ it('focuses an invalid custom address when submission opens its registry menu', () => {
+ const open = { ...IDLE_INSTALL, open: true, spec: 'dsh-new', registry: { kind: 'custom' as const, url: 'invalid' } }
+ const { actions, set } = renderTab({ install: open })
+ fireEvent.click(screen.getByRole('button', { name: en.installRun }))
+ expect(actions.runInstall).toHaveBeenCalledOnce()
+ set({ install: { ...open, registryOpen: true, registryError: true } })
+ const field = screen.getByRole('textbox', { name: en.registryCustom })
+ expect(document.activeElement).toBe(field)
+ expect(field.getAttribute('aria-invalid')).toBe('true')
+ expect(screen.getByRole('alert').textContent).toBe(en.registryCustomInvalid)
+ fireEvent.keyDown(field, { key: 'Escape' })
+ expect(actions.toggleRegistryOptions).toHaveBeenCalledOnce()
+ expect(actions.closeInstall).not.toHaveBeenCalled()
+ })
+
it('offers the registries under the spec, folded by default, and picks or types one', () => {
const open = { ...IDLE_INSTALL, open: true, registries: REGISTRIES }
const { actions, set } = renderTab({ install: open })
diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts
index 0cd5683e0e..895a4feccd 100644
--- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts
+++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts
@@ -71,10 +71,12 @@ const NO_CONFIG: HostObservable = {
subscribe: () => () => {},
}
-function bench(overrides: Partial>> = {}) {
+function bench(overrides: Partial>> = {}, enabled = true) {
const inventory = { list: overrides.inventory ?? vi.fn(() => Promise.resolve(ok({ entries: [], managementAvailable: true }))) }
const plugins = {
- listBundles: vi.fn(() => Promise.resolve(ok([BUNDLE]))),
+ listBundles: vi.fn<() => Promise> | ReturnType>>(
+ () => Promise.resolve(ok([BUNDLE])),
+ ),
listPlugins: vi.fn(() => Promise.resolve(ok(PLUGINS))),
inspect: vi.fn(() => Promise.resolve(ok(INSPECTED))),
registries: vi.fn(() => Promise.resolve(ok(REGISTRIES))),
@@ -87,7 +89,9 @@ function bench(overrides: Partial>> = {}
...overrides,
}
const probe = { fastest: overrides.fastest ?? vi.fn(() => Promise.resolve(ok(null))) }
+ const track = vi.fn()
const ctx = {
+ get: () => ({ enabled, track }),
configForms: { describe: () => ({ getSnapshot: () => ({ view: { namespaces: [] } }), subscribe: () => () => {} }), get: vi.fn((id: string) => `form:${id}`) },
remote: { pluginManager: plugins, pluginInventory: inventory, pluginRegistryProbe: probe },
} as never
@@ -100,7 +104,7 @@ function bench(overrides: Partial>> = {}
await vi.waitFor(() => { expect(state().install.phase).toBe('starting') })
return state().install.requestId as PluginInstallRequestId
}
- return { plugins, inventory, probe, controller, face, state, started }
+ return { plugins, inventory, probe, controller, face, state, started, track }
}
it('hands a custom page the shared configuration form of its entry', () => {
@@ -224,7 +228,229 @@ describe('PluginManagerController', () => {
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
})
+ it('keeps cached packages while a manual refresh reads and ignores repeated refreshes without a success notice', async () => {
+ vi.useFakeTimers()
+ onTestFinished(() => { vi.useRealTimers() })
+ const { plugins, inventory, face, state, controller } = bench()
+ await controller.load()
+ const packages = state().packages
+ const gate = deferred>>()
+ plugins.listBundles.mockReturnValueOnce(gate.promise)
+ face.refresh()
+ expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'refreshing', notice: null })
+ expect(state().packages).toBe(packages)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(plugins.listBundles).toHaveBeenCalledTimes(2)
+ face.refresh()
+ face.refresh()
+ gate.resolve(ok([{ ...BUNDLE, version: '0.17.0' }]))
+ await vi.advanceTimersByTimeAsync(399)
+ expect(state().refreshStatus).toBe('refreshing')
+ await vi.advanceTimersByTimeAsync(1)
+ expect(state().refreshStatus).toBe('idle')
+ expect(state().packages[0]?.version).toBe('0.17.0')
+ expect(state().status).toBe('ready')
+ expect(state().notice).toBeNull()
+ expect(inventory.list).toHaveBeenCalledTimes(2)
+ expect(plugins.listBundles).toHaveBeenCalledTimes(2)
+ expect(plugins.listPlugins).toHaveBeenCalledTimes(2)
+ })
+
+ it.each(['refused', 'rejected'] as const)('keeps cached packages ready after a %s refresh and clears its toast when retry starts', async (failure) => {
+ vi.useFakeTimers()
+ onTestFinished(() => { vi.useRealTimers() })
+ const { inventory, plugins, face, state, controller } = bench()
+ await controller.load()
+ const packages = state().packages
+ if (failure === 'refused') inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline'))
+ else inventory.list.mockRejectedValueOnce(new Error('transport down'))
+ face.refresh()
+ await vi.advanceTimersByTimeAsync(400)
+ expect(state().refreshStatus).toBe('idle')
+ expect(state()).toMatchObject({ status: 'ready', notice: { kind: 'refresh-failed', seq: 1 } })
+ expect(state().packages).toBe(packages)
+ const gate = deferred>>()
+ plugins.listBundles.mockReturnValueOnce(gate.promise)
+ face.refresh()
+ expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'refreshing', notice: null })
+ expect(state().packages).toBe(packages)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(plugins.listBundles).toHaveBeenCalledTimes(2)
+ gate.resolve(ok([BUNDLE]))
+ await vi.advanceTimersByTimeAsync(400)
+ expect(state().refreshStatus).toBe('idle')
+ expect(state()).toMatchObject({ status: 'ready', notice: null })
+ inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline again'))
+ face.refresh()
+ await vi.advanceTimersByTimeAsync(400)
+ expect(state().notice).toEqual({ kind: 'refresh-failed', seq: 2 })
+ face.dismissNotice()
+ expect(state().notice).toBeNull()
+ })
+
+ it.each(['refused', 'rejected'] as const)('keeps a %s first refresh failure inline without a toast', async (failure) => {
+ vi.useFakeTimers()
+ onTestFinished(() => { vi.useRealTimers() })
+ const inventory = failure === 'refused'
+ ? vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline'))
+ : vi.fn().mockRejectedValueOnce(new Error('transport down'))
+ const { face, state } = bench({ inventory })
+ face.refresh()
+ await vi.advanceTimersByTimeAsync(400)
+ expect(state().refreshStatus).toBe('failed')
+ expect(state()).toMatchObject({ status: 'error', packages: [], notice: null })
+ })
+
+ it('treats an empty successful inventory as cached for refresh failures', async () => {
+ vi.useFakeTimers()
+ onTestFinished(() => { vi.useRealTimers() })
+ const { inventory, face, state, controller } = bench({ listBundles: vi.fn().mockResolvedValue(ok([])) })
+ await controller.load()
+ expect(state().packages).toEqual([])
+ inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline'))
+ face.refresh()
+ await vi.advanceTimersByTimeAsync(400)
+ expect(state().refreshStatus).toBe('idle')
+ expect(state()).toMatchObject({ status: 'ready', packages: [], notice: { kind: 'refresh-failed' } })
+ })
+
+ it('forgets cached inventory when the managed profile becomes unavailable', async () => {
+ vi.useFakeTimers()
+ onTestFinished(() => { vi.useRealTimers() })
+ const { inventory, face, state, controller } = bench()
+ await controller.load()
+ inventory.list.mockResolvedValueOnce(ok({ entries: [], managementAvailable: false }))
+ await controller.load()
+ expect(state()).toMatchObject({ status: 'unavailable', packages: [] })
+ inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline'))
+ face.refresh()
+ await vi.advanceTimersByTimeAsync(400)
+ expect(state().refreshStatus).toBe('failed')
+ expect(state()).toMatchObject({ status: 'error', packages: [], notice: null })
+ })
+
+ it('preserves unrelated notices when a manual refresh starts and succeeds', async () => {
+ vi.useFakeTimers()
+ onTestFinished(() => { vi.useRealTimers() })
+ const { face, state, controller } = bench({
+ setBundleEnabled: vi.fn().mockResolvedValue(ok({ ...APPLIED, application: 'restart-required' })),
+ })
+ await controller.load()
+ face.setEnabled(BUNDLE.name, true)
+ await vi.advanceTimersByTimeAsync(0)
+ expect(state().notice?.kind).toBe('restart')
+ const notice = state().notice
+ face.refresh()
+ expect(state().notice).toBe(notice)
+ await vi.advanceTimersByTimeAsync(400)
+ expect(state().refreshStatus).toBe('idle')
+ expect(state().notice).toBe(notice)
+ })
+
+ it('keeps background reads quiet and clears an uncached refresh failure when they succeed', async () => {
+ vi.useFakeTimers()
+ onTestFinished(() => { vi.useRealTimers() })
+ const { plugins, face, state, controller } = bench({
+ inventory: vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline'))
+ .mockResolvedValue(ok({ entries: [], managementAvailable: true })),
+ })
+ face.refresh()
+ await vi.advanceTimersByTimeAsync(400)
+ expect(state().refreshStatus).toBe('failed')
+ const gate = deferred>>()
+ plugins.listBundles.mockReturnValueOnce(gate.promise)
+ const loading = controller.load()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(plugins.listBundles).toHaveBeenCalledOnce()
+ expect(state().refreshStatus).toBe('failed')
+ gate.resolve(ok([BUNDLE]))
+ await loading
+ expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'idle', notice: null })
+ })
+
+ it.each(['background', 'manual'] as const)('holds the refresh indicator through coalesced reads when %s starts first', async (first) => {
+ vi.useFakeTimers()
+ onTestFinished(() => { vi.useRealTimers() })
+ const { plugins, face, state, controller } = bench()
+ await controller.load()
+ const initial = deferred>>()
+ const rerun = deferred>>()
+ plugins.listBundles.mockReturnValueOnce(initial.promise).mockReturnValueOnce(rerun.promise)
+ const background = first === 'background' ? controller.load() : undefined
+ if (first === 'manual') face.refresh()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(plugins.listBundles).toHaveBeenCalledTimes(2)
+ expect(state().refreshStatus).toBe(first === 'background' ? 'idle' : 'refreshing')
+ const completion = background ?? controller.load()
+ if (first === 'background') face.refresh()
+ initial.resolve(ok([{ ...BUNDLE, version: '0.17.0' }]))
+ await vi.advanceTimersByTimeAsync(400)
+ expect(plugins.listBundles).toHaveBeenCalledTimes(3)
+ expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'refreshing', notice: null })
+ expect(state().packages[0]?.version).toBe('0.17.0')
+ face.refresh()
+ rerun.resolve(ok([{ ...BUNDLE, version: '0.18.0' }]))
+ await completion
+ await vi.advanceTimersByTimeAsync(0)
+ expect(state().refreshStatus).toBe('idle')
+ expect(state().packages[0]?.version).toBe('0.18.0')
+ expect(state().notice).toBeNull()
+ expect(plugins.listBundles).toHaveBeenCalledTimes(3)
+ })
+
+ it.each([false, true])('reports only the final coalesced refresh result (failure: %s)', async (failure) => {
+ vi.useFakeTimers()
+ onTestFinished(() => { vi.useRealTimers() })
+ const { plugins, face, state, controller } = bench()
+ await controller.load()
+ const initial = deferred> | ReturnType>()
+ const rerun = deferred> | ReturnType>()
+ plugins.listBundles.mockReturnValueOnce(initial.promise).mockReturnValueOnce(rerun.promise)
+ face.refresh()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(plugins.listBundles).toHaveBeenCalledTimes(2)
+ const loading = controller.load()
+ initial.resolve(failure ? ok([BUNDLE]) : refused('gateway/internal', 'offline'))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(plugins.listBundles).toHaveBeenCalledTimes(3)
+ expect(state().refreshStatus).toBe('refreshing')
+ expect(state().notice).toBeNull()
+ rerun.resolve(failure ? refused('gateway/internal', 'offline') : ok([BUNDLE]))
+ await loading
+ await vi.advanceTimersByTimeAsync(400)
+ expect(state().refreshStatus).toBe('idle')
+ expect(state().status).toBe('ready')
+ expect(state().notice).toEqual(failure ? { kind: 'refresh-failed', seq: 1 } : null)
+ })
+
+ it.each([false, true])('does not publish a manual refresh that settles after disposal (failure: %s)', async (failure) => {
+ vi.useFakeTimers()
+ onTestFinished(() => { vi.useRealTimers() })
+ const { plugins, face, state, controller } = bench()
+ await controller.load()
+ const gate = deferred> | ReturnType>()
+ plugins.listBundles.mockReturnValueOnce(gate.promise)
+ face.refresh()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(plugins.listBundles).toHaveBeenCalledTimes(2)
+ const completion = controller.load()
+ const before = state()
+ const listener = vi.fn()
+ const unsubscribe = face.hooks.pluginManager.subscribe(listener)
+ onTestFinished(unsubscribe)
+ controller.dispose()
+ gate.resolve(failure ? refused('gateway/internal', 'offline') : ok([{ ...BUNDLE, version: '0.17.0' }]))
+ await completion
+ face.refresh()
+ await vi.advanceTimersByTimeAsync(400)
+ expect(state()).toBe(before)
+ expect(listener).not.toHaveBeenCalled()
+ expect(plugins.listBundles).toHaveBeenCalledTimes(2)
+ })
+
it('reports a Host without a managed profile as unavailable and keeps the last packages across a failed read', async () => {
+ vi.useFakeTimers()
+ onTestFinished(() => { vi.useRealTimers() })
const { inventory, plugins, face, state, controller } = bench()
await controller.load()
expect(state().packages).toHaveLength(1)
@@ -239,11 +465,12 @@ describe('PluginManagerController', () => {
plugins.listPlugins.mockResolvedValueOnce(refused('gateway/internal', 'offline') as never)
await controller.load()
expect(state()).toMatchObject({ status: 'error', packages: [packageView(BUNDLE, PLUGINS)] })
- plugins.listBundles.mockResolvedValueOnce(refused('gateway/internal', 'offline') as never)
+ plugins.listBundles.mockResolvedValueOnce(refused('gateway/internal', 'offline'))
await controller.load()
expect(state().status).toBe('error')
face.refresh()
- await vi.waitFor(() => { expect(state().status).toBe('ready') })
+ await vi.advanceTimersByTimeAsync(400)
+ expect(state().status).toBe('ready')
})
it('enables a bundle, marks it busy meanwhile, and says when a restart is needed or a layer overrides it', async () => {
@@ -311,7 +538,7 @@ describe('PluginManagerController', () => {
})
it('always asks before uninstalling, and cancelling runs nothing', async () => {
- const { plugins, face, state, controller } = bench()
+ const { plugins, face, state, controller, track } = bench()
await controller.load()
face.uninstall(BUNDLE.name)
expect(state().confirm).toEqual({ action: 'uninstall', packageName: BUNDLE.name })
@@ -319,10 +546,12 @@ describe('PluginManagerController', () => {
expect(state().confirm).toBeNull()
face.confirm()
expect(plugins.removeBundle).not.toHaveBeenCalled()
+ expect(track).not.toHaveBeenCalled()
face.uninstall(BUNDLE.name)
face.confirm()
expect(state().confirm).toBeNull()
await vi.waitFor(() => { expect(plugins.removeBundle).toHaveBeenCalledExactlyOnceWith(BUNDLE.name) })
+ expect(track).toHaveBeenCalledExactlyOnceWith('confirm_uninstall_plugin', { plugin_name: BUNDLE.name })
await vi.waitFor(() => { expect(state().busy).toEqual([]) })
// A refused removal names the action it was.
plugins.removeBundle.mockResolvedValueOnce(ok({ ...failed(), stage: 'remove', error: { code: 'not-removable' } }) as never)
@@ -857,7 +1086,7 @@ describe('PluginManagerController', () => {
expect(state().highlight).toBeNull()
})
- it('offers the scripts a blocked run left pending, and retries the same spec with them allowed', async () => {
+ it.each([true, false])('offers the scripts a blocked run left pending, and retries with analytics enabled=%s', async (enabled) => {
const gates: ReturnType> | ReturnType>>>[] = []
const { face, state, controller, plugins, started } = bench({
installBundle: vi.fn(() => {
@@ -865,7 +1094,7 @@ describe('PluginManagerController', () => {
gates.push(gate)
return gate.promise
}),
- })
+ }, enabled)
await controller.load()
face.openInstall()
face.editInstallSpec('x')
@@ -1049,6 +1278,52 @@ describe('PluginManagerController', () => {
await loading
expect(state()).toBe(before)
})
+
+ it.each([false, true])('drops refresh feedback when disposed during the spinner hold (failure: %s)', async (failure) => {
+ vi.useFakeTimers()
+ try {
+ const { plugins, face, state, controller } = bench()
+ await controller.load()
+ plugins.listBundles.mockResolvedValueOnce(failure ? refused('gateway/internal', 'offline') : ok([BUNDLE]))
+ face.refresh()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(state().refreshStatus).toBe('refreshing')
+ expect(vi.getTimerCount()).toBe(1)
+ const before = state()
+ const listener = vi.fn()
+ const unsubscribe = face.hooks.pluginManager.subscribe(listener)
+ onTestFinished(unsubscribe)
+ controller.dispose()
+ await vi.advanceTimersByTimeAsync(400)
+ expect(state()).toBe(before)
+ expect(listener).not.toHaveBeenCalled()
+ expect(vi.getTimerCount()).toBe(0)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('settles a manual refresh whose read outlasts the spinner minimum without an extra hold', async () => {
+ vi.useFakeTimers()
+ try {
+ const { plugins, face, state, controller } = bench()
+ await controller.load()
+ const gate = deferred>>()
+ plugins.listBundles.mockReturnValueOnce(gate.promise)
+ face.refresh()
+ await vi.advanceTimersByTimeAsync(401)
+ expect(plugins.listBundles).toHaveBeenCalledTimes(2)
+ expect(state().refreshStatus).toBe('refreshing')
+ gate.resolve(ok([{ ...BUNDLE, version: '0.42.0' }]))
+ await vi.advanceTimersByTimeAsync(0)
+ expect(state().refreshStatus).toBe('idle')
+ expect(state().packages[0]?.version).toBe('0.42.0')
+ expect(state().status).toBe('ready')
+ expect(vi.getTimerCount()).toBe(0)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
it('offers the registries the Host configured, starts from its first, and asks the check and the run to start where the person chose', async () => {
const gate = deferred>>()
const { plugins, face, state, controller, started } = bench({
@@ -1152,15 +1427,18 @@ describe('PluginManagerController', () => {
removeItem: (key: string) => { storage.delete(key) },
})
try {
- const { plugins, face, state } = bench({
+ const { plugins, face, state, track } = bench({
inspect: vi.fn(() => Promise.resolve(ok({ ...INSPECTED, registry: 'https://npm.corp.example/' }))),
})
face.openInstall()
face.editInstallSpec('dsh-new')
face.chooseRegistry({ kind: 'custom', url: ' npm.corp.example ' })
face.runInstall()
- expect(state().install).toMatchObject({ phase: 'idle', registryError: true })
+ expect(state().install).toMatchObject({ phase: 'idle', registryError: true, registryOpen: true })
expect(plugins.inspect).not.toHaveBeenCalled()
+ expect(track.mock.calls.filter(([event]) => event === 'install_plugin_result')).toEqual([
+ ['install_plugin_result', expect.objectContaining({ input_value: 'dsh-new', is_success: false, error_reason: 'invalid-registry' })],
+ ])
// Typing again clears the refusal; a URL is asked as typed, trimmed.
face.chooseRegistry({ kind: 'custom', url: ' https://npm.corp.example ' })
expect(state().install.registryError).toBe(false)
@@ -1168,6 +1446,9 @@ describe('PluginManagerController', () => {
expect(plugins.inspect).toHaveBeenCalledWith('dsh-new', { registry: 'https://npm.corp.example' }, expect.any(AbortSignal))
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
expect(plugins.installBundle).toHaveBeenCalledWith('dsh-new', expect.objectContaining({ registry: 'https://npm.corp.example/' }))
+ const results = track.mock.calls.filter(([event]) => event === 'install_plugin_result')
+ expect(results).toHaveLength(2)
+ expect(results[1]).toEqual(['install_plugin_result', expect.objectContaining({ is_success: true, plugin_name: 'dsh-new' })])
// A dialog opened later, by another controller, starts from the registry last used.
const later = bench()
later.face.openInstall()
@@ -1412,3 +1693,102 @@ it('recognizes the official registry without a trailing slash and with uppercase
face.openInstall()
await vi.waitFor(() => { expect(state().install.registry).toEqual({ kind: 'offered', registry: MIRROR }) })
})
+
+
+describe('desktop analytics outcomes', () => {
+ it('redacts authenticated installer URLs from click and failed-result events', async () => {
+ const b = bench({ inspect: vi.fn(async () => ok({ status: 'refused', problem: 'not-bundle', reason: 'refused' })) })
+ b.face.openInstall()
+ b.face.editInstallSpec('git+https://user:private-token@example.invalid/repo.git?token=private-token')
+ b.face.runInstall()
+ await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ input_value: '[git]' })) })
+ expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: '[git]' })
+ expect(JSON.stringify(b.track.mock.calls)).not.toContain('private-token')
+ })
+
+ it('reports input inspection failure separately from cancellation', async () => {
+ const b = bench({ inspect: vi.fn(async () => ok({ status: 'refused', problem: 'not-bundle', reason: 'not a bundle' })) })
+ b.face.openInstall()
+ b.face.editInstallSpec('dsh-new')
+ b.face.runInstall()
+ await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ input_value: 'dsh-new', is_success: false, error_reason: 'not-bundle', duration: expect.any(Number) as number })) })
+ expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: 'dsh-new' })
+ })
+
+ it('reports unknown only after recovery confirms the result is absent', async () => {
+ const lost = deferred>>()
+ const b = bench({ installBundle: vi.fn(async () => refused('gateway/internal', 'offline')), waitForInstall: vi.fn(() => lost.promise) })
+ b.face.openInstall()
+ b.face.editInstallSpec('dsh-new')
+ b.face.runInstall()
+ await vi.waitFor(() => { expect(b.plugins.waitForInstall).toHaveBeenCalledTimes(1) })
+ expect(b.track.mock.calls.filter(call => call[0] === 'install_plugin_result')).toEqual([])
+ lost.resolve(ok(null))
+ await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ error_reason: 'unknown_result', is_success: false })) })
+ b.face.reconcileInstall()
+ expect(b.track.mock.calls.filter(call => call[0] === 'install_plugin_result')).toHaveLength(1)
+ })
+
+ it('closing during inspection reports user_cancelled once', async () => {
+ const inspecting = deferred>>()
+ const b = bench({ inspect: vi.fn(() => inspecting.promise) })
+ b.face.openInstall()
+ b.face.editInstallSpec('dsh-new')
+ b.face.runInstall()
+ await vi.waitFor(() => { expect(b.plugins.inspect).toHaveBeenCalledTimes(1) })
+ b.face.closeInstall()
+ inspecting.resolve(ok(INSPECTED))
+ await Promise.resolve()
+ const results = b.track.mock.calls.filter(call => call[0] === 'install_plugin_result')
+ expect(results).toHaveLength(1)
+ expect(results[0]?.[1]).toMatchObject({ error_reason: 'user_cancelled', is_success: false })
+ expect(results[0]?.[1]).not.toHaveProperty('result_status')
+ expect(results[0]?.[1]).not.toHaveProperty('plugin_type')
+ expect(b.plugins.installBundle).not.toHaveBeenCalled()
+ })
+
+ it('uses bundle and plugin types only after successful changes', async () => {
+ const b = bench()
+ await b.controller.load()
+ b.face.setEnabled(BUNDLE.name, true)
+ await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('plugin_toggle', { plugin_name: BUNDLE.name, plugin_type: 'bundle', is_enabled: true, is_builtin: false }) })
+ b.face.setRowEnabled(ROW_ENTRY, false)
+ await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('plugin_toggle', { plugin_name: BUNDLE.name, plugin_type: 'plugin', is_enabled: false, is_builtin: false }) })
+ b.plugins.setBundleEnabled.mockResolvedValueOnce(ok({ ...APPLIED, application: 'cancelled' }))
+ b.face.setEnabled(BUNDLE.name, false)
+ await vi.waitFor(() => { expect(b.state().busy).toEqual([]) })
+ expect(b.track.mock.calls.filter(call => call[0] === 'plugin_toggle')).toHaveLength(2)
+ })
+})
+
+
+it('does not retain install identity when analytics is disabled', async () => {
+ const b = bench({}, false)
+ b.face.openInstall()
+ b.face.editInstallSpec('dsh-new')
+ b.face.runInstall()
+ await vi.waitFor(() => { expect(b.state().install.phase).toBe('done') })
+ expect(b.track.mock.calls.filter(([name]) => name === 'plugin_install_click' || name === 'install_plugin_result')).toEqual([])
+})
+
+it('reports restart-required row toggles and tolerates a vanished inventory entry', async () => {
+ const b = bench({ setPluginEnabled: vi.fn(async () => ok({ ...APPLIED, application: 'restart-required' })) })
+ await b.controller.load()
+ b.face.setRowEnabled(ROW_ENTRY, true)
+ await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('plugin_toggle', expect.objectContaining({ plugin_type: 'plugin' })) })
+ b.face.setEnabled('vanished-package', true)
+ await vi.waitFor(() => { expect(b.state().busy).toEqual([]) })
+ expect(b.track.mock.calls.filter(([name]) => name === 'plugin_toggle')).toHaveLength(1)
+})
+
+it('reports a successful install with only documented result fields', async () => {
+ const b = bench()
+ b.face.openInstall()
+ b.face.editInstallSpec('dsh-new')
+ b.face.runInstall()
+ await vi.waitFor(() => { expect(b.state().install.phase).toBe('done') })
+ const results = b.track.mock.calls.filter(([name]) => name === 'install_plugin_result')
+ expect(results).toEqual([['install_plugin_result', {
+ input_value: 'dsh-new', is_success: true, duration: expect.any(Number) as number, plugin_name: 'dsh-new',
+ }]])
+})
diff --git a/packages/client/ui-plugin-manager/tests/sanitize-install-input.client.spec.ts b/packages/client/ui-plugin-manager/tests/sanitize-install-input.client.spec.ts
new file mode 100644
index 0000000000..d3e3e2bc17
--- /dev/null
+++ b/packages/client/ui-plugin-manager/tests/sanitize-install-input.client.spec.ts
@@ -0,0 +1,17 @@
+import { expect, it } from 'vitest'
+import { sanitizeInstallInput } from '../src/client/sanitize-install-input.ts'
+
+it.each(['dsh-example', '@scope/plugin', '@scope/plugin@1.2.3', 'plugin@latest'])('retains a registry identifier: %s', (spec) => {
+ expect(sanitizeInstallInput(spec)).toBe(spec)
+})
+
+it.each([
+ ['git+https://user:secret@example.invalid/org/repo.git?token=secret#secret', '[git]'],
+ ['https://user:secret@example.invalid/archive.tgz?signature=secret#secret', '[url]'],
+ ['git@example.invalid:private/repo', '[git]'],
+ ['/Users/private-name/plugin', '[path-or-other]'],
+ ['C:\\Users\\private-name\\plugin', '[path-or-other]'],
+ ['plugin@https://user:secret@example.invalid/archive.tgz', '[path-or-other]'],
+])('removes private installer input: %s', (spec, expected) => {
+ expect(sanitizeInstallInput(spec)).toBe(expected)
+})
diff --git a/packages/client/ui-plugin-manager/tsconfig.client.json b/packages/client/ui-plugin-manager/tsconfig.client.json
index 4d56e3dee7..5f8c1f5e42 100644
--- a/packages/client/ui-plugin-manager/tsconfig.client.json
+++ b/packages/client/ui-plugin-manager/tsconfig.client.json
@@ -47,6 +47,9 @@
},
{
"path": "../ui-settings/tsconfig.client.json"
+ },
+ {
+ "path": "../product-analytics/tsconfig.client.json"
}
],
"include": [
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index 6203412c5d..9df146dd15 100644
--- a/packages/client/ui-primitives/README.i18n.yaml
+++ b/packages/client/ui-primitives/README.i18n.yaml
@@ -18,8 +18,8 @@
en: ddec1f6f9b018747
zh: 9555f132daf8901c
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
- en: 54ced5d959bd06c6
- zh: 5a7fc2504f92a788
+ en: 27c3b3715866df73
+ zh: 65c07b00516149ad
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
en: 83052e14c3fb34cf
zh: 0adb09885723296a
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index c9f0cb4a5a..0da0f9f934 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -55,7 +55,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `DisclosureRow` | 24px compact disclosure that lays title and content side by side. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. |
| `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. The tint and dialog fade in while backdrop blur stays fully applied, honoring reduced motion. Set `backdropBlur={false}` when the caller already blurs the source page. |
| `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. |
-| `Tooltip` | Hover text anchored to a cloned child; optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. |
+| `Tooltip` | Hover and keyboard-focus text anchored to a cloned child; optional `delayMs` controls hover delay and `focusDelayMs` controls keyboard-focus delay, both defaulting to 0 ms. Optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. |
| `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. |
| `ImageLightbox` | Shared image modal with focus restoration and Escape dismissal. |
| `Toast` | Transient top-center banner held for the owner's `holdMs`. |
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index de6adf1a2d..f22fa7b540 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -55,7 +55,7 @@ kind: "package-library"
| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 |
| `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 色层与弹窗淡入,背景模糊始终完整生效,并遵循减少动态效果偏好。调用方已模糊源页面时设置 `backdropBlur={false}`。 |
| `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 |
-| `Tooltip` | 锚定在克隆子元素上的悬停文本;可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 |
+| `Tooltip` | 锚定在克隆子元素上的悬停与键盘聚焦提示;可选的 `delayMs` 控制悬停延迟,`focusDelayMs` 控制键盘聚焦延迟,均默认为 0 毫秒。可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 |
| `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 |
| `ImageLightbox` | 共享图片浮层,支持焦点恢复与 Esc 关闭。 |
| `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx
index 3d0783c422..b997279f54 100644
--- a/packages/client/ui-primitives/src/Tooltip.tsx
+++ b/packages/client/ui-primitives/src/Tooltip.tsx
@@ -40,7 +40,9 @@ type TooltipLabel = string | (() => string)
* bubble would overlap); default 'center'. Ignored for side 'right'.
* @param props.portal - render the bubble under document.body, so an ancestor's clipping or its
* stacking context (which confines the bubble's z-index to that context) cannot hide it.
- * @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate.
+ * @param props.delayMs - hover delay in milliseconds (default 0).
+ * @param props.focusDelayMs - keyboard focus delay in milliseconds (default 0); blur, click,
+ * mouse leave, disabling, and unmount cancel a pending show.
* @param props.gap - anchor-to-bubble distance in pixels for 'bottom'/'top' bubbles (default 8);
* ignored for side 'right'.
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
@@ -53,7 +55,7 @@ type TooltipLabel = string | (() => string)
* anchor dismisses the bubble until the next trigger, and focus arriving after a pointer
* interaction (a closing menu refocusing its trigger) never raises it.
*/
-export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', delayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; shortcutKeys?: readonly string[] | undefined; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement }) {
+export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', delayMs = 0, focusDelayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; shortcutKeys?: readonly string[] | undefined; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; focusDelayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement }) {
const anchor = useRef(null)
// React 18 keeps the element's ref outside props; forward it so wrapping an
// anchor in Tooltip never silently severs the owner's ref.
@@ -157,16 +159,16 @@ export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center',
})
announce(true)
}
- const showAfterHoverDelay = () => {
+ const showAfterDelay = (delay: number) => {
cancelShow()
- if (delayMs <= 0) {
+ if (delay <= 0) {
show()
return
}
showTimer.current = setTimeout(() => {
showTimer.current = null
show()
- }, delayMs)
+ }, delay)
}
const withdraw = () => {
setPos(null)
@@ -198,13 +200,18 @@ export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center',
{cloneElement(children, {
ref: mergedRef,
- onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterHoverDelay() },
+ onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; showAfterDelay(delayMs) },
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; cancelShow(); withdraw() },
// Activating the anchor dismisses the bubble: the action often changes
// what the anchor now does (pin → unpin), and the click leaves the
// anchor focused, which would otherwise pin the relabelled bubble up.
onClick: (e) => { children.props.onClick?.(e); triggers.current.focus = false; cancelShow(); withdraw() },
- onFocus: (e) => { children.props.onFocus?.(e); if (pointerModality()) return; triggers.current.focus = true; cancelShow(); show() },
+ onFocus: (e) => {
+ children.props.onFocus?.(e)
+ if (pointerModality()) return
+ triggers.current.focus = true
+ showAfterDelay(focusDelayMs)
+ },
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
})}
{portal ? (content !== false && createPortal(content, document.body)) : content}
diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts
index 0e4cc0a527..57511f4dab 100644
--- a/packages/client/ui-primitives/src/index.ts
+++ b/packages/client/ui-primitives/src/index.ts
@@ -59,6 +59,7 @@ export type {
export { projectUserText, type UserTextReferences } from './user-text.tsx'
export { Tooltip } from './Tooltip.tsx'
export type { TooltipSide } from './Tooltip.tsx'
+export { pointerModality } from './input-modality.ts'
export { Toast } from './Toast.tsx'
export { fileSizeText } from './file-size.ts'
export { writeClipboard } from './clipboard.ts'
diff --git a/packages/client/ui-primitives/src/plugin-artwork.tsx b/packages/client/ui-primitives/src/plugin-artwork.tsx
index 374d99e138..8fdbadb711 100644
--- a/packages/client/ui-primitives/src/plugin-artwork.tsx
+++ b/packages/client/ui-primitives/src/plugin-artwork.tsx
@@ -70,13 +70,13 @@ export const PluginArtworkSearch = ({ size = 36, className }: IconProps) => {
const uid = useArtworkId()
return (
diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts
index 9e4eef835c..e165c063b7 100644
--- a/packages/client/ui-settings-account/src/client/index.ts
+++ b/packages/client/ui-settings-account/src/client/index.ts
@@ -1,4 +1,5 @@
/** Desktop account settings registration and reconnecting Remote subscription. */
+import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
import type { TranscriptViewMode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -24,7 +25,7 @@ import { en, zh, type AccountKey } from './locales.ts'
import { AccountQuotaNotice, type AccountQuotaNoticeInjected } from './AccountQuotaNotice.tsx'
import { DESKTOP_ONBOARDING_NAMESPACE, type OnboardingSettings } from '../onboarding-settings.ts'
import { DesktopOnboardingController } from './onboarding-state.ts'
-import { DesktopOnboardingEntry } from './DesktopOnboardingEntry.tsx'
+import { DesktopOnboardingEntry, type DesktopOnboardingInjected } from './DesktopOnboardingEntry.tsx'
import { readOnboardingApiKeyPresence } from './onboarding-credentials.ts'
import { refreshAfterReturn } from './account-refresh.ts'
export type { AccountSectionInjected, AccountSectionProps } from './AccountSection.tsx'
@@ -211,7 +212,7 @@ export function apply(ctx: Context): void {
if ('dshDesktop' in globalThis) {
const controller = new DesktopOnboardingController(
ctx.configForms.get(DESKTOP_ONBOARDING_NAMESPACE),
- ctx.configForms.get<{ transcriptView: TranscriptViewMode; performanceUsage: 'compact' | 'detailed' }>('ui-chat'),
+ ctx.configForms.get<{ transcriptView?: TranscriptViewMode | null; performanceUsage: 'compact' | 'detailed' }>('ui-chat'),
enabled => ctx.configForms.developerTools.setEnabled(enabled),
operations.hooks.account,
readOnboardingApiKeyPresence,
@@ -229,13 +230,14 @@ export function apply(ctx: Context): void {
}, 'account: desktop credential readiness')
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay', id: 'desktop-onboarding', locale: 'settings.account',
- inject: () => ({
+ inject: (): DesktopOnboardingInjected => ({
hooks: { account: operations.hooks.account, onboarding: controller.state },
// Onboarding's recharge return re-reads profile and balance only.
...nativePlatform === undefined ? {} : { openPlatformPage: platformPageOpener(refresh) },
update: (change: OnboardingChange) => controller.update(change),
complete: (reason: 'completed' | 'skipped') => controller.complete(reason),
retry: () => controller.retry(),
+ track: (name, attributes) => ctx.get('productAnalytics')?.track(name, attributes),
}),
}, DesktopOnboardingEntry))
}
diff --git a/packages/client/ui-settings-account/src/client/onboarding-contract.ts b/packages/client/ui-settings-account/src/client/onboarding-contract.ts
index 5c1a82081c..4d3815d90e 100644
--- a/packages/client/ui-settings-account/src/client/onboarding-contract.ts
+++ b/packages/client/ui-settings-account/src/client/onboarding-contract.ts
@@ -1,4 +1,5 @@
/** Pure view inputs for the desktop onboarding flow. */
+import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client'
import type { OnboardingProgress } from '../onboarding-settings.ts'
import type { AccountSnapshot } from './AccountSection.tsx'
import type { PlatformPages } from './platform-pages.ts'
@@ -21,6 +22,8 @@ export type OnboardingChange = Partial,
- private readonly chat: ConfigForm<{ transcriptView: TranscriptViewMode; performanceUsage: 'compact' | 'detailed' }>,
+ private readonly chat: ConfigForm<{ transcriptView?: TranscriptViewMode | null; performanceUsage: 'compact' | 'detailed' }>,
private readonly setDeveloperTools: (enabled: boolean) => Promise,
private readonly account: HostObservable,
private readonly readApiKeyPresence: () => Promise,
diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts
index 20a430fec6..393d5232ec 100644
--- a/packages/client/ui-settings-account/tests/apply.client.spec.ts
+++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts
@@ -271,8 +271,13 @@ it('re-reads profile and balance but no bonus for the onboarding recharge return
vi.stubGlobal('dshPlatform', { open: vi.fn(), setBounds: vi.fn(), close: vi.fn() })
await c.reload(SELF)
const actions = operations(c)
+ const track = vi.fn()
+ c.ctx.provide('productAnalytics', { track } as never)
+ expect(actions).not.toHaveProperty('track')
const onboarding = injectedOf(c.ctx.slots.entries('shell.overlay')
.find(entry => entry.options.id === 'desktop-onboarding')!) as DesktopOnboardingInjected
+ onboarding.track?.('onboarding_page_view', { page_name: 'onboarding_recharge' })
+ expect(track).toHaveBeenCalledWith('onboarding_page_view', { page_name: 'onboarding_recharge' })
const host = injectedOf(platformHostEntry(c)!) as AccountPlatformHostInjected
c.mock.remote.account.getProfile.mockResolvedValue(ok(profile))
c.mock.remote.account.getBalance.mockResolvedValue(ok(null))
diff --git a/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx b/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx
index 80f0d38930..8152510505 100644
--- a/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx
+++ b/packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx
@@ -10,6 +10,7 @@ afterEach(() => { cleanup(); vi.unstubAllGlobals() })
function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', balance: 'zero' | 'positive' | 'bonus' | 'failed' | 'loading' = 'positive', status: DesktopOnboardingState['status'] = 'ready', copy: typeof zh = zh, creditFunded = false) {
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
+ const track = vi.fn()
const complete = vi.fn(async () => true)
const retry = vi.fn(async () => true)
// The shared host owns the native view; this flow only requests a page from it.
@@ -19,13 +20,15 @@ function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', bal
returnFromPage = onClose
return release
})
+ let changeStatus: (value: DesktopOnboardingState['status']) => void = () => {}
const update = vi.fn(async (_change: Parameters[0]) => true)
function App() {
const [state, setState] = useState({
status, visible: true, error: status === 'error' ? 'settings' : null, creditFunded,
progress: { version: 1, step, purpose: null, process: null, completion: null, usage: 'compact', developerTools: false },
})
- return copy[key]} complete={complete} retry={retry}
+ changeStatus = (value) => { setState(current => ({ ...current, status: value })) }
+ return copy[key]} complete={complete} retry={retry}
update={async (change) => {
setState(current => ({ ...current, status: 'saving' }))
const saved = await update(change)
@@ -38,7 +41,7 @@ function mount(step: DesktopOnboardingState['progress']['step'] = 'welcome', bal
}
render()
return {
- complete, openPlatformPage, release, update, retry,
+ complete, openPlatformPage, release, update, retry, track, changeStatus: (value: DesktopOnboardingState['status']) => { changeStatus(value) },
/** The viewer returning through the shared host's Back action. */
back: () => { returnFromPage?.() },
}
@@ -330,3 +333,39 @@ it('returns from purpose to credit without dropping the selected purpose', async
await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingBack })) })
expect(h.update).toHaveBeenCalledWith({ step: 'credit' })
})
+
+
+it('reports visible pages once, funded Continue, and popup close', async () => {
+ const b = mount('credit', 'positive', 'ready', zh, true)
+ expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_page_view', { page_name: 'onboarding_recharge' })
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingSkip })) })
+ expect(b.track).toHaveBeenCalledWith('onboarding_popup_view', { popup_name: 'skip_charge' })
+ b.track.mockClear()
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.close })) })
+ expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_popup_click', { popup_name: 'skip_charge', button_name: 'close' })
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh.onboardingContinue })) })
+ expect(b.track).toHaveBeenCalledWith('onboarding_page_click', { page_name: 'onboarding_recharge', button_name: 'continue' })
+ expect(b.track).toHaveBeenCalledWith('onboarding_page_view', { page_name: 'onboarding_use_case' })
+})
+
+it('does not report the loading surface as a page', () => {
+ expect(mount('welcome', 'loading', 'loading').track).not.toHaveBeenCalled()
+})
+
+
+it('does not repeat page or popup exposure during save and refresh', () => {
+ const b = mount('credit')
+ fireEvent.click(screen.getByRole('button', { name: zh.onboardingSkip }))
+ act(() => { b.changeStatus('saving') })
+ act(() => { b.changeStatus('loading') })
+ act(() => { b.changeStatus('ready') })
+ expect(b.track.mock.calls.filter(([name]) => name === 'onboarding_page_view')).toHaveLength(1)
+})
+
+it('reports closing the skip-settings popup', () => {
+ const b = mount('purpose')
+ fireEvent.click(screen.getByRole('button', { name: zh.onboardingSkip }))
+ b.track.mockClear()
+ fireEvent.click(screen.getByRole('button', { name: zh.close }))
+ expect(b.track).toHaveBeenCalledExactlyOnceWith('onboarding_popup_click', { popup_name: 'skip_setting', button_name: 'close' })
+})
diff --git a/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx b/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx
index 0cf569c39a..c03b3613d8 100644
--- a/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx
+++ b/packages/client/ui-settings-account/tests/sign-in-dialog.client.spec.tsx
@@ -178,3 +178,16 @@ it('keeps an outstanding start open until the request settles', async () => {
await act(async () => { pending.resolve(undefined); await pending.promise })
expect(screen.getByRole('button', { name: en.signIn }).hasAttribute('disabled')).toBe(false)
})
+
+// Workspace login remains outside native welcome analytics even if a caller forwards a sender.
+it('does not report workspace login views or login choices', async () => {
+ const track = vi.fn()
+ const props = { ...dialogProps(null), track }
+ const view = render()
+ view.rerender()
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.signIn })) })
+ expect(props.start).toHaveBeenCalledOnce()
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: en.addApiKey })) })
+ expect(props.useApiKey).toHaveBeenCalledOnce()
+ expect(track).not.toHaveBeenCalled()
+})
diff --git a/packages/client/ui-settings-account/tsconfig.json b/packages/client/ui-settings-account/tsconfig.json
index a2c29cb37a..ac170f07db 100644
--- a/packages/client/ui-settings-account/tsconfig.json
+++ b/packages/client/ui-settings-account/tsconfig.json
@@ -55,6 +55,9 @@
},
{
"path": "../../settings/settings"
+ },
+ {
+ "path": "../product-analytics/tsconfig.client.json"
}
]
}
diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml
index 1b6dd64dde..42484f8a4a 100644
--- a/packages/client/ui-settings-general/README.i18n.yaml
+++ b/packages/client/ui-settings-general/README.i18n.yaml
@@ -15,8 +15,8 @@
en: aeb9c4bfdb19bc56
zh: 45a29bea90cff239
/deepseek-ai-dsh-client-ui-settings-general/use-this-package:
- en: da896a9cf06fe5a5
- zh: 27545af457695988
+ en: 473e1f0843f7a896
+ zh: acfbc21efc458bbf
/deepseek-ai-dsh-client-ui-settings-general/use-this-package/the-general-section:
en: 8b8a7c6287bb303b
zh: 19535112531aedd5
diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md
index 95b38c7103..29a1a7d544 100644
--- a/packages/client/ui-settings-general/README.md
+++ b/packages/client/ui-settings-general/README.md
@@ -31,7 +31,7 @@ Users reach the shell through the sidebar's bottom Settings control; feature plu
When the section navigation exceeds the panel's available height, the list scrolls independently of the settings content and keeps the Settings title fixed.
-In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. It shares the connection indicator’s 28px height, 8px corners, 14px icon slot, 4px icon gap, and 12px medium text with an 18px line height; update-state border, fill, and text colors remain independently defined. Retry text and its dot use the same brand blue as other update labels; numeric download progress has no ellipsis. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a brand-blue dot on its top expand button, including failures. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow.
+In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. It shares the connection indicator’s 28px height, 8px corners, 14px icon slot, 4px icon gap, and 12px medium text with an 18px line height; update-state border, fill, and text colors remain independently defined. Retry text and its dot use the same brand blue as other update labels; numeric download progress has no ellipsis. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a brand-blue dot on its top expand button, including failures. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow. Checking, verifying, and preparing-to-restart tooltips show only the supplied version; without a version, they retain the state label. Desktop update tooltips preserve the complete version and prerelease suffix without adding v or V; their error summaries match the shell update dialogs.
Settings visibility and section selection live in the shell owner store. The shell supplies the effective Settings binding to the contributed launcher for menu keycaps and `aria-keyshortcuts`; the fallback button uses the same binding for hover and keyboard-focus hints and `aria-keyshortcuts`. The Settings command (`Mod+,` by default) toggles the dialog when Settings is in front or no modal is open; the sidebar control opens the same dialog. The command cannot open or close Settings while the shortcut reference or another modal is in front. Held-key repeats do nothing. Initial focus lands on the selected section in the navigation, or on the title when no sections are available, without drawing a focus outline. Tab and directional navigation retain their visible focus indicators. `Mod+/` can open the shortcut reference above Settings; `Escape` closes the top dialog and restores focus to its invoking control. Return focus omits outlines after shortcut, Escape, or pointer dismissal.
diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md
index 6874f1e5f8..f43966253f 100644
--- a/packages/client/ui-settings-general/README.zh.md
+++ b/packages/client/ui-settings-general/README.zh.md
@@ -31,7 +31,7 @@ kind: "package-reference"
当分区导航超出面板可用高度时,列表独立于设置内容滚动,并保持「设置」标题固定。
-在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。它与连接指示器共用 28px 高度、8px 圆角、14px 图标占位、4px 图文间距,以及 12px 中等字重文字和 18px 行高;更新状态的边框、填充和文字颜色独立定义。重试文字及其圆点与其他更新文案使用相同的品牌蓝色;数字下载进度不带省略号。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以品牌蓝色圆点显示相同状态,包括失败状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。
+在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。它与连接指示器共用 28px 高度、8px 圆角、14px 图标占位、4px 图文间距,以及 12px 中等字重文字和 18px 行高;更新状态的边框、填充和文字颜色独立定义。重试文字及其圆点与其他更新文案使用相同的品牌蓝色;数字下载进度不带省略号。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以品牌蓝色圆点显示相同状态,包括失败状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。检查中、校验中和准备重启状态的悬浮提示只显示已提供的版本号;没有版本号时保留状态文案。桌面更新提示完整保留版本号及预发布后缀,不额外添加 v 或 V;错误提示与桌面升级弹窗保持一致。
设置的可见状态和分区选择由 shell owner store 保存。外壳将设置命令的有效绑定传给入口贡献,用于菜单键帽和 `aria-keyshortcuts`;回退按钮使用相同绑定提供悬停、键盘聚焦提示和 `aria-keyshortcuts`。设置位于最前方或没有模态弹窗时,设置命令(默认为 `Mod+,`)切换设置弹窗的开关;侧栏控件打开同一弹窗。快捷键速查或其他模态弹窗位于前方时,该命令不能打开或关闭设置。长按产生的重复按键不执行操作。初始焦点落在导航中选中的分区;没有可用分区时落在标题上,均不绘制焦点外轮廓线。Tab 和方向键导航保留可见焦点提示。`Mod+/` 可在设置上方打开快捷键速查;`Escape` 关闭最上层弹窗,焦点返回触发该弹窗的控件。通过快捷键、Esc 或鼠标关闭后的回焦均不绘制外轮廓线。
diff --git a/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx
index 15c671155b..588b3befdb 100644
--- a/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx
+++ b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx
@@ -23,6 +23,9 @@ function updateCopy(state: DesktopUpdatePresentation, t: SettingsTranslate): { l
error: t('desktop.update.retry'),
}
const label = labels[state.phase]
+ if (state.phase === 'checking' || state.phase === 'verifying' || state.phase === 'installing') {
+ return { label, detail: state.version ?? label }
+ }
if (state.phase === 'error') {
const failures: Readonly> = {
check: t('desktop.update.checkFailed'),
diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts
index e5a3bd1855..777af69935 100644
--- a/packages/client/ui-settings-general/src/client/locales.ts
+++ b/packages/client/ui-settings-general/src/client/locales.ts
@@ -11,16 +11,16 @@ export const zh = {
'desktop.update.installing': '正在准备重启…',
'desktop.update.ready': '安装并重启',
'desktop.update.retry': '重试更新',
- 'desktop.update.versionDetail': '{label} — V{version}',
- 'desktop.update.downloadDetail': '正在下载更新:{percent}%\n目标版本:V{version}',
+ 'desktop.update.versionDetail': '{label}:{version}',
+ 'desktop.update.downloadDetail': '正在下载更新:{percent}%\n目标版本:{version}',
'desktop.update.checkFailed': '检查更新失败,请稍后重试。',
'desktop.update.downloadFailed': '下载更新失败,请重试。',
'desktop.update.installFailed': '安装更新失败,请稍后重试。',
- 'desktop.update.checkNetworkFailed': '检查更新失败,请稍后重试。网络连接异常,请检查网络后重试。',
- 'desktop.update.downloadNetworkFailed': '下载更新失败,请重试。网络连接异常,请检查网络后重试。',
- 'desktop.update.installNetworkFailed': '安装更新失败,请稍后重试。网络连接异常,请检查网络后重试。',
- 'desktop.update.stopFailed': '未能安全停止任务,更新未安装。请稍后重试。',
- 'desktop.update.tasksChanged': '有新任务开始,请重新确认更新。',
+ 'desktop.update.checkNetworkFailed': '检查更新失败,请检查网络连接后重试。',
+ 'desktop.update.downloadNetworkFailed': '下载更新失败,请检查网络连接后重试。',
+ 'desktop.update.installNetworkFailed': '安装更新失败,请检查网络连接后重试。',
+ 'desktop.update.stopFailed': '未能安全停止任务,更新尚未安装,请稍后重试。',
+ 'desktop.update.tasksChanged': '有新任务开始运行,请重新确认是否停止任务并更新。',
'desktop.update.tasksUnavailable': '无法确认任务状态,请在工作区就绪后重试更新。',
'title': '设置',
'close': '关闭',
@@ -52,16 +52,16 @@ export const en = {
'desktop.update.installing': 'Preparing to restart…',
'desktop.update.ready': 'Install and Restart',
'desktop.update.retry': 'Retry update',
- 'desktop.update.versionDetail': '{label} — V{version}',
- 'desktop.update.downloadDetail': 'Downloading update: {percent}%\nTarget version: V{version}',
+ 'desktop.update.versionDetail': '{label}: {version}',
+ 'desktop.update.downloadDetail': 'Downloading update: {percent}%\nTarget version: {version}',
'desktop.update.checkFailed': 'Could not check for updates. Please try again later.',
'desktop.update.downloadFailed': 'Could not download the update. Please try again.',
'desktop.update.installFailed': 'Could not install the update. Please try again later.',
- 'desktop.update.checkNetworkFailed': 'Could not check for updates. Please try again later. The connection was interrupted. Check your network and try again.',
- 'desktop.update.downloadNetworkFailed': 'Could not download the update. Please try again. The connection was interrupted. Check your network and try again.',
- 'desktop.update.installNetworkFailed': 'Could not install the update. Please try again later. The connection was interrupted. Check your network and try again.',
- 'desktop.update.stopFailed': 'Tasks could not be stopped safely. The update was not installed. Please try again later.',
- 'desktop.update.tasksChanged': 'New tasks started. Review the update confirmation again.',
+ 'desktop.update.checkNetworkFailed': 'Could not check for updates. Check your connection and try again.',
+ 'desktop.update.downloadNetworkFailed': 'Could not download the update. Check your connection and try again.',
+ 'desktop.update.installNetworkFailed': 'Could not install the update. Check your connection and try again.',
+ 'desktop.update.stopFailed': 'Could not safely stop the tasks. The update has not been installed. Please try again later.',
+ 'desktop.update.tasksChanged': 'New tasks have started. Confirm again to stop the tasks and update.',
'desktop.update.tasksUnavailable': 'Task status is unavailable. Try updating again when the workspace is ready.',
'title': 'Settings',
'close': 'Close',
diff --git a/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx b/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx
index 7e81c3f649..505a02d969 100644
--- a/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx
+++ b/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx
@@ -152,3 +152,28 @@ it('shows fallback progress and error details when the shell omits optional fiel
expect((await screen.findByRole('tooltip')).textContent).toBe('安装更新失败,请稍后重试。')
} finally { f.view.unmount(); f.status.resolve({ phase: 'idle' }) }
})
+
+it.each(['checking', 'verifying', 'installing'] as const)('shows only the version during %s in both locales', async (phase) => {
+ const f = fixture()
+ try {
+ await f.emit({ phase, version: '0.1.7-alpha.2' })
+ fireEvent.focus(screen.getByRole('button'))
+ expect((await screen.findByRole('tooltip')).textContent).toBe('0.1.7-alpha.2')
+ f.view.rerender()
+ fireEvent.focus(screen.getByRole('button'))
+ expect((await screen.findByRole('tooltip')).textContent).toBe('0.1.7-alpha.2')
+ } finally { f.view.unmount(); f.status.resolve({ phase: 'idle' }) }
+})
+
+it.each([
+ ['checking', '正在检查更新…'],
+ ['verifying', '正在校验更新文件…'],
+ ['installing', '正在准备重启…'],
+] as const)('retains the state label when %s has no version', async (phase, label) => {
+ const f = fixture()
+ try {
+ await f.emit({ phase })
+ fireEvent.focus(screen.getByRole('button', { name: label }))
+ expect((await screen.findByRole('tooltip')).textContent).toBe(label)
+ } finally { f.view.unmount(); f.status.resolve({ phase: 'idle' }) }
+})
diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml
index 3e3c096cab..dbd67087e4 100644
--- a/packages/client/ui-settings-models/README.i18n.yaml
+++ b/packages/client/ui-settings-models/README.i18n.yaml
@@ -6,8 +6,8 @@
en: d1bc4d9ef67bfb19
zh: d095e4dc76f1c5e0
/deepseek-ai-dsh-client-ui-settings-models:
- en: 65e1e8a17a23e576
- zh: 01de4e98a002d376
+ en: 0db6fa836e1ff511
+ zh: 0e2968763e1fb7a5
/deepseek-ai-dsh-client-ui-settings-models/summary:
en: 53301a5d29936f69
zh: 9ac408a33571dbdd
diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md
index ead2111e2b..c974239fed 100644
--- a/packages/client/ui-settings-models/README.md
+++ b/packages/client/ui-settings-models/README.md
@@ -7,6 +7,8 @@ kind: "package-reference"
English | [中文](README.zh.md)
+Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded.
+
## Summary
`dsh-client-ui-settings-models` is the Models settings page of the dsh web client: users configure API keys (stored write-only under the profile's credential reference), edit each provider's model list, and hand-declare custom pi-ai routes, with provider rows and one editor card at a time. The page joins the provider directory, the settings document, and the credential descriptions into one shared snapshot, so a row's state stays consistent across all three. It also walks first-run users through two ordered dialogs — a versioned internal-testing notice and the conditional official-DeepSeek credential step.
diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md
index 8f8e0720fb..3ac2bb0480 100644
--- a/packages/client/ui-settings-models/README.zh.md
+++ b/packages/client/ui-settings-models/README.zh.md
@@ -7,6 +7,8 @@ kind: "package-reference"
[English](README.md) | 中文
+桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。
+
## 概述
`dsh-client-ui-settings-models` 是 dsh Web 客户端的 Models 设置页面:用户可以配置 API 密钥(以只写方式存入 profile 的凭据引用之下)、编辑每个提供商的模型列表,并手工声明自定义 pi-ai 路由;页面以提供商行展示,一次只展开一张编辑卡片。该页面把提供商目录、设置文档与凭据描述合并为一个共享快照,因此行的状态在三个方面始终一致。它还会带首次运行的用户走两个有序弹窗——版本化内测声明,以及按条件显示的官方 DeepSeek 凭据步骤。
diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json
index 3d39cc682d..169aac59b9 100644
--- a/packages/client/ui-settings-models/package.json
+++ b/packages/client/ui-settings-models/package.json
@@ -57,7 +57,8 @@
"react": "^18.2.0",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
- "@deepseek-ai/dsh-remote-mock": "workspace:*"
+ "@deepseek-ai/dsh-remote-mock": "workspace:*",
+ "@deepseek-ai/dsh-client-product-analytics": "workspace:*"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:~"
diff --git a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx
index fdc32c4045..63c090b3d5 100644
--- a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx
+++ b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx
@@ -5,7 +5,7 @@
* official DeepSeek route. The step reuses that page's credential editor in
* the onboarding plugin's shared modal, so the key is entered once.
*/
-
+import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client'
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -22,6 +22,8 @@ import styles from './DeepSeekOnboardingDialog.module.css'
/** Registration-side dependencies of {@link DeepSeekOnboardingDialog}. */
export interface DeepSeekOnboardingInjected {
+ /** Desktop-only event collection. */
+ track?: TrackProductEvent
/** Whether first-run setup should show automatically. Explicit requests remain available. */
automatic: boolean
hooks: {
@@ -120,6 +122,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
readOnly={false}
hideTitle
credentialOnly
+ onSubmitCredential={() => { props.track?.('api_key_save_click', {}) }}
credentialRequired
autoFocusCredential
cancelLabelKey="onboardingLater"
diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx
index 41dac376d1..cd6656fdd5 100644
--- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx
+++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx
@@ -87,6 +87,8 @@ export interface ProviderEditorProps {
submitBusyLabelKey?: keyof typeof en
/** Close the editor; `changed` reports whether an Apply committed. */
onClose: (changed: boolean) => void
+ /** Called on a credential form submission before the save starts. */
+ onSubmitCredential?: () => void
/**
* Called once per change with whether the apply or the model list's
* endpoint interrogation is in flight, so the owner can hold its surface
@@ -530,7 +532,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
submitBusyLabelKey={props.submitBusyLabelKey ?? 'applying'}
{...props.cancelLabelKey === undefined ? {} : { cancelLabelKey: props.cancelLabelKey }}
onCancel={() => { props.onClose(false) }}
- onSubmit={() => { void apply() }}
+ onSubmit={() => { props.onSubmitCredential?.(); void apply() }}
/>
)
diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts
index a96665b11f..c80634a2b6 100644
--- a/packages/client/ui-settings-models/src/client/index.ts
+++ b/packages/client/ui-settings-models/src/client/index.ts
@@ -6,6 +6,7 @@
* Export discipline:
* packages/client/AGENTS.md.
*/
+import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
// Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry).
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
@@ -98,6 +99,7 @@ export function apply(ctx: ClientContext): void {
})
const deepSeekOnboardingInjected = (): DeepSeekOnboardingInjected => ({
automatic: credentialOnboarding,
+ track: (name, attributes) => ctx.get('productAnalytics')?.track(name, attributes),
controller,
hooks: { models: controller.store },
operations,
diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts
index 62196478f7..2372690ba2 100644
--- a/packages/client/ui-settings-models/tests/apply.client.spec.ts
+++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts
@@ -141,6 +141,12 @@ describe('ui-settings-models apply', () => {
})
const deepSeek = onboarding.find(entry => entry.options.id === 'deepseek-official')!
expect(deepSeek.component).toBe(DeepSeekOnboardingDialog)
+ const analytics = (deepSeek.inject!() as object) as import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
+ analytics.track?.('api_key_save_click', {})
+ const track = vi.fn()
+ before.ctx.provide('productAnalytics', { track } as never)
+ analytics.track?.('api_key_save_click', {})
+ expect(track).toHaveBeenCalledWith('api_key_save_click', {})
expect(deepSeek.options).toMatchObject({ id: 'deepseek-official', order: 0 })
const deepSeekInjected = (
deepSeek.inject as unknown as () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
diff --git a/packages/client/ui-settings-models/tsconfig.json b/packages/client/ui-settings-models/tsconfig.json
index 13bf556904..121f44cdde 100644
--- a/packages/client/ui-settings-models/tsconfig.json
+++ b/packages/client/ui-settings-models/tsconfig.json
@@ -8,8 +8,12 @@
"src"
],
"references": [
- { "path": "../../host/webserver" },
- { "path": "../../../vendor/schemastery" },
+ {
+ "path": "../../host/webserver"
+ },
+ {
+ "path": "../../../vendor/schemastery"
+ },
{
"path": "../../../vendor/cordis"
},
@@ -33,6 +37,9 @@
},
{
"path": "../../api/remotes/tsconfig.client.json"
+ },
+ {
+ "path": "../product-analytics/tsconfig.client.json"
}
]
}
diff --git a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml
index 6b7ff6a2bf..b30b1c29f8 100644
--- a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml
+++ b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml
@@ -9,8 +9,8 @@
en: 463eecf002ec0f54
zh: b8dc6003ddccd14a
/deepseek-ai-dsh-client-ui-settings-plugin-inventory/summary:
- en: 56f5349b6e502525
- zh: 691a0fe90b739e9d
+ en: f52353cf99816eb8
+ zh: e172c1751289882f
/deepseek-ai-dsh-client-ui-settings-plugin-inventory/table-of-contents:
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
@@ -18,14 +18,14 @@
en: 67637808e7940157
zh: d4d0716ea4d33e4a
/deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/reading-a-card:
- en: e615aaa891b5055f
- zh: 38769e763c8e54ca
+ en: 141d6a73e99fee51
+ zh: 2aa28b174867c967
/deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/the-preset-switcher:
en: 84e5938329199a22
zh: 6f5cfaac56433e8b
/deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/retrying-a-failed-read:
- en: 2859449aaae5ab59
- zh: 06380059ae429026
+ en: 673c7574e14078be
+ zh: b093e897f0341973
/deepseek-ai-dsh-client-ui-settings-plugin-inventory/understand-the-implementation:
en: cab883d26642c932
zh: 54e7321bd781b400
@@ -33,8 +33,8 @@
en: e4792880c5916ce0
zh: 4c8171a446dd153f
/deepseek-ai-dsh-client-ui-settings-plugin-inventory/understand-the-implementation/rendering:
- en: b9aa850e5f101b68
- zh: 8a29025f19f6ce37
+ en: 779146e3d3732803
+ zh: 0dfafa328dfeda75
/deepseek-ai-dsh-client-ui-settings-plugin-inventory/further-exploration:
en: ae3819f0c5da95bd
zh: 40972ab96d5c95e2
diff --git a/packages/client/ui-settings-plugin-inventory/README.md b/packages/client/ui-settings-plugin-inventory/README.md
index 7353f14d11..f6aa2312b8 100644
--- a/packages/client/ui-settings-plugin-inventory/README.md
+++ b/packages/client/ui-settings-plugin-inventory/README.md
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-The **Plugin list** tab lets Web users inspect plugins without changing their configuration. It lists agent presets, then the global inventory; both start collapsed and open on demand or during a search. Cards show localized titles and descriptions when available, identify instances by stable entry id, and expose enablement, source details, runtime status, disabled conditions, and discovery failures; preset-provided global entries name their presets. Search covers both groups and points to matches in other presets. The tab handles loading, empty, no-match, failure, and retry states without exposing transport details, and still shows the global inventory without a preset roster.
+The **Plugin list** tab lets Web users inspect plugins without changing their configuration. It lists agent presets, open by default, then the collapsed global inventory; a search opens both. Cards show localized titles and descriptions when available, identify instances by stable entry id, and expose enablement, source details, runtime status, disabled conditions, and discovery failures; preset-provided global entries name their presets. Search covers both groups and points to matches in other presets. The tab handles loading, empty, no-match, failure, and retry states without exposing transport details, and still shows the global inventory without a preset roster.
## Table of Contents
@@ -31,9 +31,9 @@ A failed installation with pending pnpm build permissions offers **Allow these s
### Reading a card
-Each collapsed card uses the plugin's localized title and description when available. Settings shortens literal package-name or module-name fallbacks by removing the npm scope and the `cordis:`, `cordis-plugin-`, or `dsh-`/`dsh-host-`/`dsh-client-` prefix; locale titles remain unchanged. The Host supplies locale and package-field fallbacks in `meta`; the Client selects the current language. Full module specifiers remain available in details and search. Missing descriptions are omitted, and metadata diagnostics appear with the card. This compact naming applies only to Settings, not the sidebar's Plugins page.
+Each collapsed card uses the plugin's localized title and description when available; a description shows at most two lines until its card is expanded. Settings shortens literal package-name or module-name fallbacks by removing the npm scope and the `cordis:`, `cordis-plugin-`, or `dsh-`/`dsh-host-`/`dsh-client-` prefix; locale titles remain unchanged. The Host supplies locale and package-field fallbacks in `meta`; the Client selects the current language. Full module specifiers remain available in details and search. Missing descriptions are omitted, and metadata diagnostics appear with the card. This compact naming applies only to Settings, not the sidebar's Plugins page.
-The stable entry id appears underneath the title, alongside a small enablement tag; a colored root-fiber status dot marks only the phases the tag cannot state: `pending` maps to idle, while `loading` and `unloading` map to ongoing. An `active` or `failed` fiber shows its tag without a dot. A composition-generated subtitle omits its leading `include:` marker, while hover, search, the accessible name, and expanded details retain the complete id. Long entry ids truncate in the row and remain available on hover. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-preset-registry/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset declarations' Chinese names. Search filters both groups by localized title and description, module name, and entry id.
+A small enablement tag marks only the states that differ from plainly enabled: disabled, conditional, provided by presets, and failed; a plainly enabled row carries no tag. A colored root-fiber status dot marks the live phases no tag states: `pending` maps to idle, while `loading` and `unloading` map to ongoing; an `active` or `failed` fiber shows no dot. The stable entry id appears in a code chip at the card's bottom-left, in line with the title, only when it differs from the title: a composition-generated id drops its leading `include:` marker there, and an id that then repeats the title is omitted. Whenever the chip shows, its hover text and the card's accessible name carry the complete id; search and expanded details always retain it. Long entry ids truncate in the chip and remain available on hover. Expanding one card reveals its full description, the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-preset-registry/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset declarations' Chinese names. Search filters both groups by localized title and description, module name, and entry id.
### The preset switcher
@@ -41,7 +41,7 @@ The switcher is the same selector-pill-plus-menu control the General settings ro
### Retrying a failed read
-A failed read renders the shared error marker inside the tab; loading and current-page synchronization use the shared ongoing loader. Retrying re-runs the lazy `list()` call without exposing transport details.
+A failed read renders the shared error marker inside the tab. Loading shows skeleton cards in the card grid, and current-page synchronization uses the shared ongoing loader. Retrying re-runs the lazy `list()` call without exposing transport details.
The Plugin list also shows synchronization failures on the current page. Its retry reapplies the latest client graph without changing Host enablement or refreshing the page.
@@ -61,7 +61,7 @@ The browser plugin registers one localized `settings.plugins.tab` contribution w
### Rendering
-Row keys are scope-qualified (`global:`, `preset::`), so one module appearing in both scopes keeps distinct disclosure state; a declared entry id appears in expanded details and supplies the collapsed subtitle after removal of a leading composition `include:` marker, while a row without one stays unlabeled. The preset-provided marking is derived client-side: a global entry carries it when it is disabled there while at least one preset row for the same module specifier is actually enabled, so a module every preset gates off (or declares only conditionally) stays plainly disabled rather than over-claiming provision.
+Row keys are scope-qualified (`global:`, `preset::`), so one module appearing in both scopes keeps distinct disclosure state; a declared entry id appears in expanded details and, after removal of a leading composition `include:` marker, supplies the collapsed id chip when the result differs from the title, while a row without one stays unlabeled. The preset-provided marking is derived client-side: a global entry carries it when it is disabled there while at least one preset row for the same module specifier is actually enabled, so a module every preset gates off (or declares only conditionally) stays plainly disabled rather than over-claiming provision.
diff --git a/packages/client/ui-settings-plugin-inventory/README.zh.md b/packages/client/ui-settings-plugin-inventory/README.zh.md
index 9078f8074a..cb0c2ce723 100644
--- a/packages/client/ui-settings-plugin-inventory/README.zh.md
+++ b/packages/client/ui-settings-plugin-inventory/README.zh.md
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
-**插件列表**标签页让 Web 用户查看插件,而不改变其配置。它先列 agent(智能体)预设分组,再列全局清单;两个分组默认都收起,按需展开,搜索期间保持展开。卡片显示可用的本地化标题与描述,以稳定的条目 id 标识实例,并展示启停状态、出处、运行状态、禁用条件与发现失败;由预设提供的全局条目会列出对应预设。搜索覆盖两个分组,并指出其他预设中的匹配。标签页处理加载、空结果、无匹配、失败与重试状态,且不暴露传输细节;没有预设 roster 时仍会展示全局清单。
+**插件列表**标签页让 Web 用户查看插件,而不改变其配置。它先列 agent(智能体)预设分组,再列全局清单;预设分组默认展开,全局分组默认收起,搜索期间两组都展开。卡片显示可用的本地化标题与描述,以稳定的条目 id 标识实例,并展示启停状态、出处、运行状态、禁用条件与发现失败;由预设提供的全局条目会列出对应预设。搜索覆盖两个分组,并指出其他预设中的匹配。标签页处理加载、空结果、无匹配、失败与重试状态,且不暴露传输细节;没有预设 roster 时仍会展示全局清单。
## 目录
@@ -31,9 +31,9 @@ kind: "package-reference"
### 阅读卡片
-每张收起的卡片使用插件可用的本地化标题与描述。设置页会缩短字面的包名或模块名回退值,移除 npm scope 以及 `cordis:`、`cordis-plugin-` 或 `dsh-`/`dsh-host-`/`dsh-client-` 前缀;locale 标题保持原样。Host 在 `meta` 中提供 locale 与包字段的回退结果,Client 选择当前语言。详情与搜索仍保留完整模块标识。缺失的描述不显示,元信息诊断随卡片展示。这种短名显示仅用于设置页,不影响侧栏的插件页。
+每张收起的卡片使用插件可用的本地化标题与描述;描述最多显示两行,展开卡片后显示全文。设置页会缩短字面的包名或模块名回退值,移除 npm scope 以及 `cordis:`、`cordis-plugin-` 或 `dsh-`/`dsh-host-`/`dsh-client-` 前缀;locale 标题保持原样。Host 在 `meta` 中提供 locale 与包字段的回退结果,Client 选择当前语言。详情与搜索仍保留完整模块标识。缺失的描述不显示,元信息诊断随卡片展示。这种短名显示仅用于设置页,不影响侧栏的插件页。
-稳定的条目 id 显示在标题下方,卡片带有表示启停状态的小标签;彩色根 fiber 状态圆点只标记标签无法表达的阶段:`pending` 映射为 idle,`loading` 与 `unloading` 映射为 ongoing。`active` 或 `failed` fiber 只显示标签,不显示圆点。组合生成的次标题省略开头的 `include:` 标记;悬停、搜索、无障碍名称与展开详情仍保留完整 id。长条目 id 会在行内截断,悬停时仍可查看完整值。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-preset-registry/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设声明里的中文名。搜索按本地化标题与描述、模块名称及条目 id 过滤两组。
+表示启停状态的小标签只标记与普通启用不同的状态:已停用、条件启用、由预设提供与启动失败;普通启用的行不带标签。彩色根 fiber 状态圆点标记没有标签表达的存活阶段:`pending` 映射为 idle,`loading` 与 `unloading` 映射为 ongoing;`active` 或 `failed` fiber 不显示圆点。稳定的条目 id 仅在与标题不同时显示,位于卡片左下角的代码标签中,与标题左对齐:组合生成的 id 在此省略开头的 `include:` 标记,省略后与标题相同的 id 不再重复显示。显示代码标签时,其悬停提示与卡片的无障碍名称带完整 id;搜索与展开详情始终保留完整 id。长条目 id 会在代码标签内截断,悬停时仍可查看完整值。展开卡片后会显示完整描述、声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-preset-registry/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设声明里的中文名。搜索按本地化标题与描述、模块名称及条目 id 过滤两组。
### 预设切换器
@@ -41,7 +41,7 @@ kind: "package-reference"
### 重试失败的读取
-读取失败会在标签页内渲染共享 error 标记;加载与当前页面同步使用共享 ongoing loading。重试会重新执行懒 `list()` 调用,且不会暴露传输细节。
+读取失败会在标签页内渲染共享 error 标记。加载时在卡片网格中显示骨架卡片,当前页面同步使用共享 ongoing loading。重试会重新执行懒 `list()` 调用,且不会暴露传输细节。
插件列表还显示当前页面的同步失败。重试会重新应用最新客户端图,不改变 Host 启用状态,也不刷新页面。
@@ -61,7 +61,7 @@ kind: "package-reference"
### 渲染
-行 key 按作用域限定(`global:`、`preset::`),因此同一模块出现在两个作用域时保持各自的展开状态;声明的条目 id 出现在展开详情中,并在去掉开头的组合 `include:` 标记后作为收起次标题,没有 id 的行不显示次级标签。预设提供标记在客户端推导:一个全局条目在全局被停用、且至少一个预设行对同一模块标识实际启用时才携带它,因此被所有预设关掉(或仅条件声明)的模块保持单纯的已停用,而不是夸大提供关系。
+行 key 按作用域限定(`global:`、`preset::`),因此同一模块出现在两个作用域时保持各自的展开状态;声明的条目 id 出现在展开详情中;去掉开头的组合 `include:` 标记后若与标题不同,则作为收起卡片的 id 代码标签,没有 id 的行不显示次级标签。预设提供标记在客户端推导:一个全局条目在全局被停用、且至少一个预设行对同一模块标识实际启用时才携带它,因此被所有预设关掉(或仅条件声明)的模块保持单纯的已停用,而不是夸大提供关系。
diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css
index 87b29d442f..be3aa42773 100644
--- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css
+++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css
@@ -1,4 +1,6 @@
+/* The tab's own width, not the window's, decides the card columns below. */
.section {
+ container: plugin-inventory / inline-size;
display: flex;
flex-direction: column;
gap: 14px;
@@ -7,7 +9,6 @@
color: var(--dsw-alias-label-primary);
}
-.catalogHeading h3,
.status,
.failure p {
margin: 0;
@@ -85,37 +86,21 @@
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)) 18%, transparent);
}
-.catalogHeading {
- display: flex;
- align-items: baseline;
- gap: 7px;
- padding: 0 2px;
-}
-
-.catalogHeading h3 {
- font-size: 13px;
- line-height: 20px;
- font-weight: 600;
-}
-
-.catalogHeading span {
- font-size: 12px;
- line-height: 18px;
- color: var(--dsw-alias-label-tertiary);
- font-variant-numeric: tabular-nums;
-}
-
.cards {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
- align-items: start;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
+/* Cards stretch to their grid row's height. The button fills a stretched card:
+ the title row and description stay at the top, in line with the other card's,
+ and the spare height sits above the entry id chip or at the bottom. */
.card {
+ display: flex;
+ flex-direction: column;
min-width: 0;
overflow: hidden;
border: 0.5px solid var(--dsw-alias-settings-card-stroke);
@@ -127,11 +112,22 @@
border-color: var(--dsw-alias-border-l3);
}
+/* The other card in an expanded card's row keeps its own height instead of
+ stretching alongside the details. The selectors assume the two columns
+ `.cards` declares, where each row starts at an odd position, and must change
+ with that count; in one column the matched card sits alone in its row. */
+.card:nth-child(odd)[data-open='true'] + .card,
+.card:nth-child(odd):has(+ .card[data-open='true']) {
+ align-self: start;
+}
+
.cardContent {
box-sizing: border-box;
display: flex;
+ flex: 1 1 auto;
flex-direction: column;
align-items: stretch;
+ gap: 2px;
width: 100%;
min-height: 52px;
border: 0;
@@ -173,11 +169,35 @@
min-width: 0;
font-size: 14px;
line-height: 20px;
- font-weight: 600;
+ font-weight: 500;
}
-/* The Loader entry id keeps one size in the card subtitle and the row it expands into;
- the collapsed subtitle stays on the quieter label tier, while the expanded row carries
+/* Two lines at most while collapsed; the expanded card shows the full text. */
+.cardDescription {
+ display: -webkit-box;
+ overflow: hidden;
+ color: var(--dsw-alias-label-tertiary);
+ font-size: 12px;
+ line-height: 18px;
+ text-wrap: pretty;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+}
+
+.card[data-open='true'] .cardDescription {
+ display: block;
+}
+
+/* The entry id is metadata, not prose: a code chip in the card's bottom-left
+ corner whose left edge lines up with the title. */
+.cardMeta {
+ display: flex;
+ margin-top: auto;
+ padding-top: 6px;
+}
+
+/* The Loader entry id keeps one size in the card chip and the row it expands into;
+ the collapsed chip stays on the quieter label tier, while the expanded row carries
the card's primary value. */
.cardIdentity,
.entryValue {
@@ -188,15 +208,19 @@
.cardIdentity {
display: block;
- width: 100%;
- color: var(--dsw-alias-label-tertiary);
+ box-sizing: border-box;
+ max-width: 100%;
+ border-radius: var(--dsw-radius-xs);
+ padding: 1px 6px;
+ background: var(--dsw-alias-bg-module-platform);
+ color: var(--dsw-alias-label-secondary);
}
.cardTrailing {
display: inline-flex;
flex: none;
align-items: center;
- gap: 7px;
+ gap: 8px;
color: var(--dsw-alias-label-tertiary);
}
@@ -213,11 +237,13 @@
gap: 10px;
}
+/* Every group header takes the preset switcher's 36px height, so each subtitle
+ sits the same distance below its title. */
.groupTitleRow {
display: flex;
align-items: center;
gap: 8px;
- min-height: 32px;
+ min-height: 36px;
}
.group + .group {
@@ -266,7 +292,7 @@
.groupSub {
display: flex;
flex-wrap: wrap;
- gap: 4px 8px;
+ row-gap: 4px;
margin: -6px 0 0 20px;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
@@ -274,6 +300,13 @@
font-variant-numeric: tabular-nums;
}
+/* A middle dot with equal space on both sides separates the subtitle's parts. */
+.groupSub > * + *::before {
+ content: '·';
+ padding: 0 6px;
+ color: var(--dsw-alias-label-tertiary);
+}
+
.failedCount {
color: var(--dsw-alias-state-error-primary);
}
@@ -330,7 +363,7 @@
padding: 8px 10px;
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent);
color: var(--dsw-alias-state-error-primary);
- font-size: 12.5px;
+ font-size: 12px;
line-height: 18px;
overflow-wrap: anywhere;
white-space: pre-line;
@@ -343,7 +376,7 @@
gap: 4px 8px;
margin: 0;
color: var(--dsw-alias-label-tertiary);
- font-size: 12.5px;
+ font-size: 12px;
line-height: 18px;
}
@@ -353,10 +386,34 @@
background: transparent;
color: var(--dsw-alias-state-business-primary);
font: inherit;
- font-size: 12.5px;
+ font-size: 12px;
cursor: pointer;
}
+/* Loading placeholder in the cards grid, sized like a card with one description
+ line; its bars pulse through the shared skeleton token as the input-trigger
+ menu's pending rows do. */
+.skeletonCard {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ border: 0.5px solid var(--dsw-alias-settings-card-stroke);
+ border-radius: var(--dsw-radius-xl);
+ padding: 15px 14px;
+ background: var(--dsw-alias-settings-card-fill);
+}
+
+.skeletonBar {
+ width: 40%;
+ height: 14px;
+ border-radius: var(--dsw-radius-xs);
+ background: var(--dsw-alias-bg-skeleton);
+}
+
+.skeletonBar + .skeletonBar {
+ width: 80%;
+ height: 12px;
+}
.enabledIn {
display: flex;
@@ -430,9 +487,19 @@
.chevron {
transition: transform 140ms var(--ds-ease-in-out);
}
+
+ .skeletonBar {
+ animation: plugin-inventory-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite;
+ }
}
-@media (max-width: 680px) {
+@keyframes plugin-inventory-skeleton {
+ 0% { opacity: 1; }
+ 40% { opacity: 0.6; }
+ 80%, 100% { opacity: 1; }
+}
+
+@container plugin-inventory (max-width: 520px) {
.cards {
grid-template-columns: minmax(0, 1fr);
}
diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx
index c5064e5441..57810b993a 100644
--- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx
+++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx
@@ -58,6 +58,9 @@ const PHASE_KEYS = {
unloading: 'unloading',
} satisfies Record, PluginInventoryLocaleKey>
+/** Placeholder cards the loading skeleton lays out in the cards grid. */
+const SKELETON_CARDS = [0, 1, 2, 3] as const
+
/** Localized accessible label for one root Fiber phase. */
function phaseLabel(phase: PluginFiberPhase, t: Translate): string {
return phase === null ? t('unobserved') : t(PHASE_KEYS[phase])
@@ -77,6 +80,16 @@ function entrySubtitle(entryId: string): string {
return entryId.replace(/^include:/, '')
}
+/** Whether a card shows its entry id: the id exists and, without its `include:` marker, differs from the title. */
+function idAddsToTitle(entryId: string | null, title: string): entryId is string {
+ return entryId !== null && entrySubtitle(entryId) !== title
+}
+
+/** Accessible card name: the title, the complete entry id when the card shows one, then the enablement state. */
+function cardLabel(title: string, entryId: string | null, state: string): string {
+ return idAddsToTitle(entryId, title) ? `${title}, ${entryId}, ${state}` : `${title}, ${state}`
+}
+
/** Preserve translated titles and shorten literal package or module name fallbacks in Settings. */
function pluginText(row: PluginInventoryEntry | AgentPresetRow, resolveText: PluginInventorySettingsTabInjected['resolveText']) {
const title = row.meta?.title
@@ -151,8 +164,12 @@ function PluginCard({
- {description === undefined ? null : {description}}
- {entryId === null ? null : {entrySubtitle(entryId)}}
+ {description === undefined ? null : {description}}
+ {idAddsToTitle(entryId, title) ? (
+
+ {entrySubtitle(entryId)}
+
+ ) : null}
{metadataError === undefined ? null :
{metadataError}
}
{open ?
{children}
: null}
@@ -189,15 +206,15 @@ function CardFacts({ moduleName, moduleLabel, entryId, facts }: {
/* `pending` is the only dotted phase with no work under way. `loading` and
* `unloading` are both live transitions the Host is running — an async
* disposer can hold `unloading` for a while — so both animate. `active` and
- * `failed` carry no dot: a settled enabled row shows its enablement tag alone,
- * and a failed row has the failure tag. */
+ * `failed` carry no dot: a settled enabled row needs no marker, and a failed
+ * row has the failure tag. */
const PHASE_DOT_STATES = {
pending: 'idle',
loading: 'ongoing',
unloading: 'ongoing',
} as const satisfies Partial, StateDotState>>
-/** A live root-fiber phase whose dot still adds to the row's enablement tag. */
+/** A live root-fiber phase the row marks with a dot. */
type DotPhase = keyof typeof PHASE_DOT_STATES
/** Whether a live root-fiber phase carries a dot of its own. */
@@ -220,16 +237,15 @@ function PhaseDot({ phase, t }: { readonly phase: DotPhase; readonly t: Translat
type EnablementKind = 'enabled' | 'disabled' | 'conditional' | 'preset' | 'failed'
const TAG_TONES = {
- enabled: 'success',
disabled: 'neutral',
conditional: 'warning',
preset: 'info',
failed: 'danger',
-} as const satisfies Record
+} as const satisfies Record, TagTone>
-/** Enablement tag; `kind` selects the palette. */
+/** Enablement tag for the states that depart from the default; a plainly enabled row carries none. */
function StateTag({ kind, label }: { readonly kind: EnablementKind; readonly label: string }): ReactNode {
- return {label}
+ return kind === 'enabled' ? null : {label}
}
/** Render the read-only plugin inventory: agent presets first, then the global plane. */
@@ -296,8 +312,8 @@ export function PluginInventorySettingsTab(
const otherMatchCount = otherPresetMatches
.reduce((total, preset) => total + preset.rows.filter(rowMatch).length, 0)
- // Both groups start collapsed; a search opens them for as long as it lasts.
- const presetEffectiveOpen = searching || (presetOpen ?? false)
+ // The preset group starts open and the larger global plane folded; a search opens both for as long as it lasts.
+ const presetEffectiveOpen = searching || (presetOpen ?? true)
const globalEffectiveOpen = searching || (globalOpen ?? false)
const nothingMatches = searching && globalCount === 0 && selectedRows.length === 0
&& otherPresetMatches.length === 0
@@ -331,7 +347,7 @@ export function PluginInventorySettingsTab(
failed={failed}
expanded={expanded}
onToggle={toggleRow}
- ariaLabel={`${title}${row.entryId === null ? '' : `, ${row.entryId}`}, ${stateText}`}
+ ariaLabel={cardLabel(title, row.entryId, stateText)}
trailing={(
<>
{row.enabled === true && showsPhaseDot(row.fiberPhase)
@@ -380,7 +396,7 @@ export function PluginInventorySettingsTab(
failed={failed}
expanded={expanded}
onToggle={toggleRow}
- ariaLabel={`${title}, ${entry.entryId}, ${stateText}`}
+ ariaLabel={cardLabel(title, entry.entryId, stateText)}
trailing={(
<>
{entry.enabled && showsPhaseDot(entry.fiberPhase)
@@ -436,9 +452,15 @@ export function PluginInventorySettingsTab(
)}
{state.status === 'loading' ? (
-