dsh-notice-center 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,100 @@
1
+ # 更新记录
2
+
3
+ 每个**已发布**版本的改动都记在这里;本文件随包一起发布(`files` 里已包含),
4
+ 发版流程见 [README → 发版](./README.md#发版)。最上面一条就是 npm 上的当前版本。
5
+
6
+ 约定:正式版发到 `latest`,`-rc.N` 只发 `next`(不动 `latest`);括号里的短哈希是那次改动的提交。
7
+
8
+ ## 1.4.0 — 2026-09-27
9
+
10
+ > **兼容性**:`engines.dsh` 要求 `>=0.1.7-rc.2 <0.2.0-0`。设置表单改用 0.1.7 的
11
+ > `runtime.Config` 派生,**宿主 0.1.6 及更早请继续用 1.3.1**。
12
+
13
+ ### 修复
14
+
15
+ - **浅色系统上标签页状态灯永远不显色**(`981e8cd`)
16
+ 宿主 `index.html` 挂了两枚 `<link rel="icon">`(`favicon-dark.svg` / `favicon.svg`,
17
+ 靠 `media="(prefers-color-scheme: …)"` 区分),浏览器按系统主题只渲染其中一枚;旧代码
18
+ `querySelector("link[rel~=icon]")` 只拿到第一枚,而浅色主题下渲染的恰恰是第二枚 ——
19
+ 琥珀、绿、默认色全画在没人用的那枚上,标签页看着一直是默认图标。现在改**全部** icon,
20
+ 逐枚记住各自原始 `href`(还原时对号入座),页面上一枚都没有时自建一枚再上色。
21
+
22
+ - **音量滑杆「改了没反应、还回弹」**(`2cfc6b4`)
23
+ 每笔 `set` 都要重写整份 profile 的 `cordis.patch.yml` 并热重载插件条目(实测约 1s/笔),
24
+ 而原生滑杆每挪一格就发一次 `input`,一次拖动几十笔全排进写队列,回显被拖到几秒之后,
25
+ 受控滑杆又被旧快照拽回 —— 表现为「点一下固定差 5%,右滑也是下降」。现在按**手势合并写**:
26
+ 首笔立即写(点一下就生效),拖动中只攒最新值,静默 150ms 后补最后一笔(一次手势 1–2 笔),
27
+ 同时当帧画回显草稿,写入最终没落盘才回滚。
28
+
29
+ - **点「↺ 恢复默认」/ 取色后,预览鲸鱼要等一会儿才变色**(`c49231d`)
30
+ 与上一条同源:预览和取色器都只读快照。现在颜色也有当帧草稿,写入落定后交还快照、失败回滚;
31
+ 取色同样按手势合并(原生取色器拖动时会连发 `input`);复位是整笔覆盖,先丢掉排队中的取色值
32
+ 再立即 `unset`。
33
+
34
+ ### 变更
35
+
36
+ - 设置页迁移到 DSH 0.1.7 的设置 API:改读 `ctx.configForms.get("notice-center")`,`loading`
37
+ 不渲染,`unavailable` / 只读时给一行说明;宿主半只声明 Config schema,旧 `settings.yaml`
38
+ 由 `dsh-settings` 按同名条目 id 迁移。
39
+ - 宿主半每个字段补 `.volatile()`(`volatileForm` 只投影「最近的可变祖先」下的字段,漏标一个
40
+ 那项就会在设置页整条消失),`schemastery` 升到 `^3.18.4`;保留 `/notice-center-sounds`
41
+ 音效路由。
42
+ - 修正图标名:`…Outline14` → `…OutlineRegular`。宿主静态模块表里没有 `…14` 那套导出,
43
+ `jsx(undefined)` 会被插槽错误边界吞掉,整个设置页会变成空白(配置项一条都不显示)。
44
+ - 导航图标换成自定义铃铛(`viewBox 0 0 1024 1024`):铃身 + 底座合并成一条 `currentColor`
45
+ 路径跟随主题,高光保留 `#3399FF`;官方回退齿轮的 path 不够用时只追加克隆节点(不删 React
46
+ 管的节点),幂等跳过避免 rAF 死循环。
47
+ - `engines` / `peerDependencies` 收到 `>=0.1.7-rc.2 <0.2.0-0`。
48
+
49
+ ### 测试与工程
50
+
51
+ - 新增 `test/compat-range.smoke.mjs`:0.1.4 ~ 0.2.0 宿主兼容区间,`npm test` 一并跑。
52
+ - `primitives` 桩改成导出白名单(名单外的名字直接抛错,原来的 Proxy 对任何名字都造得出假组件);
53
+ `rAF` 桩改为入队 + 手动 flush,对齐真浏览器时序;新增导航图标 9 条断言。
54
+ - 浏览器半冒烟补:两枚 favicon 都换色 / 各自还原 / 一枚都没有时自建一枚;音量手势合并写与
55
+ 回显草稿;颜色当帧回显与合并写(第 17–19 节)。
56
+
57
+ ## 1.3.1 — 2026-09-24
58
+
59
+ - fix: 「自动隐藏」的提示文案只讲了关闭侧,改为两侧都写清楚(`92b0dae`)
60
+ - feat: 设置页页脚(插件名 + 版本号,点击开仓库)(`ce8109e`)
61
+ - test: 补「同一会话连续完成两次 → 弹两次」回归用例,把去重语义写精确 —— 管的是
62
+ 「同一次完成」,不是「同一会话」(`d44ae93`、`cd8f2f2`)
63
+ - docs: README 重构为「使用 / 开发」两部分并加目录导航,补齐中英文主图与真实设置页截图
64
+
65
+ ## 1.3.0 — 2026-09-19
66
+
67
+ - fix: 状态灯改为**待处理优先**,与官方 `sessionStatuses` 一致(`735007f`)
68
+ - feat: 完成通知附本轮总用时;待处理通知按交互类型细化;新增「前台提醒」开关;调完音量试听
69
+ 完成提示音;设置页分组行整行可点(不必去够最右边的箭头)
70
+ - test: 新增设置页渲染测试(记录型桩,零新依赖)(`ed7acd6`)
71
+ - docs: 新增英文 README(`README.en.md`)
72
+
73
+ ## 1.3.0-rc.1 — 2026-09-19
74
+
75
+ - ci: 新增 tag 触发的 OIDC 发布工作流(npm Trusted Publishing,无令牌 + 自动 provenance),
76
+ 并加幂等保护:该版本已在 registry 上就跳过(`a8a2f38`、`de49005`)
77
+ - 首次实测 OIDC 发布链路
78
+
79
+ ## 1.2.0 — 2026-09-19
80
+
81
+ - feat: 提示音可选 —— 45 个 opencode 内置音效,下拉里悬浮即试听(`40521ef`)
82
+ - docs: README 重写(功能 / 使用 / 截图)
83
+
84
+ ## 1.1.0 — 2026-09-18
85
+
86
+ - test: 补浏览器半冒烟测试并接入 CI(`d388890`)
87
+ - feat: 通知标题改为会话名、正文改为通知类型(`9a541e9`);新增「自动隐藏」开关,关闭则常驻
88
+ - fix: 通知去掉 `tag`、前台判定纳入窗口焦点 —— 修「同一对话只有第一条会弹」(`8b75353`)
89
+
90
+ ## 0.2.2 — 2026-08-22
91
+
92
+ - 设置页与「选中会话」信号灯的修复版(`f278787`)
93
+
94
+ ## 0.1.0 — 2026-08-16
95
+
96
+ - 首个正式版:标签页鲸鱼状态灯 + 浏览器系统通知(`83a2860`)
97
+
98
+ ---
99
+
100
+ > 0.1.0 – 1.1.0 是改名 / 独立收敛之前的产物;npm 上的 `dsh-notice-center` 从 1.2.0 开始。
package/README.en.md CHANGED
@@ -36,6 +36,7 @@
36
36
  - [Local development](#local-development)
37
37
  - [Tests](#tests)
38
38
  - [Releasing](#releasing)
39
+ - [Changelog](./CHANGELOG.md)
39
40
  - [Design constraints](#design-constraints)
40
41
 
41
42
  ## Why you need it
@@ -116,7 +117,9 @@ Finished and pending each have their own dropdown, **47 entries** in total: 2 bu
116
117
 
117
118
  ### Requirements
118
119
 
119
- - **Official DeepSeek Harness** (supported range `0.1.2-rc.1+`; verified here on `0.1.5-rc.1` with client packages `0.1.5-rc.2`)
120
+ - **Official DeepSeek Harness**: `1.4.0` requires **`0.1.7-rc.2+`**; `1.3.x` supports `0.1.2-rc.1+`
121
+ - The range is declared in `engines.dsh` and the `@deepseek-ai/dsh-*` peers, guarded by `test/compat-range.smoke.mjs`. The two lines split by host version: **the Market never offers 1.4.0 to an older host, and the DSH core refuses to load it** — older hosts simply stay on `1.3.x` with no update prompt.
122
+ - The version gate only compares version strings and cannot detect a removed API (0.1.7 dropped `settings.register` while the old declaration still passed), so never treat it as a compatibility guarantee on its own.
120
123
  - System notifications need a **secure context** — `http://127.0.0.1:3080` or `localhost` works; over a **LAN IP** the browser's Notification API is unavailable (a browser restriction, not a plugin issue)
121
124
 
122
125
  ### Option 1: from npm (recommended)
@@ -221,23 +224,30 @@ The plugin has **two halves** running in different processes, exchanging configu
221
224
 
222
225
  | Part | File | Runs in | Responsibility |
223
226
  |---|---|---|---|
224
- | **Host half** | `lib/index.mjs` (128 lines) | Harness main process (Node) | ① Register the `notice-center` settings namespace schema; ② serve `/notice-center-sounds/<id>.mp3` |
225
- | **Browser half** | `lib/client.cjs` (1344 lines) | Browser (plugin bundle) | favicon state machine + notification state machine + sound library + settings page |
226
- | **Bundle patch** | `cordis.patch.yml` | Loader layer | Insert one row `id: notice-center` into the plugin tree |
227
+ | **Host half** | `lib/index.mjs` | Harness main process (Node) | ① Declare the `Config` schema (settings form, write validation and the legacy `settings.yaml` import all derive from it); ② serve `/notice-center-sounds/<id>.mp3` |
228
+ | **Browser half** | `lib/client.cjs` | Browser (plugin bundle) | favicon state machine + notification state machine + sound library + settings page |
229
+ | **Bundle patch** | `cordis.patch.yml` | Loader layer | Insert one row `id: notice-center` into the plugin tree (that id IS the settings namespace) |
227
230
 
228
231
  **The host half's two optional channels**:
229
232
 
230
- - `ctx.inject(["settings"], …)` — registers the schema; a success line is logged to the console as proof
233
+ - `ctx.inject(["settings"], …)` — declares "this plugin ships its own page": `settings.configure({ auto: false }, ctx.fiber)`. The owner must be the plugin's own fiber (`describe()` looks the policy up by `entry.fiber`, and the `inject` child is a different fiber)
231
234
  - `ctx.inject(["webServer"], …)` — mounts the sound route. When the service is absent the route is not mounted and the browser half falls back to the built-in chime
232
235
 
233
- **Why the host half does not import `@deepseek-ai/dsh-settings`**: since `0.1.2-rc.1` that package no longer exports `settingsNamespace()`, and `register` validates the kebab-case string itself, so passing a plain string is enough. **That keeps the host half's only runtime dependency at `@deepseek-ai/schemastery`** — and because profiles default to `autoInstallPeers: false`, resolving any `@deepseek-ai` peer would actually fail on a version mismatch and break loading. This is what makes a clean registry install possible.
236
+ **The settings contract (since 0.1.7 — the easiest thing to break silently here)**:
237
+
238
+ - The form is derived from the **plugin's own Config**: dsh-settings' `describe()` reads the entry fiber's `runtime.Config`, and the entry id is the namespace — the plugin **no longer registers a namespace** (the old `settings.register(ns, schema)` was removed in 0.1.7).
239
+ - **Every field must be `.volatile()`**: `volatileForm()` only projects fields under the nearest volatile ancestor, so one unmarked field disappears from the settings page entirely (`test/host-half.smoke.mjs` asserts this field by field). `.volatile()` requires **schemastery ≥ 3.18.4**.
240
+ - Existing users need no manual migration: at startup dsh-settings reads `<DSH_HOME>/settings.yaml`, writes each section into the entry carrying the **same id**, then renames the file to `settings.yaml.imported`. Our section name equals the entry id (`notice-center`).
241
+
242
+ **Why the host half does not import `@deepseek-ai/dsh-settings`**: it keeps the host half's only runtime dependency at `@deepseek-ai/schemastery` — and because profiles default to `autoInstallPeers: false`, resolving any `@deepseek-ai` peer would actually fail on a version mismatch and break loading. This is what makes a clean registry install possible.
234
243
 
235
244
  **Where the browser half gets its data** (all official client services; the plugin holds no state of its own):
236
245
 
237
246
  | Service | Provided by | Used for |
238
247
  |---|---|---|
239
- | `sessions` | `@deepseek-ai/dsh-api-session-controller/client` | Session list snapshot: `completed` / `origin` / `running` / `current` |
240
- | `uiSession` | `@deepseek-ai/dsh-client-ui-session` | `pendingInteractions`: `Map<sessionId, { kind, … }>` |
248
+ | `configForms` | `@deepseek-ai/dsh-client-ui-settings` | `configForms.get(entryId)` reads and writes settings (the old `settingsScope` was removed in 0.1.7); the snapshot carries `status` / `writable` |
249
+ | `sessions` | `@deepseek-ai/dsh-api-session-controller/client` | Session list snapshot: `origin` / `running` / `displayTitle` |
250
+ | `uiSession` | `@deepseek-ai/dsh-client-ui-session` | `sessionStatus`: `Map<sessionId, { running, pendingInteraction, completionUnread }>` — 0.1.7 removed both the row fields `completed` / `pendingInteraction` and the `pendingInteractions` store; the official workspace session rows use the same mapping (`completed` ≡ `completionUnread`) |
241
251
 
242
252
  The sound route is deliberately narrow: only `^[a-z0-9-]+\.mp3$` filenames are served (no path traversal), and because the audio is a package constant it is cached `immutable`.
243
253
 
@@ -333,6 +343,8 @@ The workflow has **three gates**; failing any one blocks the release:
333
343
 
334
344
  The publish step is also **idempotent**: if that version already exists on the registry it is skipped, so re-tagging or re-running never fails the build.
335
345
 
346
+ Before tagging, add this release to [`CHANGELOG.md`](./CHANGELOG.md) (user-visible changes plus compatibility notes) — it is the source of the release notes shown on the npm page and the GitHub Release.
347
+
336
348
  ### One-time setup: npm Trusted Publishing (OIDC)
337
349
 
338
350
  Publishing needs no npm token at all (so there is nothing to expire or leak). Open the package page → **Publishing Access** → *Trusted publishers* → **Add a trusted publisher** → choose **GitHub Actions**:
package/README.md CHANGED
@@ -36,6 +36,7 @@
36
36
  - [本地开发](#本地开发)
37
37
  - [测试](#测试)
38
38
  - [发版](#发版)
39
+ - [更新记录](./CHANGELOG.md)
39
40
  - [设计约束](#设计约束)
40
41
 
41
42
  ## 为什么需要它
@@ -115,7 +116,9 @@
115
116
 
116
117
  ### 前置要求
117
118
 
118
- - **官方版本的 DeepSeek Harness**(适配范围 `0.1.2-rc.1+`;本机实测 `0.1.5-rc.1` + 客户端包 `0.1.5-rc.2`)
119
+ - **官方版本的 DeepSeek Harness**:`1.4.0` 起要求 **`0.1.7-rc.2+`**;`1.3.x` 支持 `0.1.2-rc.1+`
120
+ - 兼容范围写在 `engines.dsh` 与 `@deepseek-ai/dsh-*` peer 上,由 `test/compat-range.smoke.mjs` 守护。两条线按宿主版本自动分流:**插件市场不会把 1.4.0 推给旧宿主,DSH 核心在加载前也会拒绝它**——旧宿主原地留在 `1.3.x`,不会收到更新提示。
121
+ - 版本闸门只比版本号,查不出 API 被删(0.1.7 删掉了 `settings.register`,旧声明在那上面照样 PASS),所以别只靠它当兼容性保障。
119
122
  - 系统通知需要**安全上下文**——`http://127.0.0.1:3080` 或 `localhost` 可用;用**局域网 IP** 访问时浏览器的 Notification API 不可用(这是浏览器限制,不是插件问题)
120
123
 
121
124
  ### 方式一:从 npm 安装(推荐)
@@ -220,23 +223,30 @@ dsh plugin --profile web remove dsh-notice-center
220
223
 
221
224
  | 组成 | 文件 | 跑在哪 | 职责 |
222
225
  |---|---|---|---|
223
- | **宿主半** | `lib/index.mjs`(128 行) | Harness 主进程(Node) | ① 注册 `notice-center` 设置命名空间 schema;② 挂载 `/notice-center-sounds/<id>.mp3` 静态音效路由 |
224
- | **浏览器半** | `lib/client.cjs`(1344 行) | 浏览器(插件 bundle) | favicon 状态机 + 通知状态机 + 音效库 + 设置页 |
225
- | **bundle patch** | `cordis.patch.yml` | Loader 层 | 往插件树里插入一行 `id: notice-center` |
226
+ | **宿主半** | `lib/index.mjs` | Harness 主进程(Node) | ① 声明 `Config` schema(设置表单、写入校验、旧 `settings.yaml` 迁移都由它派生);② 挂载 `/notice-center-sounds/<id>.mp3` 静态音效路由 |
227
+ | **浏览器半** | `lib/client.cjs` | 浏览器(插件 bundle) | favicon 状态机 + 通知状态机 + 音效库 + 设置页 |
228
+ | **bundle patch** | `cordis.patch.yml` | Loader 层 | 往插件树里插入一行 `id: notice-center`(这个 id 就是设置命名空间) |
226
229
 
227
230
  **宿主半的两个「可选通道」**:
228
231
 
229
- - `ctx.inject(["settings"], …)` —— 注册 schema。注册成功会在控制台留一行自证日志
232
+ - `ctx.inject(["settings"], …)` —— 声明「本插件自带设置页」:`settings.configure({ auto: false }, ctx.fiber)`。owner 必须是插件自身的 fiber(`describe()` 按 `entry.fiber` 查这份策略,而 `inject` 子 fiber 是另一个 fiber)
230
233
  - `ctx.inject(["webServer"], …)` —— 挂载音效路由。服务缺席时不挂载,浏览器半自动回退到内置合成音
231
234
 
232
- **为什么宿主半不 import `@deepseek-ai/dsh-settings`**:自 `0.1.2-rc.1` 起该包不再导出 `settingsNamespace()`,注册处自己按 kebab-case 校验字符串,因此直接传纯字符串即可。**这样宿主半的运行时依赖只剩 `@deepseek-ai/schemastery`**——而 profile 默认 `autoInstallPeers: false`,真去解析任何 `@deepseek-ai` peer 反而会因 npm 上的版本不匹配而加载失败。这是「能从 registry 干净安装」的关键。
235
+ **设置契约(0.1.7 起,这是本插件最容易静默踩坏的地方)**:
236
+
237
+ - 表单由**插件自己的 Config** 派生:dsh-settings 的 `describe()` 读入口 fiber 的 `runtime.Config`,条目 id 就是命名空间 —— 插件**不再注册命名空间**(旧的 `settings.register(ns, schema)` 在 0.1.7 已删除)。
238
+ - **每个字段都要 `.volatile()`**:`volatileForm()` 只投影「最近的可变祖先」下的字段,漏标一个就等于那个设置项在设置页里整条消失(`test/host-half.smoke.mjs` 为此逐字段断言)。`.volatile()` 需要 **schemastery ≥ 3.18.4**。
239
+ - 老用户设置无需手工迁移:dsh-settings 启动时会读 `<DSH_HOME>/settings.yaml`,把每个 section 按名字写进**同 id** 的条目,再把原文件改名为 `settings.yaml.imported`。我们的段名正好等于条目 id(`notice-center`)。
240
+
241
+ **为什么宿主半不 import `@deepseek-ai/dsh-settings`**:宿主半的运行时依赖只剩 `@deepseek-ai/schemastery`——而 profile 默认 `autoInstallPeers: false`,真去解析任何 `@deepseek-ai` peer 反而会因 npm 上的版本不匹配而加载失败。这是「能从 registry 干净安装」的关键。
233
242
 
234
243
  **浏览器半的数据来源**(都是官方客户端服务,插件不自持状态):
235
244
 
236
245
  | 服务 | 提供包 | 用途 |
237
246
  |---|---|---|
238
- | `sessions` | `@deepseek-ai/dsh-api-session-controller/client` | 会话列表快照:`completed` / `origin` / `running` / `current` |
239
- | `uiSession` | `@deepseek-ai/dsh-client-ui-session` | `pendingInteractions`:`Map<sessionId, { kind, … }>` |
247
+ | `configForms` | `@deepseek-ai/dsh-client-ui-settings` | `configForms.get(entryId)` 读写设置(旧的 `settingsScope` 在 0.1.7 已删除);快照带 `status` / `writable` |
248
+ | `sessions` | `@deepseek-ai/dsh-api-session-controller/client` | 会话列表快照:`origin` / `running` / `displayTitle` |
249
+ | `uiSession` | `@deepseek-ai/dsh-client-ui-session` | `sessionStatus`:`Map<sessionId, { running, pendingInteraction, completionUnread }>` —— 0.1.7 的 `completed` / `pendingInteraction` 行字段与 `pendingInteractions` store 都已删除,官方 workspace 的会话行用的是同一套映射(`completed` ≡ `completionUnread`) |
240
250
 
241
251
  音效路由的安全约束:只放行 `^[a-z0-9-]+\.mp3$` 的文件名(杜绝路径穿越),音频是随包常量故用 `immutable` 长缓存。
242
252
 
@@ -332,6 +342,9 @@ npm run test:client # 只跑浏览器半
332
342
 
333
343
  另外发布步骤**幂等**:该版本已在 registry 上就跳过,重复打 tag / 重跑工作流不会红。
334
344
 
345
+ 发版前先在本仓库 [`CHANGELOG.md`](./CHANGELOG.md) 补上这一版(用户可见改动 + 兼容性)再打 tag ——
346
+ 它是 npm 包页与 GitHub Release 上那份「更新说明」的来源。
347
+
335
348
  ### 一次性配置:npm Trusted Publishing (OIDC)
336
349
 
337
350
  发布不需要任何 npm 令牌(也就没有令牌过期 / 泄漏问题)。打开包管理页 → **Publishing Access** → *Trusted publishers* → **Add a trusted publisher** → 选 **GitHub Actions**:
package/lib/client.cjs CHANGED
@@ -22,7 +22,7 @@ window.__ModuleLoader__.load({
22
22
  * 这份 bundle 是预构建产物,运行时读不到 package.json,漂移由宿主半冒烟测试
23
23
  * (test/host-half.smoke.mjs)守护,别在发版流程之外单独改这里。 */
24
24
  const PLUGIN_NAME = "dsh-notice-center";
25
- const PLUGIN_VERSION = "1.3.1";
25
+ const PLUGIN_VERSION = "1.4.0";
26
26
  /** 插件仓库:页脚点击后在新标签页打开。 */
27
27
  const PLUGIN_REPO = "https://github.com/SCP-QQ/dsh-notice-center";
28
28
  /** 官方侧边栏状态点颜色(静态色板,明暗主题同值)。 */
@@ -365,7 +365,7 @@ function playSound(id, volume, kind) {
365
365
  },
366
366
  children: names[value] ?? value
367
367
  }),
368
- (0, react_jsx_runtime.jsx)(primitives.IconChevronDownOutline14, { style: { flex: "none" } })
368
+ (0, react_jsx_runtime.jsx)(primitives.IconChevronDownOutlineRegular, { style: { flex: "none" } })
369
369
  ]
370
370
  }),
371
371
  open ? (0, react_jsx_runtime.jsxs)("div", {
@@ -424,6 +424,137 @@ function playSound(id, volume, kind) {
424
424
  //#endregion
425
425
  //#region src/settings-section.tsx
426
426
  /**
427
+ * 写入辅助:0.1.7 的 set/unset 返回 Promise<boolean> —— Host 业务拒绝给 false
428
+ * (含 revision 冲突,控制器会自己重读镜像),传输/宿主故障则 reject。
429
+ * 设置页是即时生效的(没有暂存/保存按钮),UI 不回滚。
430
+ *
431
+ * 但「只 warn 不重试」会把瞬时失败变成静默 bug:宿主 dsh-config-editor.edit 在
432
+ * 条目正在 reload、fiber 不是 ACTIVE、文件锁被占时抛错,revision 冲突则回 false
433
+ * —— 这些都是**瞬时**的,而 set/unset 是幂等的标量赋值,重试安全。失败时滑杆
434
+ * 会因受控 value 回弹,看着像「点了没反应」,实际是压根没写进 profile
435
+ * (2026-09-26 用户反馈:音量点击有时生效有时不生效)。
436
+ *
437
+ * 200ms / 600ms 各重试一次;同字段被更新的写入取代就放弃旧重试,免得旧值盖回新值。
438
+ * 首次尝试是同步发起的(调用点紧跟着的断言/交互反馈不被推迟到微任务)。
439
+ * 定义在模块级:设置页组件不在 apply 的作用域内,scope 由槽位注入。
440
+ *
441
+ * 返回值(供音量滑杆判断「这一笔到底落盘没有」):true = 宿主已接受;false = 用尽重试
442
+ * 被拒,或已被同一字段的更新写入接管(此时真值归新写入,调用方按值比对决定是否回滚)。
443
+ * @param perform - 执行一次写入,返回 Promise<boolean>。
444
+ * @param label - 字段名:日志定位 + 同字段重试的去重键。
445
+ * @returns Promise<boolean> —— true 表示已落盘。
446
+ */
447
+ const WRITE_RETRY_DELAYS = [200, 600];
448
+ const writeEpochs = /* @__PURE__ */ new Map();
449
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
450
+ /**
451
+ * 作废同字段更早发起、还没落地的重试(新值已经接管)。
452
+ * 只影响 retry 判定,正在途中的那次 perform 不受干扰。
453
+ * @param label - 字段名(与 submit 的去重键同一个)。
454
+ */
455
+ const supersede = (label) => {
456
+ writeEpochs.set(label, (writeEpochs.get(label) ?? 0) + 1);
457
+ };
458
+ const submit = (perform, label = "settings") => {
459
+ const epoch = (writeEpochs.get(label) ?? 0) + 1;
460
+ writeEpochs.set(label, epoch);
461
+ const run = async (attempt) => {
462
+ if (attempt > 0 && writeEpochs.get(label) !== epoch) return false;
463
+ let accepted;
464
+ try {
465
+ accepted = await perform();
466
+ } catch (error) {
467
+ if (attempt >= WRITE_RETRY_DELAYS.length) {
468
+ console.warn(`dsh-notice-center: settings write failed (${label}, 第 ${attempt + 1} 次)`, error);
469
+ return false;
470
+ }
471
+ await wait(WRITE_RETRY_DELAYS[attempt]);
472
+ return run(attempt + 1);
473
+ }
474
+ if (accepted !== false) return true;
475
+ if (attempt >= WRITE_RETRY_DELAYS.length) {
476
+ console.warn(`dsh-notice-center: settings write was not accepted (${label}, 第 ${attempt + 1} 次)`);
477
+ return false;
478
+ }
479
+ await wait(WRITE_RETRY_DELAYS[attempt]);
480
+ return run(attempt + 1);
481
+ };
482
+ return run(0);
483
+ };
484
+ /**
485
+ * 手势静默窗口:多久没再动过,就把攒下的最新值写出去(并结束这个手势)。
486
+ *
487
+ * 为什么必须合并写(2026-09-26 现场取证):每笔 set/unset 都走 dsh-config-editor.edit
488
+ * —— 重写整份 profile 的 cordis.patch.yml + Loader 热重载插件条目,实测约 1s/笔;而
489
+ * 原生滑杆每挪一格、原生取色器每拖一下就发一次 input,一次拖动几十笔全排进写队列,
490
+ * 回显被拖到好几秒之后,受控 value 又一直被旧快照拽回去 —— 表现就是「改了没反应、等
491
+ * 半天才变、还一直往回掉」。手势内改值时用 supersede() 作废上一笔还没落地的重试,
492
+ * 所以再短的窗口也不会让旧值盖回新值。
493
+ */
494
+ const VOLUME_QUIET_MS = 150;
495
+ /**
496
+ * 手势合并写(音量滑杆 + 颜色取色器共用):一次拖动/连点只落「首笔立即 + 静默后补一笔」。
497
+ *
498
+ * 拆成模块级工厂而不是每处手写:滑杆那次排查踩过的坑(逐 input 写宿主 → 写队列堆积
499
+ * → 受控回显被旧快照拽回去)在取色器上一模一样,逻辑只留一份,行为由
500
+ * test/client-half.smoke.mjs 第 17/18 节钉死。
501
+ *
502
+ * @param label - 字段名:日志定位 + 同字段重试去重键(手势内改值会 supersede 旧重试)。
503
+ * @param commit - 实际写入,返回 Promise<boolean>(宿主是否已接受)。
504
+ * @param onSettle - 一笔写入落定后的回调:(本次写的值, 是否落盘, 是否还有排队值)。
505
+ * @returns push / drop / dispose 三个操作。
506
+ */
507
+ const createGestureWriter = (label, commit, onSettle) => {
508
+ const state = { started: false, inFlight: false, queued: void 0, quiet: void 0 };
509
+ const armQuiet = () => {
510
+ if (state.quiet !== void 0) clearTimeout(state.quiet);
511
+ state.quiet = setTimeout(() => {
512
+ state.quiet = void 0;
513
+ if (state.inFlight) return;
514
+ if (state.queued !== void 0) flush();
515
+ else state.started = false;
516
+ }, VOLUME_QUIET_MS);
517
+ };
518
+ const flush = () => {
519
+ const value = state.queued;
520
+ if (value === void 0) return;
521
+ state.queued = void 0;
522
+ state.inFlight = true;
523
+ submit(() => commit(value), label).then((landed) => {
524
+ state.inFlight = false;
525
+ onSettle?.(value, landed, state.queued !== void 0);
526
+ armQuiet();
527
+ });
528
+ };
529
+ return {
530
+ /** 手势内的值:首笔立即写(点一下就生效),其后只攒最新值等静默窗口。 */
531
+ push: (value) => {
532
+ state.queued = value;
533
+ if (state.started) {
534
+ supersede(label);
535
+ armQuiet();
536
+ return;
537
+ }
538
+ state.started = true;
539
+ flush();
540
+ },
541
+ /** 丢掉排队中的值并作废旧重试 —— 复位按钮这类「整笔覆盖」的直写要用。 */
542
+ drop: () => {
543
+ state.queued = void 0;
544
+ if (state.quiet !== void 0) {
545
+ clearTimeout(state.quiet);
546
+ state.quiet = void 0;
547
+ }
548
+ supersede(label);
549
+ state.started = false;
550
+ },
551
+ /** 卸载时清计时器。 */
552
+ dispose: () => {
553
+ if (state.quiet !== void 0) clearTimeout(state.quiet);
554
+ }
555
+ };
556
+ };
557
+ /**
427
558
  * 设置页组件 —— 注册进官方设置面板的 `settings.section` 槽。
428
559
  *
429
560
  * 三行颜色(完成/待处理/默认),每行原生 ColorPicker + hex 文本框(约 1/3 宽)
@@ -432,33 +563,125 @@ function playSound(id, volume, kind) {
432
563
  * - 输入非法时提示错误、不写入(拒绝写入并提示)。
433
564
  * 每行恢复按钮只清对应字段:绿/琥珀回官方默认,黑 → 官方原版。
434
565
  *
435
- * ⚠️ this 绑定:SettingsScopeController 的方法是类方法(依赖 this.store),
566
+ * ⚠️ this 绑定:ConfigFormController 的方法是类方法(依赖 this.store),
436
567
  * 直接传裸引用给 useSyncExternalStore 会丢 this 导致渲染崩溃,必须箭头包装。
437
568
  */
438
569
  function WhaleSettingsSection({ scope, t }) {
439
570
  if (scope === void 0 || t === void 0) return null;
440
- const value = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot(), () => scope.getSnapshot()).value ?? {};
571
+ /* ⚠️ 所有 hook 必须在这一个函数体里无条件跑完,再进入下面的 status 分支。
572
+ 提前 return 会让 hook 数量在两次渲染之间变化(loading 时 1 个、ready 时 6 个),
573
+ React 直接抛 "Rendered more hooks than during the previous render",
574
+ 插槽的错误边界把整页吞成空白 —— 2026-09-26 就这么白过一次。 */
575
+ const snapshot = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot(), () => scope.getSnapshot());
441
576
  /** 通知权限的本地态(未授权提示 + 开启通知时自动请求)。 */
442
577
  const [permission, setPermission] = (0, react.useState)(notificationSupport());
443
578
  /** 两个分组的展开态(本地 UI 状态,均默认折叠)。 */
444
579
  const [colorsExpanded, setColorsExpanded] = (0, react.useState)(false);
445
580
  const [notifyExpanded, setNotifyExpanded] = (0, react.useState)(false);
581
+ /** 音量试听的防抖计时器(拖动过程中不连响,停手后放一遍)。 */
582
+ const volumeTimer = (0, react.useRef)(void 0);
583
+ /** 颜色回显草稿({字段: 色值}):取色 / 复位当帧就改预览 —— 一次写要重写整份
584
+ * profile patch 并热重载条目(约 1s),干等回显的话,预览鲸鱼会先僵在旧色上。 */
585
+ const colorDraftState = (0, react.useState)(void 0);
586
+ const colorDraft = colorDraftState[0];
587
+ const setColorDraft = colorDraftState[1];
588
+ /** 音量回显草稿:拖动当帧就画出新值,不等宿主往返 —— 受控滑杆干等回显时,
589
+ * 每次渲染都会被旧快照拽回原位,看着就是「拖了没反应」。 */
590
+ const volumeDraftState = (0, react.useState)(void 0);
591
+ const volumeDraft = volumeDraftState[0];
592
+ const setVolumeDraft = volumeDraftState[1];
593
+ /** 手势写管道容器(useRef 只保证容器存在,内容由各自 writer 懒建)。 */
594
+ const volumePipe = (0, react.useRef)(void 0);
595
+ /** 颜色三个字段各一条管道 —— 三个字段是三次独立手势,不能互相合并。 */
596
+ const colorPipes = (0, react.useRef)(void 0);
597
+ (0, react.useEffect)(() => () => {
598
+ if (volumeTimer.current !== void 0) clearTimeout(volumeTimer.current);
599
+ volumePipe.current?.dispose();
600
+ const pipes = colorPipes.current;
601
+ if (pipes !== void 0) for (const key of Object.keys(pipes)) pipes[key].dispose();
602
+ }, []);
603
+ /* 草稿被宿主回显追平 → 交还给快照(两者同值,画面无跳变)。 */
604
+ (0, react.useEffect)(() => {
605
+ if (volumeDraft === void 0) return;
606
+ const current = normalizeVolume((snapshot.value ?? {}).notifyVolume ?? NOTIFY_DEFAULTS.notifyVolume);
607
+ if (current === volumeDraft) setVolumeDraft(void 0);
608
+ }, [snapshot, volumeDraft]);
609
+ /* ===== hook 到此结束,以下才可以按状态分支渲染 ===== */
610
+ /* 0.1.7 的快照多了 status / writable:unavailable 表示宿主没提供这个条目(或页面是
611
+ memory 模式=非 loopback 访问),此时没有可读写的值 —— 给一句说明,而不是画一堆
612
+ 写了不生效的控件;loading 期间先不渲染。 */
613
+ if (snapshot.status === "loading") return null;
614
+ if (snapshot.status === "unavailable" || snapshot.writable === false) {
615
+ return (0, react_jsx_runtime.jsx)("p", {
616
+ style: {
617
+ color: "var(--dsw-alias-label-tertiary)",
618
+ fontSize: 13,
619
+ lineHeight: "20px",
620
+ margin: 0
621
+ },
622
+ children: t(snapshot.status === "unavailable" ? "settingsUnavailable" : "settingsReadOnly")
623
+ });
624
+ }
625
+ const value = snapshot.value ?? {};
446
626
  /** 当前值(未配置回默认)。 */
447
627
  const colorsEnabled = value.colorsEnabled ?? COLORS_ENABLED_DEFAULT;
448
628
  const notifyEnabled = value.notifyEnabled ?? NOTIFY_DEFAULTS.notifyEnabled;
449
629
  const notifySound = value.notifySound ?? NOTIFY_DEFAULTS.notifySound;
630
+ /** 快照里的音量(宿主真值)。 */
450
631
  const notifyVolume = normalizeVolume(value.notifyVolume ?? NOTIFY_DEFAULTS.notifyVolume);
632
+ /** 画面用的音量:手上有草稿就用草稿(拖动当帧反馈),否则用快照。 */
633
+ const shownVolume = volumeDraft === void 0 ? notifyVolume : volumeDraft;
451
634
  const notifyAutoHide = value.notifyAutoHide ?? NOTIFY_DEFAULTS.notifyAutoHide;
452
635
  const notifyForeground = value.notifyForeground ?? NOTIFY_DEFAULTS.notifyForeground;
453
- /** 音量试听的防抖计时器(拖动过程中不连响,停手后放一遍)。 */
454
- const volumeTimer = (0, react.useRef)(void 0);
455
- (0, react.useEffect)(() => () => {
456
- if (volumeTimer.current !== void 0) clearTimeout(volumeTimer.current);
457
- }, []);
458
- /** 改音量后试听当前设置的完成提示音:250ms 防抖,只在停手后按新音量放一次。
459
- * primeChime 必须在 onChange 这个用户手势里调,否则防抖后 resume 可能被自动播放策略拦。 */
636
+ /** 音量手势写(懒建):首笔立即 + 静默补笔,落定后决定要不要回滚草稿。 */
637
+ const volumeWriter = () => {
638
+ if (volumePipe.current === void 0) {
639
+ volumePipe.current = createGestureWriter("notifyVolume", (next) => scope.set("notifyVolume", next), (value, landed, hasQueued) => {
640
+ /* 最终没落盘、且期间没有更新的值排队 → 回滚草稿,别让界面显示一个假值。 */
641
+ if (landed !== true && !hasQueued) setVolumeDraft((draft) => (draft === value ? void 0 : draft));
642
+ });
643
+ }
644
+ return volumePipe.current;
645
+ };
646
+ /** 撤掉某个字段的回显草稿:只有值仍是我们当时写的那个才撤 —— 中途被更新的
647
+ * 手势接管时撤,会把新草稿一起抹掉。 */
648
+ const clearColorDraft = (key, expected) => {
649
+ setColorDraft((draft) => {
650
+ if (draft === void 0 || draft[key] !== expected) return draft;
651
+ const next = { ...draft };
652
+ delete next[key];
653
+ return Object.keys(next).length > 0 ? next : void 0;
654
+ });
655
+ };
656
+ /** 颜色手势写(三个字段各一条管道,互不合并)。 */
657
+ const colorWriter = (key) => {
658
+ if (colorPipes.current === void 0) colorPipes.current = {};
659
+ return colorPipes.current[key] ?? (colorPipes.current[key] = createGestureWriter(key, (hex) => scope.set(key, hex), (hex, _landed, hasQueued) => {
660
+ /* 落盘成功 → 快照已是新值,撤草稿无缝;失败 → 回到快照真值,预览不说谎。 */
661
+ if (!hasQueued) clearColorDraft(key, hex);
662
+ }));
663
+ };
664
+ /** 取色:当帧先改预览,写宿主按手势合并 —— 原生取色器拖动时会连发 input,
665
+ * 逐帧写就是音量滑杆那次踩过的坑(一次热重载 ≈1s,写队列直接堆爆)。 */
666
+ const pickColor = (key, hex) => {
667
+ setColorDraft((draft) => ({ ...(draft ?? {}), [key]: hex }));
668
+ colorWriter(key).push(hex);
669
+ };
670
+ /** 恢复默认:当帧先显示默认色,再 unset —— 整笔覆盖,所以先丢掉排队中的取色值。 */
671
+ const resetColor = (key) => {
672
+ const expected = COLOR_DEFAULTS[key];
673
+ setColorDraft((draft) => ({ ...(draft ?? {}), [key]: expected }));
674
+ colorWriter(key).drop();
675
+ submit(() => scope.unset(key), key).finally(() => clearColorDraft(key, expected));
676
+ };
677
+ /** 画面用的颜色:草稿优先(当帧反馈),否则快照,再回默认。 */
678
+ const colorOf = (key) => (colorDraft !== void 0 && colorDraft[key] !== void 0 ? colorDraft[key] : (value[key] ?? COLOR_DEFAULTS[key]));
679
+ /** 拖动/点按滑杆:当帧先画草稿(回显不再等宿主),写宿主按手势合并 ——
680
+ * 第一笔立即写(点一下就生效),其后只攒最新值、静默 150ms 后补最后一笔。
681
+ * 试听照旧 250ms 防抖;primeChime 必须在这个用户手势里调,
682
+ * 否则防抖后 resume 可能被自动播放策略拦。 */
460
683
  const previewVolume = (next) => {
461
- scope.set("notifyVolume", next);
684
+ setVolumeDraft(next);
462
685
  primeChime();
463
686
  if (volumeTimer.current !== void 0) clearTimeout(volumeTimer.current);
464
687
  volumeTimer.current = setTimeout(() => {
@@ -466,6 +689,7 @@ function playSound(id, volume, kind) {
466
689
  const snapshot = scope.getSnapshot().value ?? {};
467
690
  playSound(snapshot.notifyDoneSound ?? NOTIFY_DEFAULTS.notifyDoneSound, next, "done");
468
691
  }, 250);
692
+ volumeWriter().push(next);
469
693
  };
470
694
  /** 官方设置行规格(对齐「通用设置 → 权限」字段的 row/title/desc 样式)。 */
471
695
  const rowStyle = {
@@ -562,7 +786,7 @@ function playSound(id, volume, kind) {
562
786
  transform: expanded ? "rotate(180deg)" : "none",
563
787
  transition: "transform 0.2s"
564
788
  },
565
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(primitives.IconChevronDownOutline14, { size: 14 })
789
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(primitives.IconChevronDownOutlineRegular, { size: 14 })
566
790
  })
567
791
  })
568
792
  ]
@@ -613,7 +837,7 @@ function playSound(id, volume, kind) {
613
837
  min: 0,
614
838
  max: 100,
615
839
  step: 5,
616
- value: Math.round(notifyVolume * 100),
840
+ value: Math.round(shownVolume * 100),
617
841
  "aria-label": t("notifyVolume"),
618
842
  onChange: (e) => previewVolume(Number(e.target.value) / 100),
619
843
  style: {
@@ -632,7 +856,7 @@ function playSound(id, volume, kind) {
632
856
  fontSize: 12,
633
857
  lineHeight: "18px"
634
858
  },
635
- children: [Math.round(notifyVolume * 100), "%"]
859
+ children: [Math.round(shownVolume * 100), "%"]
636
860
  })
637
861
  ]
638
862
  }, "volume");
@@ -657,10 +881,10 @@ function playSound(id, volume, kind) {
657
881
  value: currentSound,
658
882
  groups: soundLibrary(t),
659
883
  names: soundNames(t),
660
- volume: notifyVolume,
884
+ volume: shownVolume,
661
885
  kind,
662
886
  t,
663
- onChange: (next) => scope.set(kind === "done" ? "notifyDoneSound" : "notifyPendingSound", next)
887
+ onChange: (next) => submit(() => scope.set(soundKey, next), soundKey)
664
888
  })
665
889
  ]
