dsh-plugin-notify-sounds 1.6.9 → 1.8.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/README.md +22 -5
- package/README.zh.md +22 -5
- package/lib/client.js +473 -104
- package/lib/index.js +383 -71
- package/package.json +3 -4
package/lib/client.js
CHANGED
|
@@ -97,6 +97,18 @@ window.__ModuleLoader__.load({
|
|
|
97
97
|
*/
|
|
98
98
|
const isMac = () => typeof navigator !== "undefined" && /Mac/i.test(`${navigator.platform ?? ""} ${navigator.userAgent ?? ""}`);
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Whether the window-title mark has anywhere to appear.
|
|
102
|
+
*
|
|
103
|
+
* Only the macOS Desktop shell fails this, for the reason above. The
|
|
104
|
+
* row's switch is DISABLED rather than merely re-described: an enabled
|
|
105
|
+
* switch that the user can turn on and that then never does anything is
|
|
106
|
+
* worse than one that says it is not available yet — the old copy read
|
|
107
|
+
* like a working feature with a caveat, so people turned it on and
|
|
108
|
+
* waited.
|
|
109
|
+
*/
|
|
110
|
+
const titleFlashUnavailable = () => isMac() && isDesktopShell();
|
|
111
|
+
|
|
100
112
|
/** Stylesheet class names, one projection the components read. */
|
|
101
113
|
const css = {
|
|
102
114
|
section: "dsh-notify-sounds-section",
|
|
@@ -161,6 +173,8 @@ window.__ModuleLoader__.load({
|
|
|
161
173
|
"question.description": "智能体向你提问时播放",
|
|
162
174
|
"completion.title": "任务完成时",
|
|
163
175
|
"completion.description": "会话由运行转为空闲时播放",
|
|
176
|
+
"goalFinalOnly.title": "目标模式下只在最终轮提醒",
|
|
177
|
+
"goalFinalOnly.description": "目标模式会自动进行多轮;开启后中间轮结束时保持安静,只在目标结束(或暂停、受阻)时响一次。普通对话不受影响",
|
|
164
178
|
"subagent.title": "子智能体的提醒",
|
|
165
179
|
"subagent.description": "子智能体(后台任务)完成或需要决策时也提醒;关闭后只提醒主对话",
|
|
166
180
|
"onlyWhenHidden.title": "仅在页面不可见时提示",
|
|
@@ -178,7 +192,7 @@ window.__ModuleLoader__.load({
|
|
|
178
192
|
"systemNotify.unsupported": "当前环境不支持系统通知",
|
|
179
193
|
"titleFlash.title": "窗口标题提醒",
|
|
180
194
|
"titleFlash.description": "页面处于后台时,在窗口标题前加文字标记(待处理 / 已完成),便于在任务栏察觉;回到页面后自动清除",
|
|
181
|
-
"titleFlash.macUnsupported": "macOS
|
|
195
|
+
"titleFlash.macUnsupported": "macOS 暂不支持,开关已禁用:标题栏隐藏、Dock 也没有窗口标题,标记无处显示(只在「窗口」菜单 / Mission Control 里可见)",
|
|
182
196
|
"titleFlash.pending": "待处理",
|
|
183
197
|
"titleFlash.done": "已完成",
|
|
184
198
|
"renotify.title": "未处理决策重复提醒",
|
|
@@ -219,10 +233,21 @@ window.__ModuleLoader__.load({
|
|
|
219
233
|
"update.available": "有新版",
|
|
220
234
|
"update.failed": "检查失败(网络不可用)",
|
|
221
235
|
"update.apply": "立即更新",
|
|
236
|
+
"update.desktopGuidance": "升级请走包管理器:设置 → 插件 → 卸载本插件,再重新添加 dsh-plugin-notify-sounds。安装由包管理器完成,升级才能留在新版(本插件不再自己替换文件)",
|
|
237
|
+
"update.webGuidance": "在终端执行 dsh plugin --profile {profile} add dsh-plugin-notify-sounds@{version}",
|
|
238
|
+
"update.unregistered": "这个安装没有被包管理器登记(记录仍为 {recorded}),下次安装其他插件时可能被退回。请按上面的方式重新安装一次。",
|
|
222
239
|
"update.updating": "更新中…",
|
|
223
|
-
"update.
|
|
224
|
-
"update.restart": "重启 DSH 后生效",
|
|
240
|
+
"update.doneDetail": "更新完成:{from} → {to},重启 DSH 后生效",
|
|
225
241
|
"update.applyFailed": "更新失败({stage}),已恢复原状",
|
|
242
|
+
"update.stage.check": "检查",
|
|
243
|
+
"update.stage.download": "下载",
|
|
244
|
+
"update.stage.verify": "校验",
|
|
245
|
+
"update.stage.unpack": "解包",
|
|
246
|
+
"update.stage.manifest": "核对包信息",
|
|
247
|
+
"update.stage.replace": "替换文件",
|
|
248
|
+
"update.stage.unknown": "未知阶段",
|
|
249
|
+
"update.retry": "通常是网络或代理的临时问题,稍后重试即可;若反复失败,可到 {url} 手动安装",
|
|
250
|
+
"update.reinstall": "可在 DSH 的 设置 → 插件 里先卸载本插件,再添加 dsh-plugin-notify-sounds 重新安装;仓库:{url}",
|
|
226
251
|
};
|
|
227
252
|
|
|
228
253
|
/** English dictionary, checked complete against the zh key set. */
|
|
@@ -257,7 +282,7 @@ window.__ModuleLoader__.load({
|
|
|
257
282
|
"systemNotify.unsupported": "System notifications are unavailable in this environment",
|
|
258
283
|
"titleFlash.title": "Window title alert",
|
|
259
284
|
"titleFlash.description": "While the page is hidden, mark the window title as waiting for you or finished, so the taskbar shows it; cleared when you return",
|
|
260
|
-
"titleFlash.macUnsupported": "Not supported on macOS: the title bar is hidden and the Dock shows no window title, so the mark has nowhere to appear (only the Window menu and Mission Control would show it)",
|
|
285
|
+
"titleFlash.macUnsupported": "Not supported on macOS, so this switch is disabled: the title bar is hidden and the Dock shows no window title, so the mark has nowhere to appear (only the Window menu and Mission Control would show it)",
|
|
261
286
|
"titleFlash.pending": "Waiting for you",
|
|
262
287
|
"titleFlash.done": "Finished",
|
|
263
288
|
"renotify.title": "Repeat unanswered decisions",
|
|
@@ -266,6 +291,8 @@ window.__ModuleLoader__.load({
|
|
|
266
291
|
"renotify.interval": "Interval",
|
|
267
292
|
"renotify.minutes": "{n} min",
|
|
268
293
|
"quiet.title": "Quiet hours",
|
|
294
|
+
"goalFinalOnly.title": "Only alert on the final goal round",
|
|
295
|
+
"goalFinalOnly.description": "A goal runs several automatic rounds; with this on, intermediate rounds stay silent and one chime marks the end of the goal (or a pause or block). Ordinary sessions are unaffected",
|
|
269
296
|
"quiet.description": "No alerts during this daily time range (may wrap past midnight, e.g. 22:00 to 08:00)",
|
|
270
297
|
"quiet.start": "Start time",
|
|
271
298
|
"quiet.end": "End time",
|
|
@@ -298,10 +325,21 @@ window.__ModuleLoader__.load({
|
|
|
298
325
|
"update.available": "New version available",
|
|
299
326
|
"update.failed": "Check failed (no network)",
|
|
300
327
|
"update.apply": "Update now",
|
|
328
|
+
"update.desktopGuidance": "Update through the package manager: Settings → Plugins → remove this plugin, then add dsh-plugin-notify-sounds again. The installer keeps the upgrade (this plugin no longer replaces its own files)",
|
|
329
|
+
"update.webGuidance": "Run dsh plugin --profile {profile} add dsh-plugin-notify-sounds@{version} in a terminal",
|
|
330
|
+
"update.unregistered": "This install is not registered with the package manager (it still records {recorded}), so a later plugin install may roll it back. Reinstall it with the step above.",
|
|
301
331
|
"update.updating": "Updating…",
|
|
302
|
-
"update.
|
|
303
|
-
"update.restart": "restart DSH to load it",
|
|
332
|
+
"update.doneDetail": "Updated: {from} → {to} — restart DSH to load it",
|
|
304
333
|
"update.applyFailed": "Update failed ({stage}) — the previous version is intact",
|
|
334
|
+
"update.stage.check": "check",
|
|
335
|
+
"update.stage.download": "download",
|
|
336
|
+
"update.stage.verify": "checksum",
|
|
337
|
+
"update.stage.unpack": "unpack",
|
|
338
|
+
"update.stage.manifest": "package check",
|
|
339
|
+
"update.stage.replace": "file replacement",
|
|
340
|
+
"update.stage.unknown": "unknown step",
|
|
341
|
+
"update.retry": "This is usually a temporary network or proxy problem — try again later. If it keeps failing, install manually from {url}",
|
|
342
|
+
"update.reinstall": "Remove this plugin under Settings → Plugins, then add dsh-plugin-notify-sounds again. Repository: {url}",
|
|
305
343
|
};
|
|
306
344
|
//#endregion
|
|
307
345
|
|
|
@@ -443,19 +481,27 @@ window.__ModuleLoader__.load({
|
|
|
443
481
|
* Peak-normalization gain for one decoded buffer: scales the loudest
|
|
444
482
|
* sample toward full scale, boosting at most 4× so near-silent files
|
|
445
483
|
* do not get blown up.
|
|
484
|
+
*
|
|
485
|
+
* Every sample is visited, deliberately. An earlier version stepped
|
|
486
|
+
* through the buffer taking every 8th frame and claimed the peak would
|
|
487
|
+
* be caught anyway because "neighbouring samples carry the same
|
|
488
|
+
* magnitude" — that only holds for a heavily oversampled signal. At
|
|
489
|
+
* 44.1 kHz a 10 kHz component has about 4.4 samples per period, so a
|
|
490
|
+
* stride of 8 aliases against it and can systematically miss the real
|
|
491
|
+
* peak; a tone near fs/8 is the extreme case. An underestimated peak
|
|
492
|
+
* means a boost that is too large, so the custom sound plays hot and
|
|
493
|
+
* can clip on its loudest transient.
|
|
494
|
+
*
|
|
495
|
+
* The cost is not on a playback path: this runs once per uploaded file,
|
|
496
|
+
* right after `decodeAudioData` (which costs far more), and the result
|
|
497
|
+
* is cached alongside the buffer in `customSoundCache`.
|
|
446
498
|
*/
|
|
447
499
|
function normalizedGain(buffer) {
|
|
448
500
|
try {
|
|
449
501
|
let peak = 0;
|
|
450
|
-
// Sample every 8th frame: a peak that would clip is still
|
|
451
|
-
// caught by any reasonably strided scan (audio waveforms are
|
|
452
|
-
// smooth enough that adjacent samples carry the same
|
|
453
|
-
// magnitude), and this keeps a 30-second upload off the main
|
|
454
|
-
// thread's critical path.
|
|
455
|
-
const stride = 8;
|
|
456
502
|
for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
|
|
457
503
|
const data = buffer.getChannelData(channel);
|
|
458
|
-
for (let i = 0; i < data.length; i
|
|
504
|
+
for (let i = 0; i < data.length; i++) {
|
|
459
505
|
const magnitude = Math.abs(data[i]);
|
|
460
506
|
if (magnitude > peak) peak = magnitude;
|
|
461
507
|
}
|
|
@@ -574,11 +620,25 @@ window.__ModuleLoader__.load({
|
|
|
574
620
|
// Release the master gain once the tail has faded. Without
|
|
575
621
|
// this, every play leaves a gain node permanently wired to
|
|
576
622
|
// the destination — a slow audio-node leak on long sessions.
|
|
577
|
-
|
|
623
|
+
const remaining = () => (t0 + tail + 0.1 - engine.currentTime) * 1000;
|
|
624
|
+
const release = (rechecked) => {
|
|
578
625
|
try {
|
|
626
|
+
// The delay was computed when this was scheduled, and a
|
|
627
|
+
// suspended context freezes `currentTime`: without this
|
|
628
|
+
// second look, a timer that comes due during a freeze
|
|
629
|
+
// disconnects a tail that is still playing. Re-check
|
|
630
|
+
// once — not in a loop, so a clock that never advances
|
|
631
|
+
// (a test's frozen mock, a context suspended for good)
|
|
632
|
+
// still ends with the node released.
|
|
633
|
+
if (!rechecked && remaining() > 5) {
|
|
634
|
+
setTimeout(() => release(true), remaining() + 50);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
579
637
|
master.disconnect();
|
|
580
638
|
} catch {}
|
|
581
|
-
}
|
|
639
|
+
};
|
|
640
|
+
const timer = setTimeout(() => release(false), Math.max(0, remaining() + 50));
|
|
641
|
+
timer.unref?.();
|
|
582
642
|
});
|
|
583
643
|
}
|
|
584
644
|
|
|
@@ -814,8 +874,183 @@ window.__ModuleLoader__.load({
|
|
|
814
874
|
/** Route the Host half answers the update check on; must match the Host half's UPDATE_CHECK_PATH. */
|
|
815
875
|
const UPDATE_CHECK_PATH = "/notify-sounds/update/check";
|
|
816
876
|
|
|
817
|
-
|
|
818
|
-
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Where a user goes when the one-click update cannot finish.
|
|
880
|
+
*
|
|
881
|
+
* Shown as plain selectable text rather than a link: the Desktop shell
|
|
882
|
+
* is a `dsh-app://` page and the Settings primitives expose no external
|
|
883
|
+
* link component, so a URL the user can copy is the honest form — a
|
|
884
|
+
* button that silently did nothing here would be worse than no button.
|
|
885
|
+
*/
|
|
886
|
+
const REPOSITORY_URL = "https://github.com/BillCx330/dsh-plugin-notify-sounds";
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Every stage the Host can report, and whether a fresh install is the remedy.
|
|
890
|
+
*
|
|
891
|
+
* Keyed off the stage rather than off the message: telling someone whose
|
|
892
|
+
* download timed out to uninstall the plugin would be actively bad
|
|
893
|
+
* advice, and telling someone whose archive was rejected to "try again
|
|
894
|
+
* later" leaves them with no way forward at all. `true` means the
|
|
895
|
+
* payload itself could not be applied, which retrying will not change.
|
|
896
|
+
*/
|
|
897
|
+
const UPDATE_STAGES = { check: false, download: false, verify: false, unpack: true, manifest: true, replace: true };
|
|
898
|
+
|
|
899
|
+
/** The localized word for a Host-side update stage. */
|
|
900
|
+
const stageWord = (t, stage) => t(`update.stage.${typeof stage === "string" && Object.hasOwn(UPDATE_STAGES, stage) ? stage : "unknown"}`);
|
|
901
|
+
|
|
902
|
+
/** `v1.2.3` for display, `?` when the Host reported no version. */
|
|
903
|
+
const formatVersion = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* The line reporting an installation that landed.
|
|
907
|
+
*
|
|
908
|
+
* Composed here rather than in JSX so the notice above the section and
|
|
909
|
+
* the row inside it say exactly the same thing, and so the whole
|
|
910
|
+
* sentence — punctuation included — lives in the dictionaries instead
|
|
911
|
+
* of being assembled from fragments at the call site (which is how the
|
|
912
|
+
* English UI ended up with full-width stops).
|
|
913
|
+
*/
|
|
914
|
+
const updateAppliedText = (t, applied) => t("update.doneDetail", { from: formatVersion(applied?.from), to: formatVersion(applied?.to) });
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* The two lines a failed update shows: what happened, and what to do.
|
|
918
|
+
*
|
|
919
|
+
* The remedy is chosen from the stage the Host reported, never from the
|
|
920
|
+
* message: a download that timed out must not be answered with "uninstall
|
|
921
|
+
* and reinstall", and an archive that was refused must not be answered
|
|
922
|
+
* with "try again later".
|
|
923
|
+
*/
|
|
924
|
+
const updateFailureText = (t, failed, current) => {
|
|
925
|
+
// Name both versions whenever the target is known: "the update
|
|
926
|
+
// failed" on its own cannot be acted on afterwards, and this is the
|
|
927
|
+
// line a user copies into a bug report.
|
|
928
|
+
const named = typeof failed?.to === "string" ? `${formatVersion(current)} → ${formatVersion(failed.to)} · ` : "";
|
|
929
|
+
return {
|
|
930
|
+
detail: `${named}${t("update.applyFailed", { stage: stageWord(t, failed?.stage) })}`,
|
|
931
|
+
advice: UPDATE_STAGES[failed?.stage] === true
|
|
932
|
+
? t("update.reinstall", { url: REPOSITORY_URL })
|
|
933
|
+
: t("update.retry", { url: REPOSITORY_URL }),
|
|
934
|
+
};
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
/** Channel name and claim key shared by every page of this origin. */
|
|
938
|
+
const ALERT_CHANNEL = "dsh-notify-sounds:alerts";
|
|
939
|
+
const ALERT_LEADER_KEY = "dsh-notify-sounds:leader";
|
|
940
|
+
/** How often the leader re-asserts, and when a quiet claim is considered abandoned. */
|
|
941
|
+
const ALERT_HEARTBEAT_MS = 5000;
|
|
942
|
+
const ALERT_STALE_MS = 15000;
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* `localStorage`, when this page has one that answers.
|
|
946
|
+
*
|
|
947
|
+
* Even reading it can throw where storage is blocked (private mode, a
|
|
948
|
+
* sandboxed frame), and a page without it simply leads itself.
|
|
949
|
+
*
|
|
950
|
+
* @returns the storage, or undefined.
|
|
951
|
+
*/
|
|
952
|
+
const safeAlertStorage = () => {
|
|
953
|
+
try {
|
|
954
|
+
if (typeof localStorage === "undefined") return undefined;
|
|
955
|
+
localStorage.getItem(ALERT_LEADER_KEY);
|
|
956
|
+
return localStorage;
|
|
957
|
+
} catch {
|
|
958
|
+
return undefined;
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Elect one page among the tabs — and windows — of this GUI to be the one
|
|
964
|
+
* that speaks.
|
|
965
|
+
*
|
|
966
|
+
* Two pages are two independent subscribers to the same session facts:
|
|
967
|
+
* left alone, one decision rings the sound twice, raises two system
|
|
968
|
+
* notifications and runs two repeat reminders (the settings even say
|
|
969
|
+
* "at most one quiet check a day" while both of them check). The election
|
|
970
|
+
* is a claim in `localStorage`, which every page of this origin shares,
|
|
971
|
+
* refreshed by a heartbeat; a claim that stops being refreshed is taken
|
|
972
|
+
* over, so a closed, crashed or frozen tab hands the role on within a few
|
|
973
|
+
* seconds. `BroadcastChannel` only makes a claim heard sooner — the
|
|
974
|
+
* correctness rests on the stored claim, because whoever wrote it last
|
|
975
|
+
* owns the role and every page re-reads it rather than trusting a message.
|
|
976
|
+
*
|
|
977
|
+
* The title mark is deliberately NOT gated by this: each window and each
|
|
978
|
+
* tab has its own title, so marking them all is right, while a sound and
|
|
979
|
+
* a toast belong to the machine.
|
|
980
|
+
*
|
|
981
|
+
* Pages that can use neither mechanism — the smoke harness, a hardened
|
|
982
|
+
* browser context — lead themselves, which is the behaviour before this
|
|
983
|
+
* existed.
|
|
984
|
+
*
|
|
985
|
+
* @returns `{ isLeader, refresh, dispose }`, where `refresh` re-runs the
|
|
986
|
+
* election (the tests drive it instead of waiting for a heartbeat).
|
|
987
|
+
*/
|
|
988
|
+
function createAlertLeader() {
|
|
989
|
+
const storage = safeAlertStorage();
|
|
990
|
+
const Channel = typeof BroadcastChannel === "function" ? BroadcastChannel : undefined;
|
|
991
|
+
if (storage === undefined || Channel === undefined) {
|
|
992
|
+
return { isLeader: () => true, refresh: () => {}, dispose: () => {} };
|
|
993
|
+
}
|
|
994
|
+
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
995
|
+
let channel;
|
|
996
|
+
try {
|
|
997
|
+
channel = new Channel(ALERT_CHANNEL);
|
|
998
|
+
} catch {
|
|
999
|
+
return { isLeader: () => true, refresh: () => {}, dispose: () => {} };
|
|
1000
|
+
}
|
|
1001
|
+
let leader = false;
|
|
1002
|
+
const readClaim = () => {
|
|
1003
|
+
try {
|
|
1004
|
+
const parsed = JSON.parse(storage.getItem(ALERT_LEADER_KEY) ?? "null");
|
|
1005
|
+
return parsed !== null && typeof parsed?.id === "string" && Number.isFinite(parsed?.at) ? parsed : undefined;
|
|
1006
|
+
} catch {
|
|
1007
|
+
return undefined;
|
|
1008
|
+
}
|
|
1009
|
+
};
|
|
1010
|
+
const claim = () => {
|
|
1011
|
+
leader = true;
|
|
1012
|
+
try {
|
|
1013
|
+
storage.setItem(ALERT_LEADER_KEY, JSON.stringify({ id, at: Date.now() }));
|
|
1014
|
+
} catch {}
|
|
1015
|
+
try {
|
|
1016
|
+
channel.postMessage({ id });
|
|
1017
|
+
} catch {}
|
|
1018
|
+
};
|
|
1019
|
+
const refresh = () => {
|
|
1020
|
+
const current = readClaim();
|
|
1021
|
+
if (current === undefined || current.id === id) {
|
|
1022
|
+
claim();
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
// Somebody else holds a claim. While it is fresh this page stays
|
|
1026
|
+
// silent; once it goes stale its holder is gone and this page
|
|
1027
|
+
// takes over.
|
|
1028
|
+
if (Date.now() - current.at < ALERT_STALE_MS) {
|
|
1029
|
+
leader = false;
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
claim();
|
|
1033
|
+
};
|
|
1034
|
+
channel.onmessage = (event) => {
|
|
1035
|
+
if (event?.data?.id !== id) refresh();
|
|
1036
|
+
};
|
|
1037
|
+
const timer = setInterval(refresh, ALERT_HEARTBEAT_MS);
|
|
1038
|
+
timer.unref?.();
|
|
1039
|
+
refresh();
|
|
1040
|
+
return {
|
|
1041
|
+
isLeader: () => leader,
|
|
1042
|
+
refresh,
|
|
1043
|
+
dispose: () => {
|
|
1044
|
+
clearInterval(timer);
|
|
1045
|
+
try {
|
|
1046
|
+
if (leader && readClaim()?.id === id) storage.removeItem(ALERT_LEADER_KEY);
|
|
1047
|
+
} catch {}
|
|
1048
|
+
try {
|
|
1049
|
+
channel.close();
|
|
1050
|
+
} catch {}
|
|
1051
|
+
},
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
819
1054
|
|
|
820
1055
|
/**
|
|
821
1056
|
* Show one OS notification; every failure mode is silent.
|
|
@@ -981,8 +1216,7 @@ window.__ModuleLoader__.load({
|
|
|
981
1216
|
.catch(() => {});
|
|
982
1217
|
}, [autoCheck]);
|
|
983
1218
|
if (applied === undefined || typeof applied.to !== "string") return null;
|
|
984
|
-
|
|
985
|
-
return h("div", { className: css.hint, key: "text" }, `${t("update.done")}:${version(applied.from)} → ${version(applied.to)},${t("update.restart")}`);
|
|
1219
|
+
return h("div", { className: css.hint, key: "text" }, updateAppliedText(t, applied));
|
|
986
1220
|
}
|
|
987
1221
|
|
|
988
1222
|
function NotifySoundsSection({ useConfig, setField, preview, t }) {
|
|
@@ -1099,17 +1333,6 @@ window.__ModuleLoader__.load({
|
|
|
1099
1333
|
},
|
|
1100
1334
|
onPreview: (id) => preview(id, volume),
|
|
1101
1335
|
}),
|
|
1102
|
-
h(SwitchRow, {
|
|
1103
|
-
key: "subagent",
|
|
1104
|
-
t,
|
|
1105
|
-
title: t("subagent.title"),
|
|
1106
|
-
description: t("subagent.description"),
|
|
1107
|
-
label: t("subagent.title"),
|
|
1108
|
-
checked: value?.subagentAlerts === true,
|
|
1109
|
-
disabled,
|
|
1110
|
-
failed: failedField === "subagentAlerts",
|
|
1111
|
-
onChange: (next) => write("subagentAlerts", next),
|
|
1112
|
-
}),
|
|
1113
1336
|
h(CustomSoundRow, {
|
|
1114
1337
|
key: "custom",
|
|
1115
1338
|
t,
|
|
@@ -1152,6 +1375,28 @@ window.__ModuleLoader__.load({
|
|
|
1152
1375
|
},
|
|
1153
1376
|
onPreview: () => preview("custom", volume),
|
|
1154
1377
|
}),
|
|
1378
|
+
h(SwitchRow, {
|
|
1379
|
+
key: "subagent",
|
|
1380
|
+
t,
|
|
1381
|
+
title: t("subagent.title"),
|
|
1382
|
+
description: t("subagent.description"),
|
|
1383
|
+
label: t("subagent.title"),
|
|
1384
|
+
checked: value?.subagentAlerts === true,
|
|
1385
|
+
disabled,
|
|
1386
|
+
failed: failedField === "subagentAlerts",
|
|
1387
|
+
onChange: (next) => write("subagentAlerts", next),
|
|
1388
|
+
}),
|
|
1389
|
+
h(SwitchRow, {
|
|
1390
|
+
key: "goalFinalOnly",
|
|
1391
|
+
t,
|
|
1392
|
+
title: t("goalFinalOnly.title"),
|
|
1393
|
+
description: t("goalFinalOnly.description"),
|
|
1394
|
+
label: t("goalFinalOnly.title"),
|
|
1395
|
+
checked: value?.goalFinalOnly === true,
|
|
1396
|
+
disabled,
|
|
1397
|
+
failed: failedField === "goalFinalOnly",
|
|
1398
|
+
onChange: (next) => write("goalFinalOnly", next),
|
|
1399
|
+
}),
|
|
1155
1400
|
h(SystemNotifyRow, {
|
|
1156
1401
|
key: "systemNotify",
|
|
1157
1402
|
t,
|
|
@@ -1170,10 +1415,10 @@ window.__ModuleLoader__.load({
|
|
|
1170
1415
|
// document title like any other, so this is not
|
|
1171
1416
|
// applied there. iOS Safari's UA also contains "like
|
|
1172
1417
|
// Mac OS X" — `isDesktopShell` keeps it out.
|
|
1173
|
-
description:
|
|
1418
|
+
description: titleFlashUnavailable() ? t("titleFlash.macUnsupported") : t("titleFlash.description"),
|
|
1174
1419
|
label: t("titleFlash.title"),
|
|
1175
1420
|
checked: value?.titleFlash === true,
|
|
1176
|
-
disabled,
|
|
1421
|
+
disabled: disabled || titleFlashUnavailable(),
|
|
1177
1422
|
failed: failedField === "titleFlash",
|
|
1178
1423
|
onChange: (next) => write("titleFlash", next),
|
|
1179
1424
|
}),
|
|
@@ -1709,67 +1954,51 @@ window.__ModuleLoader__.load({
|
|
|
1709
1954
|
setState({ phase: "failed" });
|
|
1710
1955
|
}
|
|
1711
1956
|
};
|
|
1712
|
-
const apply = async () => {
|
|
1713
|
-
setState({ phase: "updating" });
|
|
1714
|
-
try {
|
|
1715
|
-
const response = await fetch(UPDATE_APPLY_PATH, { method: "POST" });
|
|
1716
|
-
const result = await response.json();
|
|
1717
|
-
setState({ phase: result?.ok === true ? (result.upToDate === true ? "done" : "updated") : "applyFailed", result });
|
|
1718
|
-
} catch {
|
|
1719
|
-
setState({ phase: "applyFailed" });
|
|
1720
|
-
}
|
|
1721
|
-
};
|
|
1722
1957
|
// A cached answer makes this free; only a cold Host reaches out.
|
|
1723
1958
|
// Same guard as the notice: `=== true` (not `!== false`), because
|
|
1724
1959
|
// during the loading frame `undefined` must not be treated as
|
|
1725
|
-
// permission to reach out.
|
|
1960
|
+
// permission to reach out.
|
|
1726
1961
|
useEffect(() => {
|
|
1727
1962
|
if (autoCheck !== true) return;
|
|
1728
1963
|
check(false)();
|
|
1729
1964
|
}, [autoCheck]);
|
|
1730
1965
|
const payload = state.payload;
|
|
1731
|
-
const
|
|
1732
|
-
// The Host remembers the last install and repeats it with every
|
|
1733
|
-
// answer, and this row prefers that: an update swaps the client
|
|
1734
|
-
// half's own file, so the page may reload and drop this row's
|
|
1735
|
-
// state mid-message — the news has to survive that. The Host keeps
|
|
1736
|
-
// it until DSH restarts, which is exactly as long as "restart to
|
|
1737
|
-
// load it" is true.
|
|
1738
|
-
const applied = payload?.applied ?? (state.phase === "updated" ? state.result : undefined);
|
|
1739
|
-
const appliedDone = applied !== undefined && (applied.ok === true || typeof applied.to === "string");
|
|
1966
|
+
const latest = payload?.latest === undefined ? undefined : formatVersion(payload.latest);
|
|
1740
1967
|
let status = payload === undefined
|
|
1741
1968
|
? t("update.idle")
|
|
1742
1969
|
: payload.updateAvailable === true
|
|
1743
|
-
? `${t("update.available")} ${
|
|
1970
|
+
? `${t("update.available")} ${latest}`
|
|
1744
1971
|
: t("update.uptodate");
|
|
1745
1972
|
if (state.phase === "checking") status = t("update.checking");
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1973
|
+
const detail = payload === undefined ? status : `${t("update.current")} ${formatVersion(payload.current)} · ${status}`;
|
|
1974
|
+
// The install belongs to the package manager now: this plugin only
|
|
1975
|
+
// checks, and every route below installs THROUGH pnpm so the
|
|
1976
|
+
// profile's lockfile moves with the files. The two shells reach it
|
|
1977
|
+
// differently, so the row spells out the one that applies here.
|
|
1978
|
+
const lines = [];
|
|
1979
|
+
if (payload?.unregistered === true) {
|
|
1980
|
+
lines.push(h("div", { className: css.hint, key: "unregistered" }, t("update.unregistered", {
|
|
1981
|
+
recorded: formatVersion(payload.recorded),
|
|
1982
|
+
})));
|
|
1983
|
+
}
|
|
1984
|
+
if (payload?.updateAvailable === true && latest !== undefined) {
|
|
1985
|
+
lines.push(h("div", { className: css.hint, key: "howto" }, isDesktopShell()
|
|
1986
|
+
? t("update.desktopGuidance")
|
|
1987
|
+
: t("update.webGuidance", { profile: payload.profile ?? "web", version: latest })));
|
|
1752
1988
|
}
|
|
1753
|
-
const busy = state.phase === "checking" || state.phase === "updating";
|
|
1754
1989
|
return h("div", { className: css.row }, [
|
|
1755
1990
|
h("div", { key: "text" }, [
|
|
1756
1991
|
h("div", { className: css.rowTitle, key: "title" }, t("update.title")),
|
|
1757
1992
|
h("div", { className: css.rowDescription, key: "description" }, t("update.description")),
|
|
1758
1993
|
h("div", { className: css.hint, key: "status" }, detail),
|
|
1994
|
+
...lines,
|
|
1759
1995
|
]),
|
|
1760
1996
|
h("div", { className: css.stackControl, key: "control" }, [
|
|
1761
|
-
payload?.updateAvailable === true && !appliedDone ? h(Button, {
|
|
1762
|
-
key: "apply",
|
|
1763
|
-
variant: "outline",
|
|
1764
|
-
size: "sm",
|
|
1765
|
-
disabled: busy,
|
|
1766
|
-
onClick: apply,
|
|
1767
|
-
}, t("update.apply")) : null,
|
|
1768
1997
|
h(Button, {
|
|
1769
1998
|
key: "check",
|
|
1770
1999
|
variant: "outline",
|
|
1771
2000
|
size: "sm",
|
|
1772
|
-
disabled:
|
|
2001
|
+
disabled: state.phase === "checking",
|
|
1773
2002
|
onClick: check(true),
|
|
1774
2003
|
}, t("update.check")),
|
|
1775
2004
|
]),
|
|
@@ -1893,6 +2122,12 @@ window.__ModuleLoader__.load({
|
|
|
1893
2122
|
let panelObserver = null;
|
|
1894
2123
|
let observedPanel = null;
|
|
1895
2124
|
const scan = (root) => {
|
|
2125
|
+
// One panel-level probe before the walk. This observer fires on
|
|
2126
|
+
// every change anywhere in the settings tree — a conversation
|
|
2127
|
+
// streaming next to an open panel included — and once this
|
|
2128
|
+
// section's cell carries the glyph there is nothing left to do;
|
|
2129
|
+
// the per-cell checks below would only re-establish that.
|
|
2130
|
+
if (root.querySelector(`[${NAV_ICON_MARKER}]`) !== null) return;
|
|
1896
2131
|
const label = t("nav");
|
|
1897
2132
|
for (const cell of root.querySelectorAll("button")) swapNavIcon(cell, label);
|
|
1898
2133
|
};
|
|
@@ -1961,6 +2196,11 @@ window.__ModuleLoader__.load({
|
|
|
1961
2196
|
*/
|
|
1962
2197
|
function apply(ctx) {
|
|
1963
2198
|
insertStyles();
|
|
2199
|
+
// One page per browser profile speaks (see createAlertLeader): a
|
|
2200
|
+
// sound and a toast belong to the machine, and a second tab of the
|
|
2201
|
+
// same GUI must not raise them a second time.
|
|
2202
|
+
const alertLeader = createAlertLeader();
|
|
2203
|
+
ctx.effect(() => alertLeader.dispose, "notify-sounds: alert leader");
|
|
1964
2204
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "notify-sounds: dictionaries");
|
|
1965
2205
|
const t = ctx.locale.bind(NS);
|
|
1966
2206
|
// The platform's own resolver for package text, which may be a plain
|
|
@@ -1995,7 +2235,8 @@ window.__ModuleLoader__.load({
|
|
|
1995
2235
|
const cooldown = new Map();
|
|
1996
2236
|
|
|
1997
2237
|
/**
|
|
1998
|
-
*
|
|
2238
|
+
* One snapshot of the session list rows, or `undefined` when this
|
|
2239
|
+
* runtime exposes none.
|
|
1999
2240
|
*
|
|
2000
2241
|
* The status facts carry no classification at all — an entry is
|
|
2001
2242
|
* only `{ running, pendingInteraction, completionUnread }` — so
|
|
@@ -2003,24 +2244,20 @@ window.__ModuleLoader__.load({
|
|
|
2003
2244
|
* session list, whose rows carry the same `origin`/`parentId` the
|
|
2004
2245
|
* sidebar uses to keep sub-agents out of the ordinary list.
|
|
2005
2246
|
*
|
|
2247
|
+
* Callers read ONCE per pass and hand the result down: the previous
|
|
2248
|
+
* shape called this per session inside the loops below, so a status
|
|
2249
|
+
* update covering ten sessions took ten snapshots, and the pass
|
|
2250
|
+
* scope was implied by "the same millisecond" rather than stated.
|
|
2251
|
+
*
|
|
2006
2252
|
* @returns the `byId` row map, or undefined when unavailable.
|
|
2007
2253
|
*/
|
|
2008
|
-
|
|
2009
|
-
// called per session in read()/trackPending loops, and each call
|
|
2010
|
-
// would otherwise take a fresh snapshot (O8).
|
|
2011
|
-
let cachedRows;
|
|
2012
|
-
let cachedRowsAt = 0;
|
|
2013
|
-
const sessionRows = () => {
|
|
2014
|
-
const now = Date.now();
|
|
2015
|
-
if (cachedRows !== undefined && now === cachedRowsAt) return cachedRows;
|
|
2254
|
+
const readSessionRows = () => {
|
|
2016
2255
|
try {
|
|
2017
|
-
|
|
2256
|
+
return ctx.sessions?.list?.getSnapshot?.()?.byId
|
|
2018
2257
|
?? ctx.uiSession?.sessions?.list?.getSnapshot?.()?.byId;
|
|
2019
2258
|
} catch {
|
|
2020
|
-
|
|
2259
|
+
return undefined;
|
|
2021
2260
|
}
|
|
2022
|
-
cachedRowsAt = now;
|
|
2023
|
-
return cachedRows;
|
|
2024
2261
|
};
|
|
2025
2262
|
|
|
2026
2263
|
/**
|
|
@@ -2028,10 +2265,11 @@ window.__ModuleLoader__.load({
|
|
|
2028
2265
|
* sub-agent, a teammate — rather than one of the user's own.
|
|
2029
2266
|
*
|
|
2030
2267
|
* @param sessionId - the session to classify.
|
|
2268
|
+
* @param rows - the pass's session rows, when the runtime has them.
|
|
2031
2269
|
* @returns whether it is a spawned child session.
|
|
2032
2270
|
*/
|
|
2033
|
-
const isSubagentSession = (sessionId) => {
|
|
2034
|
-
const row =
|
|
2271
|
+
const isSubagentSession = (sessionId, rows) => {
|
|
2272
|
+
const row = rows?.[sessionId];
|
|
2035
2273
|
// Deliberately ONLY `origin`, the same test the sidebar and the
|
|
2036
2274
|
// sub-agent UI use. `parentId` alone is not enough: a forked
|
|
2037
2275
|
// conversation carries one too and is still the user's own.
|
|
@@ -2048,12 +2286,13 @@ window.__ModuleLoader__.load({
|
|
|
2048
2286
|
* not something they want a chime for.
|
|
2049
2287
|
*
|
|
2050
2288
|
* @param sessionId - the session to test.
|
|
2289
|
+
* @param rows - the pass's session rows, when the runtime has them.
|
|
2051
2290
|
* @returns whether its events should alert.
|
|
2052
2291
|
*/
|
|
2053
|
-
const isNotifiable = (sessionId) => {
|
|
2292
|
+
const isNotifiable = (sessionId, rows) => {
|
|
2054
2293
|
const value = form.getSnapshot().value;
|
|
2055
2294
|
if (value?.subagentAlerts === true) return true;
|
|
2056
|
-
return !isSubagentSession(sessionId);
|
|
2295
|
+
return !isSubagentSession(sessionId, rows);
|
|
2057
2296
|
};
|
|
2058
2297
|
|
|
2059
2298
|
/**
|
|
@@ -2075,6 +2314,10 @@ window.__ModuleLoader__.load({
|
|
|
2075
2314
|
* reminder is not recorded as sent.
|
|
2076
2315
|
*/
|
|
2077
2316
|
const notify = (kind, interaction, sessionId, options = {}) => {
|
|
2317
|
+
// The elected page is the only one that makes a noise or raises
|
|
2318
|
+
// a notification; every page still keeps its own title mark,
|
|
2319
|
+
// because a title belongs to its own window.
|
|
2320
|
+
if (!alertLeader.isLeader()) return false;
|
|
2078
2321
|
const value = form.getSnapshot().value;
|
|
2079
2322
|
if (value?.enabled === false) return false;
|
|
2080
2323
|
if (value?.quietHours === true && inQuietHours(new Date(), value?.quietStart, value?.quietEnd)) return false;
|
|
@@ -2096,8 +2339,17 @@ window.__ModuleLoader__.load({
|
|
|
2096
2339
|
// Evict the oldest entry rather than clearing the whole
|
|
2097
2340
|
// table: one session hitting the cap must not silence
|
|
2098
2341
|
// every other session's cooldown.
|
|
2099
|
-
|
|
2100
|
-
|
|
2342
|
+
//
|
|
2343
|
+
// LRU, not FIFO: `Map.set` on a key that is already there
|
|
2344
|
+
// updates the value and KEEPS the original position, so
|
|
2345
|
+
// plain eviction would throw away whichever entry is
|
|
2346
|
+
// touched most often — precisely the session whose
|
|
2347
|
+
// two-second dedupe is doing work. Deleting first moves it
|
|
2348
|
+
// to the back on re-insertion.
|
|
2349
|
+
const key = `${kind}:${sessionId}`;
|
|
2350
|
+
if (cooldown.has(key)) cooldown.delete(key);
|
|
2351
|
+
else if (cooldown.size >= 96) cooldown.delete(cooldown.keys().next().value);
|
|
2352
|
+
cooldown.set(key, now);
|
|
2101
2353
|
}
|
|
2102
2354
|
const fallback = kind === "completion" ? "ding" : kind === "question" ? "chime" : "bell";
|
|
2103
2355
|
let id = typeof value?.[kind + "Sound"] === "string" ? value[kind + "Sound"] : fallback;
|
|
@@ -2156,12 +2408,96 @@ window.__ModuleLoader__.load({
|
|
|
2156
2408
|
return value?.quietHours === true && inQuietHours(new Date(), value?.quietStart, value?.quietEnd);
|
|
2157
2409
|
};
|
|
2158
2410
|
/** Whether any session is currently running. */
|
|
2159
|
-
const anyRunning = () => {
|
|
2411
|
+
const anyRunning = (rows) => {
|
|
2160
2412
|
for (const [sessionId, status] of source.getSnapshot()) {
|
|
2161
|
-
if (status.running === true && isNotifiable(sessionId)) return true;
|
|
2413
|
+
if (status.running === true && isNotifiable(sessionId, rows)) return true;
|
|
2162
2414
|
}
|
|
2163
2415
|
return false;
|
|
2164
2416
|
};
|
|
2417
|
+
/**
|
|
2418
|
+
* The goal projection's phase for one session, when this runtime
|
|
2419
|
+
* carries the goal packages.
|
|
2420
|
+
*
|
|
2421
|
+
* DSH's goal mode drives several rounds inside one session, and
|
|
2422
|
+
* every round ends with a running→idle edge — so an unguarded
|
|
2423
|
+
* completion chime fires once per round. The phase is the host's
|
|
2424
|
+
* own vocabulary: `active` keeps going on its own, `complete`
|
|
2425
|
+
* finished, and `paused`/`blocked` wait for the user.
|
|
2426
|
+
*
|
|
2427
|
+
* Every step is optional: a runtime without the goal projection
|
|
2428
|
+
* simply answers `undefined`, which reads as "no goal".
|
|
2429
|
+
*/
|
|
2430
|
+
const goalPhaseOf = (sessionId) => {
|
|
2431
|
+
try {
|
|
2432
|
+
const sessions = ctx.sessions ?? ctx.uiSession?.sessions;
|
|
2433
|
+
const store = sessions?.get?.(sessionId)?.projections;
|
|
2434
|
+
const snapshot = store?.get?.("goal") ?? store?.faceOf?.("goal")?.getSnapshot?.();
|
|
2435
|
+
const phase = snapshot?.goal?.phase;
|
|
2436
|
+
return typeof phase === "string" ? phase : undefined;
|
|
2437
|
+
} catch {
|
|
2438
|
+
return undefined;
|
|
2439
|
+
}
|
|
2440
|
+
};
|
|
2441
|
+
/** Whether the completion edge for a session is worth an alert now. */
|
|
2442
|
+
const completionIsFinal = (sessionId, value) => {
|
|
2443
|
+
if (value?.goalFinalOnly !== true) return true;
|
|
2444
|
+
// A session with no goal, or with one that will not continue on
|
|
2445
|
+
// its own (finished, paused, blocked), ends this edge for the
|
|
2446
|
+
// user; one that is still `active` drives another round.
|
|
2447
|
+
return goalPhaseOf(sessionId) !== "active";
|
|
2448
|
+
};
|
|
2449
|
+
/** Longest a goal may stay `active` before its watcher gives up. */
|
|
2450
|
+
const GOAL_SETTLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
2451
|
+
/** Settings whose session already has a watcher waiting for its goal. */
|
|
2452
|
+
const goalWatchers = new Map();
|
|
2453
|
+
/**
|
|
2454
|
+
* Serve the single completion chime a goal earns when it ends.
|
|
2455
|
+
*
|
|
2456
|
+
* The edge that armed this watcher was an intermediate round (the
|
|
2457
|
+
* phase was still `active`). The next round starts at quiescence,
|
|
2458
|
+
* so the projection is re-read on every change: running again means
|
|
2459
|
+
* the edge was intermediate and stays silent, while any non-active
|
|
2460
|
+
* phase means the goal ended and earns the one chime.
|
|
2461
|
+
*/
|
|
2462
|
+
const watchGoalCompletion = (sessionId) => {
|
|
2463
|
+
if (goalWatchers.has(sessionId)) return;
|
|
2464
|
+
let settled = false;
|
|
2465
|
+
let subscriptionOff = () => {};
|
|
2466
|
+
let timer;
|
|
2467
|
+
const settle = (chime) => {
|
|
2468
|
+
if (settled) return;
|
|
2469
|
+
settled = true;
|
|
2470
|
+
goalWatchers.delete(sessionId);
|
|
2471
|
+
try {
|
|
2472
|
+
subscriptionOff();
|
|
2473
|
+
} catch {}
|
|
2474
|
+
clearTimeout(timer);
|
|
2475
|
+
if (!chime) return;
|
|
2476
|
+
if (!pageIsWatched() && !quietNow()) unreadCompletion = true;
|
|
2477
|
+
notify("completion", undefined, sessionId);
|
|
2478
|
+
refreshFlash();
|
|
2479
|
+
};
|
|
2480
|
+
try {
|
|
2481
|
+
const sessions = ctx.sessions ?? ctx.uiSession?.sessions;
|
|
2482
|
+
const face = sessions?.get?.(sessionId)?.projections?.faceOf?.("goal");
|
|
2483
|
+
if (typeof face?.subscribe === "function") {
|
|
2484
|
+
const subscription = face.subscribe(() => {
|
|
2485
|
+
if (source.getSnapshot().get(sessionId)?.running === true) {
|
|
2486
|
+
settle(false);
|
|
2487
|
+
return;
|
|
2488
|
+
}
|
|
2489
|
+
const phase = goalPhaseOf(sessionId);
|
|
2490
|
+
if (phase === undefined || phase !== "active") settle(true);
|
|
2491
|
+
});
|
|
2492
|
+
if (typeof subscription === "function") subscriptionOff = subscription;
|
|
2493
|
+
}
|
|
2494
|
+
} catch {}
|
|
2495
|
+
// A goal that never resolves must not pin this entry forever;
|
|
2496
|
+
// dropping it silently beats a stray chime minutes later.
|
|
2497
|
+
timer = setTimeout(() => settle(false), GOAL_SETTLE_TIMEOUT_MS);
|
|
2498
|
+
timer?.unref?.();
|
|
2499
|
+
goalWatchers.set(sessionId, () => settle(false));
|
|
2500
|
+
};
|
|
2165
2501
|
/**
|
|
2166
2502
|
* Which alert the title should carry, if any.
|
|
2167
2503
|
*
|
|
@@ -2180,7 +2516,7 @@ window.__ModuleLoader__.load({
|
|
|
2180
2516
|
// the quiet window ends.
|
|
2181
2517
|
if (record.quiet !== true) return "pending";
|
|
2182
2518
|
}
|
|
2183
|
-
if (anyRunning()) return undefined;
|
|
2519
|
+
if (anyRunning(readSessionRows())) return undefined;
|
|
2184
2520
|
return unreadCompletion ? "done" : undefined;
|
|
2185
2521
|
};
|
|
2186
2522
|
/** Refresh the title alert from the current facts. */
|
|
@@ -2190,12 +2526,12 @@ window.__ModuleLoader__.load({
|
|
|
2190
2526
|
applyTitleFlash(wantedTitleState(), titleAlerts(t));
|
|
2191
2527
|
};
|
|
2192
2528
|
/** Rebuild the tracker from one snapshot, then refresh the flash. */
|
|
2193
|
-
const trackPending = (snapshot) => {
|
|
2529
|
+
const trackPending = (snapshot, rows) => {
|
|
2194
2530
|
const next = new Map();
|
|
2195
2531
|
for (const [sessionId, status] of snapshot) {
|
|
2196
2532
|
// A filtered session is invisible to the whole package:
|
|
2197
2533
|
// it gets no reminder and no title mark either.
|
|
2198
|
-
if (!isNotifiable(sessionId)) continue;
|
|
2534
|
+
if (!isNotifiable(sessionId, rows)) continue;
|
|
2199
2535
|
const interaction = status.pendingInteraction;
|
|
2200
2536
|
if (interaction === undefined) continue;
|
|
2201
2537
|
const record = pending.get(sessionId);
|
|
@@ -2222,11 +2558,14 @@ window.__ModuleLoader__.load({
|
|
|
2222
2558
|
};
|
|
2223
2559
|
const read = () => {
|
|
2224
2560
|
const snapshot = source.getSnapshot();
|
|
2561
|
+
// One row snapshot for this whole pass, handed to every
|
|
2562
|
+
// check below instead of being taken per session.
|
|
2563
|
+
const rows = readSessionRows();
|
|
2225
2564
|
if (previous !== undefined) {
|
|
2226
2565
|
for (const [sessionId, status] of snapshot) {
|
|
2227
2566
|
// Sub-agent sessions stay out of it unless asked for:
|
|
2228
2567
|
// the user wants the main conversation's alerts.
|
|
2229
|
-
if (!isNotifiable(sessionId)) continue;
|
|
2568
|
+
if (!isNotifiable(sessionId, rows)) continue;
|
|
2230
2569
|
const before = previous.get(sessionId);
|
|
2231
2570
|
const beforePending = before === undefined ? undefined : before.pendingInteraction;
|
|
2232
2571
|
if (status.pendingInteraction !== undefined && status.pendingInteraction !== beforePending) {
|
|
@@ -2241,18 +2580,25 @@ window.__ModuleLoader__.load({
|
|
|
2241
2580
|
notify(status.pendingInteraction.kind === "question" ? "question" : "decision", status.pendingInteraction, sessionId);
|
|
2242
2581
|
}
|
|
2243
2582
|
if (before !== undefined && before.running === true && status.running === false) {
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2583
|
+
const value = form.getSnapshot().value;
|
|
2584
|
+
if (!completionIsFinal(sessionId, value)) {
|
|
2585
|
+
// An intermediate goal round: stay silent and let
|
|
2586
|
+
// the goal's own end deliver the single chime.
|
|
2587
|
+
watchGoalCompletion(sessionId);
|
|
2588
|
+
} else {
|
|
2589
|
+
// A finish that happens while the user is away is
|
|
2590
|
+
// news; one they watch happen is not — and one
|
|
2591
|
+
// that happens during quiet hours is forgotten
|
|
2592
|
+
// rather than kept for later, so nothing about it
|
|
2593
|
+
// shows up when the quiet window ends.
|
|
2594
|
+
if (!pageIsWatched() && !quietNow()) unreadCompletion = true;
|
|
2595
|
+
notify("completion", undefined, sessionId);
|
|
2596
|
+
}
|
|
2251
2597
|
}
|
|
2252
2598
|
}
|
|
2253
2599
|
}
|
|
2254
2600
|
previous = snapshot;
|
|
2255
|
-
trackPending(snapshot);
|
|
2601
|
+
trackPending(snapshot, rows);
|
|
2256
2602
|
};
|
|
2257
2603
|
// Returning to the page clears the alert even while the
|
|
2258
2604
|
// decision stays pending — and marks every pending decision
|
|
@@ -2332,6 +2678,7 @@ window.__ModuleLoader__.load({
|
|
|
2332
2678
|
unsubscribeForm();
|
|
2333
2679
|
unsubscribeLocale();
|
|
2334
2680
|
clearInterval(renotifyTimer);
|
|
2681
|
+
for (const stop of [...goalWatchers.values()]) stop();
|
|
2335
2682
|
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisibility);
|
|
2336
2683
|
applyTitleFlash(undefined, titleAlerts(t));
|
|
2337
2684
|
};
|
|
@@ -2372,9 +2719,17 @@ window.__ModuleLoader__.load({
|
|
|
2372
2719
|
let timer;
|
|
2373
2720
|
const tick = () => {
|
|
2374
2721
|
try {
|
|
2375
|
-
if (form.getSnapshot().value?.updateAutoCheck
|
|
2376
|
-
|
|
2722
|
+
if (form.getSnapshot().value?.updateAutoCheck !== false) {
|
|
2723
|
+
fetch(UPDATE_CHECK_PATH, { method: "GET" }).catch(() => {});
|
|
2724
|
+
}
|
|
2377
2725
|
} catch {}
|
|
2726
|
+
// Rescheduled unconditionally, and that is the fix rather
|
|
2727
|
+
// than a detail: returning early here — which is what the
|
|
2728
|
+
// chain used to do when the preference was off — ended the
|
|
2729
|
+
// self-scheduling for good. Turning the switch off once, or
|
|
2730
|
+
// simply loading the page with it off, meant no check ever
|
|
2731
|
+
// ran again until a full reload, while the row went on
|
|
2732
|
+
// promising "at most one check a day".
|
|
2378
2733
|
timer = setTimeout(tick, 24 * 60 * 60 * 1000);
|
|
2379
2734
|
timer.unref?.();
|
|
2380
2735
|
};
|
|
@@ -2424,7 +2779,21 @@ window.__ModuleLoader__.load({
|
|
|
2424
2779
|
exports.SOUND_IDS = SOUND_IDS;
|
|
2425
2780
|
exports.FOCUS_PATH = FOCUS_PATH;
|
|
2426
2781
|
exports.UPDATE_CHECK_PATH = UPDATE_CHECK_PATH;
|
|
2427
|
-
|
|
2782
|
+
// Which update stages a retry can fix and which need a fresh install:
|
|
2783
|
+
// the Host names a stage in every failure it reports, so this map is the
|
|
2784
|
+
// one place the advice comes from, and the tests cross-check it against
|
|
2785
|
+
// the stage words the dictionaries carry.
|
|
2786
|
+
exports.UPDATE_STAGES = UPDATE_STAGES;
|
|
2787
|
+
// The two composed messages, so the tests can assert the sentences a
|
|
2788
|
+
// user actually reads rather than only the dictionary keys behind them.
|
|
2789
|
+
exports.updateAppliedText = updateAppliedText;
|
|
2790
|
+
exports.updateFailureText = updateFailureText;
|
|
2791
|
+
// The alert election, so the tests can drive two pages against a stubbed
|
|
2792
|
+
// storage and channel instead of hoping two real tabs cooperate.
|
|
2793
|
+
exports.createAlertLeader = createAlertLeader;
|
|
2794
|
+
// The peak scan, so a test can build the one buffer that tells a full
|
|
2795
|
+
// scan apart from a strided one.
|
|
2796
|
+
exports.normalizedGain = normalizedGain;
|
|
2428
2797
|
exports.timeOfDayToMinutes = timeOfDayToMinutes;
|
|
2429
2798
|
return module.exports;
|
|
2430
2799
|
},
|