dsh-notify-me 1.1.5 → 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.
package/lib/client.js CHANGED
@@ -1,999 +1,1224 @@
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 two services, because pending interactions and the running bit
14
- // have different owners on current hosts:
15
- // * "uiSession" -> pendingInteractions (a Map<sessionId, interaction>
16
- // snapshot whose values carry kind 'approval' | 'plan-review' |
17
- // 'question'). Present since DSH 0.1.2-alpha.2; the controller
18
- // snapshots stopped carrying `pending` in the same release, so this is
19
- // the only live source of "the agent is waiting on you" on 0.1.2+;
20
- // * "sessions" -> the selected session's ConversationSnapshot (running)
21
- // and the session-list summaries of every other session (running /
22
- // completed), which carry the "reply finished" edge on every version;
23
- // - fires a Windows desktop Notification (+ optional sound) when the agent
24
- // needs the user's input — pending interaction kinds 'approval',
25
- // 'plan-review' or 'question' — and when a reply/turn finishes;
26
- // - registers a "通知提醒 / Notify me" page into DSH Settings
27
- // (settings.section slot) exposing an on/off master switch, per-channel
28
- // toggles, volume and the notification language (follow-interface /
29
- // Simplified Chinese / English);
30
- // - preferences persist in localStorage (origin-scoped, like other
31
- // third-party client plugins) and are tweakable on window.__dshNotifyMe.
32
-
33
- window.__ModuleLoader__.load({
34
- id: "dsh-notify-me",
35
- factory: (require) => {
36
- var module = { exports: {} };
37
- var exports = module.exports;
38
- Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
39
-
40
- // react is externalized into the loader module table; never required by
41
- // the reminder core, only by the optional Settings page.
42
- var React = null;
43
- try { React = require("react") || null; } catch (e) { React = null; }
44
-
45
- // ───────────────────────── configuration ─────────────────────────
46
- var LS_CONFIG = "dshNotifyMe.config";
47
- var DEFAULTS = {
48
- enabled: true, // master switch: off = no toast/sound/title marker at all
49
- // language of the notification copy: 'auto' follows the DSH UI locale,
50
- // 'zh' and 'en' pin Simplified Chinese / English.
51
- language: "auto",
52
- // toast + sound are only useful while the user is elsewhere; the tab
53
- // being hidden is the reliable "away" signal.
54
- attentionHiddenOnly: false, // needs-input alerts even while the page is visible
55
- doneHiddenOnly: true, // reply-finished alerts only when the page is hidden
56
- toast: true,
57
- sound: true,
58
- volume: 0.5,
59
- autoFocus: true // clicking the toast focuses the DSH window
60
- };
61
- function readConfig() {
62
- var cfg = {};
63
- try {
64
- var raw = localStorage.getItem(LS_CONFIG);
65
- if (raw) cfg = JSON.parse(raw);
66
- } catch (e) { /* corrupt storage -> defaults */ }
67
- for (var k in DEFAULTS) if (!(k in cfg)) cfg[k] = DEFAULTS[k];
68
- return cfg;
69
- }
70
- function writeConfig(patch) {
71
- var cfg = readConfig();
72
- for (var k in patch) cfg[k] = patch[k];
73
- try { localStorage.setItem(LS_CONFIG, JSON.stringify(cfg)); } catch (e) {}
74
- return cfg;
75
- }
76
- var config = readConfig();
77
- // Persist a patch, swap the live module config and re-apply any active
78
- // title marker (its prefix follows the notification language).
79
- function setConfigLocal(patch) {
80
- config = writeConfig(patch);
81
- applyTitle();
82
- return config;
83
- }
84
-
85
- // ───────────────────────── copy tables ─────────────────────────
86
- // Notification copy, keyed by language id. Selected through
87
- // effectiveLang() so a pinned language stays pinned even while the DSH UI
88
- // speaks the other one.
89
- var TXT = {
90
- zh: {
91
- kind: { approval: "审批请求", "plan-review": "方案待确认", question: "提问" },
92
- kindDefault: "需要操作",
93
- bodyKind: { approval: "有审批待处理", "plan-review": "方案待确认", question: "有提问待回答" },
94
- bodyDefault: "需要你操作",
95
- current: "当前对话",
96
- doneTitle: "DSH · 回复完成",
97
- doneBody: "回复已完成",
98
- background: "后台会话",
99
- marker: "🔔 需要你 · ",
100
- sep: ":",
101
- testAttention: "这是一条测试提醒:DSH 需要你的操作",
102
- testDone: "测试提醒 · 回复完成示例"
103
- },
104
- en: {
105
- kind: { approval: "Approval request", "plan-review": "Plan review", question: "Question" },
106
- kindDefault: "Action needed",
107
- bodyKind: {
108
- approval: "An approval is waiting for you",
109
- "plan-review": "A plan awaits your review",
110
- question: "A question is waiting for you"
111
- },
112
- bodyDefault: "Your input is needed",
113
- current: "Current conversation",
114
- doneTitle: "DSH · Reply finished",
115
- doneBody: "Reply finished",
116
- background: "Background session",
117
- marker: "🔔 Action needed · ",
118
- sep: ": ",
119
- testAttention: "This is a test alert: DSH is waiting for your input",
120
- testDone: "Test alert · reply-finished sample"
121
- }
122
- };
123
- // Locale service handle (registered on the client context by dsh-client-locale).
124
- var localeSvc = null;
125
- // Current DSH UI language: 'zh' | 'en' | null (unknown). Used by the
126
- // 'auto' notification-language mode.
127
- function currentUILang() {
128
- try {
129
- if (localeSvc && typeof localeSvc.getLocale === "function") {
130
- var a = localeSvc.getLocale().active;
131
- if (typeof a === "string" && a) {
132
- if (a.indexOf("zh") === 0) return "zh";
133
- if (a.indexOf("en") === 0) return "en";
134
- }
135
- }
136
- } catch (e) {}
137
- try {
138
- var dl = (document.documentElement && document.documentElement.lang) || "";
139
- if (dl.indexOf("zh") === 0) return "zh";
140
- if (dl.indexOf("en") === 0) return "en";
141
- } catch (e) {}
142
- return null;
143
- }
144
- function effectiveLang() {
145
- if (!config.language || config.language === "auto") return currentUILang() || "zh";
146
- return config.language === "en" ? "en" : "zh";
147
- }
148
- function texts() { return TXT[effectiveLang()] || TXT.zh; }
149
-
150
- // ─────────────────────── notification plumbing ───────────────────
151
- var permAsked = false;
152
- function requestPermission() {
153
- if (!("Notification" in window) || permAsked) return;
154
- permAsked = true;
155
- try {
156
- if (Notification.permission === "default") {
157
- Notification.requestPermission().catch(function () {});
158
- }
159
- } catch (e) {}
160
- }
161
- function canToast() {
162
- return config.toast && "Notification" in window && Notification.permission === "granted";
163
- }
164
- function showToast(kind, title, body) {
165
- if (!canToast()) return;
166
- try {
167
- var n = new Notification(title, {
168
- body: body || "",
169
- tag: "dsh-notify-me-" + kind,
170
- renotify: true,
171
- icon: (location.origin || "") + "/favicon.svg"
172
- });
173
- if (config.autoFocus) {
174
- n.onclick = function () {
175
- try { window.focus(); } catch (e) {}
176
- n.close();
177
- };
178
- }
179
- setTimeout(function () { n.close(); }, 15000);
180
- } catch (e) {}
181
- }
182
- // Sound: WebAudio beeps (no asset needed). The context only runs after a
183
- // user gesture; we unlock it on the first pointer/key event.
184
- var audioCtx = null;
185
- function unlockAudio() {
186
- try {
187
- if (!audioCtx) {
188
- var AC = window.AudioContext || window.webkitAudioContext;
189
- if (!AC) return;
190
- audioCtx = new AC();
191
- }
192
- if (audioCtx.state === "suspended") audioCtx.resume().catch(function () {});
193
- } catch (e) {}
194
- }
195
- function tone(freq, startIn, durMs, vol) {
196
- if (!audioCtx || audioCtx.state !== "running") return;
197
- try {
198
- var t0 = audioCtx.currentTime + startIn;
199
- var osc = audioCtx.createOscillator();
200
- var gain = audioCtx.createGain();
201
- osc.type = "sine";
202
- osc.frequency.value = freq;
203
- gain.gain.setValueAtTime(0.0001, t0);
204
- gain.gain.exponentialRampToValueAtTime(vol, t0 + 0.02);
205
- gain.gain.exponentialRampToValueAtTime(0.0001, t0 + durMs / 1000);
206
- osc.connect(gain).connect(audioCtx.destination);
207
- osc.start(t0);
208
- osc.stop(t0 + durMs / 1000 + 0.05);
209
- } catch (e) {}
210
- }
211
- function playAttentionSound() {
212
- if (!config.sound) return;
213
- unlockAudio();
214
- tone(880, 0.0, 140, config.volume);
215
- tone(1174, 0.16, 140, config.volume);
216
- tone(1568, 0.32, 200, config.volume);
217
- }
218
- function playDoneSound() {
219
- if (!config.sound) return;
220
- unlockAudio();
221
- tone(659, 0.0, 150, config.volume);
222
- tone(988, 0.18, 260, config.volume);
223
- }
224
- function unlockOnce() {
225
- unlockAudio();
226
- requestPermission();
227
- }
228
- function onFirstGesture() {
229
- unlockOnce();
230
- window.removeEventListener("pointerdown", onFirstGesture, true);
231
- window.removeEventListener("keydown", onFirstGesture, true);
232
- }
233
- window.addEventListener("pointerdown", onFirstGesture, true);
234
- window.addEventListener("keydown", onFirstGesture, true);
235
-
236
- // ────────────────────────── title marker ─────────────────────────
237
- // The host owns document.title (it tracks the session/workspace name), so
238
- // the base title is re-read on every apply instead of being frozen once —
239
- // and the exact string we last wrote is remembered, so the host taking the
240
- // title over (or an earlier load leaving a marker behind) can never make us
241
- // mistake our own marker for the base title.
242
- var attentionActive = 0;
243
- var hostBase = null; // document.title as the host last left it
244
- var writtenTitle = null; // exact string this plugin last put into document.title
245
- // Our own marker prefix never belongs to the base title — strip a leftover
246
- // one (an earlier load that was closed while a wait was pending, or a title
247
- // the host tracked while a marker was up) before storing the base.
248
- function cleanBase(t) {
249
- var d = texts();
250
- var prefixes = [d.marker, TXT.zh.marker, TXT.en.marker];
251
- for (var i = 0; i < prefixes.length; i++) {
252
- while (t.indexOf(prefixes[i]) === 0) t = t.slice(prefixes[i].length);
253
- }
254
- return t;
255
- }
256
- function applyTitle() {
257
- try {
258
- var current = String(document.title || "");
259
- if (current !== writtenTitle) hostBase = cleanBase(current);
260
- var next = (config.enabled && attentionActive > 0) ? texts().marker + hostBase : hostBase;
261
- writtenTitle = next;
262
- if (current !== next) document.title = next;
263
- } catch (e) {}
264
- }
265
- function acquireTitleMark() {
266
- attentionActive += 1;
267
- applyTitle();
268
- }
269
- function releaseTitleMark() {
270
- if (attentionActive > 0) attentionActive -= 1;
271
- applyTitle();
272
- }
273
- // Title marker ownership: on hosts that publish pending interactions
274
- // (uiSession) that source owns the marker, but whichever source saw the wait
275
- // first must still mark it (the other one may never report that key at all),
276
- // so the mark is acquired by the first reporter and released only by its
277
- // owner. Without this, the two sources would either double-mark or never
278
- // clear the mark.
279
- function markAttention(key) {
280
- if (attentionKeysMarked[key]) return;
281
- attentionKeysMarked[key] = true;
282
- acquireTitleMark();
283
- }
284
- function unmarkAttention(key) {
285
- if (!attentionKeysMarked[key]) return;
286
- delete attentionKeysMarked[key];
287
- releaseTitleMark();
288
- }
289
-
290
- // ─────────────────────── shared alert core ───────────────────────
291
- var attentionKeysFired = Object.create(null); // dedupe per pending key
292
- var attentionKeysMarked = Object.create(null); // keys currently holding a title mark
293
- var lastAttentionAt = 0;
294
-
295
- function hidden() { return document.hidden || document.visibilityState === "hidden"; }
296
-
297
- // Deliver one alert regardless of visibility rules (used by the test
298
- // buttons); respects the master switch and permission state. The title
299
- // marker is NOT touched here — its ownership belongs to the watchers
300
- // (markAttention / unmarkAttention), which keeps the counter balanced.
301
- function deliver(kind, title, body) {
302
- if (!config.enabled) return;
303
- showToast(kind, title, body);
304
- if (kind === "attention") {
305
- playAttentionSound();
306
- } else {
307
- playDoneSound();
308
- }
309
- try {
310
- if (window.__dshNotifyMe && window.__dshNotifyMe.onEvent) {
311
- window.__dshNotifyMe.onEvent(kind, { title: title, body: body });
312
- }
313
- } catch (e) {}
314
- }
315
- function fire(kind, title, body) {
316
- if (!config.enabled) return;
317
- if (kind === "attention" && config.attentionHiddenOnly && !hidden()) return;
318
- if (kind === "done" && config.doneHiddenOnly && !hidden()) return;
319
- deliver(kind, title, body);
320
- }
321
- // A display label for a session row ("current" maps to the localized
322
- // "当前对话 / Current conversation" placeholder).
323
- function sessionLabelText(row) {
324
- var raw = row && row.label;
325
- if (!raw || raw === "current") return texts().current;
326
- return raw;
327
- }
328
- // Display label for a session id we only know from the interaction map:
329
- // the list row's title, the localized "current" placeholder for the selected
330
- // session, the raw id as a last resort.
331
- function labelForSessionId(sid) {
332
- try {
333
- var st = listStore && listStore.getSnapshot();
334
- if (st) {
335
- var sum = st.byId && st.byId[sid];
336
- if (sum) return sessionLabelText({ label: sum.displayTitle || sum.title || "" });
337
- if (st.current === sid) return texts().current;
338
- }
339
- } catch (e) {}
340
- return sid;
341
- }
342
- function fireAttention(kind, sessionLabel, detail) {
343
- var d = texts();
344
- var label = d.kind[kind] || d.kindDefault;
345
- var content = detail || d.bodyKind[kind] || d.bodyDefault;
346
- var who = sessionLabel || "";
347
- fire("attention", "DSH · " + label, (who ? who + d.sep : "") + content);
348
- }
349
- function fireDone(sessionLabel, snippet) {
350
- var d = texts();
351
- var body = sessionLabel || d.doneBody;
352
- if (snippet) body = body + "\n" + snippet;
353
- fire("done", d.doneTitle, body);
354
- }
355
- // Test alerts deliberately ignore the visibility rules (attentionHiddenOnly
356
- // / doneHiddenOnly): the whole point of a test button is to prove the
357
- // channel works while you are looking at the page. Only the master switch
358
- // (and browser notification permission) can stop them. The attention test
359
- // also drops its title marker again shortly after, so a test never leaves a
360
- // standing "waiting on you" mark in the tab.
361
- var TEST_MARK_RELEASE_MS = 6000;
362
- var TEST_MARK_KEY = "test:attention";
363
- function testAlert(kind) {
364
- var d = texts();
365
- if (kind === "attention") {
366
- deliver("attention", "DSH · " + d.kind.question, d.testAttention);
367
- markAttention(TEST_MARK_KEY);
368
- setTimeout(function () { unmarkAttention(TEST_MARK_KEY); }, TEST_MARK_RELEASE_MS);
369
- } else {
370
- deliver("done", d.doneTitle, d.testDone);
371
- }
372
- }
373
-
374
- // ───────────────────────── state machine ─────────────────────────
375
- // prevById: per-session-id baseline of what we have already reported.
376
- // { running: bool, pendingKeys: string[] , doneArmed: bool, seeded: bool }
377
- var prevById = Object.create(null);
378
- var faceSub = null; // current session face unsubscribe
379
- var currentFaceId = null;
380
- var listStore = null;
381
- var rootCtx = null; // client cordis context, captured at apply()
382
- // uiSession pending-interaction watcher (DSH >= 0.1.2-alpha.2)
383
- var uiPending = false; // true once the interaction snapshot is readable
384
- var uiSub = null;
385
- var uiBaseline = Object.create(null); // sessionId -> { key: string }
386
-
387
- // One-line detail for a pending interaction. Two shapes reach this:
388
- // * the pre-0.1.2 controller payload (approval/question requested frames)
389
- // — flat, its text under `text`/`question`/…;
390
- // * the 0.1.2+ uiSession interaction objects — class instances whose own
391
- // fields are the detail (PendingApproval: toolName/reason/callId,
392
- // PendingQuestion: questions[]).
393
- function textFromPayload(payload) {
394
- if (!payload || typeof payload !== "object") return "";
395
- function clip(s) {
396
- s = String(s).replace(/\s+/g, " ").trim();
397
- if (!s) return "";
398
- return s.length > 160 ? s.slice(0, 160) + "…" : s;
399
- }
400
- // scalar fields (covers the flat pre-0.1.2 payloads and PendingApproval)
401
- var keys = ["text", "question", "prompt", "title", "summary", "description", "message", "operation", "toolName", "reason", "name", "tool"];
402
- for (var i = 0; i < keys.length; i++) {
403
- var v = payload[keys[i]];
404
- if (typeof v === "string") {
405
- var s = clip(v);
406
- if (s) {
407
- // PendingApproval carries both the tool and the human-readable
408
- // reason — "pwsh" alone says nothing useful in a toast.
409
- if (keys[i] === "toolName" && typeof payload.reason === "string" && clip(payload.reason)) {
410
- return s + " · " + clip(payload.reason);
411
- }
412
- return s;
413
- }
414
- } else if (v && typeof v === "object" && typeof v.text === "string" && clip(v.text)) {
415
- return clip(v.text);
416
- }
417
- }
418
- // nested batches (PendingQuestion.questions -> { question, detail, options })
419
- var qs = payload.questions;
420
- if (Array.isArray(qs) && qs.length > 0) {
421
- var q = qs[0];
422
- if (typeof q === "string") return clip(q);
423
- if (q && typeof q === "object") {
424
- var qk = ["question", "title", "text", "detail"];
425
- for (var j = 0; j < qk.length; j++) {
426
- if (typeof q[qk[j]] === "string" && clip(q[qk[j]])) return clip(q[qk[j]]);
427
- }
428
- }
429
- }
430
- return "";
431
- }
432
- function snippetOfLastAssistant(snap) {
433
- try {
434
- var nodes = snap.nodes || [];
435
- for (var i = nodes.length - 1; i >= 0; i--) {
436
- var nd = nodes[i];
437
- if (nd && nd.kind === "assistant" && nd.blocks) {
438
- for (var b = nd.blocks.length - 1; b >= 0; b--) {
439
- if (nd.blocks[b] && nd.blocks[b].kind === "text" && nd.blocks[b].text) {
440
- var t = nd.blocks[b].text.replace(/\s+/g, " ").trim();
441
- if (t) return t.length > 80 ? t.slice(0, 80) + "…" : t;
442
- }
443
- }
444
- }
445
- }
446
- } catch (e) {}
447
- return "";
448
- }
449
- // Evaluate one row/face observation. row: {id, running, pending:[{key,kind,payload}], completed, label, face:bool}
450
- function evaluateRow(row) {
451
- var prev = prevById[row.id];
452
- var runningNow = !!row.running;
453
- var pendingNow = row.pending || [];
454
- var prevPendingKeys = prev ? prev.pendingKeys : [];
455
- var wasRunning = prev ? prev.running : false;
456
- var newKeys = [];
457
- for (var i = 0; i < pendingNow.length; i++) {
458
- var pk = pendingNow[i].key;
459
- if (prevPendingKeys.indexOf(pk) === -1 && !attentionKeysFired[pk]) {
460
- newKeys.push(pendingNow[i]);
461
- attentionKeysFired[pk] = true;
462
- }
463
- }
464
- var hasPendingNow = pendingNow.length > 0;
465
-
466
- // 1) a pending interaction just appeared -> the agent is waiting on the user.
467
- // First evaluation of a session also lands here (prevPendingKeys empty),
468
- // so a wait that already existed when the plugin (or page) mounted still
469
- // alerts once.
470
- // On hosts that expose uiSession this is the fallback path only: the
471
- // interaction snapshot there is the authoritative source, and it drives
472
- // the title marker itself (see the uiSession watcher below).
473
- if (newKeys.length > 0) {
474
- var it = newKeys[0];
475
- fireAttention(it.kind, sessionLabelText(row), textFromPayload(it.payload));
476
- lastAttentionAt = Date.now();
477
- markAttention(it.key);
478
- }
479
-
480
- // 2) a non-selected session finished while we were away (sidebar "done")
481
- var notified = false;
482
- if (!row.face && row.completed && prev && !prev.completed && prev.seeded) {
483
- fireDone(sessionLabelText(row), "");
484
- notified = true;
485
- }
486
-
487
- // 3) running true -> false on a session we were watching: a reply finished
488
- if (!notified && prev && prev.seeded && wasRunning && !runningNow && !hasPendingNow) {
489
- // ignore the edge if it happened in the same tick as an attention alert
490
- if (Date.now() - lastAttentionAt < 300) {
491
- /* answered by an attention alert above */
492
- } else {
493
- fireDone(sessionLabelText(row), row.face ? snippetOfLastAssistant(row.faceSnap) : "");
494
- }
495
- }
496
-
497
- // store the next baseline
498
- prevById[row.id] = {
499
- running: runningNow,
500
- pendingKeys: pendingNow.map(function (p) { return p.key; }),
501
- completed: !!row.completed,
502
- seeded: true
503
- };
504
-
505
- // a wait we alerted about has been resolved -> drop the title marker
506
- if (!uiPending && prev && prevPendingKeys.length > 0 && pendingNow.length === 0) {
507
- for (var r = 0; r < prevPendingKeys.length; r++) unmarkAttention(prevPendingKeys[r]);
508
- }
509
- }
510
-
511
- // face -> row adapter (selected session, rich data)
512
- function evalSessionFace(sessionId, snap) {
513
- if (sessionId !== currentFaceId) return;
514
- var pend = (snap.pending || []).map(function (p) {
515
- return { key: p.key || (p.kind + ":" + sessionId), kind: p.kind, payload: p.payload || null };
516
- });
517
- evaluateRow({
518
- id: sessionId,
519
- running: !!snap.running,
520
- pending: pend,
521
- completed: false,
522
- face: true,
523
- faceSnap: snap,
524
- label: "current"
525
- });
526
- }
527
- // list row -> adapter (every other listed session)
528
- function evalListRow(sid, sum, currentId) {
529
- evaluateRow({
530
- id: sid,
531
- running: !!sum.running,
532
- pending: sum.pendingInteraction ? [{ key: "list:" + sid + ":" + sum.pendingInteraction, kind: sum.pendingInteraction, payload: null }] : [],
533
- completed: !!sum.completed,
534
- face: false,
535
- label: sum.displayTitle || sum.title || sid
536
- });
537
- }
538
-
539
- // ─────────────────────── subscription wiring ─────────────────────
540
- function bindCurrentFace() {
541
- if (!listStore) return;
542
- try {
543
- var st = listStore.getSnapshot();
544
- var cur = st && st.current;
545
- if (!cur) { detachFace(); return; }
546
- if (cur === currentFaceId && faceSub) return;
547
- detachFace();
548
- currentFaceId = cur;
549
- var b = rootCtx.sessions.binding(cur);
550
- if (!b || !b.session || typeof b.session.subscribe !== "function") {
551
- // binding not minted yet — the retry timer will re-check
552
- return;
553
- }
554
- faceSub = b.session.subscribe(function () {
555
- try {
556
- evalSessionFace(cur, b.session.getSnapshot());
557
- } catch (e) {}
558
- });
559
- // First evaluation seeds the baseline (no false completion edges) while
560
- // still alerting for interactions that were already pending on bind.
561
- try {
562
- evalSessionFace(cur, b.session.getSnapshot());
563
- } catch (e) {}
564
- } catch (e) {}
565
- }
566
- function detachFace() {
567
- if (faceSub) { try { faceSub(); } catch (e) {} faceSub = null; }
568
- currentFaceId = null;
569
- }
570
- function onListChanged() {
571
- if (!listStore) return;
572
- try {
573
- var st = listStore.getSnapshot();
574
- var cur = st && st.current;
575
- bindCurrentFace();
576
- bindUiSession();
577
- var byId = (st && st.byId) || {};
578
- for (var sid in byId) {
579
- if (sid === cur) continue; // current handled by the face watcher
580
- var sum = byId[sid];
581
- if (!sum) continue;
582
- evalListRow(sid, sum, cur);
583
- }
584
- // sessions that vanished: keep baseline (they may reappear) - no action.
585
- } catch (e) {}
586
- }
587
-
588
- // ─────────────── uiSession pending-interaction watcher ───────────
589
- // DSH >= 0.1.2-alpha.2 moved pending interactions out of the Controller
590
- // snapshots into their own store (uiSession.pendingInteractions), a
591
- // Map<sessionId, interaction> whose values carry kind + key + sessionId and
592
- // the domain's own detail fields. It is the only live "the agent is waiting
593
- // on you" source on those hosts.
594
- //
595
- // The service is looked up lazily rather than declared in `inject`:
596
- // a cordis `inject` entry is a hard gate (the entry never activates while
597
- // the service is missing, and a never-activated entry makes the whole web
598
- // boot audit fail), while hosts older than 0.1.2-alpha.2 have no uiSession
599
- // at all and must keep working through the controller path above.
600
- function bindUiSession() {
601
- if (uiSub) return;
602
- var svc = null;
603
- try { svc = rootCtx && rootCtx.uiSession; } catch (e) { svc = null; }
604
- var store = svc && svc.pendingInteractions;
605
- if (!store || typeof store.getSnapshot !== "function" || typeof store.subscribe !== "function") return;
606
- uiPending = true; // this watcher now owns the title marker
607
- uiSub = store.subscribe(function () { try { evalUiPending(); } catch (e) {} });
608
- evalUiPending(); // seed: a wait that predates this plugin still alerts once
609
- }
610
- function evalUiPending() {
611
- var svc = null;
612
- try { svc = rootCtx && rootCtx.uiSession; } catch (e) { svc = null; }
613
- var store = svc && svc.pendingInteractions;
614
- if (!store || typeof store.getSnapshot !== "function") return;
615
- var snap = store.getSnapshot(); // Map<sessionId, interaction>
616
- if (!snap || typeof snap.forEach !== "function") return;
617
- var seen = Object.create(null);
618
- snap.forEach(function (it, sid) {
619
- if (!it) return;
620
- seen[sid] = true;
621
- var key = it.key || (it.kind + ":" + sid);
622
- var prev = uiBaseline[sid];
623
- // new interactions alert once; a wait that already existed on bind
624
- // (no baseline yet for that session) alerts here too.
625
- if (prev && prev.key === key) return;
626
- uiBaseline[sid] = { key: key };
627
- // a wait replaced by a newer one in the same session: the old mark goes
628
- // with it, otherwise the tab would keep one stale marker per replacement
629
- if (prev) unmarkAttention(prev.key);
630
- if (attentionKeysFired[key]) { markAttention(key); return; } // already alerted, still needs its mark
631
- attentionKeysFired[key] = true;
632
- fireAttention(it.kind, labelForSessionId(sid), textFromPayload(it));
633
- lastAttentionAt = Date.now();
634
- markAttention(key);
635
- });
636
- for (var sid2 in uiBaseline) {
637
- if (seen[sid2]) continue;
638
- // interaction answered, cancelled, or its session went away
639
- unmarkAttention(uiBaseline[sid2].key);
640
- delete uiBaseline[sid2];
641
- }
642
- }
643
- function detachUiSession() {
644
- if (uiSub) { try { uiSub(); } catch (e) {} uiSub = null; }
645
- // drop every mark this watcher holds; hosts without the interaction store
646
- // go back to the controller marker path
647
- for (var s in uiBaseline) unmarkAttention(uiBaseline[s].key);
648
- uiBaseline = Object.create(null);
649
- uiPending = false;
650
- }
651
-
652
- // ──────────────────────── global control API ─────────────────────
653
- // Expose the control API. Merge instead of replace: a consumer (or a test
654
- // harness) may have registered onEvent before the plugin applied, and that
655
- // hook must survive — binding the interaction store can deliver an alert
656
- // synchronously, in the middle of apply().
657
- var api = window.__dshNotifyMe;
658
- if (!api || typeof api !== "object") api = {};
659
- try {
660
- Object.defineProperty(api, "config", {
661
- get: function () { return readConfig(); },
662
- configurable: true
663
- });
664
- } catch (e) {}
665
- // keep in sync with package.json "version" on every release
666
- api.version = "1.1.5";
667
- api.setConfig = function (patch) { return setConfigLocal(patch); };
668
- api.resetConfig = function () {
669
- try { localStorage.removeItem(LS_CONFIG); } catch (e) {}
670
- config = readConfig();
671
- return config;
672
- };
673
- // manual test; run it after clicking anywhere so permission can be granted.
674
- // Test alerts fire regardless of the page-open visibility rules.
675
- api.test = function (kind) {
676
- kind = kind === "attention" ? "attention" : "done";
677
- unlockOnce();
678
- testAlert(kind);
679
- return "sent " + kind + " (lang=" + effectiveLang() + ", permission=" + ("Notification" in window ? Notification.permission : "n/a") + ")";
680
- };
681
- window.__dshNotifyMe = api;
682
-
683
- // ────────────────── Settings page (react only, optional) ─────────
684
- // Registered when the web profile provides react + slots + locale; the
685
- // reminder core above never depends on any of it.
686
- var NS = "dsh-notify-me";
687
- var UI_zh = {
688
- nav: "通知提醒",
689
- blurb: "当模型停下来需要你操作(审批 / 方案待确认 / 提问),或回复在后台完成时,用系统通知、提示音和标签页标题提醒你。",
690
- enable: "启用提醒",
691
- enableHint: "总开关:关闭后不再弹系统通知、不播放提示音,也不再改动标签页标题。",
692
- toast: "系统通知",
693
- toastHint: "经浏览器弹出系统 Toast(Windows 通知中心);需要已授予通知权限。",
694
- sound: "提示音",
695
- soundHint: "WebAudio 合成音,「需要你」与「回复完成」使用不同音型。",
696
- attentionVisible: "页面打开时也提醒「需要你」",
697
- attentionVisibleHint: "默认开启:模型在等你审批 / 确认方案 / 回答问题(含提问弹窗)时,即使页面在最前也会提醒。",
698
- doneVisible: "页面打开时也提醒「回复完成」",
699
- doneVisibleHint: "默认关闭:「完成」只在页面隐藏或切到后台时才提醒,避免打扰。",
700
- autoFocus: "点击通知把 DSH 切回前台",
701
- volume: "音量",
702
- language: "通知语言",
703
- langAuto: "跟随界面",
704
- langZh: "简体中文",
705
- langEn: "English",
706
- langAutoNote: "(跟随界面)",
707
- current: "界面当前语言",
708
- testTitle: "快速测试",
709
- testHint: "用当前设置各发一条测试提醒(不受上面「页面打开时也提醒」开关限制)。",
710
- testAttention: "测试「需要你」",
711
- testDone: "测试「回复完成」",
712
- reset: "恢复默认设置",
713
- resetDone: "已恢复默认设置。",
714
- sent: "已发送测试提醒 · 通知语言:",
715
- permHint: "通知权限尚未授予:先在页面上点击一次并允许通知,系统通知才能弹出(提示音不受影响)。",
716
- offHint: "已关闭提醒:先打开「启用提醒」再测试。"
717
- };
718
- var UI_en = {
719
- nav: "Notify me",
720
- 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.",
721
- enable: "Enable reminders",
722
- enableHint: "Master switch: when off, no toasts, no sounds, and the tab title is never modified.",
723
- toast: "System notifications",
724
- toastHint: "Native toasts via the browser (Windows notification center); requires notification permission.",
725
- sound: "Sound",
726
- soundHint: "WebAudio beeps — \"needs you\" and \"reply finished\" use different patterns.",
727
- attentionVisible: "Also alert \"needs you\" while the page is open",
728
- attentionVisibleHint: "On by default: alert even in the foreground when the model waits for approval / plan review / a question.",
729
- doneVisible: "Also alert \"reply finished\" while the page is open",
730
- doneVisibleHint: "Off by default: \"done\" only alerts while the page is hidden or backgrounded, to avoid noise.",
731
- autoFocus: "Click a toast to focus the DSH window",
732
- volume: "Volume",
733
- language: "Notification language",
734
- langAuto: "Follow the interface",
735
- langZh: "简体中文",
736
- langEn: "English",
737
- langAutoNote: " (follows the interface)",
738
- current: "Current interface language",
739
- testTitle: "Quick test",
740
- testHint: "Send one test alert with the current settings (not limited by the \"while the page is open\" toggles above).",
741
- testAttention: "Test \"needs you\"",
742
- testDone: "Test \"reply finished\"",
743
- reset: "Restore defaults",
744
- resetDone: "Defaults restored.",
745
- sent: "Test alert sent · notification language: ",
746
- permHint: "Notification permission is not granted yet — click once on the page and allow notifications so toasts can appear (sound is unaffected).",
747
- offHint: "Reminders are off: enable them first, then test."
748
- };
749
- // Small element helper to keep the hand-written page readable.
750
- function h(tag, props) {
751
- var args = [tag, props || null];
752
- for (var i = 2; i < arguments.length; i++) args.push(arguments[i]);
753
- return React.createElement.apply(React, args);
754
- }
755
- // css classes (namespaced to avoid collisions)
756
- var UI_CSS = "" +
757
- ".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))}" +
758
- ".dnm-blurb{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
759
- ".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}" +
760
- ".dnm-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 14px;min-height:46px;box-sizing:border-box}" +
761
- ".dnm-row+.dnm-row{border-top:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35))}" +
762
- ".dnm-rowText{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}" +
763
- ".dnm-rowLabel{font-size:13px;font-weight:600;line-height:20px;color:var(--dsw-alias-label-primary,rgba(20,20,20,.9))}" +
764
- ".dnm-rowHint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
765
- ".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}" +
766
- ".dnm-switch.on{background:var(--dsw-alias-state-business-primary,#3b82f6);border-color:transparent}" +
767
- ".dnm-knob{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;transition:left .15s ease}" +
768
- ".dnm-switch.on .dnm-knob{left:16px}" +
769
- ".dnm-range{width:150px;accent-color:var(--dsw-alias-state-business-primary,#3b82f6)}" +
770
- ".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}" +
771
- ".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}" +
772
- ".dnm-btn:hover{background:var(--dsw-alias-bg-layer-1,rgba(128,128,128,.1))}" +
773
- ".dnm-btn[disabled]{opacity:.45;cursor:default}" +
774
- ".dnm-btnRow{display:flex;align-items:center;gap:10px;flex-wrap:wrap}" +
775
- ".dnm-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
776
- ".dnm-feedback{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9));min-height:18px}";
777
- (function injectStyle() {
778
- try {
779
- if (typeof document === "undefined" || typeof document.createElement !== "function") return;
780
- var tagId = "dsh-notify-me/settings";
781
- if (document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]")) return;
782
- var s = document.createElement("style");
783
- s.dataset.plugin = "dsh-notify-me";
784
- s.dataset.pluginCss = tagId;
785
- s.textContent = UI_CSS;
786
- (document.head || document.documentElement).appendChild(s);
787
- } catch (e) {}
788
- })();
789
-
790
- function rowSwitch(key, labelText, hintText, checked, onChange) {
791
- return h("div", { className: "dnm-row" }, [
792
- h("div", { className: "dnm-rowText" }, [
793
- h("div", { className: "dnm-rowLabel" }, labelText),
794
- hintText ? h("div", { className: "dnm-rowHint" }, hintText) : null
795
- ]),
796
- h("button", {
797
- type: "button",
798
- role: "switch",
799
- "aria-checked": checked ? "true" : "false",
800
- className: "dnm-switch" + (checked ? " on" : ""),
801
- onClick: function () { onChange(!checked); }
802
- }, h("span", { className: "dnm-knob" }))
803
- ]);
804
- }
805
- function card(children) {
806
- return h("div", { className: "dnm-card" }, children);
807
- }
808
-
809
- function NotifySettingsView(props) {
810
- var ctx = props.ctx;
811
- var t = props.t;
812
- // Keep the page copy in sync with the active DSH UI language.
813
- React.useSyncExternalStore(function (cb) { return ctx.locale.subscribe(cb); }, function () { return ctx.locale.getSnapshot(); });
814
- var cfgState = React.useState(function () { return readConfig(); });
815
- var cfg = cfgState[0];
816
- var setCfg = cfgState[1];
817
- var feedbackState = React.useState("");
818
- var feedback = feedbackState[0];
819
- var setFeedback = feedbackState[1];
820
- function patch(p) {
821
- setCfg(setConfigLocal(p));
822
- }
823
- function runTest(kind) {
824
- if (!config.enabled) { setFeedback(t("offHint")); return; }
825
- unlockOnce();
826
- // Bypasses the visibility rules on purpose: a test must fire while the
827
- // page is open, otherwise the button proves nothing.
828
- testAlert(kind);
829
- var langName = effectiveLang() === "en" ? t("langEn") : t("langZh");
830
- setFeedback(t("sent") + langName);
831
- }
832
- var permOK = ("Notification" in window) ? Notification.permission === "granted" : false;
833
- var resolvedLang = effectiveLang();
834
- var resolvedLangName = resolvedLang === "en" ? t("langEn") : t("langZh");
835
- return h("div", { className: "dnm-wrap" }, [
836
- h("p", { className: "dnm-blurb" }, t("blurb")),
837
- card([
838
- rowSwitch("enabled", t("enable"), t("enableHint"), cfg.enabled, function (v) {
839
- patch({ enabled: v });
840
- if (!v && attentionActive > 0) { attentionActive = 0; applyTitle(); }
841
- })
842
- ]),
843
- card([
844
- rowSwitch("toast", t("toast"), t("toastHint"), cfg.toast, function (v) { patch({ toast: v }); }),
845
- rowSwitch("sound", t("sound"), t("soundHint"), cfg.sound, function (v) { patch({ sound: v }); }),
846
- rowSwitch("attentionVisible", t("attentionVisible"), t("attentionVisibleHint"), !cfg.attentionHiddenOnly, function (v) { patch({ attentionHiddenOnly: !v }); }),
847
- rowSwitch("doneVisible", t("doneVisible"), t("doneVisibleHint"), !cfg.doneHiddenOnly, function (v) { patch({ doneHiddenOnly: !v }); }),
848
- rowSwitch("autoFocus", t("autoFocus"), null, cfg.autoFocus, function (v) { patch({ autoFocus: v }); }),
849
- h("div", { className: "dnm-row" }, [
850
- h("div", { className: "dnm-rowText" }, [
851
- h("div", { className: "dnm-rowLabel" }, t("volume")),
852
- h("div", { className: "dnm-rowHint" }, String(Math.round(cfg.volume * 100)) + "%")
853
- ]),
854
- h("input", {
855
- type: "range",
856
- className: "dnm-range",
857
- min: 0, max: 100, step: 5,
858
- value: Math.round(cfg.volume * 100),
859
- "aria-label": t("volume"),
860
- onChange: function (e) { patch({ volume: Number(e.currentTarget.value) / 100 }); }
861
- })
862
- ])
863
- ]),
864
- card([
865
- h("div", { className: "dnm-row" }, [
866
- h("div", { className: "dnm-rowText" }, [
867
- h("div", { className: "dnm-rowLabel" }, t("language")),
868
- h("div", { className: "dnm-rowHint" }, resolvedLangName + (cfg.language === "auto" ? t("langAutoNote") : ""))
869
- ]),
870
- h("select", {
871
- className: "dnm-select",
872
- value: cfg.language,
873
- "aria-label": t("language"),
874
- onChange: function (e) { patch({ language: e.currentTarget.value }); }
875
- }, [
876
- h("option", { value: "auto" }, t("langAuto")),
877
- h("option", { value: "zh" }, t("langZh")),
878
- h("option", { value: "en" }, t("langEn"))
879
- ])
880
- ]),
881
- h("div", { className: "dnm-row" }, [
882
- h("div", { className: "dnm-rowText" }, [
883
- h("div", { className: "dnm-rowLabel" }, t("testTitle")),
884
- h("div", { className: "dnm-rowHint" }, t("testHint"))
885
- ]),
886
- h("div", { className: "dnm-btnRow" }, [
887
- h("button", { type: "button", className: "dnm-btn", disabled: !cfg.enabled, onClick: function () { runTest("attention"); } }, t("testAttention")),
888
- h("button", { type: "button", className: "dnm-btn", disabled: !cfg.enabled, onClick: function () { runTest("done"); } }, t("testDone"))
889
- ])
890
- ]),
891
- h("div", { className: "dnm-row" }, [
892
- h("div", { className: "dnm-rowText" }, [
893
- h("div", { className: "dnm-rowLabel" }, t("reset")),
894
- h("div", { className: "dnm-rowHint" }, " ")
895
- ]),
896
- h("button", { type: "button", className: "dnm-btn", onClick: function () {
897
- try { localStorage.removeItem(LS_CONFIG); } catch (e) {}
898
- config = readConfig();
899
- applyTitle();
900
- setCfg(config);
901
- setFeedback(t("resetDone"));
902
- } }, t("reset"))
903
- ]),
904
- !permOK ? h("div", { className: "dnm-row" }, [
905
- h("div", { className: "dnm-rowText" }, [h("div", { className: "dnm-rowHint" }, t("permHint"))])
906
- ]) : null
907
- ]),
908
- h("p", { className: "dnm-feedback" }, feedback)
909
- ]);
910
- }
911
-
912
- // ────────────────────────── plugin body ──────────────────────────
913
- // Hard service requirements only. `uiSession` is deliberately NOT listed:
914
- // a cordis inject entry is a hard gate (the entry stays pending while the
915
- // service is missing, and the web boot audit fails on entries that never
916
- // activate), while hosts older than 0.1.2-alpha.2 have no uiSession at all.
917
- // It is therefore looked up lazily in bindUiSession(), with the controller
918
- // path above as the fallback. The bundle entry
919
- // "@deepseek-ai/dsh-client-ui-session" stays in package.json's
920
- // dsh.client.inject so the load order keeps the bundle in the graph.
921
- var inject = ["sessions", "locale", "slots"];
922
-
923
- /**
924
- * Client plugin body: watch the sessions service and raise desktop
925
- * notifications + sounds when the agent needs input or a reply finishes;
926
- * also contributes a Settings page (settings.section slot).
927
- * @param ctx - client root context.
928
- */
929
- function apply(ctx) {
930
- rootCtx = ctx;
931
- try {
932
- if (ctx.locale && typeof ctx.locale.getLocale === "function") localeSvc = ctx.locale;
933
- } catch (e) {}
934
- // Settings UI — only when the full web profile (react + slots + locale)
935
- // is present. The reminder core above never depends on this.
936
- try {
937
- if (React && React.createElement && ctx.slots && ctx.locale && typeof ctx.locale.register === "function" && typeof ctx.slots.inject === "function") {
938
- ctx.effect(function () {
939
- ctx.locale.register(NS, { zh: UI_zh, en: UI_en });
940
- }, "dsh-notify-me: settings dictionaries");
941
- var t = ctx.locale.bind(NS);
942
- ctx.slots.inject("settings.section", function () {
943
- return ctx.slots.register({
944
- name: "settings.section",
945
- id: "dsh-notify-me",
946
- order: 45,
947
- label: function () { return t("nav"); },
948
- locale: NS,
949
- inject: function () { return { t: t }; }
950
- }, function (ownerProps) {
951
- return React.createElement(NotifySettingsView, { ctx: ctx, t: t });
952
- });
953
- });
954
- }
955
- } catch (e) {
956
- try { console.error("[dsh-notify-me] settings page unavailable", e); } catch (e2) {}
957
- }
958
- ctx.effect(function () {
959
- var disposed = false;
960
- var unsubs = [];
961
- var retryTimer = null;
962
- try {
963
- var sessionsSvc = ctx.sessions;
964
- if (!sessionsSvc || !sessionsSvc.list || typeof sessionsSvc.list.subscribe !== "function") {
965
- try { console.log("[dsh-notify-me] sessions service unavailable — watchers idle"); } catch (e) {}
966
- return;
967
- }
968
- listStore = sessionsSvc.list;
969
- unsubs.push(sessionsSvc.list.subscribe(onListChanged));
970
- // Interaction source first: it owns the title marker once present, so
971
- // binding it before the controller scan keeps a single mark per wait.
972
- bindUiSession();
973
- // initial scan (baseline only; no alerts for pre-existing sessions)
974
- onListChanged();
975
- // binding may resolve a tick later; re-check periodically while idle
976
- retryTimer = setInterval(function () {
977
- if (disposed) return;
978
- try { bindCurrentFace(); } catch (e) {}
979
- try { bindUiSession(); } catch (e) {}
980
- }, 1500);
981
- try { console.log("[dsh-notify-me] active — window.__dshNotifyMe 可配置提醒 (enabled=" + config.enabled + ", lang=" + effectiveLang() + ", toast=" + config.toast + ", sound=" + config.sound + ")"); } catch (e) {}
982
- } catch (e) {
983
- try { console.error("[dsh-notify-me] init failed", e); } catch (e2) {}
984
- }
985
- return function () {
986
- disposed = true;
987
- if (retryTimer) clearInterval(retryTimer);
988
- for (var i = 0; i < unsubs.length; i++) { try { unsubs[i](); } catch (e) {} }
989
- detachFace();
990
- detachUiSession();
991
- };
992
- }, "dsh-notify-me: watchers");
993
- }
994
-
995
- exports.inject = inject;
996
- exports.apply = apply;
997
- return module.exports;
998
- }
999
- });
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 two services, because pending interactions and the running bit
14
+ // have different owners on current hosts:
15
+ // * "uiSession" -> the pending-interaction source, whose *store* changed
16
+ // shape once and is therefore read through one adapter:
17
+ // - DSH 0.1.2-alpha.2 .. 0.1.5-rc.3: `pendingInteractions`, a
18
+ // Map<sessionId, interaction> whose values carry kind
19
+ // 'approval' | 'plan-review' | 'question' (the controller snapshots
20
+ // stopped carrying `pending` in 0.1.2-alpha.2, so that store was the
21
+ // only live source back then);
22
+ // - DSH 0.1.6-alpha.2 and later (including 0.2.0-rc.2):
23
+ // `sessionStatus`, a Map<sessionId, {running, pendingInteraction,
24
+ // completionUnread}>; the old store is gone, so this is the only
25
+ // live source on those hosts.
26
+ // Both are resolved through ctx.get() — cordis throws on a plain
27
+ // ctx.uiSession read for any name absent from the plugin's inject map;
28
+ // * "sessions" -> the selected session's ConversationSnapshot (running)
29
+ // and the session-list summaries of every other session (running /
30
+ // completed), which carry the "reply finished" edge on every version.
31
+ // The selected ("current") session is read from the list snapshot's
32
+ // `current` field where it exists (<= 0.1.5-rc.3) and otherwise derived
33
+ // from the mainView retention count (0.1.6+, the way the shipped
34
+ // DocumentTitle / ui-session pick it);
35
+ // - fires a Windows desktop Notification (+ optional sound) when the agent
36
+ // needs the user's input — pending interaction kinds 'approval',
37
+ // 'plan-review' or 'question' — and when a reply/turn finishes;
38
+ // - registers a "通知提醒 / Notify me" page into DSH Settings
39
+ // (settings.section slot) exposing an on/off master switch, per-channel
40
+ // toggles, volume and the notification language (follow-interface /
41
+ // Simplified Chinese / English);
42
+ // - preferences persist in localStorage (origin-scoped, like other
43
+ // third-party client plugins) and are tweakable on window.__dshNotifyMe.
44
+
45
+ window.__ModuleLoader__.load({
46
+ id: "dsh-notify-me",
47
+ factory: (require) => {
48
+ var module = { exports: {} };
49
+ var exports = module.exports;
50
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
51
+
52
+ // react is externalized into the loader module table; never required by
53
+ // the reminder core, only by the optional Settings page.
54
+ var React = null;
55
+ try { React = require("react") || null; } catch (e) { React = null; }
56
+
57
+ // ───────────────────────── configuration ─────────────────────────
58
+ var LS_CONFIG = "dshNotifyMe.config";
59
+ var DEFAULTS = {
60
+ enabled: true, // master switch: off = no toast/sound/title marker at all
61
+ // language of the notification copy: 'auto' follows the DSH UI locale,
62
+ // 'zh' and 'en' pin Simplified Chinese / English.
63
+ language: "auto",
64
+ // toast + sound are only useful while the user is elsewhere; the tab
65
+ // being hidden is the reliable "away" signal.
66
+ attentionHiddenOnly: false, // needs-input alerts even while the page is visible
67
+ // A wait inside the conversation already on screen needs no toast: the
68
+ // approval card is right there, and a toast lands on top of it. Leaving
69
+ // the page brings that alert back (see flushQuieted).
70
+ currentHiddenOnly: true, // the visible session's own waits stay silent
71
+ doneHiddenOnly: true, // reply-finished alerts only when the page is hidden
72
+ toast: true,
73
+ sound: true,
74
+ volume: 0.5,
75
+ autoFocus: true // clicking the toast focuses the DSH window
76
+ };
77
+ function readConfig() {
78
+ var cfg = {};
79
+ try {
80
+ var raw = localStorage.getItem(LS_CONFIG);
81
+ if (raw) cfg = JSON.parse(raw);
82
+ } catch (e) { /* corrupt storage -> defaults */ }
83
+ for (var k in DEFAULTS) if (!(k in cfg)) cfg[k] = DEFAULTS[k];
84
+ return cfg;
85
+ }
86
+ function writeConfig(patch) {
87
+ var cfg = readConfig();
88
+ for (var k in patch) cfg[k] = patch[k];
89
+ try { localStorage.setItem(LS_CONFIG, JSON.stringify(cfg)); } catch (e) {}
90
+ return cfg;
91
+ }
92
+ var config = readConfig();
93
+ // Persist a patch, swap the live module config and re-apply any active
94
+ // title marker (its prefix follows the notification language).
95
+ function setConfigLocal(patch) {
96
+ config = writeConfig(patch);
97
+ applyTitle();
98
+ return config;
99
+ }
100
+
101
+ // ───────────────────────── copy tables ─────────────────────────
102
+ // Notification copy, keyed by language id. Selected through
103
+ // effectiveLang() so a pinned language stays pinned even while the DSH UI
104
+ // speaks the other one.
105
+ var TXT = {
106
+ zh: {
107
+ kind: { approval: "审批请求", "plan-review": "方案待确认", question: "提问" },
108
+ kindDefault: "需要操作",
109
+ bodyKind: { approval: "有审批待处理", "plan-review": "方案待确认", question: "有提问待回答" },
110
+ bodyDefault: "需要你操作",
111
+ current: "当前对话",
112
+ doneTitle: "DSH · 回复完成",
113
+ doneBody: "回复已完成",
114
+ background: "后台会话",
115
+ marker: "🔔 需要你 · ",
116
+ sep: ":",
117
+ testAttention: "这是一条测试提醒:DSH 需要你的操作",
118
+ testDone: "测试提醒 · 回复完成示例"
119
+ },
120
+ en: {
121
+ kind: { approval: "Approval request", "plan-review": "Plan review", question: "Question" },
122
+ kindDefault: "Action needed",
123
+ bodyKind: {
124
+ approval: "An approval is waiting for you",
125
+ "plan-review": "A plan awaits your review",
126
+ question: "A question is waiting for you"
127
+ },
128
+ bodyDefault: "Your input is needed",
129
+ current: "Current conversation",
130
+ doneTitle: "DSH · Reply finished",
131
+ doneBody: "Reply finished",
132
+ background: "Background session",
133
+ marker: "🔔 Action needed · ",
134
+ sep: ": ",
135
+ testAttention: "This is a test alert: DSH is waiting for your input",
136
+ testDone: "Test alert · reply-finished sample"
137
+ }
138
+ };
139
+ // Locale service handle (registered on the client context by dsh-client-locale).
140
+ var localeSvc = null;
141
+ // Current DSH UI language: 'zh' | 'en' | null (unknown). Used by the
142
+ // 'auto' notification-language mode.
143
+ function currentUILang() {
144
+ try {
145
+ if (localeSvc && typeof localeSvc.getLocale === "function") {
146
+ var a = localeSvc.getLocale().active;
147
+ if (typeof a === "string" && a) {
148
+ if (a.indexOf("zh") === 0) return "zh";
149
+ if (a.indexOf("en") === 0) return "en";
150
+ }
151
+ }
152
+ } catch (e) {}
153
+ try {
154
+ var dl = (document.documentElement && document.documentElement.lang) || "";
155
+ if (dl.indexOf("zh") === 0) return "zh";
156
+ if (dl.indexOf("en") === 0) return "en";
157
+ } catch (e) {}
158
+ return null;
159
+ }
160
+ function effectiveLang() {
161
+ if (!config.language || config.language === "auto") return currentUILang() || "zh";
162
+ return config.language === "en" ? "en" : "zh";
163
+ }
164
+ function texts() { return TXT[effectiveLang()] || TXT.zh; }
165
+
166
+ // ─────────────────────── notification plumbing ───────────────────
167
+ var permAsked = false;
168
+ function requestPermission() {
169
+ if (!("Notification" in window) || permAsked) return;
170
+ permAsked = true;
171
+ try {
172
+ if (Notification.permission === "default") {
173
+ Notification.requestPermission().catch(function () {});
174
+ }
175
+ } catch (e) {}
176
+ }
177
+ function canToast() {
178
+ if (!config.toast) return false;
179
+ try {
180
+ return "Notification" in window && Notification.permission === "granted";
181
+ } catch (e) { return false; } // a policy-disabled Notification must not break delivery
182
+ }
183
+ // Bring the window forward and make the alerted session the current one,
184
+ // so a click on the toast lands on the conversation that needs the user
185
+ // instead of just the app. Navigation moved with the host API: `sessions`
186
+ // exposed open(id) through 0.1.5-rc.3, and 0.1.6+ routes the same action
187
+ // through `uiWorkspace.openSession(id)`. Every failure here is non-fatal: a
188
+ // session that left the list, an older host without either verb, or a
189
+ // cordis service that cannot be resolved must still leave a usable window
190
+ // in front.
191
+ function focusSession(sessionId) {
192
+ try { window.focus(); } catch (e) {}
193
+ if (!sessionId) return;
194
+ try {
195
+ var st = listStore && listStore.getSnapshot();
196
+ if (st && st.byId && !st.byId[sessionId] && currentSessionId() !== sessionId) return;
197
+ var svc = lookupService("sessions");
198
+ if (svc && typeof svc.open === "function") { svc.open(sessionId); return; }
199
+ var ws = lookupService("uiWorkspace");
200
+ if (ws && typeof ws.openSession === "function") ws.openSession(sessionId);
201
+ } catch (e) {}
202
+ }
203
+ function showToast(kind, title, body, sessionId) {
204
+ if (!canToast()) return;
205
+ try {
206
+ var n = new Notification(title, {
207
+ body: body || "",
208
+ tag: "dsh-notify-me-" + kind,
209
+ renotify: true,
210
+ icon: (location.origin || "") + "/favicon.svg"
211
+ });
212
+ if (config.autoFocus) {
213
+ n.onclick = function () {
214
+ focusSession(sessionId);
215
+ n.close();
216
+ };
217
+ }
218
+ setTimeout(function () { n.close(); }, 15000);
219
+ } catch (e) {}
220
+ }
221
+ // Sound: WebAudio beeps (no asset needed). The context only runs after a
222
+ // user gesture; we unlock it on the first pointer/key event.
223
+ var audioCtx = null;
224
+ function unlockAudio() {
225
+ try {
226
+ if (!audioCtx) {
227
+ var AC = window.AudioContext || window.webkitAudioContext;
228
+ if (!AC) return;
229
+ audioCtx = new AC();
230
+ }
231
+ if (audioCtx.state === "suspended") audioCtx.resume().catch(function () {});
232
+ } catch (e) {}
233
+ }
234
+ function tone(freq, startIn, durMs, vol) {
235
+ if (!audioCtx || audioCtx.state !== "running") return;
236
+ try {
237
+ var t0 = audioCtx.currentTime + startIn;
238
+ var osc = audioCtx.createOscillator();
239
+ var gain = audioCtx.createGain();
240
+ osc.type = "sine";
241
+ osc.frequency.value = freq;
242
+ gain.gain.setValueAtTime(0.0001, t0);
243
+ gain.gain.exponentialRampToValueAtTime(vol, t0 + 0.02);
244
+ gain.gain.exponentialRampToValueAtTime(0.0001, t0 + durMs / 1000);
245
+ osc.connect(gain).connect(audioCtx.destination);
246
+ osc.start(t0);
247
+ osc.stop(t0 + durMs / 1000 + 0.05);
248
+ } catch (e) {}
249
+ }
250
+ function playAttentionSound() {
251
+ if (!config.sound) return;
252
+ unlockAudio();
253
+ tone(880, 0.0, 140, config.volume);
254
+ tone(1174, 0.16, 140, config.volume);
255
+ tone(1568, 0.32, 200, config.volume);
256
+ }
257
+ function playDoneSound() {
258
+ if (!config.sound) return;
259
+ unlockAudio();
260
+ tone(659, 0.0, 150, config.volume);
261
+ tone(988, 0.18, 260, config.volume);
262
+ }
263
+ function unlockOnce() {
264
+ unlockAudio();
265
+ requestPermission();
266
+ }
267
+ function onFirstGesture() {
268
+ unlockOnce();
269
+ window.removeEventListener("pointerdown", onFirstGesture, true);
270
+ window.removeEventListener("keydown", onFirstGesture, true);
271
+ }
272
+ window.addEventListener("pointerdown", onFirstGesture, true);
273
+ window.addEventListener("keydown", onFirstGesture, true);
274
+
275
+ // ────────────────────────── title marker ─────────────────────────
276
+ // The host owns document.title (it tracks the session/workspace name), so
277
+ // the base title is re-read on every apply instead of being frozen once —
278
+ // and the exact string we last wrote is remembered, so the host taking the
279
+ // title over (or an earlier load leaving a marker behind) can never make us
280
+ // mistake our own marker for the base title.
281
+ var attentionActive = 0;
282
+ var hostBase = null; // document.title as the host last left it
283
+ var writtenTitle = null; // exact string this plugin last put into document.title
284
+ // Our own marker prefix never belongs to the base title — strip a leftover
285
+ // one (an earlier load that was closed while a wait was pending, or a title
286
+ // the host tracked while a marker was up) before storing the base.
287
+ function cleanBase(t) {
288
+ var d = texts();
289
+ var prefixes = [d.marker, TXT.zh.marker, TXT.en.marker];
290
+ for (var i = 0; i < prefixes.length; i++) {
291
+ while (t.indexOf(prefixes[i]) === 0) t = t.slice(prefixes[i].length);
292
+ }
293
+ return t;
294
+ }
295
+ function applyTitle() {
296
+ try {
297
+ var current = String(document.title || "");
298
+ if (current !== writtenTitle) hostBase = cleanBase(current);
299
+ var next = (config.enabled && attentionActive > 0) ? texts().marker + hostBase : hostBase;
300
+ writtenTitle = next;
301
+ if (current !== next) document.title = next;
302
+ } catch (e) {}
303
+ }
304
+ function acquireTitleMark() {
305
+ attentionActive += 1;
306
+ applyTitle();
307
+ }
308
+ function releaseTitleMark() {
309
+ if (attentionActive > 0) attentionActive -= 1;
310
+ applyTitle();
311
+ }
312
+ // Title marker ownership: on hosts that publish pending interactions
313
+ // (uiSession) that source owns the marker, but whichever source saw the wait
314
+ // first must still mark it (the other one may never report that key at all),
315
+ // so the mark is acquired by the first reporter and released only by its
316
+ // owner. Without this, the two sources would either double-mark or never
317
+ // clear the mark.
318
+ function markAttention(key) {
319
+ if (attentionKeysMarked[key]) return;
320
+ attentionKeysMarked[key] = true;
321
+ acquireTitleMark();
322
+ }
323
+ function unmarkAttention(key) {
324
+ delete quietedAttention[key]; // the wait is over; a queued alert dies with it
325
+ if (!attentionKeysMarked[key]) return;
326
+ delete attentionKeysMarked[key];
327
+ releaseTitleMark();
328
+ }
329
+
330
+ // ─────────────────────── shared alert core ───────────────────────
331
+ var attentionKeysFired = Object.create(null); // dedupe per pending key
332
+ var attentionKeysMarked = Object.create(null); // keys currently holding a title mark
333
+ // Waits silenced because their session is the one on screen, keyed by
334
+ // interaction key so an entry dies with the wait it belongs to (the mark
335
+ // release path deletes it) and is re-delivered once the page is backgrounded.
336
+ var quietedAttention = Object.create(null);
337
+ var lastAttentionAt = 0;
338
+
339
+ function hidden() { return document.hidden || document.visibilityState === "hidden"; }
340
+
341
+ // Deliver one alert regardless of visibility rules (used by the test
342
+ // buttons); respects the master switch and permission state. The title
343
+ // marker is NOT touched here — its ownership belongs to the watchers
344
+ // (markAttention / unmarkAttention), which keeps the counter balanced.
345
+ function deliver(kind, title, body, sessionId) {
346
+ if (!config.enabled) return;
347
+ showToast(kind, title, body, sessionId);
348
+ if (kind === "attention") {
349
+ playAttentionSound();
350
+ } else {
351
+ playDoneSound();
352
+ }
353
+ try {
354
+ if (window.__dshNotifyMe && window.__dshNotifyMe.onEvent) {
355
+ window.__dshNotifyMe.onEvent(kind, { title: title, body: body, sessionId: sessionId || null });
356
+ }
357
+ } catch (e) {}
358
+ }
359
+ function fire(kind, title, body, isCurrent, sessionId) {
360
+ if (!config.enabled) return false;
361
+ if (kind === "attention" && config.attentionHiddenOnly && !hidden()) return false;
362
+ // The wait belongs to the conversation the user is looking at right now:
363
+ // the approval card is already on screen, so a toast would only cover the
364
+ // thing it is asking about. The caller keeps the tab marker, and
365
+ // flushQuieted() re-delivers the alert the moment the page is backgrounded.
366
+ if (kind === "attention" && quietedByVisibleSession(isCurrent)) return false;
367
+ if (kind === "done" && config.doneHiddenOnly && !hidden()) return false;
368
+ deliver(kind, title, body, sessionId);
369
+ return true;
370
+ }
371
+ // True when this alert must stay silent purely because it is about the
372
+ // session already on screen. Kept separate from fire() so the caller can
373
+ // queue exactly this case and nothing else.
374
+ function quietedByVisibleSession(isCurrent) {
375
+ return !!(isCurrent && config.currentHiddenOnly && !hidden());
376
+ }
377
+ // Queued copies of the alerts that rule suppressed. Delivered by key so a
378
+ // wait that got answered while the user was still on the page never fires.
379
+ function queueQuieted(key, copy) {
380
+ if (key && copy) quietedAttention[key] = copy;
381
+ }
382
+ function flushQuieted() {
383
+ if (!config.enabled || !hidden()) return;
384
+ var keys = Object.keys(quietedAttention);
385
+ for (var i = 0; i < keys.length; i++) {
386
+ var copy = quietedAttention[keys[i]];
387
+ delete quietedAttention[keys[i]];
388
+ fire("attention", copy.title, copy.body, false, copy.sessionId);
389
+ }
390
+ }
391
+ // A display label for a session row ("current" maps to the localized
392
+ // "当前对话 / Current conversation" placeholder).
393
+ function sessionLabelText(row) {
394
+ var raw = row && row.label;
395
+ if (!raw || raw === "current") return texts().current;
396
+ return raw;
397
+ }
398
+ // The session the user is looking at, or null while the list is not ready.
399
+ // Two host generations own this fact differently:
400
+ // * <= 0.1.5-rc.3: the list snapshot carried `current`;
401
+ // * 0.1.6+ (incl. 0.2.0-rc.2): `current` is gone and the main view's
402
+ // retention count marks the on-screen session — the shipped
403
+ // DocumentTitle, ui-workspace browser and ui-session isMain() all
404
+ // derive it exactly this way.
405
+ function currentSessionId() {
406
+ try {
407
+ var st = listStore && listStore.getSnapshot();
408
+ if (!st) return null;
409
+ if (typeof st.current === "string" && st.current) return st.current;
410
+ var byId = st.byId || {};
411
+ for (var id in byId) {
412
+ var row = byId[id];
413
+ if (row && row.retainedBy && (row.retainedBy.mainView || 0) > 0) return id;
414
+ }
415
+ } catch (e) {}
416
+ return null;
417
+ }
418
+ // Display label for a session id we only know from the interaction map:
419
+ // the list row's title, the localized "current" placeholder for the selected
420
+ // session, the raw id as a last resort.
421
+ function labelForSessionId(sid) {
422
+ try {
423
+ var st = listStore && listStore.getSnapshot();
424
+ if (st) {
425
+ var sum = st.byId && st.byId[sid];
426
+ if (sum) return sessionLabelText({ label: sum.displayTitle || sum.title || "" });
427
+ if (currentSessionId() === sid) return texts().current;
428
+ }
429
+ } catch (e) {}
430
+ return sid;
431
+ }
432
+ // Returns null once the alert went out, or the copy it would have shown
433
+ // when only the visible-session rule held it back — the caller queues that
434
+ // copy by interaction key so it can still fire from the background.
435
+ function fireAttention(kind, sessionLabel, detail, isCurrent, sessionId) {
436
+ var d = texts();
437
+ var label = d.kind[kind] || d.kindDefault;
438
+ var content = detail || d.bodyKind[kind] || d.bodyDefault;
439
+ var who = sessionLabel || "";
440
+ var title = "DSH · " + label;
441
+ var body = (who ? who + d.sep : "") + content;
442
+ if (quietedByVisibleSession(isCurrent)) return { title: title, body: body, sessionId: sessionId || null };
443
+ fire("attention", title, body, isCurrent, sessionId);
444
+ return null;
445
+ }
446
+ function fireDone(sessionLabel, snippet, sessionId) {
447
+ var d = texts();
448
+ var body = sessionLabel || d.doneBody;
449
+ if (snippet) body = body + "\n" + snippet;
450
+ fire("done", d.doneTitle, body, false, sessionId);
451
+ }
452
+ // Test alerts deliberately ignore the visibility rules (attentionHiddenOnly
453
+ // / doneHiddenOnly): the whole point of a test button is to prove the
454
+ // channel works while you are looking at the page. Only the master switch
455
+ // (and browser notification permission) can stop them. The attention test
456
+ // also drops its title marker again shortly after, so a test never leaves a
457
+ // standing "waiting on you" mark in the tab.
458
+ var TEST_MARK_RELEASE_MS = 6000;
459
+ var TEST_MARK_KEY = "test:attention";
460
+ function testAlert(kind) {
461
+ var d = texts();
462
+ if (kind === "attention") {
463
+ deliver("attention", "DSH · " + d.kind.question, d.testAttention);
464
+ markAttention(TEST_MARK_KEY);
465
+ setTimeout(function () { unmarkAttention(TEST_MARK_KEY); }, TEST_MARK_RELEASE_MS);
466
+ } else {
467
+ deliver("done", d.doneTitle, d.testDone);
468
+ }
469
+ }
470
+
471
+ // ───────────────────────── state machine ─────────────────────────
472
+ // prevById: per-session-id baseline of what we have already reported.
473
+ // { running: bool, pendingKeys: string[] , doneArmed: bool, seeded: bool }
474
+ var prevById = Object.create(null);
475
+ var faceSub = null; // current session face unsubscribe
476
+ var currentFaceId = null;
477
+ var listStore = null;
478
+ var rootCtx = null; // client cordis context, captured at apply()
479
+ // uiSession pending-interaction watcher (DSH >= 0.1.2-alpha.2)
480
+ var uiPending = false; // true once the interaction snapshot is readable
481
+ var uiSub = null;
482
+ var uiStore = null; // the interaction store we hold a subscription on
483
+ var uiSource = null; // 'sessionStatus' (0.1.6+) | 'pendingInteractions' (0.1.2-0.1.5)
484
+ var uiBindNote = "not attempted"; // last lookup outcome, read by __dshNotifyMe.debug()
485
+ var uiWarned = false; // one unreachable-service warning per page load
486
+ var uiBaseline = Object.create(null); // sessionId -> { key: string }
487
+
488
+ // One-line detail for a pending interaction. Two shapes reach this:
489
+ // * the pre-0.1.2 controller payload (approval/question requested frames)
490
+ // — flat, its text under `text`/`question`/…;
491
+ // * the 0.1.2+ uiSession interaction objects — class instances whose own
492
+ // fields are the detail (PendingApproval: toolName/reason/callId,
493
+ // PendingQuestion: questions[]).
494
+ function textFromPayload(payload) {
495
+ if (!payload || typeof payload !== "object") return "";
496
+ function clip(s) {
497
+ s = String(s).replace(/\s+/g, " ").trim();
498
+ if (!s) return "";
499
+ return s.length > 160 ? s.slice(0, 160) + "…" : s;
500
+ }
501
+ // scalar fields (covers the flat pre-0.1.2 payloads and PendingApproval)
502
+ var keys = ["text", "question", "prompt", "title", "summary", "description", "message", "operation", "toolName", "reason", "name", "tool"];
503
+ for (var i = 0; i < keys.length; i++) {
504
+ var v = payload[keys[i]];
505
+ if (typeof v === "string") {
506
+ var s = clip(v);
507
+ if (s) {
508
+ // PendingApproval carries both the tool and the human-readable
509
+ // reason — "pwsh" alone says nothing useful in a toast.
510
+ if (keys[i] === "toolName" && typeof payload.reason === "string" && clip(payload.reason)) {
511
+ return s + " · " + clip(payload.reason);
512
+ }
513
+ return s;
514
+ }
515
+ } else if (v && typeof v === "object" && typeof v.text === "string" && clip(v.text)) {
516
+ return clip(v.text);
517
+ }
518
+ }
519
+ // nested batches (PendingQuestion.questions -> { question, detail, options })
520
+ var qs = payload.questions;
521
+ if (Array.isArray(qs) && qs.length > 0) {
522
+ var q = qs[0];
523
+ if (typeof q === "string") return clip(q);
524
+ if (q && typeof q === "object") {
525
+ var qk = ["question", "title", "text", "detail"];
526
+ for (var j = 0; j < qk.length; j++) {
527
+ if (typeof q[qk[j]] === "string" && clip(q[qk[j]])) return clip(q[qk[j]]);
528
+ }
529
+ }
530
+ }
531
+ return "";
532
+ }
533
+ function snippetOfLastAssistant(snap) {
534
+ try {
535
+ var nodes = snap.nodes || [];
536
+ for (var i = nodes.length - 1; i >= 0; i--) {
537
+ var nd = nodes[i];
538
+ if (nd && nd.kind === "assistant" && nd.blocks) {
539
+ for (var b = nd.blocks.length - 1; b >= 0; b--) {
540
+ if (nd.blocks[b] && nd.blocks[b].kind === "text" && nd.blocks[b].text) {
541
+ var t = nd.blocks[b].text.replace(/\s+/g, " ").trim();
542
+ if (t) return t.length > 80 ? t.slice(0, 80) + "…" : t;
543
+ }
544
+ }
545
+ }
546
+ }
547
+ } catch (e) {}
548
+ return "";
549
+ }
550
+ // Evaluate one row/face observation. row: {id, running, pending:[{key,kind,payload}], completed, label, face:bool}
551
+ function evaluateRow(row) {
552
+ var prev = prevById[row.id];
553
+ var runningNow = !!row.running;
554
+ var pendingNow = row.pending || [];
555
+ var prevPendingKeys = prev ? prev.pendingKeys : [];
556
+ var wasRunning = prev ? prev.running : false;
557
+ var newKeys = [];
558
+ for (var i = 0; i < pendingNow.length; i++) {
559
+ var pk = pendingNow[i].key;
560
+ if (prevPendingKeys.indexOf(pk) === -1 && !attentionKeysFired[pk]) {
561
+ newKeys.push(pendingNow[i]);
562
+ attentionKeysFired[pk] = true;
563
+ }
564
+ }
565
+ var hasPendingNow = pendingNow.length > 0;
566
+
567
+ // 1) a pending interaction just appeared -> the agent is waiting on the user.
568
+ // First evaluation of a session also lands here (prevPendingKeys empty),
569
+ // so a wait that already existed when the plugin (or page) mounted still
570
+ // alerts once.
571
+ // On hosts that expose uiSession this is the fallback path only: the
572
+ // interaction snapshot there is the authoritative source, and it drives
573
+ // the title marker itself (see the uiSession watcher below).
574
+ if (newKeys.length > 0) {
575
+ var it = newKeys[0];
576
+ var copy = fireAttention(it.kind, sessionLabelText(row), textFromPayload(it.payload), !!row.face, row.id);
577
+ if (copy) queueQuieted(it.key, copy);
578
+ lastAttentionAt = Date.now();
579
+ markAttention(it.key);
580
+ }
581
+
582
+ // 2) a non-selected session finished while we were away (sidebar "done")
583
+ var notified = false;
584
+ if (!row.face && row.completed && prev && !prev.completed && prev.seeded) {
585
+ fireDone(sessionLabelText(row), "", row.id);
586
+ notified = true;
587
+ }
588
+
589
+ // 3) running true -> false on a session we were watching: a reply finished
590
+ if (!notified && prev && prev.seeded && wasRunning && !runningNow && !hasPendingNow) {
591
+ // ignore the edge if it happened in the same tick as an attention alert
592
+ if (Date.now() - lastAttentionAt < 300) {
593
+ /* answered by an attention alert above */
594
+ } else {
595
+ fireDone(sessionLabelText(row), row.face ? snippetOfLastAssistant(row.faceSnap) : "", row.id);
596
+ }
597
+ }
598
+
599
+ // store the next baseline
600
+ prevById[row.id] = {
601
+ running: runningNow,
602
+ pendingKeys: pendingNow.map(function (p) { return p.key; }),
603
+ completed: !!row.completed,
604
+ seeded: true
605
+ };
606
+
607
+ // a wait we alerted about has been resolved -> drop the title marker
608
+ if (!uiPending && prev && prevPendingKeys.length > 0 && pendingNow.length === 0) {
609
+ for (var r = 0; r < prevPendingKeys.length; r++) unmarkAttention(prevPendingKeys[r]);
610
+ }
611
+ }
612
+
613
+ // face -> row adapter (selected session, rich data)
614
+ function evalSessionFace(sessionId, snap) {
615
+ if (sessionId !== currentFaceId) return;
616
+ var pend = (snap.pending || []).map(function (p) {
617
+ return { key: p.key || (p.kind + ":" + sessionId), kind: p.kind, payload: p.payload || null };
618
+ });
619
+ evaluateRow({
620
+ id: sessionId,
621
+ running: !!snap.running,
622
+ pending: pend,
623
+ completed: false,
624
+ face: true,
625
+ faceSnap: snap,
626
+ label: "current"
627
+ });
628
+ }
629
+ // list row -> adapter (every other listed session)
630
+ function evalListRow(sid, sum, currentId) {
631
+ evaluateRow({
632
+ id: sid,
633
+ running: !!sum.running,
634
+ pending: sum.pendingInteraction ? [{ key: "list:" + sid + ":" + sum.pendingInteraction, kind: sum.pendingInteraction, payload: null }] : [],
635
+ completed: !!sum.completed,
636
+ face: false,
637
+ label: sum.displayTitle || sum.title || sid
638
+ });
639
+ }
640
+
641
+ // ─────────────────────── subscription wiring ─────────────────────
642
+ function bindCurrentFace() {
643
+ if (!listStore) return;
644
+ try {
645
+ var cur = currentSessionId();
646
+ if (!cur) { detachFace(); return; }
647
+ if (cur === currentFaceId && faceSub) return;
648
+ detachFace();
649
+ currentFaceId = cur;
650
+ var b = rootCtx.sessions.binding(cur);
651
+ if (!b || !b.session || typeof b.session.subscribe !== "function") {
652
+ // binding not minted yet — the retry timer will re-check
653
+ return;
654
+ }
655
+ faceSub = b.session.subscribe(function () {
656
+ try {
657
+ evalSessionFace(cur, b.session.getSnapshot());
658
+ } catch (e) {}
659
+ });
660
+ // First evaluation seeds the baseline (no false completion edges) while
661
+ // still alerting for interactions that were already pending on bind.
662
+ try {
663
+ evalSessionFace(cur, b.session.getSnapshot());
664
+ } catch (e) {}
665
+ } catch (e) {}
666
+ }
667
+ function detachFace() {
668
+ if (faceSub) { try { faceSub(); } catch (e) {} faceSub = null; }
669
+ currentFaceId = null;
670
+ }
671
+ function onListChanged() {
672
+ if (!listStore) return;
673
+ try {
674
+ var st = listStore.getSnapshot();
675
+ var cur = currentSessionId();
676
+ bindCurrentFace();
677
+ bindUiSession();
678
+ var byId = (st && st.byId) || {};
679
+ for (var sid in byId) {
680
+ if (sid === cur) continue; // current handled by the face watcher
681
+ var sum = byId[sid];
682
+ if (!sum) continue;
683
+ evalListRow(sid, sum, cur);
684
+ }
685
+ // sessions that vanished: keep baseline (they may reappear) - no action.
686
+ } catch (e) {}
687
+ }
688
+
689
+ // ─────────────── uiSession pending-interaction watcher ───────────
690
+ // DSH >= 0.1.2-alpha.2 moved pending interactions out of the Controller
691
+ // snapshots into their own uiSession store, and that store changed shape
692
+ // once, so both readings share one adapter:
693
+ // * 0.1.2-alpha.2 .. 0.1.5-rc.3: `pendingInteractions`, a
694
+ // Map<sessionId, interaction> whose values carry kind + key + sessionId
695
+ // and the domain's own detail fields;
696
+ // * 0.1.6-alpha.2 and later (incl. 0.2.0-rc.2): `sessionStatus`, a
697
+ // Map<sessionId, {running, pendingInteraction, completionUnread}>; the
698
+ // old store no longer exists, so reading it is what made this channel
699
+ // dead on every 0.1.6+/0.2 host.
700
+ // Either store is the only live "the agent is waiting on you" source on its
701
+ // host generation.
702
+ //
703
+ // The service is looked up lazily rather than declared in `inject`:
704
+ // a cordis `inject` entry is a hard gate (the entry never activates while
705
+ // the service is missing, and a never-activated entry makes the whole web
706
+ // boot audit fail), while hosts older than 0.1.2-alpha.2 have no uiSession
707
+ // at all and must keep working through the controller path above.
708
+ //
709
+ // ⚠️ Reading `ctx.uiSession` directly does NOT work here, and failing to
710
+ // notice that is exactly how this watcher stayed dead on every 0.1.2+ host:
711
+ // cordis resolves a service property only for names declared in the plugin's
712
+ // `inject` map and throws `cannot get property "uiSession" without inject`
713
+ // for every other name — sibling loader entries never leak their service
714
+ // into an ancestor store, so the old try/catch around that read silently
715
+ // produced `null` forever. ctx.get() reads the reflector store with no
716
+ // inject requirement, which is the only lazy lookup that actually resolves
717
+ // the service; ctx.reflect.get() and a plain property read stay as
718
+ // fallbacks for contexts that predate that API.
719
+ function lookupService(name) {
720
+ var svc = null;
721
+ try {
722
+ if (rootCtx && typeof rootCtx.get === "function") svc = rootCtx.get(name) || null;
723
+ } catch (e) { svc = null; }
724
+ if (svc) return svc;
725
+ try {
726
+ if (rootCtx && rootCtx.reflect && typeof rootCtx.reflect.get === "function") svc = rootCtx.reflect.get(name) || null;
727
+ } catch (e) { svc = null; }
728
+ if (svc) return svc;
729
+ try { if (rootCtx && name in rootCtx) svc = rootCtx[name] || null; } catch (e) { svc = null; }
730
+ return svc;
731
+ }
732
+ // Is this an observable snapshot store (getSnapshot + subscribe)?
733
+ function isStore(s) {
734
+ return !!s && typeof s.getSnapshot === "function" && typeof s.subscribe === "function";
735
+ }
736
+ // Pick the host's pending-interaction store: sessionStatus wherever it
737
+ // exists (0.1.6+), else the legacy pendingInteractions map (0.1.2-0.1.5).
738
+ function interactionStore(svc) {
739
+ if (svc && isStore(svc.sessionStatus)) return { store: svc.sessionStatus, source: "sessionStatus" };
740
+ if (svc && isStore(svc.pendingInteractions)) return { store: svc.pendingInteractions, source: "pendingInteractions" };
741
+ return null;
742
+ }
743
+ // One pending interaction out of one store value; null when this session
744
+ // currently has none.
745
+ function pendingOf(value, source) {
746
+ if (!value) return null;
747
+ if (source === "sessionStatus") return value.pendingInteraction || null;
748
+ return value;
749
+ }
750
+ // Record the last lookup outcome for debug() and warn once on the only
751
+ // outcome that is a defect rather than a host generation.
752
+ function noteUiBind(note) {
753
+ if (uiBindNote === note) return;
754
+ uiBindNote = note;
755
+ // Two notes are normal and stay silent: "not visible" is either a
756
+ // pre-0.1.2 host or the ordinary boot race (this entry may apply before
757
+ // the uiSession entry activates; the 1.5s retry timer binds it a moment
758
+ // later), and "bound" is success. A service instance we can hold but
759
+ // whose shape we do not recognize is the case worth one warning.
760
+ if (note === "bound" || uiWarned || note === "uiSession not visible") return;
761
+ uiWarned = true;
762
+ try {
763
+ console.warn("[dsh-notify-me] uiSession is reachable but exposes no pending-interaction source (" + note + ") — approval alerts are silent; please report this against DSH with window.__dshNotifyMe.debug()");
764
+ } catch (e) {}
765
+ }
766
+ function bindUiSession() {
767
+ var svc = lookupService("uiSession");
768
+ var found = interactionStore(svc);
769
+ if (!found) {
770
+ noteUiBind(svc ? "uiSession has neither sessionStatus nor pendingInteractions" : "uiSession not visible");
771
+ return;
772
+ }
773
+ var store = found.store;
774
+ if (uiSub && uiStore === store) { noteUiBind("bound"); return; }
775
+ // the service instance (or its store) was swapped (plugin HMR): drop the
776
+ // stale subscription and its marks, then bind the live store
777
+ if (uiSub) detachUiSession();
778
+ uiStore = store;
779
+ uiSource = found.source;
780
+ uiPending = true; // this watcher now owns the title marker
781
+ uiSub = store.subscribe(function () { try { evalUiPending(); } catch (e) {} });
782
+ noteUiBind("bound");
783
+ evalUiPending(); // seed: a wait that predates this plugin still alerts once
784
+ }
785
+ function evalUiPending() {
786
+ var svc = lookupService("uiSession");
787
+ var found = interactionStore(svc);
788
+ if (!found || typeof found.store.getSnapshot !== "function") return;
789
+ var snap = found.store.getSnapshot(); // Map<sessionId, interaction|status>
790
+ if (!snap || typeof snap.forEach !== "function") return;
791
+ var seen = Object.create(null);
792
+ snap.forEach(function (value, sid) {
793
+ var it = pendingOf(value, found.source);
794
+ // A session with no pending interaction is not "seen": leaving it out
795
+ // lets the teardown pass below release any mark its answer replaced.
796
+ if (!it) return;
797
+ seen[sid] = true;
798
+ var key = it.key || (it.kind + ":" + sid);
799
+ var prev = uiBaseline[sid];
800
+ // new interactions alert once; a wait that already existed on bind
801
+ // (no baseline yet for that session) alerts here too.
802
+ if (prev && prev.key === key) return;
803
+ uiBaseline[sid] = { key: key };
804
+ // a wait replaced by a newer one in the same session: the old mark goes
805
+ // with it, otherwise the tab would keep one stale marker per replacement
806
+ if (prev) unmarkAttention(prev.key);
807
+ if (attentionKeysFired[key]) { markAttention(key); return; } // already alerted, still needs its mark
808
+ attentionKeysFired[key] = true;
809
+ var copy = fireAttention(it.kind, labelForSessionId(sid), textFromPayload(it), currentSessionId() === sid, sid);
810
+ if (copy) queueQuieted(key, copy);
811
+ lastAttentionAt = Date.now();
812
+ markAttention(key);
813
+ });
814
+ for (var sid2 in uiBaseline) {
815
+ if (seen[sid2]) continue;
816
+ // interaction answered, cancelled, or its session went away
817
+ unmarkAttention(uiBaseline[sid2].key);
818
+ delete uiBaseline[sid2];
819
+ }
820
+ }
821
+ function detachUiSession() {
822
+ if (uiSub) { try { uiSub(); } catch (e) {} uiSub = null; }
823
+ uiStore = null;
824
+ uiSource = null;
825
+ // drop every mark this watcher holds; hosts without the interaction store
826
+ // go back to the controller marker path
827
+ for (var s in uiBaseline) unmarkAttention(uiBaseline[s].key);
828
+ uiBaseline = Object.create(null);
829
+ uiPending = false;
830
+ }
831
+
832
+ // ──────────────────────── global control API ─────────────────────
833
+ // Expose the control API. Merge instead of replace: a consumer (or a test
834
+ // harness) may have registered onEvent before the plugin applied, and that
835
+ // hook must survive — binding the interaction store can deliver an alert
836
+ // synchronously, in the middle of apply().
837
+ var api = window.__dshNotifyMe;
838
+ if (!api || typeof api !== "object") api = {};
839
+ try {
840
+ Object.defineProperty(api, "config", {
841
+ get: function () { return readConfig(); },
842
+ configurable: true
843
+ });
844
+ } catch (e) {}
845
+ // keep in sync with package.json "version" on every release
846
+ api.version = "1.2.0";
847
+ api.setConfig = function (patch) { return setConfigLocal(patch); };
848
+ api.resetConfig = function () {
849
+ try { localStorage.removeItem(LS_CONFIG); } catch (e) {}
850
+ config = readConfig();
851
+ return config;
852
+ };
853
+ // manual test; run it after clicking anywhere so permission can be granted.
854
+ // Test alerts fire regardless of the page-open visibility rules.
855
+ api.test = function (kind) {
856
+ kind = kind === "attention" ? "attention" : "done";
857
+ unlockOnce();
858
+ testAlert(kind);
859
+ return "sent " + kind + " (lang=" + effectiveLang() + ", permission=" + ("Notification" in window ? Notification.permission : "n/a") + ")";
860
+ };
861
+ // Live diagnostics for the "why did nothing pop" case. The needs-input
862
+ // channel has three possible owners on current hosts (uiSession.sessionStatus
863
+ // on 0.1.6+, uiSession.pendingInteractions on 0.1.2-0.1.5, the controller
864
+ // snapshots before that) and each one failed silently in turn, so both the
865
+ // binding state and which store answered have to be readable from the page.
866
+ api.debug = function () {
867
+ return {
868
+ version: api.version,
869
+ enabled: config.enabled,
870
+ language: effectiveLang(),
871
+ attentionHiddenOnly: config.attentionHiddenOnly,
872
+ currentHiddenOnly: config.currentHiddenOnly,
873
+ doneHiddenOnly: config.doneHiddenOnly,
874
+ toast: config.toast,
875
+ sound: config.sound,
876
+ permission: ("Notification" in window) ? Notification.permission : "n/a",
877
+ visibility: hidden() ? "hidden" : "visible",
878
+ uiSession: uiPending ? "bound" : "unbound",
879
+ uiSessionNote: uiBindNote,
880
+ uiSessionSource: uiSource,
881
+ currentSession: currentSessionId(),
882
+ sessionBaselines: Object.keys(prevById).length,
883
+ pendingBaselines: Object.keys(uiBaseline).length,
884
+ firedKeys: Object.keys(attentionKeysFired),
885
+ markedKeys: Object.keys(attentionKeysMarked),
886
+ quietedKeys: Object.keys(quietedAttention),
887
+ lastAttentionAt: lastAttentionAt ? new Date(lastAttentionAt).toISOString() : null
888
+ };
889
+ };
890
+ window.__dshNotifyMe = api;
891
+
892
+ // ────────────────── Settings page (react only, optional) ─────────
893
+ // Registered when the web profile provides react + slots + locale; the
894
+ // reminder core above never depends on any of it.
895
+ var NS = "dsh-notify-me";
896
+ var UI_zh = {
897
+ nav: "通知提醒",
898
+ blurb: "当模型停下来需要你操作(审批 / 方案待确认 / 提问),或回复在后台完成时,用系统通知、提示音和标签页标题提醒你。",
899
+ enable: "启用提醒",
900
+ enableHint: "总开关:关闭后不再弹系统通知、不播放提示音,也不再改动标签页标题。",
901
+ toast: "系统通知",
902
+ toastHint: "经浏览器弹出系统 Toast(Windows 通知中心);需要已授予通知权限。",
903
+ sound: "提示音",
904
+ soundHint: "WebAudio 合成音,「需要你」与「回复完成」使用不同音型。",
905
+ attentionVisible: "页面打开时也提醒「需要你」",
906
+ attentionVisibleHint: "默认开启:模型在等你审批 / 确认方案 / 回答问题(含提问弹窗)时,即使页面在最前也会提醒。",
907
+ currentQuiet: "当前对话不弹通知",
908
+ currentQuietHint: "默认开启:等待就发生在你正看着的这个对话里时,只留标签页标记,不弹系统通知、不响提示音,免得挡住审批卡片;页面切到后台后照常提醒。",
909
+ doneVisible: "页面打开时也提醒「回复完成」",
910
+ doneVisibleHint: "默认关闭:「完成」只在页面隐藏或切到后台时才提醒,避免打扰。",
911
+ autoFocus: "点击通知回到对应对话",
912
+ autoFocusHint: "默认开启:点系统通知会把 DSH 带到前台,并直接切到这条提醒所属的对话;关掉后点击通知不做任何事。",
913
+ volume: "音量",
914
+ language: "通知语言",
915
+ langAuto: "跟随界面",
916
+ langZh: "简体中文",
917
+ langEn: "English",
918
+ langAutoNote: "(跟随界面)",
919
+ current: "界面当前语言",
920
+ testTitle: "快速测试",
921
+ testHint: "用当前设置各发一条测试提醒(不受上面「页面打开时也提醒」开关限制)。",
922
+ testAttention: "测试「需要你」",
923
+ testDone: "测试「回复完成」",
924
+ reset: "恢复默认设置",
925
+ resetDone: "已恢复默认设置。",
926
+ sent: "已发送测试提醒 · 通知语言:",
927
+ permHint: "通知权限尚未授予:先在页面上点击一次并允许通知,系统通知才能弹出(提示音不受影响)。",
928
+ offHint: "已关闭提醒:先打开「启用提醒」再测试。"
929
+ };
930
+ var UI_en = {
931
+ nav: "Notify me",
932
+ 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.",
933
+ enable: "Enable reminders",
934
+ enableHint: "Master switch: when off, no toasts, no sounds, and the tab title is never modified.",
935
+ toast: "System notifications",
936
+ toastHint: "Native toasts via the browser (Windows notification center); requires notification permission.",
937
+ sound: "Sound",
938
+ soundHint: "WebAudio beeps — \"needs you\" and \"reply finished\" use different patterns.",
939
+ attentionVisible: "Also alert \"needs you\" while the page is open",
940
+ attentionVisibleHint: "On by default: alert even in the foreground when the model waits for approval / plan review / a question.",
941
+ currentQuiet: "Stay quiet for the conversation on screen",
942
+ currentQuietHint: "On by default: a wait inside the conversation you are looking at keeps only the tab marker — no toast or sound landing on the approval card. Backgrounding the page puts the alert back.",
943
+ doneVisible: "Also alert \"reply finished\" while the page is open",
944
+ doneVisibleHint: "Off by default: \"done\" only alerts while the page is hidden or backgrounded, to avoid noise.",
945
+ autoFocus: "Click a toast to open that conversation",
946
+ autoFocusHint: "On by default: clicking a toast brings DSH forward and switches to the conversation the alert came from. When off, clicking a toast does nothing.",
947
+ volume: "Volume",
948
+ language: "Notification language",
949
+ langAuto: "Follow the interface",
950
+ langZh: "简体中文",
951
+ langEn: "English",
952
+ langAutoNote: " (follows the interface)",
953
+ current: "Current interface language",
954
+ testTitle: "Quick test",
955
+ testHint: "Send one test alert with the current settings (not limited by the \"while the page is open\" toggles above).",
956
+ testAttention: "Test \"needs you\"",
957
+ testDone: "Test \"reply finished\"",
958
+ reset: "Restore defaults",
959
+ resetDone: "Defaults restored.",
960
+ sent: "Test alert sent · notification language: ",
961
+ permHint: "Notification permission is not granted yet — click once on the page and allow notifications so toasts can appear (sound is unaffected).",
962
+ offHint: "Reminders are off: enable them first, then test."
963
+ };
964
+ // Small element helper to keep the hand-written page readable.
965
+ function h(tag, props) {
966
+ var args = [tag, props || null];
967
+ for (var i = 2; i < arguments.length; i++) args.push(arguments[i]);
968
+ return React.createElement.apply(React, args);
969
+ }
970
+ // css classes (namespaced to avoid collisions)
971
+ var UI_CSS = "" +
972
+ ".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))}" +
973
+ ".dnm-blurb{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
974
+ ".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}" +
975
+ ".dnm-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 14px;min-height:46px;box-sizing:border-box}" +
976
+ ".dnm-row+.dnm-row{border-top:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35))}" +
977
+ ".dnm-rowText{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}" +
978
+ ".dnm-rowLabel{font-size:13px;font-weight:600;line-height:20px;color:var(--dsw-alias-label-primary,rgba(20,20,20,.9))}" +
979
+ ".dnm-rowHint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
980
+ ".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}" +
981
+ ".dnm-switch.on{background:var(--dsw-alias-state-business-primary,#3b82f6);border-color:transparent}" +
982
+ ".dnm-knob{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;transition:left .15s ease}" +
983
+ ".dnm-switch.on .dnm-knob{left:16px}" +
984
+ ".dnm-range{width:150px;accent-color:var(--dsw-alias-state-business-primary,#3b82f6)}" +
985
+ ".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}" +
986
+ ".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}" +
987
+ ".dnm-btn:hover{background:var(--dsw-alias-bg-layer-1,rgba(128,128,128,.1))}" +
988
+ ".dnm-btn[disabled]{opacity:.45;cursor:default}" +
989
+ ".dnm-btnRow{display:flex;align-items:center;gap:10px;flex-wrap:wrap}" +
990
+ ".dnm-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9))}" +
991
+ ".dnm-feedback{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(120,120,120,.9));min-height:18px}";
992
+ (function injectStyle() {
993
+ try {
994
+ if (typeof document === "undefined" || typeof document.createElement !== "function") return;
995
+ var tagId = "dsh-notify-me/settings";
996
+ if (document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]")) return;
997
+ var s = document.createElement("style");
998
+ s.dataset.plugin = "dsh-notify-me";
999
+ s.dataset.pluginCss = tagId;
1000
+ s.textContent = UI_CSS;
1001
+ (document.head || document.documentElement).appendChild(s);
1002
+ } catch (e) {}
1003
+ })();
1004
+
1005
+ function rowSwitch(key, labelText, hintText, checked, onChange) {
1006
+ return h("div", { className: "dnm-row" }, [
1007
+ h("div", { className: "dnm-rowText" }, [
1008
+ h("div", { className: "dnm-rowLabel" }, labelText),
1009
+ hintText ? h("div", { className: "dnm-rowHint" }, hintText) : null
1010
+ ]),
1011
+ h("button", {
1012
+ type: "button",
1013
+ role: "switch",
1014
+ "aria-checked": checked ? "true" : "false",
1015
+ className: "dnm-switch" + (checked ? " on" : ""),
1016
+ onClick: function () { onChange(!checked); }
1017
+ }, h("span", { className: "dnm-knob" }))
1018
+ ]);
1019
+ }
1020
+ function card(children) {
1021
+ return h("div", { className: "dnm-card" }, children);
1022
+ }
1023
+
1024
+ function NotifySettingsView(props) {
1025
+ var ctx = props.ctx;
1026
+ var t = props.t;
1027
+ // Keep the page copy in sync with the active DSH UI language.
1028
+ React.useSyncExternalStore(function (cb) { return ctx.locale.subscribe(cb); }, function () { return ctx.locale.getSnapshot(); });
1029
+ var cfgState = React.useState(function () { return readConfig(); });
1030
+ var cfg = cfgState[0];
1031
+ var setCfg = cfgState[1];
1032
+ var feedbackState = React.useState("");
1033
+ var feedback = feedbackState[0];
1034
+ var setFeedback = feedbackState[1];
1035
+ function patch(p) {
1036
+ setCfg(setConfigLocal(p));
1037
+ }
1038
+ function runTest(kind) {
1039
+ if (!config.enabled) { setFeedback(t("offHint")); return; }
1040
+ unlockOnce();
1041
+ // Bypasses the visibility rules on purpose: a test must fire while the
1042
+ // page is open, otherwise the button proves nothing.
1043
+ testAlert(kind);
1044
+ var langName = effectiveLang() === "en" ? t("langEn") : t("langZh");
1045
+ setFeedback(t("sent") + langName);
1046
+ }
1047
+ var permOK = ("Notification" in window) ? Notification.permission === "granted" : false;
1048
+ var resolvedLang = effectiveLang();
1049
+ var resolvedLangName = resolvedLang === "en" ? t("langEn") : t("langZh");
1050
+ return h("div", { className: "dnm-wrap" }, [
1051
+ h("p", { className: "dnm-blurb" }, t("blurb")),
1052
+ card([
1053
+ rowSwitch("enabled", t("enable"), t("enableHint"), cfg.enabled, function (v) {
1054
+ patch({ enabled: v });
1055
+ if (!v && attentionActive > 0) { attentionActive = 0; applyTitle(); }
1056
+ })
1057
+ ]),
1058
+ card([
1059
+ rowSwitch("toast", t("toast"), t("toastHint"), cfg.toast, function (v) { patch({ toast: v }); }),
1060
+ rowSwitch("sound", t("sound"), t("soundHint"), cfg.sound, function (v) { patch({ sound: v }); }),
1061
+ rowSwitch("attentionVisible", t("attentionVisible"), t("attentionVisibleHint"), !cfg.attentionHiddenOnly, function (v) { patch({ attentionHiddenOnly: !v }); }),
1062
+ rowSwitch("currentQuiet", t("currentQuiet"), t("currentQuietHint"), cfg.currentHiddenOnly, function (v) { patch({ currentHiddenOnly: v }); }),
1063
+ rowSwitch("doneVisible", t("doneVisible"), t("doneVisibleHint"), !cfg.doneHiddenOnly, function (v) { patch({ doneHiddenOnly: !v }); }),
1064
+ rowSwitch("autoFocus", t("autoFocus"), t("autoFocusHint"), cfg.autoFocus, function (v) { patch({ autoFocus: v }); }),
1065
+ h("div", { className: "dnm-row" }, [
1066
+ h("div", { className: "dnm-rowText" }, [
1067
+ h("div", { className: "dnm-rowLabel" }, t("volume")),
1068
+ h("div", { className: "dnm-rowHint" }, String(Math.round(cfg.volume * 100)) + "%")
1069
+ ]),
1070
+ h("input", {
1071
+ type: "range",
1072
+ className: "dnm-range",
1073
+ min: 0, max: 100, step: 5,
1074
+ value: Math.round(cfg.volume * 100),
1075
+ "aria-label": t("volume"),
1076
+ onChange: function (e) { patch({ volume: Number(e.currentTarget.value) / 100 }); }
1077
+ })
1078
+ ])
1079
+ ]),
1080
+ card([
1081
+ h("div", { className: "dnm-row" }, [
1082
+ h("div", { className: "dnm-rowText" }, [
1083
+ h("div", { className: "dnm-rowLabel" }, t("language")),
1084
+ h("div", { className: "dnm-rowHint" }, resolvedLangName + (cfg.language === "auto" ? t("langAutoNote") : ""))
1085
+ ]),
1086
+ h("select", {
1087
+ className: "dnm-select",
1088
+ value: cfg.language,
1089
+ "aria-label": t("language"),
1090
+ onChange: function (e) { patch({ language: e.currentTarget.value }); }
1091
+ }, [
1092
+ h("option", { value: "auto" }, t("langAuto")),
1093
+ h("option", { value: "zh" }, t("langZh")),
1094
+ h("option", { value: "en" }, t("langEn"))
1095
+ ])
1096
+ ]),
1097
+ h("div", { className: "dnm-row" }, [
1098
+ h("div", { className: "dnm-rowText" }, [
1099
+ h("div", { className: "dnm-rowLabel" }, t("testTitle")),
1100
+ h("div", { className: "dnm-rowHint" }, t("testHint"))
1101
+ ]),
1102
+ h("div", { className: "dnm-btnRow" }, [
1103
+ h("button", { type: "button", className: "dnm-btn", disabled: !cfg.enabled, onClick: function () { runTest("attention"); } }, t("testAttention")),
1104
+ h("button", { type: "button", className: "dnm-btn", disabled: !cfg.enabled, onClick: function () { runTest("done"); } }, t("testDone"))
1105
+ ])
1106
+ ]),
1107
+ h("div", { className: "dnm-row" }, [
1108
+ h("div", { className: "dnm-rowText" }, [
1109
+ h("div", { className: "dnm-rowLabel" }, t("reset")),
1110
+ h("div", { className: "dnm-rowHint" }, " ")
1111
+ ]),
1112
+ h("button", { type: "button", className: "dnm-btn", onClick: function () {
1113
+ try { localStorage.removeItem(LS_CONFIG); } catch (e) {}
1114
+ config = readConfig();
1115
+ applyTitle();
1116
+ setCfg(config);
1117
+ setFeedback(t("resetDone"));
1118
+ } }, t("reset"))
1119
+ ]),
1120
+ !permOK ? h("div", { className: "dnm-row" }, [
1121
+ h("div", { className: "dnm-rowText" }, [h("div", { className: "dnm-rowHint" }, t("permHint"))])
1122
+ ]) : null
1123
+ ]),
1124
+ h("p", { className: "dnm-feedback" }, feedback)
1125
+ ]);
1126
+ }
1127
+
1128
+ // ────────────────────────── plugin body ──────────────────────────
1129
+ // Hard service requirements only. `uiSession` is deliberately NOT listed:
1130
+ // a cordis inject entry is a hard gate (the entry stays pending while the
1131
+ // service is missing, and the web boot audit fails on entries that never
1132
+ // activate), while hosts older than 0.1.2-alpha.2 have no uiSession at all.
1133
+ // It is therefore looked up lazily in bindUiSession() — as are `uiWorkspace`
1134
+ // (toast-click navigation on 0.1.6+) and `sessions.open` (its pre-0.1.6
1135
+ // predecessor) — with the controller path above as the fallback. The bundle
1136
+ // entry "@deepseek-ai/dsh-client-ui-session" stays in package.json's
1137
+ // dsh.client.inject so the load order keeps the bundle in the graph.
1138
+ var inject = ["sessions", "locale", "slots"];
1139
+
1140
+ /**
1141
+ * Client plugin body: watch the sessions service and raise desktop
1142
+ * notifications + sounds when the agent needs input or a reply finishes;
1143
+ * also contributes a Settings page (settings.section slot).
1144
+ * @param ctx - client root context.
1145
+ */
1146
+ function apply(ctx) {
1147
+ rootCtx = ctx;
1148
+ try {
1149
+ if (ctx.locale && typeof ctx.locale.getLocale === "function") localeSvc = ctx.locale;
1150
+ } catch (e) {}
1151
+ // Settings UI — only when the full web profile (react + slots + locale)
1152
+ // is present. The reminder core above never depends on this.
1153
+ try {
1154
+ if (React && React.createElement && ctx.slots && ctx.locale && typeof ctx.locale.register === "function" && typeof ctx.slots.inject === "function") {
1155
+ // The effect returns register()'s own disposer, so a client-plugin
1156
+ // reload (HMR) unregisters the dictionaries before re-registering
1157
+ // them; locale.register throws when a namespace already has a locale.
1158
+ ctx.effect(function () {
1159
+ return ctx.locale.register(NS, { zh: UI_zh, en: UI_en });
1160
+ }, "dsh-notify-me: settings dictionaries");
1161
+ var t = ctx.locale.bind(NS);
1162
+ ctx.slots.inject("settings.section", function () {
1163
+ return ctx.slots.register({
1164
+ name: "settings.section",
1165
+ id: "dsh-notify-me",
1166
+ order: 45,
1167
+ label: function () { return t("nav"); },
1168
+ locale: NS,
1169
+ inject: function () { return { t: t }; }
1170
+ }, function (ownerProps) {
1171
+ return React.createElement(NotifySettingsView, { ctx: ctx, t: t });
1172
+ });
1173
+ });
1174
+ }
1175
+ } catch (e) {
1176
+ try { console.error("[dsh-notify-me] settings page unavailable", e); } catch (e2) {}
1177
+ }
1178
+ ctx.effect(function () {
1179
+ var disposed = false;
1180
+ var unsubs = [];
1181
+ var retryTimer = null;
1182
+ try {
1183
+ var sessionsSvc = ctx.sessions;
1184
+ if (!sessionsSvc || !sessionsSvc.list || typeof sessionsSvc.list.subscribe !== "function") {
1185
+ try { console.log("[dsh-notify-me] sessions service unavailable — watchers idle"); } catch (e) {}
1186
+ return;
1187
+ }
1188
+ listStore = sessionsSvc.list;
1189
+ unsubs.push(sessionsSvc.list.subscribe(onListChanged));
1190
+ // A wait kept quiet because it was already on screen still has to
1191
+ // reach the user the moment they leave the page.
1192
+ var onVisibility = function () { try { flushQuieted(); } catch (e) {} };
1193
+ try { document.addEventListener("visibilitychange", onVisibility); } catch (e) {}
1194
+ unsubs.push(function () { try { document.removeEventListener("visibilitychange", onVisibility); } catch (e) {} });
1195
+ // Interaction source first: it owns the title marker once present, so
1196
+ // binding it before the controller scan keeps a single mark per wait.
1197
+ bindUiSession();
1198
+ // initial scan (baseline only; no alerts for pre-existing sessions)
1199
+ onListChanged();
1200
+ // binding may resolve a tick later; re-check periodically while idle
1201
+ retryTimer = setInterval(function () {
1202
+ if (disposed) return;
1203
+ try { bindCurrentFace(); } catch (e) {}
1204
+ try { bindUiSession(); } catch (e) {}
1205
+ }, 1500);
1206
+ try { console.log("[dsh-notify-me] active — window.__dshNotifyMe 可配置提醒 (enabled=" + config.enabled + ", lang=" + effectiveLang() + ", toast=" + config.toast + ", sound=" + config.sound + ")"); } catch (e) {}
1207
+ } catch (e) {
1208
+ try { console.error("[dsh-notify-me] init failed", e); } catch (e2) {}
1209
+ }
1210
+ return function () {
1211
+ disposed = true;
1212
+ if (retryTimer) clearInterval(retryTimer);
1213
+ for (var i = 0; i < unsubs.length; i++) { try { unsubs[i](); } catch (e) {} }
1214
+ detachFace();
1215
+ detachUiSession();
1216
+ };
1217
+ }, "dsh-notify-me: watchers");
1218
+ }
1219
+
1220
+ exports.inject = inject;
1221
+ exports.apply = apply;
1222
+ return module.exports;
1223
+ }
1224
+ });