666
890
  }, kind);
@@ -694,7 +918,7 @@ function playSound(id, volume, kind) {
694
918
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
695
919
  style: {
696
920
  display: "inline-flex",
697
- color: value[key] ?? COLOR_DEFAULTS[key]
921
+ color: colorOf(key)
698
922
  },
699
923
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
700
924
  viewBox: "0 0 50 50",
@@ -708,8 +932,8 @@ function playSound(id, volume, kind) {
708
932
  }),
709
933
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
710
934
  type: "color",
711
- value: value[key] ?? COLOR_DEFAULTS[key],
712
- onChange: (e) => scope.set(key, e.target.value),
935
+ value: colorOf(key),
936
+ onChange: (e) => pickColor(key, e.target.value),
713
937
  "aria-label": label,
714
938
  style: {
715
939
  position: "absolute",
@@ -730,13 +954,13 @@ function playSound(id, volume, kind) {
730
954
  "aria-label": t("restore"),
731
955
  onClick: onReset,
732
956
  style: iconButtonStyle,
733
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(primitives.IconRefreshOutline14, { size: 14 })
957
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(primitives.IconRefreshOutlineRegular, { size: 14 })
734
958
  })
735
959
  ]
736
960
  }, key);
737
961
  /** 打开通知总开关时:预热音频上下文(用户手势内)并在未授权时请求通知权限。 */
