dsh-notice-center 1.2.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.
Files changed (52) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +165 -0
  3. package/assets/audio/README.md +25 -0
  4. package/assets/audio/alert-01.mp3 +0 -0
  5. package/assets/audio/alert-02.mp3 +0 -0
  6. package/assets/audio/alert-03.mp3 +0 -0
  7. package/assets/audio/alert-04.mp3 +0 -0
  8. package/assets/audio/alert-05.mp3 +0 -0
  9. package/assets/audio/alert-06.mp3 +0 -0
  10. package/assets/audio/alert-07.mp3 +0 -0
  11. package/assets/audio/alert-08.mp3 +0 -0
  12. package/assets/audio/alert-09.mp3 +0 -0
  13. package/assets/audio/alert-10.mp3 +0 -0
  14. package/assets/audio/bip-bop-01.mp3 +0 -0
  15. package/assets/audio/bip-bop-02.mp3 +0 -0
  16. package/assets/audio/bip-bop-03.mp3 +0 -0
  17. package/assets/audio/bip-bop-04.mp3 +0 -0
  18. package/assets/audio/bip-bop-05.mp3 +0 -0
  19. package/assets/audio/bip-bop-06.mp3 +0 -0
  20. package/assets/audio/bip-bop-07.mp3 +0 -0
  21. package/assets/audio/bip-bop-08.mp3 +0 -0
  22. package/assets/audio/bip-bop-09.mp3 +0 -0
  23. package/assets/audio/bip-bop-10.mp3 +0 -0
  24. package/assets/audio/nope-01.mp3 +0 -0
  25. package/assets/audio/nope-02.mp3 +0 -0
  26. package/assets/audio/nope-03.mp3 +0 -0
  27. package/assets/audio/nope-04.mp3 +0 -0
  28. package/assets/audio/nope-05.mp3 +0 -0
  29. package/assets/audio/nope-06.mp3 +0 -0
  30. package/assets/audio/nope-07.mp3 +0 -0
  31. package/assets/audio/nope-08.mp3 +0 -0
  32. package/assets/audio/nope-09.mp3 +0 -0
  33. package/assets/audio/nope-10.mp3 +0 -0
  34. package/assets/audio/nope-11.mp3 +0 -0
  35. package/assets/audio/nope-12.mp3 +0 -0
  36. package/assets/audio/staplebops-01.mp3 +0 -0
  37. package/assets/audio/staplebops-02.mp3 +0 -0
  38. package/assets/audio/staplebops-03.mp3 +0 -0
  39. package/assets/audio/staplebops-04.mp3 +0 -0
  40. package/assets/audio/staplebops-05.mp3 +0 -0
  41. package/assets/audio/staplebops-06.mp3 +0 -0
  42. package/assets/audio/staplebops-07.mp3 +0 -0
  43. package/assets/audio/yup-01.mp3 +0 -0
  44. package/assets/audio/yup-02.mp3 +0 -0
  45. package/assets/audio/yup-03.mp3 +0 -0
  46. package/assets/audio/yup-04.mp3 +0 -0
  47. package/assets/audio/yup-05.mp3 +0 -0
  48. package/assets/audio/yup-06.mp3 +0 -0
  49. package/cordis.patch.yml +9 -0
  50. package/lib/client.cjs +1157 -0
  51. package/lib/index.mjs +126 -0
  52. package/package.json +71 -0
