dsh-notify-me 1.1.2 → 1.1.4

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/lib/client.js CHANGED
@@ -1,815 +1,815 @@
1
- // dsh-notify-me — browser half (client plugin bundle).
2
- //
3
- // Loaded by dsh-client-modules at /plugins/dsh-notify-me/client.js and
4
- // executed through the vendored cordis Loader's lazy-CJS module table
5
- // (window.__ModuleLoader__.load). The factory body is plain CJS with
6
- // require() resolved against the shell's module table — the same shape the
7
- // shipped ui-* packages' tsdown bundles emit. react is part of the platform
8
- // base, so require("react") resolves to the shell-seeded instance (same as
9
- // every shipped ui-* bundle); the settings page is skipped when react or the
10
- // slots/locale services are absent, keeping the reminder core self-contained.
11
- //
12
- // What this plugin does (all client-side, no settings transport needed):
13
- // - watches the "sessions" service (SessionRuntime): the selected
14
- // session's ConversationSnapshot (running / pending interactions) and the
15
- // session-list summaries of every other session;
16
- // - fires a Windows desktop Notification (+ optional sound) when the agent
17
- // needs the user's input — pending interaction kinds 'approval',
18
- // 'plan-review' or 'question' — and when a reply/turn finishes;
19
- // - registers a "通知提醒 / Notify me" page into DSH Settings
20
- // (settings.section slot) exposing an on/off master switch, per-channel
21
- // toggles, volume and the notification language (follow-interface /
22
- // Simplified Chinese / English);
23
- // - preferences persist in localStorage (origin-scoped, like other
24
- // third-party client plugins) and are tweakable on window.__dshNotifyMe.
25
-
26
- window.__ModuleLoader__.load({
27
- id: "dsh-notify-me",
28
- factory: (require) => {
29
- var module = { exports: {} };
30
- var exports = module.exports;
31
- Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
32
-
33
- // react is externalized into the loader module table; never required by
34
- // the reminder core, only by the optional Settings page.
35
- var React = null;
36
- try { React = require("react") || null; } catch (e) { React = null; }
37
-
38
- // ───────────────────────── configuration ─────────────────────────
39
- var LS_CONFIG = "dshNotifyMe.config";
40
- var DEFAULTS = {
41
- enabled: true, // master switch: off = no toast/sound/title marker at all
42
- // language of the notification copy: 'auto' follows the DSH UI locale,
43
- // 'zh' and 'en' pin Simplified Chinese / English.
44
- language: "auto",
45
- // toast + sound are only useful while the user is elsewhere; the tab
46
- // being hidden is the reliable "away" signal.
47
- attentionHiddenOnly: false, // needs-input alerts even while the page is visible
48
- doneHiddenOnly: true, // reply-finished alerts only when the page is hidden
49
- toast: true,
50
- sound: true,
51
- volume: 0.5,
52
- autoFocus: true // clicking the toast focuses the DSH window
53
- };
54
- function readConfig() {
55
- var cfg = {};
56
- try {
57
- var raw = localStorage.getItem(LS_CONFIG);
58
- if (raw) cfg = JSON.parse(raw);
59
- } catch (e) { /* corrupt storage -> defaults */ }
60
- for (var k in DEFAULTS) if (!(k in cfg)) cfg[k] = DEFAULTS[k];
61
- return cfg;
62
- }
63
- function writeConfig(patch) {
64
- var cfg = readConfig();
65
- for (var k in patch) cfg[k] = patch[k];
66
- try { localStorage.setItem(LS_CONFIG, JSON.stringify(cfg)); } catch (e) {}
67
- return cfg;
68
- }
69
- var config = readConfig();
70
- // Persist a patch, swap the live module config and re-apply any active
71
- // title marker (its prefix follows the notification language).
72
- function setConfigLocal(patch) {
73
- config = writeConfig(patch);
74
- applyTitle();
75
- return config;
76
- }
77
-
78
- // ───────────────────────── copy tables ─────────────────────────
79
- // Notification copy, keyed by language id. Selected through
80
- // effectiveLang() so a pinned language stays pinned even while the DSH UI
81
- // speaks the other one.
82
- var TXT = {
83
- zh: {
84
- kind: { approval: "审批请求", "plan-review": "方案待确认", question: "提问" },
85
- kindDefault: "需要操作",
86
- bodyKind: { approval: "有审批待处理", "plan-review": "方案待确认", question: "有提问待回答" },
87
- bodyDefault: "需要你操作",
88
- current: "当前对话",
89
- doneTitle: "DSH · 回复完成",
90
- doneBody: "回复已完成",
91
- background: "后台会话",
92
- marker: "🔔 需要你 · ",
93
- sep: ":",
94
- testAttention: "这是一条测试提醒:DSH 需要你的操作",
95
- testDone: "测试提醒 · 回复完成示例"
96
- },
97
- en: {
98
- kind: { approval: "Approval request", "plan-review": "Plan review", question: "Question" },
99
- kindDefault: "Action needed",
100
- bodyKind: {
101
- approval: "An approval is waiting for you",
102
- "plan-review": "A plan awaits your review",
103
- question: "A question is waiting for you"
104
- },
105
- bodyDefault: "Your input is needed",
106
- current: "Current conversation",
107
- doneTitle: "DSH · Reply finished",
108
- doneBody: "Reply finished",
109
- background: "Background session",
110
- marker: "🔔 Action needed · ",
111
- sep: ": ",
112
- testAttention: "This is a test alert: DSH is waiting for your input",
113
- testDone: "Test alert · reply-finished sample"
114
- }
115
- };
116
- // Locale service handle (registered on the client context by dsh-client-locale).
117
- var localeSvc = null;
118
- // Current DSH UI language: 'zh' | 'en' | null (unknown). Used by the
119
- // 'auto' notification-language mode.
120
- function currentUILang() {
121
- try {
122
- if (localeSvc && typeof localeSvc.getLocale === "function") {
123
- var a = localeSvc.getLocale().active;
124
- if (typeof a === "string" && a) {
125
- if (a.indexOf("zh") === 0) return "zh";
126
- if (a.indexOf("en") === 0) return "en";
127
- }
128
- }
129
- } catch (e) {}
130
- try {
131
- var dl = (document.documentElement && document.documentElement.lang) || "";
132
- if (dl.indexOf("zh") === 0) return "zh";
133
- if (dl.indexOf("en") === 0) return "en";
134
- } catch (e) {}
135
- return null;
136
- }
137
- function effectiveLang() {
138
- if (!config.language || config.language === "auto") return currentUILang() || "zh";
139
- return config.language === "en" ? "en" : "zh";
140
- }
141
- function texts() { return TXT[effectiveLang()] || TXT.zh; }
142
-
143
- // ─────────────────────── notification plumbing ───────────────────
144
- var permAsked = false;
145
- function requestPermission() {
146
- if (!("Notification" in window) || permAsked) return;
147
- permAsked = true;
148
- try {
149
- if (Notification.permission === "default") {
150
- Notification.requestPermission().catch(function () {});
151
- }
152
- } catch (e) {}
153
- }
154
- function canToast() {
155
- return config.toast && "Notification" in window && Notification.permission === "granted";
156
- }
157
- function showToast(kind, title, body) {
158
- if (!canToast()) return;
159
- try {
160
- var n = new Notification(title, {
161
- body: body || "",
162
- tag: "dsh-notify-me-" + kind,
163
- renotify: true,
164
- icon: (location.origin || "") + "/favicon.svg"
165
- });
166
- if (config.autoFocus) {
167
- n.onclick = function () {
168
- try { window.focus(); } catch (e) {}
169
- n.close();
170
- };
171
- }
172
- setTimeout(function () { n.close(); }, 15000);
173
- } catch (e) {}
174
- }
175
- // Sound: WebAudio beeps (no asset needed). The context only runs after a
176
- // user gesture; we unlock it on the first pointer/key event.
177
- var audioCtx = null;
178
- function unlockAudio() {
179
- try {
180
- if (!audioCtx) {
181
- var AC = window.AudioContext || window.webkitAudioContext;
182
- if (!AC) return;
183
- audioCtx = new AC();
184
- }
185
- if (audioCtx.state === "suspended") audioCtx.resume().catch(function () {});
186
- } catch (e) {}
187
- }
188
- function tone(freq, startIn, durMs, vol) {
189
- if (!audioCtx || audioCtx.state !== "running") return;
190
- try {
191
- var t0 = audioCtx.currentTime + startIn;
192
- var osc = audioCtx.createOscillator();
193
- var gain = audioCtx.createGain();
194
- osc.type = "sine";
195
- osc.frequency.value = freq;
196
- gain.gain.setValueAtTime(0.0001, t0);
197
- gain.gain.exponentialRampToValueAtTime(vol, t0 + 0.02);
198
- gain.gain.exponentialRampToValueAtTime(0.0001, t0 + durMs / 1000);
199
- osc.connect(gain).connect(audioCtx.destination);
200
- osc.start(t0);
201
- osc.stop(t0 + durMs / 1000 + 0.05);
202
- } catch (e) {}
203
- }
204
- function playAttentionSound() {
205
- if (!config.sound) return;
206
- unlockAudio();
207
- tone(880, 0.0, 140, config.volume);
208
- tone(1174, 0.16, 140, config.volume);
209
- tone(1568, 0.32, 200, config.volume);
210
- }
211
- function playDoneSound() {
212
- if (!config.sound) return;
213
- unlockAudio();
214
- tone(659, 0.0, 150, config.volume);
215
- tone(988, 0.18, 260, config.volume);
216
- }
217
- function unlockOnce() {
218
- unlockAudio();
219
- requestPermission();
220
- }
221
- function onFirstGesture() {
222
- unlockOnce();
223
- window.removeEventListener("pointerdown", onFirstGesture, true);
224
- window.removeEventListener("keydown", onFirstGesture, true);
225
- }
226
- window.addEventListener("pointerdown", onFirstGesture, true);
227
- window.addEventListener("keydown", onFirstGesture, true);
228
-
229
- // ────────────────────────── title marker ─────────────────────────
230
- var originalTitle = null;
231
- var attentionActive = 0;
232
- function applyTitle() {
233
- try {
234
- if (originalTitle === null) originalTitle = document.title;
235
- document.title = (config.enabled && attentionActive > 0)
236
- ? texts().marker + originalTitle
237
- : originalTitle;
238
- } catch (e) {}
239
- }
240
- function acquireTitleMark() {
241
- attentionActive += 1;
242
- applyTitle();
243
- }
244
- function releaseTitleMark() {
245
- if (attentionActive > 0) attentionActive -= 1;
246
- applyTitle();
247
- }
248
-
249
- // ─────────────────────── shared alert core ───────────────────────
250
- var attentionKeysFired = Object.create(null); // dedupe per pending key
251
- var lastAttentionAt = 0;
252
-
253
- function hidden() { return document.hidden || document.visibilityState === "hidden"; }
254
-
255
- // Deliver one alert regardless of visibility rules (used by the test
256
- // buttons); respects the master switch and permission state.
257
- function deliver(kind, title, body) {
258
- if (!config.enabled) return;
259
- showToast(kind, title, body);
260
- if (kind === "attention") {
261
- playAttentionSound();
262
- acquireTitleMark();
263
- } else {
264
- playDoneSound();
265
- }
266
- try {
267
- if (window.__dshNotifyMe && window.__dshNotifyMe.onEvent) {
268
- window.__dshNotifyMe.onEvent(kind, { title: title, body: body });
269
- }
270
- } catch (e) {}
271
- }
272
- function fire(kind, title, body) {
273
- if (!config.enabled) return;
274
- if (kind === "attention" && config.attentionHiddenOnly && !hidden()) return;
275
- if (kind === "done" && config.doneHiddenOnly && !hidden()) return;
276
- deliver(kind, title, body);
277
- }
278
- // A display label for a session row ("current" maps to the localized
279
- // "当前对话 / Current conversation" placeholder).
280
- function sessionLabelText(row) {
281
- var raw = row && row.label;
282
- if (!raw || raw === "current") return texts().current;
283
- return raw;
284
- }
285
- function fireAttention(kind, sessionLabel, detail) {
286
- var d = texts();
287
- var label = d.kind[kind] || d.kindDefault;
288
- var content = detail || d.bodyKind[kind] || d.bodyDefault;
289
- var who = sessionLabel || "";
290
- fire("attention", "DSH · " + label, (who ? who + d.sep : "") + content);
291
- }
292
- function fireDone(sessionLabel, snippet) {
293
- var d = texts();
294
- var body = sessionLabel || d.doneBody;
295
- if (snippet) body = body + "\n" + snippet;
296
- fire("done", d.doneTitle, body);
297
- }
298
- // Test alerts deliberately ignore the visibility rules (attentionHiddenOnly
299
- // / doneHiddenOnly): the whole point of a test button is to prove the
300
- // channel works while you are looking at the page. Only the master switch
301
- // (and browser notification permission) can stop them. The attention test
302
- // also drops its title marker again shortly after, so a test never leaves a
303
- // standing "waiting on you" mark in the tab.
304
- var TEST_MARK_RELEASE_MS = 6000;
305
- function testAlert(kind) {
306
- var d = texts();
307
- if (kind === "attention") {
308
- deliver("attention", "DSH · " + d.kind.question, d.testAttention);
309
- setTimeout(function () { releaseTitleMark(); }, TEST_MARK_RELEASE_MS);
310
- } else {
311
- deliver("done", d.doneTitle, d.testDone);
312
- }
313
- }
314
-
315
- // ───────────────────────── state machine ─────────────────────────
316
- // prevById: per-session-id baseline of what we have already reported.
317
- // { running: bool, pendingKeys: string[] , doneArmed: bool, seeded: bool }
318
- var prevById = Object.create(null);
319
- var faceSub = null; // current session face unsubscribe
320
- var currentFaceId = null;
321
- var listStore = null;
322
- var rootCtx = null; // client cordis context, captured at apply()
323
-
324
- function textFromPayload(payload) {
325
- if (!payload || typeof payload !== "object") return "";
326
- var keys = ["text", "question", "prompt", "title", "summary", "description", "message", "operation", "name", "tool"];
327
- for (var i = 0; i < keys.length; i++) {
328
- var v = payload[keys[i]];
329
- if (typeof v === "string" && v.trim()) return v.length > 160 ? v.slice(0, 160) + "…" : v;
330
- if (typeof v === "object" && v !== null && typeof v.text === "string" && v.text.trim()) {
331
- var s = v.text;
332
- return s.length > 160 ? s.slice(0, 160) + "…" : s;
333
- }
334
- }
335
- return "";
336
- }
337
- function snippetOfLastAssistant(snap) {
338
- try {
339
- var nodes = snap.nodes || [];
340
- for (var i = nodes.length - 1; i >= 0; i--) {
341
- var nd = nodes[i];
342
- if (nd && nd.kind === "assistant" && nd.blocks) {
343
- for (var b = nd.blocks.length - 1; b >= 0; b--) {
344
- if (nd.blocks[b] && nd.blocks[b].kind === "text" && nd.blocks[b].text) {
345
- var t = nd.blocks[b].text.replace(/\s+/g, " ").trim();
346
- if (t) return t.length > 80 ? t.slice(0, 80) + "…" : t;
347
- }
348
- }
349
- }
350
- }
351
- } catch (e) {}
352
- return "";
353
- }
354
- // Evaluate one row/face observation. row: {id, running, pending:[{key,kind,payload}], completed, label, face:bool}
355
- function evaluateRow(row) {
356
- var prev = prevById[row.id];
357
- var runningNow = !!row.running;
358
- var pendingNow = row.pending || [];
359
- var prevPendingKeys = prev ? prev.pendingKeys : [];
360
- var wasRunning = prev ? prev.running : false;
361
- var newKeys = [];
362
- for (var i = 0; i < pendingNow.length; i++) {
363
- var pk = pendingNow[i].key;
364
- if (prevPendingKeys.indexOf(pk) === -1 && !attentionKeysFired[pk]) {
365
- newKeys.push(pendingNow[i]);
366
- attentionKeysFired[pk] = true;
367
- }
368
- }
369
- var hasPendingNow = pendingNow.length > 0;
370
-
371
- // 1) a pending interaction just appeared -> the agent is waiting on the user.
372
- // First evaluation of a session also lands here (prevPendingKeys empty),
373
- // so a wait that already existed when the plugin (or page) mounted still
374
- // alerts once.
375
- if (newKeys.length > 0) {
376
- var it = newKeys[0];
377
- var detail = textFromPayload(it.payload);
378
- if (!detail) detail = "";
379
- fireAttention(it.kind, sessionLabelText(row), detail);
380
- lastAttentionAt = Date.now();
381
- }
382
-
383
- // 2) a non-selected session finished while we were away (sidebar "done")
384
- var notified = false;
385
- if (!row.face && row.completed && prev && !prev.completed && prev.seeded) {
386
- fireDone(sessionLabelText(row), "");
387
- notified = true;
388
- }
389
-
390
- // 3) running true -> false on a session we were watching: a reply finished
391
- if (!notified && prev && prev.seeded && wasRunning && !runningNow && !hasPendingNow) {
392
- // ignore the edge if it happened in the same tick as an attention alert
393
- if (Date.now() - lastAttentionAt < 300) {
394
- /* answered by an attention alert above */
395
- } else {
396
- fireDone(sessionLabelText(row), row.face ? snippetOfLastAssistant(row.faceSnap) : "");
397
- }
398
- }
399
-
400
- // store the next baseline
401
- prevById[row.id] = {
402
- running: runningNow,
403
- pendingKeys: pendingNow.map(function (p) { return p.key; }),
404
- completed: !!row.completed,
405
- seeded: true
406
- };
407
-
408
- // a wait we alerted about has been resolved -> drop the title marker
409
- if (prev && prevPendingKeys.length > 0 && pendingNow.length === 0) {
410
- releaseTitleMark();
411
- }
412
- }
413
-
414
- // face -> row adapter (selected session, rich data)
415
- function evalSessionFace(sessionId, snap) {
416
- if (sessionId !== currentFaceId) return;
417
- var pend = (snap.pending || []).map(function (p) {
418
- return { key: p.key || (p.kind + ":" + sessionId), kind: p.kind, payload: p.payload || null };
419
- });
420
- evaluateRow({
421
- id: sessionId,
422
- running: !!snap.running,
423
- pending: pend,
424
- completed: false,
425
- face: true,
426
- faceSnap: snap,
427
- label: "current"
428
- });
429
- }
430
- // list row -> adapter (every other listed session)
431
- function evalListRow(sid, sum, currentId) {
432
- evaluateRow({
433
- id: sid,
434
- running: !!sum.running,
435
- pending: sum.pendingInteraction ? [{ key: "list:" + sid + ":" + sum.pendingInteraction, kind: sum.pendingInteraction, payload: null }] : [],
436
- completed: !!sum.completed,
437
- face: false,
438
- label: sum.displayTitle || sum.title || sid
439
- });
440
- }
441
-
442
- // ─────────────────────── subscription wiring ─────────────────────
443
- function bindCurrentFace() {
444
- if (!listStore) return;
445
- try {
446
- var st = listStore.getSnapshot();
447
- var cur = st && st.current;
448
- if (!cur) { detachFace(); return; }
449
- if (cur === currentFaceId && faceSub) return;
450
- detachFace();
451
- currentFaceId = cur;
452
- var b = rootCtx.sessions.binding(cur);
453
- if (!b || !b.session || typeof b.session.subscribe !== "function") {
454
- // binding not minted yet — the retry timer will re-check
455
- return;
456
- }
457
- faceSub = b.session.subscribe(function () {
458
- try {
459
- evalSessionFace(cur, b.session.getSnapshot());
460
- } catch (e) {}
461
- });
462
- // First evaluation seeds the baseline (no false completion edges) while
463
- // still alerting for interactions that were already pending on bind.
464
- try {
465
- evalSessionFace(cur, b.session.getSnapshot());
466
- } catch (e) {}
467
- } catch (e) {}
468
- }
469
- function detachFace() {
470
- if (faceSub) { try { faceSub(); } catch (e) {} faceSub = null; }
471
- currentFaceId = null;
472
- }
473
- function onListChanged() {
474
- if (!listStore) return;
475
- try {
476
- var st = listStore.getSnapshot();
477
- var cur = st && st.current;
478
- bindCurrentFace();
479
- var byId = (st && st.byId) || {};
480
- for (var sid in byId) {
481
- if (sid === cur) continue; // current handled by the face watcher
482
- var sum = byId[sid];
483
- if (!sum) continue;
484
- evalListRow(sid, sum, cur);
485
- }
486
- // sessions that vanished: keep baseline (they may reappear) - no action.
487
- } catch (e) {}
488
- }
489
-
490
- // ──────────────────────── global control API ─────────────────────
491
- var api = {
492
- // keep in sync with package.json "version" on every release
493
- version: "1.1.2",
494
- get config() { return readConfig(); },
495
- setConfig: function (patch) { return setConfigLocal(patch); },
496
- resetConfig: function () {
497
- try { localStorage.removeItem(LS_CONFIG); } catch (e) {}
498
- config = readConfig();
499
- return config;
500
- },
501
- // manual test; run it after clicking anywhere so permission can be granted.
502
- // Test alerts fire regardless of the page-open visibility rules.
503
- test: function (kind) {
504
- kind = kind === "attention" ? "attention" : "done";
505
- unlockOnce();
506
- testAlert(kind);
507
- return "sent " + kind + " (lang=" + effectiveLang() + ", permission=" + ("Notification" in window ? Notification.permission : "n/a") + ")";
508
- }
509
- };
510
- window.__dshNotifyMe = api;
511
-
512
- // ────────────────── Settings page (react only, optional) ─────────
513
- // Registered when the web profile provides react + slots + locale; the
514
- // reminder core above never depends on any of it.
515
- var NS = "dsh-notify-me";
516
- var UI_zh = {
517
- nav: "通知提醒",
518
- blurb: "当模型停下来需要你操作(审批 / 方案待确认 / 提问),或回复在后台完成时,用系统通知、提示音和标签页标题提醒你。",
519
- enable: "启用提醒",
520
- enableHint: "总开关:关闭后不再弹系统通知、不播放提示音,也不再改动标签页标题。",
521
- toast: "系统通知",
522
- toastHint: "经浏览器弹出系统 Toast(Windows 通知中心);需要已授予通知权限。",
523
- sound: "提示音",
524
- soundHint: "WebAudio 合成音,「需要你」与「回复完成」使用不同音型。",
525
- attentionVisible: "页面打开时也提醒「需要你」",
526
- attentionVisibleHint: "默认开启:模型在等你审批 / 确认方案 / 回答问题(含提问弹窗)时,即使页面在最前也会提醒。",
527
- doneVisible: "页面打开时也提醒「回复完成」",
528
- doneVisibleHint: "默认关闭:「完成」只在页面隐藏或切到后台时才提醒,避免打扰。",
529
- autoFocus: "点击通知把 DSH 切回前台",
530
- volume: "音量",
531
- language: "通知语言",
532
- langAuto: "跟随界面",
533
- langZh: "简体中文",
534
- langEn: "English",
535
- langAutoNote: "(跟随界面)",
536
- current: "界面当前语言",
537
- testTitle: "快速测试",
538
- testHint: "用当前设置各发一条测试提醒(不受上面「页面打开时也提醒」开关限制)。",
539
- testAttention: "测试「需要你」",
540
- testDone: "测试「回复完成」",
541
- reset: "恢复默认设置",
542
- resetDone: "已恢复默认设置。",
543
- sent: "已发送测试提醒 · 通知语言:",
544
- permHint: "通知权限尚未授予:先在页面上点击一次并允许通知,系统通知才能弹出(提示音不受影响)。",
545
- offHint: "已关闭提醒:先打开「启用提醒」再测试。"
546
- };
547
- var UI_en = {
548
- nav: "Notify me",
549
- blurb: "When the agent stops and needs your input (approval / plan review / question) or a reply finishes in the background, get a system notification, a sound, and a tab-title marker.",
550
- enable: "Enable reminders",
551
- enableHint: "Master switch: when off, no toasts, no sounds, and the tab title is never modified.",
552
- toast: "System notifications",
553
- toastHint: "Native toasts via the browser (Windows notification center); requires notification permission.",
554
- sound: "Sound",
555
- soundHint: "WebAudio beeps — \"needs you\" and \"reply finished\" use different patterns.",
556
- attentionVisible: "Also alert \"needs you\" while the page is open",
557
- attentionVisibleHint: "On by default: alert even in the foreground when the model waits for approval / plan review / a question.",
558
- doneVisible: "Also alert \"reply finished\" while the page is open",
559
- doneVisibleHint: "Off by default: \"done\" only alerts while the page is hidden or backgrounded, to avoid noise.",
560
- autoFocus: "Click a toast to focus the DSH window",
561
- volume: "Volume",
562
- language: "Notification language",
563
- langAuto: "Follow the interface",
564
- langZh: "简体中文",
565
- langEn: "English",
566
- langAutoNote: " (follows the interface)",
567
- current: "Current interface language",
568
- testTitle: "Quick test",
569
- testHint: "Send one test alert with the current settings (not limited by the \"while the page is open\" toggles above).",
570
- testAttention: "Test \"needs you\"",
571
- testDone: "Test \"reply finished\"",
572
- reset: "Restore defaults",
573
- resetDone: "Defaults restored.",
574
- sent: "Test alert sent · notification language: ",
575
- permHint: "Notification permission is not granted yet — click once on the page and allow notifications so toasts can appear (sound is unaffected).",
576
- offHint: "Reminders are off: enable them first, then test."
577
- };
578
- // Small element helper to keep the hand-written page readable.
579
- function h(tag, props) {
580
- var args = [tag, props || null];
581
- for (var i = 2; i < arguments.length; i++) args.push(arguments[i]);
582
- return React.createElement.apply(React, args);
583
- }
584
- // css classes (namespaced to avoid collisions)
585
- var UI_CSS = "" +
586
- ".dnm-wrap{width:100%;max-width:720px;display:flex;flex-direction:column;gap:14px;color:var(--dsw-alias-label-primary,rgba(20,20,20,.9))}" +
587
- ".dnm-blurb{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
588
- ".dnm-card{border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35));background:var(--dsw-alias-bg-layer-3,rgba(128,128,128,.05));border-radius:10px;overflow:hidden}" +
589
- ".dnm-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 14px;min-height:46px;box-sizing:border-box}" +
590
- ".dnm-row+.dnm-row{border-top:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35))}" +
591
- ".dnm-rowText{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}" +
592
- ".dnm-rowLabel{font-size:13px;font-weight:600;line-height:20px;color:var(--dsw-alias-label-primary,rgba(20,20,20,.9))}" +
593
- ".dnm-rowHint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
594
- ".dnm-switch{flex:none;position:relative;width:34px;height:20px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.45));background:var(--dsw-alias-bg-layer-1,rgba(128,128,128,.2));cursor:pointer;padding:0;transition:background .15s ease}" +
595
- ".dnm-switch.on{background:var(--dsw-alias-state-business-primary,#3b82f6);border-color:transparent}" +
596
- ".dnm-knob{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;transition:left .15s ease}" +
597
- ".dnm-switch.on .dnm-knob{left:16px}" +
598
- ".dnm-range{width:150px;accent-color:var(--dsw-alias-state-business-primary,#3b82f6)}" +
599
- ".dnm-select{border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.03));color:var(--dsw-alias-label-primary,rgba(20,20,20,.9));border-radius:8px;padding:5px 8px;font-size:13px;font-family:inherit}" +
600
- ".dnm-btn{border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:transparent;color:var(--dsw-alias-label-primary,rgba(20,20,20,.9));border-radius:8px;padding:5px 12px;font-size:13px;cursor:pointer;font-family:inherit}" +
601
- ".dnm-btn:hover{background:var(--dsw-alias-bg-layer-1,rgba(128,128,128,.1))}" +
602
- ".dnm-btn[disabled]{opacity:.45;cursor:default}" +
603
- ".dnm-btnRow{display:flex;align-items:center;gap:10px;flex-wrap:wrap}" +
604
- ".dnm-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
605
- ".dnm-feedback{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9));min-height:18px}";
606
- (function injectStyle() {
607
- try {
608
- if (typeof document === "undefined" || typeof document.createElement !== "function") return;
609
- var tagId = "dsh-notify-me/settings";
610
- if (document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]")) return;
611
- var s = document.createElement("style");
612
- s.dataset.plugin = "dsh-notify-me";
613
- s.dataset.pluginCss = tagId;
614
- s.textContent = UI_CSS;
615
- (document.head || document.documentElement).appendChild(s);
616
- } catch (e) {}
617
- })();
618
-
619
- function rowSwitch(key, labelText, hintText, checked, onChange) {
620
- return h("div", { className: "dnm-row" }, [
621
- h("div", { className: "dnm-rowText" }, [
622
- h("div", { className: "dnm-rowLabel" }, labelText),
623
- hintText ? h("div", { className: "dnm-rowHint" }, hintText) : null
624
- ]),
625
- h("button", {
626
- type: "button",
627
- role: "switch",
628
- "aria-checked": checked ? "true" : "false",
629
- className: "dnm-switch" + (checked ? " on" : ""),
630
- onClick: function () { onChange(!checked); }
631
- }, h("span", { className: "dnm-knob" }))
632
- ]);
633
- }
634
- function card(children) {
635
- return h("div", { className: "dnm-card" }, children);
636
- }
637
-
638
- function NotifySettingsView(props) {
639
- var ctx = props.ctx;
640
- var t = props.t;
641
- // Keep the page copy in sync with the active DSH UI language.
642
- React.useSyncExternalStore(function (cb) { return ctx.locale.subscribe(cb); }, function () { return ctx.locale.getSnapshot(); });
643
- var cfgState = React.useState(function () { return readConfig(); });
644
- var cfg = cfgState[0];
645
- var setCfg = cfgState[1];
646
- var feedbackState = React.useState("");
647
- var feedback = feedbackState[0];
648
- var setFeedback = feedbackState[1];
649
- function patch(p) {
650
- setCfg(setConfigLocal(p));
651
- }
652
- function runTest(kind) {
653
- if (!config.enabled) { setFeedback(t("offHint")); return; }
654
- unlockOnce();
655
- // Bypasses the visibility rules on purpose: a test must fire while the
656
- // page is open, otherwise the button proves nothing.
657
- testAlert(kind);
658
- var langName = effectiveLang() === "en" ? t("langEn") : t("langZh");
659
- setFeedback(t("sent") + langName);
660
- }
661
- var permOK = ("Notification" in window) ? Notification.permission === "granted" : false;
662
- var resolvedLang = effectiveLang();
663
- var resolvedLangName = resolvedLang === "en" ? t("langEn") : t("langZh");
664
- return h("div", { className: "dnm-wrap" }, [
665
- h("p", { className: "dnm-blurb" }, t("blurb")),
666
- card([
667
- rowSwitch("enabled", t("enable"), t("enableHint"), cfg.enabled, function (v) {
668
- patch({ enabled: v });
669
- if (!v && attentionActive > 0) { attentionActive = 0; applyTitle(); }
670
- })
671
- ]),
672
- card([
673
- rowSwitch("toast", t("toast"), t("toastHint"), cfg.toast, function (v) { patch({ toast: v }); }),
674
- rowSwitch("sound", t("sound"), t("soundHint"), cfg.sound, function (v) { patch({ sound: v }); }),
675
- rowSwitch("attentionVisible", t("attentionVisible"), t("attentionVisibleHint"), !cfg.attentionHiddenOnly, function (v) { patch({ attentionHiddenOnly: !v }); }),
676
- rowSwitch("doneVisible", t("doneVisible"), t("doneVisibleHint"), !cfg.doneHiddenOnly, function (v) { patch({ doneHiddenOnly: !v }); }),
677
- rowSwitch("autoFocus", t("autoFocus"), null, cfg.autoFocus, function (v) { patch({ autoFocus: v }); }),
678
- h("div", { className: "dnm-row" }, [
679
- h("div", { className: "dnm-rowText" }, [
680
- h("div", { className: "dnm-rowLabel" }, t("volume")),
681
- h("div", { className: "dnm-rowHint" }, String(Math.round(cfg.volume * 100)) + "%")
682
- ]),
683
- h("input", {
684
- type: "range",
685
- className: "dnm-range",
686
- min: 0, max: 100, step: 5,
687
- value: Math.round(cfg.volume * 100),
688
- "aria-label": t("volume"),
689
- onChange: function (e) { patch({ volume: Number(e.currentTarget.value) / 100 }); }
690
- })
691
- ])
692
- ]),
693
- card([
694
- h("div", { className: "dnm-row" }, [
695
- h("div", { className: "dnm-rowText" }, [
696
- h("div", { className: "dnm-rowLabel" }, t("language")),
697
- h("div", { className: "dnm-rowHint" }, resolvedLangName + (cfg.language === "auto" ? t("langAutoNote") : ""))
698
- ]),
699
- h("select", {
700
- className: "dnm-select",
701
- value: cfg.language,
702
- "aria-label": t("language"),
703
- onChange: function (e) { patch({ language: e.currentTarget.value }); }
704
- }, [
705
- h("option", { value: "auto" }, t("langAuto")),
706
- h("option", { value: "zh" }, t("langZh")),
707
- h("option", { value: "en" }, t("langEn"))
708
- ])
709
- ]),
710
- h("div", { className: "dnm-row" }, [
711
- h("div", { className: "dnm-rowText" }, [
712
- h("div", { className: "dnm-rowLabel" }, t("testTitle")),
713
- h("div", { className: "dnm-rowHint" }, t("testHint"))
714
- ]),
715
- h("div", { className: "dnm-btnRow" }, [
716
- h("button", { type: "button", className: "dnm-btn", disabled: !cfg.enabled, onClick: function () { runTest("attention"); } }, t("testAttention")),
717
- h("button", { type: "button", className: "dnm-btn", disabled: !cfg.enabled, onClick: function () { runTest("done"); } }, t("testDone"))
718
- ])
719
- ]),
720
- h("div", { className: "dnm-row" }, [
721
- h("div", { className: "dnm-rowText" }, [
722
- h("div", { className: "dnm-rowLabel" }, t("reset")),
723
- h("div", { className: "dnm-rowHint" }, " ")
724
- ]),
725
- h("button", { type: "button", className: "dnm-btn", onClick: function () {
726
- try { localStorage.removeItem(LS_CONFIG); } catch (e) {}
727
- config = readConfig();
728
- applyTitle();
729
- setCfg(config);
730
- setFeedback(t("resetDone"));
731
- } }, t("reset"))
732
- ]),
733
- !permOK ? h("div", { className: "dnm-row" }, [
734
- h("div", { className: "dnm-rowText" }, [h("div", { className: "dnm-rowHint" }, t("permHint"))])
735
- ]) : null
736
- ]),
737
- h("p", { className: "dnm-feedback" }, feedback)
738
- ]);
739
- }
740
-
741
- // ────────────────────────── plugin body ──────────────────────────
742
- var inject = ["sessions", "locale", "slots"];
743
-
744
- /**
745
- * Client plugin body: watch the sessions service and raise desktop
746
- * notifications + sounds when the agent needs input or a reply finishes;
747
- * also contributes a Settings page (settings.section slot).
748
- * @param ctx - client root context.
749
- */
750
- function apply(ctx) {
751
- rootCtx = ctx;
752
- try {
753
- if (ctx.locale && typeof ctx.locale.getLocale === "function") localeSvc = ctx.locale;
754
- } catch (e) {}
755
- // Settings UI — only when the full web profile (react + slots + locale)
756
- // is present. The reminder core above never depends on this.
757
- try {
758
- if (React && React.createElement && ctx.slots && ctx.locale && typeof ctx.locale.register === "function" && typeof ctx.slots.inject === "function") {
759
- ctx.effect(function () {
760
- ctx.locale.register(NS, { zh: UI_zh, en: UI_en });
761
- }, "dsh-notify-me: settings dictionaries");
762
- var t = ctx.locale.bind(NS);
763
- ctx.slots.inject("settings.section", function () {
764
- return ctx.slots.register({
765
- name: "settings.section",
766
- id: "dsh-notify-me",
767
- order: 45,
768
- label: function () { return t("nav"); },
769
- locale: NS,
770
- inject: function () { return { t: t }; }
771
- }, function (ownerProps) {
772
- return React.createElement(NotifySettingsView, { ctx: ctx, t: t });
773
- });
774
- });
775
- }
776
- } catch (e) {
777
- try { console.error("[dsh-notify-me] settings page unavailable", e); } catch (e2) {}
778
- }
779
- ctx.effect(function () {
780
- var disposed = false;
781
- var unsubs = [];
782
- var retryTimer = null;
783
- try {
784
- var sessionsSvc = ctx.sessions;
785
- if (!sessionsSvc || !sessionsSvc.list || typeof sessionsSvc.list.subscribe !== "function") {
786
- try { console.log("[dsh-notify-me] sessions service unavailable — watchers idle"); } catch (e) {}
787
- return;
788
- }
789
- listStore = sessionsSvc.list;
790
- unsubs.push(sessionsSvc.list.subscribe(onListChanged));
791
- // initial scan (baseline only; no alerts for pre-existing state)
792
- onListChanged();
793
- // binding may resolve a tick later; re-check periodically while idle
794
- retryTimer = setInterval(function () {
795
- if (disposed) return;
796
- try { bindCurrentFace(); } catch (e) {}
797
- }, 1500);
798
- try { console.log("[dsh-notify-me] active — window.__dshNotifyMe 可配置提醒 (enabled=" + config.enabled + ", lang=" + effectiveLang() + ", toast=" + config.toast + ", sound=" + config.sound + ")"); } catch (e) {}
799
- } catch (e) {
800
- try { console.error("[dsh-notify-me] init failed", e); } catch (e2) {}
801
- }
802
- return function () {
803
- disposed = true;
804
- if (retryTimer) clearInterval(retryTimer);
805
- for (var i = 0; i < unsubs.length; i++) { try { unsubs[i](); } catch (e) {} }
806
- detachFace();
807
- };
808
- }, "dsh-notify-me: watchers");
809
- }
810
-
811
- exports.inject = inject;
812
- exports.apply = apply;
813
- return module.exports;
814
- }
815
- });
1
+ // dsh-notify-me — browser half (client plugin bundle).
2
+ //
3
+ // Loaded by dsh-client-modules at /plugins/dsh-notify-me/client.js and
4
+ // executed through the vendored cordis Loader's lazy-CJS module table
5
+ // (window.__ModuleLoader__.load). The factory body is plain CJS with
6
+ // require() resolved against the shell's module table — the same shape the
7
+ // shipped ui-* packages' tsdown bundles emit. react is part of the platform
8
+ // base, so require("react") resolves to the shell-seeded instance (same as
9
+ // every shipped ui-* bundle); the settings page is skipped when react or the
10
+ // slots/locale services are absent, keeping the reminder core self-contained.
11
+ //
12
+ // What this plugin does (all client-side, no settings transport needed):
13
+ // - watches the "sessions" service (SessionRuntime): the selected
14
+ // session's ConversationSnapshot (running / pending interactions) and the
15
+ // session-list summaries of every other session;
16
+ // - fires a Windows desktop Notification (+ optional sound) when the agent
17
+ // needs the user's input — pending interaction kinds 'approval',
18
+ // 'plan-review' or 'question' — and when a reply/turn finishes;
19
+ // - registers a "通知提醒 / Notify me" page into DSH Settings
20
+ // (settings.section slot) exposing an on/off master switch, per-channel
21
+ // toggles, volume and the notification language (follow-interface /
22
+ // Simplified Chinese / English);
23
+ // - preferences persist in localStorage (origin-scoped, like other
24
+ // third-party client plugins) and are tweakable on window.__dshNotifyMe.
25
+
26
+ window.__ModuleLoader__.load({
27
+ id: "dsh-notify-me",
28
+ factory: (require) => {
29
+ var module = { exports: {} };
30
+ var exports = module.exports;
31
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
32
+
33
+ // react is externalized into the loader module table; never required by
34
+ // the reminder core, only by the optional Settings page.
35
+ var React = null;
36
+ try { React = require("react") || null; } catch (e) { React = null; }
37
+
38
+ // ───────────────────────── configuration ─────────────────────────
39
+ var LS_CONFIG = "dshNotifyMe.config";
40
+ var DEFAULTS = {
41
+ enabled: true, // master switch: off = no toast/sound/title marker at all
42
+ // language of the notification copy: 'auto' follows the DSH UI locale,
43
+ // 'zh' and 'en' pin Simplified Chinese / English.
44
+ language: "auto",
45
+ // toast + sound are only useful while the user is elsewhere; the tab
46
+ // being hidden is the reliable "away" signal.
47
+ attentionHiddenOnly: false, // needs-input alerts even while the page is visible
48
+ doneHiddenOnly: true, // reply-finished alerts only when the page is hidden
49
+ toast: true,
50
+ sound: true,
51
+ volume: 0.5,
52
+ autoFocus: true // clicking the toast focuses the DSH window
53
+ };
54
+ function readConfig() {
55
+ var cfg = {};
56
+ try {
57
+ var raw = localStorage.getItem(LS_CONFIG);
58
+ if (raw) cfg = JSON.parse(raw);
59
+ } catch (e) { /* corrupt storage -> defaults */ }
60
+ for (var k in DEFAULTS) if (!(k in cfg)) cfg[k] = DEFAULTS[k];
61
+ return cfg;
62
+ }
63
+ function writeConfig(patch) {
64
+ var cfg = readConfig();
65
+ for (var k in patch) cfg[k] = patch[k];
66
+ try { localStorage.setItem(LS_CONFIG, JSON.stringify(cfg)); } catch (e) {}
67
+ return cfg;
68
+ }
69
+ var config = readConfig();
70
+ // Persist a patch, swap the live module config and re-apply any active
71
+ // title marker (its prefix follows the notification language).
72
+ function setConfigLocal(patch) {
73
+ config = writeConfig(patch);
74
+ applyTitle();
75
+ return config;
76
+ }
77
+
78
+ // ───────────────────────── copy tables ─────────────────────────
79
+ // Notification copy, keyed by language id. Selected through
80
+ // effectiveLang() so a pinned language stays pinned even while the DSH UI
81
+ // speaks the other one.
82
+ var TXT = {
83
+ zh: {
84
+ kind: { approval: "审批请求", "plan-review": "方案待确认", question: "提问" },
85
+ kindDefault: "需要操作",
86
+ bodyKind: { approval: "有审批待处理", "plan-review": "方案待确认", question: "有提问待回答" },
87
+ bodyDefault: "需要你操作",
88
+ current: "当前对话",
89
+ doneTitle: "DSH · 回复完成",
90
+ doneBody: "回复已完成",
91
+ background: "后台会话",
92
+ marker: "🔔 需要你 · ",
93
+ sep: ":",
94
+ testAttention: "这是一条测试提醒:DSH 需要你的操作",
95
+ testDone: "测试提醒 · 回复完成示例"
96
+ },
97
+ en: {
98
+ kind: { approval: "Approval request", "plan-review": "Plan review", question: "Question" },
99
+ kindDefault: "Action needed",
100
+ bodyKind: {
101
+ approval: "An approval is waiting for you",
102
+ "plan-review": "A plan awaits your review",
103
+ question: "A question is waiting for you"
104
+ },
105
+ bodyDefault: "Your input is needed",
106
+ current: "Current conversation",
107
+ doneTitle: "DSH · Reply finished",
108
+ doneBody: "Reply finished",
109
+ background: "Background session",
110
+ marker: "🔔 Action needed · ",
111
+ sep: ": ",
112
+ testAttention: "This is a test alert: DSH is waiting for your input",
113
+ testDone: "Test alert · reply-finished sample"
114
+ }
115
+ };
116
+ // Locale service handle (registered on the client context by dsh-client-locale).
117
+ var localeSvc = null;
118
+ // Current DSH UI language: 'zh' | 'en' | null (unknown). Used by the
119
+ // 'auto' notification-language mode.
120
+ function currentUILang() {
121
+ try {
122
+ if (localeSvc && typeof localeSvc.getLocale === "function") {
123
+ var a = localeSvc.getLocale().active;
124
+ if (typeof a === "string" && a) {
125
+ if (a.indexOf("zh") === 0) return "zh";
126
+ if (a.indexOf("en") === 0) return "en";
127
+ }
128
+ }
129
+ } catch (e) {}
130
+ try {
131
+ var dl = (document.documentElement && document.documentElement.lang) || "";
132
+ if (dl.indexOf("zh") === 0) return "zh";
133
+ if (dl.indexOf("en") === 0) return "en";
134
+ } catch (e) {}
135
+ return null;
136
+ }
137
+ function effectiveLang() {
138
+ if (!config.language || config.language === "auto") return currentUILang() || "zh";
139
+ return config.language === "en" ? "en" : "zh";
140
+ }
141
+ function texts() { return TXT[effectiveLang()] || TXT.zh; }
142
+
143
+ // ─────────────────────── notification plumbing ───────────────────
144
+ var permAsked = false;
145
+ function requestPermission() {
146
+ if (!("Notification" in window) || permAsked) return;
147
+ permAsked = true;
148
+ try {
149
+ if (Notification.permission === "default") {
150
+ Notification.requestPermission().catch(function () {});
151
+ }
152
+ } catch (e) {}
153
+ }
154
+ function canToast() {
155
+ return config.toast && "Notification" in window && Notification.permission === "granted";
156
+ }
157
+ function showToast(kind, title, body) {
158
+ if (!canToast()) return;
159
+ try {
160
+ var n = new Notification(title, {
161
+ body: body || "",
162
+ tag: "dsh-notify-me-" + kind,
163
+ renotify: true,
164
+ icon: (location.origin || "") + "/favicon.svg"
165
+ });
166
+ if (config.autoFocus) {
167
+ n.onclick = function () {
168
+ try { window.focus(); } catch (e) {}
169
+ n.close();
170
+ };
171
+ }
172
+ setTimeout(function () { n.close(); }, 15000);
173
+ } catch (e) {}
174
+ }
175
+ // Sound: WebAudio beeps (no asset needed). The context only runs after a
176
+ // user gesture; we unlock it on the first pointer/key event.
177
+ var audioCtx = null;
178
+ function unlockAudio() {
179
+ try {
180
+ if (!audioCtx) {
181
+ var AC = window.AudioContext || window.webkitAudioContext;
182
+ if (!AC) return;
183
+ audioCtx = new AC();
184
+ }
185
+ if (audioCtx.state === "suspended") audioCtx.resume().catch(function () {});
186
+ } catch (e) {}
187
+ }
188
+ function tone(freq, startIn, durMs, vol) {
189
+ if (!audioCtx || audioCtx.state !== "running") return;
190
+ try {
191
+ var t0 = audioCtx.currentTime + startIn;
192
+ var osc = audioCtx.createOscillator();
193
+ var gain = audioCtx.createGain();
194
+ osc.type = "sine";
195
+ osc.frequency.value = freq;
196
+ gain.gain.setValueAtTime(0.0001, t0);
197
+ gain.gain.exponentialRampToValueAtTime(vol, t0 + 0.02);
198
+ gain.gain.exponentialRampToValueAtTime(0.0001, t0 + durMs / 1000);
199
+ osc.connect(gain).connect(audioCtx.destination);
200
+ osc.start(t0);
201
+ osc.stop(t0 + durMs / 1000 + 0.05);
202
+ } catch (e) {}
203
+ }
204
+ function playAttentionSound() {
205
+ if (!config.sound) return;
206
+ unlockAudio();
207
+ tone(880, 0.0, 140, config.volume);
208
+ tone(1174, 0.16, 140, config.volume);
209
+ tone(1568, 0.32, 200, config.volume);
210
+ }
211
+ function playDoneSound() {
212
+ if (!config.sound) return;
213
+ unlockAudio();
214
+ tone(659, 0.0, 150, config.volume);
215
+ tone(988, 0.18, 260, config.volume);
216
+ }
217
+ function unlockOnce() {
218
+ unlockAudio();
219
+ requestPermission();
220
+ }
221
+ function onFirstGesture() {
222
+ unlockOnce();
223
+ window.removeEventListener("pointerdown", onFirstGesture, true);
224
+ window.removeEventListener("keydown", onFirstGesture, true);
225
+ }
226
+ window.addEventListener("pointerdown", onFirstGesture, true);
227
+ window.addEventListener("keydown", onFirstGesture, true);
228
+
229
+ // ────────────────────────── title marker ─────────────────────────
230
+ var originalTitle = null;
231
+ var attentionActive = 0;
232
+ function applyTitle() {
233
+ try {
234
+ if (originalTitle === null) originalTitle = document.title;
235
+ document.title = (config.enabled && attentionActive > 0)
236
+ ? texts().marker + originalTitle
237
+ : originalTitle;
238
+ } catch (e) {}
239
+ }
240
+ function acquireTitleMark() {
241
+ attentionActive += 1;
242
+ applyTitle();
243
+ }
244
+ function releaseTitleMark() {
245
+ if (attentionActive > 0) attentionActive -= 1;
246
+ applyTitle();
247
+ }
248
+
249
+ // ─────────────────────── shared alert core ───────────────────────
250
+ var attentionKeysFired = Object.create(null); // dedupe per pending key
251
+ var lastAttentionAt = 0;
252
+
253
+ function hidden() { return document.hidden || document.visibilityState === "hidden"; }
254
+
255
+ // Deliver one alert regardless of visibility rules (used by the test
256
+ // buttons); respects the master switch and permission state.
257
+ function deliver(kind, title, body) {
258
+ if (!config.enabled) return;
259
+ showToast(kind, title, body);
260
+ if (kind === "attention") {
261
+ playAttentionSound();
262
+ acquireTitleMark();
263
+ } else {
264
+ playDoneSound();
265
+ }
266
+ try {
267
+ if (window.__dshNotifyMe && window.__dshNotifyMe.onEvent) {
268
+ window.__dshNotifyMe.onEvent(kind, { title: title, body: body });
269
+ }
270
+ } catch (e) {}
271
+ }
272
+ function fire(kind, title, body) {
273
+ if (!config.enabled) return;
274
+ if (kind === "attention" && config.attentionHiddenOnly && !hidden()) return;
275
+ if (kind === "done" && config.doneHiddenOnly && !hidden()) return;
276
+ deliver(kind, title, body);
277
+ }
278
+ // A display label for a session row ("current" maps to the localized
279
+ // "当前对话 / Current conversation" placeholder).
280
+ function sessionLabelText(row) {
281
+ var raw = row && row.label;
282
+ if (!raw || raw === "current") return texts().current;
283
+ return raw;
284
+ }
285
+ function fireAttention(kind, sessionLabel, detail) {
286
+ var d = texts();
287
+ var label = d.kind[kind] || d.kindDefault;
288
+ var content = detail || d.bodyKind[kind] || d.bodyDefault;
289
+ var who = sessionLabel || "";
290
+ fire("attention", "DSH · " + label, (who ? who + d.sep : "") + content);
291
+ }
292
+ function fireDone(sessionLabel, snippet) {
293
+ var d = texts();
294
+ var body = sessionLabel || d.doneBody;
295
+ if (snippet) body = body + "\n" + snippet;
296
+ fire("done", d.doneTitle, body);
297
+ }
298
+ // Test alerts deliberately ignore the visibility rules (attentionHiddenOnly
299
+ // / doneHiddenOnly): the whole point of a test button is to prove the
300
+ // channel works while you are looking at the page. Only the master switch
301
+ // (and browser notification permission) can stop them. The attention test
302
+ // also drops its title marker again shortly after, so a test never leaves a
303
+ // standing "waiting on you" mark in the tab.
304
+ var TEST_MARK_RELEASE_MS = 6000;
305
+ function testAlert(kind) {
306
+ var d = texts();
307
+ if (kind === "attention") {
308
+ deliver("attention", "DSH · " + d.kind.question, d.testAttention);
309
+ setTimeout(function () { releaseTitleMark(); }, TEST_MARK_RELEASE_MS);
310
+ } else {
311
+ deliver("done", d.doneTitle, d.testDone);
312
+ }
313
+ }
314
+
315
+ // ───────────────────────── state machine ─────────────────────────
316
+ // prevById: per-session-id baseline of what we have already reported.
317
+ // { running: bool, pendingKeys: string[] , doneArmed: bool, seeded: bool }
318
+ var prevById = Object.create(null);
319
+ var faceSub = null; // current session face unsubscribe
320
+ var currentFaceId = null;
321
+ var listStore = null;
322
+ var rootCtx = null; // client cordis context, captured at apply()
323
+
324
+ function textFromPayload(payload) {
325
+ if (!payload || typeof payload !== "object") return "";
326
+ var keys = ["text", "question", "prompt", "title", "summary", "description", "message", "operation", "name", "tool"];
327
+ for (var i = 0; i < keys.length; i++) {
328
+ var v = payload[keys[i]];
329
+ if (typeof v === "string" && v.trim()) return v.length > 160 ? v.slice(0, 160) + "…" : v;
330
+ if (typeof v === "object" && v !== null && typeof v.text === "string" && v.text.trim()) {
331
+ var s = v.text;
332
+ return s.length > 160 ? s.slice(0, 160) + "…" : s;
333
+ }
334
+ }
335
+ return "";
336
+ }
337
+ function snippetOfLastAssistant(snap) {
338
+ try {
339
+ var nodes = snap.nodes || [];
340
+ for (var i = nodes.length - 1; i >= 0; i--) {
341
+ var nd = nodes[i];
342
+ if (nd && nd.kind === "assistant" && nd.blocks) {
343
+ for (var b = nd.blocks.length - 1; b >= 0; b--) {
344
+ if (nd.blocks[b] && nd.blocks[b].kind === "text" && nd.blocks[b].text) {
345
+ var t = nd.blocks[b].text.replace(/\s+/g, " ").trim();
346
+ if (t) return t.length > 80 ? t.slice(0, 80) + "…" : t;
347
+ }
348
+ }
349
+ }
350
+ }
351
+ } catch (e) {}
352
+ return "";
353
+ }
354
+ // Evaluate one row/face observation. row: {id, running, pending:[{key,kind,payload}], completed, label, face:bool}
355
+ function evaluateRow(row) {
356
+ var prev = prevById[row.id];
357
+ var runningNow = !!row.running;
358
+ var pendingNow = row.pending || [];
359
+ var prevPendingKeys = prev ? prev.pendingKeys : [];
360
+ var wasRunning = prev ? prev.running : false;
361
+ var newKeys = [];
362
+ for (var i = 0; i < pendingNow.length; i++) {
363
+ var pk = pendingNow[i].key;
364
+ if (prevPendingKeys.indexOf(pk) === -1 && !attentionKeysFired[pk]) {
365
+ newKeys.push(pendingNow[i]);
366
+ attentionKeysFired[pk] = true;
367
+ }
368
+ }
369
+ var hasPendingNow = pendingNow.length > 0;
370
+
371
+ // 1) a pending interaction just appeared -> the agent is waiting on the user.
372
+ // First evaluation of a session also lands here (prevPendingKeys empty),
373
+ // so a wait that already existed when the plugin (or page) mounted still
374
+ // alerts once.
375
+ if (newKeys.length > 0) {
376
+ var it = newKeys[0];
377
+ var detail = textFromPayload(it.payload);
378
+ if (!detail) detail = "";
379
+ fireAttention(it.kind, sessionLabelText(row), detail);
380
+ lastAttentionAt = Date.now();
381
+ }
382
+
383
+ // 2) a non-selected session finished while we were away (sidebar "done")
384
+ var notified = false;
385
+ if (!row.face && row.completed && prev && !prev.completed && prev.seeded) {
386
+ fireDone(sessionLabelText(row), "");
387
+ notified = true;
388
+ }
389
+
390
+ // 3) running true -> false on a session we were watching: a reply finished
391
+ if (!notified && prev && prev.seeded && wasRunning && !runningNow && !hasPendingNow) {
392
+ // ignore the edge if it happened in the same tick as an attention alert
393
+ if (Date.now() - lastAttentionAt < 300) {
394
+ /* answered by an attention alert above */
395
+ } else {
396
+ fireDone(sessionLabelText(row), row.face ? snippetOfLastAssistant(row.faceSnap) : "");
397
+ }
398
+ }
399
+
400
+ // store the next baseline
401
+ prevById[row.id] = {
402
+ running: runningNow,
403
+ pendingKeys: pendingNow.map(function (p) { return p.key; }),
404
+ completed: !!row.completed,
405
+ seeded: true
406
+ };
407
+
408
+ // a wait we alerted about has been resolved -> drop the title marker
409
+ if (prev && prevPendingKeys.length > 0 && pendingNow.length === 0) {
410
+ releaseTitleMark();
411
+ }
412
+ }
413
+
414
+ // face -> row adapter (selected session, rich data)
415
+ function evalSessionFace(sessionId, snap) {
416
+ if (sessionId !== currentFaceId) return;
417
+ var pend = (snap.pending || []).map(function (p) {
418
+ return { key: p.key || (p.kind + ":" + sessionId), kind: p.kind, payload: p.payload || null };
419
+ });
420
+ evaluateRow({
421
+ id: sessionId,
422
+ running: !!snap.running,
423
+ pending: pend,
424
+ completed: false,
425
+ face: true,
426
+ faceSnap: snap,
427
+ label: "current"
428
+ });
429
+ }
430
+ // list row -> adapter (every other listed session)
431
+ function evalListRow(sid, sum, currentId) {
432
+ evaluateRow({
433
+ id: sid,
434
+ running: !!sum.running,
435
+ pending: sum.pendingInteraction ? [{ key: "list:" + sid + ":" + sum.pendingInteraction, kind: sum.pendingInteraction, payload: null }] : [],
436
+ completed: !!sum.completed,
437
+ face: false,
438
+ label: sum.displayTitle || sum.title || sid
439
+ });
440
+ }
441
+
442
+ // ─────────────────────── subscription wiring ─────────────────────
443
+ function bindCurrentFace() {
444
+ if (!listStore) return;
445
+ try {
446
+ var st = listStore.getSnapshot();
447
+ var cur = st && st.current;
448
+ if (!cur) { detachFace(); return; }
449
+ if (cur === currentFaceId && faceSub) return;
450
+ detachFace();
451
+ currentFaceId = cur;
452
+ var b = rootCtx.sessions.binding(cur);
453
+ if (!b || !b.session || typeof b.session.subscribe !== "function") {
454
+ // binding not minted yet — the retry timer will re-check
455
+ return;
456
+ }
457
+ faceSub = b.session.subscribe(function () {
458
+ try {
459
+ evalSessionFace(cur, b.session.getSnapshot());
460
+ } catch (e) {}
461
+ });
462
+ // First evaluation seeds the baseline (no false completion edges) while
463
+ // still alerting for interactions that were already pending on bind.
464
+ try {
465
+ evalSessionFace(cur, b.session.getSnapshot());
466
+ } catch (e) {}
467
+ } catch (e) {}
468
+ }
469
+ function detachFace() {
470
+ if (faceSub) { try { faceSub(); } catch (e) {} faceSub = null; }
471
+ currentFaceId = null;
472
+ }
473
+ function onListChanged() {
474
+ if (!listStore) return;
475
+ try {
476
+ var st = listStore.getSnapshot();
477
+ var cur = st && st.current;
478
+ bindCurrentFace();
479
+ var byId = (st && st.byId) || {};
480
+ for (var sid in byId) {
481
+ if (sid === cur) continue; // current handled by the face watcher
482
+ var sum = byId[sid];
483
+ if (!sum) continue;
484
+ evalListRow(sid, sum, cur);
485
+ }
486
+ // sessions that vanished: keep baseline (they may reappear) - no action.
487
+ } catch (e) {}
488
+ }
489
+
490
+ // ──────────────────────── global control API ─────────────────────
491
+ var api = {
492
+ // keep in sync with package.json "version" on every release
493
+ version: "1.1.4",
494
+ get config() { return readConfig(); },
495
+ setConfig: function (patch) { return setConfigLocal(patch); },
496
+ resetConfig: function () {
497
+ try { localStorage.removeItem(LS_CONFIG); } catch (e) {}
498
+ config = readConfig();
499
+ return config;
500
+ },
501
+ // manual test; run it after clicking anywhere so permission can be granted.
502
+ // Test alerts fire regardless of the page-open visibility rules.
503
+ test: function (kind) {
504
+ kind = kind === "attention" ? "attention" : "done";
505
+ unlockOnce();
506
+ testAlert(kind);
507
+ return "sent " + kind + " (lang=" + effectiveLang() + ", permission=" + ("Notification" in window ? Notification.permission : "n/a") + ")";
508
+ }
509
+ };
510
+ window.__dshNotifyMe = api;
511
+
512
+ // ────────────────── Settings page (react only, optional) ─────────
513
+ // Registered when the web profile provides react + slots + locale; the
514
+ // reminder core above never depends on any of it.
515
+ var NS = "dsh-notify-me";
516
+ var UI_zh = {
517
+ nav: "通知提醒",
518
+ blurb: "当模型停下来需要你操作(审批 / 方案待确认 / 提问),或回复在后台完成时,用系统通知、提示音和标签页标题提醒你。",
519
+ enable: "启用提醒",
520
+ enableHint: "总开关:关闭后不再弹系统通知、不播放提示音,也不再改动标签页标题。",
521
+ toast: "系统通知",
522
+ toastHint: "经浏览器弹出系统 Toast(Windows 通知中心);需要已授予通知权限。",
523
+ sound: "提示音",
524
+ soundHint: "WebAudio 合成音,「需要你」与「回复完成」使用不同音型。",
525
+ attentionVisible: "页面打开时也提醒「需要你」",
526
+ attentionVisibleHint: "默认开启:模型在等你审批 / 确认方案 / 回答问题(含提问弹窗)时,即使页面在最前也会提醒。",
527
+ doneVisible: "页面打开时也提醒「回复完成」",
528
+ doneVisibleHint: "默认关闭:「完成」只在页面隐藏或切到后台时才提醒,避免打扰。",
529
+ autoFocus: "点击通知把 DSH 切回前台",
530
+ volume: "音量",
531
+ language: "通知语言",
532
+ langAuto: "跟随界面",
533
+ langZh: "简体中文",
534
+ langEn: "English",
535
+ langAutoNote: "(跟随界面)",
536
+ current: "界面当前语言",
537
+ testTitle: "快速测试",
538
+ testHint: "用当前设置各发一条测试提醒(不受上面「页面打开时也提醒」开关限制)。",
539
+ testAttention: "测试「需要你」",
540
+ testDone: "测试「回复完成」",
541
+ reset: "恢复默认设置",
542
+ resetDone: "已恢复默认设置。",
543
+ sent: "已发送测试提醒 · 通知语言:",
544
+ permHint: "通知权限尚未授予:先在页面上点击一次并允许通知,系统通知才能弹出(提示音不受影响)。",
545
+ offHint: "已关闭提醒:先打开「启用提醒」再测试。"
546
+ };
547
+ var UI_en = {
548
+ nav: "Notify me",
549
+ blurb: "When the agent stops and needs your input (approval / plan review / question) or a reply finishes in the background, get a system notification, a sound, and a tab-title marker.",
550
+ enable: "Enable reminders",
551
+ enableHint: "Master switch: when off, no toasts, no sounds, and the tab title is never modified.",
552
+ toast: "System notifications",
553
+ toastHint: "Native toasts via the browser (Windows notification center); requires notification permission.",
554
+ sound: "Sound",
555
+ soundHint: "WebAudio beeps — \"needs you\" and \"reply finished\" use different patterns.",
556
+ attentionVisible: "Also alert \"needs you\" while the page is open",
557
+ attentionVisibleHint: "On by default: alert even in the foreground when the model waits for approval / plan review / a question.",
558
+ doneVisible: "Also alert \"reply finished\" while the page is open",
559
+ doneVisibleHint: "Off by default: \"done\" only alerts while the page is hidden or backgrounded, to avoid noise.",
560
+ autoFocus: "Click a toast to focus the DSH window",
561
+ volume: "Volume",
562
+ language: "Notification language",
563
+ langAuto: "Follow the interface",
564
+ langZh: "简体中文",
565
+ langEn: "English",
566
+ langAutoNote: " (follows the interface)",
567
+ current: "Current interface language",
568
+ testTitle: "Quick test",
569
+ testHint: "Send one test alert with the current settings (not limited by the \"while the page is open\" toggles above).",
570
+ testAttention: "Test \"needs you\"",
571
+ testDone: "Test \"reply finished\"",
572
+ reset: "Restore defaults",
573
+ resetDone: "Defaults restored.",
574
+ sent: "Test alert sent · notification language: ",
575
+ permHint: "Notification permission is not granted yet — click once on the page and allow notifications so toasts can appear (sound is unaffected).",
576
+ offHint: "Reminders are off: enable them first, then test."
577
+ };
578
+ // Small element helper to keep the hand-written page readable.
579
+ function h(tag, props) {
580
+ var args = [tag, props || null];
581
+ for (var i = 2; i < arguments.length; i++) args.push(arguments[i]);
582
+ return React.createElement.apply(React, args);
583
+ }
584
+ // css classes (namespaced to avoid collisions)
585
+ var UI_CSS = "" +
586
+ ".dnm-wrap{width:100%;max-width:720px;display:flex;flex-direction:column;gap:14px;color:var(--dsw-alias-label-primary,rgba(20,20,20,.9))}" +
587
+ ".dnm-blurb{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
588
+ ".dnm-card{border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35));background:var(--dsw-alias-bg-layer-3,rgba(128,128,128,.05));border-radius:10px;overflow:hidden}" +
589
+ ".dnm-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 14px;min-height:46px;box-sizing:border-box}" +
590
+ ".dnm-row+.dnm-row{border-top:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35))}" +
591
+ ".dnm-rowText{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}" +
592
+ ".dnm-rowLabel{font-size:13px;font-weight:600;line-height:20px;color:var(--dsw-alias-label-primary,rgba(20,20,20,.9))}" +
593
+ ".dnm-rowHint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
594
+ ".dnm-switch{flex:none;position:relative;width:34px;height:20px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.45));background:var(--dsw-alias-bg-layer-1,rgba(128,128,128,.2));cursor:pointer;padding:0;transition:background .15s ease}" +
595
+ ".dnm-switch.on{background:var(--dsw-alias-state-business-primary,#3b82f6);border-color:transparent}" +
596
+ ".dnm-knob{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;transition:left .15s ease}" +
597
+ ".dnm-switch.on .dnm-knob{left:16px}" +
598
+ ".dnm-range{width:150px;accent-color:var(--dsw-alias-state-business-primary,#3b82f6)}" +
599
+ ".dnm-select{border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.03));color:var(--dsw-alias-label-primary,rgba(20,20,20,.9));border-radius:8px;padding:5px 8px;font-size:13px;font-family:inherit}" +
600
+ ".dnm-btn{border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:transparent;color:var(--dsw-alias-label-primary,rgba(20,20,20,.9));border-radius:8px;padding:5px 12px;font-size:13px;cursor:pointer;font-family:inherit}" +
601
+ ".dnm-btn:hover{background:var(--dsw-alias-bg-layer-1,rgba(128,128,128,.1))}" +
602
+ ".dnm-btn[disabled]{opacity:.45;cursor:default}" +
603
+ ".dnm-btnRow{display:flex;align-items:center;gap:10px;flex-wrap:wrap}" +
604
+ ".dnm-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
605
+ ".dnm-feedback{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9));min-height:18px}";
606
+ (function injectStyle() {
607
+ try {
608
+ if (typeof document === "undefined" || typeof document.createElement !== "function") return;
609
+ var tagId = "dsh-notify-me/settings";
610
+ if (document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]")) return;
611
+ var s = document.createElement("style");
612
+ s.dataset.plugin = "dsh-notify-me";
613
+ s.dataset.pluginCss = tagId;
614
+ s.textContent = UI_CSS;
615
+ (document.head || document.documentElement).appendChild(s);
616
+ } catch (e) {}
617
+ })();
618
+
619
+ function rowSwitch(key, labelText, hintText, checked, onChange) {
620
+ return h("div", { className: "dnm-row" }, [
621
+ h("div", { className: "dnm-rowText" }, [
622
+ h("div", { className: "dnm-rowLabel" }, labelText),
623
+ hintText ? h("div", { className: "dnm-rowHint" }, hintText) : null
624
+ ]),
625
+ h("button", {
626
+ type: "button",
627
+ role: "switch",
628
+ "aria-checked": checked ? "true" : "false",
629
+ className: "dnm-switch" + (checked ? " on" : ""),
630
+ onClick: function () { onChange(!checked); }
631
+ }, h("span", { className: "dnm-knob" }))
632
+ ]);
633
+ }
634
+ function card(children) {
635
+ return h("div", { className: "dnm-card" }, children);
636
+ }
637
+
638
+ function NotifySettingsView(props) {
639
+ var ctx = props.ctx;
640
+ var t = props.t;
641
+ // Keep the page copy in sync with the active DSH UI language.
642
+ React.useSyncExternalStore(function (cb) { return ctx.locale.subscribe(cb); }, function () { return ctx.locale.getSnapshot(); });
643
+ var cfgState = React.useState(function () { return readConfig(); });
644
+ var cfg = cfgState[0];
645
+ var setCfg = cfgState[1];
646
+ var feedbackState = React.useState("");
647
+ var feedback = feedbackState[0];
648
+ var setFeedback = feedbackState[1];
649
+ function patch(p) {
650
+ setCfg(setConfigLocal(p));
651
+ }
652
+ function runTest(kind) {
653
+ if (!config.enabled) { setFeedback(t("offHint")); return; }
654
+ unlockOnce();
655
+ // Bypasses the visibility rules on purpose: a test must fire while the
656
+ // page is open, otherwise the button proves nothing.
657
+ testAlert(kind);
658
+ var langName = effectiveLang() === "en" ? t("langEn") : t("langZh");
659
+ setFeedback(t("sent") + langName);
660
+ }
661
+ var permOK = ("Notification" in window) ? Notification.permission === "granted" : false;
662
+ var resolvedLang = effectiveLang();
663
+ var resolvedLangName = resolvedLang === "en" ? t("langEn") : t("langZh");
664
+ return h("div", { className: "dnm-wrap" }, [
665
+ h("p", { className: "dnm-blurb" }, t("blurb")),
666
+ card([
667
+ rowSwitch("enabled", t("enable"), t("enableHint"), cfg.enabled, function (v) {
668
+ patch({ enabled: v });
669
+ if (!v && attentionActive > 0) { attentionActive = 0; applyTitle(); }
670
+ })
671
+ ]),
672
+ card([
673
+ rowSwitch("toast", t("toast"), t("toastHint"), cfg.toast, function (v) { patch({ toast: v }); }),
674
+ rowSwitch("sound", t("sound"), t("soundHint"), cfg.sound, function (v) { patch({ sound: v }); }),
675
+ rowSwitch("attentionVisible", t("attentionVisible"), t("attentionVisibleHint"), !cfg.attentionHiddenOnly, function (v) { patch({ attentionHiddenOnly: !v }); }),
676
+ rowSwitch("doneVisible", t("doneVisible"), t("doneVisibleHint"), !cfg.doneHiddenOnly, function (v) { patch({ doneHiddenOnly: !v }); }),
677
+ rowSwitch("autoFocus", t("autoFocus"), null, cfg.autoFocus, function (v) { patch({ autoFocus: v }); }),
678
+ h("div", { className: "dnm-row" }, [
679
+ h("div", { className: "dnm-rowText" }, [
680
+ h("div", { className: "dnm-rowLabel" }, t("volume")),
681
+ h("div", { className: "dnm-rowHint" }, String(Math.round(cfg.volume * 100)) + "%")
682
+ ]),
683
+ h("input", {
684
+ type: "range",
685
+ className: "dnm-range",
686
+ min: 0, max: 100, step: 5,
687
+ value: Math.round(cfg.volume * 100),
688
+ "aria-label": t("volume"),
689
+ onChange: function (e) { patch({ volume: Number(e.currentTarget.value) / 100 }); }
690
+ })
691
+ ])
692
+ ]),
693
+ card([
694
+ h("div", { className: "dnm-row" }, [
695
+ h("div", { className: "dnm-rowText" }, [
696
+ h("div", { className: "dnm-rowLabel" }, t("language")),
697
+ h("div", { className: "dnm-rowHint" }, resolvedLangName + (cfg.language === "auto" ? t("langAutoNote") : ""))
698
+ ]),
699
+ h("select", {
700
+ className: "dnm-select",
701
+ value: cfg.language,
702
+ "aria-label": t("language"),
703
+ onChange: function (e) { patch({ language: e.currentTarget.value }); }
704
+ }, [
705
+ h("option", { value: "auto" }, t("langAuto")),
706
+ h("option", { value: "zh" }, t("langZh")),
707
+ h("option", { value: "en" }, t("langEn"))
708
+ ])
709
+ ]),
710
+ h("div", { className: "dnm-row" }, [
711
+ h("div", { className: "dnm-rowText" }, [
712
+ h("div", { className: "dnm-rowLabel" }, t("testTitle")),
713
+ h("div", { className: "dnm-rowHint" }, t("testHint"))
714
+ ]),
715
+ h("div", { className: "dnm-btnRow" }, [
716
+ h("button", { type: "button", className: "dnm-btn", disabled: !cfg.enabled, onClick: function () { runTest("attention"); } }, t("testAttention")),
717
+ h("button", { type: "button", className: "dnm-btn", disabled: !cfg.enabled, onClick: function () { runTest("done"); } }, t("testDone"))
718
+ ])
719
+ ]),
720
+ h("div", { className: "dnm-row" }, [
721
+ h("div", { className: "dnm-rowText" }, [
722
+ h("div", { className: "dnm-rowLabel" }, t("reset")),
723
+ h("div", { className: "dnm-rowHint" }, " ")
724
+ ]),
725
+ h("button", { type: "button", className: "dnm-btn", onClick: function () {
726
+ try { localStorage.removeItem(LS_CONFIG); } catch (e) {}
727
+ config = readConfig();
728
+ applyTitle();
729
+ setCfg(config);
730
+ setFeedback(t("resetDone"));
731
+ } }, t("reset"))
732
+ ]),
733
+ !permOK ? h("div", { className: "dnm-row" }, [
734
+ h("div", { className: "dnm-rowText" }, [h("div", { className: "dnm-rowHint" }, t("permHint"))])
735
+ ]) : null
736
+ ]),
737
+ h("p", { className: "dnm-feedback" }, feedback)
738
+ ]);
739
+ }
740
+
741
+ // ────────────────────────── plugin body ──────────────────────────
742
+ var inject = ["sessions", "locale", "slots"];
743
+
744
+ /**
745
+ * Client plugin body: watch the sessions service and raise desktop
746
+ * notifications + sounds when the agent needs input or a reply finishes;
747
+ * also contributes a Settings page (settings.section slot).
748
+ * @param ctx - client root context.
749
+ */
750
+ function apply(ctx) {
751
+ rootCtx = ctx;
752
+ try {
753
+ if (ctx.locale && typeof ctx.locale.getLocale === "function") localeSvc = ctx.locale;
754
+ } catch (e) {}
755
+ // Settings UI — only when the full web profile (react + slots + locale)
756
+ // is present. The reminder core above never depends on this.
757
+ try {
758
+ if (React && React.createElement && ctx.slots && ctx.locale && typeof ctx.locale.register === "function" && typeof ctx.slots.inject === "function") {
759
+ ctx.effect(function () {
760
+ ctx.locale.register(NS, { zh: UI_zh, en: UI_en });
761
+ }, "dsh-notify-me: settings dictionaries");
762
+ var t = ctx.locale.bind(NS);
763
+ ctx.slots.inject("settings.section", function () {
764
+ return ctx.slots.register({
765
+ name: "settings.section",
766
+ id: "dsh-notify-me",
767
+ order: 45,
768
+ label: function () { return t("nav"); },
769
+ locale: NS,
770
+ inject: function () { return { t: t }; }
771
+ }, function (ownerProps) {
772
+ return React.createElement(NotifySettingsView, { ctx: ctx, t: t });
773
+ });
774
+ });
775
+ }
776
+ } catch (e) {
777
+ try { console.error("[dsh-notify-me] settings page unavailable", e); } catch (e2) {}
778
+ }
779
+ ctx.effect(function () {
780
+ var disposed = false;
781
+ var unsubs = [];
782
+ var retryTimer = null;
783
+ try {
784
+ var sessionsSvc = ctx.sessions;
785
+ if (!sessionsSvc || !sessionsSvc.list || typeof sessionsSvc.list.subscribe !== "function") {
786
+ try { console.log("[dsh-notify-me] sessions service unavailable — watchers idle"); } catch (e) {}
787
+ return;
788
+ }
789
+ listStore = sessionsSvc.list;
790
+ unsubs.push(sessionsSvc.list.subscribe(onListChanged));
791
+ // initial scan (baseline only; no alerts for pre-existing state)
792
+ onListChanged();
793
+ // binding may resolve a tick later; re-check periodically while idle
794
+ retryTimer = setInterval(function () {
795
+ if (disposed) return;
796
+ try { bindCurrentFace(); } catch (e) {}
797
+ }, 1500);
798
+ try { console.log("[dsh-notify-me] active — window.__dshNotifyMe 可配置提醒 (enabled=" + config.enabled + ", lang=" + effectiveLang() + ", toast=" + config.toast + ", sound=" + config.sound + ")"); } catch (e) {}
799
+ } catch (e) {
800
+ try { console.error("[dsh-notify-me] init failed", e); } catch (e2) {}
801
+ }
802
+ return function () {
803
+ disposed = true;
804
+ if (retryTimer) clearInterval(retryTimer);
805
+ for (var i = 0; i < unsubs.length; i++) { try { unsubs[i](); } catch (e) {} }
806
+ detachFace();
807
+ };
808
+ }, "dsh-notify-me: watchers");
809
+ }
810
+
811
+ exports.inject = inject;
812
+ exports.apply = apply;
813
+ return module.exports;
814
+ }
815
+ });