738
962
  const setNotifyEnabled = (next) => {
739
- scope.set("notifyEnabled", next);
963
+ submit(() => scope.set("notifyEnabled", next), "notifyEnabled");
740
964
  if (!next) return;
741
965
  primeChime();
742
966
  if (notificationSupport() !== "default") return;
@@ -761,14 +985,14 @@ function playSound(id, volume, kind) {
761
985
  repo: PLUGIN_REPO,
762
986
  t
763
987
  }),
764
- sectionRow("colorsEnabled", t("groupColors"), t("colorsHint"), colorsEnabled, (next) => scope.set("colorsEnabled", next), colorsExpanded, () => setColorsExpanded((current) => !current), t("colorsExpand")),
765
- colorsExpanded ? colorRow("green", t("greenLabel"), () => scope.unset("green")) : null,
766
- colorsExpanded ? colorRow("amber", t("amberLabel"), () => scope.unset("amber")) : null,
767
- colorsExpanded ? colorRow("black", t("blackLabel"), () => scope.unset("black")) : null,
988
+ sectionRow("colorsEnabled", t("groupColors"), t("colorsHint"), colorsEnabled, (next) => submit(() => scope.set("colorsEnabled", next), "colorsEnabled"), colorsExpanded, () => setColorsExpanded((current) => !current), t("colorsExpand")),
989
+ colorsExpanded ? colorRow("green", t("greenLabel"), () => resetColor("green")) : null,
990
+ colorsExpanded ? colorRow("amber", t("amberLabel"), () => resetColor("amber")) : null,
991
+ colorsExpanded ? colorRow("black", t("blackLabel"), () => resetColor("black")) : null,
768
992
  sectionRow("notifyEnabled", t("groupNotify"), t("notifyEnabledHint"), notifyEnabled, setNotifyEnabled, notifyExpanded, () => setNotifyExpanded((current) => !current), t("notifyExpand")),