package/lib/client.cjs ADDED
@@ -0,0 +1,1157 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-notice-center",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ let react_jsx_runtime = require("react/jsx-runtime");
9
+ let primitives = require("@deepseek-ai/dsh-client-ui-primitives");
10
+ //#region src/favicon.ts
11
+ function whaleSvg(color) {
12
+ return "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"50\" height=\"50\" viewBox=\"0 0 50 50\" fill=\"none\"><path d=\"M48.8354 10.0479C48.3232 9.79199 48.1025 10.2798 47.8032 10.5278C47.7007 10.6079 47.6143 10.7119 47.5273 10.8076C46.7793 11.624 45.9048 12.1597 44.7622 12.0957C43.0923 12 41.666 12.5356 40.4058 13.8398C40.1377 12.2319 39.2476 11.272 37.8926 10.6558C37.1836 10.3359 36.4668 10.0156 35.9702 9.31982C35.6235 8.82373 35.5293 8.27197 35.356 7.72754C35.2456 7.3999 35.1353 7.06396 34.7651 7.00781C34.3633 6.94385 34.2056 7.2876 34.0479 7.57568C33.418 8.75195 33.1733 10.0479 33.1973 11.3599C33.2524 14.312 34.4736 16.6641 36.8999 18.3359C37.1758 18.5278 37.2466 18.7197 37.1597 19C36.9946 19.5757 36.7974 20.1357 36.624 20.7119C36.5137 21.0801 36.3486 21.1597 35.9624 21C34.6309 20.4321 33.481 19.5918 32.4644 18.5757C30.7393 16.8721 29.1792 14.9917 27.2334 13.52C26.7764 13.1758 26.3193 12.856 25.8467 12.5518C23.8618 10.584 26.1069 8.96777 26.627 8.77588C27.1704 8.57568 26.8159 7.8877 25.0591 7.896C23.3022 7.90381 21.6953 8.50391 19.647 9.30371C19.3477 9.42383 19.0322 9.51172 18.7095 9.58398C16.8501 9.22363 14.9199 9.14355 12.9033 9.37598C9.10596 9.80762 6.07275 11.6396 3.84326 14.7681C1.16455 18.5278 0.53418 22.7998 1.30664 27.2559C2.11768 31.9521 4.46582 35.8398 8.07373 38.8799C11.8159 42.0322 16.1255 43.5762 21.041 43.2803C24.0269 43.104 27.3516 42.6963 31.1016 39.4561C32.0469 39.936 33.0396 40.1279 34.686 40.272C35.9546 40.3921 37.1758 40.208 38.1211 40.0078C39.6021 39.688 39.4995 38.2881 38.9639 38.0322C34.623 35.9678 35.5762 36.8081 34.71 36.1279C36.9155 33.4639 40.2402 30.6958 41.54 21.728C41.6426 21.0161 41.5557 20.5679 41.54 19.9917C41.5322 19.6396 41.6108 19.5039 42.0049 19.4639C43.0923 19.3359 44.1479 19.0317 45.1167 18.4878C47.9292 16.9199 49.064 14.3438 49.3315 11.2559C49.3711 10.7837 49.3237 10.2959 48.8354 10.0479ZM24.3262 37.8398C20.1196 34.4639 18.0791 33.3521 17.2358 33.3999C16.4482 33.4482 16.5898 34.3682 16.7632 34.9678C16.9443 35.5601 17.1812 35.9683 17.5117 36.4878C17.7402 36.832 17.8979 37.3442 17.2832 37.728C15.9282 38.584 13.5728 37.4399 13.4624 37.3838C10.7207 35.7358 8.42822 33.5601 6.81348 30.584C5.25342 27.7197 4.34766 24.6479 4.19775 21.3677C4.1582 20.5757 4.38672 20.2959 5.15869 20.1519C6.17529 19.96 7.22314 19.9199 8.23926 20.0718C12.5327 20.7119 16.1885 22.6719 19.2529 25.7759C21.002 27.5439 22.3252 29.6558 23.6885 31.7202C25.1377 33.9121 26.6978 36 28.6831 37.7119C29.3843 38.312 29.9434 38.7681 30.479 39.104C28.8643 39.2881 26.1699 39.3281 24.3262 37.8398ZM26.3433 24.6001C26.3433 24.248 26.6191 23.9678 26.9658 23.9678C27.0444 23.9678 27.1152 23.9839 27.1782 24.0078C27.2651 24.04 27.3438 24.0879 27.4067 24.1602C27.5171 24.272 27.5801 24.4321 27.5801 24.6001C27.5801 24.9521 27.3042 25.2319 26.9575 25.2319C26.6108 25.2319 26.3433 24.9521 26.3433 24.6001ZM32.6064 27.8799C32.2046 28.0479 31.8027 28.1919 31.4165 28.208C30.8179 28.2397 30.1641 27.9922 29.8096 27.688C29.2583 27.2158 28.8643 26.9521 28.6987 26.1279C28.6279 25.7759 28.6675 25.2319 28.7305 24.9199C28.8721 24.248 28.7144 23.8159 28.2495 23.4238C27.8716 23.104 27.3911 23.0161 26.8633 23.0161C26.666 23.0161 26.4849 22.9277 26.3511 22.856C26.1304 22.7441 25.9492 22.4639 26.1226 22.1201C26.1777 22.0078 26.4458 21.7358 26.5088 21.688C27.2256 21.272 28.0527 21.4077 28.8169 21.7197C29.5259 22.0161 30.0615 22.5601 30.834 23.3281C31.6216 24.2559 31.7632 24.5117 32.2124 25.208C32.5669 25.752 32.8901 26.312 33.1104 26.9521C33.2446 27.3521 33.0713 27.6802 32.6064 27.8799Z\" fill=\"" + color + "\" fill-opacity=\"1\" fill-rule=\"nonzero\"/></svg>";
13
+ }
14
+ //#endregion
15
+ //#region src/shared.ts
16
+ /**
17
+ * 宿主/浏览器共享的常量与类型(纯值,不引入任何 @deepseek-ai 运行时依赖,
18
+ * 保证浏览器 bundle 只 import 类型)。
19
+ */
20
+ const SETTINGS_NAMESPACE = "notice-center";
21
+ /** 官方侧边栏状态点颜色(静态色板,明暗主题同值)。 */
22
+ const DEFAULT_GREEN = "#22C55E";
23
+ const DEFAULT_AMBER = "#F59E0B";
24
+ /** 6 位 hex 校验(ColorPicker 输出与手输均为此格式)。 */
25
+ const HEX_PATTERN = /^#[0-9a-fA-F]{6}$/;
26
+ /** 系统通知默认值(与宿主 schema 保持一致;客户端仅用于设置页回显)。 */
27
+ const NOTIFY_DEFAULTS = {
28
+ notifyEnabled: false,
29
+ notifySound: true,
30
+ notifyVolume: 0.6,
31
+ notifyAutoHide: true,
32
+ notifyDoneSound: "builtin-up",
33
+ notifyPendingSound: "builtin-down"
34
+ };
35
+ /** 颜色状态灯的默认值(green/amber 为官方侧边栏色;black 未配置表示沿用官方图标)。 */
36
+ const COLOR_DEFAULTS = {
37
+ green: "#22C55E",
38
+ amber: "#F59E0B",
39
+ black: "#000000"
40
+ };
41
+ /** 颜色状态灯总开关的默认值(与宿主 schema 一致)。 */
42
+ const COLORS_ENABLED_DEFAULT = true;
43
+ /** 鲸鱼轮廓的 path 数据(从 whaleSvg 提取,供设置页颜色行的鲸鱼图标预览使用)。 */
44
+ const WHALE_SHAPE_PATH = (() => {
45
+ const match = /d="([^"]+)"/.exec(whaleSvg("currentColor"));
46
+ return match === null ? "" : match[1];
47
+ })();
48
+ /**
49
+ * 当前系统通知权限:"granted" | "denied" | "default";浏览器不支持时为 "unsupported"。
50
+ * @returns 权限字符串。
51
+ */
52
+ function notificationSupport() {
53
+ try {
54
+ if (typeof Notification === "undefined" || typeof Notification.permission !== "string") return "unsupported";
55
+ return Notification.permission;
56
+ } catch {
57
+ return "unsupported";
58
+ }
59
+ }
60
+ /** 复用的音频上下文(提示音用 Web Audio 合成,不依赖任何音频资源文件)。 */
61
+ let chimeContext;
62
+ /**
63
+ * 取(或创建)音频上下文;浏览器不支持时返回 null。
64
+ * @returns AudioContext 实例或 null。
65
+ */
66
+ function chimeAudioContext() {
67
+ if (chimeContext !== void 0) return chimeContext;
68
+ try {
69
+ const Ctor = window.AudioContext ?? window.webkitAudioContext;
70
+ chimeContext = Ctor === void 0 ? null : new Ctor();
71
+ } catch {
72
+ chimeContext = null;
73
+ }
74
+ return chimeContext;
75
+ }
76
+ /** 在用户手势中预热音频上下文(浏览器自动播放策略要求先有交互才能出声)。 */
77
+ function primeChime() {
78
+ try {
79
+ const audio = chimeAudioContext();
80
+ if (audio !== null && audio.state === "suspended") audio.resume?.();
81
+ } catch {}
82
+ }
83
+ /**
84
+ * 把任意输入收敛为 0–1 的音量系数。
85
+ * @param volume - 原始音量值。
86
+ * @returns 归一化后的音量(缺省 0.6)。
87
+ */
88
+ function normalizeVolume(volume) {
89
+ if (typeof volume !== "number" || !Number.isFinite(volume)) return 0.6;
90
+ return Math.min(Math.max(volume, 0), 1);
91
+ }
92
+ /**
93
+ * 播放提示音:完成 = Chime Up(上行 660 → 990Hz),待处理 = Chime Down(下行 880 → 587Hz)。
94
+ * 两种音高走向不同,便于不看屏幕也能区分。合成失败时静默。
95
+ * @param kind - "done" | "pending"。
96
+ * @param volume - 音量 0–1(缺省 0.6);为 0 时不出声。
97
+ */
98
+ function playChime(kind, volume) {
99
+ try {
100
+ const level = normalizeVolume(volume);
101
+ if (level <= 0) return;
102
+ const audio = chimeAudioContext();
103
+ if (audio === null) return;
104
+ if (audio.state === "suspended") audio.resume?.();
105
+ const notes = kind === "done" ? [660, 990] : [880, 587];
106
+ const base = audio.currentTime;
107
+ notes.forEach((frequency, index) => {
108
+ const oscillator = audio.createOscillator();
109
+ const gain = audio.createGain();
110
+ const start = base + index * 0.14;
111
+ oscillator.type = "sine";
112
+ oscillator.frequency.value = frequency;
113
+ gain.gain.setValueAtTime(0.0001, start);
114
+ gain.gain.exponentialRampToValueAtTime(0.12 * level, start + 0.02);
115
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.18);
116
+ oscillator.connect(gain);
117
+ gain.connect(audio.destination);
118
+ oscillator.start(start);
119
+ oscillator.stop(start + 0.2);
120
+ });
121
+ } catch {}
122
+ }
123
+ //#endregion
124
+ /* ---------- 音效库(opencode 内置音效,MIT) ---------- */
125
+ /** 音效静态路由前缀(宿主半 lib/index.mjs 注册;路由缺席时自动回退合成音)。 */
126
+ const SOUND_ROUTE = "/notice-center-sounds";
127
+ /** opencode 内置音效包(取自 anomalyco/opencode packages/ui/src/assets/audio,MIT)。 */
128
+ const SOUND_PACKS = [
129
+ { name: "Alert", prefix: "alert", count: 10 },
130
+ { name: "Bip-bop", prefix: "bip-bop", count: 10 },
131
+ { name: "Staplebops", prefix: "staplebops", count: 7 },
132
+ { name: "Nope", prefix: "nope", count: 12 },
133
+ { name: "Yup", prefix: "yup", count: 6 }
134
+ ];
135
+ /** 内置合成音(无资源依赖,也是默认值)。 */
136
+ const BUILTIN_SOUNDS = [
137
+ { id: "builtin-up", labelKey: "soundBuiltinUp" },
138
+ { id: "builtin-down", labelKey: "soundBuiltinDown" }
139
+ ];
140
+ /**
141
+ * 音效库分组(内置 + opencode 五个包),下拉与 id→显示名都走这里。
142
+ * @param t - 命名空间文案函数。
143
+ * @returns 分组数组 [{ group, items: [{ id, label }] }]。
144
+ */
145
+ function soundLibrary(t) {
146
+ const packs = [{
147
+ group: t("soundPackBuiltin"),
148
+ items: BUILTIN_SOUNDS.map((item) => ({ id: item.id, label: t(item.labelKey) }))
149
+ }];
150
+ for (const pack of SOUND_PACKS) {
151
+ const items = [];
152
+ for (let index = 1; index <= pack.count; index += 1) {
153
+ const number = String(index).padStart(2, "0");
154
+ items.push({ id: pack.prefix + "-" + number, label: pack.name + " " + number });
155
+ }
156
+ packs.push({ group: pack.name, items });
157
+ }
158
+ return packs;
159
+ }
160
+ /**
161
+ * 音效 id → 显示名(设置页下拉按钮回显用)。
162
+ * @param t - 命名空间文案函数。
163
+ * @returns id→名称映射。
164
+ */
165
+ function soundNames(t) {
166
+ const names = {};
167
+ for (const pack of soundLibrary(t)) for (const item of pack.items) names[item.id] = item.label;
168
+ return names;
169
+ }
170
+ /** 当前正在播放的音频元素(同一时刻只放一条,避免悬浮扫过时叠音)。 */
171
+ let activeSound = null;
172
+ /** 停止当前音频(无音频时静默)。 */
173
+ function stopSound() {
174
+ const audio = activeSound;
175
+ activeSound = null;
176
+ if (audio === null) return;
177
+ try {
178
+ audio.pause();
179
+ audio.currentTime = 0;
180
+ } catch {}
181
+ }
182
+ /**
183
+ * 播放音效:builtin-* 走 Web Audio 合成;其余走宿主半静态路由下的 mp3。
184
+ * 音频取不到或播放被拒(路由缺席、被浏览器策略拦截)时回退合成音,
185
+ * 保证「有动静」这件事不丢。
186
+ * @param id - 音效 id。
187
+ * @param volume - 音量 0–1。
188
+ * @param kind - "done" | "pending",仅决定回退合成音的音高走向。
189
+ */
190
+ function playSound(id, volume, kind) {
191
+ const level = normalizeVolume(volume);
192
+ if (level <= 0) return;
193
+ const name = typeof id === "string" ? id : "";
194
+ stopSound();
195
+ if (name === "" || name.startsWith("builtin-")) {
196
+ playChime(name === "builtin-up" ? "done" : name === "builtin-down" ? "pending" : kind, level);
197
+ return;
198
+ }
199
+ try {
200
+ const audio = new Audio(SOUND_ROUTE + "/" + name + ".mp3");
201
+ audio.volume = level;
202
+ activeSound = audio;
203
+ const played = audio.play();
204
+ if (played !== void 0 && typeof played.catch === "function") played.catch(() => {
205
+ if (activeSound === audio) activeSound = null;
206
+ playChime(kind, level);
207
+ });
208
+ } catch {
209
+ playChime(kind, level);
210
+ }
211
+ }
212
+ //#region src/sound-picker.tsx
213
+ /**
214
+ * 音效下拉(自绘,不用原生 select):原生下拉的选项由操作系统绘制,
215
+ * 拿不到 hover 事件,做不到「悬浮哪个听哪个」。这里用绝对定位面板自绘:
216
+ * - 悬浮选项 120ms 后试听(轻微防抖,鼠标扫过整列时不会连成一片);
217
+ * - 点击选中并收起;点面板外或按 Esc 收起。
218
+ * 样式照官方:触发按钮=LanguageRow 的 .selector(36px/18px 圆角/bg-module-platform),
219
+ * 面板=primitives Menu(bg-layer-3 + .5px 边框 + 6px 圆角 + padding 4px),条目标高用 interactive-bg-hover。
220
+ */
221
+ function SoundPicker(props) {
222
+ const value = props.value;
223
+ const groups = props.groups;
224
+ const names = props.names;
225
+ const volume = props.volume;
226
+ const kind = props.kind;
227
+ const t = props.t;
228
+ const onChange = props.onChange;
229
+ const state = (0, react.useState)(false);
230
+ const open = state[0];
231
+ const setOpen = state[1];
232
+ /** 触发按钮悬停态(官方下拉没有可用的 CSS 类,用行内样式复刻 hover 换填充色)。 */
233
+ const hoverState = (0, react.useState)(false);
234
+ const hover = hoverState[0];
235
+ const setHover = hoverState[1];
236
+ /** 面板里当前悬浮的选项 id(给出与官方菜单一致的 hover 反馈)。 */
237
+ const hoverIdState = (0, react.useState)(void 0);
238
+ const hoverId = hoverIdState[0];
239
+ const setHoverId = hoverIdState[1];
240
+ const wrapRef = (0, react.useRef)(null);
241
+ const hoverTimer = (0, react.useRef)(void 0);
242
+ (0, react.useEffect)(() => {
243
+ if (!open) return void 0;
244
+ const onPointerDown = (event) => {
245
+ const node = wrapRef.current;
246
+ if (node !== null && node !== void 0 && !node.contains(event.target)) setOpen(false);
247
+ };
248
+ const onKeyDown = (event) => {
249
+ if (event.key === "Escape") setOpen(false);
250
+ };
251
+ document.addEventListener("mousedown", onPointerDown);
252
+ document.addEventListener("keydown", onKeyDown);
253
+ return () => {
254
+ document.removeEventListener("mousedown", onPointerDown);
255
+ document.removeEventListener("keydown", onKeyDown);
256
+ };
257
+ }, [open]);
258
+ /** 清掉待触发的悬浮试听(离开列表或收起面板时用)。 */
259
+ const clearHover = () => {
260
+ if (hoverTimer.current !== void 0) {
261
+ clearTimeout(hoverTimer.current);
262
+ hoverTimer.current = void 0;
263
+ }
264
+ };
265
+ (0, react.useEffect)(() => clearHover, []);
266
+ /** 悬浮试听:120ms 防抖,避免鼠标扫过整列时连响。 */
267
+ const hoverPreview = (id) => {
268
+ clearHover();
269
+ hoverTimer.current = setTimeout(() => {
270
+ hoverTimer.current = void 0;
271
+ playSound(id, volume, kind);
272
+ }, 120);
273
+ };
274
+ const panelStyle = {
275
+ position: "absolute",
276
+ zIndex: 30,
277
+ top: 40,
278
+ right: 0,
279
+ width: "100%",
280
+ minWidth: 96,
281
+ maxHeight: 284,
282
+ overflowY: "auto",
283
+ padding: 4,
284
+ boxSizing: "border-box",
285
+ display: "flex",
286
+ flexDirection: "column",
287
+ background: "var(--dsw-alias-bg-layer-3)",
288
+ border: "0.5px solid var(--dsw-alias-border-l2)",
289
+ borderRadius: 6
290
+ };
291
+ const groupStyle = {
292
+ padding: "6px 8px 2px",
293
+ color: "var(--dsw-alias-label-tertiary)",
294
+ fontSize: 11,
295
+ lineHeight: "16px"
296
+ };
297
+ const optionStyle = {
298
+ display: "flex",
299
+ alignItems: "center",
300
+ gap: 6,
301
+ width: "100%",
302
+ boxSizing: "border-box",
303
+ padding: "5px 8px",
304
+ border: "none",
305
+ borderRadius: 4,
306
+ background: "transparent",
307
+ color: "var(--dsw-alias-label-primary)",
308
+ fontSize: "var(--dsh-content-font-size-secondary, 13px)",
309
+ textAlign: "left",
310
+ cursor: "pointer"
311
+ };
312
+ return (0, react_jsx_runtime.jsxs)("div", {
313
+ ref: wrapRef,
314
+ style: {
315
+ position: "relative",
316
+ flex: "0 0 auto"
317
+ },
318
+ children: [
319
+ (0, react_jsx_runtime.jsxs)("button", {
320
+ type: "button",
321
+ "aria-haspopup": "listbox",
322
+ "aria-expanded": open,
323
+ title: t("soundPick"),
324
+ onClick: () => setOpen(!open),
325
+ onMouseEnter: () => setHover(true),
326
+ onMouseLeave: () => setHover(false),
327
+ /* 官方下拉配方(LanguageRow.module.css 的 .selector 原样搬过来):
328
+ 36px 高 / 18px 圆角 / bg-module-platform 填充,hover 换 interactive-bg-hover。 */
329
+ style: {
330
+ display: "inline-flex",
331
+ alignItems: "center",
332
+ justifyContent: "space-between",
333
+ gap: 12,
334
+ boxSizing: "border-box",
335
+ minWidth: 132,
336
+ maxWidth: 220,
337
+ height: 36,
338
+ padding: "0 14px",
339
+ border: "none",
340
+ borderRadius: 18,
341
+ background: hover ? "var(--dsw-alias-interactive-bg-hover)" : "var(--dsw-alias-bg-module-platform)",
342
+ color: "var(--dsw-alias-label-primary)",
343
+ font: "inherit",
344
+ fontSize: 14,
345
+ lineHeight: "22px",
346
+ cursor: "pointer"
347
+ },
348
+ children: [
349
+ (0, react_jsx_runtime.jsx)("span", {
350
+ style: {
351
+ flex: 1,
352
+ minWidth: 0,
353
+ overflow: "hidden",
354
+ textOverflow: "ellipsis",
355
+ whiteSpace: "nowrap",
356
+ textAlign: "left"
357
+ },
358
+ children: names[value] ?? value
359
+ }),
360
+ (0, react_jsx_runtime.jsx)(primitives.IconChevronDownOutline14, { style: { flex: "none" } })
361
+ ]
362
+ }),
363
+ open ? (0, react_jsx_runtime.jsxs)("div", {
364
+ role: "listbox",
365
+ style: panelStyle,
366
+ onMouseLeave: () => {
367
+ clearHover();
368
+ setHoverId(void 0);
369
+ },
370
+ children: groups.flatMap((pack) => [
371
+ (0, react_jsx_runtime.jsx)("div", {
372
+ style: groupStyle,
373
+ children: pack.group
374
+ }, "group-" + pack.group),
375
+ ...pack.items.map((item) => {
376
+ const selected = item.id === value;
377
+ return (0, react_jsx_runtime.jsxs)("button", {
378
+ type: "button",
379
+ role: "option",
380
+ "aria-selected": selected,
381
+ onMouseEnter: () => {
382
+ setHoverId(item.id);
383
+ hoverPreview(item.id);
384
+ },
385
+ onFocus: () => hoverPreview(item.id),
386
+ onClick: () => {
387
+ clearHover();
388
+ setOpen(false);
389
+ onChange(item.id);
390
+ },
391
+ style: {
392
+ ...optionStyle,
393
+ background: item.id === hoverId ? "var(--dsw-alias-interactive-bg-hover)" : selected ? "var(--dsw-alias-button-ghost-active-fill)" : "transparent",
394
+ color: selected ? "var(--dsw-alias-brand-primary)" : "var(--dsw-alias-label-primary)"
395
+ },
396
+ children: [
397
+ (0, react_jsx_runtime.jsx)("span", {
398
+ style: {
399
+ flex: 1,
400
+ minWidth: 0,
401
+ overflow: "hidden",
402
+ textOverflow: "ellipsis",
403
+ whiteSpace: "nowrap"
404
+ },
405
+ children: item.label
406
+ }),
407
+ selected ? (0, react_jsx_runtime.jsx)("span", { children: "✓" }) : null
408
+ ]
409
+ }, item.id);
410
+ })
411
+ ])
412
+ }) : null
413
+ ]
414
+ });
415
+ }
416
+ //#endregion
417
+ //#region src/settings-section.tsx
418
+ /**
419
+ * 设置页组件 —— 注册进官方设置面板的 `settings.section` 槽。
420
+ *
421
+ * 三行颜色(完成/待处理/默认),每行原生 ColorPicker + hex 文本框(约 1/3 宽)
422
+ * + 行内「恢复默认颜色」按钮(unset 该字段):
423
+ * - 输入合法(#RRGGBB)即写入 settings(live 生效,宿主 schema 校验兜底);
424
+ * - 输入非法时提示错误、不写入(拒绝写入并提示)。
425
+ * 每行恢复按钮只清对应字段:绿/琥珀回官方默认,黑 → 官方原版。
426
+ *
427
+ * ⚠️ this 绑定:SettingsScopeController 的方法是类方法(依赖 this.store),
428
+ * 直接传裸引用给 useSyncExternalStore 会丢 this 导致渲染崩溃,必须箭头包装。
429
+ */
430
+ function WhaleSettingsSection({ scope, t }) {
431
+ if (scope === void 0 || t === void 0) return null;
432
+ const value = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot(), () => scope.getSnapshot()).value ?? {};
433
+ /** 通知权限的本地态(未授权提示 + 开启通知时自动请求)。 */
434
+ const [permission, setPermission] = (0, react.useState)(notificationSupport());
435
+ /** 两个分组的展开态(本地 UI 状态,均默认折叠)。 */
436
+ const [colorsExpanded, setColorsExpanded] = (0, react.useState)(false);
437
+ const [notifyExpanded, setNotifyExpanded] = (0, react.useState)(false);
438
+ /** 当前值(未配置回默认)。 */
439
+ const colorsEnabled = value.colorsEnabled ?? COLORS_ENABLED_DEFAULT;
440
+ const notifyEnabled = value.notifyEnabled ?? NOTIFY_DEFAULTS.notifyEnabled;
441
+ const notifySound = value.notifySound ?? NOTIFY_DEFAULTS.notifySound;
442
+ const notifyVolume = normalizeVolume(value.notifyVolume ?? NOTIFY_DEFAULTS.notifyVolume);
443
+ const notifyAutoHide = value.notifyAutoHide ?? NOTIFY_DEFAULTS.notifyAutoHide;
444
+ /** 官方设置行规格(对齐「通用设置 → 权限」字段的 row/title/desc 样式)。 */
445
+ const rowStyle = {
446
+ display: "flex",
447
+ alignItems: "center",
448
+ gap: 8,
449
+ padding: "16px 0",
450
+ borderBottom: "0.5px solid var(--dsw-alias-border-l2)"
451
+ };
452
+ const rowTextStyle = {
453
+ display: "flex",
454
+ flexDirection: "column",
455
+ flex: 1,
456
+ gap: 4,
457
+ minWidth: 0,
458
+ paddingRight: 48
459
+ };
460
+ const titleStyle = {
461
+ color: "var(--dsw-alias-label-primary)",
462
+ fontSize: 14,
463
+ fontWeight: 400,
464
+ lineHeight: "22px"
465
+ };
466
+ const descStyle = {
467
+ color: "var(--dsw-alias-label-tertiary)",
468
+ fontSize: 12,
469
+ fontWeight: 400,
470
+ lineHeight: "18px"
471
+ };
472
+ /** 小图标按钮(展开箭头 / 恢复默认共用)。 */
473
+ const iconButtonStyle = {
474
+ flex: "0 0 auto",
475
+ display: "inline-flex",
476
+ alignItems: "center",
477
+ justifyContent: "center",
478
+ width: 28,
479
+ height: 28,
480
+ padding: 0,
481
+ border: "none",
482
+ borderRadius: 6,
483
+ background: "transparent",
484
+ color: "var(--dsw-alias-label-secondary)",
485
+ cursor: "pointer"
486
+ };
487
+ /** 分组条目:主标题 + 描述 + 官方 Switch + 折叠展开箭头(子项由展开态控制显示)。 */
488
+ const sectionRow = (key, title, hint, checked, onChange, expanded, onToggle, expandLabel) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
489
+ style: rowStyle,
490
+ children: [
491
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
492
+ style: rowTextStyle,
493
+ children: [
494
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
495
+ style: titleStyle,
496
+ children: title
497
+ }),
498
+ hint === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
499
+ style: descStyle,
500
+ children: hint
501
+ })
502
+ ]
503
+ }),
504
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(primitives.Switch, {
505
+ checked,
506
+ label: title,
507
+ onChange: (next) => onChange(typeof next === "boolean" ? next : !checked)
508
+ }),
509
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
510
+ type: "button",
511
+ "aria-expanded": expanded,
512
+ "aria-label": expandLabel,
513
+ title: expandLabel,
514
+ onClick: onToggle,
515
+ style: iconButtonStyle,
516
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
517
+ style: {
518
+ display: "inline-flex",
519
+ transform: expanded ? "rotate(180deg)" : "none",
520
+ transition: "transform 0.2s"
521
+ },
522
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(primitives.IconChevronDownOutline14, { size: 14 })
523
+ })
524
+ })
525
+ ]
526
+ }, key);
527
+ /** 一行子项设置:左侧标题(可带描述),右侧官方 Switch 原语(缩进 16px)。 */
528
+ const switchRow = (key, label, hint, checked, onChange) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
529
+ style: {
530
+ ...rowStyle,
531
+ paddingLeft: 16
532
+ },
533
+ children: [
534
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
535
+ style: rowTextStyle,
536
+ children: [
537
+ label === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
538
+ style: titleStyle,
539
+ children: label
540
+ }),
541
+ hint === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
542
+ style: descStyle,
543
+ children: hint
544
+ })
545
+ ]
546
+ }),
547
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(primitives.Switch, {
548
+ checked,
549
+ label: label ?? hint,
550
+ onChange: (next) => onChange(typeof next === "boolean" ? next : !checked)
551
+ })
552
+ ]
553
+ }, key);
554
+ /** 音量行:左侧标签,右侧原生滑杆(accent-color 跟随主题)+ 百分比。 */
555
+ const volumeRow = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
556
+ style: {
557
+ ...rowStyle,
558
+ paddingLeft: 16
559
+ },
560
+ children: [
561
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
562
+ style: rowTextStyle,
563
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
564
+ style: titleStyle,
565
+ children: t("notifyVolume")
566
+ })
567
+ }),
568
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
569
+ type: "range",
570
+ min: 0,
571
+ max: 100,
572
+ step: 5,
573
+ value: Math.round(notifyVolume * 100),
574
+ "aria-label": t("notifyVolume"),
575
+ onChange: (e) => scope.set("notifyVolume", Number(e.target.value) / 100),
576
+ style: {
577
+ flex: "0 0 auto",
578
+ width: 140,
579
+ accentColor: "var(--dsw-alias-brand-primary)",
580
+ cursor: "pointer"
581
+ }
582
+ }),
583
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
584
+ style: {
585
+ flex: "0 0 auto",
586
+ minWidth: 36,
587
+ textAlign: "right",
588
+ color: "var(--dsw-alias-label-tertiary)",
589
+ fontSize: 12,
590
+ lineHeight: "18px"
591
+ },
592
+ children: [Math.round(notifyVolume * 100), "%"]
593
+ })
594
+ ]
595
+ }, "volume");
596
+ /** 音效行:左侧标题,右侧官方样式下拉(悬浮选项即试听,点选生效)。 */
597
+ const soundRow = (kind, label) => {
598
+ const soundKey = kind === "done" ? "notifyDoneSound" : "notifyPendingSound";
599
+ const currentSound = value[soundKey] ?? NOTIFY_DEFAULTS[soundKey];
600
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
601
+ style: {
602
+ ...rowStyle,
603
+ paddingLeft: 16
604
+ },
605
+ children: [
606
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
607
+ style: rowTextStyle,
608
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
609
+ style: titleStyle,
610
+ children: label
611
+ })
612
+ }),
613
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SoundPicker, {
614
+ value: currentSound,
615
+ groups: soundLibrary(t),
616
+ names: soundNames(t),
617
+ volume: notifyVolume,
618
+ kind,
619
+ t,
620
+ onChange: (next) => scope.set(kind === "done" ? "notifyDoneSound" : "notifyPendingSound", next)
621
+ })
622
+ ]
623
+ }, kind);
624
+ };
625
+ /** 一行颜色子项:左侧标签,右侧鲸鱼图标(按配置色填充,点击调起取色器)+ ↺ 恢复默认。 */
626
+ const colorRow = (key, label, onReset) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
627
+ style: {
628
+ ...rowStyle,
629
+ paddingLeft: 16
630
+ },
631
+ children: [
632
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
633
+ style: rowTextStyle,
634
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
635
+ style: titleStyle,
636
+ children: label
637
+ })
638
+ }),
639
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
640
+ style: {
641
+ flex: "0 0 auto",
642
+ position: "relative",
643
+ display: "inline-flex",
644
+ alignItems: "center",
645
+ justifyContent: "center",
646
+ width: 28,
647
+ height: 28,
648
+ cursor: "pointer"
649
+ },
650
+ children: [
651
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
652
+ style: {
653
+ display: "inline-flex",
654
+ color: value[key] ?? COLOR_DEFAULTS[key]
655
+ },
656
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
657
+ viewBox: "0 0 50 50",
658
+ width: 22,
659
+ height: 22,
660
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
661
+ d: WHALE_SHAPE_PATH,
662
+ fill: "currentColor"
663
+ })
664
+ })
665
+ }),
666
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
667
+ type: "color",
668
+ value: value[key] ?? COLOR_DEFAULTS[key],
669
+ onChange: (e) => scope.set(key, e.target.value),
670
+ "aria-label": label,
671
+ style: {
672
+ position: "absolute",
673
+ inset: 0,
674
+ width: "100%",
675
+ height: "100%",
676
+ padding: 0,
677
+ border: "none",
678
+ opacity: 0,
679
+ cursor: "pointer"
680
+ }
681
+ })
682
+ ]
683
+ }),
684
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
685
+ type: "button",
686
+ title: t("restore"),
687
+ "aria-label": t("restore"),
688
+ onClick: onReset,
689
+ style: iconButtonStyle,
690
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(primitives.IconRefreshOutline14, { size: 14 })
691
+ })
692
+ ]
693
+ }, key);
694
+ /** 打开通知总开关时:预热音频上下文(用户手势内)并在未授权时请求通知权限。 */
695
+ const setNotifyEnabled = (next) => {
696
+ scope.set("notifyEnabled", next);
697
+ if (!next) return;
698
+ primeChime();
699
+ if (notificationSupport() !== "default") return;
700
+ try {
701
+ const answer = Notification.requestPermission();
702
+ if (answer !== void 0 && typeof answer.then === "function") answer.then((result) => setPermission(result), () => setPermission(notificationSupport()));
703
+ } catch {
704
+ setPermission(notificationSupport());
705
+ }
706
+ };
707
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
708
+ style: {
709
+ display: "flex",
710
+ flexDirection: "column"
711
+ },
712
+ children: [
713
+ sectionRow("colorsEnabled", t("groupColors"), t("colorsHint"), colorsEnabled, (next) => scope.set("colorsEnabled", next), colorsExpanded, () => setColorsExpanded((current) => !current), t("colorsExpand")),
714
+ colorsExpanded ? colorRow("green", t("greenLabel"), () => scope.unset("green")) : null,
715
+ colorsExpanded ? colorRow("amber", t("amberLabel"), () => scope.unset("amber")) : null,
716
+ colorsExpanded ? colorRow("black", t("blackLabel"), () => scope.unset("black")) : null,
717
+ sectionRow("notifyEnabled", t("groupNotify"), t("notifyEnabledHint"), notifyEnabled, setNotifyEnabled, notifyExpanded, () => setNotifyExpanded((current) => !current), t("notifyExpand")),
718
+ notifyExpanded ? switchRow("notifyAutoHide", t("notifyAutoHide"), t("notifyAutoHideHint"), notifyAutoHide, (next) => scope.set("notifyAutoHide", next)) : null,
719
+ notifyExpanded ? switchRow("notifySound", t("notifySound"), void 0, notifySound, (next) => scope.set("notifySound", next)) : null,
720
+ notifyExpanded ? volumeRow() : null,
721
+ notifyExpanded ? soundRow("done", t("notifyDoneSound")) : null,
722
+ notifyExpanded ? soundRow("pending", t("notifyPendingSound")) : null,
723
+ notifyEnabled && permission === "denied" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
724
+ style: {
725
+ ...descStyle,
726
+ padding: "0 0 8px 16px"
727
+ },
728
+ role: "alert",
729
+ children: t("permissionDenied")
730
+ }) : null,
731
+ notifyEnabled && permission === "unsupported" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
732
+ style: {
733
+ ...descStyle,
734
+ padding: "0 0 8px 16px"
735
+ },
736
+ children: t("permissionUnsupported")
737
+ }) : null
738
+ ]
739
+ });
740
+ }
741
+ //#endregion
742
+ //#region src/client.ts
743
+ const DEFAULT_HREF = "/favicon.svg";
744
+ const inject = [
745
+ "sessions",
746
+ "slots",
747
+ "locale",
748
+ "settingsScope"
749
+ ];
750
+ function apply(ctx) {
751
+ const list = ctx.sessions.list;
752
+ /** 绑定 `notice-center` 命名空间:读快照 + 写用户配置(宿主 schema 校验)。 */
753
+ const scope = ctx.settingsScope.bind({ namespace: SETTINGS_NAMESPACE });
754
+ /**
755
+ * 待处理交互信号(0.1.2-rc.1+):官方把「有待处理交互」从会话行字段迁到
756
+ * dsh-client-ui-session 的 uiSession.pendingInteractions 独立 store
757
+ * (Map<sessionId, interaction>,提问/审批处理完自动移除)。
758
+ * 早于 0.1.2-rc.1 的版本没有该服务,因此下面用 ctx.inject 做**可选通道**:
759
+ * 服务缺席时该子 fiber 不加载,琥珀判定自动回退到旧版行字段。
760
+ */
761
+ let pendingStore;
762
+ /** 已完成/待处理通知的去重键(sessionId 加冒号加 kind)。 */
763
+ const notified = new Set();
764
+ /** 聚合队列与窗口:同一批状态跃迁合并成一条通知,避免刷屏。 */
765
+ const notifyQueue = new Map();
766
+ let notifyTimer;
767
+ /** 已通知过的会话完成态,用于识别 false → true 跃迁。 */
768
+ const prevCompleted = new Map();
769
+ /** 上一轮待处理会话集合与首帧标记(首帧不通知,避免刷新页面时轰炸)。 */
770
+ let prevPending = new Set();
771
+ let pendingSeen = false;
772
+ /** 页面是否真的处于前台:标签页可见 **且** 窗口有焦点。
773
+ * 只看 document.visibilityState,会把「浏览器被别的应用压在后面」误判成
774
+ * 「正在看」——而那恰恰是最该提醒的时候,因此补上 document.hasFocus()。 */
775
+ function isForeground() {
776
+ return document.visibilityState === "visible" && document.hasFocus();
777
+ }
778
+ /** 读取通知配置(未配置回默认值)。 */
779
+ function notifyConfig() {
780
+ const snapshot = scope.getSnapshot().value ?? {};
781
+ return {
782
+ enabled: snapshot.notifyEnabled ?? NOTIFY_DEFAULTS.notifyEnabled,
783
+ sound: snapshot.notifySound ?? NOTIFY_DEFAULTS.notifySound,
784
+ volume: normalizeVolume(snapshot.notifyVolume ?? NOTIFY_DEFAULTS.notifyVolume),
785
+ autoHide: snapshot.notifyAutoHide ?? NOTIFY_DEFAULTS.notifyAutoHide,
786
+ doneSound: snapshot.notifyDoneSound ?? NOTIFY_DEFAULTS.notifyDoneSound,
787
+ pendingSound: snapshot.notifyPendingSound ?? NOTIFY_DEFAULTS.notifyPendingSound
788
+ };
789
+ }
790
+ /**
791
+ * 记录一条待发通知(总开关关闭时跳过 + 去重 + 300ms 聚合)。
792
+ * @param kind - 通知类别:"done" | "pending"。
793
+ * @param sessionId - 会话 id。
794
+ * @param label - 会话显示名;聚合后作为通知**标题**。
795
+ * @param typeLabel - 通知**正文**(仅 pending 用):交互类型文案;缺省回退到通用标题。
796
+ */
797
+ function queueNotification(kind, sessionId, label, typeLabel) {
798
+ if (!notifyConfig().enabled) return;
799
+ const key = sessionId + ":" + kind;
800
+ if (notified.has(key)) return;
801
+ notified.add(key);
802
+ notifyQueue.set(key, {
803
+ kind,
804
+ sessionId,
805
+ label,
806
+ typeLabel
807
+ });
808
+ if (notifyTimer === void 0) notifyTimer = setTimeout(flushNotifications, 300);
809
+ }
810
+ /** 发送聚合后的系统通知(仅在已授权、且按配置允许时真正弹出)。 */
811
+ function flushNotifications() {
812
+ notifyTimer = void 0;
813
+ const entries = [...notifyQueue.values()];
814
+ notifyQueue.clear();
815
+ if (entries.length === 0) return;
816
+ if (notificationSupport() !== "granted") return;
817
+ const config = notifyConfig();
818
+ /* 固定策略:只在页面不在前台时打扰(原先的可配置项已移除)。
819
+ 「前台」= 标签页可见 且 窗口有焦点 —— 只看 visibilityState 会把「浏览器被
820
+ 别的应用压在后面」误判成正在看,而那正是最该提醒的时候。 */
821
+ if (isForeground()) return;
822
+ const t = ctx.locale.bind(SETTINGS_NAMESPACE);
823
+ const grouped = new Map();
824
+ for (const entry of entries) {
825
+ const list = grouped.get(entry.kind) ?? [];
826
+ list.push(entry);
827
+ grouped.set(entry.kind, list);
828
+ }
829
+ for (const [kind, list] of grouped) {
830
+ const head = list[0];
831
+ const extra = list.length - 1;
832
+ /* 标题=会话名(聚合时补「 +N」);正文=通知类型。 */
833
+ let title = head.label ?? head.sessionId;
834
+ if (extra > 0) title = title + " +" + String(extra);
835
+ const type = kind === "done" ? t("notifyDoneTitle") : head.typeLabel ?? t("notifyPendingTitle");
836
+ try {
837
+ const notification = new Notification(title, {
838
+ body: type,
839
+ /* 不要设置 tag:同一会话反复跑完会复用同一个 tag,Windows/Chrome 会把它当成
840
+ 「更新已有通知」(静默替换、不再弹横幅)——表现为「同一对话只有第一条会弹」
841
+ 「同一条命令第一次全弹、第二次全不弹」。每条事件本就该各自弹一次,故整条去掉。 */
842
+ icon: uri(kind === "done" ? colors().green : colors().amber),
843
+ silent: config.sound,
844
+ /* 关闭「自动隐藏」=请求常驻:通知会一直显示,直到用户激活或关闭它。 */
845
+ requireInteraction: !config.autoHide
846
+ });
847
+ if (config.sound) playSound(kind === "done" ? config.doneSound : config.pendingSound, config.volume, kind);
848
+ notification.onclick = () => {
849
+ try {
850
+ window.focus();
851
+ } catch {}
852
+ try {
853
+ ctx.sessions.open(head.sessionId);
854
+ } catch {}
855
+ notification.close();
856
+ };
857
+ } catch (error) { console.warn("[notice-center] 通知构造失败", error); }
858
+ }
859
+ }
860
+ /** 待处理交互 kind → i18n key。kind 由各域自行声明:approval 来自
861
+ * dsh-client-ui-approval;question 与 plan-review 来自 dsh-client-ui-user-questions。 */
862
+ const PENDING_KIND_KEYS = {
863
+ approval: "pendingKindApproval",
864
+ question: "pendingKindQuestion",
865
+ "plan-review": "pendingKindPlanReview"
866
+ };
867
+ /**
868
+ * 待处理交互类型 → 通知正文文案(如「待审批」)。
869
+ * 未知类型返回 undefined,调用方回退到通用标题 —— 官方新增交互域时不会显示错误的类型名。
870
+ * @param interactionKind - pendingInteraction 的 kind 字段。
871
+ * @returns 类型文案;未知类型为 undefined。
872
+ */
873
+ function pendingTypeLabel(interactionKind) {
874
+ const key = PENDING_KIND_KEYS[interactionKind];
875
+ return key === void 0 ? void 0 : ctx.locale.bind(SETTINGS_NAMESPACE)(key);
876
+ }
877
+ /** 状态跃迁检测:官方 completed 由 false 变 true、以及 pending store 新增会话。 */
878
+ function detectTransitions(state) {
879
+ for (const row of Object.values(state.byId)) {
880
+ if (row.origin === "subagent") continue;
881
+ const before = prevCompleted.get(row.id);
882
+ const now = row.completed === true;
883
+ if (before === false && now) queueNotification("done", row.id, row.displayTitle ?? row.title ?? row.id);
884
+ if (!now) notified.delete(row.id + ":done");
885
+ prevCompleted.set(row.id, now);
886
+ }
887
+ for (const id of [...prevCompleted.keys()]) if (!(id in state.byId)) {
888
+ prevCompleted.delete(id);
889
+ notified.delete(id + ":done");
890
+ }
891
+ const pendingMap = pendingStore?.getSnapshot();
892
+ const current = new Set(pendingMap === void 0 ? [] : [...pendingMap.keys()]);
893
+ if (pendingSeen) for (const id of current) {
894
+ if (prevPending.has(id)) continue;
895
+ const row = state.byId[id];
896
+ if (row !== void 0 && row.origin === "subagent") continue;
897
+ const label = row?.displayTitle ?? row?.title ?? id;
898
+ queueNotification("pending", id, label, pendingTypeLabel(pendingMap?.get(id)?.kind));
899
+ }
900
+ for (const id of prevPending) if (!current.has(id)) notified.delete(id + ":pending");
901
+ prevPending = current;
902
+ pendingSeen = true;
903
+ }
904
+ const iconLink = () => document.head.querySelector("link[rel~=\"icon\"]");
905
+ /**
906
+ * 导航图标:官方 settings.section 的图标由 shell 按 section id 硬编码(未知 id
907
+ * 回退默认齿轮),注册选项里没有 icon 字段;这里把「通知中心」导航项的 path
908
+ * 数据换成官方 IconQueueOutline14 的矢量数据(语义一致:一叠待处理条目)。
909
+ * 数据取自内核 UI 原语 @deepseek-ai/dsh-client-ui-primitives(viewBox 0 0 14 14,
910
+ * 单 path,fill=currentColor)。只改属性、不增删 React 管理的节点。
911
+ */
912
+ 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";
913
+ let navIconScheduled = false;
914
+ /** 把「通知中心」导航项的图标替换为官方 IconQueueOutline14(幂等)。 */
915
+ function applyNavIcon() {
916
+ const nav = document.querySelector("[role=\"dialog\"] nav");
917
+ if (nav === null) return;
918
+ const label = ctx.locale.bind(SETTINGS_NAMESPACE)("nav");
919
+ for (const cell of nav.querySelectorAll("button")) {
920
+ if (cell.textContent === null || !cell.textContent.includes(label)) continue;
921
+ const svg = cell.querySelector("svg");
922
+ if (svg === null) continue;
923
+ const paths = svg.querySelectorAll("path");
924
+ if (paths.length === 0) continue;
925
+ if (paths[0].getAttribute("d") === NAV_ICON_PATH) continue;
926
+ svg.setAttribute("viewBox", "0 0 14 14");
927
+ svg.setAttribute("width", "16");
928
+ svg.setAttribute("height", "16");
929
+ paths.forEach((path, index) => {
930
+ if (index === 0) {
931
+ path.setAttribute("d", NAV_ICON_PATH);
932
+ path.setAttribute("fill", "currentColor");
933
+ path.removeAttribute("fill-rule");
934
+ } else path.setAttribute("d", "");
935
+ });
936
+ }
937
+ }
938
+ /** rAF 合并:DOM 变动频繁时每个渲染帧最多检查一次。 */
939
+ function scheduleNavIcon() {
940
+ if (navIconScheduled) return;
941
+ navIconScheduled = true;
942
+ requestAnimationFrame(() => {
943
+ navIconScheduled = false;
944
+ applyNavIcon();
945
+ });
946
+ }
947
+ const navIconObserver = new MutationObserver(scheduleNavIcon);
948
+ navIconObserver.observe(document.documentElement, {
949
+ childList: true,
950
+ subtree: true,
951
+ attributes: true,
952
+ attributeFilter: ["d"]
953
+ });
954
+ scheduleNavIcon();
955
+ const setHref = (href) => {
956
+ const link = iconLink();
957
+ if (link) link.href = href;
958
+ };
959
+ /** 应用瞬间的原始 href —— 还原目标(比硬编码路径更稳,前端改路径也能正确还原)。 */
960
+ const originalHref = iconLink()?.href ?? DEFAULT_HREF;
961
+ /** 我们最后一次设置的 href;null = 官方原样。 */
962
+ let applied = null;
963
+ /** 自跟踪:每会话最后观察到的 running 位(镜像官方 prevRunning 语义)。 */
964
+ const prevRunning = /* @__PURE__ */ new Map();
965
+ /** 完成时恰好被选中、且当时标签页不在台前的主会话(官方不报的空缺)。 */
966
+ const finishedWhileHidden = /* @__PURE__ */ new Set();
967
+ const restore = () => {
968
+ if (applied !== null) {
969
+ setHref(originalHref);
970
+ applied = null;
971
+ }
972
+ };
973
+ /** 当前生效的配置色(未配置回官方默认)。 */
974
+ function colors() {
975
+ const value = scope.getSnapshot().value ?? {};
976
+ return {
977
+ green: value.green ?? "#22C55E",
978
+ amber: value.amber ?? "#F59E0B",
979
+ black: value.black
980
+ };
981
+ }
982
+ const uri = (hex) => `data:image/svg+xml,${encodeURIComponent(whaleSvg(hex))}`;
983
+ /** running true→false 边沿跟踪:完成时恰好被选中且标签页不在台前 →
984
+ * 记入 finishedWhileHidden。官方 syncCompletedNotifications 只报"未选中时
985
+ * 完成"(`sessionId !== selected` 才 arm),选中的空缺在这里补齐。
986
+ * 台前完成不记(V0-02);重新运行、会话移除均清除;只跟踪主会话。 */
987
+ function trackEdges(state) {
988
+ for (const row of Object.values(state.byId)) {
989
+ if (row.origin === "subagent") continue;
990
+ const prev = prevRunning.get(row.id);
991
+ if (prev === void 0) {
992
+ prevRunning.set(row.id, row.running);
993
+ continue;
994
+ }
995
+ if (prev && !row.running) {
996
+ if (row.id === state.current && !isForeground()) {
997
+ finishedWhileHidden.add(row.id);
998
+ queueNotification("done", row.id, row.displayTitle ?? row.title ?? row.id);
999
+ }
1000
+ } else if (row.running) finishedWhileHidden.delete(row.id);
1001
+ prevRunning.set(row.id, row.running);
1002
+ }
1003
+ for (const id of [...prevRunning.keys()]) if (!(id in state.byId)) {
1004
+ prevRunning.delete(id);
1005
+ finishedWhileHidden.delete(id);
1006
+ }
1007
+ }
1008
+ /** 回到前台(标签页可见且窗口有焦点)→ 选中会话的绿灯熄灭(V0-02)。 */
1009
+ const onForeground = () => {
1010
+ if (!isForeground()) return;
1011
+ if (finishedWhileHidden.size > 0) {
1012
+ finishedWhileHidden.clear();
1013
+ sync();
1014
+ }
1015
+ };
1016
+ document.addEventListener("visibilitychange", onForeground);
1017
+ window.addEventListener("focus", onForeground);
1018
+ /** 绿/琥珀判定:主会话 only;绿优先。返回目标 href;null = 官方原版。 */
1019
+ function targetOf(state) {
1020
+ if ((scope.getSnapshot().value ?? {}).colorsEnabled === false) return null;
1021
+ const c = colors();
1022
+ const pendingMap = pendingStore?.getSnapshot();
1023
+ let amber = false;
1024
+ for (const row of Object.values(state.byId)) {
1025
+ if (row.origin === "subagent") continue;
1026
+ if (row.completed === true || finishedWhileHidden.has(row.id)) return uri(c.green);
1027
+ if (pendingMap?.has(row.id) || row.pendingInteraction !== void 0) amber = true;
1028
+ }
1029
+ if (amber) return uri(c.amber);
1030
+ return c.black ? uri(c.black) : null;
1031
+ }
1032
+ function sync() {
1033
+ const state = list.getSnapshot();
1034
+ trackEdges(state);
1035
+ detectTransitions(state);
1036
+ const next = targetOf(state);
1037
+ if (next === null) restore();
1038
+ else if (applied !== next) {
1039
+ setHref(next);
1040
+ applied = next;
1041
+ }
1042
+ }
1043
+ const unsubscribeList = list.subscribe(sync);
1044
+ const unsubscribeScope = scope.subscribe(sync);
1045
+ sync();
1046
+ /**
1047
+ * 可选通道:uiSession 服务存在时(0.1.2-rc.1 起)订阅其待处理交互 store。
1048
+ * ctx.inject 声明的是必需服务,服务缺席时该子 fiber 不加载 —— 这正是需要
1049
+ * 的效果:旧版本保持旧行字段语义,新版本拿到官方 pending 信号。
1050
+ */
1051
+ ctx.inject(["uiSession"], (uiCtx) => {
1052
+ pendingStore = uiCtx.uiSession.pendingInteractions;
1053
+ const unsubscribe = pendingStore.subscribe(sync);
1054
+ sync();
1055
+ return () => {
1056
+ unsubscribe();
1057
+ pendingStore = void 0;
1058
+ sync();
1059
+ };
1060
+ });
1061
+ ctx.effect(() => ctx.locale.register(SETTINGS_NAMESPACE, {
1062
+ zh: {
1063
+ nav: "通知中心",
1064
+ greenLabel: "完成",
1065
+ amberLabel: "待处理",
1066
+ blackLabel: "默认色",
1067
+ blackHint: "不设置时使用官方图标",
1068
+ reset: "恢复默认颜色",
1069
+ invalidHex: "颜色格式应为 #RRGGBB",
1070
+ groupColors: "鲸鱼状态灯",
1071
+ groupNotify: "系统通知",
1072
+ colorsHint: "标签页图标根据任务状态显示不同颜色",
1073
+ colorsExpand: "颜色设置",
1074
+ notifyExpand: "更多通知设置",
1075
+ restore: "恢复默认颜色",
1076
+ notifyEnabledHint: "会话完成及有交互等待处理时发送通知",
1077
+ notifySound: "提示音",
1078
+ notifyAutoHide: "自动隐藏",
1079
+ notifyAutoHideHint: "关闭后通知常驻,需手动关闭",
1080
+ notifyVolume: "音量",
1081
+ notifyDoneSound: "完成提示音",
1082
+ notifyPendingSound: "待处理提示音",
1083
+ soundPick: "选择音效",
1084
+ soundPackBuiltin: "内置",
1085
+ soundBuiltinUp: "Chime Up",
1086
+ soundBuiltinDown: "Chime Down",
1087
+ permissionDenied: "已被浏览器拒绝,请在站点设置中恢复通知权限",
1088
+ permissionUnsupported: "当前浏览器不支持系统通知",
1089
+ notifyDoneTitle: "会话已完成",
1090
+ notifyPendingTitle: "有交互等待处理",
1091
+ pendingKindApproval: "待审批",
1092
+ pendingKindQuestion: "向你提问",
1093
+ pendingKindPlanReview: "计划待审核",
1094
+ },
1095
+ en: {
1096
+ nav: "Notification center",
1097
+ greenLabel: "Done",
1098
+ amberLabel: "Pending",
1099
+ blackLabel: "Default color",
1100
+ blackHint: "Uses the official icon when unset",
1101
+ reset: "Reset colors",
1102
+ invalidHex: "Color must be #RRGGBB",
1103
+ groupColors: "Tab whale light",
1104
+ groupNotify: "System notifications",
1105
+ colorsHint: "The tab icon changes color with the task state",
1106
+ colorsExpand: "Colors",
1107
+ notifyExpand: "More notification options",
1108
+ restore: "Reset to default",
1109
+ notifyEnabledHint: "Sent when a session finishes or something awaits you",
1110
+ notifySound: "Sound",
1111
+ notifyAutoHide: "Auto hide",
1112
+ notifyAutoHideHint: "Off keeps it on screen until you close it",
1113
+ notifyVolume: "Volume",
1114
+ notifyDoneSound: "Done sound",
1115
+ notifyPendingSound: "Pending sound",
1116
+ soundPick: "Choose a sound",
1117
+ soundPackBuiltin: "Built-in",
1118
+ soundBuiltinUp: "Chime Up",
1119
+ soundBuiltinDown: "Chime Down",
1120
+ permissionDenied: "Blocked by the browser - re-enable notifications in the site settings",
1121
+ permissionUnsupported: "This browser does not support notifications",
1122
+ notifyDoneTitle: "Session finished",
1123
+ notifyPendingTitle: "Something awaits you",
1124
+ pendingKindApproval: "Approval needed",
1125
+ pendingKindQuestion: "Question",
1126
+ pendingKindPlanReview: "Plan review",
1127
+ }
1128
+ }));
1129
+ ctx.slots.inject("settings.section", () => {
1130
+ const t = ctx.locale.bind(SETTINGS_NAMESPACE);
1131
+ const injected = { scope };
1132
+ return ctx.slots.register({
1133
+ name: "settings.section",
1134
+ id: SETTINGS_NAMESPACE,
1135
+ order: 100,
1136
+ label: () => t("nav"),
1137
+ locale: SETTINGS_NAMESPACE,
1138
+ inject: () => injected
1139
+ }, WhaleSettingsSection);
1140
+ });
1141
+ ctx.effect(() => () => {
1142
+ if (notifyTimer !== void 0) clearTimeout(notifyTimer);
1143
+ notifyQueue.clear();
1144
+ navIconObserver.disconnect();
1145
+ unsubscribeList();
1146
+ unsubscribeScope();
1147
+ document.removeEventListener("visibilitychange", onForeground);
1148
+ window.removeEventListener("focus", onForeground);
1149
+ restore();
1150
+ });
1151
+ }
1152
+ //#endregion
1153
+ exports.apply = apply;
1154
+ exports.inject = inject;
1155
+ return module.exports;
1156
+ }
1157
+ });