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 +100 -0
- package/README.en.md +20 -8
- package/README.md +21 -8
- package/lib/client.cjs +405 -91
- package/lib/index.mjs +49 -30
- package/package.json +16 -8
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
|
|
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`
|
|
225
|
-
| **Browser half** | `lib/client.cjs`
|
|
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"], …)` —
|
|
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
|
-
**
|
|
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
|
-
| `
|
|
240
|
-
| `
|
|
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
|
|
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
|
|
224
|
-
| **浏览器半** | `lib/client.cjs
|
|
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"], …)` ——
|
|
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
|
-
|
|
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
|
-
| `
|
|
239
|
-
| `
|
|
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.
|
|
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.
|
|
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 绑定:
|
|
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
|
-
|
|
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
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
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:
|
|
884
|
+
volume: shownVolume,
|
|
661
885
|
kind,
|
|
662
886
|
t,
|
|
663
|
-
onChange: (next) => scope.set(
|
|
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:
|
|
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:
|
|
712
|
-
onChange: (e) =>
|
|
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.
|
|
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"), () =>
|
|
766
|
-
colorsExpanded ? colorRow("amber", t("amberLabel"), () =>
|
|
767
|
-
colorsExpanded ? colorRow("black", t("blackLabel"), () =>
|
|
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
|
-
"
|
|
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
|
-
*
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
*
|
|
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
|
|
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 -
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
1045
|
-
const current = new Set(
|
|
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 =
|
|
1299
|
+
const row = pendingNow.get(id);
|
|
1049
1300
|
if (row !== void 0 && row.origin === "subagent") continue;
|
|
1050
|
-
|
|
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
|
-
|
|
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
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1063
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
1352
|
+
let paths = [...svg.querySelectorAll("path")];
|
|
1077
1353
|
if (paths.length === 0) continue;
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|
-
|
|
1084
|
-
|
|
1085
|
-
path.setAttribute("
|
|
1086
|
-
|
|
1087
|
-
}
|
|
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
|
|
1110
|
-
|
|
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
|
-
|
|
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
|
|
1138
|
-
*
|
|
1139
|
-
*
|
|
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
|
-
|
|
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)
|
|
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 (
|
|
1157
|
-
|
|
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
|
-
|
|
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
|
-
/** 回到前台(标签页可见且窗口有焦点)→
|
|
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
|
|
1494
|
+
for (const row of sessionRows(state)) {
|
|
1190
1495
|
if (row.origin === "subagent") continue;
|
|
1191
|
-
if (
|
|
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
|
-
*
|
|
1213
|
-
*
|
|
1214
|
-
*
|
|
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
|
-
|
|
1218
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
10
|
-
* dsh-settings
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
34
|
-
*
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
+
"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.
|
|
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": "
|
|
67
|
-
"@deepseek-ai/dsh-client-ui-settings": "
|
|
68
|
-
"@deepseek-ai/dsh-client-ui-session": "
|
|
69
|
-
"@deepseek-ai/dsh-settings": "
|
|
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
|
}
|