769
- notifyExpanded ? switchRow("notifyAutoHide", t("notifyAutoHide"), t("notifyAutoHideHint"), notifyAutoHide, (next) => scope.set("notifyAutoHide", next)) : null,
770
- notifyExpanded ? switchRow("notifyForeground", t("notifyForeground"), t("notifyForegroundHint"), notifyForeground, (next) => scope.set("notifyForeground", next)) : null,
771
- notifyExpanded ? switchRow("notifySound", t("notifySound"), void 0, notifySound, (next) => scope.set("notifySound", next)) : null,
993
+ notifyExpanded ? switchRow("notifyAutoHide", t("notifyAutoHide"), t("notifyAutoHideHint"), notifyAutoHide, (next) => submit(() => scope.set("notifyAutoHide", next), "notifyAutoHide")) : null,
994
+ notifyExpanded ? switchRow("notifyForeground", t("notifyForeground"), t("notifyForegroundHint"), notifyForeground, (next) => submit(() => scope.set("notifyForeground", next), "notifyForeground")) : null,
995
+ notifyExpanded ? switchRow("notifySound", t("notifySound"), void 0, notifySound, (next) => submit(() => scope.set("notifySound", next), "notifySound")) : null,
772
996
  notifyExpanded ? volumeRow() : null,
773
997
  notifyExpanded ? soundRow("done", t("notifyDoneSound")) : null,
774
998
  notifyExpanded ? soundRow("pending", t("notifyPendingSound")) : null,
@@ -848,7 +1072,7 @@ function playSound(id, volume, kind) {
848
1072
  "sessions",
849
1073
  "slots",
850
1074
  "locale",
851
- "settingsScope"
1075
+ "configForms"
852
1076
  ];
853
1077
  /**
854
1078
  * 本轮用时文案(格式照官方 formatRunDuration:满 60 秒进位到分,秒补零)。
@@ -868,16 +1092,21 @@ function playSound(id, volume, kind) {
868
1092
  }
869
1093
  function apply(ctx) {
870
1094
  const list = ctx.sessions.list;
871
- /** 绑定 `notice-center` 命名空间:读快照 + 写用户配置(宿主 schema 校验)。 */
872
- const scope = ctx.settingsScope.bind({ namespace: SETTINGS_NAMESPACE });
873
1095
  /**
874
- * 待处理交互信号(0.1.2-rc.1+):官方把「有待处理交互」从会话行字段迁到
875
- * dsh-client-ui-session 的 uiSession.pendingInteractions 独立 store
876
- * (Map<sessionId, interaction>,提问/审批处理完自动移除)。
877
- * 早于 0.1.2-rc.1 的版本没有该服务,因此下面用 ctx.inject 做**可选通道**:
878
- * 服务缺席时该子 fiber 不加载,琥珀判定自动回退到旧版行字段。
1096
+ * 绑定本体设置条目。0.1.7 起旧的 `settingsScope` 服务已删除,改用
1097
+ * `configForms.get(条目 id)` —— 这个 id 就是 SETTINGS_NAMESPACE,也是
1098
+ * cordis.patch.yml 插入的 Loader 条目 id(`id: notice-center`)。
1099
+ * 快照多出 status / writable / revision:status 为 unavailable 表示宿主没有
1100
+ * 这个条目(或页面是 memory 模式=非 loopback),此时读写都不生效。
1101
+ */
1102
+ const scope = ctx.configForms.get(SETTINGS_NAMESPACE);
1103
+ /**
1104
+ * 官方会话状态(0.1.7 的 uiSession.sessionStatus):Map<会话 id,
1105
+ * {running, pendingInteraction, completionUnread}>。旧的行字段 completed /
1106
+ * pendingInteraction 与 uiSession.pendingInteractions store 都已删除 ——
1107
+ * 官方 workspace 的会话行用同一套映射(completed = completionUnread)。
879
1108
  */
880
- let pendingStore;
1109
+ let statusMap = new Map();
881
1110
  /** 已完成/待处理通知的去重键(sessionId 加冒号加 kind)。 */
882
1111
  const notified = new Set();
883
1112
  /** 聚合队列与窗口:同一批状态跃迁合并成一条通知,避免刷屏。 */
@@ -1002,7 +1231,7 @@ function playSound(id, volume, kind) {
1002
1231
  * 就这三种),更细的信息在 entry 的 domain 字段上:审批的 toolName、提问的 questions
1003
1232
  * 批次。这里读这些字段做细化,**全部防御式读取** —— bundle 不 import 那些类型,
1004
1233
  * 字段缺席、类型不符(第三方域自造 kind)时逐级降级,绝不显示错误的类型名。
1005
- * @param interaction - pendingInteractions 的 entry;缺席时为 undefined。
1234
+ * @param interaction - sessionStatus 里的 pendingInteraction;缺席时为 undefined。
1006
1235
  * @returns 正文文案;完全认不出时 undefined,调用方回退「有交互等待处理」。
1007
1236
  */
1008
1237
  function pendingTypeLabel(interaction) {
@@ -1027,44 +1256,91 @@ function playSound(id, volume, kind) {
1027
1256
  const key = PENDING_KIND_KEYS[kind];
1028
1257
  return key === void 0 ? void 0 : t(key);
1029
1258
  }
1030
- /** 状态跃迁检测:官方 completed 由 false 变 true、以及 pending store 新增会话。 */
1259
+ /**
1260
+ * 0.1.7 的会话行视图:把官方 sessionStatus 合并进 list 的行。
1261
+ * 旧行字段 completed / pendingInteraction 已删,running 也可能缺省,因此三个
1262
+ * 状态字段一律取自官方状态(映射同官方 workspace 的 sessionNode)。
1263
+ * @param state - ctx.sessions.list 的快照。
1264
+ * @returns 扁平化的行数组(id/origin/title/running/completed/pending)。
1265
+ */
1266
+ function sessionRows(state) {
1267
+ return Object.values(state.byId).map((row) => {
1268
+ const status = statusMap.get(row.id);
1269
+ return {
1270
+ id: row.id,
1271
+ origin: row.origin,
1272
+ title: row.displayTitle ?? row.title ?? row.id,
1273
+ running: status?.running ?? row.running === true,
1274
+ completed: status?.completionUnread === true,
1275
+ pending: status?.pendingInteraction
1276
+ };
1277
+ });
1278
+ }
1279
+ /** 状态跃迁检测:completed 由 false 变 true、以及新出现的待处理会话。 */
1031
1280
  function detectTransitions(state) {
1032
- for (const row of Object.values(state.byId)) {
1281
+ const rows = sessionRows(state);
1282
+ for (const row of rows) {
1033
1283
  if (row.origin === "subagent") continue;
1034
1284
  const before = prevCompleted.get(row.id);
1035
1285
  const now = row.completed === true;
1036
- if (before === false && now) queueNotification("done", row.id, row.displayTitle ?? row.title ?? row.id, void 0, lastRunMs.get(row.id));
1286
+ if (before === false && now) queueNotification("done", row.id, row.title, void 0, lastRunMs.get(row.id));
1037
1287
  if (!now) notified.delete(row.id + ":done");
1038
1288
  prevCompleted.set(row.id, now);
1039
1289
  }
1040
- for (const id of [...prevCompleted.keys()]) if (!(id in state.byId)) {
1290
+ const alive = new Set(rows.map((row) => row.id));
1291
+ for (const id of [...prevCompleted.keys()]) if (!alive.has(id)) {
1041
1292
  prevCompleted.delete(id);
1042
1293
  notified.delete(id + ":done");
1043
1294
  }
1044
- const pendingMap = pendingStore?.getSnapshot();
1045
- const current = new Set(pendingMap === void 0 ? [] : [...pendingMap.keys()]);
1295
+ const pendingNow = new Map(rows.filter((row) => row.pending !== void 0).map((row) => [row.id, row]));
1296
+ const current = new Set(pendingNow.keys());
1046
1297
  if (pendingSeen) for (const id of current) {
1047
1298
  if (prevPending.has(id)) continue;
1048
- const row = state.byId[id];
1299
+ const row = pendingNow.get(id);
1049
1300
  if (row !== void 0 && row.origin === "subagent") continue;
1050
- const label = row?.displayTitle ?? row?.title ?? id;
1051
- queueNotification("pending", id, label, pendingTypeLabel(pendingMap?.get(id)));
1301
+ queueNotification("pending", id, row?.title ?? id, pendingTypeLabel(row?.pending));
1052
1302
  }
1053
1303
  for (const id of prevPending) if (!current.has(id)) notified.delete(id + ":pending");
1054
1304
  prevPending = current;
1055
1305
  pendingSeen = true;
1056
1306
  }
1057
- const iconLink = () => document.head.querySelector("link[rel~=\"icon\"]");
1307
+ /**
1308
+ * 页面里的**全部** favicon link。宿主 index.html 挂了两枚 —— dark / light 各一枚,
1309
+ * 靠 media="(prefers-color-scheme: …)" 区分,浏览器按系统主题挑一枚用。
1310
+ * 早先这里用 querySelector 只拿到**第一枚**(dark 那枚),浅色系统实际渲染的是
1311
+ * 第二枚 —— 于是状态灯改了也看不见:2026-09-26 用户反馈「标签页图标一直默认色」。
1312
+ * 一枚都没有就自建一枚,否则整条改色路径会静默失效(什么也不改,也不报错)。
1313
+ */
1314
+ const faviconLinks = () => {
1315
+ const found = [...document.head.querySelectorAll("link[rel~=\"icon\"]")];
1316
+ if (found.length > 0) return found;
1317
+ const link = document.createElement("link");
1318
+ link.rel = "icon";
1319
+ link.type = "image/svg+xml";
1320
+ link.href = DEFAULT_HREF;
1321
+ document.head.appendChild(link);
1322
+ return [link];
1323
+ };
1058
1324
  /**
1059
1325
  * 导航图标:官方 settings.section 的图标由 shell 按 section id 硬编码(未知 id
1060
- * 回退默认齿轮),注册选项里没有 icon 字段;这里把「通知中心」导航项的 path
1061
- * 数据换成官方 IconQueueOutline14 的矢量数据(语义一致:一叠待处理条目)。
1062
- * 数据取自内核 UI 原语 @deepseek-ai/dsh-client-ui-primitives(viewBox 0 0 14 14,
1063
- * 单 path,fill=currentColor)。只改属性、不增删 React 管理的节点。
1326
+ * 回退默认齿轮),注册选项里没有 icon 字段;这里把「通知中心」导航项那颗 svg 的
1327
+ * path 数据换成主人指定的铃铛(viewBox 0 0 1024 1024,原图三块 path)。
1328
+ *
1329
+ * 只改属性、不增删 React 管理的节点 —— 节点归 React 所有,插件增删会被下一次渲染
1330
+ * 打回齿轮。官方回退齿轮正好只有 2 个 path,所以把同为 #333333 的两块(铃身 + 底座)
1331
+ * 合并成一条 path、高光单独一条,正好复用这 2 个节点;万一官方给的图标 path 更少,
1332
+ * 只追加克隆节点(绝不删)—— React 插入时引用的是它自己管的节点,不会因此抛错。
1333
+ * 铃身/底座原色 #333333 改成 currentColor 跟随主题(深色主题下 #333333 几乎看不见),
1334
+ * 高光保留原图的 #3399FF;想要原汁原味的深灰,把 fill 改回 "#333333" 即可。
1064
1335
  */
1065
- const NAV_ICON_PATH = "M7.00049 0.199829C3.24488 0.199829 0.199952 3.24408 0.199707 6.99963C0.199707 8.0414 0.434087 9.03061 0.854004 9.91467L1.11279 10.4576L2.19775 9.94202L1.94092 9.39905L1.81787 9.12268C1.5498 8.46885 1.40186 7.75171 1.40186 6.99963C1.4021 3.90808 3.90888 1.40198 7.00049 1.40198C10.0919 1.40219 12.5979 3.90821 12.5981 6.99963C12.5981 10.0913 10.0921 12.5981 7.00049 12.5983C6.36734 12.5983 5.90348 12.5535 5.49268 12.4401C5.08803 12.3283 4.7041 12.1414 4.24463 11.8209C3.57111 11.3511 2.60588 11.1855 1.81006 11.6881L1.79736 11.6959L1.78467 11.7047L1.25537 12.0778L1.65381 13.2672L2.46045 12.6989C2.75029 12.5214 3.18004 12.5442 3.55615 12.8063C4.10063 13.1861 4.60863 13.4423 5.17334 13.5983C5.73194 13.7525 6.31665 13.8004 7.00049 13.8004C10.7561 13.8002 13.8003 10.7553 13.8003 6.99963C13.8 3.24421 10.7559 0.200041 7.00049 0.199829ZM3.81201 7.47327V8.67542H7.11572V7.47327H3.81201ZM3.81201 6.34924H10.2173V5.14709H3.81201V6.34924Z";
1336
+ const NAV_ICON_VIEWBOX = "0 0 1024 1024";
1337
+ const NAV_ICON_PATHS = [
1338
+ { fill: "currentColor", d: "M921.6 880.64h-819.2A51.2 51.2 0 0 1 51.2 829.44v-66.56A107.7248 107.7248 0 0 1 158.72 655.36a30.72 30.72 0 0 0 30.72-30.72V376.832A319.6928 319.6928 0 0 1 399.36 78.2336a112.0256 112.0256 0 0 1 213.6064 0A319.6928 319.6928 0 0 1 824.32 376.832v247.808a30.72 30.72 0 0 0 30.72 30.72h10.24A107.7248 107.7248 0 0 1 972.8 762.88v66.56a51.2 51.2 0 0 1-51.2 51.2zM112.64 819.2h798.72v-56.32A46.08 46.08 0 0 0 865.28 716.8h-10.24a92.16 92.16 0 0 1-92.16-92.16V376.832a257.8432 257.8432 0 0 0-184.32-245.76l-19.0464-5.7344-2.6624-20.48a50.7904 50.7904 0 0 0-100.7616 0l-2.6624 20.48-19.0464 5.7344a257.8432 257.8432 0 0 0-184.32 245.76v247.808A92.16 92.16 0 0 1 158.72 716.8 46.08 46.08 0 0 0 112.64 762.88z M512 1024a174.2848 174.2848 0 0 1-174.08-174.08h61.44a112.64 112.64 0 0 0 225.28 0h61.44a174.2848 174.2848 0 0 1-174.08 174.08z" },
1339
+ { fill: "#3399FF", d: "M665.6 353.6896a30.72 30.72 0 0 1-25.6-13.9264A259.4816 259.4816 0 0 0 542.72 251.904a30.72 30.72 0 0 1 28.2624-54.4768 320.9216 320.9216 0 0 1 120.6272 108.7488A30.72 30.72 0 0 1 665.6 353.6896z" }
1340
+ ];
1066
1341
  let navIconScheduled = false;
1067
- /** 把「通知中心」导航项的图标替换为官方 IconQueueOutline14(幂等)。 */
1342
+ /** 把「通知中心」导航项的图标换成上面的铃铛(幂等:已经画过就整块跳过 ——
1343
+ * 每次写属性都会再触发 observer,不跳过就是无限 rAF 循环)。 */
1068
1344
  function applyNavIcon() {
1069
1345
  const nav = document.querySelector("[role=\"dialog\"] nav");
1070
1346
  if (nav === null) return;
@@ -1073,18 +1349,33 @@ function playSound(id, volume, kind) {
1073
1349
  if (cell.textContent === null || !cell.textContent.includes(label)) continue;
1074
1350
  const svg = cell.querySelector("svg");
1075
1351
  if (svg === null) continue;
1076
- const paths = svg.querySelectorAll("path");
1352
+ let paths = [...svg.querySelectorAll("path")];
1077
1353
  if (paths.length === 0) continue;
1078
- if (paths[0].getAttribute("d") === NAV_ICON_PATH) continue;
1079
- svg.setAttribute("viewBox", "0 0 14 14");
1354
+ const drawn = svg.getAttribute("viewBox") === NAV_ICON_VIEWBOX
1355
+ && paths.length >= NAV_ICON_PATHS.length
1356
+ && NAV_ICON_PATHS.every((spec, index) => paths[index].getAttribute("d") === spec.d && paths[index].getAttribute("fill") === spec.fill)
1357
+ && paths.slice(NAV_ICON_PATHS.length).every((path) => (path.getAttribute("d") ?? "") === "");
1358
+ if (drawn) continue;
1359
+ /* 官方图标的 path 不够用时只追加克隆节点(不删 React 管的节点)。 */
1360
+ while (paths.length < NAV_ICON_PATHS.length) {
1361
+ svg.appendChild(paths[paths.length - 1].cloneNode(false));
1362
+ paths = [...svg.querySelectorAll("path")];
1363
+ }
1364
+ svg.setAttribute("viewBox", NAV_ICON_VIEWBOX);
1080
1365
  svg.setAttribute("width", "16");
1081
1366
  svg.setAttribute("height", "16");
1082
1367
  paths.forEach((path, index) => {
1083
- if (index === 0) {
1084
- path.setAttribute("d", NAV_ICON_PATH);
1085
- path.setAttribute("fill", "currentColor");
1086
- path.removeAttribute("fill-rule");
1087
- } else path.setAttribute("d", "");
1368
+ const spec = NAV_ICON_PATHS[index];
1369
+ if (spec === void 0) {
1370
+ path.setAttribute("d", "");
1371
+ return;
1372
+ }
1373
+ path.setAttribute("d", spec.d);
1374
+ path.setAttribute("fill", spec.fill);
1375
+ /* 官方图标是 stroke 画法(fill:none + stroke:currentColor),
1376
+ 不去掉 stroke 会在实心轮廓外再描一圈,1024 坐标下会糊成一团。 */
1377
+ path.removeAttribute("stroke");
1378
+ path.removeAttribute("fill-rule");
1088
1379
  });
1089
1380
  }
1090
1381
  }
@@ -1105,21 +1396,31 @@ function playSound(id, volume, kind) {
1105
1396
  attributeFilter: ["d"]
1106
1397
  });
1107
1398
  scheduleNavIcon();
1399
+ /** 每枚 icon 的原始 href —— 还原目标(比硬编码路径更稳,前端改路径也能正确还原);
1400
+ * 两枚各记各的,dark 还原成 favicon-dark.svg、light 还原成 favicon.svg。 */
1401
+ const faviconOriginals = faviconLinks().map((link) => ({ link, href: link.href }));
1402
+ /** 应用之后才出现的 link(或自建的那枚)补记原始值,别把别人的 href 当我们的还原。 */
1403
+ const rememberFavicon = (link) => {
1404
+ if (!faviconOriginals.some((item) => item.link === link)) faviconOriginals.push({ link, href: link.href });
1405
+ };
1108
1406
  const setHref = (href) => {
1109
- const link = iconLink();
1110
- if (link) link.href = href;
1407
+ for (const link of faviconLinks()) {
1408
+ rememberFavicon(link);
1409
+ link.href = href;
1410
+ }
1411
+ };
1412
+ const restoreHrefs = () => {
1413
+ for (const item of faviconOriginals) item.link.href = item.href;
1111
1414
  };
1112
- /** 应用瞬间的原始 href —— 还原目标(比硬编码路径更稳,前端改路径也能正确还原)。 */
1113
- const originalHref = iconLink()?.href ?? DEFAULT_HREF;
1114
1415
  /** 我们最后一次设置的 href;null = 官方原样。 */
1115
1416
  let applied = null;
1116
1417
  /** 自跟踪:每会话最后观察到的 running 位(镜像官方 prevRunning 语义)。 */
1117
1418
  const prevRunning = /* @__PURE__ */ new Map();
1118
- /** 完成时恰好被选中、且当时标签页不在台前的主会话(官方不报的空缺)。 */
1419
+ /** 标签页不在台前时跑完的主会话(官方 completionUnread 之外的本地兜底)。 */
1119
1420
  const finishedWhileHidden = /* @__PURE__ */ new Set();
1120
1421
  const restore = () => {
1121
1422
  if (applied !== null) {
1122
- setHref(originalHref);
1423
+ restoreHrefs();
1123
1424
  applied = null;
1124
1425
  }
1125
1426
  };
@@ -1133,41 +1434,46 @@ function playSound(id, volume, kind) {
1133
1434
  };
1134
1435
  }
1135
1436
  const uri = (hex) => `data:image/svg+xml,${encodeURIComponent(whaleSvg(hex))}`;
1136
- /** running true→false 边沿跟踪(只跟踪主会话):完成时恰好被选中 →
1137
- * 绿灯记入 finishedWhileHidden(仅当标签页不在台前;台前完成不记,V0-02);
1138
- * 通知则无条件入队,由 flush 按「前台也提醒」统一裁决。官方 syncCompletedNotifications
1139
- * 只报"未选中时完成"(`sessionId !== selected` 才 arm),选中的空缺在这里补齐。
1140
- * 重新运行、会话移除均清除。 */
1437
+ /** running true→false 边沿跟踪(只跟踪主会话):标签页不在台前时跑完 →
1438
+ * 绿灯记入 finishedWhileHidden;通知无条件入队,由 flush 按「前台也提醒」统一裁决。
1439
+ * 0.1.7 的 list 行已没有「当前选中会话」字段(state.current 已删),favicon 本来
1440
+ * 就是跨会话聚合,因此对所有主会话一视同仁;completed 的语义也已搬到
1441
+ * status.completionUnread(官方只标「主视图之外的停顿」),这条运行边沿是
1442
+ * 「选中会话完成也提醒」的来源。重新运行、会话移除均清除。 */
1141
1443
  function trackEdges(state) {
1142
- for (const row of Object.values(state.byId)) {
1444
+ const rows = sessionRows(state);
1445
+ for (const row of rows) {
1143
1446
  if (row.origin === "subagent") continue;
1144
1447
  const prev = prevRunning.get(row.id);
1145
1448
  if (prev === void 0) {
1146
1449
  prevRunning.set(row.id, row.running);
1147
1450
  continue;
1148
1451
  }
1149
- if (!prev && row.running) runStartedAt.set(row.id, Date.now());
1452
+ if (!prev && row.running) {
1453
+ runStartedAt.set(row.id, Date.now());
1454
+ /* 重新开始跑 → 清掉完成去重键,「同一会话连续完成两次」才会弹两次。 */
1455
+ notified.delete(row.id + ":done");
1456
+ }
1150
1457
  if (prev && !row.running) {
1151
1458
  const startedAt = runStartedAt.get(row.id);
1152
1459
  const elapsed = startedAt === void 0 ? void 0 : Date.now() - startedAt;
1153
1460
  runStartedAt.delete(row.id);
1154
1461
  if (elapsed !== void 0) lastRunMs.set(row.id, elapsed);
1155
1462
  /* 绿灯只记「没看着时跑完」;台前完成不记(V0-02)。 */
1156
- if (row.id === state.current && !isForeground()) finishedWhileHidden.add(row.id);
1157
- /* 通知另算:选中会话完成时官方不置 completed,这条是它的唯一来源,
1158
- 因此「前台要不要打扰」交给 flush 按 notifyForeground 统一裁决。 */
1159
- if (row.id === state.current) queueNotification("done", row.id, row.displayTitle ?? row.title ?? row.id, void 0, elapsed);
1463
+ if (!isForeground()) finishedWhileHidden.add(row.id);
1464
+ queueNotification("done", row.id, row.title, void 0, elapsed);
1160
1465
  } else if (row.running) finishedWhileHidden.delete(row.id);
1161
1466
  prevRunning.set(row.id, row.running);
1162
1467
  }
1163
- for (const id of [...prevRunning.keys()]) if (!(id in state.byId)) {
1468
+ const alive = new Set(rows.map((row) => row.id));
1469
+ for (const id of [...prevRunning.keys()]) if (!alive.has(id)) {
1164
1470
  prevRunning.delete(id);
1165
1471
  finishedWhileHidden.delete(id);
1166
1472
  runStartedAt.delete(id);
1167
1473
  lastRunMs.delete(id);
1168
1474
  }
1169
1475
  }
1170
- /** 回到前台(标签页可见且窗口有焦点)→ 选中会话的绿灯熄灭(V0-02)。 */
1476
+ /** 回到前台(标签页可见且窗口有焦点)→ 绿灯熄灭(V0-02)。 */
1171
1477
  const onForeground = () => {
1172
1478
  if (!isForeground()) return;
1173
1479
  if (finishedWhileHidden.size > 0) {
@@ -1181,14 +1487,13 @@ function playSound(id, volume, kind) {
1181
1487
  function targetOf(state) {
1182
1488
  if ((scope.getSnapshot().value ?? {}).colorsEnabled === false) return null;
1183
1489
  const c = colors();
1184
- const pendingMap = pendingStore?.getSnapshot();
1185
1490
  let green = false;
1186
1491
  /* 顺序照官方 sessionStatuses:pending interaction 是首要状态,live activity
1187
1492
  压过 completion reminder。favicon 是跨会话聚合,套用同一原则 —— 任意会话
1188
1493
  「卡住等你」都压过任意会话「跑完了」,否则审批会被完成提醒整片掩盖。 */
1189
- for (const row of Object.values(state.byId)) {
1494
+ for (const row of sessionRows(state)) {
1190
1495
  if (row.origin === "subagent") continue;
1191
- if (pendingMap?.has(row.id) || row.pendingInteraction !== void 0) return uri(c.amber);
1496
+ if (row.pending !== void 0) return uri(c.amber);
1192
1497
  if (row.completed === true || finishedWhileHidden.has(row.id)) green = true;
1193
1498
  }
1194
1499
  if (green) return uri(c.green);
@@ -1209,23 +1514,30 @@ trackEdges(state);
1209
1514
  const unsubscribeScope = scope.subscribe(sync);
1210
1515
  sync();
1211
1516
  /**
1212
- * 可选通道:uiSession 服务存在时(0.1.2-rc.1 起)订阅其待处理交互 store。
1213
- * ctx.inject 声明的是必需服务,服务缺席时该子 fiber 不加载 —— 这正是需要
1214
- * 的效果:旧版本保持旧行字段语义,新版本拿到官方 pending 信号。
1517
+ * 官方会话状态通道(0.1.7):uiSession.sessionStatus 是 getSnapshot/subscribe
1518
+ * 一对的只读源。旧的 uiSession.pendingInteractions store 已变成私有实现细节,
1519
+ * 会话行上的 pendingInteraction 也被删了,所有「待处理 / 跑完 / 在跑」都从这里读。
1520
+ * ctx.inject 声明的是必需服务,服务缺席时该子 fiber 不加载(琥珀判定随之失效)。
1215
1521
  */
1216
1522
  ctx.inject(["uiSession"], (uiCtx) => {
1217
- pendingStore = uiCtx.uiSession.pendingInteractions;
1218
- const unsubscribe = pendingStore.subscribe(sync);
1523
+ const source = uiCtx.uiSession.sessionStatus;
1524
+ statusMap = source.getSnapshot();
1525
+ const unsubscribe = source.subscribe(() => {
1526
+ statusMap = source.getSnapshot();
1527
+ sync();
1528
+ });
1219
1529
  sync();
1220
1530
  return () => {
1221
1531
  unsubscribe();
1222
- pendingStore = void 0;
1532
+ statusMap = /* @__PURE__ */ new Map();
1223
1533
  sync();
1224
1534
  };
1225
1535
  });
1226
1536
  ctx.effect(() => ctx.locale.register(SETTINGS_NAMESPACE, {
1227
1537
  zh: {
1228
1538
  nav: "通知中心",
1539
+ settingsUnavailable: "该插件当前未加载,暂时无法配置",
1540
+ settingsReadOnly: "本部署的设置为只读",
1229
1541
  greenLabel: "完成",
1230
1542
  amberLabel: "待处理",
1231
1543
  blackLabel: "默认色",
@@ -1270,6 +1582,8 @@ trackEdges(state);
1270
1582
  },
1271
1583
  en: {
1272
1584
  nav: "Notification center",
1585
+ settingsUnavailable: "This plugin is not loaded, so it cannot be configured right now.",
1586
+ settingsReadOnly: "This deployment stores settings read-only.",
1273
1587
  greenLabel: "Done",
1274
1588
  amberLabel: "Pending",
1275
1589
  blackLabel: "Default color",
package/lib/index.mjs CHANGED
@@ -6,16 +6,24 @@ import { fileURLToPath } from "node:url";
6
6
  /**
7
7
  * dsh-notice-center — Host half.
8
8
  *
9
- * 宿主半的唯一职责:向官方 settings 服务(@deepseek-ai/dsh-settings,由
10
- * dsh-settings-file 持久化到 settings.yaml)注册 `notice-center` 命名空间的
11
- * schema。浏览器半(src/client.ts)通过 settingsScope 绑定同一命名空间,
12
- * 读配置、写配置;favicon 状态机消费配置色。
9
+ * 宿主半的唯一职责:**声明本插件的 Config schema**。0.1.7 起设置表单由插件自己的
10
+ * Config 派生(dsh-settings 的 `describe()` 读入口 fiber 的 `runtime.Config`,表单
11
+ * 按 Loader 条目 id 记账 —— 也就是这个命名空间),插件不再注册命名空间。浏览器半
12
+ * (src/client.ts)用 `ctx.configForms.get("notice-center")` 绑定同一条目,读配置、
13
+ * 写配置;favicon 状态机消费配置色。
13
14
  *
14
- * 【不 import dsh-settings】自 0.1.2-rc.1 起 dsh-settings 不再导出
15
- * `settingsNamespace()`,register 内部自己按 kebab-case 校验字符串,因此这里直接传
16
- * 纯字符串。这样宿主半的运行时依赖只剩 @deepseek-ai/schemastery(npm 上有对应版本),
17
- * 插件从 registry 安装时不必解析任何 @deepseek-ai peer —— 而 profile 默认
18
- * `autoInstallPeers: false`,真去解析反而会因 npm 上的版本不匹配而加载失败。
15
+ * 两个必须守住的点:
16
+ * 1. 每个字段都要 `.volatile()` —— dsh-settings 的 `volatileForm()` 只投影「最近的
17
+ * 可变祖先」下的字段,漏标一个就等于那个设置项在设置页里整条消失。
18
+ * `.volatile()` 需要 schemastery ≥ 3.18.4(3.18.2 上没有这个方法)。
19
+ * 2. 命名空间必须与 cordis.patch.yml 里的 Loader 条目 id 一致。旧 settings.yaml 的
20
+ * 迁移由 dsh-settings 自己完成(`importLegacyDocument()` 把 `<DSH_HOME>/settings.yaml`
21
+ * 的每个 section 按名字写进同 id 的条目),默认 patch 插入的正是
22
+ * `id: notice-center`,与 SETTINGS_NAMESPACE 相同,所以老用户设置能原样搬过来。
23
+ *
24
+ * 【不 import dsh-settings】宿主半不 import 任何 @deepseek-ai/dsh-* 运行时包
25
+ * (运行时依赖只剩 @deepseek-ai/schemastery),从 registry 安装时不必解析 dsh peer ——
26
+ * 而 profile 默认 `autoInstallPeers: false`,真去解析反而会因版本不匹配而加载失败。
19
27
  *
20
28
  * schema 语义(与主人确认的灰区决策一致):
21
29
  * green/amber 有默认值(官方侧边栏色),用户未覆盖时回默认;
@@ -30,35 +38,33 @@ const DEFAULT_AMBER = "#F59E0B";
30
38
  /** hex 色值校验:6 位 #RRGGBB(ColorPicker 输出与手输均为此格式)。 */
31
39
  const HEX = /^#[0-9a-fA-F]{6}$/;
32
40
  /**
33
- * 解析注册用的命名空间:直接传 kebab-case 字符串(0.1.2-rc.1+ 的 register 自己校验)。
34
- * @returns settings.register 接受的命名空间。
41
+ * 命名空间 schema。0.1.7 起它同时是:入口 Config 的校验器、设置表单的投影源
42
+ * (只有标了 `.volatile()` 的字段会出现在表单里)、以及浏览器侧读到的那份值。
35
43
  */
36
- function resolveNamespace() {
37
- return SETTINGS_NAMESPACE;
38
- }
39
- /** 命名空间 schema;也是浏览器侧 wire 校验依据。 */
40
44
  const NoticeSettingsSchema = z.object({
41
- green: z.string().pattern(HEX).default(DEFAULT_GREEN),
42
- amber: z.string().pattern(HEX).default(DEFAULT_AMBER),
45
+ green: z.string().pattern(HEX).default(DEFAULT_GREEN).volatile(),
46
+ amber: z.string().pattern(HEX).default(DEFAULT_AMBER).volatile(),
43
47
  /** 无默认:用户不配置时 black === undefined → 官方原版 favicon。 */
44
- black: z.string().pattern(HEX),
48
+ black: z.string().pattern(HEX).volatile(),
45
49
  /** 颜色状态灯总开关(关闭时保持官方原版图标)。 */
46
- colorsEnabled: z.boolean().default(true),
50
+ colorsEnabled: z.boolean().default(true).volatile(),
47
51
  /** 系统通知总开关(默认关闭;开启时若未授权由设置页发起授权请求)。 */
48
- notifyEnabled: z.boolean().default(false),
52
+ notifyEnabled: z.boolean().default(false).volatile(),
49
53
  /** 通知是否自动隐藏(默认 true=系统自行收起;false=常驻,需用户手动关闭)。 */
50
- notifyAutoHide: z.boolean().default(true),
54
+ notifyAutoHide: z.boolean().default(true).volatile(),
51
55
  /** 前台也提醒(默认 false=沿用原固定策略,页面在前台时保持安静;true=正在看也弹)。 */
52
- notifyForeground: z.boolean().default(false),
56
+ notifyForeground: z.boolean().default(false).volatile(),
53
57
  /** 通知提示音:完成与待处理为两种不同的预置音。 */
54
- notifySound: z.boolean().default(true),
58
+ notifySound: z.boolean().default(true).volatile(),
55
59
  /** 提示音音量(0–1)。 */
56
- notifyVolume: z.number().min(0).max(1).default(0.6),
60
+ notifyVolume: z.number().min(0).max(1).default(0.6).volatile(),
57
61
  /** 完成提示音 id(见浏览器半音效库;builtin-up = 内置合成音 Chime Up)。 */
58
- notifyDoneSound: z.string().default("builtin-up"),
62
+ notifyDoneSound: z.string().default("builtin-up").volatile(),
59
63
  /** 待处理提示音 id(builtin-down = 内置合成音 Chime Down)。 */
60
- notifyPendingSound: z.string().default("builtin-down")
64
+ notifyPendingSound: z.string().default("builtin-down").volatile()
61
65
  });
66
+ /** 具名导出:不同加载路径可能把模块命名空间或 default 当插件对象,两条都提供 Config。 */
67
+ const Config = NoticeSettingsSchema;
62
68
  /** 音效静态路由前缀:浏览器半按 `<前缀>/<id>.mp3` 取音频。 */
63
69
  const SOUND_ROUTE = "/notice-center-sounds";
64
70
  /** 随包分发的音效目录(音源:anomalyco/opencode packages/ui/src/assets/audio,MIT)。 */
@@ -102,13 +108,26 @@ function buildSoundRoutes() {
102
108
  }
103
109
  var src_default = {
104
110
  name: "dsh-notice-center",
111
+ /** 入口 Config:0.1.7 的设置表单、旧 settings.yaml 迁移与写入校验都由它派生。 */
112
+ Config: NoticeSettingsSchema,
105
113
  apply(ctx) {
106
114
  /** 路由注销器(apply 卸载时统一回收)。 */
107
115
  const disposers = [];
108
116
  ctx.inject(["settings"], (settingsCtx) => {
109
- settingsCtx.settings.register(resolveNamespace(), NoticeSettingsSchema);
110
- /* 启动自证:注册成功会在 Harness 控制台留一行,便于排查命名空间未接上的问题。 */
111
- settingsCtx.logger.info("dsh-notice-center: settings namespace \"%s\" registered", SETTINGS_NAMESPACE);
117
+ /* 本插件自带设置页,声明「不要为我自动生成配置页」。
118
+ owner 必须是插件自身的 fiber:describe() 按 entry.fiber 查这份策略,而
119
+ inject 子 fiber 是另一个 fiber(实测 childCtx.fiber !== ctx.fiber),
120
+ 传子 fiber 等于没声明。注册失败只降级成一条日志,不拖垮插件。 */
121
+ settingsCtx.effect(() => {
122
+ try {
123
+ return settingsCtx.settings.configure({ auto: false }, ctx.fiber);
124
+ } catch (error) {
125
+ settingsCtx.logger.warn("dsh-notice-center: settings.configure failed: %s", error);
126
+ return () => {};
127
+ }
128
+ }, "dsh-notice-center: settings presentation");
129
+ /* 启动自证:设置条目能被服务读到会在 Harness 控制台留一行。 */
130
+ settingsCtx.logger.info("dsh-notice-center: settings entry \"%s\" served from the Config schema", SETTINGS_NAMESPACE);
112
131
  });
113
132
  /* 可选通道:webServer 缺席时音频路由不挂载,浏览器半自动回退到合成音。 */
114
133
  ctx.inject(["webServer"], (webCtx) => {
@@ -125,4 +144,4 @@ var src_default = {
125
144
  }
126
145
  };
127
146
  //#endregion
128
- export { DEFAULT_AMBER, DEFAULT_GREEN, SETTINGS_NAMESPACE, SOUND_ROUTE, NoticeSettingsSchema, buildSoundRoutes, src_default as default };
147
+ export { Config, DEFAULT_AMBER, DEFAULT_GREEN, SETTINGS_NAMESPACE, SOUND_ROUTE, NoticeSettingsSchema, buildSoundRoutes, src_default as default };
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "dsh-notice-center",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
+ "engines": {
5
+ "dsh": ">=0.1.7-rc.2 <0.2.0-0"
6
+ },
4
7
  "description": "DeepSeek Harness 通知中心:标签页鲸鱼状态灯(绿=跑完 / 琥珀=待处理)+ 浏览器系统通知(完成/待处理提示、45 个 opencode 音效可选、音量可配)。",
5
8
  "type": "module",
6
9
  "scripts": {
7
- "test": "node test/host-half.smoke.mjs && node test/client-half.smoke.mjs",
10
+ "test": "node test/host-half.smoke.mjs && node test/client-half.smoke.mjs && node test/compat-range.smoke.mjs",
8
11
  "test:host": "node test/host-half.smoke.mjs",
9
12
  "test:client": "node test/client-half.smoke.mjs",
13
+ "test:compat": "node test/compat-range.smoke.mjs",
10
14
  "prepublishOnly": "npm test"
11
15
  },
12
16
  "main": "lib/index.mjs",
@@ -27,10 +31,14 @@
27
31
  "assets",
28
32
  "LICENSE",
29
33
  "README.md",
30
- "README.en.md"
34
+ "README.en.md",
35
+ "CHANGELOG.md"
31
36
  ],
32
37
  "dependencies": {
33
- "@deepseek-ai/schemastery": "^3.18.2"
38
+ "@deepseek-ai/schemastery": "^3.18.4"
39
+ },
40
+ "devDependencies": {
41
+ "@deepseek-ai/cosmokit": "^1.8.5"
34
42
  },
35
43
  "dsh": {
36
44
  "bundle": {
@@ -63,10 +71,10 @@
63
71
  ],
64
72
  "peerDependencies": {
65
73
  "@deepseek-ai/cordis": "^4.0.2",
66
- "@deepseek-ai/dsh-client-locale": "^0.1.2-rc.1",
67
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-rc.1",
68
- "@deepseek-ai/dsh-client-ui-session": "^0.1.2-rc.1",
69
- "@deepseek-ai/dsh-settings": "^0.1.2-rc.1"
74
+ "@deepseek-ai/dsh-client-locale": ">=0.1.7-rc.2 <0.2.0-0",
75
+ "@deepseek-ai/dsh-client-ui-settings": ">=0.1.7-rc.2 <0.2.0-0",
76
+ "@deepseek-ai/dsh-client-ui-session": ">=0.1.7-rc.2 <0.2.0-0",
77
+ "@deepseek-ai/dsh-settings": ">=0.1.7-rc.2 <0.2.0-0"
70
78
  },
71
79
  "license": "MIT"
72
80
  }